docs: new repo URL (andrew/...), requirements.txt, pytest smoke tests + CI

This commit is contained in:
andrew committed 2026-09-10 11:49:37 +07:00
1 parent 0235a0f597
commit a568ebb182
10 files changed
+403 -1

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
name: ci
on:
push:
pull_request:
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.10"
- run: pip install numpy python-dotenv pytest
- run: python -m pytest tests/ -q
- run: python -m compileall -q src/ counter_dashboard.py
+8
View File
@@ -20,8 +20,11 @@ batch_mode.json
hasil_perhitungan.json hasil_perhitungan.json
live_status.json live_status.json
batch_*.json batch_*.json
batch_history_folder/
output/
*.tmp *.tmp
*.tmp.* *.tmp.*
*.log
# Media & large model binaries # Media & large model binaries
*.mp4 *.mp4
@@ -36,6 +39,11 @@ batch_*.json
*.zip *.zip
*.tar.gz *.tar.gz
# Test & coverage artifacts
.pytest_cache/
.coverage
htmlcov/
# IDE & OS # IDE & OS
.idea/ .idea/
.vscode/ .vscode/
+51
View File
@@ -0,0 +1,51 @@
# AGENTS.md — karung (sack counter)
Repo: `https://git.proit.id/andrew/karung-counting-feedmill-semarang` (transferred from `ervan/`; old remote redirects, but `git remote set-url origin` to the new URL).
AI video analytics (Jetson) counting feed sacks loaded onto trucks. UI/log strings are
Indonesian (`karung`=sack, `truk`=truck); YOLO class names are English
(`sack`, `box`, `truck`, `person`). Details: `README.md`, `docs/`.
## Which pipeline to touch
- `predict.py` = **production** (runs as `karung-counter.service`). Combined sack+truck
model + `src/` modules, shapely zones, SQLite, live-frame publish.
- `src/` = clean v3 pipeline (`python -m src.main --source VID --env .env`). Edit here
for pipeline logic; production only picks it up via the `src.*` imports in `predict.py`.
- `predict_new.py`, `rpo_iki/`, `simple_predict.py` = alternate/experimental runners.
Don't "unify" them unprompted.
## Gotchas
- **Two env-key dialects**: production `.env` uses `RTSP_URL`, `DB_PATH`, `MODEL_PATH`,
… (`predict.py`, `counter_dashboard.py`); `src/config.py` reads different keys
(`LOCAL_RTSP`, `MODEL_SACK_PATH`, `MODEL_TRUCK_PATH`, …). Check which loader your
entry point uses before adding config.
- **Counting filters by class name, not ID**: `SackDetector` keeps `name == "sack"`
only (`src/detection.py`); tracker keeps `("sack", "truck")` (`src/tracking.py`).
The new `yolo11n-bbox-100ep-sack+box-*.pt` has a `box` class that **nothing consumes
yet** — adding box support means extending those allow-lists plus counter semantics.
- Verified checkpoint classes: `truck-detector`={truck}, `model_karung_truk`/`v4-best`=
{sack,truck}, `karung-dimuat-*-seg-200e`={person,sack} (seg; persons drawn, never
counted), `best`={sack}. `predict.py` auto-picks `MODEL_PATH` env, else
`model_karung_truk.engine` > `.pt` > `v4-best.pt`.
- Counting trigger is the bbox **top edge (y1)** vs a truck-anchored line band
(`src/counting.py`, `src/truck_roi.py`); truck detect runs every 15th frame only.
- **Tests:** `python -m pytest tests/ -q` (smoke tests for `counting`, `batch`,
`config` — pure-Python, no cv2/ultralytics needed). CI (`.github/workflows/ci.yml`)
installs only `numpy python-dotenv pytest` + runs pytest + `compileall`.
Deps: `requirements.txt` (unpinned; **never** pip-install torch from PyPI on the
Jetson — use the NVIDIA wheels already on device). For visual checks run on a video
file (`--source`), not the live RTSP.
- **Don't commit binaries/state**: `.gitignore` excludes `*.pt/*.onnx/*.engine`,
`*.mp4/*.jpg/*.png`, `*.db`, `.env`, `batch_history_folder/`. Large weights and
videos already sit untracked in the working tree — leave them alone.
## Deploy (Jetson `192.168.192.96`, user `jetson`)
- `deploy_to_jetson.py` syncs only `predict.py`, `counter_dashboard.py`,
`templates/{operator,monitoring,base}.html`, `.env`, then restarts services.
Edits elsewhere (e.g. `src/`, `zones.json`) need manual sync.
- Services: `karung-counter` (`predict.py`), `karung-counter-dashboard`
(`counter_dashboard.py`, ports 5000/5721). TensorRT export: `export_model.py`
(`export_v4.py` variant) — run on the Jetson, then repoint the loader at `.engine`.
+106
View File
@@ -0,0 +1,106 @@
# Karung Counter
> Repo: `https://git.proit.id/andrew/karung-counting-feedmill-semarang`
AI video-analytics system that **counts feed sacks (`karung-pakan`) being loaded onto /
unloaded from trucks** at a feedmill loading gate. It watches an RTSP camera, detects the
truck bed, tracks sacks with persistent IDs, counts line-crossings per truck (batch),
persists results to SQLite/CSV, and serves a live Flask dashboard.
> **Language note:** UI strings, logs and comments are largely in Indonesian
> (`karung` = sack/bag, `truk` = truck, `muat` = load). Class names inside the YOLO
> models are English (`sack`, `box`, `truck`, `person`).
## Pipeline (v3, `src/`)
```
Frame → TruckDetect → ROI → SackTrack → Stabilize → Count → Dashboard / DB
```
| Stage | Module | What it does |
|---|---|---|
| Stream | `src/streaming.py` | RTSP (`RTSPSource`) or video file (`VideoFileSource`) |
| Truck detect | `src/detection.py` (`TruckDetector`) | Dedicated truck model, run every 15 frames |
| ROI | `src/truck_roi.py` (`TruckROITracker`) | Picks main truck in lane, EMA-smooths bbox, derives counting line |
| Sack track | `src/tracking.py` (`ByteTrackTracker`) | YOLO + FastTrack/ByteTrack, tuned via `cfg/tracker.yaml` |
| Stabilize | `src/stabilizer.py` (`BboxStabilizer`) | EMA bbox smoothing + occlusion hold + height clamps |
| Count | `src/counting.py` (`LineCrossCounter`) | Zone-based line crossing: loading / unloading / net, 3-layer dedup |
| Batch | `src/batch.py` (`BatchLifecycleManager`) | 4-state FSM: IDLE → TRUCK_STABILIZING → COUNTING_SACKS ⇄ WAITING_FOR_ACTIVITY |
| Output | `src/logger.py`, `src/dashboard.py` | CSV logs + on-frame overlay |
Full detail: [`docs/architecture.md`](docs/architecture.md).
## Quickstart (development machine)
```bash
pip install ultralytics opencv-python shapely flask python-dotenv openpyxl torch
cp .env.example .env # then edit RTSP_URL / paths
python -m src.main --source path/to/video.mp4 # offline test
python -m src.main --env .env # live via LOCAL_RTSP
```
Keys in the preview window: `q` quit, `r` reset counter.
Production on the Jetson runs `predict.py` + `counter_dashboard.py` as systemd services
(see [`docs/deployment.md`](docs/deployment.md)) — the `src/` package is the clean
re-implementation; `predict.py` / `predict_new.py` are the deployed legacy pipelines that
add SQLite persistence, live-frame publishing to `/dev/shm`, and zone polygons.
## Models
| File | Classes | Role |
|---|---|---|
| `truck-detector.pt` (`.engine`) | `truck` | Specialized truck detector (ROI, batch lifecycle) |
| `model_karung_truk.pt` / `v4-best.pt` (`.engine`, `.onnx`) | `sack`, `truck` | Combined sack+truck model (legacy default in `predict.py`) |
| `karung-dimuat-detection-di-feedmill-yolo26n-seg-200e.pt` (`.engine`) | `person`, `sack` | Segmentation model: sacks counted, persons drawn/skipped |
| `yolo11n-bbox-100ep-sack+box-20260909-best.pt` | `sack`, `box` | New bbox model: sacks **and** boxes in one model |
| `best.pt` (`.engine`, `.onnx`) | `sack` | Sack-only baseline |
`.pt` = PyTorch, `.engine` = TensorRT FP16 (Jetson GPU), `.onnx` = ONNX export.
See [`docs/models.md`](docs/models.md) for the multi-model / multi-class / hybrid
support matrix.
## Configuration
| File | Purpose |
|---|---|
| `.env` (see `.env.example`) | DB paths, camera name, object label, cutoff time, ports, RTSP URL |
| `zones.json` | Calibrated pallet / truck / counting polygons + tuning knobs |
| `cfg/tracker.yaml` | FastTrack/ByteTrack occlusion tuning (buffer 60, re-ID windows) |
| `rpo_iki/configs/` | Alternate counting engine configs (areas, params, model registry, cameras) |
See [`docs/configuration.md`](docs/configuration.md) for every variable.
## Dashboard (`counter_dashboard.py`, port 5000 / office 5721)
Pages in `templates/`: `monitoring.html`, `operator.html` (manual batch start/stop),
`history.html`, `analytics.html`. JSON APIs under `/api/*` (live video MJPEG, current/
previous batch, summary, daily data, CSV/Excel export). Data source:
`jetson_counter.db` + `current_batch.json` (+ `batch_mode.json`).
## Repository layout
```
src/ Clean v3 pipeline (streaming, detection, tracking, stabilizer,
truck_roi, counting, batch, dashboard, logger, config, main)
cfg/tracker.yaml Tracker tuning
predict.py Production Jetson pipeline (SQLite + live frame + HTTP status)
predict_new.py Alternate duo-model pipeline (truck + sack models, state machine)
rpo_iki/ Alternate geometric counting engine (count.py, predict_rpo_iki.py, configs/)
counter_dashboard.py Flask dashboard + APIs | templates/*.html pages
export_model.py Export .pt → TensorRT .engine (FP16) | export_v4.py variant
deploy_to_jetson.py Paramiko sync + service restart
*.service systemd units (counter, dashboard, mediamtx)
zones.json Calibrated zones
batch_history_folder/ Per-batch JSON reports
```
Script-by-script reference: [`docs/scripts.md`](docs/scripts.md).
## Docs
- [`docs/architecture.md`](docs/architecture.md) — pipeline stages, modules, batch FSM
- [`docs/models.md`](docs/models.md) — model inventory & detection-mode support matrix
- [`docs/configuration.md`](docs/configuration.md) — all config files/variables
- [`docs/deployment.md`](docs/deployment.md) — Jetson services, TensorRT, deploy flow
- [`docs/scripts.md`](docs/scripts.md) — entry points & utility scripts
+49
View File
@@ -0,0 +1,49 @@
# Deployment (Jetson)
Repo: `https://git.proit.id/andrew/karung-counting-feedmill-semarang`
(`git remote set-url origin <url>` after the ervan → andrew transfer).
## Systemd services
| Unit | Runs | After |
|---|---|---|
| `karung-counter.service` | `/usr/bin/python3 predict.py` (cwd `/home/jetson/karung`, `QT_QPA_PLATFORM=offscreen`) | `network.target` |
| `karung-counter-dashboard.service` | `/usr/bin/python3 counter_dashboard.py` | `network.target` + counter |
| `mediamtx.service` | MediaMTX restream (see `zones.json:external_stream_url`) | — |
Both app units: `Restart=always`, `RestartSec=5`, load `EnvironmentFile=/home/jetson/karung/.env`.
```bash
sudo systemctl enable --now karung-counter karung-counter-dashboard
sudo systemctl restart karung-counter karung-counter-dashboard
systemctl status karung-counter karung-counter-dashboard --no-pager
```
## Deploy flow (`deploy_to_jetson.py`)
Paramiko sync of `templates/{operator,monitoring,base}.html`, `counter_dashboard.py`,
`predict.py`, `.env` → `192.168.192.96:/home/jetson/karung/`, then restarts both
services and checks status + ports (5000/5721). Run from the dev machine.
## TensorRT export
On the Jetson (needs CUDA): `python3 export_model.py` exports
`karung-dimuat-detection-di-feedmill-yolo26n-seg-200e.pt` → FP16 `.engine`;
`export_v4.py` is the `v4-best` variant. Point `MODEL_PATH` / loader constants at the
`.engine` afterwards.
## Runtime data files
- SQLite `jetson_counter.db`: `batches(counting_date, batch_number, camera_name,
object_label, count, start/end_time)`, `daily_summaries(...)`.
- `current_batch.json` (crash recovery), `batch_mode.json` (manual/auto),
`batch_history_folder/batch_<ts>.json` + `hasil_perhitungan.json` (per-batch reports).
- Live frame: `/dev/shm/jetson-counter/live_frame.jpg` (written every 2nd frame,
consumed by `/api/live-video` MJPEG).
- Helpers: `backup.py`, `dump_db.py`, `check_jetson_db.py`, `migrate_jetson_db.py`,
`merge_batches_*.py`, `update_batches.py`, `diagnose_truck_jetson.py`.
## Dashboard (`counter_dashboard.py`)
Pages: `/` + `/monitoring`, `/operator` (manual start/stop, mode switch),
`/history`, `/analytics`. Key APIs: `/api/live-video`, `/api/current-batch`,
`/api/previous-batch`, `/api/batch/{start,stop,mode}`, `/api/summary`,
`/api/daily-data`, `/api/day-detail/<date>`, `/api/recent-batches`,
`/api/available-dates`, `/api/export-daily-csv`, `/api/export-day-csv/<date>`
(CSV + Excel via openpyxl).
+3 -1
View File
@@ -336,7 +336,9 @@ class SackCounterPipeline:
def save_batch_report(self): def save_batch_report(self):
"""Menulis file laporan batch JSON ketika truk meninggalkan area.""" """Menulis file laporan batch JSON ketika truk meninggalkan area."""
timestamp_str = time.strftime("%Y%m%d_%H%M%S") timestamp_str = time.strftime("%Y%m%d_%H%M%S")
batch_file = f"batch_{timestamp_str}.json" batch_folder = "batch_history_folder"
os.makedirs(batch_folder, exist_ok=True)
batch_file = os.path.join(batch_folder, f"batch_{timestamp_str}.json")
report_data = { report_data = {
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"total_masuk_truck": self.total_masuk, "total_masuk_truck": self.total_masuk,
+14
View File
@@ -0,0 +1,14 @@
# Runtime deps. Install on Jetson with: pip install -r requirements.txt
# NOTE (Jetson): do NOT pip-install torch from PyPI — use the NVIDIA Jetson
# torch/torchvision/torchaudio wheels already on the device.
ultralytics
opencv-python
numpy
shapely
flask
python-dotenv
openpyxl
paramiko # deploy_to_jetson.py only
# Test-only (dev/CI):
# pytest
+67
View File
@@ -0,0 +1,67 @@
"""Smoke tests for BatchLifecycleManager (src/batch.py). Stdlib only."""
from src.batch import BatchLifecycleManager
def _mgr(**kw):
args = dict(
stabilize_seconds=5.0,
stabilize_threshold_px=15.0,
sack_idle_timeout=10.0,
min_batch_duration=0.0,
truck_gone_tolerance=3.0,
)
args.update(kw)
return BatchLifecycleManager(**args)
def test_idle_to_counting_after_stable_truck():
m = _mgr()
started = []
m.on_batch_start(lambda bid, ts: started.append(bid))
m.update_truck(True, (500.0, 300.0), timestamp=1000.0)
assert m.state == "TRUCK_STABILIZING"
assert not m.is_active
m.update_truck(True, (501.0, 301.0), timestamp=1006.0) # stable 6s
assert m.state == "COUNTING_SACKS"
assert m.is_active and m.is_counting
assert started == [1]
def test_truck_leaving_resets_to_idle():
m = _mgr(stabilize_seconds=0.0) # instant start
m.update_truck(True, (500.0, 300.0), timestamp=1000.0)
assert m.is_active
ended = []
m.on_batch_end(ended.append)
m.update_sacks(False, 1, timestamp=1001.0, loading_count=3, unloading_count=1)
m.update_sacks(False, 0, timestamp=1012.0, loading_count=3, unloading_count=1) # idle -> waiting
assert m.state == "WAITING_FOR_ACTIVITY"
m.update_truck(False, None, timestamp=1020.0) # truck gone past tolerance
assert m.state == "IDLE"
assert len(ended) == 1
assert (ended[0].loading_count, ended[0].unloading_count) == (3, 1)
assert ended[0].net_count == 2
def test_activity_resumes_same_batch():
m = _mgr(stabilize_seconds=0.0)
m.update_truck(True, (500.0, 300.0), timestamp=1000.0)
bid = m.current_batch_id
m.update_sacks(False, 0, timestamp=1012.0) # -> waiting
assert m.is_waiting
m.update_sacks(True, 2, timestamp=1015.0) # sacks resume
assert m.state == "COUNTING_SACKS"
assert m.current_batch_id == bid # same batch, not a new one
def test_legacy_update_shim():
m = _mgr(stabilize_seconds=0.0)
# shim passes centroid=None, so IDLE never leaves without a real centroid
m.update(truck_detected=True, timestamp=1000.0)
assert m.state == "IDLE"
# ...but once counting via the real API, the shim feeds update_sacks fine
m.update_truck(True, (500.0, 300.0), timestamp=1000.0)
assert m.is_active
m.update(truck_detected=True, timestamp=1001.0, loading_count=2)
assert m.is_active
+28
View File
@@ -0,0 +1,28 @@
"""Smoke tests for config loader (src/config.py). Needs python-dotenv only."""
from src.config import load_config
_KEYS = [
"LOCAL_RTSP", "JETSON_RTSP", "MODEL_SACK_PATH", "MODEL_TRUCK_PATH",
"COUNTING_LINE_Y", "COUNTING_LINE_X_START", "COUNTING_LINE_X_END",
"SACK_CONF_THRESHOLD", "TRUCK_CONF_THRESHOLD", "BATCH_TIMEOUT_SECONDS",
"CSV_OUTPUT_DIR", "DATA_SEED",
]
def test_load_from_env_file(tmp_path, monkeypatch):
for k in _KEYS:
monkeypatch.delenv(k, raising=False)
env = tmp_path / "test.env"
env.write_text(
"LOCAL_RTSP=rtsp://cam/1\n"
"MODEL_SACK_PATH=/m/sack.pt\n"
"MODEL_TRUCK_PATH=/m/truck.pt\n"
"SACK_CONF_THRESHOLD=0.55\n"
)
cfg = load_config(str(env))
assert cfg.local_rtsp == "rtsp://cam/1"
assert cfg.sack_model_path == "/m/sack.pt"
assert cfg.truck_model_path == "/m/truck.pt"
assert cfg.sack_conf == 0.55
assert cfg.truck_conf == 0.50 # default preserved
+60
View File
@@ -0,0 +1,60 @@
"""Smoke tests for LineCrossCounter (src/counting.py). Pure-Python: needs only numpy."""
from src.counting import LineCrossCounter
from src.interfaces import Detection
def _det(tid, y1, cx=500.0):
return Detection(
bbox=(cx - 20, y1, cx + 20, y1 + 60),
confidence=0.9,
class_id=0,
class_name="sack",
track_id=tid,
)
def _counter():
return LineCrossCounter(line_y=100, line_x_start=0, line_x_end=1000, margin=20)
def test_loading_above_then_below():
c = _counter()
assert c.update([_det(1, y1=10)]) == [] # above line
events = c.update([_det(1, y1=150)]) # below line
assert len(events) == 1
assert events[0]["direction"] == "loading"
assert c.loading_count == 1
def test_below_first_counts_as_unloading_not_loading():
c = _counter()
assert c.update([_det(2, y1=150)]) == [] # appeared below first
assert c.update([_det(2, y1=10)]) != [] # moved above => unloading
assert c.unloading_count == 1
assert c.loading_count == 0
def test_track_counted_once_per_direction():
c = _counter()
c.update([_det(3, y1=10)])
c.update([_det(3, y1=150)])
c.update([_det(3, y1=10)])
c.update([_det(3, y1=150)])
assert c.loading_count == 1 # track_id counted once for loading
def test_outside_x_bounds_skipped():
c = _counter()
c.update([_det(4, y1=10, cx=500.0)])
assert c.update([_det(4, y1=150, cx=5000.0)]) == []
assert c.loading_count == 0
def test_net_and_reset():
c = _counter()
c.update([_det(5, y1=10)])
c.update([_det(5, y1=150)])
assert c.net_count == 1
c.reset()
assert (c.loading_count, c.unloading_count, c.net_count) == (0, 0, 0)