File size: 23,448 Bytes
453c2bb
 
 
 
 
 
 
 
 
d054bb3
453c2bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d054bb3
c6d5e4e
 
3c0a069
 
 
142b49a
453c2bb
 
 
 
 
 
 
c6d5e4e
453c2bb
 
 
 
 
 
 
 
 
 
 
d054bb3
453c2bb
 
 
 
 
 
 
 
 
 
 
 
 
c6d5e4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98efe74
 
c6d5e4e
 
453c2bb
 
 
d054bb3
 
 
 
 
453c2bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d054bb3
 
 
 
 
453c2bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c0a069
 
c6d5e4e
 
3c0a069
 
 
 
 
 
 
 
 
 
 
 
 
c6d5e4e
 
 
 
 
 
 
 
 
 
 
 
 
 
3c0a069
c6d5e4e
 
 
 
 
 
 
 
453c2bb
 
 
 
 
 
 
 
 
c6d5e4e
 
 
 
 
 
98efe74
 
 
 
 
 
 
 
 
 
3c0a069
 
c6d5e4e
 
 
 
 
 
453c2bb
 
 
c6d5e4e
3c0a069
 
453c2bb
c6d5e4e
453c2bb
 
 
3c0a069
 
 
 
 
 
 
 
 
 
 
 
 
453c2bb
 
 
 
 
 
 
 
d054bb3
 
 
 
 
453c2bb
 
 
 
 
 
d054bb3
 
 
 
 
453c2bb
142b49a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6d5e4e
142b49a
9de189c
 
 
 
c6d5e4e
 
 
 
 
 
 
98efe74
c6d5e4e
 
 
98efe74
9de189c
142b49a
c6d5e4e
 
453c2bb
 
 
 
 
c6d5e4e
453c2bb
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
"""
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"]