diff --git a/AGENTS.md b/AGENTS.md index f8f5ca3..3ca5fbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,8 +37,8 @@ Indonesian (`karung`=sack, `truk`=truck); YOLO class names are English `batch_mode.json` keeps only batch flow mode (`auto`|`do_manual`|`manual`, default seed `batch.default_mode: auto`). **Port split:** `mode` / `model_mode` / `require_plate` / `require_do` POSTs → - office `:5721` only (403 on `:5000`); **`ocr_engine` POST both ports** (synced - via `GET /api/do/settings`). Discard batch only if **both** `net_sack==0` and + 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`; ERD: `ERD.md` (repo root). - **Counting filters by class name, not ID**: `SackDetector`/`BoxDetector`/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4be3159..ca7fc87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,15 +16,25 @@ version tags are cut in this repo — POC stage, `main` is the release line). - `delivery_orders` table + additive `batches` columns (`plate`, `do_numbers`, `expected_*`, `net_*`); photo tree `do_photos/YYYY-MM-DD/` (7-day retention). - APIs: `POST /api/batch/stop-preview`, DO CRUD + `POST /api/do/upload`, - `GET|POST /api/do/settings` (office-only plate/do flags; **ocr_engine both - ports**), office-only mode/model_mode POST (403 on operator; 409 when batch active). + `GET|POST /api/do/settings` (all gated fields office-only: + require_plate/require_do/**ocr_engine**, 403 on operator), office-only + mode/model_mode POST (403 on operator; 409 when batch active). - `src/do_batch.py` (pure gates/nets/units), `src/do_ocr.py` - (`extract_do_fields` tesseract|paddle|none). -- Operator DO panel + OCR toggle; monitoring 3-way mode switch + model mode + - OCR/require-plate controls; history/export plate, DO, expected, net columns. + (`extract_do_fields` rapid|tesseract|paddle|none). +- **RapidOCR default engine** (`rapidocr_onnxruntime`, bundled PP-OCR models); + tesseract + paddle kept as backups, explicit error if a dep is missing. +- Operator DO panel (no OCR toggle); monitoring 3-way mode switch + model mode + + OCR selector with labels `RapidOCR (Default, Light & Accurate)` / + `Tesseract (Backup, Light)` / `PaddleOCR (Accuracy, Heavy to run)` / + `None (Manual)` + require-plate controls; history/export plate, DO, + expected, net columns. - `ERD.md` (Mermaid ERD, repo root; superseded `docs/do-erd.md`); tests `tests/test_do_batch.py`. ### Changed +- OCR engine default **`rapid`** (was `tesseract`) across `config.yaml`, + `DoConfig`, `empty_do_settings`, `extract_do_fields`; `ocr_engine` write + moved office-only (was both ports); OCR engine selector removed from + operator page. - Default batch mode seed **`auto`** (was hard-coded `manual` when `batch_mode.json` missing); `predict.py` accepts `do_manual` as operator-driven and persists sack `unloading` for net-at-stop. diff --git a/README.md b/README.md index 736ff52..b01d2e4 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,8 @@ batch mode, DO upload/staged/settings, CSV/Excel export). Data source: | `do_manual` | Operator + DO photo scan | yes | | `manual` | Operator buttons (legacy) | no | -Office (`:5721`) switches mode / model mode / require_plate (403 on operator -POST). **DO manual flow:** open `http://:5000/operator` on a phone → +Office (`:5721`) switches mode / model mode / require_plate / ocr_engine (403 +on operator POST). **DO manual flow:** open `http://:5000/operator` on a phone → Ambil Foto DO → review/edit OCR fields → Mulai Batch → count → Selesai (soft-warn if zone busy). Discard only when both sack and box nets are 0. ERD: [`ERD.md`](ERD.md). @@ -117,7 +117,6 @@ archive/ Retired experiments (predict_new.py, rpo_iki/, simple_pred check/merge/test scripts) — history preserved, not imported export_model.py Export .pt → TensorRT .engine (FP16) | export_v4.py variant deploy_to_jetson.py Paramiko sync + service restart -scripts/ Loose helpers (frame sampling, DB check, guide PDF) *.service systemd units (counter, dashboard, mediamtx) zones.json Calibrated zone geometry batch_history_folder/ Per-batch JSON reports diff --git a/config.yaml b/config.yaml index 282ed74..bd8a566 100644 --- a/config.yaml +++ b/config.yaml @@ -135,8 +135,8 @@ do: max_photos_per_batch: 8 zone_warn_seconds: 3 # stop soft-warn: recent count activity ocr: - engine: "tesseract" # tesseract | paddle | none — YAML seed only - # Runtime override: do_settings.json ocr_engine (UI toggle, both ports). + engine: "rapid" # rapid | tesseract | paddle | none — YAML seed only + # Runtime override: do_settings.json ocr_engine (office-only write). output: dir: "/opt/jetson-counter" diff --git a/counter_dashboard.py b/counter_dashboard.py index fba4bec..cfb2cc7 100644 --- a/counter_dashboard.py +++ b/counter_dashboard.py @@ -75,6 +75,7 @@ from src.do_batch import ( is_valid_ocr_engine, net_counts, normalize_batch_mode, + OCR_ENGINES, retention_cutoff_date, should_discard_batch, should_purge_counting_date, @@ -300,7 +301,7 @@ def _write_do_settings(data: dict) -> None: payload = { "require_plate": bool(data.get("require_plate", False)), "require_do": bool(data.get("require_do", True)), - "ocr_engine": str(data.get("ocr_engine", "tesseract")).lower(), + "ocr_engine": str(data.get("ocr_engine", "rapid")).lower(), "updated_at": datetime.now().isoformat(), } tmp = DO_SETTINGS_PATH + ".tmp" @@ -831,32 +832,32 @@ def api_do_settings(): current = _read_do_settings() office = is_office_request() wrote = False - # ocr_engine: both ports allowed + # require_plate / require_do / ocr_engine: office only + gated_keys = ("require_plate", "require_do", "ocr_engine") + if any(k in req_data for k in gated_keys): + if not office: + return jsonify({ + "success": False, + "error": "require_plate/require_do/ocr_engine hanya dari port kantor", + }), 403 if "ocr_engine" in req_data: eng = str(req_data.get("ocr_engine", "")).lower() if not is_valid_ocr_engine(eng): return jsonify({ "success": False, - "error": f"Invalid ocr_engine. Use one of {list(('tesseract','paddle','none'))}", + "error": f"Invalid ocr_engine. Use one of {list(OCR_ENGINES)}", }), 400 current["ocr_engine"] = eng wrote = True - # require_plate / require_do: office only - plate_keys = ("require_plate", "require_do") - if any(k in req_data for k in plate_keys): - if not office: - return jsonify({ - "success": False, - "error": "require_plate/require_do hanya dari port kantor", - }), 403 - if "require_plate" in req_data: - current["require_plate"] = bool( - str(req_data.get("require_plate")).lower() in - ("1", "true", "yes", "on")) - if "require_do" in req_data: - current["require_do"] = bool( - str(req_data.get("require_do")).lower() in - ("1", "true", "yes", "on")) + if "require_plate" in req_data: + current["require_plate"] = bool( + str(req_data.get("require_plate")).lower() in + ("1", "true", "yes", "on")) + if "require_do" in req_data: + current["require_do"] = bool( + str(req_data.get("require_do")).lower() in + ("1", "true", "yes", "on")) + if any(k in req_data for k in ("require_plate", "require_do")): wrote = True if not wrote: return jsonify({"success": False, "error": "No known settings fields"}), 400 diff --git a/docs/architecture.md b/docs/architecture.md index 2b5226e..602106a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,7 +110,7 @@ stats panel (Loading / Unloading / Net / last-3-batch history), and a bottom sta (`models.modes`); missing file falls back to `.env` + legacy defaults. - `src/do_batch.py` — pure helpers for DO-gated batches (mode validation, plate grouping, start gates, net/discard both-nets rule, unit classify, retention). -- `src/do_ocr.py` — `extract_do_fields(image, engine)` for `tesseract`|`paddle`|`none` +- `src/do_ocr.py` — `extract_do_fields(image, engine)` for `rapid`|`tesseract`|`paddle`|`none` (default `rapid`) (dashboard upload only; never in the predict loop). - `src/config.py` — **deprecated** frozen v3 `Config` (different key names); do not add keys here. diff --git a/docs/configuration.md b/docs/configuration.md index c71c9f2..4eb2453 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,9 +49,10 @@ deprecated (warned). `max_photos_per_batch`, `zone_warn_seconds`, `ocr.engine` (YAML seed only). **Runtime DO settings** (`$OUTPUT_DIR/do_settings.json`): -`require_plate` / `require_do` — office-only write; **`ocr_engine`** -(`tesseract`|`paddle`|`none`) — **both ports write**. Sync via -`GET/POST /api/do/settings`. Engine flip applies to the next upload without restart. +`require_plate` / `require_do` / `ocr_engine` +(`rapid`|`tesseract`|`paddle`|`none`, default `rapid`) — **office-only write** +(403 on operator). Sync via `GET/POST /api/do/settings`. Engine flip applies +to the next upload without restart. Template: `.env.example`. Production values live in `.env` (git-ignored). Missing `config.yaml` falls back to `.env` + built-in defaults with a warning diff --git a/docs/deployment.md b/docs/deployment.md index 237bdbb..697d186 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -44,27 +44,37 @@ loads `.engine` only — see `models/modelREADME.md` for which weights each mode `batch_history_folder/batch_.json` + `hasil_perhitungan.json` (per-batch reports). - Live frame: `/dev/shm/jetson-counter/live_frame.jpg` (written every 2nd frame, consumed by `/api/live-video` MJPEG). -- Helpers: `scripts/check_jetson_db.py`; retired ops scripts in `archive/` +- Helpers: `check_jetson_db.py` (root); retired ops scripts in `archive/` (`backup.py`, `dump_db.py`, `migrate_jetson_db.py`, `merge_batches_*.py`, `update_batches.py`, `diagnose_truck_jetson.py`). -## DO OCR packages (optional Paddle) -Primary path is system Tesseract (CPU): +## DO OCR packages +Default engine is **RapidOCR** (PP-OCR via onnxruntime, bundled models — +offline-friendly; installed with `requirements.txt`): ```bash +pip install rapidocr_onnxruntime +``` + +Backups (`ocr_engine` — office monitoring UI toggle, no restart): +`Tesseract (Backup, Light)`: + +```bash +# lighter; system package + Indonesian traineddata sudo apt install tesseract-ocr tesseract-ocr-ind pip install pytesseract ``` -Backup engine (`ocr_engine: paddle` via office/operator UI toggle — no restart): +`PaddleOCR (Accuracy, Heavy to run)`: ```bash # optional; heavier — see paddleocr docs for Jetson wheels pip install paddleocr ``` -Missing paddle deps → upload returns explicit error; flip engine back to -`tesseract` from either dashboard. Operator page: `http://:5000/operator` +Missing deps → upload returns explicit error (never silent fallback); flip +engine back to `RapidOCR`/`Tesseract` from the office dashboard +(`:5721`). Operator page: `http://:5000/operator` (smartphone camera capture for DO photos). Photo dir under `output.dir` with 7-day retention (hourly purge in dashboard process). @@ -79,6 +89,5 @@ Pages: `/` + `/monitoring`, `/operator` (manual start/stop; DO panel in `/api/day-detail/`, `/api/recent-batches`, `/api/available-dates`, `/api/export-daily-csv`, `/api/export-day-csv/` (Excel via openpyxl). -Port split: mode / model_mode / require_plate / require_do POSTs → **office -5721 only** (403 on 5000). `ocr_engine` POST allowed on both ports (synced via -settings GET). Smartphones open `http://:5000/operator` for camera capture. +Port split: mode / model_mode / require_plate / require_do / ocr_engine POSTs → +**office 5721 only** (403 on 5000). Smartphones open `http://:5000/operator` for camera capture. diff --git a/requirements.txt b/requirements.txt index 8e17cb6..ec6ddbe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,8 @@ python-dotenv pyyaml # also pulled by ultralytics; used directly by src/config_loader.py openpyxl paramiko # deploy_to_jetson.py only -pytesseract # DO OCR (Phase 2); system tesseract-ocr package also required on Jetson +pytesseract # DO OCR backup; system tesseract-ocr package also required on Jetson +rapidocr_onnxruntime # DO OCR default engine (RapidOCR/PP-OCR, models bundled) # Test-only (dev/CI): # pytest diff --git a/src/config_loader.py b/src/config_loader.py index bc6a80e..224b477 100644 --- a/src/config_loader.py +++ b/src/config_loader.py @@ -110,7 +110,7 @@ class BatchConfig: @dataclass class DoOcrConfig: - engine: str = "tesseract" # tesseract | paddle | none (YAML seed; runtime in do_settings.json) + engine: str = "rapid" # rapid | tesseract | paddle | none (YAML seed; runtime in do_settings.json) @dataclass @@ -241,9 +241,9 @@ def _parse_detection_params(raw: Dict[str, Any]) -> Dict[str, DetectionParams]: def _parse_do(raw: Dict[str, Any]) -> DoConfig: raw = raw or {} ocr_raw = raw.get("ocr") or {} - engine = str(ocr_raw.get("engine", "tesseract")).lower() - if engine not in ("tesseract", "paddle", "none"): - engine = "tesseract" + engine = str(ocr_raw.get("engine", "rapid")).lower() + if engine not in ("rapid", "tesseract", "paddle", "none"): + engine = "rapid" return DoConfig( enabled=bool(raw.get("enabled", True)), require_do=bool(raw.get("require_do", True)), diff --git a/src/do_batch.py b/src/do_batch.py index d0938e9..6d74e6f 100644 --- a/src/do_batch.py +++ b/src/do_batch.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple BATCH_MODES = ("auto", "do_manual", "manual") -OCR_ENGINES = ("tesseract", "paddle", "none") +OCR_ENGINES = ("rapid", "tesseract", "paddle", "none") SACK_LIKE_UNITS = frozenset({"krg", "karung", "sack", "bg"}) BOX_LIKE_UNITS = frozenset({"box", "ctn", "karton", "dus", "kardus"}) @@ -145,9 +145,9 @@ def empty_do_settings(defaults: Dict[str, Any]) -> Dict[str, Any]: out = { "require_plate": bool(defaults.get("require_plate", False)), "require_do": bool(defaults.get("require_do", True)), - "ocr_engine": str(defaults.get("ocr_engine", "tesseract")).lower(), + "ocr_engine": str(defaults.get("ocr_engine", "rapid")).lower(), "editable": True, } if not is_valid_ocr_engine(out["ocr_engine"]): - out["ocr_engine"] = "tesseract" + out["ocr_engine"] = "rapid" return out diff --git a/src/do_ocr.py b/src/do_ocr.py index 559877d..30e98e5 100644 --- a/src/do_ocr.py +++ b/src/do_ocr.py @@ -1,6 +1,7 @@ """DO photo OCR: single entry extract_do_fields(image_path, engine). -Engines: tesseract (primary CPU), paddle (backup, optional deps), none (empty draft). +Engines: rapid (default, RapidOCR/PP-OCR via onnxruntime), tesseract +(backup, light), paddle (backup, heavy), none (empty draft). Must NOT run inside predict.py loop — dashboard upload path only. """ @@ -28,12 +29,12 @@ _QTY_LINE_RE = re.compile( ) -def extract_do_fields(image_path: str, engine: str = "tesseract") -> Dict[str, Any]: +def extract_do_fields(image_path: str, engine: str = "rapid") -> Dict[str, Any]: """Return draft fields from a DO photo. Always a dict; never raises on OCR fail. Keys: no_do, plate, expected_sack, expected_box, ocr_text, engine, ocr_ok, ocr_error """ - engine = (engine or "tesseract").strip().lower() + engine = (engine or "rapid").strip().lower() base: Dict[str, Any] = { "no_do": "", "plate": "", @@ -48,7 +49,9 @@ def extract_do_fields(image_path: str, engine: str = "tesseract") -> Dict[str, A base["ocr_error"] = "disabled" return base try: - if engine == "paddle": + if engine == "rapid": + text = _ocr_rapid(image_path) + elif engine == "paddle": text = _ocr_paddle(image_path) else: text = _ocr_tesseract(image_path) @@ -133,6 +136,33 @@ def _ocr_tesseract(image_path: str) -> Optional[str]: return None +_RAPID_OCR = None # lazy singleton; PP-OCR models load once per process + + +def _ocr_rapid(image_path: str) -> Optional[str]: + """RapidOCR (PP-OCRv4 via onnxruntime). Default engine.""" + global _RAPID_OCR + try: + from rapidocr_onnxruntime import RapidOCR # type: ignore + except ImportError: + raise RuntimeError( + "RapidOCR not installed — pip install rapidocr_onnxruntime; " + "switch ocr_engine back to tesseract" + ) + if _RAPID_OCR is None: + _RAPID_OCR = RapidOCR() + result, _ = _RAPID_OCR(image_path) + lines = [] + for item in result or []: + try: + seq = item[1] + # 1.x: [box, text, conf]; older: [box, (text, conf)] + lines.append(str(seq[0] if isinstance(seq, (tuple, list)) else seq)) + except (TypeError, IndexError): + continue + return "\n".join(lines) + + def _ocr_paddle(image_path: str) -> Optional[str]: """PaddleOCR backup. Raises clear error path via extract when deps missing.""" try: diff --git a/templates/monitoring.html b/templates/monitoring.html index 43b70e3..cb52f8f 100644 --- a/templates/monitoring.html +++ b/templates/monitoring.html @@ -354,9 +354,10 @@ OCR Engine diff --git a/templates/operator.html b/templates/operator.html index f2eb9a4..6d6fb51 100644 --- a/templates/operator.html +++ b/templates/operator.html @@ -312,14 +312,6 @@ justify-content: center; flex-wrap: wrap; } - .ocr-row select { - padding: 6px 10px; - border-radius: var(--radius-md); - border: 1px solid var(--border-color); - background: var(--bg-base); - color: var(--text-primary); - font-size: 13px; - } .mode-badge { display: inline-block; padding: 4px 12px; @@ -401,7 +393,6 @@

