diff --git a/README.md b/README.md index f3415c1..e386be9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Python 3.6-compatible Flask service that accepts an image and returns detected t ## 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. +- EAST text detector (OpenCV DNN) when model exists; otherwise OpenCV contour-based text-region 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. @@ -109,6 +109,7 @@ Example JSON response (fields may vary): "latency_ms": 123, "coral_used": true, "east_used": true, + "text_locator": "east", "ocr_lang": "eng", "ocr_lang_requested": "eng+rus", "ocr_lang_strategy": "auto_script", @@ -120,6 +121,6 @@ Example JSON response (fields may vary): ## 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. +- If EAST model is missing, service first tries OpenCV contour text regions and only then full-image OCR. - 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 index ac7b8de..c0b5dab 100644 Binary files a/__pycache__/app.cpython-313.pyc and b/__pycache__/app.cpython-313.pyc differ diff --git a/app.py b/app.py index fe043a8..7d9fd1a 100644 --- a/app.py +++ b/app.py @@ -279,6 +279,14 @@ def image2text(): coral_used = False boxes, east_used = txt_detector.detect(img_rgb) + h, w = img_rgb.shape[:2] + text_locator = "east" if east_used else "opencv_fallback" + if not east_used and len(boxes) == 1: + try: + if tuple([int(v) for v in boxes[0]]) == (0, 0, w, h): + text_locator = "full_image" + except Exception: + pass prep_options = { "upscale": OCR_UPSCALE, @@ -389,6 +397,7 @@ def image2text(): "latency_ms": latency_ms, "coral_used": coral_used, "east_used": east_used, + "text_locator": text_locator, "ocr_lang": ocr_lang_selected, "ocr_lang_requested": OCR_LANG, "ocr_lang_strategy": ocr_lang_strategy, diff --git a/core/__pycache__/ocr.cpython-313.pyc b/core/__pycache__/ocr.cpython-313.pyc index 3292b8b..88ef08c 100644 Binary files a/core/__pycache__/ocr.cpython-313.pyc and b/core/__pycache__/ocr.cpython-313.pyc differ diff --git a/core/__pycache__/text_detect.cpython-313.pyc b/core/__pycache__/text_detect.cpython-313.pyc index 641683a..28a0e68 100644 Binary files a/core/__pycache__/text_detect.cpython-313.pyc and b/core/__pycache__/text_detect.cpython-313.pyc differ diff --git a/core/text_detect.py b/core/text_detect.py index 17ab3a1..c3dc17b 100644 --- a/core/text_detect.py +++ b/core/text_detect.py @@ -24,7 +24,7 @@ class TextDetector(object): 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) + print('[INFO] EAST model not found; using OpenCV contour fallback', file=sys.stderr) return try: self.net = cv2.dnn.readNet(self.east_model_path) @@ -42,10 +42,19 @@ class TextDetector(object): 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] + if self.east_available: + boxes = self._detect_with_east(img_rgb) + if boxes: + return boxes, True + boxes = self._detect_with_cv_fallback(img_rgb) + if boxes: + return boxes, False + + return [(_int0(0), _int0(0), _int0(w), _int0(h))], False + + def _detect_with_east(self, img_rgb): h, w = img_rgb.shape[:2] # EAST expects width/height divisible by 32 new_w = 320 @@ -71,9 +80,7 @@ class TextDetector(object): 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 + return self._normalize_boxes(boxes, w, h, max_boxes=16) def _decode(self, scores, geometry, score_thresh): num_rows, num_cols = scores.shape[2:4] @@ -105,9 +112,145 @@ class TextDetector(object): confidences.append(float(score)) return rectangles, confidences + def _detect_with_cv_fallback(self, img_rgb): + if cv2 is None: + return [] + try: + gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY) + except Exception: + return [] + + h, w = gray.shape[:2] + img_area = float(max(1, h * w)) + min_area = max(160.0, img_area * 0.00035) + + boxes = [] + + # Pass #1: gradient + morphology for horizontal text lines. + try: + grad_x = cv2.Sobel(gray, ddepth=cv2.CV_32F, dx=1, dy=0, ksize=3) + grad_x = cv2.convertScaleAbs(grad_x) + grad_x = cv2.GaussianBlur(grad_x, (3, 3), 0) + _, bin1 = cv2.threshold(grad_x, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + k_close = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 5)) + k_dilate = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 3)) + bin1 = cv2.morphologyEx(bin1, cv2.MORPH_CLOSE, k_close, iterations=1) + bin1 = cv2.dilate(bin1, k_dilate, iterations=1) + boxes.extend(self._boxes_from_mask(bin1, w, h, min_area=min_area)) + except Exception: + pass + + # Pass #2: adaptive threshold for low-contrast / noisy scenes. + try: + bin2 = cv2.adaptiveThreshold( + gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 15 + ) + k_close = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 3)) + k_dilate = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) + bin2 = cv2.morphologyEx(bin2, cv2.MORPH_CLOSE, k_close, iterations=1) + bin2 = cv2.dilate(bin2, k_dilate, iterations=1) + boxes.extend(self._boxes_from_mask(bin2, w, h, min_area=min_area)) + except Exception: + pass + + return self._normalize_boxes(boxes, w, h, max_boxes=18) + + def _boxes_from_mask(self, mask, image_w, image_h, min_area): + out = [] + contours_result = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + contours = contours_result[0] if len(contours_result) == 2 else contours_result[1] + min_h = max(10, int(image_h * 0.015)) + min_w = max(16, int(image_w * 0.02)) + for cnt in contours: + x, y, bw, bh = cv2.boundingRect(cnt) + area = float(bw * bh) + if area < min_area: + continue + if bw < min_w or bh < min_h: + continue + aspect = float(bw) / float(max(1, bh)) + if aspect < 0.7 or aspect > 45.0: + continue + contour_area = float(cv2.contourArea(cnt)) + fill_ratio = contour_area / float(max(1, bw * bh)) + if fill_ratio < 0.03: + continue + pad_x = max(2, int(bw * 0.08)) + pad_y = max(2, int(bh * 0.30)) + x1 = max(0, x - pad_x) + y1 = max(0, y - pad_y) + x2 = min(image_w, x + bw + pad_x) + y2 = min(image_h, y + bh + pad_y) + out.append((_int0(x1), _int0(y1), _int0(x2), _int0(y2))) + return out + + def _normalize_boxes(self, boxes, image_w, image_h, max_boxes=16): + norm = [] + for box in boxes or []: + x1, y1, x2, y2 = _clip_box(box, image_w, image_h) + if x2 <= x1 or y2 <= y1: + continue + norm.append((x1, y1, x2, y2)) + if not norm: + return [] + kept = _nms_by_iou(norm, iou_thresh=0.45) + kept = sorted(kept, key=lambda b: ((b[3] - b[1]) * (b[2] - b[0])), reverse=True) + if max_boxes and len(kept) > max_boxes: + kept = kept[:max_boxes] + kept = sorted(kept, key=lambda b: (b[1], b[0])) + return kept + def _int0(val): try: return int(val) except Exception: return 0 + + +def _clip_box(box, image_w, image_h): + x1, y1, x2, y2 = box + x1 = max(0, min(_int0(x1), _int0(image_w))) + y1 = max(0, min(_int0(y1), _int0(image_h))) + x2 = max(0, min(_int0(x2), _int0(image_w))) + y2 = max(0, min(_int0(y2), _int0(image_h))) + return x1, y1, x2, y2 + + +def _nms_by_iou(boxes, iou_thresh=0.45): + if not boxes: + return [] + ranked = sorted(boxes, key=_box_area, reverse=True) + keep = [] + for candidate in ranked: + should_keep = True + for chosen in keep: + if _iou(candidate, chosen) >= iou_thresh: + should_keep = False + break + if should_keep: + keep.append(candidate) + return keep + + +def _box_area(box): + x1, y1, x2, y2 = box + return max(0, x2 - x1) * max(0, y2 - y1) + + +def _iou(a, b): + ax1, ay1, ax2, ay2 = a + bx1, by1, bx2, by2 = b + xx1 = max(ax1, bx1) + yy1 = max(ay1, by1) + xx2 = min(ax2, bx2) + yy2 = min(ay2, by2) + iw = max(0, xx2 - xx1) + ih = max(0, yy2 - yy1) + inter = iw * ih + if inter <= 0: + return 0.0 + union = float(_box_area(a) + _box_area(b) - inter) + if union <= 0: + return 0.0 + return inter / union diff --git a/run_service.sh b/run_service.sh index 4d6a5e2..af308cc 100755 --- a/run_service.sh +++ b/run_service.sh @@ -9,6 +9,7 @@ export MODEL_EDGETPU_PATH=${MODEL_EDGETPU_PATH:-./models/ssd_mobilenet_v2_coco_q 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_AUTO_LANG=${OCR_AUTO_LANG:-true} export OCR_CONFIG=${OCR_CONFIG:---oem 1 --psm 6} # Point to tessdata_best if installed export TESSDATA_PREFIX=${TESSDATA_PREFIX:-}