From deccd982e4cc3a480de0691011cef88cf8847ffd Mon Sep 17 00:00:00 2001 From: q Date: Sun, 8 Mar 2026 01:40:26 +0300 Subject: [PATCH] first commit --- README.md | 125 ++++++ __pycache__/app.cpython-313.pyc | Bin 0 -> 19394 bytes app.py | 441 +++++++++++++++++++ core/__init__.py | 0 core/__pycache__/image_io.cpython-313.pyc | Bin 0 -> 5288 bytes core/__pycache__/ocr.cpython-313.pyc | Bin 0 -> 7070 bytes core/__pycache__/schema.cpython-313.pyc | Bin 0 -> 1215 bytes core/__pycache__/text_detect.cpython-313.pyc | Bin 0 -> 6864 bytes core/coral_vision.py | 92 ++++ core/image_io.py | 110 +++++ core/ocr.py | 174 ++++++++ core/schema.py | 24 + core/text_detect.py | 113 +++++ requirements.txt | 7 + run_service.sh | 24 + 15 files changed, 1110 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/app.cpython-313.pyc create mode 100644 app.py create mode 100644 core/__init__.py create mode 100644 core/__pycache__/image_io.cpython-313.pyc create mode 100644 core/__pycache__/ocr.cpython-313.pyc create mode 100644 core/__pycache__/schema.cpython-313.pyc create mode 100644 core/__pycache__/text_detect.cpython-313.pyc create mode 100644 core/coral_vision.py create mode 100644 core/image_io.py create mode 100644 core/ocr.py create mode 100644 core/schema.py create mode 100644 core/text_detect.py create mode 100644 requirements.txt create mode 100755 run_service.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3415c1 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# Jetson Nano Image2Text API + +Python 3.6-compatible Flask service that accepts an image and returns detected text plus optional EdgeTPU object context. + +## Features +- Accepts JPEG/PNG/WebP/BMP/TIFF/GIF (first frame) via multipart or base64 JSON. +- Optional Google Coral EdgeTPU detection for scene context (loaded once at start). +- EAST text detector (OpenCV DNN) when model exists; otherwise whole-image fallback. +- OCR via `pytesseract` with per-block confidences; configurable languages. +- Auto-selects `rus` or `eng` (via Tesseract OSD script detection) before full OCR when `OCR_LANG=eng+rus`. +- If OCR result is empty, retries with fallback language/full-frame pass to improve recall. +- Hard payload cap (10MB default) and graceful degradation if Coral/EAST unavailable. + +## System prerequisites (Ubuntu 18.04 aarch64) +```bash +# EdgeTPU runtime (choose std or max) +sudo apt-get update +sudo apt-get install -y libedgetpu1-std python3-pycoral + +# Tesseract OCR + languages +sudo apt-get install -y tesseract-ocr tesseract-ocr-eng tesseract-ocr-rus + +# OpenCV (binary from apt is preferred on Jetson) +sudo apt-get install -y python3-opencv + +# If you prefer pip OpenCV (may be large on Jetson): +# pip3 install opencv-python==4.5.3.56 +``` + +## Python dependencies +```bash +pip3 install -r requirements.txt +``` + +## Models +Place your models in `./models` (paths can be overridden via env): +- EdgeTPU SSD: `ssd_mobilenet_v2_coco_quant_postprocess_edgetpu.tflite` +- Labels: `coco_labels.txt` +- EAST text detector (optional): `frozen_east_text_detection.pb` + +### Better OCR models (tessdata_best) +```bash +sudo apt-get install -y wget +sudo mkdir -p /usr/share/tesseract-ocr/4.00/tessdata_best +sudo wget -O /usr/share/tesseract-ocr/4.00/tessdata_best/eng.traineddata https://github.com/tesseract-ocr/tessdata_best/raw/main/eng.traineddata +sudo wget -O /usr/share/tesseract-ocr/4.00/tessdata_best/rus.traineddata https://github.com/tesseract-ocr/tessdata_best/raw/main/rus.traineddata +# then run the service with: +export TESSDATA_PREFIX=/usr/share/tesseract-ocr/4.00/tessdata_best +# if you skip this, the app will auto-try common system paths +``` + +### OpenCV with CUDA (for faster EAST) +OpenCV from JetPack repos (4.x) usually ships without CUDA dnn. To use CUDA, build OpenCV 4.5+ with `-D WITH_CUDA=ON -D OPENCV_DNN_CUDA=ON`. After installing, export `USE_CUDA_DNN=true` so the service enables CUDA backend/target when available. + +## Environment variables +- `MODEL_EDGETPU_PATH` (default `./models/ssd_mobilenet_v2_coco_quant_postprocess_edgetpu.tflite`) +- `MODEL_LABELS_PATH` (default `./models/coco_labels.txt`) +- `MODEL_EAST_PATH` (default `./models/frozen_east_text_detection.pb`) +- `OCR_LANG` (default `eng+rus`; request language set for OCR) +- `OCR_AUTO_LANG` (default `true`; auto-pick `eng` or `rus` by script when `OCR_LANG` contains both) +- `OCR_CONFIG` (default `--oem 1 --psm 6`, forwarded to Tesseract) +- `TESSDATA_PREFIX` (path to tessdata / tessdata_best if you install better models) +- `OCR_UPSCALE` (default `1.5`, upscale factor before OCR; max 3.0) +- `OCR_CLAHE` (default `true`, contrast-limited adaptive histogram) +- `OCR_BILATERAL` (default `false`, light denoise) +- `OCR_BLUR` (default `0`, Gaussian kernel size; 0 disables) +- `OCR_INVERT` (default `false`, invert after threshold) +- `OCR_USE_THRESHOLD` (default `true`, adaptive threshold) +- `MAX_IMAGE_MB` (default `10`) +- `CONF_THRESHOLD` (default `0.4`) +- `RETURN_BOXES` (default `true`) +- `USE_CUDA_DNN` (default `false`, set `true` to attempt CUDA backend for EAST if OpenCV built with CUDA) +- `DEBUG_VISUAL` (default `false`; if true, response includes `debug_image_base64`. Per-request: add `?debug=1` or form field `debug=1`.) +- `SHOW_GUI` (default `false`; if true and OpenCV GUI available, service opens a window with overlays. Requires X/Wayland/VDI that supports GUI.) + +## Run +```bash +python3 app.py --host 0.0.0.0 --port 8000 +``` +Waitress or gunicorn can also serve the app if desired (not required). + +## API +- `GET /health` -> `{ "status": "ok", "coral": true/false }` +- `POST /v1/image2text` + - Multipart: field `image` + - JSON: `{ "image_base64": "" }` + +Example curl (multipart): +```bash +curl -X POST http://localhost:8000/v1/image2text \ + -F "image=@sample.jpg" \ + -F "debug=1" \ + | python -m json.tool +``` + +Example JSON response (fields may vary): +```json +{ + "text": "hello world", + "blocks": [ + {"bbox": [10, 20, 200, 60], "text": "hello", "conf": 87.3}, + {"bbox": [10, 70, 200, 120], "text": "world", "conf": 90.1} + ], + "objects": [ + {"label": "person", "score": 0.63, "bbox": [5, 12, 180, 210]} + ], + "scene_summary": "person x1", + "meta": { + "latency_ms": 123, + "coral_used": true, + "east_used": true, + "ocr_lang": "eng", + "ocr_lang_requested": "eng+rus", + "ocr_lang_strategy": "auto_script", + "ocr_passes": 1, + "request_id": "..." + } +} +``` + +## Notes +- If Coral is absent or fails, the service still returns OCR with `coral_used=false`. +- If EAST model is missing, the whole image is passed to OCR to avoid empty results. +- Keep images under `MAX_IMAGE_MB` (413 returned otherwise). +- Optimize Tesseract languages to those you need to speed up OCR. diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ac7b8debe10c0613342b55d28532ac03eea2787d GIT binary patch literal 19394 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 literal 0 HcmV?d00001 diff --git a/app.py b/app.py new file mode 100644 index 0000000..fe043a8 --- /dev/null +++ b/app.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Lightweight image-to-text API for Jetson Nano + EdgeTPU. +Compatible with Python 3.6. +""" +import argparse +import base64 +import io +import os +import sys +import time +import uuid +import threading +from flask import Flask, request, jsonify + +from core import image_io, coral_vision, text_detect, ocr, schema + +try: + import cv2 +except ImportError: # pragma: no cover + cv2 = None +import numpy as np +from PIL import Image, ImageDraw + +# ---- Configuration ---- + +def env_bool(name, default): + val = os.getenv(name) + if val is None: + return default + return val.lower() in ("1", "true", "yes", "on") + +def env_float(name, default): + val = os.getenv(name) + try: + return float(val) if val is not None else default + except ValueError: + return default + +def env_int(name, default): + val = os.getenv(name) + try: + return int(val) if val is not None else default + except ValueError: + return default + +MODEL_EDGETPU_PATH = os.getenv("MODEL_EDGETPU_PATH", "./models/ssd_mobilenet_v2_coco_quant_postprocess_edgetpu.tflite") +MODEL_LABELS_PATH = os.getenv("MODEL_LABELS_PATH", "./models/coco_labels.txt") +MODEL_EAST_PATH = os.getenv("MODEL_EAST_PATH", "./models/frozen_east_text_detection.pb") +OCR_LANG = os.getenv("OCR_LANG", "eng+rus") +MAX_IMAGE_MB = env_int("MAX_IMAGE_MB", 10) +CONF_THRESHOLD = env_float("CONF_THRESHOLD", 0.4) +RETURN_BOXES = env_bool("RETURN_BOXES", True) +OCR_CONFIG = os.getenv("OCR_CONFIG", "--oem 1 --psm 6") +TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX") +USE_CUDA_DNN = env_bool("USE_CUDA_DNN", False) +DEBUG_VISUAL = env_bool("DEBUG_VISUAL", False) +SHOW_GUI = env_bool("SHOW_GUI", False) +GUI_TARGET_WIDTH = env_int("GUI_TARGET_WIDTH", 1600) +OCR_UPSCALE = env_float("OCR_UPSCALE", 1.5) # 1.0 = no upscale +OCR_CLAHE = env_bool("OCR_CLAHE", True) +OCR_BILATERAL = env_bool("OCR_BILATERAL", False) +OCR_BLUR = env_int("OCR_BLUR", 0) # 0 disables gaussian blur +OCR_INVERT = env_bool("OCR_INVERT", False) +OCR_USE_THRESHOLD = env_bool("OCR_USE_THRESHOLD", True) +OCR_AUTO_LANG = env_bool("OCR_AUTO_LANG", True) +MAX_CONTENT_LENGTH = MAX_IMAGE_MB * 1024 * 1024 + +app = Flask(__name__) +app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH + +if not TESSDATA_PREFIX: + # Attempt common system locations + for candidate in ( + "/usr/share/tesseract-ocr/4.00/tessdata_best", + "/usr/share/tesseract-ocr/4.00/tessdata", + ): + if os.path.exists(candidate): + TESSDATA_PREFIX = candidate + break + +if TESSDATA_PREFIX: + os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX + +_GUI_FRAME = None +_GUI_LOCK = threading.Lock() +_GUI_THREAD = None + +def _start_gui_thread(): + if not SHOW_GUI or cv2 is None: + return + global _GUI_THREAD + if _GUI_THREAD is not None: + return + def _loop(): + cv2.namedWindow("image2text debug", cv2.WINDOW_NORMAL) + while True: + frame = None + with _GUI_LOCK: + if _GUI_FRAME is not None: + frame = _GUI_FRAME.copy() + if frame is not None: + cv2.imshow("image2text debug", frame) + cv2.resizeWindow("image2text debug", frame.shape[1], frame.shape[0]) + cv2.waitKey(50) + _GUI_THREAD = threading.Thread(target=_loop, daemon=True) + _GUI_THREAD.start() + +def _show_placeholder(): + if not SHOW_GUI or cv2 is None: + return + _gui_show_message("image2text waiting for request") + + +def _gui_show(frame): + if not SHOW_GUI or cv2 is None or frame is None: + return + global _GUI_FRAME + with _GUI_LOCK: + _GUI_FRAME = frame + + +def _gui_show_message(msg): + if not SHOW_GUI or cv2 is None: + return + width = GUI_TARGET_WIDTH if GUI_TARGET_WIDTH > 0 else 1280 + height = int(width * 9 / 16) + canvas = np.ones((height, width, 3), dtype=np.uint8) * 240 + cv2.putText(canvas, msg[:60], (40, height // 2), + cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2, cv2.LINE_AA) + _gui_show(canvas) + +# ---- Load models once ---- + +coral = coral_vision.CoralVision( + model_path=MODEL_EDGETPU_PATH, + labels_path=MODEL_LABELS_PATH, + score_threshold=CONF_THRESHOLD, +) + +txt_detector = text_detect.TextDetector( + east_model_path=MODEL_EAST_PATH, + score_threshold=CONF_THRESHOLD, + use_cuda=USE_CUDA_DNN, +) + +# ---- Helpers ---- + +def parse_image_from_request(req): + if 'image' in req.files: + data = req.files['image'].read() + source = 'multipart' + else: + try: + payload = req.get_json(force=True, silent=False) + except Exception: + payload = None + if payload and 'image_base64' in payload: + b64_str = payload['image_base64'] or '' + if ',' in b64_str: + b64_str = b64_str.split(',', 1)[1] + try: + data = base64.b64decode(b64_str, validate=False) + except Exception: + return None, 'invalid_base64' + source = 'base64' + else: + return None, 'not_found' + if not data: + return None, 'empty' + if len(data) > MAX_CONTENT_LENGTH: + return None, 'too_large' + return data, source + + +def build_debug_image(img_rgb, boxes, blocks, objects, target_width=1024, return_image=False): + """Return (base64 JPEG, optional ndarray for display) with drawn boxes.""" + try: + vis = img_rgb.copy() + h, w = vis.shape[:2] + scale = 1.0 + if target_width and target_width > 0 and w != target_width: + scale = float(target_width) / float(w) + # avoid crazy upscales + if scale > 3.0: + scale = 3.0 + new_w = int(w * scale) + new_h = int(h * scale) + vis = np.array(Image.fromarray(vis).resize((new_w, new_h))) + # draw using cv2 if available + if cv2 is not None: + vis_bgr = cv2.cvtColor(vis, cv2.COLOR_RGB2BGR) + # text boxes in blue + for b in boxes: + x1, y1, x2, y2 = [int(v * scale) for v in b] + cv2.rectangle(vis_bgr, (x1, y1), (x2, y2), (255, 0, 0), 2) + for blk in blocks: + x1, y1, x2, y2 = [int(v * scale) for v in blk.get("bbox", [0, 0, 0, 0])] + cv2.rectangle(vis_bgr, (x1, y1), (x2, y2), (0, 128, 255), 2) + txt = blk.get("text", "")[:20] + cv2.putText(vis_bgr, txt, (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 128, 255), 1, cv2.LINE_AA) + # objects in green + for obj in objects or []: + bbox = obj.get("bbox", [0, 0, 0, 0]) + x1, y1, x2, y2 = [int(v * scale) for v in bbox] + cv2.rectangle(vis_bgr, (x1, y1), (x2, y2), (0, 200, 0), 2) + lbl = obj.get("label", "obj") + cv2.putText(vis_bgr, lbl, (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 200, 0), 1, cv2.LINE_AA) + ok, buf = cv2.imencode(".jpg", vis_bgr, [int(cv2.IMWRITE_JPEG_QUALITY), 85]) + if not ok: + return None, None + b64 = base64.b64encode(buf.tobytes()).decode("ascii") + return b64, vis_bgr if return_image else None + # fallback to PIL + img = Image.fromarray(vis) + draw = ImageDraw.Draw(img) + for b in boxes: + x1, y1, x2, y2 = [int(v * scale) for v in b] + draw.rectangle([x1, y1, x2, y2], outline="blue", width=2) + for blk in blocks: + x1, y1, x2, y2 = [int(v * scale) for v in blk.get("bbox", [0, 0, 0, 0])] + draw.rectangle([x1, y1, x2, y2], outline="orange", width=2) + txt = blk.get("text", "")[:20] + draw.text((x1, max(0, y1 - 10)), txt, fill="orange") + for obj in objects or []: + bbox = obj.get("bbox", [0, 0, 0, 0]) + x1, y1, x2, y2 = [int(v * scale) for v in bbox] + draw.rectangle([x1, y1, x2, y2], outline="green", width=2) + draw.text((x1, max(0, y1 - 10)), obj.get("label", "obj"), fill="green") + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=85) + b64 = base64.b64encode(buf.getvalue()).decode("ascii") + return b64, np.array(img) if return_image else None + except Exception: + return None, None + +# ---- Routes ---- + +@app.route('/health', methods=['GET']) +def health(): + return jsonify({"status": "ok", "coral": coral.available}) + + +@app.route('/v1/image2text', methods=['POST']) +def image2text(): + t0 = time.time() + request_id = str(uuid.uuid4()) + data, status = parse_image_from_request(request) + if SHOW_GUI and cv2 is not None: + _gui_show_message("processing request {}".format(request_id[:8])) + + if data is None: + if status == 'too_large': + if SHOW_GUI and cv2 is not None: + _gui_show_message("error: payload too large") + return jsonify({"error": "payload too large"}), 413 + if status in ('not_found', 'empty'): + if SHOW_GUI and cv2 is not None: + _gui_show_message("error: image missing") + return jsonify({"error": "image is required"}), 400 + if SHOW_GUI and cv2 is not None: + _gui_show_message("error: invalid base64") + return jsonify({"error": "invalid image_base64"}), 400 + + img_rgb = image_io.decode_image(data) + if img_rgb is None: + if SHOW_GUI and cv2 is not None: + _gui_show_message("error: unsupported format") + return jsonify({"error": "unsupported image format"}), 415 + + objects = [] + coral_used = False + try: + objects, coral_used = coral.infer(img_rgb) + except Exception as exc: + print("[WARN] Coral failure {}: {}".format(request_id, exc), file=sys.stderr) + objects = [] + coral_used = False + + boxes, east_used = txt_detector.detect(img_rgb) + + prep_options = { + "upscale": OCR_UPSCALE, + "clahe": OCR_CLAHE, + "bilateral": OCR_BILATERAL, + "blur": OCR_BLUR, + "invert": OCR_INVERT, + "use_threshold": OCR_USE_THRESHOLD, + } + + ocr_lang_selected = OCR_LANG + ocr_lang_strategy = "fixed" + if OCR_AUTO_LANG: + try: + ocr_lang_selected, ocr_lang_strategy = ocr.choose_ocr_lang( + img_rgb, + boxes, + requested_lang=OCR_LANG, + prep_options=prep_options, + ) + except Exception as exc: + print("[WARN] OCR lang auto-detect failed {}: {}".format(request_id, exc), file=sys.stderr) + ocr_lang_selected = OCR_LANG + ocr_lang_strategy = "auto_error_fallback" + + text, blocks = ocr.run_ocr( + img_rgb, + boxes, + lang=ocr_lang_selected, + config=OCR_CONFIG, + return_boxes=RETURN_BOXES, + prep_options=prep_options, + ) + ocr_passes = 1 + + # Retry with requested language set when auto-picked language produced no text. + if (not (text or '').strip()) and ocr_lang_selected != OCR_LANG: + retry_text, retry_blocks = ocr.run_ocr( + img_rgb, + boxes, + lang=OCR_LANG, + config=OCR_CONFIG, + return_boxes=RETURN_BOXES, + prep_options=prep_options, + ) + ocr_passes += 1 + if (retry_text or '').strip(): + text, blocks = retry_text, retry_blocks + ocr_lang_selected = OCR_LANG + ocr_lang_strategy = "{}+lang_retry".format(ocr_lang_strategy) + + # Retry with full-frame OCR if EAST boxes were too aggressive and OCR is empty. + if not (text or '').strip(): + h, w = img_rgb.shape[:2] + full_box = (0, 0, w, h) + needs_full_pass = True + if len(boxes) == 1: + try: + cur_box = tuple([int(v) for v in boxes[0]]) + needs_full_pass = cur_box != full_box + except Exception: + needs_full_pass = True + if needs_full_pass: + retry_text, retry_blocks = ocr.run_ocr( + img_rgb, + [full_box], + lang=ocr_lang_selected, + config=OCR_CONFIG, + return_boxes=RETURN_BOXES, + prep_options=prep_options, + ) + ocr_passes += 1 + if (retry_text or '').strip(): + text, blocks = retry_text, retry_blocks + ocr_lang_strategy = "{}+full_frame_retry".format(ocr_lang_strategy) + + scene_summary = schema.summarize_scene(objects) + + latency_ms = int((time.time() - t0) * 1000) + + debug_requested = DEBUG_VISUAL or request.args.get('debug', '').lower() in ('1', 'true', 'yes') + if SHOW_GUI: + debug_requested = True # ensure overlay built when GUI requested + debug_image_b64 = None + debug_image_arr = None + if debug_requested: + target_w = GUI_TARGET_WIDTH if SHOW_GUI else 1024 + debug_image_b64, debug_image_arr = build_debug_image( + img_rgb, + boxes, + blocks if RETURN_BOXES else [], + objects, + target_width=target_w, + return_image=SHOW_GUI, + ) + if SHOW_GUI and cv2 is not None and debug_image_arr is not None: + try: + _gui_show(debug_image_arr) + except Exception: + pass + + resp = schema.build_response( + text=text, + blocks=blocks, + objects=objects, + scene_summary=scene_summary, + metadata={ + "latency_ms": latency_ms, + "coral_used": coral_used, + "east_used": east_used, + "ocr_lang": ocr_lang_selected, + "ocr_lang_requested": OCR_LANG, + "ocr_lang_strategy": ocr_lang_strategy, + "ocr_passes": ocr_passes, + "request_id": request_id, + "debug": debug_requested, + }, + return_boxes=RETURN_BOXES, + ) + + if debug_image_b64: + resp["debug_image_base64"] = debug_image_b64 + + print("[INFO] req={} size={}KB coral={} east={} latency={}ms".format( + request_id, + len(data) // 1024, + coral_used, + east_used, + latency_ms + )) + + return jsonify(resp) + + +# ---- Entrypoint ---- + + +def main(): + parser = argparse.ArgumentParser(description='Image to text API') + parser.add_argument('--host', default='0.0.0.0') + parser.add_argument('--port', type=int, default=8000) + parser.add_argument('--debug-visual', action='store_true', help='force returning debug overlay base64') + parser.add_argument('--show-gui', action='store_true', help='show overlay in a window (requires DISPLAY and OpenCV GUI); forces debug overlay creation') + args = parser.parse_args() + + global DEBUG_VISUAL + if args.debug_visual: + DEBUG_VISUAL = True + global SHOW_GUI + if args.show_gui: + SHOW_GUI = True + if SHOW_GUI: + _start_gui_thread() + _show_placeholder() + + app.run(host=args.host, port=args.port, threaded=True) + + +if __name__ == '__main__': + main() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/__pycache__/image_io.cpython-313.pyc b/core/__pycache__/image_io.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..94120b7e8d743c72326b4572038fcc78e78b97d9 GIT binary patch literal 5288 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 l?PoMHI>8T9j%Bm`TqhCf_*N!2YEAP(Ety%; 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 literal 0 HcmV?d00001 diff --git a/core/__pycache__/schema.cpython-313.pyc b/core/__pycache__/schema.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..43de3f8fd2b434dcb49afabc379e7464a0dfd3bd GIT binary patch literal 1215 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 literal 0 HcmV?d00001 diff --git a/core/coral_vision.py b/core/coral_vision.py new file mode 100644 index 0000000..6132580 --- /dev/null +++ b/core/coral_vision.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +"""Minimal wrapper around Coral EdgeTPU detection/classification.""" +import os +import sys + +try: + from pycoral.utils.edgetpu import make_interpreter + from pycoral.adapters import common, detect +except ImportError: # pragma: no cover + make_interpreter = None + common = None + detect = None + +import numpy as np +from PIL import Image + + +class CoralVision(object): + def __init__(self, model_path, labels_path, score_threshold=0.4): + self.model_path = model_path + self.labels_path = labels_path + self.score_threshold = score_threshold + self.available = False + self.labels = {} + self.interpreter = None + self._load() + + def _load_labels(self): + labels = {} + if not os.path.exists(self.labels_path): + return labels + with open(self.labels_path, 'r') as f: + for line in f: + if not line.strip(): + continue + pair = line.strip().split(None, 1) + if len(pair) == 2 and pair[0].isdigit(): + labels[int(pair[0])] = pair[1] + else: + # fallback for plain list + labels[len(labels)] = pair[0] + return labels + + def _load(self): + if make_interpreter is None: + print('[WARN] pycoral not installed; Coral disabled', file=sys.stderr) + return + if not os.path.exists(self.model_path): + print('[WARN] Coral model not found at {} - EdgeTPU disabled'.format(self.model_path), file=sys.stderr) + return + try: + self.interpreter = make_interpreter(self.model_path) + self.interpreter.allocate_tensors() + self.labels = self._load_labels() + self.available = True + print('[INFO] Coral model loaded: {}'.format(self.model_path)) + except Exception as exc: + print('[WARN] Failed to init Coral: {}'.format(exc), file=sys.stderr) + self.available = False + + def infer(self, img_rgb): + if not self.available: + return [], False + + h, w, _ = img_rgb.shape + input_size = common.input_size(self.interpreter) + resized = _resize_keep_aspect(img_rgb, input_size) + common.set_input(self.interpreter, resized) + self.interpreter.invoke() + objs = detect.get_objects(self.interpreter, score_threshold=self.score_threshold) + + results = [] + for obj in objs: + bbox = obj.bbox + scale_x = float(w) / float(input_size[0]) + scale_y = float(h) / float(input_size[1]) + x1 = int(bbox.xmin * scale_x) + y1 = int(bbox.ymin * scale_y) + x2 = int(bbox.xmax * scale_x) + y2 = int(bbox.ymax * scale_y) + label = self.labels.get(obj.id, str(obj.id)) + results.append({ + "label": label, + "score": float(obj.score), + "bbox": [x1, y1, x2, y2], + }) + return results, True + + +def _resize_keep_aspect(img_rgb, target_size): + tw, th = target_size + return np.array(Image.fromarray(img_rgb).resize((tw, th))) diff --git a/core/image_io.py b/core/image_io.py new file mode 100644 index 0000000..af0c5ba --- /dev/null +++ b/core/image_io.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +"""Image decoding and preprocessing helpers.""" +import io +import numpy as np +from PIL import Image, ImageSequence + +try: + import cv2 +except ImportError: # pragma: no cover + cv2 = None + + +def decode_image(data): + """Decode bytes to RGB numpy array. Returns None on failure.""" + img = _decode_with_pillow(data) + if img is None and cv2 is not None: + img = _decode_with_cv2(data) + return img + + +def _decode_with_pillow(data): + try: + with Image.open(io.BytesIO(data)) as im: + # pick first frame for GIFs + frame = next(ImageSequence.Iterator(im)) + rgb = frame.convert('RGB') + arr = np.array(rgb) + return arr + except Exception: + return None + + +def _decode_with_cv2(data): + try: + arr = np.frombuffer(data, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_COLOR) + if img is None: + return None + rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + return rgb + except Exception: + return None + + +def prepare_for_ocr(img_rgb, use_threshold=True, upscale=1.0, clahe=False, + bilateral=False, blur=0, invert=False): + """Convert RGB to grayscale and apply light preprocessing for OCR.""" + if img_rgb is None: + return None + + gray = _to_gray(img_rgb) + + # optional upscale (cap at 3x to avoid memory blow-up) + if upscale and upscale > 1.0: + factor = min(float(upscale), 3.0) + h, w = gray.shape[:2] + new_w = int(w * factor) + new_h = int(h * factor) + if cv2 is not None: + gray = cv2.resize(gray, (new_w, new_h), interpolation=cv2.INTER_CUBIC) + else: + gray = np.array(Image.fromarray(gray).resize((new_w, new_h))) + + if clahe and cv2 is not None: + try: + clahe_obj = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + gray = clahe_obj.apply(gray) + except Exception: + pass + + if bilateral and cv2 is not None: + try: + gray = cv2.bilateralFilter(gray, d=5, sigmaColor=75, sigmaSpace=75) + except Exception: + pass + + if blur and blur > 0 and cv2 is not None: + k = int(blur) if int(blur) % 2 == 1 else int(blur) + 1 + try: + gray = cv2.GaussianBlur(gray, (k, k), 0) + except Exception: + pass + + if use_threshold and cv2 is not None: + try: + gray = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY, 25, 15) + except Exception: + pass + + if invert: + gray = 255 - gray + + return gray + + +def _to_gray(img_rgb): + if img_rgb.ndim == 3 and img_rgb.shape[2] == 3: + return np.dot(img_rgb[..., :3], [0.2989, 0.5870, 0.1140]).astype('uint8') + return img_rgb.astype('uint8') + + +def crop(img_rgb, bbox): + x1, y1, x2, y2 = bbox + h, w = img_rgb.shape[:2] + x1 = max(0, min(w - 1, int(x1))) + y1 = max(0, min(h - 1, int(y1))) + x2 = max(0, min(w, int(x2))) + y2 = max(0, min(h, int(y2))) + return img_rgb[y1:y2, x1:x2] diff --git a/core/ocr.py b/core/ocr.py new file mode 100644 index 0000000..ac30806 --- /dev/null +++ b/core/ocr.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +"""OCR wrapper using pytesseract.""" +import pytesseract +import numpy as np + +from . import image_io + +_AUTO_LANGS = frozenset(("eng", "rus")) +_SCRIPT_TO_LANG = { + "latin": "eng", + "cyrillic": "rus", +} + + +def choose_ocr_lang(img_rgb, boxes, requested_lang='eng+rus', prep_options=None): + """ + Pick OCR language for RU/EN inputs. + + Uses a fast Tesseract OSD pass to detect script (Latin/Cyrillic) on a few + largest text regions. Falls back to requested_lang when uncertain. + """ + requested = (requested_lang or 'eng').strip() + langs = _split_langs(requested) + if set(langs) != _AUTO_LANGS: + return requested, "fixed" + + detected = _detect_lang_via_osd(img_rgb, boxes, prep_options=prep_options) + if detected: + return detected, "auto_script" + return requested, "auto_fallback" + + +def run_ocr(img_rgb, boxes, lang='eng', config='', return_boxes=True, prep_options=None): + if not boxes: + boxes = [(0, 0, img_rgb.shape[1], img_rgb.shape[0])] + + blocks = [] + collected_text = [] + prep_options = prep_options or {} + + for bbox in boxes: + crop = image_io.crop(img_rgb, bbox) + if crop.size == 0: + continue + prepared = image_io.prepare_for_ocr( + crop, + use_threshold=prep_options.get("use_threshold", True), + upscale=prep_options.get("upscale", 1.0), + clahe=prep_options.get("clahe", False), + bilateral=prep_options.get("bilateral", False), + blur=prep_options.get("blur", 0), + invert=prep_options.get("invert", False), + ) + try: + data = pytesseract.image_to_data( + prepared, + lang=lang, + config=config, + output_type=pytesseract.Output.DICT, + ) + texts = [] + confs = [] + for txt, conf in zip(data.get('text', []), data.get('conf', [])): + if txt and txt.strip(): + texts.append(txt.strip()) + try: + confs.append(float(conf)) + except Exception: + confs.append(-1.0) + block_text = ' '.join(texts).strip() + avg_conf = float(np.mean(confs)) if confs else -1.0 + except Exception: + # Fallback to simple string extraction + block_text = pytesseract.image_to_string(prepared, lang=lang, config=config) + avg_conf = -1.0 + + if block_text: + collected_text.append(block_text) + if return_boxes: + blocks.append({ + "bbox": [int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])], + "text": block_text, + "conf": avg_conf, + }) + + combined_text = '\n'.join(collected_text) + if not return_boxes: + blocks = [] + return combined_text, blocks + + +def _split_langs(lang): + parts = [] + for item in (lang or '').split('+'): + item = item.strip() + if item and item not in parts: + parts.append(item) + return parts + + +def _bbox_area(bbox): + try: + x1, y1, x2, y2 = bbox + return max(0, int(x2) - int(x1)) * max(0, int(y2) - int(y1)) + except Exception: + return 0 + + +def _probe_boxes(img_rgb, boxes, limit=3): + if not boxes: + h, w = img_rgb.shape[:2] + return [(0, 0, w, h)] + ranked = sorted(list(boxes), key=_bbox_area, reverse=True) + return ranked[:limit] + + +def _prepare_probe_crop(crop, prep_options): + prep_options = prep_options or {} + upscale = prep_options.get("upscale", 1.0) + try: + upscale = float(upscale) + except Exception: + upscale = 1.0 + if upscale < 1.0: + upscale = 1.0 + if upscale > 1.5: + upscale = 1.5 + + return image_io.prepare_for_ocr( + crop, + use_threshold=False, + upscale=upscale, + clahe=prep_options.get("clahe", False), + bilateral=False, + blur=0, + invert=False, + ) + + +def _detect_lang_via_osd(img_rgb, boxes, prep_options=None): + votes = {"eng": 0, "rus": 0} + for bbox in _probe_boxes(img_rgb, boxes): + crop = image_io.crop(img_rgb, bbox) + if crop is None or crop.size == 0: + continue + prepared = _prepare_probe_crop(crop, prep_options) + if prepared is None or prepared.size == 0: + continue + lang = _lang_from_osd(prepared) + if lang in votes: + votes[lang] += 1 + + if votes["eng"] == votes["rus"]: + return None + if votes["eng"] > votes["rus"]: + return "eng" + if votes["rus"] > votes["eng"]: + return "rus" + return None + + +def _lang_from_osd(prepared): + try: + osd_text = pytesseract.image_to_osd(prepared, config='--psm 0') + except Exception: + return None + + for line in osd_text.splitlines(): + line_low = line.lower() + if not line_low.startswith("script:"): + continue + script = line.split(':', 1)[1].strip().lower() + return _SCRIPT_TO_LANG.get(script) + return None diff --git a/core/schema.py b/core/schema.py new file mode 100644 index 0000000..2927567 --- /dev/null +++ b/core/schema.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +"""Response assembly utilities.""" +from collections import Counter + + +def summarize_scene(objects): + if not objects: + return "no objects detected" + labels = [o.get('label', 'unknown') for o in objects] + counts = Counter(labels) + top = counts.most_common(3) + parts = ["{} x{}".format(lbl, cnt) for lbl, cnt in top] + return ', '.join(parts) + + +def build_response(text, blocks, objects, scene_summary, metadata, return_boxes=True): + resp = { + "text": text or "", + "blocks": blocks if return_boxes else [], + "objects": objects or [], + "scene_summary": scene_summary or "", + "meta": metadata or {}, + } + return resp diff --git a/core/text_detect.py b/core/text_detect.py new file mode 100644 index 0000000..17ab3a1 --- /dev/null +++ b/core/text_detect.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +"""Text region detection using OpenCV EAST with graceful fallback.""" +import os +import sys +import numpy as np + +try: + import cv2 +except ImportError: # pragma: no cover + cv2 = None + + +class TextDetector(object): + def __init__(self, east_model_path, score_threshold=0.4, use_cuda=False): + self.east_model_path = east_model_path + self.score_threshold = score_threshold + self.use_cuda = use_cuda + self.net = None + self.east_available = False + self._load() + + def _load(self): + if cv2 is None: + print('[WARN] OpenCV not installed; EAST disabled', file=sys.stderr) + return + if not os.path.exists(self.east_model_path): + print('[INFO] EAST model not found; will use whole-image fallback', file=sys.stderr) + return + try: + self.net = cv2.dnn.readNet(self.east_model_path) + if self.use_cuda and hasattr(cv2, "cuda") and cv2.cuda.getCudaEnabledDeviceCount() > 0: + try: + self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_CUDA) + self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CUDA) + print('[INFO] EAST using CUDA backend/target', file=sys.stderr) + except Exception as exc: + print('[WARN] Failed to enable CUDA for EAST: {}'.format(exc), file=sys.stderr) + self.east_available = True + print('[INFO] EAST model loaded: {}'.format(self.east_model_path)) + except Exception as exc: + print('[WARN] Failed to load EAST model: {}'.format(exc), file=sys.stderr) + self.east_available = False + + def detect(self, img_rgb): + if not self.east_available: + h, w = img_rgb.shape[:2] + return [(_int0(0), _int0(0), _int0(w), _int0(h))], False + + h, w = img_rgb.shape[:2] + # EAST expects width/height divisible by 32 + new_w = 320 + new_h = 320 + blob = cv2.dnn.blobFromImage(img_rgb, 1.0, (new_w, new_h), + (123.68, 116.78, 103.94), swapRB=True, crop=False) + self.net.setInput(blob) + scores, geometry = self.net.forward([ + "feature_fusion/Conv_7/Sigmoid", + "feature_fusion/concat_3" + ]) + rectangles, confidences = self._decode(scores, geometry, self.score_threshold) + indices = cv2.dnn.NMSBoxes(rectangles, confidences, self.score_threshold, 0.4) + + boxes = [] + rW = float(w) / float(new_w) + rH = float(h) / float(new_h) + if len(indices) > 0: + for i in indices.flatten(): + x, y, bw, bh = rectangles[i] + x1 = int(x * rW) + y1 = int(y * rH) + x2 = int((x + bw) * rW) + y2 = int((y + bh) * rH) + boxes.append((_int0(x1), _int0(y1), _int0(x2), _int0(y2))) + if not boxes: + boxes.append((_int0(0), _int0(0), _int0(w), _int0(h))) + return boxes, True + + def _decode(self, scores, geometry, score_thresh): + num_rows, num_cols = scores.shape[2:4] + rectangles = [] + confidences = [] + for y in range(num_rows): + scores_data = scores[0, 0, y] + x0 = geometry[0, 0, y] + x1 = geometry[0, 1, y] + x2 = geometry[0, 2, y] + x3 = geometry[0, 3, y] + angles = geometry[0, 4, y] + for x in range(num_cols): + score = scores_data[x] + if score < score_thresh: + continue + offset_x = x * 4.0 + offset_y = y * 4.0 + angle = angles[x] + cos = np.cos(angle) + sin = np.sin(angle) + h = x0[x] + x2[x] + w = x1[x] + x3[x] + end_x = int(offset_x + (cos * x1[x]) + (sin * x2[x])) + end_y = int(offset_y - (sin * x1[x]) + (cos * x2[x])) + start_x = int(end_x - w) + start_y = int(end_y - h) + rectangles.append((start_x, start_y, int(w), int(h))) + confidences.append(float(score)) + return rectangles, confidences + + +def _int0(val): + try: + return int(val) + except Exception: + return 0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..891666e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask==2.0.3 +waitress==1.4.4 +Pillow==8.4.0 +numpy==1.19.5 +pytesseract==0.3.10 +pycoral==0.1.1 +opencv-python==4.5.3.56 diff --git a/run_service.sh b/run_service.sh new file mode 100755 index 0000000..4d6a5e2 --- /dev/null +++ b/run_service.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Runner for image2text service on Jetson Nano +# Adjust env vars below as needed. + +set -euo pipefail + +# ---- Configurable env ---- +export MODEL_EDGETPU_PATH=${MODEL_EDGETPU_PATH:-./models/ssd_mobilenet_v2_coco_quant_postprocess_edgetpu.tflite} +export MODEL_LABELS_PATH=${MODEL_LABELS_PATH:-./models/coco_labels.txt} +export MODEL_EAST_PATH=${MODEL_EAST_PATH:-./models/frozen_east_text_detection.pb} +export OCR_LANG=${OCR_LANG:-eng+rus} +export OCR_CONFIG=${OCR_CONFIG:---oem 1 --psm 6} +# Point to tessdata_best if installed +export TESSDATA_PREFIX=${TESSDATA_PREFIX:-} +export MAX_IMAGE_MB=${MAX_IMAGE_MB:-10} +export CONF_THRESHOLD=${CONF_THRESHOLD:-0.4} +export RETURN_BOXES=${RETURN_BOXES:-true} +# Enable CUDA DNN for EAST if your OpenCV is built with CUDA +export USE_CUDA_DNN=${USE_CUDA_DNN:-false} + +HOST=${HOST:-0.0.0.0} +PORT=${PORT:-8000} + +python3 app.py --host "$HOST" --port "$PORT"