Pabloler21 Claude Opus 5.5 commited on
Commit
132e289
·
1 Parent(s): 7dcef0a

Rellenar la zona cuando se marca dibujando su contorno

Browse files

Solo lo pintado contaba como zona: con un contorno, quien tocaba la
línea por afuera era intruso y quien estaba adentro no (se detectaba al
~14% de la gente de adentro). fill_outlines cierra huecos chicos del
trazo, rellena lo encerrado y recorta el medio trazo que sobresale hacia
afuera. Si se pintó el área entera, no cambia nada. El cierre de huecos
tiene un radio máximo (MAX_GAP_PX): sin él, un área grande tardaba ~100 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Files changed (2) hide show
  1. camguard/zone.py +45 -1
  2. tests/test_zone.py +47 -1
camguard/zone.py CHANGED
@@ -9,6 +9,10 @@ import numpy as np
9
 
10
  NO_ZONE_MSG = "Pintá la zona restringida sobre la imagen antes de analizar."
11
 
 
 
 
 
12
 
13
  def zone_from_layers(layers: list[np.ndarray], shape: tuple[int, int]) -> np.ndarray:
14
  """Combina las capas pintadas en el editor en una sola máscara del tamaño del video.
@@ -32,7 +36,47 @@ def zone_from_layers(layers: list[np.ndarray], shape: tuple[int, int]) -> np.nda
32
  # INTER_NEAREST mantiene los bordes nítidos (sí/no) en vez de difuminarlos.
33
  size = (shape[1], shape[0]) # OpenCV pide (ancho, alto)
34
  mask = cv2.resize(mask.astype(np.uint8), size, interpolation=cv2.INTER_NEAREST) > 0
35
- return mask
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
  def feet_in_zone(feet_xy: np.ndarray, zone: np.ndarray) -> np.ndarray:
 
9
 
10
  NO_ZONE_MSG = "Pintá la zona restringida sobre la imagen antes de analizar."
11
 
12
+ # Hueco máximo (en píxeles) que se cierra en un contorno dibujado a mano antes de rellenarlo.
13
+ # También acota el costo: cerrar con un radio grande sobre un área pintada tarda minutos.
14
+ MAX_GAP_PX = 60
15
+
16
 
17
  def zone_from_layers(layers: list[np.ndarray], shape: tuple[int, int]) -> np.ndarray:
18
  """Combina las capas pintadas en el editor en una sola máscara del tamaño del video.
 
36
  # INTER_NEAREST mantiene los bordes nítidos (sí/no) en vez de difuminarlos.
37
  size = (shape[1], shape[0]) # OpenCV pide (ancho, alto)
38
  mask = cv2.resize(mask.astype(np.uint8), size, interpolation=cv2.INTER_NEAREST) > 0
39
+ return fill_outlines(mask)
40
+
41
+
42
+ def _disk(radius: int) -> np.ndarray:
43
+ return cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * radius + 1, 2 * radius + 1))
44
+
45
+
46
+ def fill_outlines(mask: np.ndarray) -> np.ndarray:
47
+ """Si la zona se marcó dibujando su contorno, devuelve el área encerrada.
48
+
49
+ Lo natural es marcar un área dibujando su borde, pero solo lo pintado cuenta como zona:
50
+ con un contorno, quien tocaba la línea por afuera era "intruso" y quien estaba adentro no.
51
+ Medido en el video de ejemplo, con contorno se detectaba solo al ~15% de los de adentro.
52
+
53
+ Pasos:
54
+ 1. Cierra huecos de hasta ~2 grosores de trazo, con un máximo de ``MAX_GAP_PX`` (un
55
+ contorno a mano rara vez cierra justo).
56
+ 2. Rellena lo que quedó encerrado.
57
+ 3. Recorta medio trazo hacia adentro: el trazo se dibuja centrado sobre el borde que
58
+ se quiso marcar, así que su mitad externa ya está afuera.
59
+
60
+ Si no hay nada encerrado (se pintó el área entera), devuelve la máscara sin cambios.
61
+
62
+ Args:
63
+ mask: Máscara booleana con lo pintado.
64
+
65
+ Returns:
66
+ Máscara booleana de la zona.
67
+ """
68
+ painted = mask.astype(np.uint8)
69
+ # Medio grosor del trazo: la mayor distancia de un píxel pintado al borde de lo pintado.
70
+ # ponytail: asume trazos de grosor parejo; un contorno junto a un área grande pintada
71
+ # sobreestima el grosor (si pasa, medir el grosor solo sobre los trazos finos).
72
+ half_stroke = max(1, int(cv2.distanceTransform(painted, cv2.DIST_L2, 5).max()))
73
+ closed = cv2.morphologyEx(painted, cv2.MORPH_CLOSE, _disk(min(2 * half_stroke, MAX_GAP_PX)))
74
+ contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
75
+ filled = np.zeros_like(painted)
76
+ cv2.drawContours(filled, contours, -1, 1, cv2.FILLED)
77
+ if filled.sum() == closed.sum(): # nada encerrado: se pintó el área entera
78
+ return mask
79
+ return cv2.erode(filled, _disk(half_stroke)).astype(bool)
80
 
