update
This commit is contained in:
parent
deccd982e4
commit
268c4a6e7e
7 changed files with 163 additions and 9 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Binary file not shown.
9
app.py
9
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,
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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]
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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:-}
|
||||
|
|
|
|||
Loading…
Reference in a new issue