perf: throttle tracker to every 2nd frame, sup interval 10, 720p output option
- detect_interval=2 on both pipelines; stabilizer 10-frame hold bridges skipped frames (GPU inference cut ~half during active batch) - sup_det_interval 5 -> 10 (still <= stabilizer hold / synthetic max_age) - preview_every_n default 2 -> 5 - AnnotatedVideoWriter: codec auto-chain gstreamer_nvenc -> avc1 -> mp4v, max_height downscale (even dims, INTER_AREA); backend logged - output_max_height plumbed Job -> web checkbox (720p) -> CLI --output-height - README: 720p option, CLI flag - 153 tests pass (+13)
This commit is contained in:
1 parent
ad41bf01e1
commit
07289a419a
10 files changed
+345
-19
No files matched your search
@@ -16,6 +16,7 @@ Built on top of [karung_counter_semarang](https://git.proit.id/andrew/karung-cou
|
||||
- **Sack/Box Counts**: Separate in/out counts per object class in results
|
||||
- **Video Reuse**: Re-analyze previously uploaded videos without re-uploading
|
||||
- **Annotated Output**: Download MP4 videos with detection overlays for human review
|
||||
- **720p Output Option**: Downscale annotated MP4 to 720p for smaller files / faster encode
|
||||
- **Async Processing**: Background job queue — upload and poll status
|
||||
- **Abort**: Cancel running jobs mid-processing
|
||||
|
||||
@@ -47,6 +48,7 @@ recounter --video PATH Input video file
|
||||
--truck-conf FLOAT Truck confidence threshold (default: 0.5)
|
||||
--output PATH Output path (single model only)
|
||||
--output-dir DIR Output directory (default: ./output)
|
||||
--output-height INT Downscale annotated output to this height (e.g. 720)
|
||||
--models-dir DIR Models directory (default: ./models)
|
||||
```
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ def upload():
|
||||
n += 1
|
||||
video.save(video_path)
|
||||
|
||||
output_height = request.form.get("output_height", "")
|
||||
zone_mode = request.form.get("zone_mode", "dynamic")
|
||||
fixed_zone = None
|
||||
zone_preset = ""
|
||||
@@ -151,6 +152,7 @@ def upload():
|
||||
zone_mode=zone_mode,
|
||||
fixed_zone=fixed_zone,
|
||||
zone_preset=zone_preset,
|
||||
output_max_height=(int(output_height) if output_height else None),
|
||||
)
|
||||
return redirect(url_for("status", job_id=job.job_id))
|
||||
|
||||
@@ -166,6 +168,7 @@ def upload_reuse():
|
||||
if not model_configs:
|
||||
return "No models selected", 400
|
||||
|
||||
output_height = request.form.get("output_height", "")
|
||||
zone_mode = request.form.get("zone_mode", "dynamic")
|
||||
fixed_zone = None
|
||||
zone_preset = ""
|
||||
@@ -188,6 +191,7 @@ def upload_reuse():
|
||||
zone_mode=zone_mode,
|
||||
fixed_zone=fixed_zone,
|
||||
zone_preset=zone_preset,
|
||||
output_max_height=(int(output_height) if output_height else None),
|
||||
)
|
||||
return redirect(url_for("status", job_id=job.job_id))
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
help="Output path (honored only for single-model runs)")
|
||||
parser.add_argument("--output-dir", type=str, default="./output",
|
||||
help="Output directory for annotated videos")
|
||||
parser.add_argument("--output-height", type=int, default=None,
|
||||
help="Downscale annotated output to this height (e.g. 720)")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
@@ -115,6 +117,7 @@ def main(argv: list[str] | None = None) -> None:
|
||||
class_filter=class_filter,
|
||||
sack_conf=args.sack_conf,
|
||||
truck_conf=args.truck_conf,
|
||||
output_max_height=args.output_height,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
print()
|
||||
|
||||
+7
-2
@@ -68,6 +68,7 @@ class Job:
|
||||
zone_mode: str = "dynamic"
|
||||
fixed_zone: TruckROI | None = None
|
||||
zone_preset: str = ""
|
||||
output_max_height: int | None = None
|
||||
|
||||
|
||||
class JobQueue:
|
||||
@@ -88,6 +89,7 @@ class JobQueue:
|
||||
zone_mode: str = "dynamic",
|
||||
fixed_zone: TruckROI | None = None,
|
||||
zone_preset: str = "",
|
||||
output_max_height: int | None = None,
|
||||
) -> Job:
|
||||
"""Create a new job and enqueue it. Returns the Job (processing starts immediately)."""
|
||||
job_id = f"job-{uuid.uuid4().hex[:8]}"
|
||||
@@ -100,6 +102,7 @@ class JobQueue:
|
||||
zone_mode=zone_mode,
|
||||
fixed_zone=fixed_zone,
|
||||
zone_preset=zone_preset,
|
||||
output_max_height=output_max_height,
|
||||
)
|
||||
Path(job.output_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -212,7 +215,8 @@ class JobQueue:
|
||||
freeze_roi=(job.zone_mode == "freeze"),
|
||||
preview_path=job.preview_path,
|
||||
preview_enabled=job.preview_enabled,
|
||||
preview_every_n=2,
|
||||
preview_every_n=5,
|
||||
output_max_height=job.output_max_height,
|
||||
cancel_check=lambda: job.status == JobStatus.CANCELLED,
|
||||
live_stats_callback=_on_live_stats,
|
||||
)
|
||||
@@ -260,7 +264,8 @@ class JobQueue:
|
||||
freeze_roi=(job.zone_mode == "freeze"),
|
||||
preview_path=job.preview_path,
|
||||
preview_enabled=job.preview_enabled,
|
||||
preview_every_n=2,
|
||||
preview_every_n=5,
|
||||
output_max_height=job.output_max_height,
|
||||
cancel_check=lambda: job.status == JobStatus.CANCELLED,
|
||||
live_stats_callback=_on_live_stats_merged,
|
||||
)
|
||||
|
||||
+25
-8
@@ -100,16 +100,18 @@ def run_pipeline(
|
||||
sack_conf: float = 0.4,
|
||||
truck_conf: float = 0.5,
|
||||
truck_det_interval: int = 15,
|
||||
detect_interval: int = 2,
|
||||
progress_callback: Callable[[int, int], None] | None = None,
|
||||
frame_callback: Callable[[bytes], None] | None = None,
|
||||
cancel_check: Callable[[], bool] | None = None,
|
||||
preview_enabled: bool = True,
|
||||
preview_path: str | None = None,
|
||||
preview_every_n: int = 2,
|
||||
preview_every_n: int = 5,
|
||||
preview_max_dim: int = 480,
|
||||
preview_jpeg_quality: int = 60,
|
||||
live_stats_callback: Callable[[dict], None] | None = None,
|
||||
freeze_roi: bool = False,
|
||||
output_max_height: int | None = None,
|
||||
) -> PipelineResult:
|
||||
"""Process a video file through the counting pipeline.
|
||||
|
||||
@@ -121,17 +123,22 @@ def run_pipeline(
|
||||
sack_conf: Sack detection confidence threshold (default: 0.4).
|
||||
truck_conf: Truck detection confidence threshold (default: 0.5).
|
||||
truck_det_interval: Run truck detection every N frames.
|
||||
detect_interval: Run detection+tracking every N frames; BboxStabilizer's
|
||||
10-frame hold bridges skipped frames. 1 = every frame.
|
||||
progress_callback: Optional fn(frame_idx, total_frames) called per frame.
|
||||
frame_callback: Optional fn(jpeg_bytes) called every 10th frame (fallback).
|
||||
cancel_check: Optional fn() returning True to abort processing.
|
||||
preview_path: Optional file path for live preview frames (JPEG written atomically).
|
||||
preview_every_n: Write a preview frame every N frames (default 2).
|
||||
preview_every_n: Write a preview JPEG every N frames (default 5;
|
||||
~5fps at 25fps video, less CPU).
|
||||
preview_max_dim: Max dimension (w or h) for preview frames.
|
||||
preview_jpeg_quality: JPEG quality for preview frames (1-100).
|
||||
live_stats_callback: Optional fn(dict) called every 10 frames with
|
||||
{fps, loading, unloading, frame} for live UI stats.
|
||||
freeze_roi: Lock the auto-detected truck ROI after 3 consecutive
|
||||
detections (zone_mode "freeze"); False = plain auto-detect.
|
||||
output_max_height: If set and video is taller, annotated output is
|
||||
downscaled to this height (e.g. 720); None = native resolution.
|
||||
|
||||
Returns:
|
||||
PipelineResult with counting summary.
|
||||
@@ -205,7 +212,7 @@ def run_pipeline(
|
||||
batch_mgr.on_batch_end(on_batch_end)
|
||||
|
||||
try:
|
||||
writer = AnnotatedVideoWriter(output_path, fps=fps, frame_size=(w, h))
|
||||
writer = AnnotatedVideoWriter(output_path, fps=fps, frame_size=(w, h), max_height=output_max_height)
|
||||
_frame_times: list[float] = []
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
@@ -256,7 +263,7 @@ def run_pipeline(
|
||||
# Track → Stabilize → Count
|
||||
tracked_sacks: list[Detection] = []
|
||||
if batch_mgr.is_active:
|
||||
raw_tracked = tracker.update(frame, [])
|
||||
raw_tracked = tracker.update(frame, []) if frame_idx % detect_interval == 0 else []
|
||||
stable = stabilizer.update(raw_tracked)
|
||||
stable = apply_class_filter(stable, effective_filter)
|
||||
|
||||
@@ -410,20 +417,28 @@ def run_merged_pipeline(
|
||||
sack_conf: float = 0.4,
|
||||
truck_conf: float = 0.5,
|
||||
truck_det_interval: int = 15,
|
||||
detect_interval: int = 2,
|
||||
progress_callback: Callable[[int, int], None] | None = None,
|
||||
cancel_check: Callable[[], bool] | None = None,
|
||||
preview_path: str | None = None,
|
||||
preview_enabled: bool = True,
|
||||
preview_every_n: int = 2,
|
||||
preview_every_n: int = 5,
|
||||
preview_max_dim: int = 480,
|
||||
preview_jpeg_quality: int = 60,
|
||||
live_stats_callback: Callable[[dict], None] | None = None,
|
||||
freeze_roi: bool = False,
|
||||
output_max_height: int | None = None,
|
||||
) -> MergedPipelineResult:
|
||||
"""Run multiple models on the same video, merge detections into one output.
|
||||
|
||||
freeze_roi: Lock the auto-detected truck ROI after 3 consecutive
|
||||
detections (zone_mode "freeze"); False = plain auto-detect.
|
||||
detect_interval: Run detection+tracking every N frames; BboxStabilizer's
|
||||
10-frame hold bridges skipped frames. 1 = every frame.
|
||||
output_max_height: If set and video is taller, annotated output is
|
||||
downscaled to this height (e.g. 720); None = native resolution.
|
||||
preview_every_n: Write a preview JPEG every N frames (default 5;
|
||||
~5fps at 25fps video, less CPU).
|
||||
"""
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
@@ -478,13 +493,15 @@ def run_merged_pipeline(
|
||||
)
|
||||
batch_mgr = BatchLifecycleManager()
|
||||
dashboard = DashboardOverlay()
|
||||
writer = AnnotatedVideoWriter(output_path, fps=fps, frame_size=(w, h))
|
||||
writer = AnnotatedVideoWriter(output_path, fps=fps, frame_size=(w, h), max_height=output_max_height)
|
||||
|
||||
start_time = time.time()
|
||||
frame_idx = 0
|
||||
completed_batches = 0
|
||||
_frame_times: list[float] = []
|
||||
sup_det_interval = 5 # run supplementary models every N frames
|
||||
# Must stay <= stabilizer max_hold (10) and synthetic assigner max_age (10)
|
||||
# so supplementary dets are held between inferences.
|
||||
sup_det_interval = 10 # run supplementary models every N frames
|
||||
# Primary model's class filter (mirrors eff_filter above) — applied to
|
||||
# ByteTrack output so UI class checkboxes apply to the primary model too.
|
||||
primary_filter = (
|
||||
@@ -545,7 +562,7 @@ def run_merged_pipeline(
|
||||
all_detections: list[Detection] = []
|
||||
if batch_mgr.is_active:
|
||||
# Primary: tracker gives detections WITH track_id for counting
|
||||
primary = tracker.update(frame, [])
|
||||
primary = tracker.update(frame, []) if frame_idx % detect_interval == 0 else []
|
||||
primary = apply_class_filter(primary, primary_filter)
|
||||
all_detections.extend(primary)
|
||||
|
||||
|
||||
+75
-9
@@ -2,11 +2,25 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Tried in order when codec == "auto"; first backend that opens wins.
|
||||
# 1. Jetson NVENC via GStreamer (needs OpenCV built with GStreamer + nvv4l2h264enc)
|
||||
# 2. H.264 via default/FFmpeg backend ("avc1")
|
||||
# 3. MPEG-4 Part 2 ("mp4v") — always available
|
||||
_AUTO_BACKENDS = ("gstreamer_nvenc", "avc1", "mp4v")
|
||||
|
||||
|
||||
def _even(n: int) -> int:
|
||||
"""Round down to even — many encoders reject odd dimensions."""
|
||||
return n - n % 2
|
||||
|
||||
|
||||
class AnnotatedVideoWriter:
|
||||
"""Writes annotated frames to an MP4 file.
|
||||
@@ -14,8 +28,13 @@ class AnnotatedVideoWriter:
|
||||
Args:
|
||||
output_path: Destination .mp4 file path.
|
||||
fps: Frames per second for the output video.
|
||||
frame_size: (width, height) tuple.
|
||||
codec: FourCC codec string (default "mp4v").
|
||||
frame_size: (width, height) tuple of frames handed to write_frame.
|
||||
codec: FourCC string, or "auto" to try NVENC/H.264 then MPEG-4 fallback.
|
||||
max_height: If set, frames are downscaled so encoded height <= max_height.
|
||||
|
||||
Attributes:
|
||||
backend: Backend actually opened (e.g. "gstreamer_nvenc", "avc1", "mp4v").
|
||||
written_size: (width, height) actually encoded.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -23,23 +42,70 @@ class AnnotatedVideoWriter:
|
||||
output_path: str,
|
||||
fps: float,
|
||||
frame_size: tuple[int, int],
|
||||
codec: str = "mp4v",
|
||||
codec: str = "auto",
|
||||
max_height: int | None = None,
|
||||
) -> None:
|
||||
if fps <= 0:
|
||||
raise ValueError(f"fps must be > 0, got {fps}")
|
||||
if max_height is not None and max_height <= 0:
|
||||
raise ValueError(f"max_height must be > 0, got {max_height}")
|
||||
self._path = Path(output_path)
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._fps = float(fps)
|
||||
|
||||
w, h = int(frame_size[0]), int(frame_size[1])
|
||||
if max_height is not None and h > max_height:
|
||||
scale = max_height / h
|
||||
tw, th = round(w * scale), round(h * scale)
|
||||
self.written_size = (_even(tw), _even(th))
|
||||
else:
|
||||
self.written_size = (w, h)
|
||||
|
||||
chain = list(_AUTO_BACKENDS) if codec == "auto" else [codec]
|
||||
self.backend = ""
|
||||
for candidate in chain:
|
||||
if self._try_open(candidate):
|
||||
self.backend = candidate
|
||||
log.info("video writer backend %r -> %s", candidate, self._path)
|
||||
break
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Failed to open VideoWriter for {self._path} "
|
||||
f"(tried: {', '.join(chain)})"
|
||||
)
|
||||
|
||||
w, h = frame_size
|
||||
fourcc = cv2.VideoWriter_fourcc(*codec)
|
||||
self._writer = cv2.VideoWriter(str(self._path), fourcc, fps, (w, h))
|
||||
self._frame_count = 0
|
||||
|
||||
if not self._writer.isOpened():
|
||||
raise RuntimeError(f"Failed to open VideoWriter for {self._path}")
|
||||
def _try_open(self, backend: str) -> bool:
|
||||
"""Attempt one backend. Cleans up partial files from failed attempts."""
|
||||
existed = self._path.exists()
|
||||
writer = cv2.VideoWriter()
|
||||
try:
|
||||
if backend == "gstreamer_nvenc":
|
||||
pipeline = (
|
||||
"appsrc ! queue ! nvv4l2h264enc bitrate=8000000 "
|
||||
f'! h264parse ! qtmux ! filesink location="{self._path}"'
|
||||
)
|
||||
ok = writer.open(
|
||||
pipeline, cv2.CAP_GSTREAMER, -1, self._fps, self.written_size
|
||||
)
|
||||
else:
|
||||
fourcc = cv2.VideoWriter_fourcc(*(backend + " ")[:4])
|
||||
ok = writer.open(str(self._path), fourcc, self._fps, self.written_size)
|
||||
if ok and writer.isOpened():
|
||||
self._writer = writer
|
||||
return True
|
||||
except cv2.error as exc:
|
||||
log.debug("backend %r failed for %s: %s", backend, self._path, exc)
|
||||
writer.release()
|
||||
if not existed and self._path.exists():
|
||||
self._path.unlink()
|
||||
return False
|
||||
|
||||
def write_frame(self, frame: np.ndarray) -> None:
|
||||
"""Write one frame. Frame size must match constructor frame_size."""
|
||||
"""Write one frame. Resized to written_size if it does not match."""
|
||||
if frame.shape[1] != self.written_size[0] or frame.shape[0] != self.written_size[1]:
|
||||
frame = cv2.resize(frame, self.written_size, interpolation=cv2.INTER_AREA)
|
||||
self._writer.write(frame)
|
||||
self._frame_count += 1
|
||||
|
||||
|
||||
@@ -237,6 +237,17 @@
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* Output options */
|
||||
.output-height-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-neutral-600);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Submit area */
|
||||
.submit-area {
|
||||
margin-top: var(--space-8);
|
||||
@@ -480,6 +491,12 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# --- Output Options --- #}
|
||||
<label class="output-height-label">
|
||||
<input type="checkbox" name="output_height" value="720">
|
||||
Downscale output to 720p
|
||||
</label>
|
||||
|
||||
{# --- Submit --- #}
|
||||
<div class="submit-area">
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn" disabled>Start Analysis</button>
|
||||
|
||||
@@ -235,3 +235,128 @@ def test_run_job_merged_propagates_box_counts(tmp_path, monkeypatch):
|
||||
assert r.box_loading_count == 6
|
||||
assert r.box_unloading_count == 2
|
||||
assert r.box_net_count == 4
|
||||
|
||||
|
||||
# ── output_max_height plumbing ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_job_output_max_height_default_none():
|
||||
"""Job.output_max_height defaults to None."""
|
||||
job = Job(
|
||||
job_id="test-oh",
|
||||
video_path="/tmp/test.mp4",
|
||||
model_configs=[],
|
||||
output_dir="/tmp/output",
|
||||
)
|
||||
assert job.output_max_height is None
|
||||
|
||||
|
||||
def test_queue_add_job_output_max_height():
|
||||
"""add_job passes output_max_height through to the Job."""
|
||||
q = JobQueue(output_dir="/tmp/output")
|
||||
job = q.add_job(video_path="/tmp/test.mp4", model_configs=[],
|
||||
output_max_height=720)
|
||||
assert job.output_max_height == 720
|
||||
|
||||
|
||||
def test_queue_add_job_output_max_height_default():
|
||||
"""add_job without output_max_height keeps None."""
|
||||
q = JobQueue(output_dir="/tmp/output")
|
||||
job = q.add_job(video_path="/tmp/test.mp4", model_configs=[])
|
||||
assert job.output_max_height is None
|
||||
|
||||
|
||||
def _fake_single_job(tmp_path, output_max_height, monkeypatch):
|
||||
import src.job as job_mod
|
||||
from src.model_registry import ModelConfig
|
||||
from src.pipeline import PipelineResult
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_run_pipeline(**kwargs):
|
||||
seen.update(kwargs)
|
||||
return PipelineResult(
|
||||
output_path=str(tmp_path / "out.mp4"),
|
||||
frame_count=10,
|
||||
loading_count=1,
|
||||
unloading_count=0,
|
||||
batch_count=1,
|
||||
duration_seconds=1.0,
|
||||
model_name="a.pt",
|
||||
class_filter=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(job_mod, "run_pipeline", fake_run_pipeline)
|
||||
|
||||
q = JobQueue(output_dir=str(tmp_path))
|
||||
job = Job(
|
||||
job_id="job-oh-single",
|
||||
video_path="/tmp/test.mp4",
|
||||
model_configs=[
|
||||
ModelConfig(filename="a.pt", path=str(tmp_path / "a.pt"), stem="a",
|
||||
known_classes=["sack", "truck"])
|
||||
],
|
||||
output_dir=str(tmp_path / "job-oh-single"),
|
||||
output_max_height=output_max_height,
|
||||
)
|
||||
q._jobs[job.job_id] = job
|
||||
q._run_job(job.job_id)
|
||||
return job, seen
|
||||
|
||||
|
||||
def test_run_job_single_passes_output_max_height(tmp_path, monkeypatch):
|
||||
"""_run_job single branch forwards output_max_height=720 to run_pipeline."""
|
||||
job, seen = _fake_single_job(tmp_path, 720, monkeypatch)
|
||||
assert job.status == JobStatus.COMPLETED
|
||||
assert seen.get("output_max_height") == 720
|
||||
|
||||
|
||||
def test_run_job_single_output_max_height_none(tmp_path, monkeypatch):
|
||||
"""_run_job single branch forwards output_max_height=None."""
|
||||
job, seen = _fake_single_job(tmp_path, None, monkeypatch)
|
||||
assert job.status == JobStatus.COMPLETED
|
||||
assert seen.get("output_max_height") is None
|
||||
|
||||
|
||||
def test_run_job_merged_passes_output_max_height(tmp_path, monkeypatch):
|
||||
"""_run_job merged branch forwards output_max_height to run_merged_pipeline."""
|
||||
import src.job as job_mod
|
||||
from src.job import Job, JobQueue, JobStatus
|
||||
from src.model_registry import ModelConfig
|
||||
from src.pipeline import MergedPipelineResult
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_run_merged(**kwargs):
|
||||
seen.update(kwargs)
|
||||
return MergedPipelineResult(
|
||||
output_path=str(tmp_path / "merged.mp4"),
|
||||
frame_count=10,
|
||||
loading_count=3,
|
||||
unloading_count=1,
|
||||
batch_count=1,
|
||||
duration_seconds=1.0,
|
||||
model_names=["a.pt", "b.pt"],
|
||||
class_filter=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(job_mod, "run_merged_pipeline", fake_run_merged)
|
||||
|
||||
q = JobQueue(output_dir=str(tmp_path))
|
||||
job = Job(
|
||||
job_id="job-oh-merged",
|
||||
video_path="/tmp/test.mp4",
|
||||
model_configs=[
|
||||
ModelConfig(filename="a.pt", path=str(tmp_path / "a.pt"), stem="a",
|
||||
known_classes=["sack", "truck"]),
|
||||
ModelConfig(filename="b.pt", path=str(tmp_path / "b.pt"), stem="b",
|
||||
known_classes=["sack"]),
|
||||
],
|
||||
output_dir=str(tmp_path / "job-oh-merged"),
|
||||
output_max_height=720,
|
||||
)
|
||||
q._jobs[job.job_id] = job
|
||||
q._run_job(job.job_id)
|
||||
|
||||
assert job.status == JobStatus.COMPLETED
|
||||
assert seen.get("output_max_height") == 720
|
||||
@@ -327,3 +327,21 @@ def test_run_merged_pipeline_propagates_box_counts(tmp_path, monkeypatch):
|
||||
assert result.box_loading_count == 4
|
||||
assert result.box_unloading_count == 1
|
||||
assert result.box_net_count == 3
|
||||
|
||||
|
||||
# ── Perf/output tuning params (signature contracts) ────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fn_name", ["run_pipeline", "run_merged_pipeline"]
|
||||
)
|
||||
def test_pipeline_perf_params_signature(fn_name):
|
||||
"""Both runners expose detect_interval=2, output_max_height=None, preview_every_n=5."""
|
||||
import inspect
|
||||
|
||||
from src import pipeline as pipe_mod
|
||||
|
||||
sig = inspect.signature(getattr(pipe_mod, fn_name))
|
||||
assert sig.parameters["detect_interval"].default == 2
|
||||
assert sig.parameters["output_max_height"].default is None
|
||||
assert sig.parameters["preview_every_n"].default == 5
|
||||
@@ -6,6 +6,18 @@ import pytest
|
||||
from src.video_writer import AnnotatedVideoWriter
|
||||
|
||||
|
||||
def probe(path):
|
||||
cap = cv2.VideoCapture(str(path))
|
||||
assert cap.isOpened(), f"cannot reopen {path}"
|
||||
info = (
|
||||
int(cap.get(cv2.CAP_PROP_FRAME_COUNT)),
|
||||
int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
|
||||
int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
|
||||
)
|
||||
cap.release()
|
||||
return info
|
||||
|
||||
|
||||
def test_writer_creates_output_file(tmp_path):
|
||||
out = tmp_path / "test_output.mp4"
|
||||
writer = AnnotatedVideoWriter(str(out), fps=25.0, frame_size=(640, 480))
|
||||
@@ -37,3 +49,60 @@ def test_writer_close_idempotent(tmp_path):
|
||||
def test_writer_invalid_fps():
|
||||
with pytest.raises(ValueError):
|
||||
AnnotatedVideoWriter("/tmp/x.mp4", fps=0.0, frame_size=(640, 480))
|
||||
|
||||
|
||||
def test_max_height_downscale(tmp_path):
|
||||
out = tmp_path / "down.mp4"
|
||||
writer = AnnotatedVideoWriter(
|
||||
str(out), fps=25.0, frame_size=(640, 480), max_height=240
|
||||
)
|
||||
assert writer.written_size == (320, 240)
|
||||
for _ in range(3):
|
||||
writer.write_frame(np.zeros((480, 640, 3), dtype=np.uint8))
|
||||
writer.finish()
|
||||
assert out.exists() and out.stat().st_size > 0
|
||||
count, w, h = probe(out)
|
||||
assert count == 3
|
||||
assert (w, h) == writer.written_size
|
||||
|
||||
|
||||
def test_no_max_height_keeps_native_size(tmp_path):
|
||||
out = tmp_path / "native.mp4"
|
||||
writer = AnnotatedVideoWriter(str(out), fps=25.0, frame_size=(640, 480))
|
||||
assert writer.written_size == (640, 480)
|
||||
writer.write_frame(np.zeros((480, 640, 3), dtype=np.uint8))
|
||||
writer.finish()
|
||||
assert probe(out)[1:] == (640, 480)
|
||||
|
||||
|
||||
def test_even_dimensions_after_downscale(tmp_path):
|
||||
out = tmp_path / "even.mp4"
|
||||
writer = AnnotatedVideoWriter(
|
||||
str(out), fps=25.0, frame_size=(641, 481), max_height=240
|
||||
)
|
||||
tw, th = writer.written_size
|
||||
assert tw % 2 == 0 and th % 2 == 0
|
||||
assert th <= 240
|
||||
writer.write_frame(np.zeros((481, 641, 3), dtype=np.uint8))
|
||||
writer.finish()
|
||||
assert probe(out)[1:] == writer.written_size
|
||||
|
||||
|
||||
def test_auto_codec_records_backend(tmp_path):
|
||||
out = tmp_path / "auto.mp4"
|
||||
writer = AnnotatedVideoWriter(str(out), fps=25.0, frame_size=(640, 480))
|
||||
assert isinstance(writer.backend, str)
|
||||
assert writer.backend
|
||||
writer.write_frame(np.zeros((480, 640, 3), dtype=np.uint8))
|
||||
writer.finish()
|
||||
|
||||
|
||||
def test_explicit_codec_forces_fourcc(tmp_path):
|
||||
out = tmp_path / "explicit.mp4"
|
||||
writer = AnnotatedVideoWriter(
|
||||
str(out), fps=25.0, frame_size=(640, 480), codec="mp4v"
|
||||
)
|
||||
assert writer.backend == "mp4v"
|
||||
writer.write_frame(np.zeros((480, 640, 3), dtype=np.uint8))
|
||||
writer.finish()
|
||||
assert out.exists() and out.stat().st_size > 0
|
||||
Reference in new issue
Block a user