detbox: label + score pill над bbox (FreeType)

Раньше detbox рисовал только рамку. Теперь на каждое active событие
отображает "label NN%" в полупрозрачной pill цвета рамки над верхним
краем bbox.

include/cuframes_composer/overlay.h:
- cfc_overlay_detbox_config_t: + font_path, font_size, label_bg_alpha
- cfc_overlay_detbox_upsert: + float score parameter

src/overlay.c:
- detbox_entry_t: + score, rendered_text (кеш), text_atlas/w/h/pitch
- detbox_data_t: + ft_library, ft_face, font_path_copy, font_size_px
- cfc_overlay_create_detection_boxes: opens FT face если font_path задан
- cfc_overlay_destroy: free cached atlas'ы + FT face/library
- detbox_rebuild_label_atlas: format "label NN%" → FT render → VRAM upload
  (reuse text_measure / text_render из CFC_OVERLAY_TEXT)
- upsert: вызывает rebuild при изменении label/score (кеш по rendered_text)
- draw_detection_boxes: snap расширен под text_atlas; после border рисует
  pill bg (fill_nv12 цветом рамки) + текст (blit_rgba_nv12, белый)
- mutex hold всю draw — atlas чтение должно быть atomic против upsert

src/frigate_mqtt.c:
- parse after.score → передаём в upsert (Frigate & yoloworld envelope
  совместимы: оба содержат score)

examples/grid_record.c:
- 4 frigate detbox: font_path=/fonts/DejaVuSans-Bold.ttf, font_size=16
- 4 yw detbox: то же — magenta pill с белым текстом

