phase 10
This commit is contained in:
@@ -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...")
|
||||
|
||||
|
||||
123
tests/detect/manual/test_replay.py
Normal file
123
tests/detect/manual/test_replay.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
182
tests/detect/test_checkpoint.py
Normal file
182
tests/detect/test_checkpoint.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
67
tests/detect/test_replay.py
Normal file
67
tests/detect/test_replay.py
Normal 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
|
||||
Reference in New Issue
Block a user