File size: 16,528 Bytes
699f3cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import json
from pathlib import Path
from statistics import mean
from typing import Iterable

from src.config.settings import MvpRuntimeSettings
from src.core.frame_state import DecodedFrame, FrameEnvelope
from src.core.video_io import iter_video_frames
from src.task3.matcher import Task3Matcher
from src.task3.no_match_logic import filter_no_match_candidates
from src.task3.reference_cache import ReferenceCache
from src.task3.verifier import verify_matches
from src.tools.report_paths import GENERATED_REPORTS_ROOT


def discover_task3_videos(reference_dir: str | Path | None = None) -> list[Path]:
    base = Path(reference_dir) if reference_dir is not None else MvpRuntimeSettings().task3_eval_reference_dir
    parent = base.parent if base.is_dir() else base
    deduped: dict[str, Path] = {}
    for candidate in list(parent.glob("*.MP4")) + list(parent.glob("*.mp4")):
        deduped[str(candidate.resolve()).lower()] = candidate
    return sorted(deduped.values())


def evaluate_task3_frames(
    frames: Iterable[DecodedFrame],
    *,
    runtime_settings: MvpRuntimeSettings | None = None,
    reference_dir: str | Path | None = None,
    video_name: str = "task3_replay",
    mode: str = "orb_template",
) -> dict[str, object]:
    settings = runtime_settings or MvpRuntimeSettings()
    cache = ReferenceCache()
    cache.preload_from_directory(reference_dir or settings.task3_eval_reference_dir, orb_features=settings.task3_orb_features)
    matcher = Task3Matcher(reference_cache=cache, runtime_settings=settings)
    reference_ids = cache.list_ids()

    total_frames = 0
    raw_candidate_frames = 0
    accepted_match_count = 0
    rejected_verification_count = 0
    ambiguity_suppression_count = 0
    no_match_frames = 0
    descriptor_path_count = 0
    template_path_count = 0
    false_positive_proxy_count = 0
    match_scores: list[float] = []
    total_candidates_generated = 0
    total_candidates_accepted = 0
    total_candidates_rejected = 0
    total_candidates_rejected_by_gate = 0
    total_candidates_rejected_by_score_filter = 0
    yoloe_inference_ms_values: list[float] = []
    lightglue_verify_ms_values: list[float] = []
    homography_compute_ms_values: list[float] = []
    effective_mode_counts: dict[str, int] = {}
    fallback_reason_counts: dict[str, int] = {}

    for frame_index, decoded in enumerate(frames):
        total_frames += 1
        frame = FrameEnvelope(
            frame_url=f"http://task3-eval/frames/{frame_index + 1}/",
            image_url=f"/task3/{frame_index + 1}.jpg",
            video_name=video_name,
            translation_x=0.0,
            translation_y=0.0,
            translation_z=0.0,
            health_status="1",
            metadata={"frame_index": decoded.frame_index, "image_width": decoded.width, "image_height": decoded.height},
        )
        raw_matches = matcher.match(frame, b"", reference_ids, decoded_frame=decoded, mode=mode)
        task3_info = dict(matcher.last_run_info)
        effective_mode = str(task3_info.get("effective_mode", mode))
        effective_mode_counts[effective_mode] = effective_mode_counts.get(effective_mode, 0) + 1
        fallback_reason = task3_info.get("fallback_reason")
        if fallback_reason:
            reason_text = str(fallback_reason)
            fallback_reason_counts[reason_text] = fallback_reason_counts.get(reason_text, 0) + 1
        generated_count = int(task3_info.get("candidates_generated", len(raw_matches)))
        total_candidates_generated += generated_count
        gate_rejected_count = int(task3_info.get("candidates_rejected_by_gate", max(generated_count - len(raw_matches), 0)))
        total_candidates_rejected_by_gate += gate_rejected_count
        yoloe_inference_ms_values.append(float(task3_info.get("yoloe_inference_ms", 0.0)))
        lightglue_verify_ms_values.append(float(task3_info.get("lightglue_verify_ms_total", 0.0)))
        homography_compute_ms_values.append(float(task3_info.get("homography_compute_ms_total", 0.0)))
        if raw_matches:
            raw_candidate_frames += 1
        filtered = filter_no_match_candidates(
            raw_matches,
            min_score=settings.task3_min_score,
            mode=mode,
            yoloe_min_score=settings.task3_yoloe_min_score,
            modality=decoded.modality,
            yoloe_thermal_min_score=settings.task3_yoloe_thermal_min_score,
            ambiguity_margin=settings.task3_ambiguity_margin,
            suppression_mode=cache.get_candidate_suppression_mode(),
        )
        score_filter_rejected_count = max(len(raw_matches) - len(filtered), 0)
        total_candidates_rejected_by_score_filter += score_filter_rejected_count
        if raw_matches and not filtered and len(raw_matches) > 1:
            ambiguity_suppression_count += 1
        verified = verify_matches(
            frame,
            filtered,
            decoded_frame=decoded,
            min_inliers=settings.task3_match_min_inliers,
        )
        rejected_verification_count += max(len(filtered) - len(verified), 0)
        final_accepted_count = len(verified)
        total_candidates_accepted += final_accepted_count
        total_candidates_rejected += max(generated_count - final_accepted_count, 0)
        if not verified:
            no_match_frames += 1
            continue

        for match in verified:
            accepted_match_count += 1
            score = float(match.metadata.get("match_score", 0.0))
            match_scores.append(score)
            source = str(match.metadata.get("matcher_source", ""))
            if "template" in source:
                template_path_count += 1
                if score < 0.88:
                    false_positive_proxy_count += 1
            elif "yoloe" in source:
                descriptor_path_count += 1
                yoloe_info = match.metadata.get("task3_yoloe", {})
                if not bool(yoloe_info.get("verify_passed", False)):
                    false_positive_proxy_count += 1
            elif "learned" in source:
                descriptor_path_count += 1
                if float(match.metadata.get("similarity", 0.0)) < settings.task3_learned_min_similarity:
                    false_positive_proxy_count += 1
            else:
                descriptor_path_count += 1
                if float(match.metadata.get("inlier_ratio", 0.0)) < 0.45:
                    false_positive_proxy_count += 1

    decision = _decide_learned_descriptor_need(
        accepted_match_count=accepted_match_count,
        false_positive_proxy_count=false_positive_proxy_count,
        descriptor_path_count=descriptor_path_count,
        template_path_count=template_path_count,
    )
    return {
        "video_name": video_name,
        "status": "ok",
        "reference_count": len(reference_ids),
        "total_frames": total_frames,
        "raw_candidate_frames": raw_candidate_frames,
        "accepted_match_count": accepted_match_count,
        "rejected_verification_count": rejected_verification_count,
        "ambiguity_suppression_count": ambiguity_suppression_count,
        "no_match_suppression_rate": round(no_match_frames / max(total_frames, 1), 6),
        "false_positive_proxy_count": false_positive_proxy_count,
        "descriptor_path_count": descriptor_path_count,
        "template_path_count": template_path_count,
        "mean_match_score": round(mean(match_scores), 6) if match_scores else 0.0,
        "effective_mode_counts": effective_mode_counts,
        "fallback_reason_counts": fallback_reason_counts,
        "fallback_reason": _summarize_reason_counts(fallback_reason_counts),
        "fallback_active": bool(fallback_reason_counts),
        "candidates_generated": total_candidates_generated,
        "candidates_accepted": total_candidates_accepted,
        "candidates_rejected": total_candidates_rejected,
        "candidates_rejected_by_gate": total_candidates_rejected_by_gate,
        "candidates_rejected_by_score_filter": total_candidates_rejected_by_score_filter,
        "candidate_rejected_ratio": round(total_candidates_rejected / max(total_candidates_generated, 1), 6),
        "yoloe_inference_ms_per_frame_avg": round(_safe_mean(yoloe_inference_ms_values), 6),
        "lightglue_verify_ms_total_per_frame_avg": round(_safe_mean(lightglue_verify_ms_values), 6),
        "homography_compute_ms_per_frame_avg": round(_safe_mean(homography_compute_ms_values), 6),
        "decision": decision,
        "mode": mode,
    }


