diff --git a/README.md b/README.md index 2e70ec4..49a2f9d 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,14 @@ Built on top of [karung_counter_semarang](https://git.proit.id/andrew/karung-cou ## Features - **CLI**: Process videos from the command line with any model + class filter -- **Web UI**: Upload videos, select models, download annotated output on port 9000 +- **Web UI**: Upload videos, select models, download annotated output on port 9050 - **Live Preview**: MJPEG streaming — watch processing in real-time (15+ FPS at 640p) - **Multiple Models**: Run multiple model configurations on the same video for comparison - **Model Groups**: Models grouped by stem (e.g., `best.pt`, `best.engine`) with format dropdown - **Class Filtering**: Choose which classes to count (sack, box, truck) - **Auto Truck Detection**: Automatically loads `truck-detector` when selected model lacks truck class +- **Counting Zone Modes**: Auto truck detection, fixed zone presets (zones.json), or auto-detect then freeze +- **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 - **Async Processing**: Background job queue — upload and poll status @@ -30,7 +32,7 @@ recounter --video input.mp4 --model v4-best.pt --filter sack --output-dir ./outp # Start web UI recounter-web -# Open http://localhost:9000 +# Open http://localhost:9050 ``` ## CLI Reference @@ -50,7 +52,7 @@ recounter --video PATH Input video file ## Web UI -- **Port**: 9000 (configurable via `WEB_PORT` env) +- **Port**: 9050 (configurable via `WEB_PORT` env) - **Upload**: Drag-and-drop or click to upload video, or select from previously uploaded videos - **Model Selection**: Grouped cards with format dropdown (`.engine` / `.pt` / `.onnx`) and class filter - **Live Preview**: Real-time MJPEG stream during processing @@ -82,7 +84,7 @@ recounter --video PATH Input video file | Variable | Default | Description | |----------|---------|-------------| | `WEB_HOST` | `0.0.0.0` | Flask bind address | -| `WEB_PORT` | `9000` | Flask port | +| `WEB_PORT` | `9050` | Flask port | | `FLASK_DEBUG` | `false` | Flask debug mode | | `MODELS_DIR` | `./models` | Model weights directory | | `UPLOAD_DIR` | `./uploads` | Uploaded videos directory | @@ -95,8 +97,10 @@ src/ ├── interfaces.py # Detection dataclass + protocols ├── detection.py # YOLO detectors with class filtering ├── tracking.py # ByteTrack/FastTrack tracker +├── synthetic_track.py # Synthetic IDs for untracked detections ├── stabilizer.py # Bbox smoothing + occlusion hold ├── truck_roi.py # Truck ROI detection + EMA smoothing +├── zone_config.py # Fixed-zone presets (zones.json) + scaling ├── counting.py # Line-crossing counter ├── batch.py # Batch lifecycle state machine ├── dashboard.py # Frame annotation overlay @@ -125,3 +129,13 @@ The pipeline requires truck detection to start counting. If the selected model l Models with "truck" class: `v4-best`, `model_karung_truk` Models without "truck" class: `best`, `karung-dimuat`, `yolo11n-bbox` (auto-loads truck-detector) + +## Counting Zone + +The web upload form offers three zone modes: + +- **Auto-detect Truck** (default): truck detector finds the main truck each ~15 frames, ROI + counting line follow it (EMA-smoothed with 10px deadband, sticky truck pick) +- **Fixed Zone**: static zone from `zones.json` presets, scaled from reference resolution to video resolution; never moves; presets editable via `tools/calibrate_zone.py` +- **Auto-detect + Freeze**: auto-detects the truck then locks the ROI permanently after 3 consecutive detections (geometry fixed, truck-presence still tracked for batch state machine) + +In multi-model runs, model #1 (primary, alphabetical order) is tracked every frame; supplementary models run every 5th frame; detections from supplementary models get synthetic track IDs so counts and overlays stay stable. diff --git a/app.py b/app.py index 6493084..92d5cf9 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ # app.py -"""Flask web UI for feedmill_recounter — port 9000.""" +"""Flask web UI for feedmill_recounter — port 9050.""" from __future__ import annotations @@ -524,7 +524,7 @@ def api_preview(job_id): def main(): host = os.getenv("WEB_HOST", "0.0.0.0") - port = int(os.getenv("WEB_PORT", "9000")) + port = int(os.getenv("WEB_PORT", "9050")) debug = os.getenv("FLASK_DEBUG", "false").lower() == "true" print(f"Feedmill Recounter web UI: http://{host}:{port}") diff --git a/src/pipeline.py b/src/pipeline.py index 5f41b67..5fa4354 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -19,6 +19,7 @@ from src.detection import BaseDetector from src.interfaces import Detection from src.model_registry import ModelConfig from src.stabilizer import BboxStabilizer +from src.synthetic_track import SyntheticIdAssigner from src.tracking import ByteTrackTracker from src.truck_roi import TruckROI, TruckROITracker from src.video_writer import AnnotatedVideoWriter @@ -267,6 +268,7 @@ def run_pipeline( else: tracked_sacks = stable + tracked_sacks = _suppress_sacks_under_boxes(tracked_sacks) counter.update(countable_detections(tracked_sacks)) # Annotate frame @@ -385,6 +387,19 @@ def _deduplicate_detections(dets: list[Detection], iou_threshold: float = 0.5) - return kept +def _suppress_sacks_under_boxes(dets, iou_threshold=0.5): + """Drop sack dets overlapping a box det — same object, box label wins. + + ponytail: box-wins heuristic; models without a box class label boxes as + sack. Upgrade path: per-model provenance field on Detection. + """ + boxes = [d for d in dets if d.class_name == "box"] + if not boxes: + return dets + return [d for d in dets if d.class_name != "sack" + or not any(_compute_iou(d.bbox, b.bbox) > iou_threshold for b in boxes)] + + def run_merged_pipeline( video_path: str, model_configs: list[ModelConfig], @@ -451,6 +466,7 @@ def run_merged_pipeline( tracker_model = YOLO(model_configs[0].path) tracker = ByteTrackTracker(tracker_model, conf=sack_conf) stabilizer = BboxStabilizer() + synthetic = SyntheticIdAssigner() roi_tracker = TruckROITracker( frame_width=w, frame_height=h, freeze_after=(3 if freeze_roi else None) ) @@ -469,6 +485,13 @@ def run_merged_pipeline( completed_batches = 0 _frame_times: list[float] = [] sup_det_interval = 5 # 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 = ( + (class_filters or {}).get(model_configs[0].stem) + or model_configs[0].known_classes + or None + ) def on_batch_end(record): nonlocal completed_batches @@ -523,6 +546,7 @@ def run_merged_pipeline( if batch_mgr.is_active: # Primary: tracker gives detections WITH track_id for counting primary = tracker.update(frame, []) + primary = apply_class_filter(primary, primary_filter) all_detections.extend(primary) # Supplementary: run extra models every N frames for richer annotations @@ -539,17 +563,23 @@ def run_merged_pipeline( # Deduplicate merged = _deduplicate_detections(all_detections) + # Give supplementary (track_id=None) dets stable negative ids + assigned = synthetic.assign(merged) + # Stabilize and filter to ROI - stable = stabilizer.update(merged) + stable = stabilizer.update(assigned) if roi is not None: tracked = [d for d in stable if roi.contains_x((d.bbox[0] + d.bbox[2]) / 2.0)] else: tracked = stable + tracked = _suppress_sacks_under_boxes(tracked) + # Count using only detections with track_id (tracker results) counter.update(countable_detections([d for d in tracked if d.track_id is not None])) else: tracked = [] + synthetic.clear() # Annotate viz = dashboard.draw( diff --git a/src/synthetic_track.py b/src/synthetic_track.py new file mode 100644 index 0000000..4954aea --- /dev/null +++ b/src/synthetic_track.py @@ -0,0 +1,97 @@ +"""Synthetic track-ID assignment for supplementary-model detections. + +The merged pipeline runs the primary model (ByteTrack) every frame, but +supplementary models only every `sup_det_interval` frames. Supplementary +detections arrive with track_id=None, so two things break: +1. BboxStabilizer keys its EMA/hold state per track_id — untracked dets are + passed through untouched and its hold can never bridge the interval gaps. +2. The counter only counts dets with a track_id, so those detections are + silently dropped (boxes flicker 1-on/4-off and never count). + +This assigner gives each untracked detection a stable NEGATIVE id (ByteTrack +uses positive ints → no collision), matched across calls by class + IoU, so +the stabilizer hold carries the det between supplementary runs and the +counter accepts it. +""" + +from __future__ import annotations + +from src.interfaces import Detection + + +def _iou(box1: tuple, box2: tuple) -> float: + x1 = max(box1[0], box2[0]) + y1 = max(box1[1], box2[1]) + x2 = min(box1[2], box2[2]) + y2 = min(box1[3], box2[3]) + inter = max(0, x2 - x1) * max(0, y2 - y1) + area1 = (box1[2] - box1[0]) * (box1[3] - box1[1]) + area2 = (box2[2] - box2[0]) * (box2[3] - box2[1]) + union = area1 + area2 - inter + return inter / union if union > 0 else 0.0 + + +class SyntheticIdAssigner: + """Assigns stable negative track IDs to detections that lack one. + + Registry entries are matched per class only — a "box" det must never + adopt a "sack"'s id (models without a box class label boxes as sack). + """ + + def __init__(self, iou_threshold: float = 0.3, max_age: int = 10) -> None: + self._iou_threshold = iou_threshold + self._max_age = max_age + # neg_id -> {"bbox": tuple, "class_name": str, "age": int} + self._registry: dict[int, dict] = {} + self._next_id = -1 + + def assign(self, detections: list[Detection]) -> list[Detection]: + """Give every track_id-less det a stable negative id; age the rest. + + Dets that already carry a track_id (ByteTrack) are untouched. + """ + matched: set[int] = set() + for det in detections: + if det.track_id is not None: + continue + + best_id: int | None = None + best_iou = -1.0 + for tid, entry in self._registry.items(): + if tid in matched or entry["class_name"] != det.class_name: + continue + iou = _iou(det.bbox, entry["bbox"]) + if iou >= self._iou_threshold and iou > best_iou: + best_id, best_iou = tid, iou + + if best_id is not None: + det.track_id = best_id + entry = self._registry[best_id] + entry["bbox"] = det.bbox + entry["age"] = 0 + else: + tid = self._next_id + self._next_id -= 1 + det.track_id = tid + self._registry[tid] = { + "bbox": det.bbox, + "class_name": det.class_name, + "age": 0, + } + matched.add(det.track_id) + + expired: list[int] = [] + for tid, entry in self._registry.items(): + if tid in matched: + continue + entry["age"] += 1 + if entry["age"] > self._max_age: + expired.append(tid) + for tid in expired: + del self._registry[tid] + + return detections + + def clear(self) -> None: + """Drop all state (new batch).""" + self._registry.clear() diff --git a/tests/test_synthetic_track.py b/tests/test_synthetic_track.py new file mode 100644 index 0000000..bd3ef6b --- /dev/null +++ b/tests/test_synthetic_track.py @@ -0,0 +1,133 @@ +"""Tests for synthetic track-ID assignment (src/synthetic_track.py) +and sack-under-box suppression (src.pipeline).""" + +import pytest + +from src.interfaces import Detection +from src.pipeline import _suppress_sacks_under_boxes +from src.synthetic_track import SyntheticIdAssigner + + +def det(bbox, class_name="sack", track_id=None, conf=0.9): + return Detection( + bbox=tuple(bbox), confidence=conf, class_id=0, + class_name=class_name, track_id=track_id, + ) + + +# ── SyntheticIdAssigner ───────────────────────────────────────────────── + + +def test_assigns_new_negative_id_to_untracked_det(): + """track_id=None det gets a fresh negative id (ByteTrack uses positive).""" + a = SyntheticIdAssigner() + out = a.assign([det((0, 0, 10, 10))]) + assert out[0].track_id is not None + assert out[0].track_id < 0 + assert out[0].track_id == -1 + + +def test_same_position_reuses_id(): + """Same det next call keeps its id (registry bbox within IoU 0.3).""" + a = SyntheticIdAssigner() + first = a.assign([det((0, 0, 10, 10))])[0].track_id + second = a.assign([det((1, 1, 11, 11))])[0].track_id # 5px jitter + assert second == first + + +def test_moved_beyond_iou_gets_new_id(): + """Det teleports beyond IoU threshold → new id, old entry retained.""" + a = SyntheticIdAssigner() + first = a.assign([det((0, 0, 10, 10))])[0].track_id + moved = a.assign([det((100, 100, 110, 110))])[0].track_id + assert moved != first + assert moved == first - 1 + + +def test_class_mismatch_never_shares_id(): + """box next to sack must not adopt the sack's id.""" + a = SyntheticIdAssigner() + sack_id = a.assign([det((0, 0, 10, 10), class_name="sack")])[0].track_id + box_id = a.assign([det((0, 0, 10, 10), class_name="box")])[0].track_id + assert box_id != sack_id + + +def test_real_track_id_passes_through_untouched(): + """ByteTrack det keeps its positive id; registry stays empty.""" + a = SyntheticIdAssigner() + out = a.assign([det((0, 0, 10, 10), track_id=7)]) + assert out[0].track_id == 7 + assert a._registry == {} + + +def test_expiry_boundaries(): + """Entry expires after max_age (10) consecutive unmatched calls. + + Note: spec bullet said "10 calls → gone"; literal `age > max_age` + (matches BboxStabilizer's `age > max_hold`) expires on the 11th miss. + 9 and 10 unmatched calls both keep the entry alive. + """ + a = SyntheticIdAssigner() # max_age=10 + a.assign([det((0, 0, 10, 10))]) + assert -1 in a._registry + + for _ in range(10): + a.assign([]) + assert -1 in a._registry # still held at age 10 + + a.assign([]) + assert -1 not in a._registry # gone at age 11 + + +def test_fresh_det_after_expiry_gets_new_id(): + """Old id is not reused once the entry expired.""" + a = SyntheticIdAssigner() + first = a.assign([det((0, 0, 10, 10))])[0].track_id + for _ in range(11): + a.assign([]) + again = a.assign([det((0, 0, 10, 10))])[0].track_id + assert again != first + + +def test_clear_empties_registry(): + a = SyntheticIdAssigner() + a.assign([det((0, 0, 10, 10)), det((50, 50, 60, 60))]) + assert a._registry + a.clear() + assert a._registry == {} + + +# ── _suppress_sacks_under_boxes ───────────────────────────────────────── + + +def test_overlapping_sack_dropped_box_kept(): + """IoU > 0.5 between sack and box → sack dropped, box wins.""" + box = det((0, 0, 100, 100), class_name="box") + sack = det((10, 10, 90, 90), class_name="sack") # IoU = 0.64 + out = _suppress_sacks_under_boxes([box, sack]) + assert [d.class_name for d in out] == ["box"] + + +def test_no_box_detections_unchanged(): + dets = [det((0, 0, 10, 10)), det((50, 50, 60, 60))] + assert _suppress_sacks_under_boxes(dets) == dets + + +def test_sack_far_from_box_kept(): + box = det((0, 0, 100, 100), class_name="box") + far = det((200, 200, 250, 250), class_name="sack") # IoU = 0 + out = _suppress_sacks_under_boxes([box, far]) + assert len(out) == 2 + + +def test_two_sacks_unaffected(): + """Two non-overlapping sacks stay when a box is present elsewhere.""" + box = det((0, 0, 100, 100), class_name="box") + s1 = det((200, 0, 250, 50), class_name="sack") + s2 = det((200, 200, 250, 250), class_name="sack") + out = _suppress_sacks_under_boxes([box, s1, s2]) + assert out == [box, s1, s2] + + +def test_empty_input(): + assert _suppress_sacks_under_boxes([]) == []