This commit is contained in:
2026-03-26 04:24:32 -03:00
parent 08b67f2bb7
commit 08c58a6a9d
43 changed files with 2627 additions and 252 deletions

View File

@@ -31,8 +31,16 @@ def ts():
return datetime.now(timezone.utc).isoformat()
RUN_CONTEXT = {}
def set_run_context(run_id: str, parent_job_id: str, run_type: str = "initial"):
RUN_CONTEXT.update({"run_id": run_id, "parent_job_id": parent_job_id, "run_type": run_type})
def push(r, key, event):
event["ts"] = event.get("ts", ts())
event.update(RUN_CONTEXT)
r.rpush(key, json.dumps(event))
return event
@@ -85,7 +93,11 @@ def main():
r.delete(key)
delay = args.delay
run_id = f"{args.job[:8]}-r1"
set_run_context(run_id=run_id, parent_job_id=args.job, run_type="initial")
logger.info("Full escalation pipeline simulation → %s", key)
logger.info("Run: %s (parent: %s)", run_id, args.job)
logger.info("Open: http://mpr.local.ar/detection/?job=%s", args.job)
input("\nPress Enter to start...")

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
Test checkpoint + replay flow end-to-end.
1. Runs the pipeline with checkpointing enabled on a test video
2. Lists available checkpoints
3. Replays from run_ocr with different config
4. Compares detection counts
Usage:
MPR_CHECKPOINT=1 INFERENCE_URL=http://mcrndeb:8000 python tests/detect/manual/test_replay.py [--job JOB_ID]
Requires: inference server running, MinIO/S3 running, test video available
"""
import argparse
import logging
import os
import sys
from pathlib import Path
# Load ctrl/.env
env_file = Path(__file__).resolve().parents[3] / "ctrl" / ".env"
if env_file.exists():
for line in env_file.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
sys.path.insert(0, ".")
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(name)s%(message)s")
logger = logging.getLogger(__name__)
# Force checkpointing on
os.environ["MPR_CHECKPOINT"] = "1"
def main():
parser = argparse.ArgumentParser()
import time
default_job = f"replay-{int(time.time()) % 100000}"
parser.add_argument("--job", default=default_job)
parser.add_argument("--port", type=int, default=6382)
args = parser.parse_args()
# Override Redis to localhost (ctrl/.env has k8s hostname)
os.environ["REDIS_URL"] = f"redis://localhost:{args.port}/0"
from detect.graph import get_pipeline, NODES
from detect.checkpoint import list_checkpoints
from detect.checkpoint import replay_from
from detect.state import DetectState
VIDEO = "media/out/chunks/95043d50-4df6-4ac8-bbd5-2ba873117c6e/chunk_0000.mp4"
logger.info("Job: %s", args.job)
logger.info("Checkpoint: enabled")
logger.info("Video: %s", VIDEO)
logger.info("Open: http://mpr.local.ar/detection/?job=%s", args.job)
input("\nPress Enter to run initial pipeline...")
# --- Initial run ---
pipeline = get_pipeline(checkpoint=True)
initial_state = DetectState(
video_path=VIDEO,
job_id=args.job,
profile_name="soccer_broadcast",
)
logger.info("Running initial pipeline...")
result = pipeline.invoke(initial_state)
detections = result.get("detections", [])
report = result.get("report")
logger.info("Initial run: %d detections, %d brands",
len(detections), len(report.brands) if report else 0)
# --- List checkpoints ---
stages = list_checkpoints(args.job)
logger.info("Available checkpoints: %s", stages)
if "detect_objects" not in stages:
logger.error("Expected checkpoint for detect_objects — aborting replay test")
return
input("\nPress Enter to replay from run_ocr with different config...")
# --- Replay with different OCR config ---
overrides = {"ocr": {"min_confidence": 0.3, "languages": ["en", "es"]}}
logger.info("Replaying from run_ocr with overrides: %s", overrides)
replay_result = replay_from(
job_id=args.job,
start_stage="run_ocr",
config_overrides=overrides,
)
replay_detections = replay_result.get("detections", [])
replay_report = replay_result.get("report")
logger.info("Replay run: %d detections, %d brands",
len(replay_detections),
len(replay_report.brands) if replay_report else 0)
# --- Compare ---
logger.info("--- Comparison ---")
logger.info("Initial: %d detections", len(detections))
logger.info("Replay: %d detections (min_confidence 0.5 → 0.3)", len(replay_detections))
diff = len(replay_detections) - len(detections)
if diff > 0:
logger.info("Replay found %d more detections with lower threshold", diff)
elif diff == 0:
logger.info("Same count — threshold change didn't affect this video")
else:
logger.warning("Replay found fewer detections — unexpected")
logger.info("Done.")
if __name__ == "__main__":
main()

View File

@@ -1,20 +1,13 @@
"""Tests for BrandResolver stage."""
"""Tests for BrandResolver stage (discovery mode)."""
import numpy as np
import pytest
from detect.models import BoundingBox, Frame, TextCandidate
from detect.profiles.base import BrandDictionary, ResolverConfig
from detect.stages.brand_resolver import resolve_brands, _exact_match, _fuzzy_match
from detect.profiles.base import ResolverConfig
from detect.stages.brand_resolver import resolve_brands, _normalize, _match_session
DICTIONARY = BrandDictionary(brands={
"Nike": ["nike", "NIKE", "swoosh"],
"Adidas": ["adidas", "ADIDAS"],
"Coca-Cola": ["coca-cola", "coca cola", "coke", "COCA-COLA"],
"Emirates": ["emirates", "fly emirates", "EMIRATES"],
})
CONFIG = ResolverConfig(fuzzy_threshold=75)
@@ -25,57 +18,76 @@ def _make_candidate(text: str, confidence: float = 0.9) -> TextCandidate:
return TextCandidate(frame=dummy_frame, bbox=dummy_box, text=text, ocr_confidence=confidence)
def test_exact_match():
assert _exact_match("Nike", DICTIONARY) == "Nike"
assert _exact_match("nike", DICTIONARY) == "Nike"
assert _exact_match("COCA-COLA", DICTIONARY) == "Coca-Cola"
assert _exact_match("fly emirates", DICTIONARY) == "Emirates"
assert _exact_match("unknown brand", DICTIONARY) is None
def test_session_match():
session = {"nike": "Nike", "fly emirates": "Emirates"}
assert _match_session("Nike", session) == "Nike"
assert _match_session("nike", session) == "Nike"
assert _match_session("FLY EMIRATES", session) == "Emirates"
assert _match_session("unknown", session) is None
def test_fuzzy_match():
brand, score = _fuzzy_match("Nik3", DICTIONARY, threshold=75)
assert brand == "Nike"
assert score >= 75
def test_resolve_with_session(monkeypatch):
events = []
monkeypatch.setattr("detect.emit.push_detect_event",
lambda job_id, etype, data: events.append((etype, data)))
brand, score = _fuzzy_match("adldas", DICTIONARY, threshold=75)
assert brand == "Adidas"
brand, score = _fuzzy_match("xyzxyzxyz", DICTIONARY, threshold=75)
assert brand is None
def test_resolve_exact():
session = {"nike": "Nike", "emirates": "Emirates"}
candidates = [_make_candidate("Nike"), _make_candidate("EMIRATES")]
matched, unresolved = resolve_brands(candidates, DICTIONARY, CONFIG)
matched, unresolved = resolve_brands(
candidates, CONFIG, session_brands=session,
)
assert len(matched) == 2
assert len(unresolved) == 0
assert matched[0].brand == "Nike"
assert matched[1].brand == "Emirates"
def test_resolve_fuzzy():
candidates = [_make_candidate("coca coIa")] # OCR misread
matched, unresolved = resolve_brands(candidates, DICTIONARY, CONFIG)
assert len(matched) == 1
assert matched[0].brand == "Coca-Cola"
def test_resolve_unresolved_without_db(monkeypatch):
events = []
monkeypatch.setattr("detect.emit.push_detect_event",
lambda job_id, etype, data: events.append((etype, data)))
def test_resolve_unresolved():
candidates = [_make_candidate("random garbage text")]
matched, unresolved = resolve_brands(candidates, DICTIONARY, CONFIG)
matched, unresolved = resolve_brands(
candidates, CONFIG, session_brands={},
)
assert len(matched) == 0
assert len(unresolved) == 1
def test_resolve_mixed():
def test_resolve_empty(monkeypatch):
events = []
monkeypatch.setattr("detect.emit.push_detect_event",
lambda job_id, etype, data: events.append((etype, data)))
matched, unresolved = resolve_brands([], CONFIG, session_brands={})
assert len(matched) == 0
assert len(unresolved) == 0
def test_resolve_builds_session_during_run(monkeypatch):
"""Session brands accumulate during a single run — second candidate benefits."""
events = []
monkeypatch.setattr("detect.emit.push_detect_event",
lambda job_id, etype, data: events.append((etype, data)))
session = {"nike": "Nike"}
candidates = [
_make_candidate("Nike"),
_make_candidate("unknown"),
_make_candidate("adldas"),
_make_candidate("Nike"), # hits session
_make_candidate("unknown"), # misses everything
]
matched, unresolved = resolve_brands(candidates, DICTIONARY, CONFIG)
assert len(matched) == 2 # Nike exact + Adidas fuzzy
matched, unresolved = resolve_brands(
candidates, CONFIG, session_brands=session,
)
assert len(matched) == 1
assert matched[0].brand == "Nike"
assert len(unresolved) == 1
@@ -84,8 +96,10 @@ def test_events_emitted(monkeypatch):
monkeypatch.setattr("detect.emit.push_detect_event",
lambda job_id, etype, data: events.append((etype, data)))
session = {"nike": "Nike"}
candidates = [_make_candidate("Nike")]
resolve_brands(candidates, DICTIONARY, CONFIG, job_id="test-job")
resolve_brands(candidates, CONFIG, session_brands=session, job_id="test-job")
event_types = [e[0] for e in events]
assert "log" in event_types

View File

@@ -0,0 +1,182 @@
"""Tests for checkpoint serialization — round-trip without S3."""
import numpy as np
import pytest
from detect.models import BoundingBox, BrandDetection, Frame, PipelineStats, TextCandidate
from detect.checkpoint.serializer import (
serialize_state,
deserialize_state,
serialize_frame_meta,
serialize_text_candidate,
deserialize_text_candidate,
)
def _make_frame(seq: int = 0, w: int = 100, h: int = 80) -> Frame:
image = np.random.randint(0, 255, (h, w, 3), dtype=np.uint8)
return Frame(
sequence=seq,
chunk_id=0,
timestamp=float(seq) * 0.5,
image=image,
perceptual_hash=f"hash_{seq}",
)
def _make_box(x=10, y=10, w=30, h=20) -> BoundingBox:
return BoundingBox(x=x, y=y, w=w, h=h, confidence=0.9, label="text")
def _make_candidate(frame: Frame, text: str = "NIKE") -> TextCandidate:
box = _make_box()
return TextCandidate(frame=frame, bbox=box, text=text, ocr_confidence=0.85)
def _make_detection(brand: str = "Nike", timestamp: float = 1.0) -> BrandDetection:
return BrandDetection(
brand=brand,
timestamp=timestamp,
duration=0.5,
confidence=0.92,
source="ocr",
content_type="soccer_broadcast",
frame_ref=0,
)
# --- Frame metadata ---
def test_serialize_frame_meta():
frame = _make_frame(seq=5)
meta = serialize_frame_meta(frame)
assert meta["sequence"] == 5
assert meta["timestamp"] == 2.5
assert meta["perceptual_hash"] == "hash_5"
assert "image" not in meta
# --- TextCandidate ---
def test_serialize_text_candidate():
frame = _make_frame()
candidate = _make_candidate(frame, text="EMIRATES")
data = serialize_text_candidate(candidate)
assert data["frame_sequence"] == 0
assert data["text"] == "EMIRATES"
assert data["ocr_confidence"] == 0.85
assert "bbox" in data
def test_deserialize_text_candidate():
frame = _make_frame()
candidate = _make_candidate(frame, text="ADIDAS")
serialized = serialize_text_candidate(candidate)
frame_map = {frame.sequence: frame}
restored = deserialize_text_candidate(serialized, frame_map)
assert restored.text == "ADIDAS"
assert restored.ocr_confidence == 0.85
assert restored.frame is frame # same object reference
assert restored.bbox.x == 10
# --- Full state round-trip ---
def test_state_round_trip():
frames = [_make_frame(seq=i) for i in range(3)]
filtered = frames[:2]
box = _make_box()
boxes_by_frame = {0: [box], 1: [box]}
candidates = [_make_candidate(frames[0], "NIKE"), _make_candidate(frames[1], "EMIRATES")]
unresolved = [_make_candidate(frames[2], "unknown")]
detections = [_make_detection("Nike", 0.5), _make_detection("Emirates", 1.0)]
stats = PipelineStats(
frames_extracted=3,
frames_after_scene_filter=2,
regions_detected=2,
regions_resolved_by_ocr=2,
cloud_llm_calls=1,
estimated_cloud_cost_usd=0.003,
)
state = {
"job_id": "test-123",
"video_path": "/tmp/test.mp4",
"profile_name": "soccer_broadcast",
"config_overrides": {"ocr": {"min_confidence": 0.3}},
"frames": frames,
"filtered_frames": filtered,
"boxes_by_frame": boxes_by_frame,
"text_candidates": candidates,
"unresolved_candidates": unresolved,
"detections": detections,
"stats": stats,
}
manifest = {f.sequence: f"s3://fake/frames/{f.sequence}.jpg" for f in frames}
# Serialize
serialized = serialize_state(state, manifest)
# Verify JSON-compatible (no numpy, no Frame objects)
import json
json_str = json.dumps(serialized, default=str)
assert len(json_str) > 0
# Deserialize with the original frames (simulating frame load from S3)
restored = deserialize_state(serialized, frames)
# Verify round-trip
assert restored["job_id"] == "test-123"
assert restored["video_path"] == "/tmp/test.mp4"
assert restored["profile_name"] == "soccer_broadcast"
assert restored["config_overrides"] == {"ocr": {"min_confidence": 0.3}}
assert len(restored["frames"]) == 3
assert len(restored["filtered_frames"]) == 2
assert len(restored["boxes_by_frame"]) == 2
assert len(restored["text_candidates"]) == 2
assert len(restored["unresolved_candidates"]) == 1
assert len(restored["detections"]) == 2
restored_stats = restored["stats"]
assert restored_stats.frames_extracted == 3
assert restored_stats.cloud_llm_calls == 1
assert restored_stats.estimated_cloud_cost_usd == 0.003
# TextCandidate frame references should point to actual Frame objects
tc = restored["text_candidates"][0]
assert tc.frame is frames[0]
assert tc.text == "NIKE"
def test_state_round_trip_empty():
"""Empty state should serialize/deserialize cleanly."""
state = {
"job_id": "empty-job",
"video_path": "",
"profile_name": "soccer_broadcast",
"frames": [],
"filtered_frames": [],
"boxes_by_frame": {},
"text_candidates": [],
"unresolved_candidates": [],
"detections": [],
"stats": PipelineStats(),
}
serialized = serialize_state(state, {})
restored = deserialize_state(serialized, [])
assert restored["job_id"] == "empty-job"
assert len(restored["frames"]) == 0
assert len(restored["detections"]) == 0
assert restored["stats"].frames_extracted == 0

View File

@@ -25,11 +25,9 @@ def test_soccer_detection_config():
assert isinstance(cfg.target_classes, list)
def test_soccer_brand_dictionary_non_empty():
bd = SoccerBroadcastProfile().brand_dictionary()
assert len(bd.brands) > 0
for canonical, aliases in bd.brands.items():
assert len(aliases) > 0
def test_soccer_resolver_config():
cfg = SoccerBroadcastProfile().resolver_config()
assert cfg.fuzzy_threshold > 0
def test_soccer_vlm_prompt():
@@ -70,4 +68,4 @@ def test_stubs_raise(stub_cls):
with pytest.raises(NotImplementedError):
stub.frame_extraction_config()
with pytest.raises(NotImplementedError):
stub.brand_dictionary()
stub.resolver_config()

View File

@@ -0,0 +1,67 @@
"""Tests for replay and OverrideProfile."""
import pytest
from detect.profiles.soccer import SoccerBroadcastProfile
from detect.checkpoint.replay import OverrideProfile
def test_override_profile_patches_ocr():
base = SoccerBroadcastProfile()
overrides = {"ocr": {"min_confidence": 0.3, "languages": ["en", "es", "pt"]}}
profile = OverrideProfile(base, overrides)
config = profile.ocr_config()
assert config.min_confidence == 0.3
assert config.languages == ["en", "es", "pt"]
def test_override_profile_patches_resolver():
base = SoccerBroadcastProfile()
overrides = {"resolver": {"fuzzy_threshold": 60}}
profile = OverrideProfile(base, overrides)
config = profile.resolver_config()
assert config.fuzzy_threshold == 60
def test_override_profile_patches_detection():
base = SoccerBroadcastProfile()
overrides = {"detection": {"confidence_threshold": 0.5}}
profile = OverrideProfile(base, overrides)
config = profile.detection_config()
assert config.confidence_threshold == 0.5
def test_override_profile_no_overrides():
base = SoccerBroadcastProfile()
profile = OverrideProfile(base, {})
ocr = profile.ocr_config()
base_ocr = base.ocr_config()
assert ocr.min_confidence == base_ocr.min_confidence
assert ocr.languages == base_ocr.languages
def test_override_profile_delegates_non_config():
base = SoccerBroadcastProfile()
profile = OverrideProfile(base, {"ocr": {"min_confidence": 0.1}})
assert profile.name == "soccer_broadcast"
assert profile.resolver_config().fuzzy_threshold > 0
def test_override_profile_ignores_unknown_fields():
base = SoccerBroadcastProfile()
overrides = {"ocr": {"nonexistent_field": 42}}
profile = OverrideProfile(base, overrides)
config = profile.ocr_config()
assert not hasattr(config, "nonexistent_field")
assert config.min_confidence == base.ocr_config().min_confidence