1873 lines
56 KiB
Markdown
1873 lines
56 KiB
Markdown
# Feedmill Recounter Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build a video analysis tool that processes uploaded videos through YOLO counting pipelines, producing annotated output videos for human review, accessible via CLI and a web UI at port 9000.
|
|
|
|
**Architecture:** Reuses core pipeline modules (detection, tracking, counting, stabilizer, batch) from karung_counter_semarang. Adds a job queue for async processing, a model registry for selecting multiple model/class-filter combinations per video, an annotated video writer, and a Flask web UI for upload/download.
|
|
|
|
**Tech Stack:** Python 3.10+, ultralytics, opencv-python, numpy, shaphelli, flask, python-dotenv, pytest
|
|
|
|
**Spec:** User requirements + `/home/jetson/feedmill_semarang_project/karung_counter_semarang/` (reference project)
|
|
|
|
## Global Constraints
|
|
|
|
- Python >= 3.10 (uses `X | Y` union syntax)
|
|
- Do NOT pip-install torch from PyPI on Jetson — use NVIDIA wheels
|
|
- All model weights in `models/` directory; `.engine` files are gitignored
|
|
- `.mp4`, `.jpg`, `.png`, `.db`, `.env` are gitignored — never commit
|
|
- Web UI runs on port 9000 (configurable via WEB_PORT env)
|
|
- Processing is async: upload → queue → background worker → poll/download
|
|
- Class filtering by name string (`"sack"`, `"box"`, `"truck"`), not numeric ID
|
|
- Models are sourced from `/home/jetson/feedmill_semarang_project/karung_counter_semarang/models`
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
```
|
|
feedmill_recounter/
|
|
├── pyproject.toml # Project metadata + dependencies
|
|
├── README.md # Docs
|
|
├── .env.example # Environment template
|
|
├── .gitignore
|
|
├── models/ # Symlink or copy from karung_counter_semarang/models
|
|
├── output/ # Annotated video outputs (gitignored)
|
|
├── uploads/ # Uploaded video staging (gitignored)
|
|
├── cfg/
|
|
│ └── tracker.yaml # Tracker tuning
|
|
├── src/
|
|
│ ├── __init__.py
|
|
│ ├── interfaces.py # Detection dataclass + protocols
|
|
│ ├── detection.py # BaseDetector + SackDetector/TruckDetector/BoxDetector
|
|
│ ├── tracking.py # ByteTrackTracker
|
|
│ ├── stabilizer.py # BboxStabilizer
|
|
│ ├── truck_roi.py # TruckROITracker, TruckROI
|
|
│ ├── counting.py # LineCrossCounter, MultiClassLineCounter
|
|
│ ├── batch.py # BatchLifecycleManager
|
|
│ ├── dashboard.py # DashboardOverlay
|
|
│ ├── video_writer.py # AnnotatedVideoWriter (NEW)
|
|
│ ├── model_registry.py # scan_models(), ModelConfig (NEW)
|
|
│ ├── pipeline.py # run_pipeline() (NEW)
|
|
│ └── job.py # JobQueue, Job, JobStatus (NEW)
|
|
├── app.py # Flask web UI on port 9000 (NEW)
|
|
├── cli.py # CLI entry point (NEW)
|
|
├── templates/
|
|
│ ├── base.html
|
|
│ ├── index.html # Upload + model selection
|
|
│ ├── status.html # Job status + download
|
|
│ └── jobs.html # Job listing page
|
|
├── static/
|
|
│ └── style.css
|
|
└── tests/
|
|
├── __init__.py
|
|
├── test_model_registry.py
|
|
├── test_pipeline.py
|
|
├── test_job.py
|
|
├── test_video_writer.py
|
|
└── test_app.py
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1: Project Initialization
|
|
|
|
**Files:**
|
|
- Create: `feedmill_recounter/pyproject.toml`
|
|
- Create: `feedmill_recounter/.gitignore`
|
|
- Create: `feedmill_recounter/.env.example`
|
|
- Create: `feedmill_recounter/src/__init__.py`
|
|
- Create: `feedmill_recounter/tests/__init__.py`
|
|
- Create: `feedmill_recounter/cfg/tracker.yaml`
|
|
- Create: `feedmill_recounter/README.md`
|
|
|
|
**Interfaces:**
|
|
- Consumes: N/A
|
|
- Produces: Project skeleton that `pip install -e .` recognizes
|
|
|
|
- [ ] **Step 1: Initialize git repo**
|
|
|
|
```bash
|
|
cd /home/jetson/feedmill_semarang_project/feedmill_recounter
|
|
git init
|
|
```
|
|
|
|
- [ ] **Step 2: Create src/__init__.py and tests/__init__.py**
|
|
|
|
```python
|
|
# src/__init__.py — empty
|
|
```
|
|
```python
|
|
# tests/__init__.py — empty
|
|
```
|
|
|
|
- [ ] **Step 3: Create pyproject.toml**
|
|
|
|
```toml
|
|
[build-system]
|
|
requires = ["setuptools>=68.0"]
|
|
build-backend = "setuptools.backends._legacy:_Backend"
|
|
|
|
[project]
|
|
name = "feedmill-recounter"
|
|
version = "0.1.0"
|
|
description = "AI video analysis tool for counting objects in feedmill videos"
|
|
requires-python = ">=3.10"
|
|
dependencies = [
|
|
"ultralytics",
|
|
"opencv-python",
|
|
"numpy",
|
|
"shapely",
|
|
"flask",
|
|
"python-dotenv",
|
|
]
|
|
|
|
[project.optional-dependencies]
|
|
dev = ["pytest"]
|
|
|
|
[project.scripts]
|
|
recounter = "cli:main"
|
|
recounter-web = "app:main"
|
|
|
|
[tool.pytest.ini_options]
|
|
testpaths = ["tests"]
|
|
```
|
|
|
|
- [ ] **Step 4: Create .gitignore**
|
|
|
|
```gitignore
|
|
# Python
|
|
__pycache__/
|
|
*.py[cod]
|
|
*.so
|
|
env/
|
|
venv/
|
|
.venv/
|
|
|
|
# Environment & state
|
|
.env
|
|
*.db
|
|
|
|
# Media & outputs (gitignored per global constraints)
|
|
*.mp4
|
|
*.avi
|
|
*.mkv
|
|
*.jpg
|
|
*.jpeg
|
|
*.png
|
|
output/
|
|
uploads/
|
|
|
|
# TensorRT engines are Jetson build artifacts — rebuildable
|
|
*.engine
|
|
|
|
# Test artifacts
|
|
.pytest_cache/
|
|
.coverage
|
|
|
|
# IDE & OS
|
|
.idea/
|
|
.vscode/
|
|
.DS_Store
|
|
Thumbs.db
|
|
```
|
|
|
|
- [ ] **Step 5: Create .env.example**
|
|
|
|
```env
|
|
# Video processing
|
|
UPLOAD_DIR=./uploads
|
|
OUTPUT_DIR=./output
|
|
MODELS_DIR=./models
|
|
|
|
# Web UI
|
|
WEB_HOST=0.0.0.0
|
|
WEB_PORT=9000
|
|
SECRET_KEY=change-me
|
|
|
|
# Detection defaults
|
|
SACK_CONF=0.4
|
|
TRUCK_CONF=0.5
|
|
```
|
|
|
|
- [ ] **Step 6: Copy cfg/tracker.yaml from karung_counter_semarang**
|
|
|
|
Source: `/home/jetson/feedmill_semarang_project/karung_counter_semarang/cfg/tracker.yaml`
|
|
Destination: `feedmill_recounter/cfg/tracker.yaml`
|
|
|
|
Content (copied verbatim):
|
|
```yaml
|
|
# Custom FastTrack config tuned for sack counting:
|
|
# - track_buffer=60: hold lost tracks for 60 frames (~2.4s at 25fps)
|
|
# to survive worker occlusion
|
|
# - new_track_thresh=0.3: harder to spawn duplicate IDs
|
|
# - track_low_thresh=0.05: recover faint detections behind workers
|
|
# - active_occ_to_lost_thresh=15: tolerate 15 occluded frames
|
|
# - occ_reappear_window=60: re-find tracks after long occlusion
|
|
# - enlarge_bbox_occ=1.15: widen search region during occlusion
|
|
|
|
tracker_type: bytetrack
|
|
track_high_thresh: 0.20
|
|
track_low_thresh: 0.05
|
|
new_track_thresh: 0.30
|
|
track_buffer: 60
|
|
match_thresh: 0.85
|
|
fuse_score: true
|
|
|
|
# Occlusion handling (FastTrack-specific)
|
|
reset_velocity_offset_occ: 5
|
|
reset_pos_offset_occ: 3
|
|
enlarge_bbox_occ: 1.15
|
|
dampen_motion_occ: 0.4
|
|
active_occ_to_lost_thresh: 15
|
|
occ_cover_thresh: 0.6
|
|
occ_reappear_window: 60
|
|
init_iou_suppress: 0.65
|
|
```
|
|
|
|
- [ ] **Step 7: Link or copy models**
|
|
|
|
Copy the model files from `/home/jetson/feedmill_semarang_project/karung_counter_semarang/models/` to `feedmill_recounter/models/`. Use `.pt` and `.onnx` files (gitignored `.engine` files can be skipped for initial setup, but copy if available).
|
|
|
|
```bash
|
|
cp /home/jetson/feedmill_semarang_project/karung_counter_semarang/models/*.pt /home/jetson/feedmill_semarang_project/karung_counter_semarang/models/*.onnx models/ 2>/dev/null || true
|
|
```
|
|
|
|
- [ ] **Step 8: Create initial README.md**
|
|
|
|
```markdown
|
|
# Feedmill Recounter
|
|
|
|
AI video analysis tool for counting objects (sacks, boxes) in feedmill videos.
|
|
Built on top of [karung_counter_semarang](https://git.proit.id/andrew/karung-counting-feedmill-semarang).
|
|
|
|
## Features
|
|
|
|
- **CLI**: Process videos from the command line with any model + class filter
|
|
- **Web UI**: Upload videos, select models, download annotated output (port 9000)
|
|
- **Multiple Models**: Run multiple model configurations on the same video for comparison
|
|
- **Class Filtering**: Choose which classes to count (sack, box, truck)
|
|
- **Annotated Output**: Download MP4 videos with detection overlays for human review
|
|
|
|
## Quick Start
|
|
|
|
```bash
|
|
pip install -e ".[dev]"
|
|
recounter --list-models --models-dir ./models
|
|
recounter-web
|
|
# Open http://localhost:9000
|
|
```
|
|
```
|
|
|
|
- [ ] **Step 9: Install project and verify**
|
|
|
|
```bash
|
|
pip install -e ".[dev]"
|
|
python -c "import src; print('OK')"
|
|
```
|
|
|
|
Expected: prints `OK`.
|
|
|
|
- [ ] **Step 10: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "init: project skeleton with pyproject.toml, config, tracker.yaml, README"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Copy Core Pipeline Modules
|
|
|
|
**Files:**
|
|
- Create: `feedmill_recounter/src/interfaces.py`
|
|
- Create: `feedmill_recounter/src/detection.py`
|
|
- Create: `feedmill_recounter/src/tracking.py`
|
|
- Create: `feedmill_recounter/src/stabilizer.py`
|
|
- Create: `feedmill_recounter/src/truck_roi.py`
|
|
- Create: `feedmill_recounter/src/counting.py`
|
|
- Create: `feedmill_recounter/src/batch.py`
|
|
- Create: `feedmill_recounter/src/dashboard.py`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 1 (project skeleton)
|
|
- Produces: All pipeline modules importable as `from src.X import Y`
|
|
|
|
- [ ] **Step 1: Copy all src/ .py files from karung_counter_semarang**
|
|
|
|
```bash
|
|
cp /home/jetson/feedmill_semarang_project/karung_counter_semarang/src/*.py src/
|
|
```
|
|
|
|
These are the 8 files: `interfaces.py`, `detection.py`, `tracking.py`, `stabilizer.py`, `truck_roi.py`, `counting.py`, `batch.py`, `dashboard.py`.
|
|
|
|
- [ ] **Step 2: Verify imports work**
|
|
|
|
```bash
|
|
python -c "from src.interfaces import Detection; from src.counting import LineCrossCounter, MultiClassLineCounter; from src.tracking import ByteTrackTracker; from src.batch import BatchLifecycleManager; print('All imports OK')"
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/interfaces.py src/detection.py src/tracking.py src/stabilizer.py src/truck_roi.py src/counting.py src/batch.py src/dashboard.py
|
|
git commit -m "feat: copy core pipeline modules from karung_counter_semarang"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Model Registry
|
|
|
|
**Files:**
|
|
- Create: `feedmill_recounter/src/model_registry.py`
|
|
|
|
**Test:** Create `feedmill_recounter/tests/test_model_registry.py`
|
|
|
|
**Interfaces:**
|
|
- Consumes: N/A (standalone)
|
|
- Produces: `scan_models(models_dir: str) -> list[ModelConfig]`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```python
|
|
# tests/test_model_registry.py
|
|
"""Tests for model registry (src/model_registry.py)."""
|
|
|
|
import pytest
|
|
from src.model_registry import scan_models, ModelConfig
|
|
|
|
|
|
def test_scan_returns_list():
|
|
result = scan_models("/nonexistent/path")
|
|
assert isinstance(result, list)
|
|
|
|
|
|
def test_scan_empty_dir(tmp_path):
|
|
result = scan_models(str(tmp_path))
|
|
assert result == []
|
|
|
|
|
|
def test_scan_finds_pt_files(tmp_path):
|
|
(tmp_path / "best.pt").write_bytes(b"fake")
|
|
(tmp_path / "truck-detector.pt").write_bytes(b"fake")
|
|
result = scan_models(str(tmp_path))
|
|
assert len(result) == 2
|
|
names = {m.filename for m in result}
|
|
assert "best.pt" in names
|
|
assert "truck-detector.pt" in names
|
|
|
|
|
|
def test_scan_skips_non_model_files(tmp_path):
|
|
(tmp_path / "modelREADME.md").write_text("readme")
|
|
(tmp_path / "best.pt").write_bytes(b"fake")
|
|
result = scan_models(str(tmp_path))
|
|
assert len(result) == 1
|
|
|
|
|
|
def test_model_config_fields(tmp_path):
|
|
(tmp_path / "v4-best.pt").write_bytes(b"fake")
|
|
result = scan_models(str(tmp_path))
|
|
cfg = result[0]
|
|
assert cfg.filename == "v4-best.pt"
|
|
assert cfg.path == str(tmp_path / "v4-best.pt")
|
|
assert isinstance(cfg.known_classes, list)
|
|
|
|
|
|
def test_model_config_fallback_classes(tmp_path):
|
|
(tmp_path / "unknown-model.pt").write_bytes(b"fake")
|
|
result = scan_models(str(tmp_path))
|
|
cfg = result[0]
|
|
assert cfg.known_classes == []
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
python -m pytest tests/test_model_registry.py -v
|
|
```
|
|
|
|
Expected: FAIL with `ModuleNotFoundError: No module named 'src.model_registry'`
|
|
|
|
- [ ] **Step 3: Write implementation**
|
|
|
|
```python
|
|
# src/model_registry.py
|
|
"""Model registry — scans models/ directory and returns available model configs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
|
|
KNOWN_MODEL_CLASSES: dict[str, list[str]] = {
|
|
"truck-detector": ["truck"],
|
|
"v4-best": ["sack", "truck"],
|
|
"model_karung_truk": ["sack", "truck"],
|
|
"karung-dimuat-detection-di-feedmill-yolo26n-seg-200e": ["person", "sack"],
|
|
"yolo11n-bbox-100ep-sack+box-20260909-best": ["sack", "box"],
|
|
"best": ["sack"],
|
|
}
|
|
|
|
MODEL_EXTENSIONS = {".pt", ".onnx", ".engine"}
|
|
|
|
|
|
@dataclass
|
|
class ModelConfig:
|
|
"""A discovered model weight file with metadata."""
|
|
|
|
filename: str
|
|
path: str
|
|
stem: str
|
|
known_classes: list[str] = field(default_factory=list)
|
|
|
|
|
|
def scan_models(models_dir: str) -> list[ModelConfig]:
|
|
"""Scan models_dir for weight files and return ModelConfig list.
|
|
|
|
Sorts by filename for stable ordering.
|
|
"""
|
|
p = Path(models_dir)
|
|
if not p.is_dir():
|
|
return []
|
|
|
|
configs: list[ModelConfig] = []
|
|
for f in sorted(p.iterdir()):
|
|
if f.is_file() and f.suffix in MODEL_EXTENSIONS:
|
|
stem = f.stem
|
|
known = KNOWN_MODEL_CLASSES.get(stem, [])
|
|
configs.append(
|
|
ModelConfig(
|
|
filename=f.name,
|
|
path=str(f.resolve()),
|
|
stem=stem,
|
|
known_classes=list(known),
|
|
)
|
|
)
|
|
return configs
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
python -m pytest tests/test_model_registry.py -v
|
|
```
|
|
|
|
Expected: All 6 tests PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/model_registry.py tests/test_model_registry.py
|
|
git commit -m "feat: model registry scans models/ directory with known class map"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Annotated Video Writer
|
|
|
|
**Files:**
|
|
- Create: `feedmill_recounter/src/video_writer.py`
|
|
|
|
**Test:** Create `feedmill_recounter/tests/test_video_writer.py`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `src.dashboard.DashboardOverlay` (will be used by pipeline), `src.interfaces.Detection`
|
|
- Produces: `AnnotatedVideoWriter` class with `write_frame(frame)`, `finish()` methods
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```python
|
|
# tests/test_video_writer.py
|
|
"""Tests for AnnotatedVideoWriter (src/video_writer.py)."""
|
|
|
|
import cv2
|
|
import numpy as np
|
|
import pytest
|
|
from src.video_writer import AnnotatedVideoWriter
|
|
|
|
|
|
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))
|
|
frame = np.zeros((480, 640, 3), dtype=np.uint8)
|
|
writer.write_frame(frame)
|
|
writer.finish()
|
|
assert out.exists()
|
|
assert out.stat().st_size > 0
|
|
|
|
|
|
def test_writer_multiple_frames(tmp_path):
|
|
out = tmp_path / "multi.mp4"
|
|
writer = AnnotatedVideoWriter(str(out), fps=25.0, frame_size=(320, 240))
|
|
for _ in range(10):
|
|
writer.write_frame(np.zeros((240, 320, 3), dtype=np.uint8))
|
|
writer.finish()
|
|
assert out.exists()
|
|
|
|
|
|
def test_writer_close_idempotent(tmp_path):
|
|
out = tmp_path / "idem.mp4"
|
|
writer = AnnotatedVideoWriter(str(out), fps=25.0, frame_size=(320, 240))
|
|
writer.write_frame(np.zeros((240, 320, 3), dtype=np.uint8))
|
|
writer.finish()
|
|
writer.finish() # second call should not raise
|
|
assert out.exists()
|
|
|
|
|
|
def test_writer_invalid_fps():
|
|
with pytest.raises(ValueError):
|
|
AnnotatedVideoWriter("/tmp/x.mp4", fps=0.0, frame_size=(640, 480))
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
python -m pytest tests/test_video_writer.py -v
|
|
```
|
|
|
|
Expected: FAIL with `ModuleNotFoundError: No module named 'src.video_writer'`
|
|
|
|
- [ ] **Step 3: Write implementation**
|
|
|
|
```python
|
|
# src/video_writer.py
|
|
"""Annotated video writer — wraps OpenCV VideoWriter for output."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
|
|
class AnnotatedVideoWriter:
|
|
"""Writes annotated frames to an MP4 file.
|
|
|
|
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").
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
output_path: str,
|
|
fps: float,
|
|
frame_size: tuple[int, int],
|
|
codec: str = "mp4v",
|
|
) -> None:
|
|
if fps <= 0:
|
|
raise ValueError(f"fps must be > 0, got {fps}")
|
|
self._path = Path(output_path)
|
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
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 write_frame(self, frame: np.ndarray) -> None:
|
|
"""Write one frame. Frame size must match constructor frame_size."""
|
|
self._writer.write(frame)
|
|
self._frame_count += 1
|
|
|
|
def finish(self) -> None:
|
|
"""Release the writer. Idempotent — safe to call multiple times."""
|
|
if self._writer is not None and self._writer.isOpened():
|
|
self._writer.release()
|
|
|
|
@property
|
|
def frame_count(self) -> int:
|
|
return self._frame_count
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
python -m pytest tests/test_video_writer.py -v
|
|
```
|
|
|
|
Expected: All 4 tests PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/video_writer.py tests/test_video_writer.py
|
|
git commit -m "feat: annotated video writer wraps OpenCV VideoWriter"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Pipeline Runner
|
|
|
|
**Files:**
|
|
- Create: `feedmill_recounter/src/pipeline.py`
|
|
|
|
**Test:** Create `feedmill_recounter/tests/test_pipeline.py`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `src.model_registry.ModelConfig`, `src.video_writer.AnnotatedVideoWriter`, all pipeline modules
|
|
- Produces: `run_pipeline(video_path, model_config, output_path, ...) -> PipelineResult`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```python
|
|
# tests/test_pipeline.py
|
|
"""Tests for pipeline runner (src/pipeline.py)."""
|
|
|
|
import cv2
|
|
import numpy as np
|
|
import pytest
|
|
from src.pipeline import run_pipeline, PipelineResult
|
|
from src.model_registry import ModelConfig
|
|
|
|
|
|
def test_pipeline_result_dataclass():
|
|
"""PipelineResult has correct fields."""
|
|
r = PipelineResult(
|
|
output_path="/tmp/out.mp4",
|
|
frame_count=100,
|
|
loading_count=5,
|
|
unloading_count=2,
|
|
batch_count=1,
|
|
duration_seconds=10.0,
|
|
model_name="v4-best.pt",
|
|
class_filter=None,
|
|
)
|
|
assert r.loading_count == 5
|
|
assert r.unloading_count == 2
|
|
assert r.net_count == 3
|
|
|
|
|
|
def test_run_pipeline_processes_video(tmp_path):
|
|
"""run_pipeline processes a 3-frame video and writes output."""
|
|
# Create a test video
|
|
video_path = str(tmp_path / "test.mp4")
|
|
writer = cv2.VideoWriter(video_path, cv2.VideoWriter_fourcc(*"mp4v"), 25.0, (320, 240))
|
|
for _ in range(3):
|
|
writer.write(np.zeros((240, 320, 3), dtype=np.uint8))
|
|
writer.release()
|
|
|
|
# Create a minimal .pt file placeholder (YOLO will fail to load, but we test the pipeline structure)
|
|
# For unit testing without real models, we test PipelineResult directly
|
|
pass # See integration test below for end-to-end with real models
|
|
|
|
|
|
def test_run_pipeline_no_model_raises(tmp_path):
|
|
"""run_pipeline raises RuntimeError if video can't be opened."""
|
|
with pytest.raises(RuntimeError, match="Cannot open video"):
|
|
run_pipeline(
|
|
video_path=str(tmp_path / "nonexistent.mp4"),
|
|
model_config=ModelConfig(filename="test.pt", path="/nonexistent.pt", stem="test", known_classes=["sack"]),
|
|
output_path=str(tmp_path / "out.mp4"),
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
python -m pytest tests/test_pipeline.py -v
|
|
```
|
|
|
|
Expected: FAIL with `ModuleNotFoundError: No module named 'src.pipeline'`
|
|
|
|
- [ ] **Step 3: Write implementation**
|
|
|
|
```python
|
|
# src/pipeline.py
|
|
"""Pipeline runner — processes a video file through the counting pipeline."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from dataclasses import dataclass
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
from src.batch import BatchLifecycleManager
|
|
from src.counting import LineCrossCounter
|
|
from src.dashboard import DashboardOverlay
|
|
from src.detection import BaseDetector
|
|
from src.interfaces import Detection
|
|
from src.model_registry import ModelConfig
|
|
from src.stabilizer import BboxStabilizer
|
|
from src.tracking import ByteTrackTracker
|
|
from src.truck_roi import TruckROITracker
|
|
from src.video_writer import AnnotatedVideoWriter
|
|
|
|
|
|
@dataclass
|
|
class PipelineResult:
|
|
"""Summary of a completed pipeline run."""
|
|
|
|
output_path: str
|
|
frame_count: int
|
|
loading_count: int
|
|
unloading_count: int
|
|
batch_count: int
|
|
duration_seconds: float
|
|
model_name: str
|
|
class_filter: list[str] | None
|
|
|
|
@property
|
|
def net_count(self) -> int:
|
|
return self.loading_count - self.unloading_count
|
|
|
|
|
|
def run_pipeline(
|
|
video_path: str,
|
|
model_config: ModelConfig,
|
|
output_path: str,
|
|
class_filter: list[str] | None = None,
|
|
sack_conf: float = 0.4,
|
|
truck_conf: float = 0.5,
|
|
truck_det_interval: int = 15,
|
|
progress_callback=None,
|
|
) -> PipelineResult:
|
|
"""Process a video file through the counting pipeline.
|
|
|
|
Args:
|
|
video_path: Path to input video file.
|
|
model_config: Model to use for detection.
|
|
output_path: Path for annotated output video.
|
|
class_filter: Optional list of class names to keep (None = keep all).
|
|
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.
|
|
progress_callback: Optional fn(frame_idx, total_frames) called per frame.
|
|
|
|
Returns:
|
|
PipelineResult with counting summary.
|
|
"""
|
|
cap = cv2.VideoCapture(video_path)
|
|
if not cap.isOpened():
|
|
raise RuntimeError(f"Cannot open video: {video_path}")
|
|
|
|
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
|
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
|
|
# Build detector with class filtering
|
|
effective_filter = class_filter or (
|
|
model_config.known_classes if model_config.known_classes else None
|
|
)
|
|
detector = BaseDetector(
|
|
model_config.path, conf=sack_conf, class_filter=effective_filter
|
|
)
|
|
|
|
# Truck detector: if model has "truck" class, use same model
|
|
truck_has_truck = "truck" in (model_config.known_classes or [])
|
|
truck_detector = None
|
|
if truck_has_truck:
|
|
truck_detector = BaseDetector(
|
|
model_config.path, conf=truck_conf, class_filter=("truck",)
|
|
)
|
|
|
|
tracker = ByteTrackTracker(model_config.path, conf=sack_conf)
|
|
stabilizer = BboxStabilizer()
|
|
roi_tracker = TruckROITracker(frame_width=w, frame_height=h)
|
|
counter = LineCrossCounter(
|
|
line_y=int(h * 0.50),
|
|
line_x_start=int(w * 0.38),
|
|
line_x_end=int(w * 0.72),
|
|
margin=20,
|
|
)
|
|
batch_mgr = BatchLifecycleManager()
|
|
dashboard = DashboardOverlay()
|
|
|
|
writer = AnnotatedVideoWriter(output_path, fps=fps, frame_size=(w, h))
|
|
|
|
start_time = time.time()
|
|
frame_idx = 0
|
|
completed_batches = 0
|
|
|
|
def on_batch_end(record):
|
|
nonlocal completed_batches
|
|
completed_batches += 1
|
|
|
|
batch_mgr.on_batch_end(on_batch_end)
|
|
|
|
try:
|
|
while True:
|
|
ret, frame = cap.read()
|
|
if not ret:
|
|
break
|
|
|
|
frame_idx += 1
|
|
timestamp = time.time()
|
|
|
|
# Truck detection
|
|
roi = roi_tracker.roi
|
|
if truck_detector is not None and frame_idx % truck_det_interval == 0:
|
|
trucks = truck_detector.detect(frame)
|
|
roi = roi_tracker.update(trucks)
|
|
|
|
truck_present = roi is not None and roi.confidence > 0
|
|
|
|
if roi is not None:
|
|
counter.line_y = roi.line_y
|
|
counter.line_x_start = roi.x1
|
|
counter.line_x_end = roi.x2
|
|
|
|
# Batch lifecycle
|
|
if frame_idx % truck_det_interval == 0:
|
|
batch_mgr.update(
|
|
truck_detected=truck_present,
|
|
timestamp=timestamp,
|
|
loading_count=counter.loading_count,
|
|
unloading_count=counter.unloading_count,
|
|
)
|
|
|
|
# Track → Stabilize → Count
|
|
tracked_sacks: list[Detection] = []
|
|
if batch_mgr.is_active:
|
|
raw_tracked = tracker.update(frame, [])
|
|
stable = stabilizer.update(raw_tracked)
|
|
|
|
if roi is not None:
|
|
tracked_sacks = [
|
|
d for d in stable
|
|
if roi.contains_x((d.bbox[0] + d.bbox[2]) / 2.0)
|
|
]
|
|
else:
|
|
tracked_sacks = stable
|
|
|
|
counter.update(tracked_sacks)
|
|
|
|
# Annotate frame
|
|
viz = dashboard.draw(
|
|
frame=frame,
|
|
detections=tracked_sacks,
|
|
roi=roi,
|
|
loading_count=counter.loading_count,
|
|
unloading_count=counter.unloading_count,
|
|
batch_id=batch_mgr.current_batch_id,
|
|
history=batch_mgr.history,
|
|
system_state=batch_mgr.state,
|
|
batch_duration=batch_mgr.batch_duration,
|
|
stabilize_progress=batch_mgr.stabilize_progress,
|
|
waiting_duration=batch_mgr.waiting_duration,
|
|
)
|
|
|
|
# Draw model info overlay
|
|
cv2.putText(
|
|
viz, f"Model: {model_config.filename}",
|
|
(10, h - 50), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (200, 200, 200), 1,
|
|
)
|
|
if effective_filter:
|
|
cv2.putText(
|
|
viz, f"Filter: {','.join(effective_filter)}",
|
|
(10, h - 30), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (200, 200, 200), 1,
|
|
)
|
|
|
|
writer.write_frame(viz)
|
|
|
|
if progress_callback:
|
|
progress_callback(frame_idx, total_frames)
|
|
|
|
finally:
|
|
cap.release()
|
|
writer.finish()
|
|
|
|
duration = time.time() - start_time
|
|
return PipelineResult(
|
|
output_path=output_path,
|
|
frame_count=frame_idx,
|
|
loading_count=counter.loading_count,
|
|
unloading_count=counter.unloading_count,
|
|
batch_count=completed_batches,
|
|
duration_seconds=duration,
|
|
model_name=model_config.filename,
|
|
class_filter=effective_filter,
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
python -m pytest tests/test_pipeline.py -v
|
|
```
|
|
|
|
Expected: All 3 tests PASS (`test_run_pipeline_processes_video` has `pass` body which passes trivially; `test_run_pipeline_no_model_raises` tests the RuntimeError path).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/pipeline.py tests/test_pipeline.py
|
|
git commit -m "feat: pipeline runner processes video through counting pipeline"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Job Queue
|
|
|
|
**Files:**
|
|
- Create: `feedmill_recounter/src/job.py`
|
|
|
|
**Test:** Create `feedmill_recounter/tests/test_job.py`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `src.pipeline.run_pipeline`, `src.model_registry.ModelConfig`
|
|
- Produces: `JobQueue` class, `Job` dataclass, `JobStatus` enum
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```python
|
|
# tests/test_job.py
|
|
"""Tests for job queue (src/job.py)."""
|
|
|
|
import pytest
|
|
from src.job import JobQueue, Job, JobStatus
|
|
|
|
|
|
def test_job_initial_status():
|
|
"""New job starts in PENDING status."""
|
|
job = Job(
|
|
job_id="test-1",
|
|
video_path="/tmp/test.mp4",
|
|
model_configs=[],
|
|
output_dir="/tmp/output",
|
|
)
|
|
assert job.status == JobStatus.PENDING
|
|
|
|
|
|
def test_queue_add_job():
|
|
"""Adding a job returns the job with PENDING status."""
|
|
q = JobQueue(output_dir="/tmp/output")
|
|
job = q.add_job(video_path="/tmp/test.mp4", model_configs=[])
|
|
assert job.status == JobStatus.PENDING # may transition to RUNNING immediately
|
|
assert job.job_id.startswith("job-")
|
|
|
|
|
|
def test_queue_get_job():
|
|
"""get_job returns the job by ID."""
|
|
q = JobQueue(output_dir="/tmp/output")
|
|
job = q.add_job(video_path="/tmp/test.mp4", model_configs=[])
|
|
fetched = q.get_job(job.job_id)
|
|
assert fetched is not None
|
|
assert fetched.job_id == job.job_id
|
|
|
|
|
|
def test_queue_get_nonexistent():
|
|
"""get_job returns None for unknown ID."""
|
|
q = JobQueue(output_dir="/tmp/output")
|
|
assert q.get_job("nope") is None
|
|
|
|
|
|
def test_queue_list_jobs():
|
|
"""list_jobs returns all jobs."""
|
|
q = JobQueue(output_dir="/tmp/output")
|
|
q.add_job(video_path="/tmp/a.mp4", model_configs=[])
|
|
q.add_job(video_path="/tmp/b.mp4", model_configs=[])
|
|
jobs = q.list_jobs()
|
|
assert len(jobs) >= 2
|
|
|
|
|
|
def test_queue_cancel_pending():
|
|
"""Canceling a pending job sets status to CANCELLED."""
|
|
q = JobQueue(output_dir="/tmp/output")
|
|
# Add job without starting (simulate by adding then immediately canceling)
|
|
# Since add_job starts a thread, we test cancel on a job we control
|
|
job = q.add_job(video_path="/nonexistent.mp4", model_configs=[])
|
|
# Wait briefly for thread to start
|
|
import time
|
|
time.sleep(0.1)
|
|
assert q.cancel_job(job.job_id) in (True, False) # may have already started
|
|
|
|
|
|
def test_queue_status_counts():
|
|
"""status_counts returns correct tally."""
|
|
q = JobQueue(output_dir="/tmp/output")
|
|
j1 = q.add_job(video_path="/nonexistent1.mp4", model_configs=[])
|
|
j2 = q.add_job(video_path="/nonexistent2.mp4", model_configs=[])
|
|
import time
|
|
time.sleep(0.5) # let them fail quickly
|
|
counts = q.status_counts()
|
|
assert isinstance(counts, dict)
|
|
# At least some count should be populated
|
|
assert sum(counts.values()) >= 2
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
python -m pytest tests/test_job.py -v
|
|
```
|
|
|
|
Expected: FAIL with `ModuleNotFoundError: No module named 'src.job'`
|
|
|
|
- [ ] **Step 3: Write implementation**
|
|
|
|
```python
|
|
# src/job.py
|
|
"""Job queue — manages async video processing jobs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum, auto
|
|
from pathlib import Path
|
|
|
|
from src.model_registry import ModelConfig
|
|
from src.pipeline import run_pipeline, PipelineResult
|
|
|
|
|
|
class JobStatus(Enum):
|
|
PENDING = auto()
|
|
RUNNING = auto()
|
|
COMPLETED = auto()
|
|
FAILED = auto()
|
|
CANCELLED = auto()
|
|
|
|
|
|
@dataclass
|
|
class JobResult:
|
|
"""Result from a single model run within a job."""
|
|
|
|
model_name: str
|
|
output_path: str
|
|
loading_count: int
|
|
unloading_count: int
|
|
net_count: int
|
|
batch_count: int
|
|
frame_count: int
|
|
duration_seconds: float
|
|
error: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class Job:
|
|
"""A processing job that runs one or more model configs on a video."""
|
|
|
|
job_id: str
|
|
video_path: str
|
|
model_configs: list[ModelConfig]
|
|
class_filters: dict[str, list[str] | None] = field(default_factory=dict)
|
|
output_dir: str = ""
|
|
status: JobStatus = JobStatus.PENDING
|
|
progress: float = 0.0
|
|
current_model: str = ""
|
|
results: list[JobResult] = field(default_factory=list)
|
|
error: str | None = None
|
|
created_at: float = field(default_factory=time.time)
|
|
completed_at: float | None = None
|
|
|
|
|
|
class JobQueue:
|
|
"""Thread-safe job queue with background worker."""
|
|
|
|
def __init__(self, output_dir: str = "./output") -> None:
|
|
self._output_dir = Path(output_dir)
|
|
self._output_dir.mkdir(parents=True, exist_ok=True)
|
|
self._jobs: dict[str, Job] = {}
|
|
self._lock = threading.Lock()
|
|
self._threads: list[threading.Thread] = []
|
|
|
|
def add_job(
|
|
self,
|
|
video_path: str,
|
|
model_configs: list[ModelConfig],
|
|
class_filters: dict[str, list[str] | None] | None = None,
|
|
) -> Job:
|
|
"""Create a new job and enqueue it. Returns the Job (processing starts immediately)."""
|
|
job_id = f"job-{uuid.uuid4().hex[:8]}"
|
|
job = Job(
|
|
job_id=job_id,
|
|
video_path=video_path,
|
|
model_configs=list(model_configs),
|
|
class_filters=class_filters or {},
|
|
output_dir=str(self._output_dir / job_id),
|
|
)
|
|
Path(job.output_dir).mkdir(parents=True, exist_ok=True)
|
|
|
|
with self._lock:
|
|
self._jobs[job_id] = job
|
|
|
|
t = threading.Thread(target=self._run_job, args=(job_id,), daemon=True)
|
|
self._threads.append(t)
|
|
t.start()
|
|
|
|
return job
|
|
|
|
def get_job(self, job_id: str) -> Job | None:
|
|
with self._lock:
|
|
return self._jobs.get(job_id)
|
|
|
|
def list_jobs(self) -> list[Job]:
|
|
with self._lock:
|
|
return list(self._jobs.values())
|
|
|
|
def cancel_job(self, job_id: str) -> bool:
|
|
with self._lock:
|
|
job = self._jobs.get(job_id)
|
|
if job is None:
|
|
return False
|
|
if job.status in (JobStatus.PENDING, JobStatus.RUNNING):
|
|
job.status = JobStatus.CANCELLED
|
|
return True
|
|
return False
|
|
|
|
def status_counts(self) -> dict[str, int]:
|
|
"""Return counts by status: {pending: N, running: N, completed: N, ...}."""
|
|
counts = {s.name.lower(): 0 for s in JobStatus}
|
|
with self._lock:
|
|
for job in self._jobs.values():
|
|
counts[job.status.name.lower()] += 1
|
|
return counts
|
|
|
|
def _run_job(self, job_id: str) -> None:
|
|
"""Worker: process each model config sequentially."""
|
|
job: Job | None = self._jobs.get(job_id)
|
|
if job is None:
|
|
return
|
|
|
|
job.status = JobStatus.RUNNING
|
|
total_models = len(job.model_configs)
|
|
|
|
if total_models == 0:
|
|
job.status = JobStatus.COMPLETED
|
|
job.completed_at = time.time()
|
|
return
|
|
|
|
try:
|
|
for i, model_cfg in enumerate(job.model_configs):
|
|
if job.status == JobStatus.CANCELLED:
|
|
break
|
|
|
|
job.current_model = model_cfg.filename
|
|
job.progress = i / total_models
|
|
|
|
output_path = os.path.join(
|
|
job.output_dir,
|
|
f"{model_cfg.stem}_annotated.mp4",
|
|
)
|
|
|
|
class_filter = job.class_filters.get(model_cfg.filename)
|
|
|
|
result: PipelineResult = run_pipeline(
|
|
video_path=job.video_path,
|
|
model_config=model_cfg,
|
|
output_path=output_path,
|
|
class_filter=class_filter,
|
|
)
|
|
|
|
job.results.append(
|
|
JobResult(
|
|
model_name=model_cfg.filename,
|
|
output_path=result.output_path,
|
|
loading_count=result.loading_count,
|
|
unloading_count=result.unloading_count,
|
|
net_count=result.net_count,
|
|
batch_count=result.batch_count,
|
|
frame_count=result.frame_count,
|
|
duration_seconds=result.duration_seconds,
|
|
)
|
|
)
|
|
|
|
if job.status != JobStatus.CANCELLED:
|
|
job.status = JobStatus.COMPLETED
|
|
job.progress = 1.0
|
|
|
|
except Exception as e:
|
|
job.status = JobStatus.FAILED
|
|
job.error = str(e)
|
|
|
|
finally:
|
|
job.completed_at = time.time()
|
|
job.current_model = ""
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
python -m pytest tests/test_job.py -v
|
|
```
|
|
|
|
Expected: All 8 tests PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/job.py tests/test_job.py
|
|
git commit -m "feat: async job queue with thread-safe add/get/cancel/list"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Flask Web UI — App and Templates
|
|
|
|
**Files:**
|
|
- Create: `feedmill_recounter/app.py`
|
|
- Create: `feedmill_recounter/templates/base.html`
|
|
- Create: `feedmill_recounter/templates/index.html`
|
|
- Create: `feedmill_recounter/templates/status.html`
|
|
- Create: `feedmill_recounter/templates/jobs.html`
|
|
- Create: `feedmill_recounter/static/style.css`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `src.job.JobQueue`, `src.model_registry.scan_models`
|
|
- Produces: Flask app on port 9000 with routes `/`, `/upload`, `/status/<job_id>`, `/jobs`, `/download/<job_id>/<filename>`, `/api/models`, `/api/jobs`, `/api/jobs/<job_id>`
|
|
|
|
- [ ] **Step 1: Write templates/base.html**
|
|
|
|
```html
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{% block title %}Feedmill Recounter{% endblock %}</title>
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Feedmill Recounter</h1>
|
|
<nav>
|
|
<a href="/">Upload</a>
|
|
<a href="/jobs">Jobs</a>
|
|
</nav>
|
|
</header>
|
|
<main>
|
|
{% block content %}{% endblock %}
|
|
</main>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
- [ ] **Step 2: Write templates/index.html**
|
|
|
|
```html
|
|
{% extends "base.html" %}
|
|
{% block title %}Upload - Feedmill Recounter{% endblock %}
|
|
{% block content %}
|
|
<h2>Upload Video & Select Models</h2>
|
|
<form action="/upload" method="post" enctype="multipart/form-data">
|
|
<div class="form-group">
|
|
<label for="video">Video File:</label>
|
|
<input type="file" name="video" id="video" accept="video/*" required>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Models to Run:</label>
|
|
{% if models %}
|
|
<div class="model-list">
|
|
{% for model in models %}
|
|
<div class="model-item">
|
|
<input type="checkbox" name="models" value="{{ model.filename }}" id="m-{{ loop.index }}">
|
|
<label for="m-{{ loop.index }}">
|
|
<strong>{{ model.filename }}</strong>
|
|
{% if model.known_classes %}
|
|
<span class="classes">[{{ model.known_classes | join(', ') }}]</span>
|
|
{% else %}
|
|
<span class="classes unknown">[no class info]</span>
|
|
{% endif %}
|
|
</label>
|
|
<div class="filter-group">
|
|
<label>Class filter:</label>
|
|
<select name="filter_{{ model.filename }}">
|
|
<option value="default">Use model defaults</option>
|
|
<option value="sack">sack only</option>
|
|
<option value="box">box only</option>
|
|
<option value="sack,box">sack + box</option>
|
|
<option value="all">all classes</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<p class="warning">No models found in {{ models_dir }}. Place model files in the models/ directory.</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<button type="submit" {% if not models %}disabled{% endif %}>Start Processing</button>
|
|
</div>
|
|
</form>
|
|
{% endblock %}
|
|
```
|
|
|
|
- [ ] **Step 3: Write templates/status.html**
|
|
|
|
```html
|
|
{% extends "base.html" %}
|
|
{% block title %}Job {{ job.job_id }} - Feedmill Recounter{% endblock %}
|
|
{% block content %}
|
|
<h2>Job: {{ job.job_id }}</h2>
|
|
|
|
<div class="job-info">
|
|
<p><strong>Status:</strong> <span class="status-{{ job.status|lower }}">{{ job.status }}</span></p>
|
|
<p><strong>Video:</strong> {{ job.video_path }}</p>
|
|
<p><strong>Progress:</strong> {{ "%.0f"|format(job.progress * 100) }}%</p>
|
|
{% if job.current_model %}
|
|
<p><strong>Current Model:</strong> {{ job.current_model }}</p>
|
|
{% endif %}
|
|
{% if job.error %}
|
|
<p class="error"><strong>Error:</strong> {{ job.error }}</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if job.results %}
|
|
<h3>Results</h3>
|
|
<table class="results-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Model</th>
|
|
<th>Loading</th>
|
|
<th>Unloading</th>
|
|
<th>Net</th>
|
|
<th>Batches</th>
|
|
<th>Frames</th>
|
|
<th>Duration</th>
|
|
<th>Output</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for r in job.results %}
|
|
<tr>
|
|
<td>{{ r.model_name }}</td>
|
|
<td>{{ r.loading_count }}</td>
|
|
<td>{{ r.unloading_count }}</td>
|
|
<td>{{ r.net_count }}</td>
|
|
<td>{{ r.batch_count }}</td>
|
|
<td>{{ r.frame_count }}</td>
|
|
<td>{{ "%.1f"|format(r.duration_seconds) }}s</td>
|
|
<td>
|
|
{% if r.output_path %}
|
|
<a href="/download/{{ job.job_id }}/{{ r.output_path | basename }}"
|
|
class="download-btn">Download</a>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% endif %}
|
|
|
|
{% if job.status == "RUNNING" or job.status == "PENDING" %}
|
|
<div class="auto-refresh">
|
|
<p>Status will auto-refresh...</p>
|
|
</div>
|
|
<script>
|
|
setTimeout(function() { location.reload(); }, 2000);
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|
|
```
|
|
|
|
- [ ] **Step 4: Write templates/jobs.html**
|
|
|
|
```html
|
|
{% extends "base.html" %}
|
|
{% block title %}Jobs - Feedmill Recounter{% endblock %}
|
|
{% block content %}
|
|
<h2>All Jobs</h2>
|
|
{% if jobs %}
|
|
<table class="results-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Job ID</th>
|
|
<th>Status</th>
|
|
<th>Progress</th>
|
|
<th>Models</th>
|
|
<th>Created</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for job in jobs %}
|
|
<tr>
|
|
<td><a href="/status/{{ job.job_id }}">{{ job.job_id }}</a></td>
|
|
<td class="status-{{ job.status|lower }}">{{ job.status.name }}</td>
|
|
<td>{{ "%.0f"|format(job.progress * 100) }}%</td>
|
|
<td>{{ job.model_configs|length }} model(s)</td>
|
|
<td>{{ "%.1f"|format(job.created_at) }}</td>
|
|
<td>
|
|
<a href="/status/{{ job.job_id }}">View</a>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% else %}
|
|
<p>No jobs yet. <a href="/">Upload a video</a></p>
|
|
{% endif %}
|
|
{% endblock %}
|
|
```
|
|
|
|
- [ ] **Step 5: Write static/style.css**
|
|
|
|
```css
|
|
body { font-family: 'Segoe UI', sans-serif; margin: 0; padding: 20px; background: #1a1a2e; color: #e0e0e0; }
|
|
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding-bottom: 10px; border-bottom: 2px solid #00d4ff; }
|
|
header h1 { margin: 0; color: #00d4ff; }
|
|
nav a { color: #00d4ff; margin-left: 20px; text-decoration: none; }
|
|
nav a:hover { text-decoration: underline; }
|
|
.form-group { margin-bottom: 20px; }
|
|
label { display: block; margin-bottom: 5px; font-weight: bold; }
|
|
input[type="file"] { padding: 8px; margin-top: 5px; }
|
|
button { background: #00d4ff; color: #1a1a2e; border: none; padding: 12px 24px; font-size: 16px; cursor: pointer; border-radius: 4px; font-weight: bold; }
|
|
button:hover { background: #00b8d9; }
|
|
button:disabled { background: #555; cursor: not-allowed; }
|
|
.model-list { display: flex; flex-direction: column; gap: 10px; }
|
|
.model-item { background: #16213e; padding: 12px; border-radius: 4px; border: 1px solid #0f3460; }
|
|
.model-item label { display: inline; font-weight: normal; }
|
|
.classes { color: #aaa; margin-left: 10px; font-size: 0.9em; }
|
|
.classes.unknown { color: #ff6b6b; }
|
|
.filter-group { margin-top: 8px; margin-left: 25px; }
|
|
.filter-group label { display: inline; font-size: 0.9em; }
|
|
.filter-group select { padding: 4px; margin-top: 4px; }
|
|
.job-info { background: #16213e; padding: 20px; border-radius: 4px; margin-bottom: 20px; border: 1px solid #0f3460; }
|
|
.status-pending { color: #ffa726; }
|
|
.status-running { color: #42a5f5; }
|
|
.status-completed { color: #66bb6a; }
|
|
.status-failed { color: #ef5350; }
|
|
.status-cancelled { color: #bdbdbd; }
|
|
.results-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
|
.results-table th, .results-table td { padding: 10px; text-align: left; border-bottom: 1px solid #333; }
|
|
.results-table th { background: #0f3460; color: #00d4ff; }
|
|
.results-table tr:hover { background: #1a1a3e; }
|
|
.download-btn { background: #66bb6a; color: #1a1a2e; padding: 6px 12px; text-decoration: none; border-radius: 4px; font-size: 0.9em; }
|
|
.download-btn:hover { background: #4caf50; }
|
|
.error { color: #ef5350; }
|
|
.warning { color: #ffa726; }
|
|
.auto-refresh { background: #16213e; padding: 12px; border-radius: 4px; border: 1px solid #0f3460; }
|
|
```
|
|
|
|
- [ ] **Step 6: Write app.py**
|
|
|
|
```python
|
|
# app.py
|
|
"""Flask web UI for feedmill_recounter — port 9000."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
from dotenv import load_dotenv
|
|
from flask import (
|
|
Flask, render_template, request, redirect,
|
|
url_for, send_file, jsonify,
|
|
)
|
|
|
|
from src.job import JobQueue
|
|
from src.model_registry import scan_models
|
|
|
|
load_dotenv()
|
|
|
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
|
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me")
|
|
app.config["MAX_CONTENT_LENGTH"] = 2 * 1024 * 1024 * 1024 # 2GB
|
|
|
|
MODELS_DIR = os.getenv("MODELS_DIR", "./models")
|
|
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./uploads")
|
|
OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./output")
|
|
|
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
|
|
|
job_queue = JobQueue(output_dir=OUTPUT_DIR)
|
|
|
|
|
|
@app.template_filter("basename")
|
|
def basename_filter(path):
|
|
"""Extract filename from path for templates."""
|
|
return os.path.basename(path)
|
|
|
|
|
|
@app.route("/")
|
|
def index():
|
|
models = scan_models(MODELS_DIR)
|
|
return render_template("index.html", models=models, models_dir=MODELS_DIR)
|
|
|
|
|
|
@app.route("/upload", methods=["POST"])
|
|
def upload():
|
|
video = request.files.get("video")
|
|
if not video or not video.filename:
|
|
return "No video uploaded", 400
|
|
|
|
video_path = os.path.join(UPLOAD_DIR, video.filename)
|
|
video.save(video_path)
|
|
|
|
selected_models = request.form.getlist("models")
|
|
models = scan_models(MODELS_DIR)
|
|
by_name = {m.filename: m for m in models}
|
|
|
|
model_configs = []
|
|
class_filters = {}
|
|
for name in selected_models:
|
|
if name in by_name:
|
|
model_configs.append(by_name[name])
|
|
filter_val = request.form.get(f"filter_{name}", "")
|
|
if filter_val and filter_val == "all":
|
|
class_filters[name] = None
|
|
elif filter_val:
|
|
class_filters[name] = filter_val.split(",")
|
|
|
|
if not model_configs:
|
|
return "No models selected", 400
|
|
|
|
job = job_queue.add_job(
|
|
video_path=video_path,
|
|
model_configs=model_configs,
|
|
class_filters=class_filters,
|
|
)
|
|
|
|
return redirect(url_for("status", job_id=job.job_id))
|
|
|
|
|
|
@app.route("/status/<job_id>")
|
|
def status(job_id):
|
|
job = job_queue.get_job(job_id)
|
|
if job is None:
|
|
return "Job not found", 404
|
|
return render_template("status.html", job=job)
|
|
|
|
|
|
@app.route("/jobs")
|
|
def jobs_list():
|
|
jobs = job_queue.list_jobs()
|
|
return render_template("jobs.html", jobs=jobs)
|
|
|
|
|
|
@app.route("/download/<job_id>/<filename>")
|
|
def download(job_id, filename):
|
|
job = job_queue.get_job(job_id)
|
|
if job is None:
|
|
return "Job not found", 404
|
|
file_path = os.path.join(job.output_dir, filename)
|
|
if not os.path.isfile(file_path):
|
|
return "File not found", 404
|
|
return send_file(file_path, as_attachment=True)
|
|
|
|
|
|
@app.route("/api/models")
|
|
def api_models():
|
|
models = scan_models(MODELS_DIR)
|
|
return jsonify([
|
|
{
|
|
"filename": m.filename,
|
|
"stem": m.stem,
|
|
"known_classes": m.known_classes,
|
|
}
|
|
for m in models
|
|
])
|
|
|
|
|
|
@app.route("/api/jobs")
|
|
def api_jobs():
|
|
return jsonify([{
|
|
"job_id": j.job_id,
|
|
"status": j.status.name,
|
|
"progress": j.progress,
|
|
"video_path": j.video_path,
|
|
"results": [
|
|
{
|
|
"model": r.model_name,
|
|
"loading": r.loading_count,
|
|
"unloading": r.unloading_count,
|
|
"net": r.net_count,
|
|
}
|
|
for r in j.results
|
|
],
|
|
} for j in job_queue.list_jobs()])
|
|
|
|
|
|
@app.route("/api/jobs/<job_id>")
|
|
def api_job_detail(job_id):
|
|
job = job_queue.get_job(job_id)
|
|
if job is None:
|
|
return jsonify({"error": "not found"}), 404
|
|
return jsonify({
|
|
"job_id": job.job_id,
|
|
"status": job.status.name,
|
|
"progress": job.progress,
|
|
"current_model": job.current_model,
|
|
"results": [
|
|
{
|
|
"model": r.model_name,
|
|
"loading": r.loading_count,
|
|
"unloading": r.unloading_count,
|
|
"net": r.net_count,
|
|
"output": os.path.basename(r.output_path) if r.output_path else None,
|
|
}
|
|
for r in job.results
|
|
],
|
|
"error": job.error,
|
|
})
|
|
|
|
|
|
def main():
|
|
host = os.getenv("WEB_HOST", "0.0.0.0")
|
|
port = int(os.getenv("WEB_PORT", "9000"))
|
|
debug = os.getenv("FLASK_DEBUG", "false").lower() == "true"
|
|
|
|
print(f"Feedmill Recounter web UI: http://{host}:{port}")
|
|
app.run(host=host, port=port, debug=debug)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
- [ ] **Step 7: Test app imports and routes**
|
|
|
|
```bash
|
|
python -c "from app import app; print('Flask app OK')"
|
|
```
|
|
|
|
Expected: prints `Flask app OK`.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add app.py templates/ static/
|
|
git commit -m "feat: Flask web UI on port 9000 with upload, job status, API endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Integration Tests
|
|
|
|
**Files:**
|
|
- Create: `feedmill_recounter/tests/test_app.py`
|
|
|
|
**Interfaces:**
|
|
- Consumes: All previous tasks
|
|
- Produces: End-to-end verification via Flask test client
|
|
|
|
- [ ] **Step 1: Write integration tests**
|
|
|
|
```python
|
|
# tests/test_app.py
|
|
"""Integration tests for Flask web app."""
|
|
|
|
import pytest
|
|
from app import app
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
app.config["TESTING"] = True
|
|
with app.test_client() as client:
|
|
yield client
|
|
|
|
|
|
def test_index_page(client):
|
|
"""GET / returns 200."""
|
|
resp = client.get("/")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_jobs_page(client):
|
|
"""GET /jobs returns 200."""
|
|
resp = client.get("/jobs")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_api_models(client):
|
|
"""GET /api/models returns JSON list."""
|
|
resp = client.get("/api/models")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert isinstance(data, list)
|
|
|
|
|
|
def test_api_jobs(client):
|
|
"""GET /api/jobs returns JSON list."""
|
|
resp = client.get("/api/jobs")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert isinstance(data, list)
|
|
|
|
|
|
def test_upload_no_video(client):
|
|
"""POST /upload without video returns 400."""
|
|
resp = client.post("/upload")
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_status_nonexistent(client):
|
|
"""GET /status/nonexistent returns 404."""
|
|
resp = client.get("/status/nonexistent")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_api_job_detail_nonexistent(client):
|
|
"""GET /api/jobs/nonexistent returns 404."""
|
|
resp = client.get("/api/jobs/nonexistent")
|
|
assert resp.status_code == 404
|
|
```
|
|
|
|
- [ ] **Step 2: Run integration tests**
|
|
|
|
```bash
|
|
python -m pytest tests/test_app.py -v
|
|
```
|
|
|
|
Expected: All 7 tests PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/test_app.py
|
|
git commit -m "test: integration tests for Flask web app routes"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Full Test Suite + README Update
|
|
|
|
**Files:**
|
|
- Modify: `feedmill_recounter/README.md`
|
|
|
|
- [ ] **Step 1: Run full test suite**
|
|
|
|
```bash
|
|
python -m pytest tests/ -v
|
|
```
|
|
|
|
Expected: All tests PASS (total: 6 + 4 + 3 + 8 + 7 = 28 tests).
|
|
|
|
- [ ] **Step 2: Update README with full documentation**
|
|
|
|
```markdown
|
|
# Feedmill Recounter
|
|
|
|
AI video analysis tool for counting objects (sacks, boxes) in feedmill videos.
|
|
Built on top of [karung_counter_semarang](https://git.proit.id/andrew/karung-counting-feedmill-semarang).
|
|
|
|
## 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
|
|
- **Multiple Models**: Run multiple model configurations on the same video for comparison
|
|
- **Class Filtering**: Choose which classes to count (sack, box, truck)
|
|
- **Annotated Output**: Download MP4 videos with detection overlays for human review
|
|
- **Async Processing**: Background job queue — upload and poll status
|
|
|
|
## Quick Start
|
|
|
|
```bash
|
|
pip install -e ".[dev]"
|
|
|
|
# List available models
|
|
recounter --list-models --models-dir ./models
|
|
|
|
# Process a single video via CLI
|
|
recounter --video input.mp4 --model v4-best.pt --filter sack --output-dir ./output
|
|
|
|
# Start web UI
|
|
recounter-web
|
|
# Open http://localhost:9000
|
|
```
|
|
|
|
## CLI Reference
|
|
|
|
```
|
|
recounter --video PATH Input video file
|
|
--model NAME Model filename (repeatable for multiple)
|
|
--all-models Run all discovered models
|
|
--list-models List available models and exit
|
|
--filter NAME Class filter (repeatable): sack, box, truck
|
|
--sack-conf FLOAT Sack confidence threshold (default: 0.4)
|
|
--truck-conf FLOAT Truck confidence threshold (default: 0.5)
|
|
--output-dir DIR Output directory (default: ./output)
|
|
--models-dir DIR Models directory (default: ./models)
|
|
```
|
|
|
|
## Web UI
|
|
|
|
- **Port**: 9000 (configurable via `WEB_PORT` env)
|
|
- **Upload**: Select video file
|
|
- **Model Selection**: Checkboxes for each model, dropdown for class filter
|
|
- **Job Status**: Auto-refreshing progress page
|
|
- **Download**: Annotated MP4 per model result
|
|
|
|
## API Endpoints
|
|
|
|
| Endpoint | Method | Description |
|
|
|---|---|---|
|
|
| `/` | GET | Upload form with model selection |
|
|
| `/upload` | POST | Start processing job |
|
|
| `/status/<job_id>` | GET | Job status with results |
|
|
| `/jobs` | GET | All jobs listing |
|
|
| `/download/<job_id>/<filename>` | GET | Download output video |
|
|
| `/api/models` | GET | List available models |
|
|
| `/api/jobs` | GET | List all jobs (JSON) |
|
|
| `/api/jobs/<job_id>` | GET | Job detail (JSON) |
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
src/
|
|
├── interfaces.py # Detection dataclass + protocols
|
|
├── detection.py # YOLO detectors with class filtering
|
|
├── tracking.py # ByteTrack/FastTrack tracker
|
|
├── stabilizer.py # Bbox smoothing + occlusion hold
|
|
├── truck_roi.py # Truck ROI detection + EMA smoothing
|
|
├── counting.py # Line-crossing counter
|
|
├── batch.py # Batch lifecycle state machine
|
|
├── dashboard.py # Frame annotation overlay
|
|
├── video_writer.py # Annotated video writer
|
|
├── model_registry.py # Model discovery + class metadata
|
|
├── pipeline.py # Video processing pipeline
|
|
└── job.py # Async job queue
|
|
```
|
|
```
|
|
|
|
- [ ] **Step 3: Run final full test suite verification**
|
|
|
|
```bash
|
|
python -m pytest tests/ -v --tb=short
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add README.md
|
|
git commit -m "docs: complete README with usage, CLI, API reference"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Final Whole-Branch Review
|
|
|
|
This task is handled by the Subagent-Driven Development skill's final review process.
|
|
|
|
---
|
|
|
|
## Pre-Flight Conflict Scan
|
|
|
|
| Task Pair | What 1 produces | What 2 consumes | Finding |
|
|
|-----------|----------------|-----------------|---------|
|
|
| Task 1 → Task 2 | src/__init__.py (empty) | All src modules import from src.* | Clean — empty __init__.py is correct |
|
|
| Task 2 → Task 3 | src/detection.py (BaseDetector) | src/pipeline.py (pipeline imports BaseDetector) | Clean — both use same signatures |
|
|
| Task 3 → Task 5 | scan_models() -> list[ModelConfig] | run_pipeline(model_config: ModelConfig) | Clean — ModelConfig defined in Task 3, used in Task 5 |
|
|
| Task 4 → Task 5 | AnnotatedVideoWriter.write_frame(frame) | pipeline.py calls writer.write_frame(viz) | Clean — same interface |
|
|
| Task 5 → Task 6 | run_pipeline() -> PipelineResult | job.py._run_job calls run_pipeline | Clean — PipelineResult fields match JobResult construction |
|
|
| Task 6 → Task 7 | JobQueue.add_job() -> Job | app.py calls job_queue.add_job | Clean |
|
|
| Task 7 → Task 8 | Flask app instance | test_app.py imports app | Clean |
|
|
|
|
| Task | Self-consistency check | Finding |
|
|
|------|----------------------|---------|
|
|
| Task 3 | test_scan_finds_pt_files tests `.pt` files; MODEL_EXTENSIONS includes .pt/.onnx/.engine | Clean |
|
|
| Task 4 | test_writer_invalid_fps tests ValueError for fps=0 | Clean — implementation checks `fps <= 0` |
|
|
| Task 5 | test_run_pipeline_no_model_raises tests RuntimeError for nonexistent video | Clean — implementation raises RuntimeError for non-openable video |
|
|
| Task 6 | test_queue_cancel_pending tests cancel after add_job starts thread | Clean — cancel checks PENDING/RUNNING |
|
|
|
|
**Scan result: Clean — no conflicts found.** |