marquee / faces.py
mamuflih13's picture
Refactor face detection and tracking in faces.py; optimize performance by reducing redundant detections and using a dictionary for track lookups. Update OpenVINO model usage and remove unused models. Enhance logging for better visibility.
afa7690
Raw
History Blame Contribute Delete
11.7 kB
"""faces.py — Person tracking + face cropping for Marquee.
Performance fixes vs previous version:
- Face detection runs ONCE per sampled frame (not once per person box)
- Track lookup uses dict instead of linear scan — O(1) vs O(n)
- Sightings stored only in JSONL, not accumulated in memory per track
- Logging added throughout for visibility
"""
import json
import logging
import time
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
from ov_models import OVModel
log = logging.getLogger(__name__)
PERSON_CONF = 0.50
FACE_CONF = 0.55
SAMPLE_FPS = 2.0
IOU_THRESHOLD = 0.35
MAX_MISS = 4
_person_det = None
_face_det = None
def _load():
global _person_det, _face_det
if _person_det is None:
log.info("[faces] Loading OpenVINO models (person + face detection)…")
t0 = time.time()
_person_det = OVModel("person-detection-retail-0013")
_face_det = OVModel("face-detection-retail-0004")
log.info(f"[faces] Models loaded in {time.time()-t0:.1f}s")
return _person_det, _face_det
def _detect_persons(frame_bgr, det) -> list[list[int]]:
h, w = frame_bgr.shape[:2]
out = det.infer(frame_bgr).reshape(-1, 7)
boxes = []
for _, _, conf, x1, y1, x2, y2 in out:
if conf < PERSON_CONF:
continue
boxes.append([max(0,int(x1*w)), max(0,int(y1*h)),
min(w,int(x2*w)), min(h,int(y2*h))])
return boxes
def _detect_faces_once(frame_bgr, fdet) -> list[list[int]]:
"""Run face detection ONCE per frame — reuse results for all person boxes."""
h, w = frame_bgr.shape[:2]
out = fdet.infer(frame_bgr).reshape(-1, 7)
boxes = []
for _, _, conf, x1, y1, x2, y2 in out:
if conf < FACE_CONF:
continue
boxes.append([max(0,int(x1*w)), max(0,int(y1*h)),
min(w,int(x2*w)), min(h,int(y2*h))])
return boxes
def _best_face_for_person(person_box, all_faces) -> list[int] | None:
"""Match a person box to the face with highest IoU from the pre-detected list."""
px1, py1, px2, py2 = person_box
best_iou, best_box = 0.0, None
for fx1, fy1, fx2, fy2 in all_faces:
ix1=max(px1,fx1); iy1=max(py1,fy1)
ix2=min(px2,fx2); iy2=min(py2,fy2)
inter = max(0,ix2-ix1)*max(0,iy2-iy1)
if inter == 0:
continue
union = (px2-px1)*(py2-py1) + (fx2-fx1)*(fy2-fy1) - inter
iou = inter / (union + 1e-6)
if iou > best_iou:
best_iou, best_box = iou, [fx1,fy1,fx2,fy2]
return best_box
def _iou(a, b) -> float:
ax1,ay1,ax2,ay2 = a
bx1,by1,bx2,by2 = b
ix1=max(ax1,bx1); iy1=max(ay1,by1)
ix2=min(ax2,bx2); iy2=min(ay2,by2)
inter = max(0,ix2-ix1)*max(0,iy2-iy1)
if inter == 0: return 0.0
return inter/((ax2-ax1)*(ay2-ay1)+(bx2-bx1)*(by2-by1)-inter+1e-6)
class _Track:
_next_id = 0
def __init__(self, box):
self.id = _Track._next_id; _Track._next_id += 1
self.box = box
self.miss = 0
self.best_crop: Image.Image | None = None
self.best_area = 0
self.count = 0
def update(self, box, crop: Image.Image | None):
self.box = box
self.miss = 0
self.count += 1
if crop is not None:
area = crop.size[0] * crop.size[1]
if area > self.best_area:
self.best_crop = crop
self.best_area = area
class _IoUTracker:
def __init__(self):
self.tracks: list[_Track] = []
self._id_map: dict[int, _Track] = {} # O(1) lookup by track id
def step(self, boxes, crops) -> list[tuple[int, list[int], _Track]]:
matched_t = set()
matched_d = set()
pairs = []
for di, dbox in enumerate(boxes):
best_iou, best_ti = 0.0, -1
for ti, t in enumerate(self.tracks):
iou = _iou(t.box, dbox)
if iou > best_iou:
best_iou, best_ti = iou, ti
if best_iou >= IOU_THRESHOLD and best_ti not in matched_t:
pairs.append((best_ti, di))
matched_t.add(best_ti); matched_d.add(di)
for ti, di in pairs:
self.tracks[ti].update(boxes[di], crops[di])
for ti, t in enumerate(self.tracks):
if ti not in matched_t:
t.miss += 1
for di, (dbox, crop) in enumerate(zip(boxes, crops)):
if di not in matched_d:
t = _Track(dbox); t.update(dbox, crop)
self.tracks.append(t)
self._id_map[t.id] = t
self.tracks = [t for t in self.tracks if t.miss <= MAX_MISS]
# keep id_map in sync
alive = {t.id for t in self.tracks}
self._id_map = {k: v for k, v in self._id_map.items() if k in alive}
return [(t.id, t.box, t) for t in self.tracks if t.miss == 0]
def get_by_id(self, tid: int) -> "_Track | None":
return self._id_map.get(tid)
def scan_video(video_path: str, max_seconds: float = 600.0):
"""Person tracking scan with progress logging.
Returns:
tracks: [{id, crop (PIL), sightings: [{t, bbox}]}] — strong tracks only
motion: {frame_idx: float}
meta: {"fps", "n_frames"}
jsonl_path: str
"""
t_start = time.time()
log.info(f"[scan] Starting scan: {video_path}")
pdet, fdet = _load()
_Track._next_id = 0
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 0
stride = max(1, int(round(fps / SAMPLE_FPS)))
log.info(f"[scan] Video: {total} frames @ {fps:.1f}fps, "
f"sampling every {stride} frames ({SAMPLE_FPS}fps)")
tracker = _IoUTracker()
all_tracks: dict[int, dict] = {}
motion: dict[int, float] = {}
prev_small = None
idx = 0
sampled = 0
persons_seen = 0
while True:
ok, frame = cap.read()
if not ok or idx / fps > max_seconds:
break
if idx % stride == 0:
t_sec = round(idx / fps, 3)
sampled += 1
# motion
small = cv2.cvtColor(cv2.resize(frame,(160,90)), cv2.COLOR_BGR2GRAY)
if prev_small is not None:
motion[idx] = float(np.mean(cv2.absdiff(small, prev_small)))
prev_small = small
# detect persons + faces (ONE face inference per frame, not per person)
p_boxes = _detect_persons(frame, pdet)
f_boxes = _detect_faces_once(frame, fdet) if p_boxes else []
persons_seen += len(p_boxes)
# build face crops per person
crops = []
for pb in p_boxes:
fb = _best_face_for_person(pb, f_boxes)
if fb is not None:
fx1,fy1,fx2,fy2 = fb
face_img = Image.fromarray(
cv2.cvtColor(frame[fy1:fy2,fx1:fx2], cv2.COLOR_BGR2RGB))
else:
px1,py1,px2,py2 = pb
fh = max(1,(py2-py1)//3)
face_img = Image.fromarray(
cv2.cvtColor(frame[py1:py1+fh,px1:px2], cv2.COLOR_BGR2RGB))
crops.append(face_img)
# track
active = tracker.step(p_boxes, crops)
for tid, bbox, trk in active:
if tid not in all_tracks:
all_tracks[tid] = {"id": tid, "crop": None,
"sightings": [], "count": 0}
entry = all_tracks[tid]
entry["count"] += 1
entry["sightings"].append({"t": t_sec, "bbox": bbox})
if trk.best_crop is not None:
area = trk.best_crop.size[0] * trk.best_crop.size[1]
if entry["crop"] is None or area > entry.get("_area", 0):
entry["crop"] = trk.best_crop
entry["_area"] = area
# progress log every ~5 seconds of video
if sampled % max(1, int(5 * SAMPLE_FPS)) == 0:
pct = int(100 * idx / max(total, 1))
log.info(f"[scan] {pct}% — t={t_sec:.1f}s, "
f"active tracks={len(tracker.tracks)}, "
f"persons this frame={len(p_boxes)}")
idx += 1
cap.release()
elapsed = time.time() - t_start
strong = {tid: v for tid, v in all_tracks.items() if v["count"] >= 2}
if not strong:
strong = all_tracks
log.info(f"[scan] Done in {elapsed:.1f}s — "
f"sampled {sampled} frames, "
f"total tracks={len(all_tracks)}, "
f"strong tracks={len(strong)}, "
f"total person detections={persons_seen}")
# write JSONL
jsonl_path = str(Path(video_path).with_suffix(".tracks.jsonl"))
rows_written = 0
with open(jsonl_path, "w") as f:
for tid, entry in strong.items():
for sight in entry["sightings"]:
f.write(json.dumps({
"track_id": tid,
"t": sight["t"],
"bbox": sight["bbox"],
"name": "",
}) + "\n")
rows_written += 1
log.info(f"[scan] JSONL written: {jsonl_path} ({rows_written} rows)")
tracks_list = [
{"id": v["id"], "crop": v["crop"], "sightings": v["sightings"]}
for v in strong.values()
]
return tracks_list, motion, {"fps": fps, "n_frames": idx}, jsonl_path
def write_names_to_jsonl(jsonl_path: str, id_to_name: dict[int, str]):
log.info(f"[faces] Writing names to JSONL: {id_to_name}")
rows = []
with open(jsonl_path) as f:
for line in f:
row = json.loads(line)
tid = row["track_id"]
if tid in id_to_name:
row["name"] = id_to_name[tid]
rows.append(row)
with open(jsonl_path, "w") as f:
for row in rows:
f.write(json.dumps(row) + "\n")
log.info(f"[faces] Names written for {len(id_to_name)} tracks")
def annotate_event_frame(frame_bgr, jsonl_path: str, t_sec: float,
tol: float = 1.5) -> Image.Image:
img = frame_bgr.copy()
try:
entries = []
with open(jsonl_path) as f:
for line in f:
row = json.loads(line)
if abs(row["t"] - t_sec) <= tol and row.get("name","").strip():
entries.append(row)
if entries:
log.debug(f"[annotate] t={t_sec:.2f}s — burning {len(entries)} names")
except (FileNotFoundError, json.JSONDecodeError) as e:
log.warning(f"[annotate] Could not read JSONL: {e}")
return Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
for row in entries:
name = row["name"].strip()
if not name: continue
x1,y1,x2,y2 = row["bbox"]
scale = max(0.5,(x2-x1)/200.0)
thick = max(1,int(scale*2))
(tw,th),_ = cv2.getTextSize(name, cv2.FONT_HERSHEY_DUPLEX, scale, thick)
tx=x1; ty=max(th+6,y1-8)
cv2.rectangle(img,(tx-4,ty-th-6),(tx+tw+4,ty+4),(0,0,0),-1)
cv2.putText(img,name,(tx,ty),cv2.FONT_HERSHEY_DUPLEX,
scale,(0,255,255),thick,cv2.LINE_AA)
return Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
def frame_at(video_path: str, frame_idx: int):
cap = cv2.VideoCapture(video_path)
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
ok, frame = cap.read()
cap.release()
if not ok:
log.warning(f"[faces] frame_at({frame_idx}) failed")
return frame if ok else None