| """Visualize OCR results — overlay recognized text directly on detected regions. |
| |
| Features: |
| - Text overlaid on word bounding boxes, scaled to fit |
| - Semi-transparent background behind text for readability |
| - Color-coded line bounding boxes |
| - Confidence heat-map coloring (green=high, red=low) |
| - Summary panel with statistics |
| """ |
| import sys |
| import time |
| from pathlib import Path |
|
|
| import numpy as np |
| from PIL import Image, ImageDraw, ImageFont |
|
|
| sys.path.insert(0, str(Path(__file__).parent.parent)) |
|
|
| from ocr.engine_onnx import OcrEngineOnnx |
| from ocr.models import BoundingRect |
|
|
|
|
| |
|
|
| def _conf_color(conf: float) -> tuple[int, int, int]: |
| """Map confidence 0..1 → red..yellow..green.""" |
| if conf >= 0.85: |
| return (40, 180, 40) |
| elif conf >= 0.6: |
| t = (conf - 0.6) / 0.25 |
| return (int(220 * (1 - t)), int(180 * t + 100), 40) |
| else: |
| return (220, 60, 40) |
|
|
|
|
| _LINE_COLORS = [ |
| (70, 130, 255), (255, 100, 70), (50, 200, 100), (255, 180, 40), |
| (180, 80, 255), (40, 200, 200), (255, 80, 180), (160, 200, 60), |
| ] |
|
|
|
|
| |
|
|
| def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: |
| """Try to load TrueType font, fallback to default.""" |
| for name in ("arial.ttf", "Arial.ttf", "segoeui.ttf", "msyh.ttc", |
| "NotoSansCJK-Regular.ttc", "DejaVuSans.ttf"): |
| try: |
| return ImageFont.truetype(name, size) |
| except Exception: |
| pass |
| return ImageFont.load_default() |
|
|
|
|
| def _fit_font_size( |
| text: str, box_w: float, box_h: float, |
| min_size: int = 8, max_size: int = 120, |
| ) -> int: |
| """Binary search for font size that fits text into box_w × box_h.""" |
| lo, hi = min_size, max_size |
| best = min_size |
| while lo <= hi: |
| mid = (lo + hi) // 2 |
| font = _load_font(mid) |
| bbox = font.getbbox(text) |
| tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] |
| if tw <= box_w * 0.95 and th <= box_h * 0.88: |
| best = mid |
| lo = mid + 1 |
| else: |
| hi = mid - 1 |
| return best |
|
|
|
|
| |
|
|
| def _draw_quad( |
| draw: ImageDraw.ImageDraw, b: BoundingRect, |
| color: tuple, width: int = 2, |
| ) -> None: |
| """Draw a quadrilateral outline.""" |
| pts = [(b.x1, b.y1), (b.x2, b.y2), (b.x3, b.y3), (b.x4, b.y4)] |
| draw.polygon(pts, outline=color, width=width) |
|
|
|
|
| def _overlay_text_on_word( |
| overlay: Image.Image, |
| word_text: str, |
| b: BoundingRect, |
| conf: float, |
| ) -> None: |
| """Draw text overlaid inside the word bounding box with semi-transparent bg.""" |
| xs = [b.x1, b.x2, b.x3, b.x4] |
| ys = [b.y1, b.y2, b.y3, b.y4] |
| x_min, x_max = min(xs), max(xs) |
| y_min, y_max = min(ys), max(ys) |
| box_w = x_max - x_min |
| box_h = y_max - y_min |
|
|
| if box_w < 3 or box_h < 3: |
| return |
|
|
| |
| font_size = _fit_font_size(word_text, box_w, box_h) |
| font = _load_font(font_size) |
|
|
| |
| bbox = font.getbbox(word_text) |
| tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] |
|
|
| |
| tx = x_min + (box_w - tw) / 2 |
| ty = y_min + (box_h - th) / 2 - bbox[1] |
|
|
| |
| bg = Image.new("RGBA", overlay.size, (0, 0, 0, 0)) |
| bg_draw = ImageDraw.Draw(bg) |
| pad = 2 |
| bg_draw.rectangle( |
| [tx - pad, y_min + (box_h - th) / 2 - pad, |
| tx + tw + pad, y_min + (box_h + th) / 2 + pad], |
| fill=(255, 255, 255, 170), |
| ) |
| overlay.alpha_composite(bg) |
|
|
| |
| text_color = _conf_color(conf) |
| draw = ImageDraw.Draw(overlay) |
| draw.text((tx, ty), word_text, fill=(*text_color, 255), font=font) |
|
|
|
|
| |
|
|
| def _draw_summary( |
| overlay: Image.Image, |
| n_lines: int, n_words: int, avg_conf: float, |
| angle: float, elapsed: float, img_size: tuple[int, int], |
| ) -> None: |
| """Draw summary statistics panel at the top of the image.""" |
| font = _load_font(16) |
| stats = ( |
| f"Lines: {n_lines} | Words: {n_words} | " |
| f"Conf: {avg_conf:.1%} | Angle: {angle:.1f}\u00b0 | " |
| f"Time: {elapsed:.0f}ms | {img_size[0]}\u00d7{img_size[1]}" |
| ) |
|
|
| bbox = font.getbbox(stats) |
| th = bbox[3] - bbox[1] |
| panel_h = th + 12 |
|
|
| |
| bg = Image.new("RGBA", overlay.size, (0, 0, 0, 0)) |
| bg_draw = ImageDraw.Draw(bg) |
| bg_draw.rectangle([0, 0, overlay.width, panel_h], fill=(0, 0, 0, 180)) |
| overlay.alpha_composite(bg) |
|
|
| draw = ImageDraw.Draw(overlay) |
| draw.text((8, 4), stats, fill=(255, 255, 255, 255), font=font) |
|
|
|
|
| |
| |
| |
|
|
| def visualize( |
| image_path: str, |
| output_path: str = "result_ocr.png", |
| show_word_boxes: bool = True, |
| show_line_boxes: bool = True, |
| show_text_overlay: bool = True, |
| show_confidence: bool = True, |
| ) -> None: |
| """Run OCR and visualize results with text overlay. |
| |
| Args: |
| image_path: Input image path. |
| output_path: Output path for annotated image. |
| show_word_boxes: Draw word-level bounding boxes. |
| show_line_boxes: Draw line-level bounding boxes. |
| show_text_overlay: Overlay recognized text on words. |
| show_confidence: Show confidence % below words. |
| """ |
| img = Image.open(image_path).convert("RGBA") |
| engine = OcrEngineOnnx() |
|
|
| t0 = time.perf_counter() |
| result = engine.recognize_pil(img.convert("RGB")) |
| elapsed_ms = (time.perf_counter() - t0) * 1000 |
|
|
| if result.error: |
| print(f"Error: {result.error}") |
| return |
|
|
| overlay = img.copy() |
| draw = ImageDraw.Draw(overlay) |
| n_words = sum(len(l.words) for l in result.lines) |
|
|
| for i, line in enumerate(result.lines): |
| lc = _LINE_COLORS[i % len(_LINE_COLORS)] |
|
|
| |
| if show_line_boxes and line.bounding_rect: |
| _draw_quad(draw, line.bounding_rect, color=lc, width=3) |
|
|
| for word in line.words: |
| if not word.bounding_rect: |
| continue |
| b = word.bounding_rect |
|
|
| |
| if show_word_boxes: |
| wc = _conf_color(word.confidence) |
| _draw_quad(draw, b, color=wc, width=2) |
|
|
| |
| if show_text_overlay: |
| _overlay_text_on_word(overlay, word.text, b, word.confidence) |
| draw = ImageDraw.Draw(overlay) |
|
|
| |
| if show_confidence: |
| xs = [b.x1, b.x2, b.x3, b.x4] |
| ys = [b.y1, b.y2, b.y3, b.y4] |
| cx = sum(xs) / 4 |
| y_bot = max(ys) + 2 |
| conf_font = _load_font(11) |
| label = f"{word.confidence:.0%}" |
| lbbox = conf_font.getbbox(label) |
| lw = lbbox[2] - lbbox[0] |
| draw.text( |
| (cx - lw / 2, y_bot), |
| label, |
| fill=(*_conf_color(word.confidence), 220), |
| font=conf_font, |
| ) |
|
|
| |
| _draw_summary( |
| overlay, |
| n_lines=len(result.lines), |
| n_words=n_words, |
| avg_conf=result.average_confidence, |
| angle=result.text_angle or 0.0, |
| elapsed=elapsed_ms, |
| img_size=(img.width, img.height), |
| ) |
|
|
| |
| final = Image.new("RGB", overlay.size, (255, 255, 255)) |
| final.paste(overlay, mask=overlay.split()[3]) |
| final.save(output_path, quality=95) |
|
|
| print(f"\nSaved: {output_path}") |
| print(f"Text: \"{result.text}\"") |
| print(f"Lines: {len(result.lines)}, Words: {n_words}, " |
| f"Conf: {result.average_confidence:.1%}, Time: {elapsed_ms:.0f}ms") |
|
|
| for i, line in enumerate(result.lines): |
| words_info = " ".join( |
| f'"{w.text}"({w.confidence:.0%})' for w in line.words |
| ) |
| print(f" L{i}: {words_info}") |
|
|
|
|
| if __name__ == "__main__": |
| image_path = sys.argv[1] if len(sys.argv) > 1 else "test3.png" |
| output_path = sys.argv[2] if len(sys.argv) > 2 else "result_ocr.png" |
| visualize(image_path, output_path) |
|
|