import base64 import os import tempfile import logging from typing import Any, Dict, List, Optional, Tuple import cv2 import numpy as np from fastapi import HTTPException from app.core.config import MODEL_SERVICE_URL, MODEL_SERVICE_TIMEOUT_SECONDS, HF_SPACE_ID, LOGGER LOGGER = logging.getLogger("ain_el_aql.plate_recognition") CHAR_MAP = { "aain": ("ع", "E"), "alef": ("أ", "A"), "a": ("أ", "A"), "alf": ("أ", "A"), "baa": ("ب", "B"), "daal": ("د", "D"), "dal": ("د", "D"), "e": ("ع", "E"), "faa": ("ف", "F"), "geem": ("ج", "G"), "haa": ("هـ", "H"), "kaaf": ("ك", "K"), "laam": ("ل", "L"), "meem": ("م", "M"), "noon": ("ن", "N"), "qaf": ("ق", "Q"), "raa": ("ر", "R"), "sad": ("ص", "S"), "seen": ("س", "C"), "taa": ("ط", "T"), "waaw": ("و", "W"), "waw": ("و", "W"), "yaa": ("ى", "Y"), "zay": ("ز", "Z"), "dad": ("ض", "DD"), "0": ("0", "0"), "1": ("1", "1"), "2": ("2", "2"), "3": ("3", "3"), "4": ("4", "4"), "5": ("5", "5"), "6": ("6", "6"), "7": ("7", "7"), "8": ("8", "8"), "9": ("9", "9"), "٠": ("0", "0"), "١": ("1", "1"), "٢": ("2", "2"), "٣": ("3", "3"), "٤": ("4", "4"), "٥": ("5", "5"), "٦": ("6", "6"), "٧": ("7", "7"), "٨": ("8", "8"), "٩": ("9", "9"), } def _decode_image_bytes(image_bytes: bytes) -> np.ndarray: image_np = np.frombuffer(image_bytes, dtype=np.uint8) image_bgr = cv2.imdecode(image_np, cv2.IMREAD_COLOR) if image_bgr is None: raise HTTPException(status_code=400, detail="Unable to decode image.") return image_bgr def _encode_image_base64(image_bgr: np.ndarray) -> str: ok, encoded = cv2.imencode(".jpg", image_bgr) if not ok: raise HTTPException(status_code=500, detail="Failed to encode image.") return base64.b64encode(encoded.tobytes()).decode("utf-8") def _fit_into_canvas(image_bgr: np.ndarray, target_w: int, target_h: int) -> np.ndarray: canvas = np.full((target_h, target_w, 3), 18, dtype=np.uint8) if image_bgr.size == 0: return canvas src_h, src_w = image_bgr.shape[:2] scale = min(target_w / max(1, src_w), target_h / max(1, src_h)) new_w = max(1, int(src_w * scale)) new_h = max(1, int(src_h * scale)) resized = cv2.resize(image_bgr, (new_w, new_h), interpolation=cv2.INTER_AREA) x_off = (target_w - new_w) // 2 y_off = (target_h - new_h) // 2 canvas[y_off: y_off + new_h, x_off: x_off + new_w] = resized return canvas def _compose_user_split_image(plate_focus_bgr: np.ndarray, car_focus_bgr: np.ndarray) -> np.ndarray: half_h = max(plate_focus_bgr.shape[0], car_focus_bgr.shape[0], 220) half_w = max(plate_focus_bgr.shape[1], car_focus_bgr.shape[1], 320) left_half = _fit_into_canvas(plate_focus_bgr, half_w, half_h) right_half = _fit_into_canvas(car_focus_bgr, half_w, half_h) return np.concatenate([left_half, right_half], axis=1) def _clamp_bbox(x1: float, y1: float, x2: float, y2: float, width: int, height: int) -> Tuple[int, int, int, int]: left = max(0, min(int(x1), width - 1)) top = max(0, min(int(y1), height - 1)) right = max(1, min(int(x2), width)) bottom = max(1, min(int(y2), height)) if right <= left: right = min(width, left + 1) if bottom <= top: bottom = min(height, top + 1) return left, top, right, bottom def _build_plate_placeholder(reference_bgr: np.ndarray) -> np.ndarray: placeholder = np.full(reference_bgr.shape, 18, dtype=np.uint8) h, w = placeholder.shape[:2] text = "NO PLATE DETECTED" font_scale = 0.8 if w >= 500 else 0.6 thickness = 2 text_size, _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness) text_x = max(10, (w - text_size[0]) // 2) text_y = max(28, h // 2) cv2.putText(placeholder, text, (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, font_scale, (0, 220, 220), thickness, cv2.LINE_AA) return placeholder def decode_ocr_result(result: Any) -> Dict[str, Any]: if result.boxes is None or len(result.boxes) == 0: return {"raw_ordered_labels": [], "characters": [], "arabic": "N/A", "english": "N/A"} names = result.names if hasattr(result, "names") else {} detections: List[Dict[str, Any]] = [] for box in result.boxes: cls_idx = int(box.cls[0].item()) if box.cls is not None else -1 raw_label = names.get(cls_idx, str(cls_idx)) if isinstance(names, dict) else str(cls_idx) norm_label = str(raw_label).strip().lower() xyxy = box.xyxy[0].tolist() confidence = float(box.conf[0].item()) if box.conf is not None else 0.0 center_x = (xyxy[0] + xyxy[2]) / 2.0 ar_char, en_char = CHAR_MAP.get(norm_label, (str(raw_label), str(raw_label))) detections.append({ "label": str(raw_label), "normalized_label": norm_label, "arabic": ar_char, "english": en_char, "is_digit": norm_label.isdigit(), "confidence": round(confidence, 4), "bbox": [int(xyxy[0]), int(xyxy[1]), int(xyxy[2]), int(xyxy[3])], "center_x": center_x, }) letter_detections = [d for d in detections if not d["is_digit"]] number_detections = [d for d in detections if d["is_digit"]] letter_detections.sort(key=lambda item: item["center_x"], reverse=True) number_detections.sort(key=lambda item: item["center_x"], reverse=False) ar_letters = [d["arabic"] for d in letter_detections] ar_numbers = [d["arabic"] for d in number_detections] en_letters = [d["english"] for d in letter_detections] en_numbers = [d["english"] for d in number_detections] arabic_text = f"{' '.join(ar_letters)} | {' '.join(ar_numbers)}" if (ar_letters or ar_numbers) else "N/A" english_text = f"{' '.join(en_letters)} | {''.join(en_numbers)}" if (en_letters or en_numbers) else "N/A" clean_chars = [{"label": item["label"], "arabic": item["arabic"], "english": item["english"], "confidence": item["confidence"], "bbox": item["bbox"]} for item in detections] return {"raw_ordered_labels": [item["label"] for item in detections], "characters": clean_chars, "arabic": arabic_text, "english": english_text} def run_pipeline_remote(*, image_bytes: bytes, filename: Optional[str] = None, content_type: Optional[str] = None) -> Dict[str, Any]: try: from gradio_client import Client, handle_file except ImportError: raise HTTPException(status_code=500, detail="gradio_client is not installed.") # Monkey-patch gradio_client schema parsing bug where boolean additionalProperties throws TypeError try: import gradio_client.utils _orig_parser = gradio_client.utils._json_schema_to_python_type if not hasattr(gradio_client.utils, "_safe_parser_applied"): def _safe_parser(schema, defs): if isinstance(schema, bool) or not isinstance(schema, dict): return "Any" return _orig_parser(schema, defs) gradio_client.utils._json_schema_to_python_type = _safe_parser gradio_client.utils._safe_parser_applied = True except Exception: pass with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp: tmp.write(image_bytes) tmp_path = tmp.name try: client = Client(HF_SPACE_ID) result = client.predict(img=handle_file(tmp_path), api_name="/predict_plate") if not isinstance(result, tuple) or len(result) < 3: raise HTTPException(status_code=502, detail=f"Unexpected response from HF: {result}") annotated_img_path = result[0] numbers_ar = str(result[1] or "").strip() letters_ar = str(result[2] or "").strip() arabic_text = f"{letters_ar} | {numbers_ar}" if (letters_ar or numbers_ar) else "N/A" _ar_to_en = {v[0]: v[1] for v in CHAR_MAP.values()} letters_en_parts = [_ar_to_en.get(ch, ch) for ch in letters_ar.split(" ") if ch] numbers_en_parts = [_ar_to_en.get(ch, ch) for ch in numbers_ar.split(" ") if ch] letters_en = " ".join(letters_en_parts) numbers_en = "".join(numbers_en_parts) english_text = f"{letters_en} | {numbers_en}" if (letters_en or numbers_en) else "N/A" annotated_b64 = "" if annotated_img_path and os.path.exists(str(annotated_img_path)): with open(str(annotated_img_path), "rb") as f: annotated_b64 = base64.b64encode(f.read()).decode("utf-8") return { "plate_info": {"arabic": arabic_text, "english": english_text, "characters": [], "raw_ordered_labels": []}, "user_page": {}, "admin_page": {"annotated_image_base64": annotated_b64}, } except HTTPException: raise except Exception as exc: LOGGER.exception("Remote model service failed") import traceback tb = traceback.format_exc() raise HTTPException(status_code=502, detail=f"Remote model service failed: {exc}\nTraceback:\n{tb}") from exc finally: if os.path.exists(tmp_path): try: os.remove(tmp_path) except OSError: pass