From 07289a419a8256a113a34b188bcf96a8f6e93bb6 Mon Sep 17 00:00:00 2001 From: jetson Date: Tue, 29 Sep 2026 15:58:55 +0700 Subject: [PATCH] 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) --- README.md | 2 + app.py | 4 ++ cli.py | 3 + src/job.py | 9 ++- src/pipeline.py | 33 +++++++--- src/video_writer.py | 84 ++++++++++++++++++++++--- templates/index.html | 17 +++++ tests/test_job.py | 125 +++++++++++++++++++++++++++++++++++++ tests/test_pipeline.py | 18 ++++++ tests/test_video_writer.py | 69 ++++++++++++++++++++ 10 files changed, 345 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 49a2f9d..56bdcc4 100644 --- a/README.md +++ b/README.md @@ -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) ``` diff --git a/app.py b/app.py index 92d5cf9..41062ef 100644 --- a/app.py +++ b/app.py @@ -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)) diff --git a/cli.py b/cli.py index 20464bf..52eaf44 100644 --- a/cli.py +++ b/cli.py @@ -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() diff --git a/src/job.py b/src/job.py index 6abda6c..48efe5a 100644 --- a/src/job.py +++ b/src/job.py @@ -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, ) diff --git a/src/pipeline.py b/src/pipeline.py index 5fa4354..e6a377d 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -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) diff --git a/src/video_writer.py b/src/video_writer.py index 2e2fe40..4a89f87 100644 --- a/src/video_writer.py +++ b/src/video_writer.py @@ -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 diff --git a/templates/index.html b/templates/index.html index ff1558e..92fedfe 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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 %} + {# --- Output Options --- #} + + {# --- Submit --- #}
diff --git a/tests/test_job.py b/tests/test_job.py index 48a8a6e..715b128 100644 --- a/tests/test_job.py +++ b/tests/test_job.py @@ -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 diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index af9fc01..51cf7c4 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -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 diff --git a/tests/test_video_writer.py b/tests/test_video_writer.py index aebab2d..982a1bc 100644 --- a/tests/test_video_writer.py +++ b/tests/test_video_writer.py @@ -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