def evaluate_task3_baseline(
    *,
    runtime_settings: MvpRuntimeSettings | None = None,
    output_dir: str | Path = GENERATED_REPORTS_ROOT,
    mode: str = "orb_template",
) -> dict[str, object]:
    settings = runtime_settings or MvpRuntimeSettings()
    output_path = Path(output_dir)
    output_path.mkdir(parents=True, exist_ok=True)
    results: list[dict[str, object]] = []

    for video_path in discover_task3_videos(settings.task3_eval_reference_dir):
        try:
            frames = iter_video_frames(
                video_path,
                frame_stride=settings.task3_eval_frame_stride,
                limit=settings.task3_eval_frame_limit,
                video_name=video_path.stem,
            )
            summary = evaluate_task3_frames(
                frames,
                runtime_settings=settings,
                reference_dir=settings.task3_eval_reference_dir,
                video_name=video_path.stem,
                mode=mode,
            )
        except Exception as exc:
            summary = {"video_name": video_path.stem, "status": "failed", "error": str(exc), "total_frames": 0}
        results.append(summary)

    aggregate = {
        "video_count": len(results),
        "ok_count": sum(1 for item in results if item.get("status") == "ok"),
        "no_match_suppression_rate": round(_safe_mean(item.get("no_match_suppression_rate", 0.0) for item in results if item.get("status") == "ok"), 6),
        "false_positive_proxy_count": int(sum(int(item.get("false_positive_proxy_count", 0)) for item in results if item.get("status") == "ok")),
        "accepted_match_count": int(sum(int(item.get("accepted_match_count", 0)) for item in results if item.get("status") == "ok")),
        "decision": _aggregate_decision(results),
    }
    payload = {"results": results, "aggregate": aggregate, "mode": mode}
    if mode == "orb_template":
        (output_path / "task3_baseline_summary.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
        (output_path / "task3_baseline_table.md").write_text(render_task3_table(results), encoding="utf-8")
    suffix_map = {
        "orb_template": "orb",
        "learned_descriptor": "learned",
        "yoloe_vp_lightglue": "yoloe",
    }
    suffix = suffix_map.get(mode, mode.replace("-", "_"))
    (output_path / f"task3_baseline_{suffix}_summary.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
    (output_path / f"task3_baseline_{suffix}_table.md").write_text(render_task3_table(results), encoding="utf-8")
    write_task3_comparison(output_path)
    return payload


def write_task3_comparison(output_dir: str | Path) -> dict[str, object] | None:
    output_path = Path(output_dir)
    orb_path = output_path / "task3_baseline_orb_summary.json"
    learned_path = output_path / "task3_baseline_learned_summary.json"
    if not orb_path.exists() or not learned_path.exists():
        return None
    orb_payload = json.loads(orb_path.read_text(encoding="utf-8"))
    learned_payload = json.loads(learned_path.read_text(encoding="utf-8"))
    orb_aggregate = orb_payload.get("aggregate", {})
    learned_aggregate = learned_payload.get("aggregate", {})
    comparison_decision = decide_learned_descriptor_gain(orb_aggregate, learned_aggregate)
    markdown = (
        "| Metric | ORB/Template | Learned | Delta |\n"
        "| --- | --- | --- | --- |\n"
        f"| Accepted Match Count | {orb_aggregate.get('accepted_match_count', '-')} | {learned_aggregate.get('accepted_match_count', '-')} | "
        f"{int(learned_aggregate.get('accepted_match_count', 0)) - int(orb_aggregate.get('accepted_match_count', 0))} |\n"
        f"| False Positive Proxy | {orb_aggregate.get('false_positive_proxy_count', '-')} | {learned_aggregate.get('false_positive_proxy_count', '-')} | "
        f"{int(learned_aggregate.get('false_positive_proxy_count', 0)) - int(orb_aggregate.get('false_positive_proxy_count', 0))} |\n"
        f"| No-match Suppression Rate | {orb_aggregate.get('no_match_suppression_rate', '-')} | {learned_aggregate.get('no_match_suppression_rate', '-')} | "
        f"{round(float(learned_aggregate.get('no_match_suppression_rate', 0.0)) - float(orb_aggregate.get('no_match_suppression_rate', 0.0)), 6)} |\n"
        f"| Decision | {orb_aggregate.get('decision', '-')} | {learned_aggregate.get('decision', '-')} | - |\n"
        f"| Faz 6 Learned Gain | - | {comparison_decision} | - |\n"
    )
    (output_path / "task3_baseline_comparison.md").write_text(markdown, encoding="utf-8")
    return {"orb": orb_aggregate, "learned": learned_aggregate, "decision": comparison_decision}


def render_task3_table(results: list[dict[str, object]]) -> str:
    lines = [
        "| Video | Status | Frames | Accepted | Rejected | No-match Rate | FP Proxy | Descriptor | Template | Fallback | Decision |",
        "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
    ]
    for item in results:
        lines.append(
            "| {video} | {status} | {frames} | {accepted} | {rejected} | {no_match} | {fp} | {descriptor} | {template} | {fallback} | {decision} |".format(
                video=item.get("video_name"),
                status=item.get("status"),
                frames=item.get("total_frames", 0),
                accepted=item.get("accepted_match_count", "-"),
                rejected=item.get("rejected_verification_count", "-"),
                no_match=item.get("no_match_suppression_rate", "-"),
                fp=item.get("false_positive_proxy_count", "-"),
                descriptor=item.get("descriptor_path_count", "-"),
                template=item.get("template_path_count", "-"),
                fallback=item.get("fallback_reason", "-"),
                decision=item.get("decision", "-"),
            )
        )
    return "\n".join(lines) + "\n"


def _decide_learned_descriptor_need(
    *,
    accepted_match_count: int,
    false_positive_proxy_count: int,
    descriptor_path_count: int,
    template_path_count: int,
) -> str:
    if accepted_match_count == 0:
        return "belirsiz"
    if false_positive_proxy_count > max(2, accepted_match_count // 5):
        return "gerekli"
    if descriptor_path_count >= template_path_count and false_positive_proxy_count == 0:
        return "henuz_gereksiz"
    return "belirsiz"


def _aggregate_decision(results: list[dict[str, object]]) -> str:
    decisions = [str(item.get("decision", "belirsiz")) for item in results if item.get("status") == "ok"]
    if not decisions:
        return "belirsiz"
    if any(item == "gerekli" for item in decisions):
        return "gerekli"
    if all(item == "henuz_gereksiz" for item in decisions):
        return "henuz_gereksiz"
    return "belirsiz"


def decide_learned_descriptor_gain(orb_aggregate: dict[str, object], learned_aggregate: dict[str, object]) -> str:
    orb_fp = int(orb_aggregate.get("false_positive_proxy_count", 0))
    learned_fp = int(learned_aggregate.get("false_positive_proxy_count", 0))
    orb_no_match = float(orb_aggregate.get("no_match_suppression_rate", 0.0))
    learned_no_match = float(learned_aggregate.get("no_match_suppression_rate", 0.0))
    orb_accept = int(orb_aggregate.get("accepted_match_count", 0))
    learned_accept = int(learned_aggregate.get("accepted_match_count", 0))

    fp_improved = learned_fp <= int(round(orb_fp * 0.8))
    no_match_ok = (learned_no_match - orb_no_match) <= 0.10
    accept_ok = learned_accept >= int(round(orb_accept * 0.85))
    if fp_improved and no_match_ok and accept_ok:
        return "kazanc_var"
    if learned_fp >= orb_fp and learned_accept <= orb_accept:
        return "kazanc_yok"
    return "belirsiz"


def _safe_mean(values: Iterable[float]) -> float:
    filtered = [float(value) for value in values]
    return mean(filtered) if filtered else 0.0


def _summarize_reason_counts(counts: dict[str, int]) -> str | None:
    if not counts:
        return None
    if len(counts) == 1:
        return next(iter(counts))
    return "mixed"