feat: multi-model modes (A/B/C/D, default B) + box counting + manual-mode banner
ci / smoke (push) Canceled after 0s
ci / smoke (push) Canceled after 0s
This commit is contained in:
1 parent
52d3127302
commit
6e4af50646
14 files changed
+809
-118
No files matched your search
@@ -17,3 +17,8 @@ FLASK_DEBUG=false
|
||||
|
||||
# Stream URL
|
||||
RTSP_URL=rtsp://user:pass@192.168.192.209:8554/camera_stream_640
|
||||
|
||||
# Model pipeline mode: A=combined only; B=v4 truck + yolo11n sack+box (default);
|
||||
# C=A + yolo11n box-only; D=v4 truck + best sack-only + yolo11n box-only.
|
||||
# Dashboard switches persist here and apply on next service restart.
|
||||
MODEL_MODE=B
|
||||
@@ -10,10 +10,13 @@ Indonesian (`karung`=sack, `truk`=truck); YOLO class names are English
|
||||
|
||||
- `predict.py` = **production AND dev CLI** (runs as `karung-counter.service` with
|
||||
zero args). Dev flags: `--source VID --env .env --model X --output-dir D
|
||||
--output-json F --sack-conf C --truck-conf C --box-conf C(placeholder, unused)
|
||||
--batch-timeout S --max-frames N --no-dashboard --no-db`. Zero flags = systemd
|
||||
behaviour. Combined sack+truck model + `src/` modules, shapely zones, SQLite,
|
||||
live-frame publish.
|
||||
--output-json F --sack-conf C --truck-conf C --box-conf C --box-model P
|
||||
--model-mode A|B|C|D --batch-timeout S --max-frames N --no-dashboard --no-db`.
|
||||
Zero flags = systemd behaviour (`MODEL_MODE` env, default B).
|
||||
Model modes: A=combined only; B=v4 truck + yolo11n sack+box (default);
|
||||
C=A + yolo11n box-only; D=v4 truck + best sack-only + yolo11n box-only.
|
||||
All modes load `.engine` files (2-3 coexist, ~24 MB peak); never mix load
|
||||
order assumptions — PyTorch `.pt` must load before TensorRT `.engine`.
|
||||
- `src/` = shared library (detection/tracking/counting/batch). `python -m src.main`
|
||||
still works but prints a deprecation pointer to `predict.py`.
|
||||
- `archive/` = retired experiments (`predict_new.py`, `rpo_iki/`, `simple_predict.py`,
|
||||
@@ -26,10 +29,11 @@ Indonesian (`karung`=sack, `truk`=truck); YOLO class names are English
|
||||
… (`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.
|
||||
- **Counting filters by class name, not ID**: `SackDetector`/`BoxDetector`/
|
||||
`TruckDetector` filter via `BaseDetector(class_filter)` (`src/detection.py`);
|
||||
tracker keeps `("sack", "truck", "box")` (`src/tracking.py`); counting uses
|
||||
`MultiClassLineCounter` = dual `LineCrossCounter`s on one shared line
|
||||
(`src/counting.py`). Same line geometry + 30px dedup for sacks and boxes.
|
||||
- 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
|
||||
|
||||
+126
-20
@@ -110,6 +110,16 @@ def _ensure_db():
|
||||
)
|
||||
"""
|
||||
)
|
||||
# Additive migration for box counting + model mode (idempotent).
|
||||
cur.execute("PRAGMA table_info(batches)")
|
||||
_cols = {r[1] for r in cur.fetchall()}
|
||||
for _col, _typ in (
|
||||
("box_loading", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("box_unloading", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("model_mode", "TEXT NOT NULL DEFAULT 'A'"),
|
||||
):
|
||||
if _col not in _cols:
|
||||
cur.execute(f"ALTER TABLE batches ADD COLUMN {_col} {_typ}")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -122,6 +132,21 @@ def get_db():
|
||||
return conn
|
||||
|
||||
|
||||
def _batches_box_select():
|
||||
"""Box/model select exprs — real columns when migrated, literals otherwise."""
|
||||
try:
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute("PRAGMA table_info(batches)")
|
||||
cols = {r[1] for r in cur.fetchall()}
|
||||
conn.close()
|
||||
except Exception:
|
||||
cols = set()
|
||||
if {"box_loading", "box_unloading", "model_mode"} <= cols:
|
||||
return "box_loading, box_unloading, model_mode"
|
||||
return "0 AS box_loading, 0 AS box_unloading, 'A' AS model_mode"
|
||||
|
||||
|
||||
def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):
|
||||
if dt is None:
|
||||
dt = datetime.now()
|
||||
@@ -223,6 +248,9 @@ def api_batch_start():
|
||||
"counting_date": counting_date,
|
||||
"batch_number": batch_num,
|
||||
"count": 0,
|
||||
"box_count": 0,
|
||||
"box_unloading": 0,
|
||||
"model_mode": _read_batch_mode_file()["model_mode"],
|
||||
"start_time": now_iso,
|
||||
"last_detection_time": now_iso,
|
||||
"manual_control": True
|
||||
@@ -260,6 +288,9 @@ def api_batch_stop():
|
||||
counting_date = curr["counting_date"]
|
||||
batch_num = curr["batch_number"]
|
||||
final_count = curr.get("count", 0)
|
||||
box_final = curr.get("box_count", 0)
|
||||
box_unloading = curr.get("box_unloading", 0)
|
||||
model_mode = curr.get("model_mode", _read_batch_mode_file()["model_mode"])
|
||||
start_time_iso = curr.get("start_time", datetime.now().isoformat())
|
||||
end_time_iso = datetime.now().isoformat()
|
||||
|
||||
@@ -274,6 +305,17 @@ def api_batch_stop():
|
||||
""",
|
||||
(counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL, final_count, start_time_iso, end_time_iso),
|
||||
)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE batches SET box_loading = ?, box_unloading = ?, model_mode = ?
|
||||
WHERE counting_date = ? AND batch_number = ? AND camera_name = ? AND object_label = ?
|
||||
""",
|
||||
(box_final, box_unloading, model_mode,
|
||||
counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -309,6 +351,7 @@ def api_batch_stop():
|
||||
"message": f"Batch #{batch_num} stopped",
|
||||
"batch_number": batch_num,
|
||||
"final_count": final_count,
|
||||
"box_final_count": box_final,
|
||||
"start_time": start_time_iso,
|
||||
"end_time": end_time_iso
|
||||
})
|
||||
@@ -316,33 +359,74 @@ def api_batch_stop():
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
MODEL_MODE_CHOICES = ("A", "B", "C", "D")
|
||||
MODEL_MODE_DESCRIPTIONS = {
|
||||
"A": "Combined v4 sack+truck only (legacy)",
|
||||
"B": "v4 truck-only + yolo11n sack+box (default production)",
|
||||
"C": "Combined v4 sack+truck + yolo11n box-only",
|
||||
"D": "v4 truck + best.pt sack-only + yolo11n box-only",
|
||||
}
|
||||
|
||||
|
||||
def _read_batch_mode_file():
|
||||
data = {"mode": "manual", "model_mode": "B"}
|
||||
if os.path.exists(BATCH_MODE_PATH):
|
||||
try:
|
||||
with open(BATCH_MODE_PATH, "r") as f:
|
||||
stored = json.load(f)
|
||||
data["mode"] = stored.get("mode", "manual")
|
||||
data["model_mode"] = (stored.get("model_mode") or "B").upper()
|
||||
if data["model_mode"] not in MODEL_MODE_CHOICES:
|
||||
data["model_mode"] = "B"
|
||||
except Exception:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
@app.route("/api/batch/mode", methods=["GET", "POST"])
|
||||
def api_batch_mode():
|
||||
if request.method == "POST":
|
||||
try:
|
||||
req_data = request.get_json(silent=True) or request.form
|
||||
mode = req_data.get("mode", "manual").lower()
|
||||
if mode not in ["auto", "manual"]:
|
||||
return jsonify({"success": False, "error": "Invalid mode. Use 'auto' or 'manual'"}), 400
|
||||
stored = _read_batch_mode_file()
|
||||
if "mode" in req_data:
|
||||
mode = str(req_data.get("mode", "manual")).lower()
|
||||
if mode not in ["auto", "manual"]:
|
||||
return jsonify({"success": False, "error": "Invalid mode. Use 'auto' or 'manual'"}), 400
|
||||
stored["mode"] = mode
|
||||
if "model_mode" in req_data:
|
||||
mmode = str(req_data.get("model_mode", "B")).upper()
|
||||
if mmode not in MODEL_MODE_CHOICES:
|
||||
return jsonify({"success": False, "error": "Invalid model_mode. Use A/B/C/D"}), 400
|
||||
stored["model_mode"] = mmode
|
||||
stored["updated_at"] = datetime.now().isoformat()
|
||||
|
||||
os.makedirs(os.path.dirname(BATCH_MODE_PATH), exist_ok=True)
|
||||
with open(BATCH_MODE_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump({"mode": mode, "updated_at": datetime.now().isoformat()}, f, indent=2)
|
||||
json.dump(stored, f, indent=2)
|
||||
|
||||
return jsonify({"success": True, "mode": mode, "message": f"Batch mode switched to {mode}"})
|
||||
return jsonify({"success": True, "mode": stored["mode"],
|
||||
"model_mode": stored["model_mode"],
|
||||
"message": f"Batch mode={stored['mode']}, model_mode={stored['model_mode']} "
|
||||
f"(model_mode applies on next service restart)"})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
# GET method
|
||||
current_mode = "manual"
|
||||
if os.path.exists(BATCH_MODE_PATH):
|
||||
try:
|
||||
with open(BATCH_MODE_PATH, "r") as f:
|
||||
data = json.load(f)
|
||||
current_mode = data.get("mode", "manual")
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"success": True, "mode": current_mode})
|
||||
stored = _read_batch_mode_file()
|
||||
return jsonify({"success": True, "mode": stored["mode"],
|
||||
"model_mode": stored["model_mode"]})
|
||||
|
||||
|
||||
@app.route("/api/model-modes")
|
||||
def api_model_modes():
|
||||
stored = _read_batch_mode_file()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"active": stored["model_mode"],
|
||||
"modes": [{"id": m, "description": d}
|
||||
for m, d in MODEL_MODE_DESCRIPTIONS.items()],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/current-batch")
|
||||
@@ -360,23 +444,32 @@ def api_current_batch():
|
||||
try:
|
||||
with open(CURRENT_BATCH_PATH, "r") as f:
|
||||
data = json.load(f)
|
||||
stored = _read_batch_mode_file()
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"counting_date": data.get("counting_date"),
|
||||
"batch_number": data.get("batch_number"),
|
||||
"count": data.get("count", 0),
|
||||
"box_count": data.get("box_count", 0),
|
||||
"box_unloading": data.get("box_unloading", 0),
|
||||
"model_mode": data.get("model_mode", stored["model_mode"]),
|
||||
"mode": stored["mode"],
|
||||
"start_time": data.get("start_time"),
|
||||
"last_detection_time": data.get("last_detection_time"),
|
||||
"fps": fps_val
|
||||
}
|
||||
)
|
||||
except FileNotFoundError:
|
||||
stored = _read_batch_mode_file()
|
||||
return jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": "No active batch",
|
||||
"count": 0,
|
||||
"box_count": 0,
|
||||
"model_mode": stored["model_mode"],
|
||||
"mode": stored["mode"],
|
||||
"batch_number": None,
|
||||
"counting_date": None,
|
||||
"fps": fps_val
|
||||
@@ -388,6 +481,7 @@ def api_current_batch():
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"count": 0,
|
||||
"box_count": 0,
|
||||
"batch_number": None,
|
||||
"counting_date": None,
|
||||
"fps": fps_val
|
||||
@@ -401,9 +495,10 @@ def api_previous_batch():
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT counting_date, batch_number, count, start_time, end_time,
|
||||
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
||||
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes,
|
||||
{_batches_box_select()}
|
||||
FROM batches
|
||||
ORDER BY end_time DESC
|
||||
LIMIT 1
|
||||
@@ -418,6 +513,8 @@ def api_previous_batch():
|
||||
"date": row["counting_date"],
|
||||
"batch_number": row["batch_number"],
|
||||
"count": row["count"],
|
||||
"box_loading": row["box_loading"],
|
||||
"model_mode": row["model_mode"],
|
||||
"start_time": row["start_time"],
|
||||
"end_time": row["end_time"],
|
||||
"duration_minutes": row["duration_minutes"],
|
||||
@@ -548,9 +645,10 @@ def api_day_detail(date):
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT batch_number, count, start_time, end_time,
|
||||
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
||||
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes,
|
||||
{_batches_box_select()}
|
||||
FROM batches
|
||||
WHERE counting_date = ?
|
||||
ORDER BY batch_number ASC
|
||||
@@ -559,13 +657,17 @@ def api_day_detail(date):
|
||||
)
|
||||
batches = []
|
||||
total_duration = 0
|
||||
total_boxes = 0
|
||||
for row in cur.fetchall():
|
||||
duration = row["duration_minutes"] or 0
|
||||
total_duration += duration
|
||||
total_boxes += row["box_loading"] or 0
|
||||
batches.append(
|
||||
{
|
||||
"batch_number": row["batch_number"],
|
||||
"count": row["count"],
|
||||
"box_loading": row["box_loading"],
|
||||
"model_mode": row["model_mode"],
|
||||
"start_time": row["start_time"],
|
||||
"end_time": row["end_time"],
|
||||
"duration_minutes": duration,
|
||||
@@ -586,6 +688,7 @@ def api_day_detail(date):
|
||||
"date": date,
|
||||
"total_count": summary["total_count"] if summary else 0,
|
||||
"total_batches": summary["total_batches"] if summary else 0,
|
||||
"total_boxes": total_boxes,
|
||||
"total_duration_minutes": round(total_duration, 1),
|
||||
"avg_duration_minutes": round(total_duration / len(batches), 1) if batches else 0,
|
||||
"batches": batches,
|
||||
@@ -604,9 +707,10 @@ def api_recent_batches():
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT counting_date, batch_number, count, start_time, end_time,
|
||||
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes
|
||||
ROUND((julianday(end_time) - julianday(start_time)) * 24 * 60, 1) as duration_minutes,
|
||||
{_batches_box_select()}
|
||||
FROM batches
|
||||
ORDER BY end_time DESC
|
||||
LIMIT ?
|
||||
@@ -618,6 +722,8 @@ def api_recent_batches():
|
||||
"date": row["counting_date"],
|
||||
"batch_number": row["batch_number"],
|
||||
"count": row["count"],
|
||||
"box_loading": row["box_loading"],
|
||||
"model_mode": row["model_mode"],
|
||||
"start_time": row["start_time"],
|
||||
"end_time": row["end_time"],
|
||||
"duration_minutes": row["duration_minutes"] or 0,
|
||||
|
||||
+16
-11
@@ -12,7 +12,7 @@ see `export_model.py` / `export_v4.py`).
|
||||
| `model_karung_truk.pt` | `{0: sack, 1: truck}` | bbox | Legacy default combined model in `predict.py` (auto-picked `MODEL_PATH`) |
|
||||
| `v4-best.pt` (`v4-best (1).pt` = copy) | `{0: sack, 1: truck}` | bbox | Alternate combined model |
|
||||
| `karung-dimuat-detection-di-feedmill-yolo26n-seg-200e.pt` | `{0: person, 1: sack}` | **seg** | Sack segmentation + person exclusion |
|
||||
| `yolo11n-bbox-100ep-sack+box-20260909-best.pt` | `{0: sack, 1: box}` | bbox | **New: sacks and boxes in one model** (nothing in code consumes class `box` yet) |
|
||||
| `yolo11n-bbox-100ep-sack+box-20260909-best.pt` (`.engine`) | `{0: sack, 1: box}` | bbox | Sack+box model, consumed via `BoxDetector` + shared tracker |
|
||||
| `best.pt` | `{0: sack}` | seg | Sack-only baseline (`simple_predict.py`) |
|
||||
|
||||
Model registry for the `rpo_iki` engine: `rpo_iki/configs/model_registry.json`
|
||||
@@ -42,16 +42,21 @@ Two dedicated models run in parallel on the same frames:
|
||||
- Rule of thumb: **only the sack/karung class is ever counted**; other classes are
|
||||
auxiliary (ROI, visualization, exclusion).
|
||||
|
||||
### 3. Hybrid: 1 specialized + 1 general model — YES; boxes+sacks NOT wired yet
|
||||
- The deployed hybrid is **specialized truck + general sack model** (`src/main.py`).
|
||||
- For **boxes + sacks**: the checkpoint exists
|
||||
(`yolo11n-bbox-100ep-sack+box-20260909-best.pt`, `{sack, box}`) but **no code path
|
||||
consumes class `box` today** — every parse/count filter keeps `sack` (and `truck`)
|
||||
only. Wiring it in means:
|
||||
1. Point the tracker/detector at the sack+box weights,
|
||||
2. Extend the `_parse` allow-lists (`src/detection.py:34`, `src/tracking.py:62`),
|
||||
3. Decide counting semantics per class (shared `LineCrossCounter` vs one counter per
|
||||
class — the `Detection.class_name` field already carries what you need).
|
||||
### 3. Hybrid multi-model — YES (modes A/B/C/D, default B)
|
||||
- `predict.py --model-mode` (or `MODEL_MODE` env / dashboard `/api/batch/mode`):
|
||||
|
||||
| Mode | Truck | Sack | Box |
|
||||
|---|---|---|---|
|
||||
| A | v4 (sack+truck) | v4 | — |
|
||||
| B | v4 (truck-only) | yolo11n | yolo11n |
|
||||
| C | v4 (sack+truck) | v4 | yolo11n (box-only) |
|
||||
| D | v4 (truck-only) | best (sack-only) | yolo11n (box-only) |
|
||||
|
||||
- All weights load as `.engine` (verified coexist: 2 engines ~16 MB, 3 engines
|
||||
~24 MB peak of 7.6 GB). Dashboard switches persist to `batch_mode.json` and
|
||||
apply on next service restart (models load once at startup).
|
||||
- Counting: `MultiClassLineCounter` = dual `LineCrossCounter`s on one shared line;
|
||||
events tagged with `class_name`; sack and box track IDs live in separate spaces.
|
||||
|
||||
## Choosing / swapping a model
|
||||
- `src/` pipeline: `MODEL_SACK_PATH` / `MODEL_TRUCK_PATH` env vars (see
|
||||
|
||||
+256
-36
@@ -17,11 +17,11 @@ from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
# Import repo rafan modules
|
||||
from src.detection import SackDetector, TruckDetector
|
||||
from src.detection import SackDetector, TruckDetector, BoxDetector
|
||||
from src.tracking import ByteTrackTracker
|
||||
from src.stabilizer import BboxStabilizer
|
||||
from src.truck_roi import TruckROITracker
|
||||
from src.counting import LineCrossCounter
|
||||
from src.counting import LineCrossCounter, MultiClassLineCounter
|
||||
from src.batch import BatchLifecycleManager, BatchRecord
|
||||
from src.dashboard import DashboardOverlay
|
||||
|
||||
@@ -71,7 +71,14 @@ def parse_args():
|
||||
p.add_argument("--truck-conf", type=float, default=None,
|
||||
help="Override truck detection confidence (default 0.35)")
|
||||
p.add_argument("--box-conf", type=float, default=None,
|
||||
help="PLACEHOLDER for future box detector (currently unused)")
|
||||
help="Override box detection confidence (default 0.35)")
|
||||
p.add_argument("--box-model", type=str, default=None,
|
||||
help="Override yolo11n sack+box model path (.pt/.engine)")
|
||||
p.add_argument("--model-mode", type=str, default=None,
|
||||
choices=["A", "B", "C", "D"],
|
||||
help="Model pipeline mode (default: MODEL_MODE env or B). "
|
||||
"A=combined only; B=v4 truck + yolo11n sack+box; "
|
||||
"C=A + yolo11n box-only; D=v4 truck + best sack + yolo11n box")
|
||||
p.add_argument("--batch-timeout", type=float, default=None,
|
||||
help="Override sack-idle + truck-gone timeouts (seconds)")
|
||||
p.add_argument("--max-frames", type=int, default=None,
|
||||
@@ -132,6 +139,16 @@ def init_db():
|
||||
UNIQUE(counting_date, camera_name, object_label)
|
||||
)
|
||||
""")
|
||||
# Additive migration for box counting + model mode (idempotent).
|
||||
cur.execute("PRAGMA table_info(batches)")
|
||||
_cols = {r[1] for r in cur.fetchall()}
|
||||
for _col, _typ in (
|
||||
("box_loading", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("box_unloading", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("model_mode", "TEXT NOT NULL DEFAULT 'A'"),
|
||||
):
|
||||
if _col not in _cols:
|
||||
cur.execute(f"ALTER TABLE batches ADD COLUMN {_col} {_typ}")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"[DB Info] Inisialisasi SQLite database berhasil: {DB_PATH}")
|
||||
@@ -194,13 +211,14 @@ def save_active_batch_state():
|
||||
except Exception as e:
|
||||
print(f"[DB Error] Gagal menulis {STATE_FILE}: {e}")
|
||||
|
||||
def finalize_batch(final_count, start_time_iso, end_time_iso):
|
||||
def finalize_batch(final_count, start_time_iso, end_time_iso,
|
||||
box_final_count=0, box_unloading_count=0, model_mode="?"):
|
||||
global active_batch_info
|
||||
if active_batch_info is None:
|
||||
return
|
||||
if NO_DB:
|
||||
print(f"[DB Info] --no-db: batch #{active_batch_info.get('batch_number', 0)} "
|
||||
f"({final_count} karung) tidak disimpan.")
|
||||
f"({final_count} karung, {box_final_count} box) tidak disimpan.")
|
||||
active_batch_info = None
|
||||
return
|
||||
if final_count == 0:
|
||||
@@ -236,10 +254,22 @@ def finalize_batch(final_count, start_time_iso, end_time_iso):
|
||||
(counting_date, camera_name, object_label, total_count, total_batches, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
""", (counting_date, CAMERA_NAME, OBJECT_LABEL, tot_count, tot_batches))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Box columns may not exist on pre-migration DBs — tolerant update.
|
||||
try:
|
||||
cur2 = conn.cursor()
|
||||
cur2.execute("""
|
||||
UPDATE batches SET box_loading = ?, box_unloading = ?, model_mode = ?
|
||||
WHERE counting_date = ? AND batch_number = ? AND camera_name = ? AND object_label = ?
|
||||
""", (box_final_count, box_unloading_count, model_mode,
|
||||
counting_date, batch_num, CAMERA_NAME, OBJECT_LABEL))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
conn.close()
|
||||
print(f"[DB Info] Sesi batch #{batch_num} disimpan ke database SQLite: {final_count} karung.")
|
||||
print(f"[DB Info] Sesi batch #{batch_num} disimpan ke database SQLite: "
|
||||
f"{final_count} karung, {box_final_count} box (mode {model_mode}).")
|
||||
except Exception as e:
|
||||
print(f"[DB Error] Gagal menyimpan batch ke database: {e}")
|
||||
active_batch_info = None
|
||||
@@ -429,6 +459,123 @@ else:
|
||||
]
|
||||
COMBINED_MODEL_PATH = next((p for p in _candidates if os.path.exists(p)), "v4-best.pt")
|
||||
|
||||
|
||||
def _pick_box_model(explicit: str | None) -> str:
|
||||
"""Resolve yolo11n sack+box weights: explicit > .engine > .pt."""
|
||||
if explicit:
|
||||
return explicit
|
||||
for cand in (
|
||||
os.path.join(_BASE_DIR, "yolo11n-bbox-100ep-sack+box-20260909-best.engine"),
|
||||
os.path.join(_BASE_DIR, "yolo11n-bbox-100ep-sack+box-20260909-best.pt"),
|
||||
):
|
||||
if os.path.exists(cand):
|
||||
return cand
|
||||
return os.path.join(_BASE_DIR, "yolo11n-bbox-100ep-sack+box-20260909-best.engine")
|
||||
|
||||
|
||||
def _pick_sack_only_model() -> str:
|
||||
"""Resolve best.pt sack-only weights: .engine > .pt."""
|
||||
for cand in (
|
||||
os.path.join(_BASE_DIR, "best.engine"),
|
||||
os.path.join(_BASE_DIR, "best.pt"),
|
||||
):
|
||||
if os.path.exists(cand):
|
||||
return cand
|
||||
return os.path.join(_BASE_DIR, "best.engine")
|
||||
|
||||
|
||||
# --- Model pipeline modes -------------------------------------------------
|
||||
# A: combined v4 sack+truck only (legacy production).
|
||||
# B: v4 truck-only + yolo11n sack+box (default production).
|
||||
# C: A + yolo11n box-only.
|
||||
# D: v4 truck + best.pt sack-only + yolo11n box-only.
|
||||
# All engines verified to coexist (~16-24 MB peak of 7.6 GB).
|
||||
MODEL_MODES = ("A", "B", "C", "D")
|
||||
|
||||
|
||||
def resolve_model_mode(explicit: str | None) -> str:
|
||||
"""Precedence: --model-mode > MODEL_MODE env > batch_mode.json model_mode > B.
|
||||
|
||||
Dashboard-driven switches persist to batch_mode.json and take effect
|
||||
on next service restart (models are loaded once at startup).
|
||||
"""
|
||||
mode = (explicit or os.getenv("MODEL_MODE") or "").upper()
|
||||
if not mode:
|
||||
try:
|
||||
_bm = os.path.join(
|
||||
os.getenv("OUTPUT_DIR", "/opt/jetson-counter") if os.name != "nt"
|
||||
else "d:/Belajar/menghitung karung",
|
||||
"batch_mode.json",
|
||||
)
|
||||
_bm = os.getenv("BATCH_MODE_FILE", _bm)
|
||||
with open(_bm, "r", encoding="utf-8") as f:
|
||||
mode = (json.load(f).get("model_mode") or "").upper()
|
||||
except Exception:
|
||||
pass
|
||||
mode = mode or "B"
|
||||
if mode not in MODEL_MODES:
|
||||
print(f"[WARN] MODEL_MODE '{mode}' tidak dikenal, pakai B.")
|
||||
return "B"
|
||||
return mode
|
||||
|
||||
|
||||
def build_model_pipeline(mode, combined_path, box_model_path, sack_only_path,
|
||||
sack_conf, truck_conf, box_conf, device):
|
||||
"""Instantiate YOLO handles + detectors/trackers for a model mode.
|
||||
|
||||
Returns dict with keys: mode, truck_model, sack_model, box_model,
|
||||
truck_detector, tracker, box_tracker (None when sack tracker covers boxes).
|
||||
"""
|
||||
print(f"[INFO] Model mode: {mode}")
|
||||
dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
|
||||
|
||||
def _load(path, label):
|
||||
print(f"[INFO] Memuat {label}: {path}")
|
||||
m = YOLO(path)
|
||||
_ = m(dummy, imgsz=640, device=device, verbose=False) # warm-up CUDA/TRT ctx
|
||||
return m
|
||||
|
||||
if mode == "A":
|
||||
shared = _load(combined_path, "model gabungan sack+truck")
|
||||
return {
|
||||
"mode": mode,
|
||||
"truck_detector": TruckDetector(shared, truck_conf),
|
||||
"tracker": ByteTrackTracker(shared, sack_conf),
|
||||
"box_tracker": None,
|
||||
"separate_truck_model": False,
|
||||
}
|
||||
if mode == "B":
|
||||
v4 = _load(combined_path, "model v4 (truck-only)")
|
||||
yb = _load(box_model_path, "model yolo11n (sack+box)")
|
||||
return {
|
||||
"mode": mode,
|
||||
"truck_detector": TruckDetector(v4, truck_conf, class_filter=("truck",)),
|
||||
"tracker": ByteTrackTracker(yb, sack_conf),
|
||||
"box_tracker": None, # boxes share the yolo11n tracker
|
||||
"separate_truck_model": True,
|
||||
}
|
||||
if mode == "C":
|
||||
shared = _load(combined_path, "model gabungan sack+truck")
|
||||
yb = _load(box_model_path, "model yolo11n (box-only)")
|
||||
return {
|
||||
"mode": mode,
|
||||
"truck_detector": TruckDetector(shared, truck_conf),
|
||||
"tracker": ByteTrackTracker(shared, sack_conf),
|
||||
"box_tracker": ByteTrackTracker(yb, box_conf),
|
||||
"separate_truck_model": False,
|
||||
}
|
||||
# mode == "D"
|
||||
v4 = _load(combined_path, "model v4 (truck-only)")
|
||||
sb = _load(sack_only_path, "model best (sack-only)")
|
||||
yb = _load(box_model_path, "model yolo11n (box-only)")
|
||||
return {
|
||||
"mode": mode,
|
||||
"truck_detector": TruckDetector(v4, truck_conf, class_filter=("truck",)),
|
||||
"tracker": ByteTrackTracker(sb, sack_conf),
|
||||
"box_tracker": ByteTrackTracker(yb, box_conf),
|
||||
"separate_truck_model": True,
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
|
||||
# =====================================================================
|
||||
@@ -569,7 +716,9 @@ track_is_valid_bag = defaultdict(bool)
|
||||
|
||||
metrics = {
|
||||
"total_masuk": 0,
|
||||
"total_keluar": 0
|
||||
"total_keluar": 0,
|
||||
"box_masuk": 0,
|
||||
"box_keluar": 0
|
||||
}
|
||||
|
||||
MAX_REID_DISTANCE = 120
|
||||
@@ -1050,7 +1199,8 @@ def _filter_sacks_in_roi(detections, roi):
|
||||
def run_prediction(model_path, source_path,
|
||||
output_json_path="hasil_perhitungan.json", max_frames=None,
|
||||
inference_stride=2, sack_conf=0.35, truck_conf=0.35,
|
||||
box_conf=None, output_dir=None, batch_timeout=None):
|
||||
box_conf=0.35, box_model_path=None, model_mode=None,
|
||||
output_dir=None, batch_timeout=None):
|
||||
global prev_active_track_ids, lost_tracks, metrics, track_positions, counted_at_frame
|
||||
global track_confirmed_state, already_counted, is_locked, has_crossed_line, exit_crossed_line, track_areas
|
||||
global pending_enter_since, pending_exit_since, track_started_in_truck, outside_truck_frames
|
||||
@@ -1061,8 +1211,9 @@ def run_prediction(model_path, source_path,
|
||||
global DUPLICATE_CIRCLE_RADIUS, MIN_VALID_AREA, JARAK_TOLERANSI_DUPLIKAT, MAX_REID_TRANSIT_DISTANCE
|
||||
global DB_PATH, STATE_FILE, BATCH_MODE_FILE, LIVE_STREAM_FRAME_PATH
|
||||
|
||||
if box_conf is not None:
|
||||
print("[INFO] --box-conf diterima tapi detektor box belum diimplementasikan (placeholder, diabaikan).")
|
||||
mode = resolve_model_mode(model_mode)
|
||||
if box_model_path is None:
|
||||
box_model_path = _pick_box_model(None)
|
||||
|
||||
if output_dir:
|
||||
# Cross-platform: plain join, keep .env layout when output_dir is None
|
||||
@@ -1112,24 +1263,31 @@ def run_prediction(model_path, source_path,
|
||||
|
||||
print(f"[INFO] Resolusi Asli: {int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))}x{int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))} @ {fps:.1f} FPS (Diresize ke 1280x720 untuk koordinat tetap)")
|
||||
|
||||
# Initialize components from repo rafan using shared model
|
||||
print(f"[INFO] Memuat model YOLO gabungan dari: {model_path}")
|
||||
shared_model = YOLO(model_path)
|
||||
|
||||
# Warm-up model to initialize CUDA/TensorRT execution context and prevent segfaults on tracking
|
||||
print("[INFO] Melakukan warm-up model YOLO...")
|
||||
dummy_frame = np.zeros((720, 1280, 3), dtype=np.uint8)
|
||||
_ = shared_model(dummy_frame, imgsz=640, device=device, verbose=False)
|
||||
# Initialize components per model mode (engines verified to coexist)
|
||||
pipe = build_model_pipeline(
|
||||
mode, model_path, box_model_path, _pick_sack_only_model(),
|
||||
sack_conf, truck_conf, box_conf, device,
|
||||
)
|
||||
print("[INFO] Warm-up model selesai.")
|
||||
|
||||
truck_detector = TruckDetector(shared_model, truck_conf)
|
||||
tracker = ByteTrackTracker(shared_model, sack_conf)
|
||||
truck_detector = pipe["truck_detector"]
|
||||
tracker = pipe["tracker"]
|
||||
box_tracker = pipe["box_tracker"]
|
||||
separate_truck_model = pipe["separate_truck_model"]
|
||||
stabilizer = BboxStabilizer(
|
||||
ema_alpha=0.35,
|
||||
max_hold_frames=10,
|
||||
max_height_ratio=1.5,
|
||||
min_height_ratio=0.70,
|
||||
)
|
||||
# Separate stabilizer for the box tracker's own ID space (modes C/D).
|
||||
# Mode B shares the yolo11n tracker (single ID space) so this stays unused.
|
||||
box_stabilizer = BboxStabilizer(
|
||||
ema_alpha=0.35,
|
||||
max_hold_frames=10,
|
||||
max_height_ratio=1.5,
|
||||
min_height_ratio=0.70,
|
||||
)
|
||||
|
||||
# ================================================================
|
||||
# HARDCODED COORDINATES FOR LOCAL (1280x720)
|
||||
@@ -1179,7 +1337,7 @@ def run_prediction(model_path, source_path,
|
||||
JARAK_TOLERANSI_DUPLIKAT = JARAK_TOLERANSI_DUPLIKAT_REF
|
||||
MAX_REID_TRANSIT_DISTANCE = MAX_REID_TRANSIT_DISTANCE_REF
|
||||
|
||||
counter = LineCrossCounter(
|
||||
counter = MultiClassLineCounter(
|
||||
line_y=static_line_y,
|
||||
line_x_start=static_line_x_start,
|
||||
line_x_end=static_line_x_end,
|
||||
@@ -1263,12 +1421,22 @@ def run_prediction(model_path, source_path,
|
||||
else:
|
||||
raw_tracked_all = last_raw_tracked_all
|
||||
|
||||
# Filter sack detections (confidence >= 0.50)
|
||||
raw_tracked_sacks = [d for d in raw_tracked_all if d.class_name == "sack" and d.confidence >= 0.50]
|
||||
# Filter sack (+box, modes B-D) detections (confidence >= 0.50).
|
||||
# Counter splits by class_name downstream; MultiClassLineCounter
|
||||
# ignores anything that is not sack/box.
|
||||
raw_tracked_sacks = [d for d in raw_tracked_all if d.class_name in ("sack", "box") and d.confidence >= 0.50]
|
||||
|
||||
# Truck candidates: from shared tracker (modes A/C) and/or the
|
||||
# separate v4 truck model (modes B/D, every 5th frame, cached).
|
||||
truck_candidates = [d for d in raw_tracked_all if d.class_name == "truck"]
|
||||
if separate_truck_model:
|
||||
if frame_idx % 5 == 0 or 'last_detected_trucks' not in locals():
|
||||
last_detected_trucks = truck_detector.detect(frame)
|
||||
truck_candidates += last_detected_trucks
|
||||
|
||||
# Filter truck detections: Wajib 100% berada di dalam detection_polygon & ambil maksimal 1 bbox terbaik
|
||||
valid_trucks = []
|
||||
for d in raw_tracked_all:
|
||||
for d in truck_candidates:
|
||||
if d.class_name == "truck" and d.confidence >= 0.45:
|
||||
x1, y1, x2, y2 = d.bbox
|
||||
# Bounding box truk 100% harus berada di dalam detection_polygon
|
||||
@@ -1286,12 +1454,35 @@ def run_prediction(model_path, source_path,
|
||||
|
||||
stable = stabilizer.update(raw_tracked_sacks)
|
||||
|
||||
# Box stream: modes C/D run a dedicated box tracker (own ID space +
|
||||
# own stabilizer); mode B shares the yolo11n tracker (single ID space).
|
||||
stable_boxes = []
|
||||
if box_tracker is not None:
|
||||
if INFERENCE_STRIDE <= 1 or frame_idx % INFERENCE_STRIDE == 0 or 'last_raw_tracked_boxes' not in locals():
|
||||
raw_tracked_boxes = box_tracker.update(frame, [])
|
||||
last_raw_tracked_boxes = raw_tracked_boxes
|
||||
else:
|
||||
raw_tracked_boxes = last_raw_tracked_boxes
|
||||
raw_boxes = [d for d in raw_tracked_boxes if d.class_name == "box"]
|
||||
stable_boxes = box_stabilizer.update(raw_boxes)
|
||||
stable_boxes = [
|
||||
d for d in stable_boxes
|
||||
if detection_polygon.contains(Point((d.bbox[0] + d.bbox[2]) / 2.0, (d.bbox[1] + d.bbox[3]) / 2.0))
|
||||
]
|
||||
else:
|
||||
# Mode B: boxes already stabilized alongside sacks; mode A: none.
|
||||
stable_boxes = [d for d in stable if d.class_name == "box"]
|
||||
stable = [d for d in stable if d.class_name != "box"]
|
||||
|
||||
# Filter using Detection Area (4-point Polygon)
|
||||
stable = [
|
||||
d for d in stable
|
||||
if detection_polygon.contains(Point((d.bbox[0] + d.bbox[2]) / 2.0, (d.bbox[1] + d.bbox[3]) / 2.0))
|
||||
]
|
||||
|
||||
# Combined list for ROI filter / counting / viz (counter splits by class).
|
||||
stable_all = stable + stable_boxes
|
||||
|
||||
# Count sacks currently visible in the bottom 85% of truck area (for batch start/end condition)
|
||||
min_ty, max_ty = truck_polygon.bounds[1], truck_polygon.bounds[3]
|
||||
truck_height = max_ty - min_ty
|
||||
@@ -1342,6 +1533,7 @@ def run_prediction(model_path, source_path,
|
||||
if is_batch_active and not prev_manual_active:
|
||||
counter.reset()
|
||||
stabilizer.reset()
|
||||
box_stabilizer.reset()
|
||||
print(f"[BATCH] Manual Batch #{active_batch_info.get('batch_number')} dimulai via Tombol.")
|
||||
system_state = STATE_COUNTING_SACKS
|
||||
elif not is_batch_active and prev_manual_active:
|
||||
@@ -1349,21 +1541,26 @@ def run_prediction(model_path, source_path,
|
||||
system_state = STATE_WAITING_FOR_TRUCK
|
||||
counter.reset()
|
||||
stabilizer.reset()
|
||||
box_stabilizer.reset()
|
||||
|
||||
prev_manual_active = is_batch_active
|
||||
|
||||
if is_batch_active:
|
||||
tracked_sacks = _filter_sacks_in_roi(stable, static_roi)
|
||||
tracked_sacks = _filter_sacks_in_roi(stable_all, static_roi)
|
||||
events = counter.update(tracked_sacks)
|
||||
for ev in events:
|
||||
print(f"[KARUNG] Karung #{ev['track_id']} masuk.")
|
||||
print(f"[TOTAL] Total karung saat ini: {counter.loading_count}.")
|
||||
_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}.")
|
||||
|
||||
if 'cx' in ev and 'cy' in ev:
|
||||
counted_sack_positions.append((ev['cx'], ev['cy'], time.time(), ev['track_id']))
|
||||
|
||||
if active_batch_info is not None:
|
||||
active_batch_info["count"] = counter.loading_count
|
||||
active_batch_info["box_count"] = counter.box_loading_count
|
||||
active_batch_info["box_unloading"] = counter.box_unloading_count
|
||||
active_batch_info["last_detection_time"] = datetime.now().isoformat()
|
||||
save_active_batch_state()
|
||||
else:
|
||||
@@ -1379,7 +1576,7 @@ def run_prediction(model_path, source_path,
|
||||
# ================================================================
|
||||
else:
|
||||
# Run line crossing counter on every frame
|
||||
tracked_sacks = _filter_sacks_in_roi(stable, static_roi)
|
||||
tracked_sacks = _filter_sacks_in_roi(stable_all, static_roi)
|
||||
events = counter.update(tracked_sacks)
|
||||
has_crossing = len(events) > 0
|
||||
|
||||
@@ -1403,14 +1600,18 @@ def run_prediction(model_path, source_path,
|
||||
batch_mgr.update_truck(activity_detected, None, timestamp)
|
||||
|
||||
for ev in events:
|
||||
print(f"[KARUNG] Karung #{ev['track_id']} masuk.")
|
||||
print(f"[TOTAL] Total karung saat ini: {counter.loading_count}.")
|
||||
_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}.")
|
||||
|
||||
if 'cx' in ev and 'cy' in ev:
|
||||
counted_sack_positions.append((ev['cx'], ev['cy'], time.time(), ev['track_id']))
|
||||
|
||||
if active_batch_info is not None:
|
||||
active_batch_info["count"] = counter.loading_count
|
||||
active_batch_info["box_count"] = counter.box_loading_count
|
||||
active_batch_info["box_unloading"] = counter.box_unloading_count
|
||||
active_batch_info["last_detection_time"] = datetime.now().isoformat()
|
||||
save_active_batch_state()
|
||||
|
||||
@@ -1423,6 +1624,8 @@ def run_prediction(model_path, source_path,
|
||||
"counting_date": counting_date,
|
||||
"batch_number": batch_num,
|
||||
"count": 0,
|
||||
"box_count": 0,
|
||||
"model_mode": mode,
|
||||
"start_time": now_iso,
|
||||
"last_detection_time": now_iso
|
||||
}
|
||||
@@ -1436,12 +1639,17 @@ def run_prediction(model_path, source_path,
|
||||
start_iso = active_batch_info["start_time"] if active_batch_info else datetime.now().isoformat()
|
||||
end_iso = datetime.now().isoformat()
|
||||
batch_num = active_batch_info["batch_number"] if active_batch_info else 0
|
||||
finalize_batch(final_count, start_iso, end_iso)
|
||||
print(f"[BATCH] Truk pergi. Sesi batch #{batch_num} selesai secara otomatis. Total karung: {final_count}.")
|
||||
finalize_batch(final_count, start_iso, end_iso,
|
||||
box_final_count=counter.box_loading_count,
|
||||
box_unloading_count=counter.box_unloading_count,
|
||||
model_mode=mode)
|
||||
print(f"[BATCH] Truk pergi. Sesi batch #{batch_num} selesai secara otomatis. "
|
||||
f"Total karung: {final_count}, box: {counter.box_loading_count}.")
|
||||
system_state = STATE_WAITING_FOR_TRUCK
|
||||
|
||||
counter.reset()
|
||||
stabilizer.reset()
|
||||
box_stabilizer.reset()
|
||||
|
||||
if batch_mgr.state == "TRUCK_STABILIZING" and prev_state != "TRUCK_STABILIZING":
|
||||
system_state = "TRUCK_STABILIZING"
|
||||
@@ -1459,6 +1667,8 @@ def run_prediction(model_path, source_path,
|
||||
# Sync counts to metrics so APIs get correct results
|
||||
metrics['total_masuk'] = counter.loading_count
|
||||
metrics['total_keluar'] = counter.unloading_count
|
||||
metrics['box_masuk'] = counter.box_loading_count
|
||||
metrics['box_keluar'] = counter.box_unloading_count
|
||||
|
||||
# 4. Draw Dashboard visualization overlay
|
||||
viz = dashboard.draw(
|
||||
@@ -1534,10 +1744,14 @@ def run_prediction(model_path, source_path,
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
net_count = metrics['total_masuk'] - metrics['total_keluar']
|
||||
box_net = metrics['box_masuk'] - metrics['box_keluar']
|
||||
final_results = {
|
||||
"total_masuk_truck": metrics['total_masuk'],
|
||||
"total_keluar_truck": metrics['total_keluar'],
|
||||
"net_karung_di_truck": net_count
|
||||
"net_karung_di_truck": net_count,
|
||||
"box_masuk_truck": metrics['box_masuk'],
|
||||
"box_keluar_truck": metrics['box_keluar'],
|
||||
"net_box_di_truck": box_net,
|
||||
}
|
||||
|
||||
with open(output_json_path, 'w') as f:
|
||||
@@ -1574,6 +1788,7 @@ if __name__ == "__main__":
|
||||
|
||||
print(f"[INFO] source={SOURCE_INPUT} model={MODEL_FILE} "
|
||||
f"sack_conf={args.sack_conf or 0.35} truck_conf={args.truck_conf or 0.35} "
|
||||
f"box_conf={args.box_conf or 0.35} model_mode={args.model_mode or os.getenv('MODEL_MODE', 'B')} "
|
||||
f"no_dashboard={NO_DASHBOARD} no_db={NO_DB}")
|
||||
|
||||
try:
|
||||
@@ -1584,7 +1799,9 @@ if __name__ == "__main__":
|
||||
max_frames=args.max_frames,
|
||||
sack_conf=args.sack_conf or 0.35,
|
||||
truck_conf=args.truck_conf or 0.35,
|
||||
box_conf=args.box_conf,
|
||||
box_conf=args.box_conf or 0.35,
|
||||
box_model_path=args.box_model,
|
||||
model_mode=args.model_mode,
|
||||
output_dir=args.output_dir,
|
||||
batch_timeout=args.batch_timeout,
|
||||
)
|
||||
@@ -1597,7 +1814,10 @@ if __name__ == "__main__":
|
||||
final_results = {
|
||||
"total_masuk_truck": metrics['total_masuk'],
|
||||
"total_keluar_truck": metrics['total_keluar'],
|
||||
"net_karung_di_truck": metrics['total_masuk'] - metrics['total_keluar']
|
||||
"net_karung_di_truck": metrics['total_masuk'] - metrics['total_keluar'],
|
||||
"box_masuk_truck": metrics['box_masuk'],
|
||||
"box_keluar_truck": metrics['box_keluar'],
|
||||
"net_box_di_truck": metrics['box_masuk'] - metrics['box_keluar'],
|
||||
}
|
||||
with open(OUTPUT_JSON, 'w') as f:
|
||||
json.dump(final_results, f, indent=4)
|
||||
|
||||
@@ -38,11 +38,17 @@ class BatchRecord:
|
||||
end_time: float
|
||||
loading_count: int
|
||||
unloading_count: int
|
||||
box_loading_count: int = 0
|
||||
box_unloading_count: int = 0
|
||||
|
||||
@property
|
||||
def net_count(self) -> int:
|
||||
return self.loading_count - self.unloading_count
|
||||
|
||||
@property
|
||||
def box_net_count(self) -> int:
|
||||
return self.box_loading_count - self.box_unloading_count
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> float:
|
||||
return self.end_time - self.start_time
|
||||
|
||||
+102
@@ -235,3 +235,105 @@ class LineCrossCounter:
|
||||
self._counted.clear()
|
||||
self._entry_points.clear()
|
||||
self._dedup_circles.clear()
|
||||
|
||||
|
||||
class MultiClassLineCounter:
|
||||
"""Sack + box counting on one shared line (Option 2: dual counters).
|
||||
|
||||
Two independent LineCrossCounter instances share the same geometry
|
||||
(line_y / x-bounds / margin / dedup radius) but keep fully separate
|
||||
track state, so sack and box IDs never collide. Events are tagged
|
||||
with "class_name".
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
line_y: int,
|
||||
line_x_start: int,
|
||||
line_x_end: int,
|
||||
margin: int = 20,
|
||||
dedup_radius: float = 30.0,
|
||||
) -> None:
|
||||
self._sack = LineCrossCounter(
|
||||
line_y, line_x_start, line_x_end, margin, dedup_radius
|
||||
)
|
||||
self._box = LineCrossCounter(
|
||||
line_y, line_x_start, line_x_end, margin, dedup_radius
|
||||
)
|
||||
|
||||
# -- line geometry proxies (kept in sync on both counters) --
|
||||
@property
|
||||
def line_y(self) -> int:
|
||||
return self._sack.line_y
|
||||
|
||||
@line_y.setter
|
||||
def line_y(self, value: int) -> None:
|
||||
self._sack.line_y = value
|
||||
self._box.line_y = value
|
||||
|
||||
@property
|
||||
def line_x_start(self) -> int:
|
||||
return self._sack.line_x_start
|
||||
|
||||
@line_x_start.setter
|
||||
def line_x_start(self, value: int) -> None:
|
||||
self._sack.line_x_start = value
|
||||
self._box.line_x_start = value
|
||||
|
||||
@property
|
||||
def line_x_end(self) -> int:
|
||||
return self._sack.line_x_end
|
||||
|
||||
@line_x_end.setter
|
||||
def line_x_end(self, value: int) -> None:
|
||||
self._sack.line_x_end = value
|
||||
self._box.line_x_end = value
|
||||
|
||||
def update(self, detections: list[Detection]) -> list[dict]:
|
||||
"""Split by class_name, count independently, return flat tagged events.
|
||||
|
||||
Flat list (not dict) so existing `len(events)` / `for ev in events`
|
||||
callsites keep working. Use update_by_class() for per-class lists.
|
||||
"""
|
||||
by_class = self.update_by_class(detections)
|
||||
return by_class["sack"] + by_class["box"]
|
||||
|
||||
def update_by_class(self, detections: list[Detection]) -> dict[str, list[dict]]:
|
||||
"""Split by class_name, count independently, tag events."""
|
||||
sacks = [d for d in detections if d.class_name == "sack"]
|
||||
boxes = [d for d in detections if d.class_name == "box"]
|
||||
sack_events = self._sack.update(sacks)
|
||||
box_events = self._box.update(boxes)
|
||||
for ev in sack_events:
|
||||
ev["class_name"] = "sack"
|
||||
for ev in box_events:
|
||||
ev["class_name"] = "box"
|
||||
return {"sack": sack_events, "box": box_events}
|
||||
|
||||
@property
|
||||
def loading_count(self) -> int:
|
||||
return self._sack.loading_count
|
||||
|
||||
@property
|
||||
def unloading_count(self) -> int:
|
||||
return self._sack.unloading_count
|
||||
|
||||
@property
|
||||
def net_count(self) -> int:
|
||||
return self._sack.net_count
|
||||
|
||||
@property
|
||||
def box_loading_count(self) -> int:
|
||||
return self._box.loading_count
|
||||
|
||||
@property
|
||||
def box_unloading_count(self) -> int:
|
||||
return self._box.unloading_count
|
||||
|
||||
@property
|
||||
def box_net_count(self) -> int:
|
||||
return self._box.net_count
|
||||
|
||||
def reset(self) -> None:
|
||||
self._sack.reset()
|
||||
self._box.reset()
|
||||
+43
-32
@@ -1,4 +1,4 @@
|
||||
"""YOLO-based detectors for sacks and trucks.
|
||||
"""YOLO-based detectors for sacks, boxes and trucks.
|
||||
|
||||
Each detector is a single-responsibility unit (S). New model types can be
|
||||
added as new classes without touching these (O).
|
||||
@@ -12,12 +12,21 @@ from ultralytics import YOLO
|
||||
from src.interfaces import Detection
|
||||
|
||||
|
||||
class SackDetector:
|
||||
"""Detects sacks (and persons) using a YOLO segmentation model."""
|
||||
class BaseDetector:
|
||||
"""YOLO detector filtered to an explicit class allow-list.
|
||||
|
||||
def __init__(self, model_path: str | YOLO, conf: float = 0.35) -> None:
|
||||
`class_filter=None` keeps every class (legacy TruckDetector behaviour).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_path: str | YOLO,
|
||||
conf: float = 0.35,
|
||||
class_filter: tuple[str, ...] | list[str] | None = None,
|
||||
) -> None:
|
||||
self._model = model_path if isinstance(model_path, YOLO) else YOLO(model_path)
|
||||
self._conf = conf
|
||||
self._class_filter = set(class_filter) if class_filter else None
|
||||
|
||||
def detect(self, frame: np.ndarray) -> list[Detection]:
|
||||
results = self._model.predict(
|
||||
@@ -25,13 +34,21 @@ class SackDetector:
|
||||
)
|
||||
return self._parse(results[0])
|
||||
|
||||
def _class_name(self, cls_id: int) -> str:
|
||||
names = self._model.names
|
||||
if isinstance(names, dict):
|
||||
return names.get(cls_id, str(cls_id))
|
||||
return names[cls_id]
|
||||
|
||||
def _parse(self, result) -> list[Detection]:
|
||||
detections: list[Detection] = []
|
||||
if result.boxes is None or len(result.boxes) == 0:
|
||||
return detections
|
||||
masks = result.masks
|
||||
for i, box in enumerate(result.boxes):
|
||||
cls_id = int(box.cls[0])
|
||||
name = self._model.names.get(cls_id, str(cls_id)) if isinstance(self._model.names, dict) else self._model.names[cls_id]
|
||||
if name != "sack":
|
||||
name = self._class_name(cls_id)
|
||||
if self._class_filter is not None and name not in self._class_filter:
|
||||
continue
|
||||
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||
mask = None
|
||||
@@ -49,33 +66,27 @@ class SackDetector:
|
||||
return detections
|
||||
|
||||
|
||||
class TruckDetector:
|
||||
"""Detects trucks using a YOLO detection model."""
|
||||
class SackDetector(BaseDetector):
|
||||
"""Detects sacks (drops persons/boxes/trucks from multi-class models)."""
|
||||
|
||||
def __init__(self, model_path: str | YOLO, conf: float = 0.35) -> None:
|
||||
self._model = model_path if isinstance(model_path, YOLO) else YOLO(model_path)
|
||||
self._conf = conf
|
||||
super().__init__(model_path, conf, class_filter=("sack",))
|
||||
|
||||
def detect(self, frame: np.ndarray) -> list[Detection]:
|
||||
results = self._model.predict(
|
||||
frame, conf=self._conf, verbose=False
|
||||
)
|
||||
return self._parse(results[0])
|
||||
|
||||
def _parse(self, result) -> list[Detection]:
|
||||
detections: list[Detection] = []
|
||||
if result.boxes is None or len(result.boxes) == 0:
|
||||
return detections
|
||||
for box in result.boxes:
|
||||
cls_id = int(box.cls[0])
|
||||
name = self._model.names.get(cls_id, str(cls_id)) if isinstance(self._model.names, dict) else self._model.names[cls_id]
|
||||
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||
detections.append(
|
||||
Detection(
|
||||
bbox=(x1, y1, x2, y2),
|
||||
confidence=float(box.conf[0]),
|
||||
class_id=cls_id,
|
||||
class_name=name,
|
||||
)
|
||||
)
|
||||
return detections
|
||||
class TruckDetector(BaseDetector):
|
||||
"""Detects trucks (keeps every class when filter is None — legacy default)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_path: str | YOLO,
|
||||
conf: float = 0.35,
|
||||
class_filter: tuple[str, ...] | list[str] | None = None,
|
||||
) -> None:
|
||||
super().__init__(model_path, conf, class_filter=class_filter)
|
||||
|
||||
|
||||
class BoxDetector(BaseDetector):
|
||||
"""Detects boxes (drops sacks/persons from the sack+box model)."""
|
||||
|
||||
def __init__(self, model_path: str | YOLO, conf: float = 0.35) -> None:
|
||||
super().__init__(model_path, conf, class_filter=("box",))
|
||||
+7
-2
@@ -37,6 +37,9 @@ class CSVLogger:
|
||||
record.loading_count,
|
||||
record.unloading_count,
|
||||
record.net_count,
|
||||
record.box_loading_count,
|
||||
record.box_unloading_count,
|
||||
record.box_net_count,
|
||||
])
|
||||
|
||||
# ── Sack events ──────────────────────────────────────────────────
|
||||
@@ -47,14 +50,15 @@ class CSVLogger:
|
||||
track_id: int,
|
||||
direction: str,
|
||||
timestamp: float,
|
||||
class_name: str = "sack",
|
||||
) -> None:
|
||||
"""Append one sack crossing event."""
|
||||
"""Append one crossing event (sack or box)."""
|
||||
with open(self._event_file, "a", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([
|
||||
self._fmt(timestamp),
|
||||
batch_id,
|
||||
"sack_crossed",
|
||||
f"{class_name}_crossed",
|
||||
f"T-{track_id:04d}",
|
||||
direction,
|
||||
])
|
||||
@@ -68,6 +72,7 @@ class CSVLogger:
|
||||
writer.writerow([
|
||||
"batch_id", "start_time", "end_time",
|
||||
"duration_s", "loading", "unloading", "net",
|
||||
"box_loading", "box_unloading", "box_net",
|
||||
])
|
||||
|
||||
def _init_event_csv(self) -> None:
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ class ByteTrackTracker:
|
||||
for i, box in enumerate(result.boxes):
|
||||
cls_id = int(box.cls[0])
|
||||
name = self._model.names.get(cls_id, str(cls_id)) if isinstance(self._model.names, dict) else self._model.names[cls_id]
|
||||
if name not in ("sack", "truck"):
|
||||
if name not in ("sack", "truck", "box"):
|
||||
continue
|
||||
track_id = int(ids[i]) if ids is not None else None
|
||||
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||
|
||||
+11
-4
@@ -237,6 +237,10 @@
|
||||
<div class="stat-label">Total Sacks</div>
|
||||
<div class="stat-number" id="statTotalCount">0</div>
|
||||
</div>
|
||||
<div class="stat-mini-card">
|
||||
<div class="stat-label">Total Boxes</div>
|
||||
<div class="stat-number" id="statTotalBoxes">0</div>
|
||||
</div>
|
||||
<div class="stat-mini-card">
|
||||
<div class="stat-label">Total Batches</div>
|
||||
<div class="stat-number" id="statTotalBatches">0</div>
|
||||
@@ -258,6 +262,7 @@
|
||||
<tr>
|
||||
<th>Batch #</th>
|
||||
<th>Sacks Counted</th>
|
||||
<th>Boxes Counted</th>
|
||||
<th>Start Time</th>
|
||||
<th>End Time</th>
|
||||
<th>Duration (Min)</th>
|
||||
@@ -265,7 +270,7 @@
|
||||
</thead>
|
||||
<tbody id="batchesTableBody">
|
||||
<tr>
|
||||
<td colspan="5" class="no-data">
|
||||
<td colspan="6" class="no-data">
|
||||
<i class="fa-solid fa-arrow-left"></i>
|
||||
Silakan pilih tanggal dari daftar sebelah kiri untuk memuat detail batch.
|
||||
</td>
|
||||
@@ -349,7 +354,7 @@
|
||||
const tableBody = document.getElementById('batchesTableBody');
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" class="no-data">
|
||||
<td colspan="6" class="no-data">
|
||||
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
Memuat data batch untuk tanggal ${date}...
|
||||
</td>
|
||||
@@ -365,6 +370,7 @@
|
||||
// Show stats row
|
||||
document.getElementById('dateStatsRow').style.display = 'grid';
|
||||
document.getElementById('statTotalCount').textContent = (data.total_count || 0).toLocaleString();
|
||||
document.getElementById('statTotalBoxes').textContent = (data.total_boxes || 0).toLocaleString();
|
||||
document.getElementById('statTotalBatches').textContent = data.total_batches || 0;
|
||||
|
||||
const avgSacks = data.total_batches > 0 ? (data.total_count / data.total_batches).toFixed(1) : '0';
|
||||
@@ -374,7 +380,7 @@
|
||||
if (!data.batches || data.batches.length === 0) {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" class="no-data">
|
||||
<td colspan="6" class="no-data">
|
||||
<i class="fa-solid fa-inbox"></i>
|
||||
Tidak ada batch tercatat pada tanggal ${date}.
|
||||
</td>
|
||||
@@ -392,6 +398,7 @@
|
||||
tr.innerHTML = `
|
||||
<td style="font-weight: 600; color: var(--accent-blue);">Batch #${b.batch_number}</td>
|
||||
<td style="font-weight: 500;">${b.count.toLocaleString()}</td>
|
||||
<td style="font-weight: 500;">${(b.box_loading || 0).toLocaleString()}</td>
|
||||
<td>${startStr}</td>
|
||||
<td>${endStr}</td>
|
||||
<td>${b.duration_minutes || 0} min</td>
|
||||
@@ -403,7 +410,7 @@
|
||||
console.error('Failed to load date details:', err);
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" class="no-data" style="color: var(--accent-red);">
|
||||
<td colspan="6" class="no-data" style="color: var(--accent-red);">
|
||||
<i class="fa-solid fa-circle-exclamation"></i>
|
||||
Gagal memuat detail data: ${err.message}
|
||||
</td>
|
||||
|
||||
@@ -319,11 +319,18 @@
|
||||
<div class="count-display" id="liveCount">0</div>
|
||||
<div class="card-label" style="font-size: 11px; margin-top: -4px;">Sacks Counted</div>
|
||||
|
||||
<div class="count-display" id="liveBoxCount">0</div>
|
||||
<div class="card-label" style="font-size: 11px; margin-top: -4px;">Boxes Counted</div>
|
||||
|
||||
<div class="meta-list">
|
||||
<div class="meta-item">
|
||||
<span class="meta-key">Mode Batch</span>
|
||||
<span class="meta-value" id="liveBatchModeText" style="font-weight: 600; color: var(--accent-blue);">Manual (Tombol)</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-key">Model Mode</span>
|
||||
<span class="meta-value" id="liveModelMode" style="font-weight: 600;">--</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-key">Batch Number</span>
|
||||
<span class="meta-value" id="liveBatchNumber">--</span>
|
||||
@@ -495,10 +502,12 @@
|
||||
const data = await res.json();
|
||||
|
||||
const countEl = document.getElementById('liveCount');
|
||||
const boxCountEl = document.getElementById('liveBoxCount');
|
||||
const batchNumEl = document.getElementById('liveBatchNumber');
|
||||
const lastDetEl = document.getElementById('liveLastDetection');
|
||||
const statusEl = document.getElementById('liveStatus');
|
||||
const fpsEl = document.getElementById('liveFps');
|
||||
const modelModeEl = document.getElementById('liveModelMode');
|
||||
const btnStart = document.getElementById('monBtnStart');
|
||||
const btnStop = document.getElementById('monBtnStop');
|
||||
|
||||
@@ -507,11 +516,13 @@
|
||||
} else {
|
||||
fpsEl.textContent = '--';
|
||||
}
|
||||
if (data.model_mode && modelModeEl) modelModeEl.textContent = data.model_mode;
|
||||
|
||||
if (data.success && data.batch_number) {
|
||||
// Batch sedang berjalan
|
||||
const currentCount = parseInt(data.count) || 0;
|
||||
countEl.textContent = currentCount.toLocaleString();
|
||||
if (boxCountEl) boxCountEl.textContent = (parseInt(data.box_count) || 0).toLocaleString();
|
||||
batchNumEl.textContent = '#' + (data.batch_number || '--');
|
||||
statusEl.textContent = 'Counting Sacks (Active)';
|
||||
statusEl.style.color = 'var(--accent-blue)';
|
||||
@@ -533,6 +544,7 @@
|
||||
} else {
|
||||
// Tidak ada batch aktif (standby)
|
||||
countEl.textContent = '0';
|
||||
if (boxCountEl) boxCountEl.textContent = (parseInt(data.box_count) || 0).toLocaleString();
|
||||
batchNumEl.textContent = '--';
|
||||
lastDetEl.textContent = '--';
|
||||
statusEl.textContent = 'Standby (Menunggu Mulai)';
|
||||
|
||||
@@ -167,10 +167,73 @@
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Non-blocking mode banner (fixed top, status stays visible below) */
|
||||
.mode-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 500;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background-color: rgba(255, 204, 0, 0.15);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mode-banner .btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mode-banner .btn-yes {
|
||||
background-color: var(--accent-green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mode-banner .btn-no {
|
||||
background-color: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.model-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.model-row select {
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Non-blocking manual-mode prompt: status/counts stay visible below -->
|
||||
<div id="modeBanner" class="mode-banner">
|
||||
<span>🟡 Mode OTOMATIS aktif. Alihkan ke MANUAL untuk kontrol tombol batch?</span>
|
||||
<button class="btn btn-yes" onclick="switchToManual()">Ya, Manual</button>
|
||||
<button class="btn btn-no" onclick="dismissModeBanner()">Tidak, Tetap Otomatis</button>
|
||||
</div>
|
||||
<div class="operator-container">
|
||||
<div class="operator-card">
|
||||
<h1 class="operator-title"><i class="fa-solid fa-boxes-packing"></i> Kontrol Pemuatan Karung</h1>
|
||||
@@ -192,6 +255,14 @@
|
||||
<div class="info-label">Waktu Mulai</div>
|
||||
<div class="info-val" id="opStartTime">--:--:--</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Karung Masuk</div>
|
||||
<div class="info-val" id="opSackCount">0</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Box Masuk</div>
|
||||
<div class="info-val" id="opBoxCount">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
@@ -203,6 +274,16 @@
|
||||
<i class="fa-solid fa-square"></i> SELESAI / AKHIRI BATCH
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Model mode -->
|
||||
<div class="model-row">
|
||||
<span>Model:</span>
|
||||
<strong id="opModelMode">--</strong>
|
||||
<select id="modelModeSelect" onchange="setModelMode(this.value)">
|
||||
<option value="">Ganti mode…</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="operator-subtitle" style="margin-top:6px;font-size:12px">Ganti mode model berlaku setelah service direstart.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -239,6 +320,86 @@
|
||||
document.getElementById(id).style.display = 'none';
|
||||
}
|
||||
|
||||
// --- Non-blocking manual-mode prompt (banner, status stays visible) ---
|
||||
async function loadModeBanner() {
|
||||
try {
|
||||
const res = await fetch('/api/batch/mode');
|
||||
const data = await res.json();
|
||||
if (data.mode === 'auto' && !sessionStorage.getItem('mode_banner_dismissed')) {
|
||||
document.getElementById('modeBanner').style.display = 'flex';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Mode load error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function dismissModeBanner() {
|
||||
sessionStorage.setItem('mode_banner_dismissed', '1');
|
||||
document.getElementById('modeBanner').style.display = 'none';
|
||||
}
|
||||
|
||||
async function switchToManual() {
|
||||
try {
|
||||
const res = await fetch('/api/batch/mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'manual' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
dismissModeBanner();
|
||||
} else {
|
||||
alert('Gagal alih mode: ' + (data.error || 'Terjadi kesalahan'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Gagal terhubung ke server');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Model mode selector ---
|
||||
async function loadModelModes() {
|
||||
try {
|
||||
const res = await fetch('/api/model-modes');
|
||||
const data = await res.json();
|
||||
if (!data.success) return;
|
||||
const sel = document.getElementById('modelModeSelect');
|
||||
data.modes.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.id + ' — ' + m.description;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Model modes load error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function setModelMode(mode) {
|
||||
if (!mode) return;
|
||||
if (!confirm('Ganti mode model ke ' + mode + '? Berlaku setelah service karung-counter direstart.')) {
|
||||
document.getElementById('modelModeSelect').value = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/batch/mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model_mode: mode })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.getElementById('opModelMode').textContent = data.model_mode;
|
||||
alert('Mode model tersimpan: ' + data.model_mode + '. Restart service untuk berlaku.');
|
||||
} else {
|
||||
alert('Gagal ganti mode: ' + (data.error || 'Terjadi kesalahan'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Gagal terhubung ke server');
|
||||
} finally {
|
||||
document.getElementById('modelModeSelect').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function confirmStartBatch() {
|
||||
document.getElementById('modalStart').style.display = 'flex';
|
||||
}
|
||||
@@ -301,9 +462,16 @@
|
||||
const text = document.getElementById('statusText');
|
||||
const batchNum = document.getElementById('opBatchNum');
|
||||
const startTime = document.getElementById('opStartTime');
|
||||
const sackCount = document.getElementById('opSackCount');
|
||||
const boxCount = document.getElementById('opBoxCount');
|
||||
const modelMode = document.getElementById('opModelMode');
|
||||
const btnStart = document.getElementById('btnStartBatch');
|
||||
const btnStop = document.getElementById('btnStopBatch');
|
||||
|
||||
if (data.model_mode) modelMode.textContent = data.model_mode;
|
||||
sackCount.textContent = data.count || 0;
|
||||
boxCount.textContent = data.box_count || 0;
|
||||
|
||||
if (data.success && data.batch_number) {
|
||||
isBatchActive = true;
|
||||
badge.className = 'status-badge-box status-active';
|
||||
@@ -339,5 +507,7 @@
|
||||
|
||||
setInterval(pollStatus, 2000);
|
||||
pollStatus();
|
||||
loadModeBanner();
|
||||
loadModelModes();
|
||||
</script>
|
||||
{% endblock %}
|
||||
+42
-4
@@ -1,15 +1,15 @@
|
||||
"""Smoke tests for LineCrossCounter (src/counting.py). Pure-Python: needs only numpy."""
|
||||
"""Smoke tests for LineCrossCounter + MultiClassLineCounter (src/counting.py). Pure-Python: needs only numpy."""
|
||||
|
||||
from src.counting import LineCrossCounter
|
||||
from src.counting import LineCrossCounter, MultiClassLineCounter
|
||||
from src.interfaces import Detection
|
||||
|
||||
|
||||
def _det(tid, y1, cx=500.0):
|
||||
def _det(tid, y1, cx=500.0, class_name="sack"):
|
||||
return Detection(
|
||||
bbox=(cx - 20, y1, cx + 20, y1 + 60),
|
||||
confidence=0.9,
|
||||
class_id=0,
|
||||
class_name="sack",
|
||||
class_name=class_name,
|
||||
track_id=tid,
|
||||
)
|
||||
|
||||
@@ -58,3 +58,41 @@ def test_net_and_reset():
|
||||
assert c.net_count == 1
|
||||
c.reset()
|
||||
assert (c.loading_count, c.unloading_count, c.net_count) == (0, 0, 0)
|
||||
|
||||
|
||||
def _mcounter():
|
||||
return MultiClassLineCounter(line_y=100, line_x_start=0, line_x_end=1000, margin=20)
|
||||
|
||||
|
||||
def test_multiclass_sack_and_box_independent():
|
||||
c = _mcounter()
|
||||
c.update([_det(1, y1=10, class_name="sack"), _det(7, y1=10, class_name="box")])
|
||||
events = c.update([_det(1, y1=150, class_name="sack"), _det(7, y1=150, class_name="box")])
|
||||
assert len(events) == 2 # flat list, callsites unaffected
|
||||
assert {e["class_name"] for e in events} == {"sack", "box"}
|
||||
assert c.loading_count == 1
|
||||
assert c.box_loading_count == 1
|
||||
assert c.unloading_count == 0 and c.box_unloading_count == 0
|
||||
|
||||
|
||||
def test_multiclass_shared_track_id_no_collision():
|
||||
# sack track 9 and box track 9 are different objects; both must count
|
||||
c = _mcounter()
|
||||
c.update([_det(9, y1=10, class_name="sack"), _det(9, y1=10, class_name="box")])
|
||||
c.update([_det(9, y1=150, class_name="sack"), _det(9, y1=150, class_name="box")])
|
||||
assert c.loading_count == 1
|
||||
assert c.box_loading_count == 1
|
||||
|
||||
|
||||
def test_multiclass_geometry_sync_and_reset():
|
||||
c = _mcounter()
|
||||
c.line_y = 200
|
||||
assert c._sack.line_y == 200 and c._box.line_y == 200
|
||||
c.line_x_start = 10
|
||||
c.line_x_end = 900
|
||||
assert (c._sack.line_x_start, c._box.line_x_end) == (10, 900)
|
||||
c.update([_det(1, y1=100, class_name="sack")])
|
||||
c.update([_det(1, y1=250, class_name="sack")])
|
||||
assert c.loading_count == 1
|
||||
c.reset()
|
||||
assert (c.loading_count, c.box_loading_count, c.box_net_count) == (0, 0, 0)
|
||||
Reference in new issue
Block a user