Live: cfc-grid healthy 25 fps, image gx/cuframes-composer:0.11b-step4
deployed. Видно на TV при первом detection event.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 03:08:14 +01:00
parent 7bd8184159
commit 265c5c9503
4 changed files with 193 additions and 11 deletions
+8
View File
@@ -570,6 +570,11 @@ int main(int argc, char **argv)
.stale_ms = 8000,
.required_zones = detcells[i].num_zones ? detcells[i].zone_ptrs : NULL,
.required_zones_count = detcells[i].num_zones,
/* Label+score pill — белым текстом на полупрозрачном зелёном
* фоне (color_y/u/v). Шрифт DejaVu mounted из /fonts (см. compose). */
.font_path = "/fonts/DejaVuSans-Bold.ttf",
.font_size = 16,
.label_bg_alpha = 200,
};
if (cfc_overlay_create_detection_boxes(&dc, &detbox_overlays[i]) != 0) {
fprintf(stderr, "[grid_record] detbox create failed для '%s'\n",
@@ -608,6 +613,9 @@ int main(int argc, char **argv)
.stale_ms = 8000,
.required_zones = detcells[i].num_zones ? detcells[i].zone_ptrs : NULL,
.required_zones_count = detcells[i].num_zones,
.font_path = "/fonts/DejaVuSans-Bold.ttf",
.font_size = 16,
.label_bg_alpha = 200,
};
if (cfc_overlay_create_detection_boxes(&yc, &yw_detbox_overlays[i]) != 0) {
fprintf(stderr, "[grid_record] yw detbox create failed для '%s'\n",
+10
View File
@@ -165,6 +165,13 @@ typedef struct cfc_overlay_detbox_config {
* NULL или пустой массив → принимать все события. */
const char *const *required_zones; /* массив строк */
int required_zones_count;
/* Label + confidence text над bbox.
* NULL font_path → рисовать только рамки (legacy behavior).
* Текст формата "<label> <pct>%" в pill. Color = тот же что у рамки. */
const char *font_path; /* напр. "/fonts/DejaVuSans-Bold.ttf" */
int font_size; /* px, рекомендуется 16-20 */
int label_bg_alpha; /* 0..255 (default 200 если 0) */
} cfc_overlay_detbox_config_t;
int cfc_overlay_create_detection_boxes(
@@ -195,6 +202,8 @@ int cfc_overlay_detbox_match_zones(cfc_overlay_t *ov,
/* Upsert одного active детекта.
* event_id — идентификатор Frigate event'а (для трекинга/end).
* label — "car", "person", и т.п. (для будущего цветового кодирования).
* score — confidence ∈ [0..1] (используется для подписи "label NN%").
* Если < 0 — score не рисуется.
* x1/y1/x2/y2 — bbox в detect-разрешении (raw Frigate coords).
* frame_time_ms — Frigate frame_time для TTL.
* Thread-safe (mutex внутри). */
@@ -202,6 +211,7 @@ int cfc_overlay_detbox_upsert(
cfc_overlay_t *ov,
const char *event_id,
const char *label,
float score,
int x1, int y1, int x2, int y2,
int64_t frame_time_ms
);
+4 -2
View File
@@ -92,13 +92,14 @@ static void parse_event(cfc_frigate_mqtt_t *f, const char *payload)
const char *type = jtype ? json_object_get_string(jtype) : "update";
struct json_object *jcam = NULL, *jid = NULL, *jlabel = NULL, *jbox = NULL,
*jft = NULL, *jzones = NULL;
*jft = NULL, *jzones = NULL, *jscore = NULL;
json_object_object_get_ex(jafter, "camera", &jcam);
json_object_object_get_ex(jafter, "id", &jid);
json_object_object_get_ex(jafter, "label", &jlabel);
json_object_object_get_ex(jafter, "box", &jbox);
json_object_object_get_ex(jafter, "frame_time", &jft);
json_object_object_get_ex(jafter, "current_zones", &jzones);
json_object_object_get_ex(jafter, "score", &jscore);
if (!jcam || !jid) { json_object_put(root); return; }
const char *camera = json_object_get_string(jcam);
@@ -153,6 +154,7 @@ static void parse_event(cfc_frigate_mqtt_t *f, const char *payload)
const char *label = jlabel ? json_object_get_string(jlabel) : "";
double frame_time = jft ? json_object_get_double(jft) : 0.0;
float score = jscore ? (float)json_object_get_double(jscore) : -1.0f;
/* Zone-filter overlay'я (current_zones уже распарсены выше). Отсев
* street-флуда — Frigate 0.17 не даёт native objects.filters.required_zones. */
@@ -161,7 +163,7 @@ static void parse_event(cfc_frigate_mqtt_t *f, const char *payload)
return;
}
cfc_overlay_detbox_upsert(ov, event_id, label, x1, y1, x2, y2,
cfc_overlay_detbox_upsert(ov, event_id, label, score, x1, y1, x2, y2,
(int64_t)(frame_time * 1000));
json_object_put(root);
+171 -9
View File
@@ -35,13 +35,20 @@
typedef struct detbox_entry {
char event_id[48]; /* "" = slot пустой */
char label[16];
float score; /* 0..1; <0 → не рисовать score */
int x1, y1, x2, y2; /* raw detect coords */
int64_t last_update_ms; /* для TTL */
/* Cached label atlas. Rebuild только при изменении label_with_score_txt.
* Atlas — RGBA на VRAM (того же color что и border, но через FT bitmap). */
char rendered_text[32]; /* что сейчас в atlas'е ("car 87%") */
CUdeviceptr text_atlas; /* 0 = нет cached */
int text_w, text_h, text_pitch; /* RGBA buffer size */
} detbox_entry_t;
typedef struct detbox_data {
cfc_overlay_detbox_config_t cfg;
char camera_key[64]; /* копия cfg.camera_key */
char font_path_copy[128]; /* копия cfg.font_path */
pthread_mutex_t mu;
detbox_entry_t entries[CFC_DETBOX_MAX];
int count;
@@ -49,6 +56,11 @@ typedef struct detbox_data {
* указатель caller'а, может быть stack/temp). */
char required_zones[CFC_DETBOX_ZONE_MAX][CFC_DETBOX_ZONE_NAME];
int required_zones_count;
/* FreeType — opaque (FT_Library, FT_Face) — нужен <ft2build.h>.
* Hold как void* чтобы не тянуть FT API в overlay.h. */
void *ft_library; /* FT_Library */
void *ft_face; /* FT_Face — NULL если нет font_path */
int font_size_px;
} detbox_data_t;
typedef struct png_data {
@@ -600,6 +612,59 @@ static int draw_text(cfc_overlay_t *ov, CUstream stream,
/* ── DETECTION_BOXES (Phase 7) ────────────────────────────────────────── */
/* Re-render label atlas в VRAM для entry. Called с mutex'ом удерживаемым
* caller'ом (mu локает entries access). Если label/score не изменились —
* no-op. Возвращает 0 даже если FT нет (атлас просто не создаётся).
*
* Алгоритм: format → measure → render RGBA → upload в CUDA. Использует
* существующие text_measure/text_render (общие с CFC_OVERLAY_TEXT).
*
* Color RGBA пишется белым с background pill цвета overlay (фон даст
* контраст border-цвета, белый текст всегда читаемо). */
static void detbox_rebuild_label_atlas(detbox_data_t *d, int slot)
{
if (!d->ft_face) return;
FT_Face face = (FT_Face)d->ft_face;
detbox_entry_t *e = &d->entries[slot];
/* Формат: "label NN%" (если score >= 0) или просто "label". */
char txt[32];
if (e->score >= 0.0f) {
int pct = (int)(e->score * 100.0f + 0.5f);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
snprintf(txt, sizeof(txt), "%s %d%%", e->label, pct);
} else {
snprintf(txt, sizeof(txt), "%s", e->label);
}
if (!strcmp(txt, e->rendered_text)) return; /* кеш свеж */
/* Measure */
int w = 0, h = 0, ascent = 0;
if (text_measure(face, txt, &w, &h, &ascent) != 0) return;
if (w <= 0 || h <= 0) return;
unsigned char *cpu = calloc((size_t)w * h, 4);
if (!cpu) return;
/* Белый текст для контраста с pill background. */
text_render(face, txt, cpu, w, h, ascent, 255, 255, 255);
/* Free old + allocate new — каждый раз. Размер может меняться. */
if (e->text_atlas) { cuMemFree(e->text_atlas); e->text_atlas = 0; }
if (cuMemAlloc(&e->text_atlas, (size_t)w * h * 4) != CUDA_SUCCESS) {
free(cpu); return;
}
if (cuMemcpyHtoD(e->text_atlas, cpu, (size_t)w * h * 4) != CUDA_SUCCESS) {
cuMemFree(e->text_atlas); e->text_atlas = 0;
free(cpu); return;
}
free(cpu);
e->text_w = w; e->text_h = h; e->text_pitch = w * 4;
strncpy(e->rendered_text, txt, sizeof(e->rendered_text) - 1);
e->rendered_text[sizeof(e->rendered_text) - 1] = '\0';
}
int cfc_overlay_create_detection_boxes(
const cfc_overlay_detbox_config_t *cfg, cfc_overlay_t **out)
{
@@ -637,6 +702,30 @@ int cfc_overlay_create_detection_boxes(
d->cfg.required_zones = NULL;
d->cfg.required_zones_count = d->required_zones_count;
/* FreeType — опционально, для рендеринга label+score над bbox.
* Если font_path не задан → text не рисуется (legacy behavior). */
d->ft_library = NULL;
d->ft_face = NULL;
d->font_size_px = cfg->font_size > 0 ? cfg->font_size : 16;
if (cfg->font_path) {
strncpy(d->font_path_copy, cfg->font_path, sizeof(d->font_path_copy) - 1);
d->cfg.font_path = d->font_path_copy;
FT_Library lib = NULL;
if (FT_Init_FreeType(&lib) == 0) {
FT_Face face = NULL;
if (FT_New_Face(lib, d->font_path_copy, 0, &face) == 0) {
FT_Set_Pixel_Sizes(face, 0, (FT_UInt)d->font_size_px);
d->ft_library = lib;
d->ft_face = face;
} else {
FT_Done_FreeType(lib);
fprintf(stderr, "[overlay] detbox: font %s не открыт\n",
d->font_path_copy);
}
}
}
if (d->cfg.label_bg_alpha <= 0) d->cfg.label_bg_alpha = 200;
*out = ov;
return 0;
}
@@ -681,6 +770,7 @@ int cfc_overlay_detbox_match_zones(cfc_overlay_t *ov,
int cfc_overlay_detbox_upsert(cfc_overlay_t *ov, const char *event_id,
const char *label,
float score,
int x1, int y1, int x2, int y2,
int64_t frame_time_ms)
{
@@ -714,12 +804,19 @@ int cfc_overlay_detbox_upsert(cfc_overlay_t *ov, const char *event_id,
strncpy(d->entries[slot].label, label,
sizeof(d->entries[slot].label) - 1);
}
d->entries[slot].score = score;
d->entries[slot].x1 = x1;
d->entries[slot].y1 = y1;
d->entries[slot].x2 = x2;
d->entries[slot].y2 = y2;
d->entries[slot].last_update_ms = now_ms();
/* Rebuild label atlas (если label/score изменились). FT render + CUDA
* upload под mutex'ом — это slow (~few ms), но upsert редкий (cooldown
* детектора 3s × 4 камеры × 2-3 labels ~ 3-4/sec total). Если text
* не изменился — no-op. */
detbox_rebuild_label_atlas(d, slot);
pthread_mutex_unlock(&d->mu);
return 0;
}
@@ -751,22 +848,45 @@ static int draw_detection_boxes(cfc_overlay_t *ov,
detbox_data_t *d = &ov->u.detbox;
int64_t cutoff = now_ms() - d->cfg.stale_ms;
/* Snapshot active boxes под mutex'ом — короткая критическая секция. */
typedef struct { int x1, y1, x2, y2; } box_t;
/* Snapshot active boxes под mutex'ом — короткая критическая секция.
* Также захватываем text_atlas pointer + size (read-only — упомянуть в
* thread-safety: atlas остаётся валидным пока entry не disposed,
* а entry disposed только в upsert/end/destroy под mutex'ом — и мы тут
* не держим mutex после snapshot. Если в этот промежуток какой-то
* upsert rebuild'ит атлас (cuMemFree + alloc) — мы будем читать
* освобождённую память. Делаем под mutex'ом). */
typedef struct {
int x1, y1, x2, y2;
CUdeviceptr text_atlas;
int text_w, text_h, text_pitch;
} box_t;
box_t snap[CFC_DETBOX_MAX];
int snap_n = 0;
pthread_mutex_lock(&d->mu);
for (int i = 0; i < CFC_DETBOX_MAX; i++) {
if (!d->entries[i].event_id[0]) continue;
if (d->entries[i].last_update_ms < cutoff) continue; /* TTL expired */
snap[snap_n++] = (box_t){
.x1 = d->entries[i].x1, .y1 = d->entries[i].y1,
.x2 = d->entries[i].x2, .y2 = d->entries[i].y2,
};
snap[snap_n].x1 = d->entries[i].x1;
snap[snap_n].y1 = d->entries[i].y1;
snap[snap_n].x2 = d->entries[i].x2;
snap[snap_n].y2 = d->entries[i].y2;
snap[snap_n].text_atlas = d->entries[i].text_atlas;
snap[snap_n].text_w = d->entries[i].text_w;
snap[snap_n].text_h = d->entries[i].text_h;
snap[snap_n].text_pitch = d->entries[i].text_pitch;
snap_n++;
}
pthread_mutex_unlock(&d->mu);
/* Удерживаем mutex до конца draw — atlas чтение в blit_rgba_nv12
* issue'ит CUDA копирование, после launch host может отпустить.
* Но launch синхронный wrt host — поэтому safe отпустить после
* последнего launch. Делаем так: snapshot done → unlock. blit launches
* (после) идут через CUstream — async wrt host, но CUDA hold'ает
* input atlas pointer до completion. Между launch и atlas free есть race.
* Решение: НЕ освобождать atlas в upsert, только аллоцировать новый
* (старый утечёт). Альтернатива — synchronize stream перед free.
* На MVP оставляем mutex до конца — простота важнее. */
if (snap_n == 0) return 0;
if (snap_n == 0) { pthread_mutex_unlock(&d->mu); return 0; }
/* Coordinate mapping: detect → cell. Линейный scale + offset.
* detect_w/h не кэшированы — берутся из cfg в момент draw'а, layout
@@ -813,7 +933,39 @@ static int draw_detection_boxes(cfc_overlay_t *ov,
x + w - tt, y + tt, tt, h - 2 * tt,
d->cfg.color_y, d->cfg.color_u, d->cfg.color_v,
d->cfg.alpha);
/* Label + score pill — над bbox (или внутри, если bbox у верха frame'а). */
if (snap[i].text_atlas && snap[i].text_w > 0 && snap[i].text_h > 0) {
int pad = 4;
int pill_w = snap[i].text_w + 2 * pad;
int pill_h = snap[i].text_h + 2 * pad;
int pill_x = x; /* выравниваем по левому краю bbox */
int pill_y = y - pill_h; /* над верхним краем */
if (pill_y < 0) pill_y = y + tt; /* fallback внутрь top */
pill_x &= ~1; pill_y &= ~1;
int eff_w = pill_w & ~1;
int eff_h = pill_h & ~1;
if (pill_x + eff_w > frame_w) eff_w = (frame_w - pill_x) & ~1;
if (pill_y + eff_h > frame_h) eff_h = (frame_h - pill_y) & ~1;
if (eff_w > 0 && eff_h > 0) {
/* Pill background — цвет border'а (зелёный/magenta) полу-непрозрачный. */
cfc_cugrid_fill_nv12(stream, dst_y, pitch_y, dst_uv, pitch_uv,
pill_x, pill_y, eff_w, eff_h,
d->cfg.color_y, d->cfg.color_u, d->cfg.color_v,
d->cfg.label_bg_alpha);
/* Text — белый RGBA → blend в NV12. */
int text_x = (pill_x + pad) & ~1;
int text_y = (pill_y + pad) & ~1;
cfc_cugrid_blit_rgba_nv12(stream, dst_y, pitch_y, dst_uv, pitch_uv,
text_x, text_y,
snap[i].text_atlas,
snap[i].text_w, snap[i].text_h,
snap[i].text_pitch,
255);
}
}
}
pthread_mutex_unlock(&d->mu);
return 0;
}
@@ -858,7 +1010,17 @@ int cfc_overlay_destroy(cfc_overlay_t *ov)
free(td->font_path_owned);
}
if (ov->type == CFC_OVERLAY_DETECTION_BOXES) {
pthread_mutex_destroy(&ov->u.detbox.mu);
detbox_data_t *d = &ov->u.detbox;
/* Освободить cached text atlas'ы. */
for (int i = 0; i < CFC_DETBOX_MAX; i++) {
if (d->entries[i].text_atlas) {
cuMemFree(d->entries[i].text_atlas);
d->entries[i].text_atlas = 0;
}
}
if (d->ft_face) FT_Done_Face((FT_Face)d->ft_face);
if (d->ft_library) FT_Done_FreeType((FT_Library)d->ft_library);
pthread_mutex_destroy(&d->mu);
}
free(ov);
return 0;