81
 
82
  def feet_in_zone(feet_xy: np.ndarray, zone: np.ndarray) -> np.ndarray:
tests/test_zone.py CHANGED
@@ -1,7 +1,53 @@
 
 
 
1
  import numpy as np
2
  import pytest
3
 
4
- from camguard.zone import feet_in_zone, zone_from_layers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
 
7
  def make_layer(shape, painted=None):
 
1
+ import time
2
+
3
+ import cv2
4
  import numpy as np
5
  import pytest
6
 
7
+ from camguard.zone import feet_in_zone, fill_outlines, zone_from_layers
8
+
9
+
10
+ def outline(gap=0):
11
+ """Máscara 200x200 con el contorno de un cuadrado (50,50)-(150,150), trazo de 10 px.
12
+
13
+ `gap` deja un hueco de ese ancho en el lado de arriba, como un trazo a mano que no cierra.
14
+ """
15
+ mask = np.zeros((200, 200), np.uint8)
16
+ cv2.rectangle(mask, (50, 50), (150, 150), 1, 10)
17
+ if gap:
18
+ mask[40:60, 100 : 100 + gap] = 0
19
+ return mask.astype(bool)
20
+
21
+
22
+ @pytest.mark.parametrize("gap", [pytest.param(0, id="cerrado"), pytest.param(15, id="con-hueco")])
23
+ def test_fill_outlines_fills_the_drawn_contour(gap):
24
+ zone = fill_outlines(outline(gap))
25
+ assert zone[100, 100] # centro del cuadrado: adentro
26
+ assert zone[100, 53] and zone[100, 147] # justo adentro del borde marcado
27
+ # justo afuera: la mitad externa del trazo no cuenta como zona
28
+ assert not zone[100, 46] and not zone[100, 154]
29
+
30
+
31
+ def test_fill_outlines_keeps_a_painted_area_as_is():
32
+ mask = np.zeros((200, 200), bool)
33
+ mask[50:150, 50:150] = True
34
+ assert np.array_equal(fill_outlines(mask), mask)
35
+
36
+
37
+ def test_fill_outlines_is_fast_on_a_large_painted_area():
38
+ # Regresión: con un área grande el "grosor de trazo" estimado es enorme y cerrar huecos con
39
+ # ese radio tardaba ~100 s en 1080p.
40
+ mask = np.zeros((1080, 1920), bool)
41
+ mask[594:, 960:] = True
42
+ start = time.perf_counter()
43
+ assert np.array_equal(fill_outlines(mask), mask)
44
+ assert time.perf_counter() - start < 2
45
+
46
+
47
+ def test_zone_from_layers_fills_a_drawn_contour():
48
+ layer = np.zeros((200, 200, 4), np.uint8)
49
+ layer[outline()] = (255, 0, 0, 255)
50
+ assert zone_from_layers([layer], (200, 200))[100, 100]
51
 
52
 
53
  def make_layer(shape, painted=None):