Spaces:
Sleeping
Sleeping
Commit ·
132e289
1
Parent(s): 7dcef0a
Rellenar la zona cuando se marca dibujando su contorno
Browse filesSolo 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>
- camguard/zone.py +45 -1
- 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):
|