Foto DO (Smartphone) -

@@ -413,15 +404,6 @@
-
- OCR: - - -
@@ -474,7 +456,7 @@ let isBatchActive = false; let currentMode = 'auto'; let stagedDos = []; - let settings = { require_plate: false, require_do: true, ocr_engine: 'tesseract', editable: false }; + let settings = { require_plate: false, require_do: true, editable: false }; function closeModal(id) { document.getElementById(id).style.display = 'none'; @@ -531,12 +513,6 @@ const data = await res.json(); if (!data.success) return; settings = data; - const sel = document.getElementById('ocrEngineSelect'); - if (sel && data.ocr_engine) sel.value = data.ocr_engine; - const hint = document.getElementById('ocrEngineHint'); - if (hint) hint.textContent = 'engine: ' + (data.ocr_engine || 'tesseract'); - const badge = document.getElementById('doEngineBadge'); - if (badge) badge.textContent = (data.ocr_engine || 'tesseract'); const chk = document.getElementById('requirePlateChk'); const row = document.getElementById('doRequirePlateRow'); if (chk) chk.checked = !!data.require_plate; @@ -544,26 +520,6 @@ } catch (e) { console.error(e); } } - async function setOcrEngine(eng) { - try { - const res = await fetch('/api/do/settings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ocr_engine: eng }) - }); - const data = await res.json(); - if (!data.success) { - alert('Gagal set OCR: ' + (data.error || res.status)); - loadDoSettings(); - return; - } - loadDoSettings(); - } catch (e) { - alert('Gagal terhubung ke server'); - loadDoSettings(); - } - } - async function setRequirePlate(val) { try { const res = await fetch('/api/do/settings', { diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index f0e5364..3c4ec0c 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -49,7 +49,7 @@ def test_do_block_and_batch_default_mode(repo_config_path): assert cfg.do.photo_dir == "do_photos" assert cfg.do.max_photos_per_batch == 8 assert cfg.do.zone_warn_seconds == 3 - assert cfg.do.ocr.engine == "tesseract" + assert cfg.do.ocr.engine == "rapid" def test_do_block_missing_uses_defaults(tmp_path): @@ -65,7 +65,7 @@ def test_do_block_missing_uses_defaults(tmp_path): cfg = load_config(p) assert cfg.batch.default_mode == "auto" assert cfg.do.enabled is True - assert cfg.do.ocr.engine == "tesseract" + assert cfg.do.ocr.engine == "rapid" assert cfg.do.retention_days == 7 diff --git a/tests/test_do_batch.py b/tests/test_do_batch.py index 79ba3c1..c381ee0 100644 --- a/tests/test_do_batch.py +++ b/tests/test_do_batch.py @@ -116,6 +116,7 @@ def test_ocr_engine_validation(): for e in OCR_ENGINES: assert is_valid_ocr_engine(e) assert not is_valid_ocr_engine("easyocr") + assert OCR_ENGINES[0] == "rapid" # default engine def test_empty_do_settings_defaults(): @@ -123,7 +124,7 @@ def test_empty_do_settings_defaults(): assert s["ocr_engine"] == "tesseract" assert s["require_do"] is True bad = empty_do_settings({"ocr_engine": "nope"}) - assert bad["ocr_engine"] == "tesseract" + assert bad["ocr_engine"] == "rapid" def test_parse_fields_from_text(): @@ -145,6 +146,21 @@ def test_extract_do_fields_none_engine(tmp_path): assert out["engine"] == "none" +def test_extract_do_fields_rapid_engine(tmp_path): + from src.do_ocr import extract_do_fields + Image = pytest.importorskip("PIL.Image") + + p = tmp_path / "do.png" + Image.new("RGB", (200, 60), "white").save(p) + out = extract_do_fields(str(p), engine="rapid") + assert out["engine"] == "rapid" + if out["ocr_ok"]: + assert out["ocr_text"] == "" # blank image → empty text, no crash + else: + # rapid missing → explicit error (never silent fallback) + assert "rapidocr" in out["ocr_error"].lower() + + # --------------------------------------------------------------------------- # Flask API auth tests — skipped when flask/openpyxl not installed (CI) # --------------------------------------------------------------------------- @@ -213,17 +229,18 @@ def test_model_mode_post_operator_403(dash_client): assert res.status_code == 403 -def test_ocr_engine_post_both_ports(dash_client): +def test_ocr_engine_post_office_only(dash_client): client, _, _, settings_path = dash_client res = client.post("/api/do/settings", json={"ocr_engine": "paddle"}, headers={"Host": "localhost:5000"}) + assert res.status_code == 403 + office = {"Host": f"localhost:{cd_office_port()}"} + res = client.post("/api/do/settings", json={"ocr_engine": "paddle"}, headers=office) assert res.status_code == 200 assert res.get_json()["ocr_engine"] == "paddle" - res = client.post("/api/do/settings", json={"ocr_engine": "tesseract"}, - headers={"Host": f"localhost:{cd_office_port()}"}) + res = client.post("/api/do/settings", json={"ocr_engine": "tesseract"}, headers=office) assert res.status_code == 200 - res = client.post("/api/do/settings", json={"ocr_engine": "bogus"}, - headers={"Host": "localhost:5000"}) + res = client.post("/api/do/settings", json={"ocr_engine": "bogus"}, headers=office) assert res.status_code == 400