790 lines
30 KiB
Python
790 lines
30 KiB
Python
import argparse
|
|
import asyncio
|
|
import configparser
|
|
import json
|
|
import random
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
import numpy as np
|
|
import uvicorn
|
|
from fastapi import FastAPI, File, Form, UploadFile
|
|
|
|
|
|
DEFAULT_CONFIG_PATH = "flywms_navigation.ini"
|
|
CUDA_MIN_PIXELS = 640 * 360
|
|
|
|
|
|
def opencv_cuda_available() -> bool:
|
|
try:
|
|
return hasattr(cv2, "cuda") and cv2.cuda.getCudaEnabledDeviceCount() > 0
|
|
except cv2.error:
|
|
return False
|
|
|
|
|
|
OPENCV_CUDA_AVAILABLE = opencv_cuda_available()
|
|
|
|
|
|
def cuda_resize(
|
|
image: np.ndarray,
|
|
size: tuple[int, int],
|
|
interpolation: int = cv2.INTER_LINEAR,
|
|
min_pixels: int = CUDA_MIN_PIXELS,
|
|
) -> np.ndarray:
|
|
if not OPENCV_CUDA_AVAILABLE or image.size < min_pixels:
|
|
return cv2.resize(image, size, interpolation=interpolation)
|
|
try:
|
|
gpu = cv2.cuda_GpuMat()
|
|
gpu.upload(image)
|
|
return cv2.cuda.resize(gpu, size, interpolation=interpolation).download()
|
|
except cv2.error:
|
|
return cv2.resize(image, size, interpolation=interpolation)
|
|
|
|
|
|
def cuda_cvt_color(
|
|
image: np.ndarray,
|
|
code: int,
|
|
min_pixels: int = CUDA_MIN_PIXELS,
|
|
) -> np.ndarray:
|
|
if not OPENCV_CUDA_AVAILABLE or image.size < min_pixels:
|
|
return cv2.cvtColor(image, code)
|
|
try:
|
|
gpu = cv2.cuda_GpuMat()
|
|
gpu.upload(image)
|
|
return cv2.cuda.cvtColor(gpu, code).download()
|
|
except cv2.error:
|
|
return cv2.cvtColor(image, code)
|
|
|
|
app = FastAPI(title="FlyWMS Demo WMS Server")
|
|
server_state = {
|
|
"received_dir": "wms_received",
|
|
"fake_ack_mode": "always-ack",
|
|
"fake_processing_sec": 0.5,
|
|
"ui_enabled": True,
|
|
"ocr_enabled": True,
|
|
"ocr_mode": "paddleocr",
|
|
"tesseract_cmd": "",
|
|
"paddle_python": r"C:\devel\yolo-ocr\.venv-paddle-cpu\Scripts\python.exe",
|
|
"paddle_worker_script": str(Path(__file__).with_name("flywms_paddleocr_worker.py")),
|
|
"paddle_target_heights": "96",
|
|
"paddle_variant_set": "fast",
|
|
"paddle_expected_digits": 6,
|
|
"paddle_min_votes": 2,
|
|
"paddle_min_confidence": 0.70,
|
|
"fake_ocr_prefix": "UDC",
|
|
"undetermined_code_text": "udc non determinato",
|
|
"fake_ocr_delay_sec": 0.2,
|
|
"operator": "udrone",
|
|
"site": "demo-magazzino",
|
|
"counter": 0,
|
|
}
|
|
state_lock = threading.Lock()
|
|
ui_lock = threading.Lock()
|
|
ui_state = {
|
|
"image": None,
|
|
"payload_lines": ["In attesa di payload WMS"],
|
|
"updated": False,
|
|
"stop": False,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OcrServerResult:
|
|
text: str
|
|
raw_text: str
|
|
confidence: float
|
|
backend: str
|
|
fallback_used: bool
|
|
votes: int = 0
|
|
variant: str = ""
|
|
|
|
|
|
class PaddleOcrWorker:
|
|
def __init__(
|
|
self,
|
|
python_path: str,
|
|
script_path: str,
|
|
target_heights: str,
|
|
variant_set: str,
|
|
expected_digits: int,
|
|
min_votes: int,
|
|
min_confidence: float,
|
|
) -> None:
|
|
self.python_path = python_path
|
|
self.script_path = script_path
|
|
self.target_heights = target_heights
|
|
self.variant_set = variant_set
|
|
self.expected_digits = expected_digits
|
|
self.min_votes = min_votes
|
|
self.min_confidence = min_confidence
|
|
self.lock = threading.Lock()
|
|
self.process: subprocess.Popen[str] | None = None
|
|
|
|
def close(self) -> None:
|
|
with self.lock:
|
|
process = self.process
|
|
self.process = None
|
|
if process is None:
|
|
return
|
|
try:
|
|
if process.stdin:
|
|
process.stdin.write("__quit__\n")
|
|
process.stdin.flush()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
process.wait(timeout=2.0)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
|
|
def predict(self, image_path: Path) -> dict[str, object]:
|
|
with self.lock:
|
|
process = self._ensure_process()
|
|
if process.stdin is None or process.stdout is None:
|
|
raise RuntimeError("worker stdio non disponibile")
|
|
request = {
|
|
"image_path": str(image_path),
|
|
"target_heights": self.target_heights,
|
|
"variant_set": self.variant_set,
|
|
"expected_digits": self.expected_digits,
|
|
"min_votes": self.min_votes,
|
|
"min_confidence": self.min_confidence,
|
|
}
|
|
process.stdin.write(json.dumps(request, ensure_ascii=True) + "\n")
|
|
process.stdin.flush()
|
|
line = process.stdout.readline()
|
|
if not line:
|
|
self.process = None
|
|
raise RuntimeError("worker PaddleOCR terminato")
|
|
return json.loads(line)
|
|
|
|
def _ensure_process(self) -> subprocess.Popen[str]:
|
|
if self.process is not None and self.process.poll() is None:
|
|
return self.process
|
|
python_path = Path(self.python_path)
|
|
script_path = Path(self.script_path)
|
|
if not python_path.exists():
|
|
raise RuntimeError(f"python PaddleOCR non trovato: {python_path}")
|
|
if not script_path.exists():
|
|
raise RuntimeError(f"worker PaddleOCR non trovato: {script_path}")
|
|
self.process = subprocess.Popen(
|
|
[str(python_path), str(script_path)],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
|
|
)
|
|
assert self.process.stdout is not None
|
|
ready_line = self.process.stdout.readline()
|
|
if not ready_line:
|
|
raise RuntimeError("worker PaddleOCR non avviato")
|
|
ready = json.loads(ready_line)
|
|
if not ready.get("ready"):
|
|
raise RuntimeError(f"worker PaddleOCR risposta inattesa: {ready}")
|
|
return self.process
|
|
|
|
|
|
_paddle_worker: PaddleOcrWorker | None = None
|
|
_paddle_worker_lock = threading.Lock()
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
|
|
|
|
|
def load_server_config(path_str: str) -> dict[str, object]:
|
|
defaults: dict[str, object] = {
|
|
"wms_server_host": "0.0.0.0",
|
|
"wms_server_port": 8088,
|
|
"wms_received_dir": "wms_received",
|
|
"wms_fake_ack_mode": "always-ack",
|
|
"wms_fake_processing_sec": 0.5,
|
|
"wms_ui_enabled": True,
|
|
"wms_ocr_enabled": True,
|
|
"wms_ocr_mode": "paddleocr",
|
|
"wms_tesseract_cmd": "",
|
|
"wms_paddle_python": r"C:\devel\yolo-ocr\.venv-paddle-cpu\Scripts\python.exe",
|
|
"wms_paddle_worker_script": str(Path(__file__).with_name("flywms_paddleocr_worker.py")),
|
|
"wms_paddle_target_heights": "96",
|
|
"wms_paddle_variant_set": "fast",
|
|
"wms_paddle_expected_digits": 6,
|
|
"wms_paddle_min_votes": 2,
|
|
"wms_paddle_min_confidence": 0.70,
|
|
"wms_fake_ocr_prefix": "UDC",
|
|
"wms_undetermined_code_text": "udc non determinato",
|
|
"wms_fake_ocr_delay_sec": 0.2,
|
|
"wms_operator": "udrone",
|
|
"wms_site": "demo-magazzino",
|
|
}
|
|
|
|
path = Path(path_str)
|
|
if not path.exists():
|
|
return defaults
|
|
|
|
parser = configparser.ConfigParser()
|
|
parser.read(path, encoding="utf-8")
|
|
section = parser["navigation"] if parser.has_section("navigation") else {}
|
|
for key, default_value in defaults.items():
|
|
if key not in section:
|
|
continue
|
|
if isinstance(default_value, bool):
|
|
defaults[key] = parser.getboolean("navigation", key, fallback=default_value)
|
|
elif isinstance(default_value, int):
|
|
defaults[key] = parser.getint("navigation", key, fallback=default_value)
|
|
elif isinstance(default_value, float):
|
|
defaults[key] = parser.getfloat("navigation", key, fallback=default_value)
|
|
else:
|
|
defaults[key] = section.get(key, str(default_value)).strip()
|
|
return defaults
|
|
|
|
|
|
def parse_args():
|
|
pre = argparse.ArgumentParser(add_help=False)
|
|
pre.add_argument("--config", default=DEFAULT_CONFIG_PATH, help="File configurazione INI")
|
|
pre_args, _ = pre.parse_known_args()
|
|
defaults = load_server_config(pre_args.config)
|
|
|
|
ap = argparse.ArgumentParser(parents=[pre])
|
|
ap.add_argument("--host", default=defaults["wms_server_host"], help="Host bind server")
|
|
ap.add_argument("--port", type=int, default=defaults["wms_server_port"], help="Porta server")
|
|
ap.add_argument("--received-dir", default=defaults["wms_received_dir"], help="Directory upload ricevuti")
|
|
ap.add_argument(
|
|
"--fake-ack-mode",
|
|
choices=["always-ack", "always-nack", "alternate", "random"],
|
|
default=defaults["wms_fake_ack_mode"],
|
|
help="Modalita risposta WMS demo",
|
|
)
|
|
ap.add_argument(
|
|
"--fake-processing-sec",
|
|
type=float,
|
|
default=defaults["wms_fake_processing_sec"],
|
|
help="Ritardo elaborazione finta",
|
|
)
|
|
ap.add_argument("--ui-enabled", action="store_true", default=defaults["wms_ui_enabled"], help="Mostra finestre OpenCV server")
|
|
ap.add_argument("--no-ui", action="store_false", dest="ui_enabled", help="Disabilita finestre OpenCV server")
|
|
ap.add_argument("--ocr-enabled", action="store_true", default=defaults["wms_ocr_enabled"], help="Esegue OCR reale se disponibile")
|
|
ap.add_argument("--no-ocr", action="store_false", dest="ocr_enabled", help="Disabilita OCR reale e usa fallback demo")
|
|
ap.add_argument("--ocr-mode", choices=["paddleocr", "easyocr", "tesseract", "fake"], default=defaults["wms_ocr_mode"], help="Motore OCR server")
|
|
ap.add_argument("--tesseract-cmd", default=defaults["wms_tesseract_cmd"], help="Percorso esplicito tesseract.exe")
|
|
ap.add_argument("--paddle-python", default=defaults["wms_paddle_python"], help="Python del virtualenv PaddleOCR")
|
|
ap.add_argument("--paddle-worker-script", default=defaults["wms_paddle_worker_script"], help="Script worker PaddleOCR")
|
|
ap.add_argument("--paddle-target-heights", default=defaults["wms_paddle_target_heights"], help="Altezze resize PaddleOCR, es. 96 oppure 64,96,128")
|
|
ap.add_argument("--paddle-variant-set", choices=["fast", "balanced", "full"], default=defaults["wms_paddle_variant_set"], help="Numero varianti preprocessing PaddleOCR")
|
|
ap.add_argument("--paddle-expected-digits", type=int, default=defaults["wms_paddle_expected_digits"], help="Numero cifre atteso codice UDC")
|
|
ap.add_argument("--paddle-min-votes", type=int, default=defaults["wms_paddle_min_votes"], help="Consenso minimo varianti PaddleOCR")
|
|
ap.add_argument("--paddle-min-confidence", type=float, default=defaults["wms_paddle_min_confidence"], help="Confidenza minima PaddleOCR")
|
|
ap.add_argument("--fake-ocr-prefix", default=defaults["wms_fake_ocr_prefix"], help="Prefisso fallback OCR demo")
|
|
ap.add_argument("--undetermined-code-text", default=defaults["wms_undetermined_code_text"], help="Testo usato se OCR non determina il codice")
|
|
ap.add_argument("--fake-ocr-delay-sec", type=float, default=defaults["wms_fake_ocr_delay_sec"], help="Ritardo OCR demo/fallback")
|
|
ap.add_argument("--operator", default=defaults["wms_operator"], help="Operatore WMS demo")
|
|
ap.add_argument("--site", default=defaults["wms_site"], help="Sito/magazzino demo")
|
|
return ap.parse_args()
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.post("/api/v1/navigation-snapshot")
|
|
async def receive_navigation_snapshot(
|
|
metadata: str = Form(...),
|
|
label_image: UploadFile = File(...),
|
|
gaylord_image: UploadFile | None = File(None),
|
|
) -> dict[str, object]:
|
|
started = time.perf_counter()
|
|
try:
|
|
meta = json.loads(metadata)
|
|
except json.JSONDecodeError:
|
|
meta = {"raw_metadata": metadata}
|
|
|
|
with state_lock:
|
|
server_state["counter"] = int(server_state["counter"]) + 1
|
|
counter = int(server_state["counter"])
|
|
|
|
request_id = str(meta.get("request_id") or f"server-{counter:06d}")
|
|
snapshot_id = meta.get("snapshot_id", counter)
|
|
received_dir = Path(str(server_state["received_dir"]))
|
|
request_dir = received_dir / f"{counter:06d}_{safe_name(request_id)}"
|
|
request_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
label_path = request_dir / safe_upload_name(label_image.filename, "label.jpg")
|
|
label_bytes = await label_image.read()
|
|
label_path.write_bytes(label_bytes)
|
|
|
|
gaylord_path = None
|
|
gaylord_bytes = b""
|
|
if gaylord_image is not None:
|
|
gaylord_path = request_dir / safe_upload_name(gaylord_image.filename, "gaylord.jpg")
|
|
gaylord_bytes = await gaylord_image.read()
|
|
gaylord_path.write_bytes(gaylord_bytes)
|
|
|
|
(request_dir / "metadata.json").write_text(
|
|
json.dumps(meta, indent=2, ensure_ascii=True),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
image = cv2.imdecode(np.frombuffer(label_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
|
|
ocr_result = await asyncio.to_thread(run_server_ocr, image, int(snapshot_id), label_path)
|
|
|
|
fake_processing_sec = float(server_state["fake_processing_sec"])
|
|
if fake_processing_sec > 0:
|
|
await asyncio.sleep(fake_processing_sec)
|
|
|
|
status = choose_status(str(server_state["fake_ack_mode"]), counter)
|
|
udc_code = ocr_result.text if status == "ACK" else ""
|
|
message = (
|
|
f"codice valido su WMS: {udc_code}"
|
|
if status == "ACK"
|
|
else f"codice non riconosciuto: {ocr_result.text}"
|
|
)
|
|
processing_ms = (time.perf_counter() - started) * 1000.0
|
|
received_at = datetime.now(timezone.utc).isoformat()
|
|
wms_payload = {
|
|
"request_id": request_id,
|
|
"client_id": meta.get("client_id", ""),
|
|
"site": server_state["site"],
|
|
"operator": server_state["operator"],
|
|
"received_at": received_at,
|
|
"snapshot_id": snapshot_id,
|
|
"position": meta.get("simulated_position", ""),
|
|
"track_id": meta.get("track_id", ""),
|
|
"udc_code": udc_code,
|
|
"ocr_raw_text": ocr_result.raw_text,
|
|
"ocr_confidence": ocr_result.confidence,
|
|
"ocr_backend": ocr_result.backend,
|
|
"ocr_fallback_used": ocr_result.fallback_used,
|
|
"ocr_votes": ocr_result.votes,
|
|
"ocr_variant": ocr_result.variant,
|
|
"validation_status": status,
|
|
"validation_message": message,
|
|
"label_bbox": meta.get("label_bbox", []),
|
|
"gaylord_bbox": meta.get("gaylord_bbox", []),
|
|
"movement_vector_px": meta.get("movement_vector_px", {}),
|
|
"label_image_path": str(label_path),
|
|
"gaylord_image_path": str(gaylord_path) if gaylord_path else "",
|
|
}
|
|
(request_dir / "wms_payload.json").write_text(
|
|
json.dumps(wms_payload, indent=2, ensure_ascii=True),
|
|
encoding="utf-8",
|
|
)
|
|
update_ui_state(image, wms_payload)
|
|
|
|
log(
|
|
f"received request_id={request_id} snapshot={snapshot_id} "
|
|
f"label_bytes={len(label_bytes)} gaylord_bytes={len(gaylord_bytes)} "
|
|
f"ocr='{ocr_result.text}' status={status}"
|
|
)
|
|
return {
|
|
"status": status,
|
|
"request_id": request_id,
|
|
"message": message,
|
|
"ocr_text": udc_code,
|
|
"ocr_raw_text": ocr_result.raw_text,
|
|
"ocr_confidence": ocr_result.confidence,
|
|
"ocr_backend": ocr_result.backend,
|
|
"ocr_fallback_used": ocr_result.fallback_used,
|
|
"ocr_votes": ocr_result.votes,
|
|
"ocr_variant": ocr_result.variant,
|
|
"processing_ms": round(processing_ms, 1),
|
|
"saved_dir": str(request_dir),
|
|
"wms_payload_path": str(request_dir / "wms_payload.json"),
|
|
"label_image_path": str(label_path),
|
|
"gaylord_image_path": str(gaylord_path) if gaylord_path else "",
|
|
}
|
|
|
|
|
|
def run_server_ocr(image: np.ndarray | None, snapshot_id: int, label_path: Path | None = None) -> OcrServerResult:
|
|
fake_delay = float(server_state["fake_ocr_delay_sec"])
|
|
if fake_delay > 0:
|
|
time.sleep(fake_delay)
|
|
fallback = str(server_state["undetermined_code_text"])
|
|
if image is None:
|
|
return OcrServerResult(fallback, "", 0.0, "fallback", True)
|
|
if not bool(server_state["ocr_enabled"]) or str(server_state["ocr_mode"]) == "fake":
|
|
return OcrServerResult(fallback, fallback, 1.0, "fake", True)
|
|
if str(server_state["ocr_mode"]) == "paddleocr":
|
|
if label_path is None:
|
|
return OcrServerResult(fallback, "", 0.0, "paddleocr-missing-path", True)
|
|
return run_paddleocr_ocr(label_path, fallback)
|
|
if str(server_state["ocr_mode"]) == "tesseract":
|
|
return run_tesseract_ocr(image, fallback)
|
|
|
|
try:
|
|
reader = get_easyocr_reader()
|
|
ocr_input = preprocess_label_for_ocr(image)
|
|
results = reader.readtext(
|
|
ocr_input,
|
|
allowlist="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
|
|
detail=1,
|
|
paragraph=False,
|
|
)
|
|
except Exception as exc:
|
|
log(f"OCR fallback: {exc}")
|
|
return OcrServerResult(fallback, "", 0.0, "easyocr-error", True)
|
|
|
|
raw_parts: list[str] = []
|
|
best_text = ""
|
|
best_conf = 0.0
|
|
for item in results:
|
|
text = str(item[1]).strip()
|
|
conf = float(item[2]) if len(item) > 2 else 0.0
|
|
if text:
|
|
raw_parts.append(text)
|
|
candidate = normalize_ocr_code(text)
|
|
if candidate and conf >= best_conf:
|
|
best_text = candidate
|
|
best_conf = conf
|
|
|
|
raw_text = " ".join(raw_parts)
|
|
if not best_text:
|
|
digits = re.sub(r"\D+", "", raw_text)
|
|
if digits:
|
|
best_text = digits
|
|
best_conf = max(best_conf, 0.01)
|
|
if best_text:
|
|
return OcrServerResult(best_text, raw_text, best_conf, "easyocr", False)
|
|
return OcrServerResult(fallback, raw_text, best_conf, "easyocr-fallback", True)
|
|
|
|
|
|
def get_paddle_worker() -> PaddleOcrWorker:
|
|
global _paddle_worker
|
|
with _paddle_worker_lock:
|
|
if _paddle_worker is None:
|
|
_paddle_worker = PaddleOcrWorker(
|
|
python_path=str(server_state["paddle_python"]),
|
|
script_path=str(server_state["paddle_worker_script"]),
|
|
target_heights=str(server_state["paddle_target_heights"]),
|
|
variant_set=str(server_state["paddle_variant_set"]),
|
|
expected_digits=int(server_state["paddle_expected_digits"]),
|
|
min_votes=int(server_state["paddle_min_votes"]),
|
|
min_confidence=float(server_state["paddle_min_confidence"]),
|
|
)
|
|
return _paddle_worker
|
|
|
|
|
|
def close_paddle_worker() -> None:
|
|
global _paddle_worker
|
|
with _paddle_worker_lock:
|
|
worker = _paddle_worker
|
|
_paddle_worker = None
|
|
if worker is not None:
|
|
worker.close()
|
|
|
|
|
|
def run_paddleocr_ocr(label_path: Path, fallback: str) -> OcrServerResult:
|
|
try:
|
|
response = get_paddle_worker().predict(label_path)
|
|
except Exception as exc:
|
|
log(f"PaddleOCR fallback: {exc}")
|
|
close_paddle_worker()
|
|
return OcrServerResult(fallback, "", 0.0, "paddleocr-error", True)
|
|
|
|
text = str(response.get("text") or "")
|
|
best_text = str(response.get("best_text") or text)
|
|
raw_text = str(response.get("raw_text") or best_text)
|
|
confidence = float(response.get("confidence") or 0.0)
|
|
votes = int(response.get("votes") or 0)
|
|
variant = str(response.get("variant") or "")
|
|
if response.get("ok") and text:
|
|
return OcrServerResult(text, raw_text, confidence, "paddleocr", False, votes=votes, variant=variant)
|
|
reason = str(response.get("reason") or "fallback")
|
|
raw_with_reason = f"{raw_text} [{reason}]".strip()
|
|
return OcrServerResult(fallback, raw_with_reason, confidence, "paddleocr-fallback", True, votes=votes, variant=variant)
|
|
|
|
|
|
def run_tesseract_ocr(image: np.ndarray, fallback: str) -> OcrServerResult:
|
|
try:
|
|
import pytesseract
|
|
except Exception as exc:
|
|
log(f"Tesseract fallback: pytesseract non disponibile: {exc}")
|
|
return OcrServerResult(fallback, "", 0.0, "tesseract-unavailable", True)
|
|
|
|
tesseract_cmd = str(server_state.get("tesseract_cmd") or "").strip()
|
|
if tesseract_cmd:
|
|
if not Path(tesseract_cmd).exists():
|
|
log(f"Tesseract fallback: binario non trovato: {tesseract_cmd}")
|
|
return OcrServerResult(fallback, "", 0.0, "tesseract-missing", True)
|
|
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
|
|
elif shutil.which("tesseract") is None:
|
|
log("Tesseract fallback: tesseract.exe non trovato nel PATH")
|
|
return OcrServerResult(fallback, "", 0.0, "tesseract-missing", True)
|
|
|
|
variants = build_tesseract_variants(image)
|
|
candidates: list[tuple[str, float, str]] = []
|
|
config = "--psm 7 --oem 3 -c tessedit_char_whitelist=0123456789"
|
|
for name, variant in variants:
|
|
try:
|
|
data = pytesseract.image_to_data(
|
|
variant,
|
|
config=config,
|
|
output_type=pytesseract.Output.DICT,
|
|
)
|
|
except Exception as exc:
|
|
log(f"Tesseract fallback su variante {name}: {exc}")
|
|
return OcrServerResult(fallback, "", 0.0, "tesseract-error", True)
|
|
|
|
texts = [text.strip() for text in data.get("text", []) if text and text.strip()]
|
|
confs: list[float] = []
|
|
for conf in data.get("conf", []):
|
|
try:
|
|
value = float(conf)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if value >= 0:
|
|
confs.append(value / 100.0)
|
|
raw = "".join(texts)
|
|
digits = re.sub(r"\D+", "", raw)
|
|
confidence = sum(confs) / len(confs) if confs else 0.0
|
|
if digits:
|
|
candidates.append((digits, confidence, name))
|
|
|
|
if not candidates:
|
|
return OcrServerResult(fallback, "", 0.0, "tesseract-fallback", True)
|
|
|
|
votes: dict[str, list[float]] = {}
|
|
for digits, confidence, _ in candidates:
|
|
votes.setdefault(digits, []).append(confidence)
|
|
best_digits, best_confs = max(
|
|
votes.items(),
|
|
key=lambda item: (len(item[1]), sum(item[1]) / max(len(item[1]), 1)),
|
|
)
|
|
consensus_count = len(best_confs)
|
|
avg_conf = sum(best_confs) / consensus_count
|
|
raw_text = "; ".join(f"{digits}@{conf:.2f}/{name}" for digits, conf, name in candidates)
|
|
if consensus_count < 2 or avg_conf < 0.45:
|
|
return OcrServerResult(fallback, raw_text, avg_conf, "tesseract-ambiguous", True)
|
|
return OcrServerResult(best_digits, raw_text, avg_conf, "tesseract", False)
|
|
|
|
|
|
def build_tesseract_variants(image: np.ndarray) -> list[tuple[str, np.ndarray]]:
|
|
h, w = image.shape[:2]
|
|
scale = 4.0 if w < 500 else 2.0
|
|
resized = cuda_resize(image, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_CUBIC)
|
|
gray = cuda_cvt_color(resized, cv2.COLOR_BGR2GRAY)
|
|
gray = cv2.bilateralFilter(gray, 7, 45, 45)
|
|
sharpen = cv2.addWeighted(gray, 1.6, cv2.GaussianBlur(gray, (0, 0), 1.2), -0.6, 0)
|
|
_, otsu = cv2.threshold(sharpen, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
adaptive = cv2.adaptiveThreshold(
|
|
sharpen,
|
|
255,
|
|
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
cv2.THRESH_BINARY,
|
|
31,
|
|
7,
|
|
)
|
|
return [
|
|
("gray", gray),
|
|
("sharpen", sharpen),
|
|
("otsu", otsu),
|
|
("adaptive", adaptive),
|
|
("otsu_inv", cv2.bitwise_not(otsu)),
|
|
]
|
|
|
|
|
|
_easyocr_reader = None
|
|
_easyocr_lock = threading.Lock()
|
|
|
|
|
|
def get_easyocr_reader():
|
|
global _easyocr_reader
|
|
with _easyocr_lock:
|
|
if _easyocr_reader is None:
|
|
import easyocr
|
|
|
|
_easyocr_reader = easyocr.Reader(["en"], gpu=False, verbose=False)
|
|
return _easyocr_reader
|
|
|
|
|
|
def preprocess_label_for_ocr(image: np.ndarray) -> np.ndarray:
|
|
h, w = image.shape[:2]
|
|
scale = 3.0 if w < 450 else 2.0
|
|
resized = cuda_resize(image, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_CUBIC)
|
|
gray = cuda_cvt_color(resized, cv2.COLOR_BGR2GRAY)
|
|
gray = cv2.bilateralFilter(gray, 7, 45, 45)
|
|
return cuda_cvt_color(gray, cv2.COLOR_GRAY2BGR)
|
|
|
|
|
|
def normalize_ocr_code(text: str) -> str:
|
|
compact = re.sub(r"[^0-9A-Za-z-]+", "", text).strip("-")
|
|
digits = re.sub(r"\D+", "", compact)
|
|
return digits if digits else compact
|
|
|
|
|
|
def update_ui_state(image: np.ndarray | None, payload: dict[str, object]) -> None:
|
|
if not bool(server_state["ui_enabled"]):
|
|
return
|
|
lines = [
|
|
"ULTIMO PAYLOAD WMS",
|
|
f"request_id: {payload.get('request_id', '')}",
|
|
f"client_id: {payload.get('client_id', '')}",
|
|
f"site: {payload.get('site', '')}",
|
|
f"operator: {payload.get('operator', '')}",
|
|
f"received_at: {payload.get('received_at', '')}",
|
|
f"snapshot_id: {payload.get('snapshot_id', '')}",
|
|
f"position: {payload.get('position', '')}",
|
|
f"track_id: {payload.get('track_id', '')}",
|
|
f"udc_code: {payload.get('udc_code', '')}",
|
|
f"ocr_raw: {payload.get('ocr_raw_text', '')}",
|
|
f"ocr_backend: {payload.get('ocr_backend', '')}",
|
|
f"ocr_fallback: {payload.get('ocr_fallback_used', '')}",
|
|
f"ocr_votes: {payload.get('ocr_votes', '')}",
|
|
f"ocr_variant: {payload.get('ocr_variant', '')}",
|
|
f"status: {payload.get('validation_status', '')}",
|
|
f"label_bbox: {payload.get('label_bbox', '')}",
|
|
f"movement: {payload.get('movement_vector_px', '')}",
|
|
]
|
|
with ui_lock:
|
|
ui_state["image"] = None if image is None else image.copy()
|
|
ui_state["payload_lines"] = lines
|
|
ui_state["updated"] = True
|
|
|
|
|
|
def choose_status(mode: str, counter: int) -> str:
|
|
if mode == "always-nack":
|
|
return "NACK"
|
|
if mode == "alternate":
|
|
return "ACK" if counter % 2 == 1 else "NACK"
|
|
if mode == "random":
|
|
return "ACK" if random.random() >= 0.5 else "NACK"
|
|
return "ACK"
|
|
|
|
|
|
def safe_upload_name(filename: str | None, fallback: str) -> str:
|
|
name = Path(filename or fallback).name
|
|
return safe_name(name) or fallback
|
|
|
|
|
|
def safe_name(value: str) -> str:
|
|
allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"
|
|
return "".join(ch if ch in allowed else "_" for ch in value)[:160]
|
|
|
|
|
|
def server_ui_loop() -> None:
|
|
try:
|
|
cv2.namedWindow("wms immagine ricevuta", cv2.WINDOW_NORMAL)
|
|
cv2.namedWindow("wms payload", cv2.WINDOW_NORMAL)
|
|
except cv2.error as exc:
|
|
log(f"UI OpenCV disabilitata: highgui non disponibile ({exc})")
|
|
with ui_lock:
|
|
ui_state["stop"] = True
|
|
return
|
|
cv2.resizeWindow("wms immagine ricevuta", 640, 360)
|
|
cv2.resizeWindow("wms payload", 900, 560)
|
|
cv2.moveWindow("wms immagine ricevuta", 40, 40)
|
|
cv2.moveWindow("wms payload", 720, 40)
|
|
while True:
|
|
with ui_lock:
|
|
if ui_state["stop"]:
|
|
break
|
|
image = None if ui_state["image"] is None else ui_state["image"].copy()
|
|
lines = list(ui_state["payload_lines"])
|
|
if image is None:
|
|
image_display = np.full((360, 640, 3), 240, dtype=np.uint8)
|
|
cv2.putText(
|
|
image_display,
|
|
"In attesa immagine etichetta",
|
|
(30, 180),
|
|
cv2.FONT_HERSHEY_SIMPLEX,
|
|
0.9,
|
|
(0, 0, 0),
|
|
2,
|
|
cv2.LINE_AA,
|
|
)
|
|
else:
|
|
image_display = resize_preview(image, 640)
|
|
cv2.imshow("wms immagine ricevuta", image_display)
|
|
cv2.imshow("wms payload", draw_payload_window(lines))
|
|
key = cv2.waitKey(100) & 0xFF
|
|
if key in (27, ord("q")):
|
|
with ui_lock:
|
|
ui_state["stop"] = True
|
|
break
|
|
cv2.destroyWindow("wms immagine ricevuta")
|
|
cv2.destroyWindow("wms payload")
|
|
|
|
|
|
def resize_preview(frame: np.ndarray, max_width: int) -> np.ndarray:
|
|
h, w = frame.shape[:2]
|
|
if max_width <= 0 or w <= max_width:
|
|
return frame
|
|
scale = max_width / float(w)
|
|
return cuda_resize(frame, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_LINEAR)
|
|
|
|
|
|
def draw_payload_window(lines: list[str]) -> np.ndarray:
|
|
canvas = np.full((560, 900, 3), 245, dtype=np.uint8)
|
|
y = 42
|
|
for idx, line in enumerate(lines[:18]):
|
|
color = (0, 0, 160) if idx == 0 else (0, 0, 0)
|
|
scale = 0.9 if idx == 0 else 0.63
|
|
thickness = 2 if idx == 0 else 1
|
|
cv2.putText(
|
|
canvas,
|
|
str(line)[:110],
|
|
(24, y),
|
|
cv2.FONT_HERSHEY_SIMPLEX,
|
|
scale,
|
|
color,
|
|
thickness,
|
|
cv2.LINE_AA,
|
|
)
|
|
y += 34 if idx == 0 else 30
|
|
return canvas
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
server_state["received_dir"] = args.received_dir
|
|
server_state["fake_ack_mode"] = args.fake_ack_mode
|
|
server_state["fake_processing_sec"] = args.fake_processing_sec
|
|
server_state["ui_enabled"] = args.ui_enabled
|
|
server_state["ocr_enabled"] = args.ocr_enabled
|
|
server_state["ocr_mode"] = args.ocr_mode
|
|
server_state["tesseract_cmd"] = args.tesseract_cmd
|
|
server_state["paddle_python"] = args.paddle_python
|
|
server_state["paddle_worker_script"] = args.paddle_worker_script
|
|
server_state["paddle_target_heights"] = args.paddle_target_heights
|
|
server_state["paddle_variant_set"] = args.paddle_variant_set
|
|
server_state["paddle_expected_digits"] = args.paddle_expected_digits
|
|
server_state["paddle_min_votes"] = args.paddle_min_votes
|
|
server_state["paddle_min_confidence"] = args.paddle_min_confidence
|
|
server_state["fake_ocr_prefix"] = args.fake_ocr_prefix
|
|
server_state["undetermined_code_text"] = args.undetermined_code_text
|
|
server_state["fake_ocr_delay_sec"] = args.fake_ocr_delay_sec
|
|
server_state["operator"] = args.operator
|
|
server_state["site"] = args.site
|
|
Path(args.received_dir).mkdir(parents=True, exist_ok=True)
|
|
log(
|
|
f"FlyWMS WMS demo server host={args.host} port={args.port} "
|
|
f"mode={args.fake_ack_mode} received_dir={args.received_dir}"
|
|
)
|
|
log(f"OpenCV CUDA: {'attivo' if OPENCV_CUDA_AVAILABLE else 'non disponibile'}")
|
|
ui_thread = None
|
|
if args.ui_enabled:
|
|
ui_thread = threading.Thread(target=server_ui_loop, name="wms-server-ui", daemon=True)
|
|
ui_thread.start()
|
|
try:
|
|
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
|
|
finally:
|
|
close_paddle_worker()
|
|
with ui_lock:
|
|
ui_state["stop"] = True
|
|
if ui_thread is not None:
|
|
ui_thread.join(timeout=2.0)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|