text_detect/app.py
2026-03-08 01:40:26 +03:00

441 lines
15 KiB
Python

#!/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()