fix: gross-based batch discard, direction-correct event logs, wire CSV logger
ci / smoke (push) Canceled after 0s

This commit is contained in:
andrew committed 2026-09-28 12:08:37 +07:00
1 parent 508bee736d
commit d3357fccef
8 files changed
+200 -22

No files matched your search

+2 -2
View File
@@ -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`);
+7 -1
View File
@@ -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
+1 -1
View File
@@ -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()
+8 -3
View File
@@ -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-<id>,direction`).
(`timestamp,batch_id,sack_crossed,T-<id>,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.
+99 -10
View File
@@ -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']))
+3 -3
View File
@@ -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:
+21 -2
View File
@@ -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()}"}
+59
View File
@@ -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"]