kerojohan commited on
Commit
aed2053
·
1 Parent(s): d709964

Sync Space project with latest bat_tracker upstream

Browse files
.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 horizontal
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.cli --input /path/video.mp4 --output /path/out_dir --config /path/config.yaml
49
  ```
50
 
51
  Generacion standalone de mascara vertical valida:
52
 
53
  ```bash
54
- python -m bat_tracker.valid_region \
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
- ## Ejemplo de Resultados
 
 
 
 
63
 
64
- Aquí se muestran visualizaciones de las salidas generadas:
65
 
66
- **1. Máscara de Zona Válida (Método Híbrido):**
67
- *Filtra los laterales oscuros combinando profundidad en el centro y perfil de iluminación, reteniendo la zona útil.*
68
- ![Valid Region](src_assets/valid_region_overlay.png)
69
 
70
- **2. Tracking Final:**
71
- *Trayectorias 2D superpuestas sobre el fondo calculado.*
72
- ![Tracks Overlay](src_assets/tracks_overlay.png)
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 bloque `valid_region` con `x_start`, `x_end`, `width` y `method`.
 
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. Export de `tracks.csv` y render final `tracks_overlay.png` (color por track, primer punto mas grande).
115
- 8. Si `valid_region.enabled`, calculo de banda vertical valida desde iluminacion horizontal y guardado en `valid_region/*`.
116
- 9. Export de `meta.json` con parametros, metadatos y metricas.
 
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.overlay_draw_track_labels`: dibuja el numero de `track_id` junto al inicio de cada track
165
- - `output.overlay_draw_track_labels_at_end`: dibuja el numero de `track_id` al final del track
166
- - `output.overlay_label_font_scale` y `output.overlay_label_thickness`: estilo de etiqueta
 
 
 
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 "exits"
120
  if not start_inside and end_inside:
121
- return "enters"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- continue
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
- continue
405
 
 
 
 
406
  if require_start_or_end_in_valid_region and gate_mask is not None:
407
- if not (_point_in_mask(start, gate_mask) or _point_in_mask(end, gate_mask)):
408
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
 
410
  filtered.extend(track_points)
411
 
412
- return filtered
 
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
- deduped: List[TrackPoint] = []
568
- seen = set()
569
  for point in merged_points:
570
- key = (point.track_id, point.frame, round(point.x, 3), round(point.y, 3))
571
- if key in seen:
 
 
 
 
 
572
  continue
573
- seen.add(key)
574
- deduped.append(point)
575
 
576
- return deduped, merges_applied
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- filtered_points = _filter_track_points(all_points, cfg["tracking"], meta.fps, valid_mask=valid_mask)
844
- filtered_points, merges_applied = _auto_merge_track_points(filtered_points, cfg["tracking"])
 
 
 
 
 
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
- from typing import Dict, List, Sequence, Tuple
 
 
 
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
- min_track_duration_sec: 0.1
 
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
- require_start_or_end_in_valid_region: true
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.20
66
- bottom_contour_regularization_mix: 0.90
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
- progress_enabled: true
82
- progress_step_percent: 1
 
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.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) == []