ShortSmith_v4 / models /object_detector.py
defalt-here's picture
Enhance ObjectDetector: adjust identity similarity and add cross-identity merge threshold for improved face grouping
142b49a
Raw
History Blame Contribute Delete
23.4 kB
"""
Lightweight object/face detection helper.
Samples frames from a video (OpenCV) and runs the available recognizers
(face -> fallback to body). Returns per-frame detections and writes
thumbnails to a temporary folder.
"""
from pathlib import Path
import tempfile
from typing import List, Dict, Optional, Callable
from utils.logger import get_logger
logger = get_logger("models.object_detector")
class ObjectDetector:
"""Detection-only helper that samples frames and returns detections.
It will try to use `FaceRecognizer` first (if available) and fall back to
`BodyRecognizer` (YOLO) if face code is not present.
"""
def __init__(self, use_insightface: bool = True, use_opencv_face_fallback: bool = True):
# Import lazily so the module can still be imported when optional deps
# are missing.
if use_insightface:
try:
from models.face_recognizer import FaceRecognizer
try:
# Try to load the face model eagerly so detect_* calls work
self.face = FaceRecognizer(load_model=True)
except Exception as e:
logger.warning(f"Could not load FaceRecognizer: {e}")
self.face = None
except Exception:
self.face = None
else:
self.face = None
try:
from models.body_recognizer import BodyRecognizer
self.body = BodyRecognizer(load_model=False)
except Exception:
self.body = None
# OpenCV Haar fallback for face detection (no extra model download).
self.cv_face_cascade = None
self.use_opencv_face_fallback = use_opencv_face_fallback
try:
import cv2
cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
cascade = cv2.CascadeClassifier(cascade_path)
if not cascade.empty():
self.cv_face_cascade = cascade
except Exception:
self.cv_face_cascade = None
def _detect_faces_opencv(self, frame, min_size: int = 24) -> List[object]:
"""Detect faces with OpenCV Haar cascade as a lightweight fallback."""
import cv2
if self.cv_face_cascade is None:
return []
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
faces = self.cv_face_cascade.detectMultiScale(
gray,
scaleFactor=1.1,
minNeighbors=4,
minSize=(min_size, min_size),
)
class _Face:
pass
detections: List[object] = []
for (x, y, w, h) in faces:
d = _Face()
d.bbox = (int(x), int(y), int(x + w), int(y + h))
d.confidence = 0.8 # Haar cascade does not expose a calibrated score
d.landmarks = None # Marks this as a face-like detection for labeling
detections.append(d)
return detections
def detect_faces_in_video(
self,
video_path: str,
sample_rate: float = 1.0,
min_confidence: float = 0.5,
max_frames: Optional[int] = None,
output_dir: Optional[str] = None,
include_full_frame_fallback: bool = False,
detection_type: str = "face", # 'face', 'body' or 'both'
progress_callback: Optional[Callable[[int, int, int, int], None]] = None,
group_faces: bool = True,
identity_similarity_threshold: float = 0.45,
min_face_area: int = 2500,
min_sharpness: float = 40.0,
min_quality_score: float = 0.08,
cross_identity_merge_threshold: float = 0.35,
) -> Dict:
"""Sample frames and detect faces/persons.
Returns a dict with keys: `output_dir`, `fps`, `frame_count`, `detections`.
Each detection is a dict: `{timestamp, frame_index, detection_index, bbox, confidence, thumbnail}`
"""
import cv2
import numpy as np
video_path = str(video_path)
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise RuntimeError(f"Could not open video: {video_path}")
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
# Calculate sampling interval in frames
frame_interval = max(1, int(round(max(1.0, fps) / max(0.1, sample_rate))))
total_sampled = max(1, (frame_count + frame_interval - 1) // frame_interval) if frame_count > 0 else 1
if output_dir:
out_dir = Path(output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
else:
out_dir = Path(tempfile.mkdtemp(prefix="shortsmith_det_"))
thumb_dir = out_dir / "thumbnails"
thumb_dir.mkdir(parents=True, exist_ok=True)
# We'll collect per-sampled-frame records. Each record has a
# timestamp, frame_index and a list of detections (may be empty).
frames: List[Dict] = []
identities_state: Dict[int, Dict] = {}
next_identity_id = 0
def _cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
a_n = np.linalg.norm(a)
b_n = np.linalg.norm(b)
if a_n == 0 or b_n == 0:
return -1.0
return float(np.dot(a, b) / (a_n * b_n))
def _match_identity(embedding: Optional[np.ndarray]) -> Optional[int]:
nonlocal next_identity_id
if embedding is None:
return None
best_id = None
best_sim = -1.0
for ident_id, st in identities_state.items():
centroid = st["embedding_sum"] / max(1, st["embedding_count"])
sim = _cosine_similarity(embedding, centroid)
if sim > best_sim:
best_sim = sim
best_id = ident_id
if best_id is not None and best_sim >= identity_similarity_threshold:
st = identities_state[best_id]
st["embedding_sum"] = st["embedding_sum"] + embedding
st["embedding_count"] += 1
return best_id
ident_id = next_identity_id
next_identity_id += 1
identities_state[ident_id] = {
"embedding_sum": embedding.copy(),
"embedding_count": 1,
"detections": 0,
"first_timestamp": None,
"last_timestamp": None,
"best_thumbnail": None,
"best_quality": -1.0,
"best_confidence": 0.0,
"best_bbox": None,
"best_frame_index": None,
"best_timestamp": None,
"occurrences": [],
"last_occurrence_frame": None,
}
return ident_id
frame_idx = 0
sampled = 0
if progress_callback is not None:
try:
progress_callback(0, total_sampled, 0, frame_count)
except Exception:
pass
while True:
ret, frame = cap.read()
if not ret:
break
if frame_idx % frame_interval == 0:
timestamp = frame_idx / fps
# Choose detector(s) based on detection_type
dets = []
if detection_type in ("face", "both"):
if self.face is not None:
try:
dets = self.face.detect_faces(frame, max_faces=10, min_confidence=min_confidence)
except Exception:
dets = []
# Optional fallback to OpenCV Haar face detection
if not dets and self.use_opencv_face_fallback:
dets = self._detect_faces_opencv(frame)
# Face-only mode still needs a useful error if no face backend exists
if detection_type == "face" and self.face is None:
raise RuntimeError(
"InsightFace detector not available. "
"Install insightface dependencies and ensure model weights are available."
)
# If requested and still empty, run body detector
if (not dets) and detection_type in ("body", "both"):
if self.body is None:
if detection_type == "body":
raise RuntimeError("Body recognizer not available (ultralytics missing)")
else:
try:
dets = self.body.detect_persons(frame, min_confidence=min_confidence)
except Exception:
dets = []
# If no detections found, optionally skip. If caller requests
# a full-frame fallback (legacy behavior), generate a single
# detection that covers the entire frame.
if not dets and include_full_frame_fallback:
h, w = frame.shape[:2]
class _Full:
pass
d = _Full()
d.bbox = (0, 0, w, h)
d.confidence = 1.0
dets = [d]
# If still no detections, skip this sampled frame entirely
if not dets:
sampled += 1
if progress_callback is not None:
try:
progress_callback(sampled, total_sampled, frame_idx, frame_count)
except Exception:
pass
if max_frames and sampled >= max_frames:
break
frame_idx += 1
continue
det_list: List[Dict] = []
for i, d in enumerate(dets):
x1, y1, x2, y2 = d.bbox
# Clamp bbox to image
h, w = frame.shape[:2]
x1 = max(0, min(int(x1), w - 1))
x2 = max(0, min(int(x2), w))
y1 = max(0, min(int(y1), h - 1))
y2 = max(0, min(int(y2), h))
crop = frame[y1:y2, x1:x2]
if crop.size == 0:
continue
confidence = float(getattr(d, "confidence", 1.0))
area = float(max(1, (x2 - x1) * (y2 - y1)))
gray_crop = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
sharpness = float(cv2.Laplacian(gray_crop, cv2.CV_64F).var())
area_norm = min(1.0, area / 40000.0)
sharpness_norm = min(1.0, sharpness / 300.0)
quality_score = float(confidence * area_norm * sharpness_norm)
# Filter low-quality faces so gallery/exports contain only usable crops.
if area < float(min_face_area):
continue
if sharpness < float(min_sharpness):
continue
if quality_score < float(min_quality_score):
continue
identity_id = None
embedding = getattr(d, "embedding", None)
if group_faces and detection_type in ("face", "both"):
if embedding is not None:
try:
embedding = np.asarray(embedding, dtype=np.float32)
identity_id = _match_identity(embedding)
except Exception:
identity_id = None
should_write_thumb = True
if group_faces and identity_id is not None:
st = identities_state[identity_id]
should_write_thumb = quality_score > st["best_quality"]
thumb_str = None
if should_write_thumb:
thumb_path = thumb_dir / f"det_{sampled:06d}_{i}.jpg"
try:
cv2.imwrite(str(thumb_path), crop)
thumb_str = str(thumb_path)
except Exception:
thumb_str = None
# Decide label: face detector -> 'face', body detector -> 'person'
label = 'unknown'
# FaceDetection objects come from FaceRecognizer and have 'embedding' attr
if hasattr(d, 'embedding') or hasattr(d, 'landmarks'):
label = 'face'
else:
label = 'person'
if group_faces and identity_id is not None:
st = identities_state[identity_id]
st["detections"] += 1
if st["first_timestamp"] is None:
st["first_timestamp"] = float(timestamp)
st["last_timestamp"] = float(timestamp)
if st["last_occurrence_frame"] != int(frame_idx):
st["occurrences"].append({
"timestamp": float(timestamp),
"frame_index": int(frame_idx),
"bbox": (int(x1), int(y1), int(x2), int(y2)),
"confidence": confidence,
"sharpness": sharpness,
"quality_score": quality_score,
})
st["last_occurrence_frame"] = int(frame_idx)
if should_write_thumb and thumb_str is not None and quality_score > st["best_quality"]:
st["best_quality"] = quality_score
st["best_thumbnail"] = thumb_str
st["best_confidence"] = confidence
st["best_bbox"] = (int(x1), int(y1), int(x2), int(y2))
st["best_frame_index"] = int(frame_idx)
st["best_timestamp"] = float(timestamp)
det_list.append({
"detection_index": int(i),
"bbox": (int(x1), int(y1), int(x2), int(y2)),
"confidence": confidence,
"sharpness": sharpness,
"quality_score": quality_score,
"label": label,
"identity_id": int(identity_id) if identity_id is not None else None,
"thumbnail": thumb_str,
})
# If all detections got filtered out by quality thresholds, skip frame.
if not det_list:
sampled += 1
if progress_callback is not None:
try:
progress_callback(sampled, total_sampled, frame_idx, frame_count)
except Exception:
pass
if max_frames and sampled >= max_frames:
break
frame_idx += 1
continue
# Record this sampled frame (may have empty detections if fallback disabled)
frames.append({
"timestamp": float(timestamp),
"frame_index": int(frame_idx),
"detections": det_list,
})
sampled += 1
if progress_callback is not None:
try:
progress_callback(sampled, total_sampled, frame_idx, frame_count)
except Exception:
pass
if max_frames and sampled >= max_frames:
break
frame_idx += 1
cap.release()
if progress_callback is not None:
try:
progress_callback(total_sampled, total_sampled, frame_count, frame_count)
except Exception:
pass
def _merge_states(
states: Dict[int, Dict],
merge_threshold: float,
) -> tuple[Dict[int, Dict], Dict[int, int]]:
ids = sorted(states.keys())
if not ids:
return {}, {}
parent: Dict[int, int] = {i: i for i in ids}
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a: int, b: int) -> None:
ra, rb = find(a), find(b)
if ra == rb:
return
if ra < rb:
parent[rb] = ra
else:
parent[ra] = rb
centroids: Dict[int, np.ndarray] = {}
for ident_id in ids:
st = states[ident_id]
if st["embedding_count"] > 0:
centroids[ident_id] = st["embedding_sum"] / max(1, st["embedding_count"])
for i, a in enumerate(ids):
emb_a = centroids.get(a)
if emb_a is None:
continue
for b in ids[i + 1:]:
emb_b = centroids.get(b)
if emb_b is None:
continue
sim = _cosine_similarity(emb_a, emb_b)
if sim >= merge_threshold:
union(a, b)
groups: Dict[int, List[int]] = {}
for ident_id in ids:
root = find(ident_id)
groups.setdefault(root, []).append(ident_id)
merged_states: Dict[int, Dict] = {}
old_to_new: Dict[int, int] = {}
next_new_id = 0
for root in sorted(groups.keys()):
members = sorted(groups[root])
first = states[members[0]]
merged = {
"embedding_sum": first["embedding_sum"].copy(),
"embedding_count": int(first["embedding_count"]),
"detections": int(first["detections"]),
"first_timestamp": first["first_timestamp"],
"last_timestamp": first["last_timestamp"],
"best_thumbnail": first["best_thumbnail"],
"best_quality": float(first["best_quality"]),
"best_confidence": float(first["best_confidence"]),
"best_bbox": first["best_bbox"],
"best_frame_index": first["best_frame_index"],
"best_timestamp": first["best_timestamp"],
"occurrences": list(first["occurrences"]),
"last_occurrence_frame": first["last_occurrence_frame"],
"merged_from_ids": members.copy(),
}
for member in members[1:]:
st = states[member]
merged["embedding_sum"] = merged["embedding_sum"] + st["embedding_sum"]
merged["embedding_count"] += int(st["embedding_count"])
merged["detections"] += int(st["detections"])
if merged["first_timestamp"] is None or (
st["first_timestamp"] is not None and st["first_timestamp"] < merged["first_timestamp"]
):
merged["first_timestamp"] = st["first_timestamp"]
if merged["last_timestamp"] is None or (
st["last_timestamp"] is not None and st["last_timestamp"] > merged["last_timestamp"]
):
merged["last_timestamp"] = st["last_timestamp"]
merged["occurrences"].extend(st["occurrences"])
if st["best_quality"] > merged["best_quality"]:
merged["best_quality"] = float(st["best_quality"])
merged["best_thumbnail"] = st["best_thumbnail"]
merged["best_confidence"] = float(st["best_confidence"])
merged["best_bbox"] = st["best_bbox"]
merged["best_frame_index"] = st["best_frame_index"]
merged["best_timestamp"] = st["best_timestamp"]
# De-duplicate occurrences by frame index.
seen = set()
uniq_occ = []
for occ in sorted(merged["occurrences"], key=lambda o: float(o.get("timestamp", 0.0))):
fi = int(occ.get("frame_index", -1))
if fi in seen:
continue
seen.add(fi)
uniq_occ.append(occ)
merged["occurrences"] = uniq_occ
merged["last_occurrence_frame"] = int(uniq_occ[-1]["frame_index"]) if uniq_occ else None
merged_states[next_new_id] = merged
for member in members:
old_to_new[member] = next_new_id
next_new_id += 1
return merged_states, old_to_new
merged_states, old_to_new = _merge_states(
identities_state,
merge_threshold=float(cross_identity_merge_threshold),
)
states_for_output = merged_states if group_faces else identities_state
# Remap per-frame identity ids to merged ids so downstream UI uses unified face ids.
if group_faces and old_to_new:
for frame in frames:
for det in frame.get("detections", []):
old_id = det.get("identity_id")
if old_id is not None and int(old_id) in old_to_new:
det["identity_id"] = int(old_to_new[int(old_id)])
identities: List[Dict] = []
for ident_id, st in sorted(states_for_output.items(), key=lambda kv: kv[0]):
embedding_mean = None
if st["embedding_count"] > 0:
centroid = st["embedding_sum"] / max(1, st["embedding_count"])
embedding_mean = centroid.tolist()
identities.append({
"identity_id": int(ident_id),
"detections": int(st["detections"]),
"first_timestamp": st["first_timestamp"],
"last_timestamp": st["last_timestamp"],
"representative_thumbnail": st["best_thumbnail"],
"representative_confidence": float(st["best_confidence"]),
"representative_quality": float(st["best_quality"]),
"representative_bbox": st["best_bbox"],
"representative_frame_index": st["best_frame_index"],
"representative_timestamp": st["best_timestamp"],
"occurrences": st["occurrences"],
"embedding_mean": embedding_mean,
"merged_from_ids": st.get("merged_from_ids", [int(ident_id)]),
})
return {
"output_dir": str(out_dir),
"fps": float(fps),
"frame_count": frame_count,
"frames": frames,
"identities": identities,
}
__all__ = ["ObjectDetector"]