diff --git a/AGENTS.md b/AGENTS.md index 3ca5fbb..3f5a325 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,8 +38,8 @@ Indonesian (`karung`=sack, `truk`=truck); YOLO class names are English default seed `batch.default_mode: auto`). **Port split:** `mode` / `model_mode` / `require_plate` / `require_do` POSTs → office `:5721` only (403 on `:5000`); **`ocr_engine` office-only too** - (default `rapid`). Discard batch only if **both** `net_sack==0` and - `net_box==0`. DO helpers: `src/do_batch.py`, OCR: `src/do_ocr.py`; + (default `rapid`). Discard batch only if both **gross** `count==0` and + `box_loading==0` (nets kept in DB/API). DO helpers: `src/do_batch.py`, OCR: `src/do_ocr.py`; ERD: `ERD.md` (repo root). - **Counting filters by class name, not ID**: `SackDetector`/`BoxDetector`/ `TruckDetector` filter via `BaseDetector(class_filter)` (`src/detection.py`); diff --git a/CHANGELOG.md b/CHANGELOG.md index ca7fc87..0e6fd58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ version tags are cut in this repo — POC stage, `main` is the release line). - **DO-gated manual batch mode** (`auto` default · `do_manual` · legacy `manual`): smartphone photo capture → OCR draft → plate-grouped start gates → net expected/counted on live panel → stop soft-warn + force → discard only when - **both** `net_sack` and `net_box` are 0. + **both** gross `count` and `box_loading` are 0. - `config.yaml` `batch.default_mode` + `do:` block; `DoConfig` in `src/config_loader.py`; runtime `$OUTPUT_DIR/do_settings.json`. - `delivery_orders` table + additive `batches` columns (`plate`, `do_numbers`, @@ -40,6 +40,12 @@ version tags are cut in this repo — POC stage, `main` is the release line). and persists sack `unloading` for net-at-stop. - Finalize discard rule (dashboard stop **and** `predict.py` `finalize_batch`): keep row if **either** net ≠ 0 (box-only batches no longer dropped). +- Batch discard rule now gross-based: drop only when `count == 0` **and** + `box_loading == 0` (was both nets 0 — kept rows like `count=0, unloading=7`). +- Event log wording: `masuk`/`keluar` now match event direction (unloading no + longer logged as `masuk`, `TOTAL` line follows direction); wired the + existing-but-unused `src/logger.py` `CSVLogger` into `predict.py` + (`batch_summary.csv`, `sack_events.csv` under `output.dir`). ## 2026-09-18 — Config tuning: Sack confidence and batch timeout diff --git a/counter_dashboard.py b/counter_dashboard.py index cfb2cc7..d067d87 100644 --- a/counter_dashboard.py +++ b/counter_dashboard.py @@ -601,7 +601,7 @@ def api_batch_stop(): net_sack, net_box = net_counts( final_count, unloading, box_final, box_unloading) - discarded = should_discard_batch(net_sack, net_box) + discarded = should_discard_batch(final_count, box_final) if not discarded: conn = get_db() diff --git a/docs/architecture.md b/docs/architecture.md index 602106a..d0730e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,7 +116,11 @@ stats panel (Loading / Unloading / Net / last-3-batch history), and a bottom sta do not add keys here. - `src/logger.py` — `CSVLogger` appends `batch_summary.csv` (`batch_id,start,end,duration,loading,unloading,net`) and `sack_events.csv` - (`timestamp,batch_id,sack_crossed,T-,direction`). + (`timestamp,batch_id,sack_crossed,T-,direction`). **Wired into + `predict.py`**: one `log_event` per crossing in both event loops, one + `log_batch` per kept batch (`finalize_batch` + manual dashboard stop); + files live in `output.dir` next to `jetson_counter.db`, all calls + best-effort (warn on failure, never crash the pipeline). ## Production pipeline (`predict.py`) @@ -142,8 +146,9 @@ dashboard integration is file-based.) `predict.py` reads mode every frame: `auto` → FSM branch; any other valid value → operator/state-file branch (persists `count`, `box_count`, `box_unloading`, -**`unloading`**). Finalize discard = **both nets 0** -(`net_sack = loading − unloading`, `net_box = box_loading − box_unloading`). +**`unloading`**). Finalize discard = **both gross counts 0** (`count == 0` and +`box_loading == 0`). Stored nets: `net_sack = loading − unloading`, +`net_box = box_loading − box_unloading`. DO pipeline (dashboard): smartphone upload → OCR draft → edit/stage → start gates → active batch → stop-preview soft-warn → stop → `batches` + DO columns. diff --git a/predict.py b/predict.py index a957f15..f109e60 100644 --- a/predict.py +++ b/predict.py @@ -23,6 +23,7 @@ from src.stabilizer import BboxStabilizer from src.truck_roi import TruckROITracker from src.counting import LineCrossCounter, MultiClassLineCounter, drop_sacks_overlapping_boxes from src.batch import BatchLifecycleManager, BatchRecord +from src.logger import CSVLogger from src.dashboard import DashboardOverlay from src.config_loader import ( Config, @@ -52,6 +53,7 @@ DAILY_CUTOFF_TIME = os.getenv('DAILY_CUTOFF_TIME', '20:00') BATCH_MERGE_THRESHOLD_SECONDS = int(os.getenv('BATCH_MERGE_THRESHOLD_SECONDS', '300')) active_batch_info = None +batch_logger = None # CSVLogger for batch_summary.csv / sack_events.csv (set in run_prediction) # --- CLI runtime flags (set from parse_args in __main__; defaults = production) --- NO_DASHBOARD = False # --no-dashboard: skip live frame + live_status.json writes @@ -220,6 +222,59 @@ def save_active_batch_state(): except Exception as e: print(f"[DB Error] Gagal menulis {STATE_FILE}: {e}") +def log_crossing_event(ev, counter, batch_id, timestamp): + """Print one crossing event (direction-aware wording) + append CSV event row. + + Shared by the manual and auto event loops. CSV failure only warns — + the counting pipeline must never crash because of logging. + """ + label = "KARUNG" if ev.get("class_name", "sack") == "sack" else "BOX" + is_unloading = ev.get("direction") == "unloading" + if is_unloading: + total = counter.unloading_count if label == "KARUNG" else counter.box_unloading_count + print(f"[{label}] {label.capitalize()} #{ev['track_id']} keluar.") + print(f"[TOTAL] Total {label.lower()} keluar: {total}.") + else: + total = counter.loading_count if label == "KARUNG" else counter.box_loading_count + print(f"[{label}] {label.capitalize()} #{ev['track_id']} masuk.") + print(f"[TOTAL] Total {label.lower()} saat ini: {total}.") + if batch_logger: + try: + batch_logger.log_event( + int(batch_id or 0), ev["track_id"], + ev.get("direction", "loading"), timestamp, + ev.get("class_name", "sack")) + except Exception as e: + print(f"[WARN] CSV log_event gagal: {e}") + +def log_manual_batch_stop(): + """CSV row for a manual batch stopped via dashboard button. + + predict.py does not write manual batches to SQLite (counter_dashboard.py + owns that insert), so this is the only place the CSV row is produced. + """ + info = active_batch_info + if info is None or batch_logger is None: + return + final_count = int(info.get("count", 0) or 0) + box_final = int(info.get("box_count", 0) or 0) + if final_count == 0 and box_final == 0: + return # same discard rule as finalize_batch / dashboard stop + try: + batch_logger.log_batch(BatchRecord( + batch_id=int(info.get("batch_number", 0) or 0), + start_time=datetime.fromisoformat( + info.get("start_time") or datetime.now().isoformat()).timestamp(), + end_time=time.time(), + loading_count=final_count, + unloading_count=int(info.get("unloading", 0) or 0), + box_loading_count=box_final, + box_unloading_count=int(info.get("box_unloading", 0) or 0), + )) + print(f"[CSV Info] Manual batch #{info.get('batch_number')} disimpan ke batch_summary.csv.") + except Exception as e: + print(f"[WARN] CSV log_batch manual gagal: {e}") + def finalize_batch(final_count, start_time_iso, end_time_iso, box_final_count=0, box_unloading_count=0, model_mode="?", unloading_count=0): @@ -234,7 +289,7 @@ def finalize_batch(final_count, start_time_iso, end_time_iso, active_batch_info = None save_active_batch_state() return - if net_sack == 0 and net_box == 0: + if int(final_count) == 0 and int(box_final_count) == 0: print(f"[BATCH] Batch #{active_batch_info.get('batch_number', 0)} bernilai 0 diabaikan (tidak disimpan ke database).") active_batch_info = None save_active_batch_state() @@ -310,6 +365,22 @@ def finalize_batch(final_count, start_time_iso, end_time_iso, f"net karung={net_sack}, net box={net_box}).") except Exception as e: print(f"[DB Error] Gagal menyimpan batch ke database: {e}") + # CSV row for every kept batch (discard/NO_DB early-returns above), even if + # the SQLite write failed — CSV is the fallback record. + if batch_logger: + try: + batch_logger.log_batch(BatchRecord( + batch_id=batch_num, + start_time=datetime.fromisoformat(start_time_iso).timestamp(), + end_time=datetime.fromisoformat(end_time_iso).timestamp(), + loading_count=int(final_count), + unloading_count=int(unloading_count or 0), + box_loading_count=int(box_final_count or 0), + box_unloading_count=int(box_unloading_count or 0), + )) + print(f"[CSV Info] Batch #{batch_num} disimpan ke batch_summary.csv.") + except Exception as e: + print(f"[WARN] CSV log_batch gagal: {e}") active_batch_info = None save_active_batch_state() @@ -1272,6 +1343,7 @@ def run_prediction(model_path, source_path, global MAX_REID_FRAMES, DEBOUNCE_FRAMES global CROSS_CLASS_IOU global DB_PATH, STATE_FILE, BATCH_MODE_FILE, LIVE_STREAM_FRAME_PATH + global batch_logger global CAMERA_NAME, OBJECT_LABEL, DAILY_CUTOFF_TIME, BATCH_MERGE_THRESHOLD_SECONDS global CIRCLE_STAY_TIMEOUT_SEC, _CFG @@ -1309,6 +1381,20 @@ def run_prediction(model_path, source_path, DAILY_CUTOFF_TIME = cfg.batch.daily_cutoff_time BATCH_MERGE_THRESHOLD_SECONDS = cfg.batch.merge_threshold_seconds + # --- CSV logger (batch_summary.csv + sack_events.csv) next to the DB --- + # --no-db means "persist nothing"; construction/call failures only warn. + if NO_DB: + batch_logger = None + else: + _csv_dir = os.path.dirname(DB_PATH) or "." + try: + batch_logger = CSVLogger(_csv_dir) + print(f"[INFO] CSV logger aktif: {os.path.join(_csv_dir, 'batch_summary.csv')}, " + f"{os.path.join(_csv_dir, 'sack_events.csv')}") + except Exception as e: + batch_logger = None + print(f"[WARN] CSV logger gagal diinisialisasi: {e}") + # --- Counting-knob globals from config (values match legacy zones.json) --- CONFIRM_DELAY_SEC = cfg.counting.confirm_delay_sec EXIT_CONFIRM_DELAY_SEC = cfg.counting.exit_confirm_delay_sec @@ -1649,7 +1735,11 @@ def run_prediction(model_path, source_path, except Exception: pass else: - active_batch_info = None + # Stop transition below needs the last batch id/counts/times + # (the dashboard already deleted STATE_FILE) — keep them one + # frame; the stop branch clears them. + if not prev_manual_active: + active_batch_info = None is_batch_active = manual_batch_exists @@ -1662,6 +1752,8 @@ def run_prediction(model_path, source_path, system_state = STATE_COUNTING_SACKS elif not is_batch_active and prev_manual_active: print(f"[BATCH] Manual Batch dihentikan via Tombol.") + log_manual_batch_stop() + active_batch_info = None system_state = STATE_WAITING_FOR_TRUCK counter.reset() stabilizer.reset() @@ -1673,10 +1765,8 @@ def run_prediction(model_path, source_path, tracked_sacks = _filter_sacks_in_roi(stable_all, static_roi) events = counter.update(tracked_sacks) for ev in events: - _label = "KARUNG" if ev.get("class_name", "sack") == "sack" else "BOX" - _total = counter.loading_count if _label == "KARUNG" else counter.box_loading_count - print(f"[{_label}] {_label.capitalize()} #{ev['track_id']} masuk.") - print(f"[TOTAL] Total {_label.lower()} saat ini: {_total}.") + log_crossing_event(ev, counter, + active_batch_info["batch_number"], timestamp) if 'cx' in ev and 'cy' in ev: counted_sack_positions.append((ev['cx'], ev['cy'], time.time(), ev['track_id'])) @@ -1725,10 +1815,9 @@ def run_prediction(model_path, source_path, batch_mgr.update_truck(activity_detected, None, timestamp) for ev in events: - _label = "KARUNG" if ev.get("class_name", "sack") == "sack" else "BOX" - _total = counter.loading_count if _label == "KARUNG" else counter.box_loading_count - print(f"[{_label}] {_label.capitalize()} #{ev['track_id']} masuk.") - print(f"[TOTAL] Total {_label.lower()} saat ini: {_total}.") + log_crossing_event(ev, counter, + active_batch_info["batch_number"] if active_batch_info else 0, + timestamp) if 'cx' in ev and 'cy' in ev: counted_sack_positions.append((ev['cx'], ev['cy'], time.time(), ev['track_id'])) diff --git a/src/do_batch.py b/src/do_batch.py index 6d74e6f..107b609 100644 --- a/src/do_batch.py +++ b/src/do_batch.py @@ -37,9 +37,9 @@ def net_counts( return int(loading) - int(unloading), int(box_loading) - int(box_unloading) -def should_discard_batch(net_sack: int, net_box: int) -> bool: - """Discard only when both nets are 0. Keep box-only or sack-only rows.""" - return int(net_sack) == 0 and int(net_box) == 0 +def should_discard_batch(count: int, box_loading: int) -> bool: + """Discard only when BOTH gross loading counts are 0. Nets are irrelevant.""" + return int(count) == 0 and int(box_loading) == 0 def classify_unit_token(token: Optional[str]) -> str: diff --git a/tests/test_do_batch.py b/tests/test_do_batch.py index c381ee0..2e54e75 100644 --- a/tests/test_do_batch.py +++ b/tests/test_do_batch.py @@ -39,7 +39,8 @@ def test_net_counts_and_discard(): assert should_discard_batch(0, 0) is True assert should_discard_batch(1, 0) is False assert should_discard_batch(0, 1) is False - assert should_discard_batch(-1, 0) is False + assert should_discard_batch(0, 7) is False + assert should_discard_batch(5, 0) is False def test_classify_unit_tokens(): @@ -265,7 +266,7 @@ def test_start_stop_auto_409(dash_client): assert res.status_code == 409 -def test_stop_discard_both_nets_zero(dash_client): +def test_stop_discard_gross_zero(dash_client): client, mode_path, state_path, _ = dash_client office = {"Host": f"localhost:{cd_office_port()}"} client.post("/api/batch/mode", json={"mode": "manual"}, headers=office) @@ -283,6 +284,24 @@ def test_stop_discard_both_nets_zero(dash_client): assert not state_path.exists() +def test_stop_discards_zero_loading_with_unloading(dash_client): + client, mode_path, state_path, _ = dash_client + office = {"Host": f"localhost:{cd_office_port()}"} + client.post("/api/batch/mode", json={"mode": "manual"}, headers=office) + state_path.write_text(json.dumps({ + "counting_date": "2026-09-24", "batch_number": 97, + "count": 0, "box_count": 0, "box_unloading": 0, "unloading": 7, + "start_time": "2026-09-24T00:00:00", + "model_mode": "C", + })) + res = client.post("/api/batch/stop", headers={"Host": "localhost:5000"}) + body = res.get_json() + assert res.status_code == 200 + assert body["discarded"] is True + assert body["net_sack"] == -7 + assert not state_path.exists() + + def test_stop_keeps_box_only_batch(dash_client): client, mode_path, state_path, _ = dash_client office = {"Host": f"localhost:{cd_office_port()}"} diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 0000000..ca855a9 --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,59 @@ +"""Smoke tests for src/logger.py (CSVLogger) — stdlib only, no flask/cv2.""" + +import csv + +from src.batch import BatchRecord +from src.logger import CSVLogger + + +def test_log_batch_and_log_event_write_csvs(tmp_path): + logger = CSVLogger(str(tmp_path)) + rec = BatchRecord( + batch_id=7, + start_time=1700000000.0, + end_time=1700000060.0, + loading_count=5, + unloading_count=2, + box_loading_count=1, + box_unloading_count=0, + ) + logger.log_batch(rec) + logger.log_event(7, 123, "unloading", 1700000030.0, "sack") + + batch_csv = tmp_path / "batch_summary.csv" + event_csv = tmp_path / "sack_events.csv" + assert batch_csv.exists() and batch_csv.stat().st_size > 0 + assert event_csv.exists() and event_csv.stat().st_size > 0 + + with open(batch_csv, newline="") as f: + rows = list(csv.reader(f)) + assert rows[0][0] == "batch_id" + data = rows[1] + assert data[0] == "7" + assert data[3] == "60.0" # duration_s + assert data[4] == "5" # loading + assert data[5] == "2" # unloading + assert data[6] == "3" # net + assert data[7] == "1" # box_loading + + with open(event_csv, newline="") as f: + erows = list(csv.reader(f)) + assert erows[0][:5] == ["timestamp", "batch_id", "event", "track_id", "direction"] + ev = erows[1] + assert ev[1] == "7" + assert ev[2] == "sack_crossed" + assert ev[3] == "T-0123" + assert ev[4] == "unloading" + + +def test_logger_appends_rows_and_creates_nested_dir(tmp_path): + nested = tmp_path / "a" / "b" + logger = CSVLogger(str(nested)) + logger.log_batch(BatchRecord(batch_id=1, start_time=0.0, end_time=1.0, + loading_count=1, unloading_count=0)) + logger.log_batch(BatchRecord(batch_id=2, start_time=0.0, end_time=1.0, + loading_count=2, unloading_count=0)) + with open(nested / "batch_summary.csv", newline="") as f: + rows = list(csv.reader(f)) + # header written once, both batches appended + assert [r[0] for r in rows[1:]] == ["1", "2"]