File size: 5,853 Bytes
fc803f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
QC Agent β€” stateful cross-frame quality control engine
"""

from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field

from .sop_rules import run_all_rules, RuleViolation, Severity


@dataclass
class FrameAnnotation:
    frame_idx: int
    objects: list[dict]   # [{"track_id","label","bbox":[x,y,w,h],"confidence"}]


@dataclass
class QCIssue:
    check:    str
    severity: str
    message:  str
    frame:    int
    track_id: str | None = None

    def user_message(self) -> str:
        icon = "πŸ”΄" if self.severity == "error" else "🟑"
        tid  = f" Β· Object {self.track_id}" if self.track_id else ""
        return f"{icon} Frame {self.frame}{tid} β€” {self.message}"


@dataclass
class QCReport:
    total_frames:    int
    total_issues:    int
    issues_by_type:  dict[str, int]
    frames_affected: list[int]
    issues:          list[QCIssue]
    summary:         str = ""

    def to_display(self) -> str:
        lines = [
            f"QC Report  ({self.total_frames} frames)",
            f"Total issues : {self.total_issues}",
            f"Frames affected: {len(self.frames_affected)}",
            "", "Issues by type:",
        ]
        for k, v in sorted(self.issues_by_type.items(), key=lambda x: -x[1]):
            lines.append(f"  {k}: {v}")
        lines.append("\nDetail:")
        for i in self.issues:
            lines.append("  " + i.user_message())
        return "\n".join(lines)


class QCAgent:
    def __init__(self, ctx: dict | None = None):
        self.ctx: dict = ctx or {}
        self.ctx.setdefault("track_history", {})
        self.ctx.setdefault("track_frame_count", {})
        self.ctx.setdefault("track_last_seen", {})
        self.ctx.setdefault("detection_index", {})
        self._issues: list[QCIssue] = []
        self._frames: list[FrameAnnotation] = []
        self._FLICKER_GAP    = self.ctx.get("flicker_gap", 5)
        self._MISSING_THRESH = 0.30

    # ── public ────────────────────────────────

    def process_frame(self, fa: FrameAnnotation):
        self._frames.append(fa)
        ann = {"objects": fa.objects}

        # SOP rule engine
        for v in run_all_rules(ann, fa.frame_idx, self.ctx):
            self._issues.append(QCIssue(
                check=v.rule_id, severity=v.severity.value,
                message=v.message, frame=v.frame, track_id=v.track_id,
            ))

        # cross-frame checks
        self._check_flicker(fa)
        self._check_missed(fa)
        self._update_state(fa)

    def generate_report(self) -> QCReport:
        by_type: dict[str, int] = defaultdict(int)
        affected: set[int] = set()
        for i in self._issues:
            by_type[i.check] += 1
            affected.add(i.frame)

        report = QCReport(
            total_frames=len(self._frames),
            total_issues=len(self._issues),
            issues_by_type=dict(by_type),
            frames_affected=sorted(affected),
            issues=self._issues,
        )
        if self._issues:
            top = max(by_type, key=by_type.get)
            report.summary = (
                f"⚠️ {report.total_issues} issue(s) across "
                f"{len(affected)} frame(s). Most common: {top} ({by_type[top]})."
            )
        else:
            report.summary = "βœ… All annotations passed QC checks."
        return report

    def get_frame_warnings(self, frame_idx: int) -> list[str]:
        return [i.user_message() for i in self._issues if i.frame == frame_idx]

    def load_ai_detections(self, index: dict[int, list[dict]]):
        self.ctx["detection_index"] = index

    # ── cross-frame checks ────────────────────

    def _check_flicker(self, fa: FrameAnnotation):
        for obj in fa.objects:
            tid = obj.get("track_id")
            if not tid:
                continue
            last = self.ctx["track_last_seen"].get(tid)
            if last is not None:
                gap = fa.frame_idx - last
                if 1 < gap <= self._FLICKER_GAP:
                    self._issues.append(QCIssue(
                        check="FLICKER", severity="warning",
                        message=f"Object disappeared for {gap-1} frame(s) then reappeared β€” possible flickering tag",
                        frame=fa.frame_idx, track_id=tid,
                    ))

    def _check_missed(self, fa: FrameAnnotation):
        dets = self.ctx["detection_index"].get(fa.frame_idx, [])
        if not dets:
            return
        human_boxes = [o["bbox"] for o in fa.objects if o.get("bbox")]
        for det in dets:
            db = det.get("bbox", [])
            if not any(_iou(db, hb) > self._MISSING_THRESH for hb in human_boxes):
                self._issues.append(QCIssue(
                    check="MISSED", severity="error",
                    message=(
                        f"AI detected '{det.get('label','object')}' "
                        f"(conf {det.get('confidence',0):.0%}) but no annotation found"
                    ),
                    frame=fa.frame_idx,
                ))

    def _update_state(self, fa: FrameAnnotation):
        for obj in fa.objects:
            tid = obj.get("track_id")
            if not tid:
                continue
            self.ctx["track_frame_count"][tid] = self.ctx["track_frame_count"].get(tid, 0) + 1
            self.ctx["track_last_seen"][tid] = fa.frame_idx


def _iou(a: list, b: list) -> float:
    if len(a) < 4 or len(b) < 4:
        return 0.0
    x1,y1,w1,h1 = a[:4]; x2,y2,w2,h2 = b[:4]
    ix = max(0, min(x1+w1,x2+w2)-max(x1,x2))
    iy = max(0, min(y1+h1,y2+h2)-max(y1,y2))
    inter = ix*iy
    union = w1*h1+w2*h2-inter
    return inter/union if union > 0 else 0.0