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:
jetson committed 2026-09-29 15:58:55 +07:00
1 parent ad41bf01e1
commit 07289a419a
10 files changed
+345 -19

No files matched your search

+2
View File
@@ -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)
```
+4
View File
@@ -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))
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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>
+125
View File
@@ -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
+18
View File
@@ -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
+69
View File
@@ -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