phase 4
This commit is contained in:
@@ -10,12 +10,16 @@ Opens: http://mpr.local.ar/detection/?job=<JOB_ID>
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import redis
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(name)s — %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STAGES = ["FrameExtractor", "SceneFilter", "YOLODetector", "OCRStage", "BrandResolver", "VLMLocal", "Aggregator"]
|
||||
LEVELS = ["INFO", "INFO", "INFO", "INFO", "WARNING", "DEBUG", "ERROR"] # weighted toward INFO
|
||||
MESSAGES = {
|
||||
@@ -70,9 +74,9 @@ def main():
|
||||
r = redis.Redis(port=args.port, decode_responses=True)
|
||||
key = f"detect_events:{args.job}"
|
||||
|
||||
print(f"Pushing {args.count} log events to {key} (redis port {args.port})")
|
||||
print(f"Open: http://mpr.local.ar/detection/?job={args.job}")
|
||||
print()
|
||||
logger.info("Pushing %d log events to %s (redis port %d)", args.count, key, args.port)
|
||||
logger.info("Open: http://mpr.local.ar/detection/?job=%s", args.job)
|
||||
input("\nPress Enter to start...")
|
||||
|
||||
for i in range(args.count):
|
||||
stage = random.choice(STAGES)
|
||||
@@ -88,10 +92,10 @@ def main():
|
||||
}
|
||||
|
||||
r.rpush(key, json.dumps(event))
|
||||
print(f" {level:7s} {stage:16s} {msg[:60]}")
|
||||
logger.log(getattr(logging, level, logging.INFO), "[%s] %s", stage, msg)
|
||||
time.sleep(args.delay)
|
||||
|
||||
print(f"\nDone. {args.count} events pushed.")
|
||||
logger.info("Done. %d events pushed.", args.count)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -10,11 +10,15 @@ Opens: http://mpr.local.ar/detection/?job=<JOB_ID>
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import redis
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(name)s — %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ts():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@@ -25,7 +29,7 @@ def push(r, key, event):
|
||||
r.rpush(key, json.dumps(event))
|
||||
etype = event["event"]
|
||||
detail = event.get("msg", event.get("stage", ""))
|
||||
print(f" [{etype:14s}] {detail}")
|
||||
logger.info("[%s] %s", etype, detail)
|
||||
return event
|
||||
|
||||
|
||||
@@ -39,12 +43,11 @@ def main():
|
||||
r = redis.Redis(port=args.port, decode_responses=True)
|
||||
key = f"detect_events:{args.job}"
|
||||
|
||||
# Clear previous events for this job
|
||||
r.delete(key)
|
||||
|
||||
print(f"Simulating pipeline run → {key}")
|
||||
print(f"Open: http://mpr.local.ar/detection/?job={args.job}")
|
||||
print()
|
||||
logger.info("Simulating pipeline run → %s", key)
|
||||
logger.info("Open: http://mpr.local.ar/detection/?job=%s", args.job)
|
||||
input("\nPress Enter to start...")
|
||||
|
||||
delay = args.delay
|
||||
|
||||
@@ -171,7 +174,7 @@ def main():
|
||||
},
|
||||
}})
|
||||
|
||||
print(f"\nPipeline simulation complete.")
|
||||
logger.info("Pipeline simulation complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
60
tests/detect/manual/run_extract_filter.py
Normal file
60
tests/detect/manual/run_extract_filter.py
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Run FrameExtractor → SceneFilter on a real video and push events to Redis.
|
||||
|
||||
Usage:
|
||||
python tests/detect/manual/run_extract_filter.py [--job JOB_ID] [--port PORT]
|
||||
|
||||
Opens: http://mpr.local.ar/detection/?job=<JOB_ID>
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Parse args early so we can set REDIS_URL before imports
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--job", default="extract-filter-test")
|
||||
parser.add_argument("--port", type=int, default=6382)
|
||||
args = parser.parse_args()
|
||||
|
||||
os.environ["REDIS_URL"] = f"redis://localhost:{args.port}/0"
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(name)s — %(message)s")
|
||||
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
from detect.profiles.soccer import SoccerBroadcastProfile
|
||||
from detect.stages.frame_extractor import extract_frames
|
||||
from detect.stages.scene_filter import scene_filter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VIDEO = "media/out/chunks/95043d50-4df6-4ac8-bbd5-2ba873117c6e/chunk_0000.mp4"
|
||||
|
||||
|
||||
def main():
|
||||
profile = SoccerBroadcastProfile()
|
||||
|
||||
logger.info("Job: %s", args.job)
|
||||
logger.info("Open: http://mpr.local.ar/detection/?job=%s", args.job)
|
||||
input("\nPress Enter to start...")
|
||||
|
||||
# Stage 1: Extract frames
|
||||
extract_config = profile.frame_extraction_config()
|
||||
extract_config.max_frames = 30
|
||||
logger.info("Extracting frames (fps=%s, max=%d)...", extract_config.fps, extract_config.max_frames)
|
||||
frames = extract_frames(VIDEO, extract_config, job_id=args.job)
|
||||
logger.info(" → %d frames extracted", len(frames))
|
||||
|
||||
# Stage 2: Scene filter
|
||||
filter_config = profile.scene_filter_config()
|
||||
logger.info("Filtering scenes (hamming_threshold=%d)...", filter_config.hamming_threshold)
|
||||
kept = scene_filter(frames, filter_config, job_id=args.job)
|
||||
logger.info(" → %d frames kept (%d dropped)", len(kept), len(frames) - len(kept))
|
||||
|
||||
logger.info("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
34
tests/detect/manual/test_frame_extractor_e2e.py
Normal file
34
tests/detect/manual/test_frame_extractor_e2e.py
Normal file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end test: run FrameExtractor and verify SSE events are emitted.
|
||||
|
||||
Usage:
|
||||
python tests/detect/manual/test_frame_extractor_e2e.py
|
||||
|
||||
Requires Redis running. Events appear at: http://mpr.local.ar/detection/?job=e2e-test
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
from detect.profiles.soccer import SoccerBroadcastProfile
|
||||
from detect.stages.frame_extractor import extract_frames
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VIDEO = "media/out/chunks/95043d50-4df6-4ac8-bbd5-2ba873117c6e/chunk_0000.mp4"
|
||||
JOB_ID = "e2e-test"
|
||||
|
||||
profile = SoccerBroadcastProfile()
|
||||
config = profile.frame_extraction_config()
|
||||
config.max_frames = 20
|
||||
|
||||
logger.info("Extracting frames from %s (fps=%s, max=%d)", VIDEO, config.fps, config.max_frames)
|
||||
logger.info("Open: http://mpr.local.ar/detection/?job=%s", JOB_ID)
|
||||
input("\nPress Enter to start...")
|
||||
|
||||
frames = extract_frames(VIDEO, config, job_id=JOB_ID)
|
||||
logger.info("Done: %d frames extracted", len(frames))
|
||||
logger.info("Open http://mpr.local.ar/detection/?job=%s to see the events", JOB_ID)
|
||||
@@ -61,7 +61,7 @@ def test_extract_frames_with_events(monkeypatch):
|
||||
def mock_push(job_id, event_type, data):
|
||||
events.append((job_id, event_type, data))
|
||||
|
||||
monkeypatch.setattr("detect.stages.frame_extractor.push_detect_event", mock_push)
|
||||
monkeypatch.setattr("detect.emit.push_detect_event", mock_push)
|
||||
|
||||
video = _get_sample_video()
|
||||
config = FrameExtractionConfig(fps=1, max_frames=5)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""
|
||||
End-to-end test: run FrameExtractor and verify SSE events are emitted.
|
||||
|
||||
Usage (manual):
|
||||
python tests/detect/test_frame_extractor_e2e.py
|
||||
|
||||
Requires Redis running on localhost:6381.
|
||||
Push events will appear at: http://mpr.local.ar/detection/?job=e2e-test
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
from detect.profiles.soccer import SoccerBroadcastProfile
|
||||
from detect.stages.frame_extractor import extract_frames
|
||||
|
||||
VIDEO = "media/out/chunks/95043d50-4df6-4ac8-bbd5-2ba873117c6e/chunk_0000.mp4"
|
||||
JOB_ID = "e2e-test"
|
||||
|
||||
profile = SoccerBroadcastProfile()
|
||||
config = profile.frame_extraction_config()
|
||||
config.max_frames = 20 # keep it quick
|
||||
|
||||
print(f"Extracting frames from {VIDEO} (fps={config.fps}, max={config.max_frames})")
|
||||
frames = extract_frames(VIDEO, config, job_id=JOB_ID)
|
||||
print(f"Done: {len(frames)} frames extracted")
|
||||
print(f"Open http://mpr.local.ar/detection/?job={JOB_ID} to see the events")
|
||||
84
tests/detect/test_scene_filter.py
Normal file
84
tests/detect/test_scene_filter.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Tests for SceneFilter stage."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from detect.models import Frame
|
||||
from detect.profiles.base import SceneFilterConfig
|
||||
from detect.stages.scene_filter import scene_filter
|
||||
|
||||
|
||||
def _make_frame(seq: int, color: tuple[int, int, int] = (128, 128, 128)) -> Frame:
|
||||
"""Create a solid-color test frame."""
|
||||
img = np.full((64, 64, 3), color, dtype=np.uint8)
|
||||
return Frame(sequence=seq, chunk_id=0, timestamp=seq * 0.5, image=img)
|
||||
|
||||
|
||||
def test_identical_frames_deduped():
|
||||
frames = [_make_frame(i) for i in range(10)]
|
||||
config = SceneFilterConfig(hamming_threshold=8)
|
||||
kept = scene_filter(frames, config)
|
||||
|
||||
# All identical → only first survives
|
||||
assert len(kept) == 1
|
||||
assert kept[0].sequence == 0
|
||||
|
||||
|
||||
def _make_noisy_frame(seq: int, seed: int) -> Frame:
|
||||
"""Create a frame with random noise — each is visually unique."""
|
||||
rng = np.random.RandomState(seed)
|
||||
img = rng.randint(0, 256, (64, 64, 3), dtype=np.uint8)
|
||||
return Frame(sequence=seq, chunk_id=0, timestamp=seq * 0.5, image=img)
|
||||
|
||||
|
||||
def test_different_frames_kept():
|
||||
frames = [_make_noisy_frame(i, seed=i * 1000) for i in range(5)]
|
||||
config = SceneFilterConfig(hamming_threshold=8)
|
||||
kept = scene_filter(frames, config)
|
||||
|
||||
# Random noise frames are visually distinct → most should survive
|
||||
assert len(kept) >= 3
|
||||
|
||||
|
||||
def test_disabled_passes_all():
|
||||
frames = [_make_frame(i) for i in range(5)]
|
||||
config = SceneFilterConfig(enabled=False)
|
||||
kept = scene_filter(frames, config)
|
||||
|
||||
assert len(kept) == 5
|
||||
|
||||
|
||||
def test_empty_input():
|
||||
config = SceneFilterConfig(hamming_threshold=8)
|
||||
kept = scene_filter([], config)
|
||||
assert kept == []
|
||||
|
||||
|
||||
def test_single_frame():
|
||||
frames = [_make_frame(0)]
|
||||
config = SceneFilterConfig(hamming_threshold=8)
|
||||
kept = scene_filter(frames, config)
|
||||
assert len(kept) == 1
|
||||
|
||||
|
||||
def test_hashes_populated():
|
||||
frames = [_make_frame(i, color=(i * 50, 100, 200)) for i in range(3)]
|
||||
config = SceneFilterConfig(hamming_threshold=8)
|
||||
scene_filter(frames, config)
|
||||
|
||||
for f in frames:
|
||||
assert f.perceptual_hash != ""
|
||||
|
||||
|
||||
def test_events_emitted(monkeypatch):
|
||||
events = []
|
||||
monkeypatch.setattr("detect.emit.push_detect_event",
|
||||
lambda job_id, etype, data: events.append((etype, data)))
|
||||
|
||||
frames = [_make_frame(i) for i in range(5)]
|
||||
config = SceneFilterConfig(hamming_threshold=8)
|
||||
scene_filter(frames, config, job_id="test-job")
|
||||
|
||||
event_types = [e[0] for e in events]
|
||||
assert "log" in event_types
|
||||
assert "stats_update" in event_types
|
||||
Reference in New Issue
Block a user