Spaces:
Sleeping
Sleeping
kerojohan commited on
Commit ·
aed2053
1
Parent(s): d709964
Sync Space project with latest bat_tracker upstream
Browse files- .gitignore +0 -5
- README.md +53 -49
- bat_tracker/config.py +1 -0
- bat_tracker/pipeline.py +210 -26
- bat_tracker/render.py +309 -1
- config.out3_clean.yaml +18 -12
- pyproject.toml +1 -1
- tests/test_track_exports.py +198 -0
.gitignore
CHANGED
|
@@ -38,10 +38,5 @@ hf_downloads/
|
|
| 38 |
# Keep project docs/configs tracked even with png ignored
|
| 39 |
!README.md
|
| 40 |
!config*.yaml
|
| 41 |
-
!src_assets/*.png
|
| 42 |
|
| 43 |
deliverables/
|
| 44 |
-
src_assets/*.png
|
| 45 |
-
|
| 46 |
-
deliverables/
|
| 47 |
-
src_assets/*.png
|
|
|
|
| 38 |
# Keep project docs/configs tracked even with png ignored
|
| 39 |
!README.md
|
| 40 |
!config*.yaml
|
|
|
|
| 41 |
|
| 42 |
deliverables/
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -13,8 +13,12 @@ pinned: false
|
|
| 13 |
Proyecto Python para Linux orientado a CPU (con opcion CUDA cuando esta disponible) que procesa videos IR monocromos de cueva y genera:
|
| 14 |
|
| 15 |
- `background.png`: fondo por mediana temporal
|
| 16 |
-
- `valid_region/`: mascara vertical de zona valida por iluminacion
|
| 17 |
- `tracks.csv`: trayectorias 2D por objeto
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
- `tracks_overlay.png`: trayectorias sobre el fondo
|
| 19 |
- `meta.json`: parametros y metricas de ejecucion
|
| 20 |
|
|
@@ -45,13 +49,13 @@ O sin `--config` para usar defaults.
|
|
| 45 |
Tambien puede ejecutarse sin instalar entrypoint:
|
| 46 |
|
| 47 |
```bash
|
| 48 |
-
python -m bat_tracker
|
| 49 |
```
|
| 50 |
|
| 51 |
Generacion standalone de mascara vertical valida:
|
| 52 |
|
| 53 |
```bash
|
| 54 |
-
|
| 55 |
--input /path/out_dir/background.png \
|
| 56 |
--output /path/out_dir/valid_region \
|
| 57 |
--blur-kernel-size 151 \
|
|
@@ -59,17 +63,18 @@ python -m bat_tracker.valid_region \
|
|
| 59 |
--safety-margin 10
|
| 60 |
```
|
| 61 |
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
-
|
| 65 |
|
| 66 |
-
|
| 67 |
-
*Filtra los laterales oscuros combinando profundidad en el centro y perfil de iluminación, reteniendo la zona útil.*
|
| 68 |
-

|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-

|
| 73 |
|
| 74 |
## Entradas
|
| 75 |
|
|
@@ -81,7 +86,6 @@ Ejemplos de configuracion incluidos:
|
|
| 81 |
|
| 82 |
- `config.yaml.example` (base)
|
| 83 |
- `config.out3_clean.yaml` (perfil limpio para escenas tipo out3 con menos ruido)
|
| 84 |
-
- `config.universal.yaml` (perfil general para escenas variadas)
|
| 85 |
|
| 86 |
## Salidas
|
| 87 |
|
|
@@ -90,12 +94,20 @@ Se escriben en la carpeta indicada por `--output`:
|
|
| 90 |
- `background.png`: fondo estimado por mediana temporal.
|
| 91 |
- `valid_region/mask.png`: mascara binaria vertical (255 zona valida, 0 laterales invalidos).
|
| 92 |
- `valid_region/overlay.png`: debug visual de banda valida sobre la imagen.
|
|
|
|
| 93 |
- `valid_region/profile.png`: debug de region valida (perfil horizontal en modo `horizontal_illumination_profile`; mapa de profundidad en modos `central_deep_layer`/`hybrid_deep_layer_profile`).
|
| 94 |
- `tracks.csv`: trayectorias 2D por deteccion y frame.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
- `tracks_overlay.png`: trayectorias dibujadas sobre `background.png`.
|
|
|
|
| 96 |
- `track_clips/` (opcional): clips de video por track (`track_0001_000120-000186.mp4`, etc.).
|
| 97 |
- `meta.json`: metadatos del video, parametros efectivos y metricas de ejecucion.
|
| 98 |
-
- incluye
|
|
|
|
| 99 |
|
| 100 |
## Formato de tracks.csv
|
| 101 |
|
|
@@ -103,6 +115,14 @@ Columnas exactas:
|
|
| 103 |
|
| 104 |
`video_id,track_id,frame,time_sec,x,y,vx,vy,bbox_x1,bbox_y1,bbox_x2,bbox_y2,area`
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
## Pipeline implementado
|
| 107 |
|
| 108 |
1. Lectura del video y metadatos.
|
|
@@ -111,10 +131,12 @@ Columnas exactas:
|
|
| 111 |
4. Umbral binario (fijo u Otsu) + morfologia (open/close) + contornos.
|
| 112 |
5. Filtrado de blobs por area minima/maxima.
|
| 113 |
6. Tracking 2D frame a frame con asignacion greedy por distancia maxima y prediccion por velocidad para reducir cortes.
|
| 114 |
-
7.
|
| 115 |
-
8.
|
| 116 |
-
9.
|
|
|
|
| 117 |
- incluye `postprocess.auto_merges_applied` cuando `tracking.auto_merge_suggested` esta activo.
|
|
|
|
| 118 |
|
| 119 |
## Configuracion
|
| 120 |
|
|
@@ -122,6 +144,9 @@ Usa `config.yaml.example` como base.
|
|
| 122 |
|
| 123 |
- `background.sample_frames`: numero de frames para mediana temporal
|
| 124 |
- `background.uniform_sampling`: muestreo uniforme en todo el video
|
|
|
|
|
|
|
|
|
|
| 125 |
- `detection.*`: parametros de blur, threshold, morfologia y area
|
| 126 |
- `detection.threshold_mode`: `fixed` o `otsu`
|
| 127 |
- `detection.otsu_offset`: ajuste fino sobre umbral Otsu (negativo = mas sensible)
|
|
@@ -143,6 +168,7 @@ Usa `config.yaml.example` como base.
|
|
| 143 |
- `tracking.require_start_or_end_in_valid_region`: conserva solo tracks que empiezan o acaban dentro de la mascara valida
|
| 144 |
- `tracking.valid_region_gate_dilate_px`: dilata la mascara valida en pixeles antes de aplicar el filtro inicio/fin
|
| 145 |
- `tracking.auto_merge_suggested`: fusion automatica postproceso de tracks potencialmente duplicados
|
|
|
|
| 146 |
- `tracking.merge_max_gap_frames` y `tracking.merge_max_endpoint_distance`: merge por handoff cercano (fin->inicio)
|
| 147 |
- `tracking.merge_overlap_min_common_frames`: minimo de frames comunes para evaluar merge por solape
|
| 148 |
- `tracking.merge_overlap_max_mean_distance`: distancia media maxima en frames comunes
|
|
@@ -153,6 +179,8 @@ Usa `config.yaml.example` como base.
|
|
| 153 |
- `valid_region.apply_to_detection`: aplica mascara en deteccion por frame (si no, se usa solo para filtros de track)
|
| 154 |
- `valid_region.hybrid_combine_mode`: `and`/`or` para combinar capa de profundidad + umbral por perfil
|
| 155 |
- `valid_region.input_image`: si se define, usa esta imagen en vez de `background.png`
|
|
|
|
|
|
|
| 156 |
- `valid_region.blur_kernel_size` y `valid_region.profile_smooth_window`: deben ser impares
|
| 157 |
- `valid_region.threshold_ratio`: fraccion del pico del perfil para definir region valida
|
| 158 |
- `valid_region.safety_margin`: recorte adicional en pixeles por lado
|
|
@@ -160,15 +188,20 @@ Usa `config.yaml.example` como base.
|
|
| 160 |
- `valid_region.depth_percentile/depth_morph_kernel/depth_min_area_ratio`: parametros del modo `central_deep_layer`
|
| 161 |
- `valid_region.depth_layer_percentiles` + `valid_region.depth_layer_dilate_px`: expansion no uniforme por capas de profundidad (listas emparejadas)
|
| 162 |
- `valid_region.bottom_contour_*`: refinado opcional del borde inferior ajustandolo al gradiente vertical de profundidad (`*_search_*` define ventana de busqueda, `*_smooth_window` suaviza la curva, `*_gradient_quantile` controla sensibilidad, `*_regularization`/`*_max_step_px` reducen muescas, `*_downward_bias` permite bajar cuando hay empate, `*_regularization_mix` mezcla ajuste local/global, `*_deepest_strong_ratio` favorece el borde fuerte mas profundo frente a crestas intermedias)
|
| 163 |
-
- `output.*`: estilo del overlay
|
| 164 |
-
- `output.
|
| 165 |
-
- `output.
|
| 166 |
-
- `output.
|
|
|
|
|
|
|
|
|
|
| 167 |
- `output.progress_enabled`: muestra trazas de avance global por consola durante todo el pipeline (etapas + frames)
|
| 168 |
- `output.progress_step_percent`: porcentaje global entre trazas (1..100, por defecto `5`)
|
| 169 |
- `output.export_track_clips`: exporta clips por track en una carpeta
|
| 170 |
- `output.track_clips_subdir`: nombre de la carpeta de clips dentro del output
|
| 171 |
- `output.track_clips_padding_frames`: frames extra antes/despues del rango del track
|
|
|
|
|
|
|
| 172 |
- `execution.*`: seleccion de backend de computo
|
| 173 |
- `execution.device`: `auto` (default), `cpu` o `cuda`
|
| 174 |
- `execution.strict_parity`: cuando esta en `true`, compara mascara CPU/GPU y conserva la salida CPU para mantener resultados equivalentes al pipeline original
|
|
@@ -200,35 +233,6 @@ pytest
|
|
| 200 |
|
| 201 |
Los tests cubren deteccion, tracking y export/render de salida.
|
| 202 |
|
| 203 |
-
## Interfaz Web Para Hugging Face Spaces
|
| 204 |
-
|
| 205 |
-
Se ha incluido una app Gradio en `app.py` pensada para un Space de Hugging Face.
|
| 206 |
-
|
| 207 |
-
La interfaz permite:
|
| 208 |
-
|
| 209 |
-
- subir el video de entrada
|
| 210 |
-
- subir opcionalmente un YAML de configuracion
|
| 211 |
-
- visualizar la imagen de region valida detectada
|
| 212 |
-
- visualizar `tracks_overlay.png`
|
| 213 |
-
- consultar `events.csv` como tabla
|
| 214 |
-
- descargar `events.csv` y `tracks.csv`
|
| 215 |
-
|
| 216 |
-
Ejecucion local:
|
| 217 |
-
|
| 218 |
-
```bash
|
| 219 |
-
python3 -m venv .venv
|
| 220 |
-
source .venv/bin/activate
|
| 221 |
-
pip install -r requirements.txt
|
| 222 |
-
python3 app.py
|
| 223 |
-
```
|
| 224 |
-
|
| 225 |
-
Para desplegar en Hugging Face Spaces:
|
| 226 |
-
|
| 227 |
-
1. crea un Space de tipo `Gradio`
|
| 228 |
-
2. sube este repositorio o su contenido
|
| 229 |
-
3. asegúrate de que `app.py` y `requirements.txt` queden en la raiz del Space
|
| 230 |
-
4. Hugging Face instalará dependencias y lanzará la app automáticamente
|
| 231 |
-
|
| 232 |
## Agradecimientos / Referencias
|
| 233 |
|
| 234 |
Parte de los parámetros y perfiles de uso incluidos en este proyecto se han inspirado en el enfoque y resultados de la herramienta **[ThruTracker](https://github.com/AaronJCorcoran/ThruTracker)** desarrollada por Aaron J. Corcoran. Recomendaos consultar su repositorio en GitHub.
|
|
|
|
| 13 |
Proyecto Python para Linux orientado a CPU (con opcion CUDA cuando esta disponible) que procesa videos IR monocromos de cueva y genera:
|
| 14 |
|
| 15 |
- `background.png`: fondo por mediana temporal
|
| 16 |
+
- `valid_region/`: mascara vertical de zona valida estimada por perfil de iluminacion o profundidad
|
| 17 |
- `tracks.csv`: trayectorias 2D por objeto
|
| 18 |
+
- `track_candidates.csv`: evaluacion de todos los tracks candidatos con score y motivos de rechazo
|
| 19 |
+
- `events.csv`: resumen por track con direccion, duracion, desplazamiento y estadisticas
|
| 20 |
+
- `tracks.svg`: artefacto vectorial autocontenido con las trayectorias 2D en coordenadas originales
|
| 21 |
+
- `tracks_render.json`: geometria normalizada por track para consumo externo
|
| 22 |
- `tracks_overlay.png`: trayectorias sobre el fondo
|
| 23 |
- `meta.json`: parametros y metricas de ejecucion
|
| 24 |
|
|
|
|
| 49 |
Tambien puede ejecutarse sin instalar entrypoint:
|
| 50 |
|
| 51 |
```bash
|
| 52 |
+
python -m bat_tracker --input /path/video.mp4 --output /path/out_dir --config /path/config.yaml
|
| 53 |
```
|
| 54 |
|
| 55 |
Generacion standalone de mascara vertical valida:
|
| 56 |
|
| 57 |
```bash
|
| 58 |
+
bat-valid-region \
|
| 59 |
--input /path/out_dir/background.png \
|
| 60 |
--output /path/out_dir/valid_region \
|
| 61 |
--blur-kernel-size 151 \
|
|
|
|
| 63 |
--safety-margin 10
|
| 64 |
```
|
| 65 |
|
| 66 |
+
Alternativamente:
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
python -m bat_tracker.valid_region --input /path/out_dir/background.png --output /path/out_dir/valid_region
|
| 70 |
+
```
|
| 71 |
|
| 72 |
+
## Ejemplo de Resultados
|
| 73 |
|
| 74 |
+
El pipeline genera visualizaciones de depuración y tracking como:
|
|
|
|
|
|
|
| 75 |
|
| 76 |
+
- `valid_region/overlay.png`: máscara de zona válida superpuesta sobre el fondo.
|
| 77 |
+
- `tracks_overlay.png`: trayectorias 2D sobre el fondo calculado.
|
|
|
|
| 78 |
|
| 79 |
## Entradas
|
| 80 |
|
|
|
|
| 86 |
|
| 87 |
- `config.yaml.example` (base)
|
| 88 |
- `config.out3_clean.yaml` (perfil limpio para escenas tipo out3 con menos ruido)
|
|
|
|
| 89 |
|
| 90 |
## Salidas
|
| 91 |
|
|
|
|
| 94 |
- `background.png`: fondo estimado por mediana temporal.
|
| 95 |
- `valid_region/mask.png`: mascara binaria vertical (255 zona valida, 0 laterales invalidos).
|
| 96 |
- `valid_region/overlay.png`: debug visual de banda valida sobre la imagen.
|
| 97 |
+
- `valid_region/gate_overlay.png`: debug visual del gate real usado en tracking tras aplicar `valid_region_gate_dilate_px`.
|
| 98 |
- `valid_region/profile.png`: debug de region valida (perfil horizontal en modo `horizontal_illumination_profile`; mapa de profundidad en modos `central_deep_layer`/`hybrid_deep_layer_profile`).
|
| 99 |
- `tracks.csv`: trayectorias 2D por deteccion y frame.
|
| 100 |
+
- `track_candidates.csv`: auditoria opcional de todos los tracks candidatos tras merge, con `accepted`, `score` y `reject_reasons`.
|
| 101 |
+
- `events.csv`: resumen por track con inicio/fin, duracion, desplazamiento, recorrido, straightness y direccion.
|
| 102 |
+
- `tracks.svg`: export vectorial autocontenido de todas las trayectorias en el sistema de coordenadas original del video, con el mismo color por `track_id` y las mismas etiquetas opcionales que `tracks_overlay.png`.
|
| 103 |
+
- `tracks_render.json`: export JSON con `width`, `height`, puntos por track y metadatos minimos (`track_id`, `frame_start`, `frame_end`, `duration_sec`, `direction`, `point_start`, `point_end`).
|
| 104 |
+
- `direction` usa el vocabulario `entry`, `exit`, `inside`, `outside`, `unknown`.
|
| 105 |
- `tracks_overlay.png`: trayectorias dibujadas sobre `background.png`.
|
| 106 |
+
- `tracks_overlay_raw.png` y `tracks_overlay_smoothed.png` (opcionales): overlays adicionales cuando `output.trajectory_smoothing_enabled` esta activo.
|
| 107 |
- `track_clips/` (opcional): clips de video por track (`track_0001_000120-000186.mp4`, etc.).
|
| 108 |
- `meta.json`: metadatos del video, parametros efectivos y metricas de ejecucion.
|
| 109 |
+
- incluye bloques `video`, `parameters`, `background`, `valid_region`, `metrics`, `execution`, `performance`, `outputs`, `trajectory_smoothing` y `postprocess`.
|
| 110 |
+
- `postprocess` resume tambien cuantos candidatos se aceptaron/rechazaron y las causas mas frecuentes.
|
| 111 |
|
| 112 |
## Formato de tracks.csv
|
| 113 |
|
|
|
|
| 115 |
|
| 116 |
`video_id,track_id,frame,time_sec,x,y,vx,vy,bbox_x1,bbox_y1,bbox_x2,bbox_y2,area`
|
| 117 |
|
| 118 |
+
## Formato de events.csv
|
| 119 |
+
|
| 120 |
+
Columnas exactas:
|
| 121 |
+
|
| 122 |
+
`video_id,track_id,time_start_sec,time_end_sec,duration_sec,frame_start,frame_end,num_detections,x_start,y_start,x_end,y_end,displacement_px,path_length_px,straightness,mean_speed_px_sec,mean_area,start_in_valid_region,end_in_valid_region,direction`
|
| 123 |
+
|
| 124 |
+
`direction` usa el vocabulario `entry`, `exit`, `inside`, `outside`, `unknown`.
|
| 125 |
+
|
| 126 |
## Pipeline implementado
|
| 127 |
|
| 128 |
1. Lectura del video y metadatos.
|
|
|
|
| 131 |
4. Umbral binario (fijo u Otsu) + morfologia (open/close) + contornos.
|
| 132 |
5. Filtrado de blobs por area minima/maxima.
|
| 133 |
6. Tracking 2D frame a frame con asignacion greedy por distancia maxima y prediccion por velocidad para reducir cortes.
|
| 134 |
+
7. Merge automatico opcional de tracks fragmentados antes del filtrado final.
|
| 135 |
+
8. Evaluacion centralizada de tracks candidatos (score + motivos de rechazo) y export de `tracks.csv`, `events.csv`, `tracks.svg`, `tracks_render.json` y render final `tracks_overlay.png`.
|
| 136 |
+
9. Si `valid_region.enabled`, calculo de banda vertical valida desde iluminacion horizontal y guardado en `valid_region/*`.
|
| 137 |
+
10. Export de `meta.json` con parametros, metadatos y metricas.
|
| 138 |
- incluye `postprocess.auto_merges_applied` cuando `tracking.auto_merge_suggested` esta activo.
|
| 139 |
+
- incluye `trajectory_smoothing.enabled/window` y rutas extra de overlay cuando el suavizado esta activado.
|
| 140 |
|
| 141 |
## Configuracion
|
| 142 |
|
|
|
|
| 144 |
|
| 145 |
- `background.sample_frames`: numero de frames para mediana temporal
|
| 146 |
- `background.uniform_sampling`: muestreo uniforme en todo el video
|
| 147 |
+
- `background.input_image`: si se define, reutiliza un fondo precomputado y omite la mediana temporal
|
| 148 |
+
- `background.context_start_sec`: segundo inicial de la ventana usada para estimar `background.png`
|
| 149 |
+
- `background.context_duration_sec`: duracion de esa ventana; `-1` usa el video entero
|
| 150 |
- `detection.*`: parametros de blur, threshold, morfologia y area
|
| 151 |
- `detection.threshold_mode`: `fixed` o `otsu`
|
| 152 |
- `detection.otsu_offset`: ajuste fino sobre umbral Otsu (negativo = mas sensible)
|
|
|
|
| 168 |
- `tracking.require_start_or_end_in_valid_region`: conserva solo tracks que empiezan o acaban dentro de la mascara valida
|
| 169 |
- `tracking.valid_region_gate_dilate_px`: dilata la mascara valida en pixeles antes de aplicar el filtro inicio/fin
|
| 170 |
- `tracking.auto_merge_suggested`: fusion automatica postproceso de tracks potencialmente duplicados
|
| 171 |
+
- `tracking.export_track_candidates`: escribe `track_candidates.csv` con todos los tracks evaluados, incluidos los rechazados
|
| 172 |
- `tracking.merge_max_gap_frames` y `tracking.merge_max_endpoint_distance`: merge por handoff cercano (fin->inicio)
|
| 173 |
- `tracking.merge_overlap_min_common_frames`: minimo de frames comunes para evaluar merge por solape
|
| 174 |
- `tracking.merge_overlap_max_mean_distance`: distancia media maxima en frames comunes
|
|
|
|
| 179 |
- `valid_region.apply_to_detection`: aplica mascara en deteccion por frame (si no, se usa solo para filtros de track)
|
| 180 |
- `valid_region.hybrid_combine_mode`: `and`/`or` para combinar capa de profundidad + umbral por perfil
|
| 181 |
- `valid_region.input_image`: si se define, usa esta imagen en vez de `background.png`
|
| 182 |
+
- `valid_region.input_mask`: si se define, reutiliza exactamente esta mascara y omite su estimacion
|
| 183 |
+
- `valid_region.context_start_sec` y `valid_region.context_duration_sec`: permiten estimar la mascara con una ventana temporal distinta a la del fondo de deteccion
|
| 184 |
- `valid_region.blur_kernel_size` y `valid_region.profile_smooth_window`: deben ser impares
|
| 185 |
- `valid_region.threshold_ratio`: fraccion del pico del perfil para definir region valida
|
| 186 |
- `valid_region.safety_margin`: recorte adicional en pixeles por lado
|
|
|
|
| 188 |
- `valid_region.depth_percentile/depth_morph_kernel/depth_min_area_ratio`: parametros del modo `central_deep_layer`
|
| 189 |
- `valid_region.depth_layer_percentiles` + `valid_region.depth_layer_dilate_px`: expansion no uniforme por capas de profundidad (listas emparejadas)
|
| 190 |
- `valid_region.bottom_contour_*`: refinado opcional del borde inferior ajustandolo al gradiente vertical de profundidad (`*_search_*` define ventana de busqueda, `*_smooth_window` suaviza la curva, `*_gradient_quantile` controla sensibilidad, `*_regularization`/`*_max_step_px` reducen muescas, `*_downward_bias` permite bajar cuando hay empate, `*_regularization_mix` mezcla ajuste local/global, `*_deepest_strong_ratio` favorece el borde fuerte mas profundo frente a crestas intermedias)
|
| 191 |
+
- `output.*`: estilo del overlay y artefactos de salida
|
| 192 |
+
- `output.overlay_line_thickness`: grosor de linea en `tracks_overlay.png` y `tracks.svg`
|
| 193 |
+
- `output.overlay_start_radius`: radio del marcador del primer punto del track
|
| 194 |
+
- `output.overlay_alpha`: alpha del overlay raster `tracks_overlay.png`
|
| 195 |
+
- `output.overlay_draw_track_labels`: dibuja el numero de `track_id` junto al inicio de cada track en `tracks_overlay.png` y `tracks.svg`
|
| 196 |
+
- `output.overlay_draw_track_labels_at_end`: dibuja el numero de `track_id` al final del track en `tracks_overlay.png` y `tracks.svg`
|
| 197 |
+
- `output.overlay_label_font_scale` y `output.overlay_label_thickness`: estilo de etiqueta compartido por `tracks_overlay.png` y `tracks.svg`
|
| 198 |
- `output.progress_enabled`: muestra trazas de avance global por consola durante todo el pipeline (etapas + frames)
|
| 199 |
- `output.progress_step_percent`: porcentaje global entre trazas (1..100, por defecto `5`)
|
| 200 |
- `output.export_track_clips`: exporta clips por track en una carpeta
|
| 201 |
- `output.track_clips_subdir`: nombre de la carpeta de clips dentro del output
|
| 202 |
- `output.track_clips_padding_frames`: frames extra antes/despues del rango del track
|
| 203 |
+
- `output.trajectory_smoothing_enabled`: genera una version suavizada de las trayectorias para overlays y `events.csv`
|
| 204 |
+
- `output.trajectory_smoothing_window`: ventana impar >= 3 usada en el suavizado
|
| 205 |
- `execution.*`: seleccion de backend de computo
|
| 206 |
- `execution.device`: `auto` (default), `cpu` o `cuda`
|
| 207 |
- `execution.strict_parity`: cuando esta en `true`, compara mascara CPU/GPU y conserva la salida CPU para mantener resultados equivalentes al pipeline original
|
|
|
|
| 233 |
|
| 234 |
Los tests cubren deteccion, tracking y export/render de salida.
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
## Agradecimientos / Referencias
|
| 237 |
|
| 238 |
Parte de los parámetros y perfiles de uso incluidos en este proyecto se han inspirado en el enfoque y resultados de la herramienta **[ThruTracker](https://github.com/AaronJCorcoran/ThruTracker)** desarrollada por Aaron J. Corcoran. Recomendaos consultar su repositorio en GitHub.
|
bat_tracker/config.py
CHANGED
|
@@ -55,6 +55,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
|
| 55 |
"merge_overlap_min_common_frames": 3,
|
| 56 |
"merge_overlap_max_mean_distance": 60.0,
|
| 57 |
"merge_overlap_min_direction_cosine": 0.8,
|
|
|
|
| 58 |
},
|
| 59 |
"valid_region": {
|
| 60 |
"enabled": True,
|
|
|
|
| 55 |
"merge_overlap_min_common_frames": 3,
|
| 56 |
"merge_overlap_max_mean_distance": 60.0,
|
| 57 |
"merge_overlap_min_direction_cosine": 0.8,
|
| 58 |
+
"export_track_candidates": False,
|
| 59 |
},
|
| 60 |
"valid_region": {
|
| 61 |
"enabled": True,
|
bat_tracker/pipeline.py
CHANGED
|
@@ -21,7 +21,7 @@ from .config import load_config
|
|
| 21 |
from .detection import build_detection_context
|
| 22 |
from .detection import detect_foreground_blobs
|
| 23 |
from .perf import PerformanceCollector
|
| 24 |
-
from .render import render_tracks_overlay
|
| 25 |
from .track_smoothing import smooth_track_points
|
| 26 |
from .tracker import GreedyTracker, TrackPoint
|
| 27 |
from .valid_region import load_image as load_valid_region_image
|
|
@@ -111,14 +111,56 @@ EVENTS_CSV_COLUMNS = [
|
|
| 111 |
"direction",
|
| 112 |
]
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
def _classify_direction(start_inside: bool, end_inside: bool) -> str:
|
| 116 |
if start_inside and end_inside:
|
| 117 |
return "inside"
|
| 118 |
if start_inside and not end_inside:
|
| 119 |
-
return "
|
| 120 |
if not start_inside and end_inside:
|
| 121 |
-
return "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
return "outside"
|
| 123 |
|
| 124 |
|
|
@@ -148,6 +190,8 @@ def _write_events_csv(
|
|
| 148 |
s_in = _point_in_mask(start, valid_mask)
|
| 149 |
e_in = _point_in_mask(end, valid_mask)
|
| 150 |
direction = _classify_direction(s_in, e_in)
|
|
|
|
|
|
|
| 151 |
else:
|
| 152 |
s_in = None
|
| 153 |
e_in = None
|
|
@@ -192,6 +236,14 @@ def _write_tracks_csv(path: Path, points: List[TrackPoint]) -> None:
|
|
| 192 |
writer.writerow(row)
|
| 193 |
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
def _build_metrics(points: List[TrackPoint], frame_count: int) -> Dict:
|
| 196 |
tracks_counter = Counter(p.track_id for p in points)
|
| 197 |
tracks_lengths = list(tracks_counter.values())
|
|
@@ -367,7 +419,12 @@ def _filter_track_points(
|
|
| 367 |
tracking_cfg: Dict,
|
| 368 |
fps: float,
|
| 369 |
valid_mask: np.ndarray | None = None,
|
| 370 |
-
) -> List[TrackPoint]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
min_track_length_cfg = int(tracking_cfg.get("min_track_length", 1))
|
| 372 |
min_track_duration_sec = float(tracking_cfg.get("min_track_duration_sec", 0.0))
|
| 373 |
min_track_length_from_sec = int(ceil(max(0.0, min_track_duration_sec) * max(1e-6, fps)))
|
|
@@ -377,39 +434,100 @@ def _filter_track_points(
|
|
| 377 |
min_track_straightness = float(tracking_cfg.get("min_track_straightness", 0.0))
|
| 378 |
require_start_or_end_in_valid_region = bool(tracking_cfg.get("require_start_or_end_in_valid_region", False))
|
| 379 |
gate_mask = _build_valid_region_gate_mask(valid_mask, tracking_cfg)
|
|
|
|
| 380 |
|
| 381 |
by_track: Dict[int, List[TrackPoint]] = defaultdict(list)
|
| 382 |
for point in points:
|
| 383 |
by_track[point.track_id].append(point)
|
| 384 |
|
| 385 |
filtered: List[TrackPoint] = []
|
|
|
|
| 386 |
for track_points in by_track.values():
|
| 387 |
track_points = sorted(track_points, key=lambda p: p.frame)
|
| 388 |
-
if len(track_points) < min_track_length:
|
| 389 |
-
continue
|
| 390 |
-
|
| 391 |
start = track_points[0]
|
| 392 |
end = track_points[-1]
|
|
|
|
| 393 |
displacement = hypot(end.x - start.x, end.y - start.y)
|
| 394 |
-
if displacement < min_track_displacement:
|
| 395 |
-
continue
|
| 396 |
-
|
| 397 |
path_length = _path_length(track_points)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
if path_length < min_track_path_length:
|
| 399 |
-
|
| 400 |
-
|
| 401 |
if min_track_straightness > 0.0 and path_length > 0.0:
|
| 402 |
-
straightness = displacement / path_length
|
| 403 |
if straightness < min_track_straightness:
|
| 404 |
-
|
| 405 |
|
|
|
|
|
|
|
|
|
|
| 406 |
if require_start_or_end_in_valid_region and gate_mask is not None:
|
| 407 |
-
|
| 408 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
|
| 410 |
filtered.extend(track_points)
|
| 411 |
|
| 412 |
-
|
|
|
|
| 413 |
|
| 414 |
|
| 415 |
def _vector_cosine(v0: tuple[float, float], v1: tuple[float, float]) -> float | None:
|
|
@@ -564,16 +682,31 @@ def _auto_merge_track_points(points: List[TrackPoint], tracking_cfg: Dict) -> tu
|
|
| 564 |
)
|
| 565 |
|
| 566 |
merged_points = sorted(merged_points, key=lambda p: (p.track_id, p.frame))
|
| 567 |
-
|
| 568 |
-
seen = set()
|
| 569 |
for point in merged_points:
|
| 570 |
-
|
| 571 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
continue
|
| 573 |
-
seen.add(key)
|
| 574 |
-
deduped.append(point)
|
| 575 |
|
| 576 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
|
| 578 |
|
| 579 |
def _export_track_clips(
|
|
@@ -840,14 +973,22 @@ def run_pipeline(
|
|
| 840 |
|
| 841 |
progress.start_stage("postprocess")
|
| 842 |
postprocess_started = perf_counter()
|
| 843 |
-
|
| 844 |
-
filtered_points,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 845 |
perf.record("postprocess_stage", perf_counter() - postprocess_started, executions=1)
|
| 846 |
progress.complete_stage("postprocess", detail="postprocess done")
|
| 847 |
|
| 848 |
progress.start_stage("exports_core")
|
| 849 |
tracks_csv_path = out_dir / "tracks.csv"
|
| 850 |
_write_tracks_csv(tracks_csv_path, filtered_points)
|
|
|
|
|
|
|
|
|
|
| 851 |
|
| 852 |
out_cfg_export = cfg.get("output", {})
|
| 853 |
smoothing_on = bool(out_cfg_export.get("trajectory_smoothing_enabled", False))
|
|
@@ -859,6 +1000,31 @@ def run_pipeline(
|
|
| 859 |
events_csv_path = out_dir / "events.csv"
|
| 860 |
points_for_events = smoothed_points if smoothed_points is not None else filtered_points
|
| 861 |
_write_events_csv(events_csv_path, points_for_events, valid_gate_mask)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
|
| 863 |
overlay_line_t = int(cfg["output"]["overlay_line_thickness"])
|
| 864 |
overlay_start_r = int(cfg["output"]["overlay_start_radius"])
|
|
@@ -976,7 +1142,14 @@ def run_pipeline(
|
|
| 976 |
"background_png": str(background_path.resolve()),
|
| 977 |
"tracks_csv": str(tracks_csv_path.resolve()),
|
| 978 |
"events_csv": str(events_csv_path.resolve()),
|
|
|
|
|
|
|
| 979 |
"tracks_overlay_png": str(overlay_path.resolve()),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 980 |
**overlay_smoothing_paths,
|
| 981 |
"track_clips": track_clip_outputs,
|
| 982 |
**valid_region_outputs,
|
|
@@ -984,6 +1157,17 @@ def run_pipeline(
|
|
| 984 |
"postprocess": {
|
| 985 |
"auto_merge_enabled": bool(cfg["tracking"].get("auto_merge_suggested", False)),
|
| 986 |
"auto_merges_applied": merges_applied,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 987 |
},
|
| 988 |
}
|
| 989 |
|
|
|
|
| 21 |
from .detection import build_detection_context
|
| 22 |
from .detection import detect_foreground_blobs
|
| 23 |
from .perf import PerformanceCollector
|
| 24 |
+
from .render import export_tracks_render_json, export_tracks_svg, render_tracks_overlay
|
| 25 |
from .track_smoothing import smooth_track_points
|
| 26 |
from .tracker import GreedyTracker, TrackPoint
|
| 27 |
from .valid_region import load_image as load_valid_region_image
|
|
|
|
| 111 |
"direction",
|
| 112 |
]
|
| 113 |
|
| 114 |
+
TRACK_CANDIDATES_CSV_COLUMNS = [
|
| 115 |
+
"video_id",
|
| 116 |
+
"track_id",
|
| 117 |
+
"accepted",
|
| 118 |
+
"reject_reasons",
|
| 119 |
+
"score",
|
| 120 |
+
"frame_start",
|
| 121 |
+
"frame_end",
|
| 122 |
+
"num_detections",
|
| 123 |
+
"duration_sec",
|
| 124 |
+
"x_start",
|
| 125 |
+
"y_start",
|
| 126 |
+
"x_end",
|
| 127 |
+
"y_end",
|
| 128 |
+
"displacement_px",
|
| 129 |
+
"path_length_px",
|
| 130 |
+
"straightness",
|
| 131 |
+
"mean_speed_px_sec",
|
| 132 |
+
"mean_area",
|
| 133 |
+
"start_in_valid_region",
|
| 134 |
+
"end_in_valid_region",
|
| 135 |
+
"direction",
|
| 136 |
+
]
|
| 137 |
+
|
| 138 |
|
| 139 |
def _classify_direction(start_inside: bool, end_inside: bool) -> str:
|
| 140 |
if start_inside and end_inside:
|
| 141 |
return "inside"
|
| 142 |
if start_inside and not end_inside:
|
| 143 |
+
return "exit"
|
| 144 |
if not start_inside and end_inside:
|
| 145 |
+
return "entry"
|
| 146 |
+
return "outside"
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _infer_outside_direction_from_motion(
|
| 150 |
+
start: TrackPoint,
|
| 151 |
+
end: TrackPoint,
|
| 152 |
+
frame_shape: tuple[int, int] | None,
|
| 153 |
+
) -> str:
|
| 154 |
+
if frame_shape is None:
|
| 155 |
+
return "outside"
|
| 156 |
+
height, width = frame_shape
|
| 157 |
+
dy = end.y - start.y
|
| 158 |
+
min_vertical_move = max(40.0, 0.15 * float(height))
|
| 159 |
+
top_band = 0.20 * float(height)
|
| 160 |
+
if dy <= -min_vertical_move and end.y <= top_band:
|
| 161 |
+
return "exit"
|
| 162 |
+
if dy >= min_vertical_move and start.y <= top_band:
|
| 163 |
+
return "entry"
|
| 164 |
return "outside"
|
| 165 |
|
| 166 |
|
|
|
|
| 190 |
s_in = _point_in_mask(start, valid_mask)
|
| 191 |
e_in = _point_in_mask(end, valid_mask)
|
| 192 |
direction = _classify_direction(s_in, e_in)
|
| 193 |
+
if direction == "outside":
|
| 194 |
+
direction = _infer_outside_direction_from_motion(start, end, valid_mask.shape[:2])
|
| 195 |
else:
|
| 196 |
s_in = None
|
| 197 |
e_in = None
|
|
|
|
| 236 |
writer.writerow(row)
|
| 237 |
|
| 238 |
|
| 239 |
+
def _write_track_candidates_csv(path: Path, rows: List[dict]) -> None:
|
| 240 |
+
with path.open("w", newline="", encoding="utf-8") as handle:
|
| 241 |
+
writer = csv.DictWriter(handle, fieldnames=TRACK_CANDIDATES_CSV_COLUMNS)
|
| 242 |
+
writer.writeheader()
|
| 243 |
+
for row in rows:
|
| 244 |
+
writer.writerow(row)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
def _build_metrics(points: List[TrackPoint], frame_count: int) -> Dict:
|
| 248 |
tracks_counter = Counter(p.track_id for p in points)
|
| 249 |
tracks_lengths = list(tracks_counter.values())
|
|
|
|
| 419 |
tracking_cfg: Dict,
|
| 420 |
fps: float,
|
| 421 |
valid_mask: np.ndarray | None = None,
|
| 422 |
+
) -> tuple[List[TrackPoint], List[dict]]:
|
| 423 |
+
def _ratio(value: float, threshold: float) -> float:
|
| 424 |
+
if threshold <= 0.0:
|
| 425 |
+
return 1.0
|
| 426 |
+
return min(1.0, max(0.0, value / threshold))
|
| 427 |
+
|
| 428 |
min_track_length_cfg = int(tracking_cfg.get("min_track_length", 1))
|
| 429 |
min_track_duration_sec = float(tracking_cfg.get("min_track_duration_sec", 0.0))
|
| 430 |
min_track_length_from_sec = int(ceil(max(0.0, min_track_duration_sec) * max(1e-6, fps)))
|
|
|
|
| 434 |
min_track_straightness = float(tracking_cfg.get("min_track_straightness", 0.0))
|
| 435 |
require_start_or_end_in_valid_region = bool(tracking_cfg.get("require_start_or_end_in_valid_region", False))
|
| 436 |
gate_mask = _build_valid_region_gate_mask(valid_mask, tracking_cfg)
|
| 437 |
+
strong_short_score_min = 0.9
|
| 438 |
|
| 439 |
by_track: Dict[int, List[TrackPoint]] = defaultdict(list)
|
| 440 |
for point in points:
|
| 441 |
by_track[point.track_id].append(point)
|
| 442 |
|
| 443 |
filtered: List[TrackPoint] = []
|
| 444 |
+
assessments: List[dict] = []
|
| 445 |
for track_points in by_track.values():
|
| 446 |
track_points = sorted(track_points, key=lambda p: p.frame)
|
|
|
|
|
|
|
|
|
|
| 447 |
start = track_points[0]
|
| 448 |
end = track_points[-1]
|
| 449 |
+
duration = end.time_sec - start.time_sec
|
| 450 |
displacement = hypot(end.x - start.x, end.y - start.y)
|
|
|
|
|
|
|
|
|
|
| 451 |
path_length = _path_length(track_points)
|
| 452 |
+
straightness = (displacement / path_length) if path_length > 0 else 0.0
|
| 453 |
+
mean_speed = (path_length / duration) if duration > 0 else 0.0
|
| 454 |
+
avg_area = sum(p.area for p in track_points) / len(track_points)
|
| 455 |
+
score = mean(
|
| 456 |
+
[
|
| 457 |
+
_ratio(float(len(track_points)), float(min_track_length)),
|
| 458 |
+
_ratio(displacement, min_track_displacement),
|
| 459 |
+
_ratio(path_length, min_track_path_length),
|
| 460 |
+
_ratio(straightness, min_track_straightness) if min_track_straightness > 0.0 else 1.0,
|
| 461 |
+
]
|
| 462 |
+
)
|
| 463 |
+
reject_reasons: list[str] = []
|
| 464 |
+
if len(track_points) < min_track_length:
|
| 465 |
+
reject_reasons.append("min_track_length")
|
| 466 |
+
if displacement < min_track_displacement:
|
| 467 |
+
reject_reasons.append("min_track_displacement")
|
| 468 |
if path_length < min_track_path_length:
|
| 469 |
+
reject_reasons.append("min_track_path_length")
|
|
|
|
| 470 |
if min_track_straightness > 0.0 and path_length > 0.0:
|
|
|
|
| 471 |
if straightness < min_track_straightness:
|
| 472 |
+
reject_reasons.append("min_track_straightness")
|
| 473 |
|
| 474 |
+
s_in = None
|
| 475 |
+
e_in = None
|
| 476 |
+
direction = "unknown"
|
| 477 |
if require_start_or_end_in_valid_region and gate_mask is not None:
|
| 478 |
+
s_in = _point_in_mask(start, gate_mask)
|
| 479 |
+
e_in = _point_in_mask(end, gate_mask)
|
| 480 |
+
direction = _classify_direction(s_in, e_in)
|
| 481 |
+
if not (s_in or e_in):
|
| 482 |
+
reject_reasons.append("valid_region_gate")
|
| 483 |
+
elif valid_mask is not None:
|
| 484 |
+
s_in = _point_in_mask(start, valid_mask)
|
| 485 |
+
e_in = _point_in_mask(end, valid_mask)
|
| 486 |
+
direction = _classify_direction(s_in, e_in)
|
| 487 |
+
if direction == "outside":
|
| 488 |
+
direction = _infer_outside_direction_from_motion(start, end, valid_mask.shape[:2])
|
| 489 |
+
|
| 490 |
+
accepted = not reject_reasons
|
| 491 |
+
if not accepted:
|
| 492 |
+
reasons_set = set(reject_reasons)
|
| 493 |
+
if (
|
| 494 |
+
reasons_set.issubset({"min_track_length", "valid_region_gate"})
|
| 495 |
+
and len(track_points) >= min_track_length_from_sec
|
| 496 |
+
and score >= strong_short_score_min
|
| 497 |
+
):
|
| 498 |
+
accepted = True
|
| 499 |
+
reject_reasons = []
|
| 500 |
+
assessments.append({
|
| 501 |
+
"video_id": start.video_id,
|
| 502 |
+
"track_id": start.track_id,
|
| 503 |
+
"accepted": accepted,
|
| 504 |
+
"reject_reasons": ";".join(reject_reasons),
|
| 505 |
+
"score": round(score, 4),
|
| 506 |
+
"frame_start": start.frame,
|
| 507 |
+
"frame_end": end.frame,
|
| 508 |
+
"num_detections": len(track_points),
|
| 509 |
+
"duration_sec": round(duration, 4),
|
| 510 |
+
"x_start": round(start.x, 2),
|
| 511 |
+
"y_start": round(start.y, 2),
|
| 512 |
+
"x_end": round(end.x, 2),
|
| 513 |
+
"y_end": round(end.y, 2),
|
| 514 |
+
"displacement_px": round(displacement, 2),
|
| 515 |
+
"path_length_px": round(path_length, 2),
|
| 516 |
+
"straightness": round(straightness, 4),
|
| 517 |
+
"mean_speed_px_sec": round(mean_speed, 2),
|
| 518 |
+
"mean_area": round(avg_area, 2),
|
| 519 |
+
"start_in_valid_region": s_in if s_in is not None else "",
|
| 520 |
+
"end_in_valid_region": e_in if e_in is not None else "",
|
| 521 |
+
"direction": direction,
|
| 522 |
+
})
|
| 523 |
+
|
| 524 |
+
if not accepted:
|
| 525 |
+
continue
|
| 526 |
|
| 527 |
filtered.extend(track_points)
|
| 528 |
|
| 529 |
+
assessments.sort(key=lambda row: int(row["track_id"]))
|
| 530 |
+
return filtered, assessments
|
| 531 |
|
| 532 |
|
| 533 |
def _vector_cosine(v0: tuple[float, float], v1: tuple[float, float]) -> float | None:
|
|
|
|
| 682 |
)
|
| 683 |
|
| 684 |
merged_points = sorted(merged_points, key=lambda p: (p.track_id, p.frame))
|
| 685 |
+
by_track_frame: Dict[tuple[int, int], List[TrackPoint]] = defaultdict(list)
|
|
|
|
| 686 |
for point in merged_points:
|
| 687 |
+
by_track_frame[(point.track_id, point.frame)].append(point)
|
| 688 |
+
|
| 689 |
+
consolidated: List[TrackPoint] = []
|
| 690 |
+
for key in sorted(by_track_frame.keys()):
|
| 691 |
+
candidates = by_track_frame[key]
|
| 692 |
+
if len(candidates) == 1:
|
| 693 |
+
consolidated.append(candidates[0])
|
| 694 |
continue
|
|
|
|
|
|
|
| 695 |
|
| 696 |
+
# Keep one point per merged track/frame. Prefer the strongest blob and
|
| 697 |
+
# break ties deterministically to stabilize overlays and exports.
|
| 698 |
+
best = max(
|
| 699 |
+
candidates,
|
| 700 |
+
key=lambda p: (
|
| 701 |
+
p.area,
|
| 702 |
+
-abs(p.vx) - abs(p.vy),
|
| 703 |
+
-p.x,
|
| 704 |
+
-p.y,
|
| 705 |
+
),
|
| 706 |
+
)
|
| 707 |
+
consolidated.append(best)
|
| 708 |
+
|
| 709 |
+
return consolidated, merges_applied
|
| 710 |
|
| 711 |
|
| 712 |
def _export_track_clips(
|
|
|
|
| 973 |
|
| 974 |
progress.start_stage("postprocess")
|
| 975 |
postprocess_started = perf_counter()
|
| 976 |
+
merged_points, merges_applied = _auto_merge_track_points(all_points, cfg["tracking"])
|
| 977 |
+
filtered_points, track_assessments = _filter_track_points(
|
| 978 |
+
merged_points,
|
| 979 |
+
cfg["tracking"],
|
| 980 |
+
meta.fps,
|
| 981 |
+
valid_mask=valid_mask,
|
| 982 |
+
)
|
| 983 |
perf.record("postprocess_stage", perf_counter() - postprocess_started, executions=1)
|
| 984 |
progress.complete_stage("postprocess", detail="postprocess done")
|
| 985 |
|
| 986 |
progress.start_stage("exports_core")
|
| 987 |
tracks_csv_path = out_dir / "tracks.csv"
|
| 988 |
_write_tracks_csv(tracks_csv_path, filtered_points)
|
| 989 |
+
track_candidates_csv_path = out_dir / "track_candidates.csv"
|
| 990 |
+
if bool(cfg["tracking"].get("export_track_candidates", False)):
|
| 991 |
+
_write_track_candidates_csv(track_candidates_csv_path, track_assessments)
|
| 992 |
|
| 993 |
out_cfg_export = cfg.get("output", {})
|
| 994 |
smoothing_on = bool(out_cfg_export.get("trajectory_smoothing_enabled", False))
|
|
|
|
| 1000 |
events_csv_path = out_dir / "events.csv"
|
| 1001 |
points_for_events = smoothed_points if smoothed_points is not None else filtered_points
|
| 1002 |
_write_events_csv(events_csv_path, points_for_events, valid_gate_mask)
|
| 1003 |
+
tracks_svg_path = out_dir / "tracks.svg"
|
| 1004 |
+
export_tracks_svg(
|
| 1005 |
+
tracks_svg_path,
|
| 1006 |
+
width=meta.width,
|
| 1007 |
+
height=meta.height,
|
| 1008 |
+
points=filtered_points,
|
| 1009 |
+
line_thickness=int(cfg["output"]["overlay_line_thickness"]),
|
| 1010 |
+
start_radius=int(cfg["output"]["overlay_start_radius"]),
|
| 1011 |
+
alpha=float(cfg["output"].get("overlay_alpha", 1.0)),
|
| 1012 |
+
draw_track_labels=bool(cfg["output"].get("overlay_draw_track_labels", False)),
|
| 1013 |
+
draw_track_labels_at_end=bool(cfg["output"].get("overlay_draw_track_labels_at_end", False)),
|
| 1014 |
+
label_font_scale=float(cfg["output"].get("overlay_label_font_scale", 0.5)),
|
| 1015 |
+
label_thickness=int(cfg["output"].get("overlay_label_thickness", 1)),
|
| 1016 |
+
valid_region_mask=valid_mask,
|
| 1017 |
+
direction_mask=valid_gate_mask,
|
| 1018 |
+
)
|
| 1019 |
+
tracks_render_json_path = out_dir / "tracks_render.json"
|
| 1020 |
+
export_tracks_render_json(
|
| 1021 |
+
tracks_render_json_path,
|
| 1022 |
+
width=meta.width,
|
| 1023 |
+
height=meta.height,
|
| 1024 |
+
points=filtered_points,
|
| 1025 |
+
valid_region_mask=valid_mask,
|
| 1026 |
+
direction_mask=valid_gate_mask,
|
| 1027 |
+
)
|
| 1028 |
|
| 1029 |
overlay_line_t = int(cfg["output"]["overlay_line_thickness"])
|
| 1030 |
overlay_start_r = int(cfg["output"]["overlay_start_radius"])
|
|
|
|
| 1142 |
"background_png": str(background_path.resolve()),
|
| 1143 |
"tracks_csv": str(tracks_csv_path.resolve()),
|
| 1144 |
"events_csv": str(events_csv_path.resolve()),
|
| 1145 |
+
"tracks_svg": str(tracks_svg_path.resolve()),
|
| 1146 |
+
"tracks_render_json": str(tracks_render_json_path.resolve()),
|
| 1147 |
"tracks_overlay_png": str(overlay_path.resolve()),
|
| 1148 |
+
"track_candidates_csv": (
|
| 1149 |
+
str(track_candidates_csv_path.resolve())
|
| 1150 |
+
if bool(cfg["tracking"].get("export_track_candidates", False))
|
| 1151 |
+
else ""
|
| 1152 |
+
),
|
| 1153 |
**overlay_smoothing_paths,
|
| 1154 |
"track_clips": track_clip_outputs,
|
| 1155 |
**valid_region_outputs,
|
|
|
|
| 1157 |
"postprocess": {
|
| 1158 |
"auto_merge_enabled": bool(cfg["tracking"].get("auto_merge_suggested", False)),
|
| 1159 |
"auto_merges_applied": merges_applied,
|
| 1160 |
+
"track_candidates_total": len(track_assessments),
|
| 1161 |
+
"track_candidates_kept": sum(1 for row in track_assessments if row["accepted"]),
|
| 1162 |
+
"track_candidates_rejected": sum(1 for row in track_assessments if not row["accepted"]),
|
| 1163 |
+
"track_candidates_top_rejections": dict(
|
| 1164 |
+
Counter(
|
| 1165 |
+
reason
|
| 1166 |
+
for row in track_assessments
|
| 1167 |
+
for reason in str(row["reject_reasons"]).split(";")
|
| 1168 |
+
if reason
|
| 1169 |
+
)
|
| 1170 |
+
),
|
| 1171 |
},
|
| 1172 |
}
|
| 1173 |
|
bat_tracker/render.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from collections import defaultdict
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
import cv2
|
| 7 |
import numpy as np
|
|
@@ -15,6 +18,311 @@ def track_color(track_id: int) -> Tuple[int, int, int]:
|
|
| 15 |
return int(bgr[0]), int(bgr[1]), int(bgr[2])
|
| 16 |
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
def render_tracks_overlay(
|
| 19 |
background_gray: np.ndarray,
|
| 20 |
points: Sequence[TrackPoint],
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from collections import defaultdict
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, Dict, List, Sequence, Tuple
|
| 7 |
+
from xml.etree import ElementTree as ET
|
| 8 |
|
| 9 |
import cv2
|
| 10 |
import numpy as np
|
|
|
|
| 18 |
return int(bgr[0]), int(bgr[1]), int(bgr[2])
|
| 19 |
|
| 20 |
|
| 21 |
+
def _track_color_hex(track_id: int) -> str:
|
| 22 |
+
blue, green, red = track_color(track_id)
|
| 23 |
+
return f"#{red:02x}{green:02x}{blue:02x}"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _svg_number(value: float) -> str:
|
| 27 |
+
return format(float(value), ".15g")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _svg_stroke_width(line_thickness: int) -> str:
|
| 31 |
+
# OpenCV anti-aliased lines render visually thicker than an SVG stroke with
|
| 32 |
+
# the same numeric width. Apply a small compensation so the vector export
|
| 33 |
+
# better matches the PNG overlay.
|
| 34 |
+
return _svg_number(max(1.0, float(line_thickness) * 1.5))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _svg_label_font_size(label_font_scale: float) -> str:
|
| 38 |
+
# cv2.putText with FONT_HERSHEY_SIMPLEX renders larger than a same-number
|
| 39 |
+
# SVG font-size. This compensation keeps SVG labels visually aligned with
|
| 40 |
+
# the PNG overlay labels.
|
| 41 |
+
return _svg_number(max(0.3, float(label_font_scale)) * 28.0)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _point_in_mask_xy(x: float, y: float, mask: np.ndarray) -> bool:
|
| 45 |
+
xi = int(round(x))
|
| 46 |
+
yi = int(round(y))
|
| 47 |
+
if yi < 0 or yi >= mask.shape[0] or xi < 0 or xi >= mask.shape[1]:
|
| 48 |
+
return False
|
| 49 |
+
return bool(mask[yi, xi] > 0)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _classify_track_direction(start_inside: bool | None, end_inside: bool | None) -> str:
|
| 53 |
+
if start_inside is None or end_inside is None:
|
| 54 |
+
return "unknown"
|
| 55 |
+
if start_inside and end_inside:
|
| 56 |
+
return "inside"
|
| 57 |
+
if start_inside and not end_inside:
|
| 58 |
+
return "exit"
|
| 59 |
+
if not start_inside and end_inside:
|
| 60 |
+
return "entry"
|
| 61 |
+
return "outside"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _point_payload(point: TrackPoint) -> Dict[str, Any]:
|
| 65 |
+
return {
|
| 66 |
+
"x": float(point.x),
|
| 67 |
+
"y": float(point.y),
|
| 68 |
+
"frame": int(point.frame),
|
| 69 |
+
"time_sec": float(point.time_sec),
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _mask_contours_payload(mask: np.ndarray) -> List[List[Dict[str, int]]]:
|
| 74 |
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 75 |
+
payload: List[List[Dict[str, int]]] = []
|
| 76 |
+
for contour in contours:
|
| 77 |
+
coords = contour.reshape(-1, 2)
|
| 78 |
+
payload.append([{"x": int(x), "y": int(y)} for x, y in coords])
|
| 79 |
+
return payload
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def build_tracks_render_payload(
|
| 83 |
+
width: int,
|
| 84 |
+
height: int,
|
| 85 |
+
points: Sequence[TrackPoint],
|
| 86 |
+
*,
|
| 87 |
+
valid_region_mask: np.ndarray | None = None,
|
| 88 |
+
direction_mask: np.ndarray | None = None,
|
| 89 |
+
) -> Dict[str, Any]:
|
| 90 |
+
by_track: Dict[int, List[TrackPoint]] = defaultdict(list)
|
| 91 |
+
for point in points:
|
| 92 |
+
by_track[point.track_id].append(point)
|
| 93 |
+
|
| 94 |
+
payload: Dict[str, Any] = {
|
| 95 |
+
"width": int(width),
|
| 96 |
+
"height": int(height),
|
| 97 |
+
"tracks": [],
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
if valid_region_mask is not None:
|
| 101 |
+
payload["valid_region"] = {
|
| 102 |
+
"contours": _mask_contours_payload(valid_region_mask),
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
effective_direction_mask = direction_mask if direction_mask is not None else valid_region_mask
|
| 106 |
+
tracks_payload: List[Dict[str, Any]] = []
|
| 107 |
+
for track_id in sorted(by_track):
|
| 108 |
+
track_points = sorted(by_track[track_id], key=lambda p: p.frame)
|
| 109 |
+
start = track_points[0]
|
| 110 |
+
end = track_points[-1]
|
| 111 |
+
start_inside = None
|
| 112 |
+
end_inside = None
|
| 113 |
+
if effective_direction_mask is not None:
|
| 114 |
+
start_inside = _point_in_mask_xy(start.x, start.y, effective_direction_mask)
|
| 115 |
+
end_inside = _point_in_mask_xy(end.x, end.y, effective_direction_mask)
|
| 116 |
+
|
| 117 |
+
tracks_payload.append(
|
| 118 |
+
{
|
| 119 |
+
"track_id": int(track_id),
|
| 120 |
+
"color": _track_color_hex(track_id),
|
| 121 |
+
"frame_start": int(start.frame),
|
| 122 |
+
"frame_end": int(end.frame),
|
| 123 |
+
"duration_sec": float(end.time_sec - start.time_sec),
|
| 124 |
+
"direction": _classify_track_direction(start_inside, end_inside),
|
| 125 |
+
"point_start": _point_payload(start),
|
| 126 |
+
"point_end": _point_payload(end),
|
| 127 |
+
"points": [_point_payload(point) for point in track_points],
|
| 128 |
+
}
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
payload["tracks"] = tracks_payload
|
| 132 |
+
return payload
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def export_tracks_render_json(
|
| 136 |
+
path: str | Path,
|
| 137 |
+
width: int,
|
| 138 |
+
height: int,
|
| 139 |
+
points: Sequence[TrackPoint],
|
| 140 |
+
*,
|
| 141 |
+
valid_region_mask: np.ndarray | None = None,
|
| 142 |
+
direction_mask: np.ndarray | None = None,
|
| 143 |
+
) -> Dict[str, Any]:
|
| 144 |
+
payload = build_tracks_render_payload(
|
| 145 |
+
width=width,
|
| 146 |
+
height=height,
|
| 147 |
+
points=points,
|
| 148 |
+
valid_region_mask=valid_region_mask,
|
| 149 |
+
direction_mask=direction_mask,
|
| 150 |
+
)
|
| 151 |
+
with Path(path).open("w", encoding="utf-8") as handle:
|
| 152 |
+
json.dump(payload, handle, indent=2)
|
| 153 |
+
return payload
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def export_tracks_svg(
|
| 157 |
+
path: str | Path,
|
| 158 |
+
width: int,
|
| 159 |
+
height: int,
|
| 160 |
+
points: Sequence[TrackPoint],
|
| 161 |
+
*,
|
| 162 |
+
line_thickness: int,
|
| 163 |
+
start_radius: int,
|
| 164 |
+
alpha: float = 1.0,
|
| 165 |
+
draw_track_labels: bool = False,
|
| 166 |
+
draw_track_labels_at_end: bool = False,
|
| 167 |
+
label_font_scale: float = 0.5,
|
| 168 |
+
label_thickness: int = 1,
|
| 169 |
+
valid_region_mask: np.ndarray | None = None,
|
| 170 |
+
direction_mask: np.ndarray | None = None,
|
| 171 |
+
) -> Dict[str, Any]:
|
| 172 |
+
payload = build_tracks_render_payload(
|
| 173 |
+
width=width,
|
| 174 |
+
height=height,
|
| 175 |
+
points=points,
|
| 176 |
+
valid_region_mask=valid_region_mask,
|
| 177 |
+
direction_mask=direction_mask,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
svg = ET.Element(
|
| 181 |
+
"svg",
|
| 182 |
+
{
|
| 183 |
+
"xmlns": "http://www.w3.org/2000/svg",
|
| 184 |
+
"viewBox": f"0 0 {int(width)} {int(height)}",
|
| 185 |
+
"width": str(int(width)),
|
| 186 |
+
"height": str(int(height)),
|
| 187 |
+
"role": "img",
|
| 188 |
+
"aria-label": "bat_tracker trajectories",
|
| 189 |
+
},
|
| 190 |
+
)
|
| 191 |
+
title = ET.SubElement(svg, "title")
|
| 192 |
+
title.text = "bat_tracker trajectories"
|
| 193 |
+
desc = ET.SubElement(svg, "desc")
|
| 194 |
+
desc.text = "Vector export of tracked trajectories in original video coordinates."
|
| 195 |
+
style = ET.SubElement(svg, "style")
|
| 196 |
+
style.text = (
|
| 197 |
+
".track polyline { fill: none; stroke: var(--track-color); stroke-linecap: round; "
|
| 198 |
+
"stroke-linejoin: round; vector-effect: non-scaling-stroke; }\n"
|
| 199 |
+
".track .track-start { fill: var(--track-color); }\n"
|
| 200 |
+
".track .track-end { fill: var(--track-color); opacity: 0.9; }\n"
|
| 201 |
+
".track text { fill: var(--track-color); stroke: #000; paint-order: stroke fill; "
|
| 202 |
+
"stroke-linejoin: round; dominant-baseline: alphabetic; }\n"
|
| 203 |
+
".valid-region path { fill: rgba(0, 255, 0, 0.12); stroke: #00ffaa; stroke-width: 1.5; "
|
| 204 |
+
"vector-effect: non-scaling-stroke; }\n"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
valid_region = payload.get("valid_region", {})
|
| 208 |
+
contours = valid_region.get("contours", []) if isinstance(valid_region, dict) else []
|
| 209 |
+
if contours:
|
| 210 |
+
valid_group = ET.SubElement(svg, "g", {"id": "valid-region", "class": "valid-region"})
|
| 211 |
+
valid_title = ET.SubElement(valid_group, "title")
|
| 212 |
+
valid_title.text = "Valid region"
|
| 213 |
+
for idx, contour in enumerate(contours):
|
| 214 |
+
if not contour:
|
| 215 |
+
continue
|
| 216 |
+
commands = [f"M {_svg_number(contour[0]['x'])} {_svg_number(contour[0]['y'])}"]
|
| 217 |
+
for point in contour[1:]:
|
| 218 |
+
commands.append(f"L {_svg_number(point['x'])} {_svg_number(point['y'])}")
|
| 219 |
+
commands.append("Z")
|
| 220 |
+
ET.SubElement(
|
| 221 |
+
valid_group,
|
| 222 |
+
"path",
|
| 223 |
+
{
|
| 224 |
+
"id": f"valid-region-contour-{idx}",
|
| 225 |
+
"d": " ".join(commands),
|
| 226 |
+
},
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
polyline_width = _svg_stroke_width(line_thickness)
|
| 230 |
+
start_radius_px = str(max(2, int(start_radius)))
|
| 231 |
+
end_radius_px = str(max(1, int(round(max(2, start_radius) * 0.6))))
|
| 232 |
+
group_opacity = _svg_number(max(0.0, min(1.0, alpha)))
|
| 233 |
+
label_offset = max(4, int(start_radius) + 2)
|
| 234 |
+
label_font_size = _svg_label_font_size(label_font_scale)
|
| 235 |
+
label_stroke_width = _svg_number(max(1, int(label_thickness)) + 2)
|
| 236 |
+
for track in payload["tracks"]:
|
| 237 |
+
track_id = int(track["track_id"])
|
| 238 |
+
group = ET.SubElement(
|
| 239 |
+
svg,
|
| 240 |
+
"g",
|
| 241 |
+
{
|
| 242 |
+
"id": f"track-{track_id}",
|
| 243 |
+
"class": "track",
|
| 244 |
+
"style": f"--track-color: {track['color']}",
|
| 245 |
+
"data-track-id": str(track_id),
|
| 246 |
+
"data-frame-start": str(track["frame_start"]),
|
| 247 |
+
"data-frame-end": str(track["frame_end"]),
|
| 248 |
+
"data-direction": str(track["direction"]),
|
| 249 |
+
"opacity": group_opacity,
|
| 250 |
+
},
|
| 251 |
+
)
|
| 252 |
+
group_title = ET.SubElement(group, "title")
|
| 253 |
+
group_title.text = (
|
| 254 |
+
f"Track {track_id} | frames {track['frame_start']}-{track['frame_end']} | "
|
| 255 |
+
f"duration {track['duration_sec']:.4f}s | direction {track['direction']}"
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
points_attr = " ".join(
|
| 259 |
+
f"{_svg_number(point['x'])},{_svg_number(point['y'])}" for point in track["points"]
|
| 260 |
+
)
|
| 261 |
+
ET.SubElement(
|
| 262 |
+
group,
|
| 263 |
+
"polyline",
|
| 264 |
+
{
|
| 265 |
+
"points": points_attr,
|
| 266 |
+
"stroke-width": polyline_width,
|
| 267 |
+
},
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
start = track["point_start"]
|
| 271 |
+
end = track["point_end"]
|
| 272 |
+
ET.SubElement(
|
| 273 |
+
group,
|
| 274 |
+
"circle",
|
| 275 |
+
{
|
| 276 |
+
"class": "track-start",
|
| 277 |
+
"cx": _svg_number(start["x"]),
|
| 278 |
+
"cy": _svg_number(start["y"]),
|
| 279 |
+
"r": start_radius_px,
|
| 280 |
+
},
|
| 281 |
+
)
|
| 282 |
+
ET.SubElement(
|
| 283 |
+
group,
|
| 284 |
+
"circle",
|
| 285 |
+
{
|
| 286 |
+
"class": "track-end",
|
| 287 |
+
"cx": _svg_number(end["x"]),
|
| 288 |
+
"cy": _svg_number(end["y"]),
|
| 289 |
+
"r": end_radius_px,
|
| 290 |
+
},
|
| 291 |
+
)
|
| 292 |
+
if draw_track_labels:
|
| 293 |
+
ET.SubElement(
|
| 294 |
+
group,
|
| 295 |
+
"text",
|
| 296 |
+
{
|
| 297 |
+
"class": "track-label track-label-start",
|
| 298 |
+
"x": _svg_number(start["x"] + label_offset),
|
| 299 |
+
"y": _svg_number(start["y"] - label_offset),
|
| 300 |
+
"font-size": label_font_size,
|
| 301 |
+
"font-family": "sans-serif",
|
| 302 |
+
"stroke-width": label_stroke_width,
|
| 303 |
+
},
|
| 304 |
+
).text = str(track_id)
|
| 305 |
+
if draw_track_labels_at_end and track["points"]:
|
| 306 |
+
ET.SubElement(
|
| 307 |
+
group,
|
| 308 |
+
"text",
|
| 309 |
+
{
|
| 310 |
+
"class": "track-label track-label-end",
|
| 311 |
+
"x": _svg_number(end["x"] + label_offset),
|
| 312 |
+
"y": _svg_number(end["y"] - label_offset),
|
| 313 |
+
"font-size": label_font_size,
|
| 314 |
+
"font-family": "sans-serif",
|
| 315 |
+
"stroke-width": label_stroke_width,
|
| 316 |
+
},
|
| 317 |
+
).text = str(track_id)
|
| 318 |
+
|
| 319 |
+
tree = ET.ElementTree(svg)
|
| 320 |
+
if hasattr(ET, "indent"):
|
| 321 |
+
ET.indent(tree, space=" ")
|
| 322 |
+
tree.write(Path(path), encoding="utf-8", xml_declaration=True)
|
| 323 |
+
return payload
|
| 324 |
+
|
| 325 |
+
|
| 326 |
def render_tracks_overlay(
|
| 327 |
background_gray: np.ndarray,
|
| 328 |
points: Sequence[TrackPoint],
|
config.out3_clean.yaml
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
background:
|
| 2 |
sample_frames: 120
|
| 3 |
uniform_sampling: true
|
|
|
|
| 4 |
context_start_sec: 0.0
|
| 5 |
context_duration_sec: -1.0
|
| 6 |
-
|
| 7 |
detection:
|
| 8 |
blur_kernel: 9
|
| 9 |
threshold_mode: fixed
|
|
@@ -24,32 +24,37 @@ detection:
|
|
| 24 |
temporal_burst_window_frames: 10
|
| 25 |
temporal_burst_trigger_frames: 3
|
| 26 |
temporal_burst_cooldown_frames: 24
|
| 27 |
-
|
| 28 |
tracking:
|
| 29 |
max_distance: 120.0
|
| 30 |
max_missed: 12
|
| 31 |
min_track_length: 6
|
|
|
|
| 32 |
min_track_displacement: 20.0
|
| 33 |
min_track_path_length: 28.0
|
| 34 |
min_track_straightness: 0.08
|
| 35 |
-
|
|
|
|
| 36 |
auto_merge_suggested: true
|
| 37 |
merge_max_gap_frames: 12
|
| 38 |
merge_max_endpoint_distance: 100.0
|
| 39 |
merge_overlap_min_common_frames: 3
|
| 40 |
merge_overlap_max_mean_distance: 60.0
|
| 41 |
merge_overlap_min_direction_cosine: 0.8
|
| 42 |
-
|
| 43 |
-
valid_region_gate_dilate_px: 20
|
| 44 |
-
|
| 45 |
valid_region:
|
| 46 |
enabled: true
|
| 47 |
method: hybrid_deep_layer_profile
|
| 48 |
apply_to_detection: false
|
|
|
|
|
|
|
|
|
|
| 49 |
context_start_sec: 0.0
|
| 50 |
context_duration_sec: 75.0
|
| 51 |
-
hybrid_combine_mode: and
|
| 52 |
blur_kernel_size: 151
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
depth_percentile: 85.0
|
| 54 |
depth_morph_kernel: 9
|
| 55 |
depth_min_area_ratio: 0.02
|
|
@@ -62,11 +67,10 @@ valid_region:
|
|
| 62 |
bottom_contour_gradient_quantile: 52.0
|
| 63 |
bottom_contour_regularization: 1.25
|
| 64 |
bottom_contour_max_step_px: 8
|
| 65 |
-
bottom_contour_downward_bias: 0.
|
| 66 |
-
bottom_contour_regularization_mix: 0.
|
| 67 |
bottom_contour_deepest_strong_ratio: 0.62
|
| 68 |
output_subdir: valid_region
|
| 69 |
-
|
| 70 |
output:
|
| 71 |
overlay_line_thickness: 2
|
| 72 |
overlay_start_radius: 5
|
|
@@ -75,8 +79,10 @@ output:
|
|
| 75 |
overlay_draw_track_labels_at_end: true
|
| 76 |
overlay_label_font_scale: 0.5
|
| 77 |
overlay_label_thickness: 1
|
|
|
|
|
|
|
| 78 |
export_track_clips: false
|
| 79 |
track_clips_subdir: track_clips
|
| 80 |
track_clips_padding_frames: 5
|
| 81 |
-
|
| 82 |
-
|
|
|
|
| 1 |
background:
|
| 2 |
sample_frames: 120
|
| 3 |
uniform_sampling: true
|
| 4 |
+
input_image: ""
|
| 5 |
context_start_sec: 0.0
|
| 6 |
context_duration_sec: -1.0
|
|
|
|
| 7 |
detection:
|
| 8 |
blur_kernel: 9
|
| 9 |
threshold_mode: fixed
|
|
|
|
| 24 |
temporal_burst_window_frames: 10
|
| 25 |
temporal_burst_trigger_frames: 3
|
| 26 |
temporal_burst_cooldown_frames: 24
|
|
|
|
| 27 |
tracking:
|
| 28 |
max_distance: 120.0
|
| 29 |
max_missed: 12
|
| 30 |
min_track_length: 6
|
| 31 |
+
min_track_duration_sec: 0.1
|
| 32 |
min_track_displacement: 20.0
|
| 33 |
min_track_path_length: 28.0
|
| 34 |
min_track_straightness: 0.08
|
| 35 |
+
require_start_or_end_in_valid_region: true
|
| 36 |
+
valid_region_gate_dilate_px: 20
|
| 37 |
auto_merge_suggested: true
|
| 38 |
merge_max_gap_frames: 12
|
| 39 |
merge_max_endpoint_distance: 100.0
|
| 40 |
merge_overlap_min_common_frames: 3
|
| 41 |
merge_overlap_max_mean_distance: 60.0
|
| 42 |
merge_overlap_min_direction_cosine: 0.8
|
| 43 |
+
export_track_candidates: true
|
|
|
|
|
|
|
| 44 |
valid_region:
|
| 45 |
enabled: true
|
| 46 |
method: hybrid_deep_layer_profile
|
| 47 |
apply_to_detection: false
|
| 48 |
+
hybrid_combine_mode: and
|
| 49 |
+
input_image: ""
|
| 50 |
+
input_mask: ""
|
| 51 |
context_start_sec: 0.0
|
| 52 |
context_duration_sec: 75.0
|
|
|
|
| 53 |
blur_kernel_size: 151
|
| 54 |
+
profile_smooth_window: 31
|
| 55 |
+
threshold_ratio: 0.45
|
| 56 |
+
safety_margin: 10
|
| 57 |
+
min_region_width_ratio: 0.35
|
| 58 |
depth_percentile: 85.0
|
| 59 |
depth_morph_kernel: 9
|
| 60 |
depth_min_area_ratio: 0.02
|
|
|
|
| 67 |
bottom_contour_gradient_quantile: 52.0
|
| 68 |
bottom_contour_regularization: 1.25
|
| 69 |
bottom_contour_max_step_px: 8
|
| 70 |
+
bottom_contour_downward_bias: 0.2
|
| 71 |
+
bottom_contour_regularization_mix: 0.9
|
| 72 |
bottom_contour_deepest_strong_ratio: 0.62
|
| 73 |
output_subdir: valid_region
|
|
|
|
| 74 |
output:
|
| 75 |
overlay_line_thickness: 2
|
| 76 |
overlay_start_radius: 5
|
|
|
|
| 79 |
overlay_draw_track_labels_at_end: true
|
| 80 |
overlay_label_font_scale: 0.5
|
| 81 |
overlay_label_thickness: 1
|
| 82 |
+
progress_enabled: true
|
| 83 |
+
progress_step_percent: 1
|
| 84 |
export_track_clips: false
|
| 85 |
track_clips_subdir: track_clips
|
| 86 |
track_clips_padding_frames: 5
|
| 87 |
+
trajectory_smoothing_enabled: false
|
| 88 |
+
trajectory_smoothing_window: 5
|
pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "bat_tracker"
|
| 7 |
-
version = "1.1.
|
| 8 |
description = "CPU-first bat trajectory extraction from monochrome IR cave videos"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "bat_tracker"
|
| 7 |
+
version = "1.1.3"
|
| 8 |
description = "CPU-first bat trajectory extraction from monochrome IR cave videos"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
tests/test_track_exports.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import csv
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from xml.etree import ElementTree as ET
|
| 7 |
+
|
| 8 |
+
import cv2
|
| 9 |
+
import numpy as np
|
| 10 |
+
import yaml
|
| 11 |
+
|
| 12 |
+
from bat_tracker.pipeline import run_pipeline
|
| 13 |
+
from bat_tracker.render import export_tracks_render_json, export_tracks_svg
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
SVG_NS = {"svg": "http://www.w3.org/2000/svg"}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _write_video(path: Path, frames: list[np.ndarray], fps: int = 10) -> None:
|
| 20 |
+
height, width = frames[0].shape
|
| 21 |
+
writer = cv2.VideoWriter(
|
| 22 |
+
str(path),
|
| 23 |
+
cv2.VideoWriter_fourcc(*"mp4v"),
|
| 24 |
+
float(fps),
|
| 25 |
+
(width, height),
|
| 26 |
+
)
|
| 27 |
+
assert writer.isOpened(), f"could not open writer for {path}"
|
| 28 |
+
for frame in frames:
|
| 29 |
+
writer.write(cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR))
|
| 30 |
+
writer.release()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _read_tracks(path: Path) -> list[dict[str, str]]:
|
| 34 |
+
with path.open(newline="", encoding="utf-8") as handle:
|
| 35 |
+
return list(csv.DictReader(handle))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _base_config() -> dict:
|
| 39 |
+
return {
|
| 40 |
+
"background": {
|
| 41 |
+
"sample_frames": 12,
|
| 42 |
+
"uniform_sampling": True,
|
| 43 |
+
},
|
| 44 |
+
"detection": {
|
| 45 |
+
"blur_kernel": 1,
|
| 46 |
+
"threshold_mode": "fixed",
|
| 47 |
+
"diff_threshold": 10,
|
| 48 |
+
"morph_open": 1,
|
| 49 |
+
"morph_close": 1,
|
| 50 |
+
"min_area": 8,
|
| 51 |
+
"max_area": 5000,
|
| 52 |
+
"max_global_intensity_shift": -1.0,
|
| 53 |
+
"max_foreground_ratio": -1.0,
|
| 54 |
+
"max_detections_per_frame": 0,
|
| 55 |
+
"temporal_burst_min_detections": 0,
|
| 56 |
+
"temporal_burst_window_frames": 0,
|
| 57 |
+
"temporal_burst_trigger_frames": 0,
|
| 58 |
+
"temporal_burst_cooldown_frames": 0,
|
| 59 |
+
},
|
| 60 |
+
"tracking": {
|
| 61 |
+
"max_distance": 18,
|
| 62 |
+
"max_missed": 2,
|
| 63 |
+
"min_track_length": 1,
|
| 64 |
+
"min_track_displacement": 0.0,
|
| 65 |
+
"min_track_path_length": 0.0,
|
| 66 |
+
"min_track_straightness": 0.0,
|
| 67 |
+
"min_track_duration_sec": 0.0,
|
| 68 |
+
"auto_merge_suggested": False,
|
| 69 |
+
"require_start_or_end_in_valid_region": False,
|
| 70 |
+
"valid_region_gate_dilate_px": 0,
|
| 71 |
+
},
|
| 72 |
+
"valid_region": {
|
| 73 |
+
"enabled": False,
|
| 74 |
+
},
|
| 75 |
+
"output": {
|
| 76 |
+
"progress_enabled": False,
|
| 77 |
+
"overlay_line_thickness": 2,
|
| 78 |
+
"overlay_start_radius": 5,
|
| 79 |
+
"overlay_alpha": 1.0,
|
| 80 |
+
"overlay_draw_track_labels": True,
|
| 81 |
+
"overlay_draw_track_labels_at_end": True,
|
| 82 |
+
"overlay_label_font_scale": 0.5,
|
| 83 |
+
"overlay_label_thickness": 1,
|
| 84 |
+
"export_track_clips": False,
|
| 85 |
+
},
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _make_single_track_video(tmp_path: Path) -> Path:
|
| 90 |
+
frames: list[np.ndarray] = []
|
| 91 |
+
for idx in range(10):
|
| 92 |
+
frame = np.zeros((48, 64), dtype=np.uint8)
|
| 93 |
+
if idx < 5:
|
| 94 |
+
x0 = 8 + idx * 5
|
| 95 |
+
cv2.rectangle(frame, (x0, 22), (x0 + 6, 28), 220, -1)
|
| 96 |
+
frames.append(frame)
|
| 97 |
+
|
| 98 |
+
video_path = tmp_path / "single_track.mp4"
|
| 99 |
+
_write_video(video_path, frames)
|
| 100 |
+
return video_path
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_pipeline_exports_svg_and_render_json_from_in_memory_tracks(tmp_path: Path) -> None:
|
| 104 |
+
video_path = _make_single_track_video(tmp_path)
|
| 105 |
+
|
| 106 |
+
mask = np.zeros((48, 64), dtype=np.uint8)
|
| 107 |
+
mask[:, 20:60] = 255
|
| 108 |
+
mask_path = tmp_path / "valid_mask.png"
|
| 109 |
+
cv2.imwrite(str(mask_path), mask)
|
| 110 |
+
|
| 111 |
+
cfg = _base_config()
|
| 112 |
+
cfg["valid_region"] = {
|
| 113 |
+
"enabled": True,
|
| 114 |
+
"input_mask": str(mask_path),
|
| 115 |
+
"apply_to_detection": False,
|
| 116 |
+
}
|
| 117 |
+
cfg_path = tmp_path / "cfg.yaml"
|
| 118 |
+
cfg_path.write_text(yaml.safe_dump(cfg), encoding="utf-8")
|
| 119 |
+
|
| 120 |
+
out_dir = tmp_path / "out"
|
| 121 |
+
meta = run_pipeline(str(video_path), str(out_dir), str(cfg_path))
|
| 122 |
+
|
| 123 |
+
tracks_rows = _read_tracks(out_dir / "tracks.csv")
|
| 124 |
+
assert tracks_rows
|
| 125 |
+
|
| 126 |
+
with (out_dir / "tracks_render.json").open(encoding="utf-8") as handle:
|
| 127 |
+
render_payload = json.load(handle)
|
| 128 |
+
|
| 129 |
+
assert render_payload["width"] == 64
|
| 130 |
+
assert render_payload["height"] == 48
|
| 131 |
+
assert len(render_payload["tracks"]) == 1
|
| 132 |
+
track_payload = render_payload["tracks"][0]
|
| 133 |
+
assert track_payload["track_id"] == 1
|
| 134 |
+
assert track_payload["direction"] == "entry"
|
| 135 |
+
assert track_payload["frame_start"] == 0
|
| 136 |
+
assert track_payload["frame_end"] == 4
|
| 137 |
+
assert track_payload["point_start"]["frame"] == 0
|
| 138 |
+
assert track_payload["point_end"]["frame"] == 4
|
| 139 |
+
assert "valid_region" in render_payload
|
| 140 |
+
assert render_payload["valid_region"]["contours"]
|
| 141 |
+
|
| 142 |
+
csv_points = [
|
| 143 |
+
{
|
| 144 |
+
"frame": int(row["frame"]),
|
| 145 |
+
"time_sec": float(row["time_sec"]),
|
| 146 |
+
"x": float(row["x"]),
|
| 147 |
+
"y": float(row["y"]),
|
| 148 |
+
}
|
| 149 |
+
for row in tracks_rows
|
| 150 |
+
]
|
| 151 |
+
assert track_payload["points"] == csv_points
|
| 152 |
+
|
| 153 |
+
svg_root = ET.parse(out_dir / "tracks.svg").getroot()
|
| 154 |
+
assert svg_root.attrib["viewBox"] == "0 0 64 48"
|
| 155 |
+
valid_region_group = svg_root.find("svg:g[@id='valid-region']", SVG_NS)
|
| 156 |
+
assert valid_region_group is not None
|
| 157 |
+
track_group = svg_root.find("svg:g[@id='track-1']", SVG_NS)
|
| 158 |
+
assert track_group is not None
|
| 159 |
+
assert track_group.attrib["data-track-id"] == "1"
|
| 160 |
+
assert track_group.attrib["data-frame-start"] == "0"
|
| 161 |
+
assert track_group.attrib["data-frame-end"] == "4"
|
| 162 |
+
assert track_group.attrib["data-direction"] == "entry"
|
| 163 |
+
title = track_group.find("svg:title", SVG_NS)
|
| 164 |
+
assert title is not None
|
| 165 |
+
assert "Track 1" in (title.text or "")
|
| 166 |
+
|
| 167 |
+
polyline = track_group.find("svg:polyline", SVG_NS)
|
| 168 |
+
assert polyline is not None
|
| 169 |
+
expected_points = " ".join(f"{point['x']},{point['y']}" for point in csv_points)
|
| 170 |
+
assert polyline.attrib["points"] == expected_points
|
| 171 |
+
|
| 172 |
+
circles = track_group.findall("svg:circle", SVG_NS)
|
| 173 |
+
assert len(circles) == 2
|
| 174 |
+
labels = track_group.findall("svg:text", SVG_NS)
|
| 175 |
+
assert len(labels) == 2
|
| 176 |
+
assert {label.attrib["class"] for label in labels} == {
|
| 177 |
+
"track-label track-label-start",
|
| 178 |
+
"track-label track-label-end",
|
| 179 |
+
}
|
| 180 |
+
assert {label.text for label in labels} == {"1"}
|
| 181 |
+
assert meta["outputs"]["tracks_svg"] == str((out_dir / "tracks.svg").resolve())
|
| 182 |
+
assert meta["outputs"]["tracks_render_json"] == str((out_dir / "tracks_render.json").resolve())
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_svg_and_render_json_export_empty_tracks_as_valid_empty_documents(tmp_path: Path) -> None:
|
| 186 |
+
svg_path = tmp_path / "tracks.svg"
|
| 187 |
+
json_path = tmp_path / "tracks_render.json"
|
| 188 |
+
|
| 189 |
+
export_tracks_svg(svg_path, width=64, height=48, points=[], line_thickness=2, start_radius=5)
|
| 190 |
+
payload = export_tracks_render_json(json_path, width=64, height=48, points=[])
|
| 191 |
+
|
| 192 |
+
assert payload["tracks"] == []
|
| 193 |
+
with json_path.open(encoding="utf-8") as handle:
|
| 194 |
+
assert json.load(handle)["tracks"] == []
|
| 195 |
+
|
| 196 |
+
svg_root = ET.parse(svg_path).getroot()
|
| 197 |
+
assert svg_root.attrib["viewBox"] == "0 0 64 48"
|
| 198 |
+
assert svg_root.findall("svg:g[@class='track']", SVG_NS) == []
|