fix: gross-based batch discard, direction-correct event logs, wire CSV logger
ci / smoke (push) Canceled after 0s
ci / smoke (push) Canceled after 0s
This commit is contained in:
1 parent
508bee736d
commit
d3357fccef
8 files changed
+199
-21
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
+98
-9
@@ -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,6 +1735,10 @@ def run_prediction(model_path, source_path,
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# 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
@@ -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
@@ -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()}"}
|
||||
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user