From a568ebb1828d8b48f30b914dc2381891ed47f623 Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 10 Sep 2026 11:49:37 +0700 Subject: [PATCH] docs: new repo URL (andrew/...), requirements.txt, pytest smoke tests + CI --- .github/workflows/ci.yml | 17 +++++++ .gitignore | 8 +++ AGENTS.md | 51 +++++++++++++++++++ README.md | 106 +++++++++++++++++++++++++++++++++++++++ docs/deployment.md | 49 ++++++++++++++++++ predict_new.py | 4 +- requirements.txt | 14 ++++++ tests/test_batch.py | 67 +++++++++++++++++++++++++ tests/test_config.py | 28 +++++++++++ tests/test_counting.py | 60 ++++++++++++++++++++++ 10 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 docs/deployment.md create mode 100644 requirements.txt create mode 100644 tests/test_batch.py create mode 100644 tests/test_config.py create mode 100644 tests/test_counting.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..56b95e2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 5e6ccdc..36aedab 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,11 @@ batch_mode.json hasil_perhitungan.json live_status.json batch_*.json +batch_history_folder/ +output/ *.tmp *.tmp.* +*.log # Media & large model binaries *.mp4 @@ -36,6 +39,11 @@ batch_*.json *.zip *.tar.gz +# Test & coverage artifacts +.pytest_cache/ +.coverage +htmlcov/ + # IDE & OS .idea/ .vscode/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..949d541 --- /dev/null +++ b/AGENTS.md @@ -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`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a31a61b --- /dev/null +++ b/README.md @@ -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 diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..13acab8 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,49 @@ +# Deployment (Jetson) + +Repo: `https://git.proit.id/andrew/karung-counting-feedmill-semarang` +(`git remote set-url origin ` 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_.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/`, `/api/recent-batches`, +`/api/available-dates`, `/api/export-daily-csv`, `/api/export-day-csv/` +(CSV + Excel via openpyxl). diff --git a/predict_new.py b/predict_new.py index 8b97337..c91eeff 100644 --- a/predict_new.py +++ b/predict_new.py @@ -336,7 +336,9 @@ class SackCounterPipeline: def save_batch_report(self): """Menulis file laporan batch JSON ketika truk meninggalkan area.""" 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 = { "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), "total_masuk_truck": self.total_masuk, diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..09f1669 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/test_batch.py b/tests/test_batch.py new file mode 100644 index 0000000..4776fbc --- /dev/null +++ b/tests/test_batch.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..6b9c6fe --- /dev/null +++ b/tests/test_config.py @@ -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 diff --git a/tests/test_counting.py b/tests/test_counting.py new file mode 100644 index 0000000..7485013 --- /dev/null +++ b/tests/test_counting.py @@ -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)