Water-Meter-OCR / utils.py
JIRATHX's picture
Update Water Meter OCR to dedicated in-memory demo_huggingface pipeline
a453fc3 verified
Raw History Blame Contribute Delete
7.58 kB
"""
utils.py — Reusable Computer Vision and Geometry Utilities for Meter OCR Pipeline
Optimized for Hugging Face Spaces & Standalone Inference.
"""
from __future__ import annotations
import math
from typing import Any
import cv2
import numpy as np
from PIL import Image
def rotate_image(img_bgr: np.ndarray, angle: int) -> np.ndarray:
"""
Rotate BGR image by 90, 180, or 270 degrees clockwise.
Returns original image unchanged if angle == 0.
"""
if angle == 90:
return cv2.rotate(img_bgr, cv2.ROTATE_90_CLOCKWISE)
if angle == 180:
return cv2.rotate(img_bgr, cv2.ROTATE_180)
if angle == 270:
return cv2.rotate(img_bgr, cv2.ROTATE_90_COUNTERCLOCKWISE)
return img_bgr
def apply_prep(
img_bgr: np.ndarray,
prep: str,
clahe_clip: float = 2.0,
clahe_grid: tuple[int, int] = (8, 8),
) -> np.ndarray:
"""
Apply image contrast enhancement:
- 'clahe': Contrast Limited Adaptive Histogram Equalization on L channel (LAB color space)
- 'histeq': Global Histogram Equalization on Y channel (YCrCb color space)
- 'orig' or unknown: returns original image without modification
"""
if prep == "clahe":
lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB)
l, a, b = cv2.split(lab)
clahe = cv2.createCLAHE(clipLimit=clahe_clip, tileGridSize=clahe_grid)
l_clahe = clahe.apply(l)
return cv2.cvtColor(cv2.merge([l_clahe, a, b]), cv2.COLOR_LAB2BGR)
if prep == "histeq":
ycrcb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2YCrCb)
y, cr, cb = cv2.split(ycrcb)
y_eq = cv2.equalizeHist(y)
return cv2.cvtColor(cv2.merge([y_eq, cr, cb]), cv2.COLOR_YCrCb2BGR)
return img_bgr
def remap_point(x: float, y: float, angle: int, w: int, h: int) -> tuple[float, float]:
"""
Map coordinate (x, y) from a rotated image frame back to original image dimensions (w, h).
"""
if angle == 90:
return y, h - x
if angle == 180:
return w - x, h - y
if angle == 270:
return w - y, x
return x, y
def remap_bbox(bbox: list[float], angle: int, w: int, h: int) -> list[float]:
"""
Map bounding box [x1, y1, x2, y2] from rotated frame back to original image coordinates.
Maintains [min_x, min_y, max_x, max_y] canonical ordering.
"""
x1, y1, x2, y2 = bbox
p1_x, p1_y = remap_point(x1, y1, angle, w, h)
p2_x, p2_y = remap_point(x2, y2, angle, w, h)
return [
min(p1_x, p2_x),
min(p1_y, p2_y),
max(p1_x, p2_x),
max(p1_y, p2_y),
]
def inverse_remap_bbox(bbox: list[float], angle: int, w: int, h: int) -> list[float]:
"""
Map bounding box [x1, y1, x2, y2] from original image dimensions to rotated image frame.
Useful for drawing detections directly onto the selected orientation image.
"""
x1, y1, x2, y2 = bbox
if angle == 0:
return bbox
if angle == 90:
return [h - y2, x1, h - y1, x2]
if angle == 180:
return [w - x2, h - y2, w - x1, h - y1]
if angle == 270:
return [y1, w - x2, y2, w - x1]
return bbox
def iou(box1: list[float], box2: list[float]) -> float:
"""Calculate Intersection over Union (IoU) between two bounding boxes [x1, y1, x2, y2]."""
x1 = max(box1[0], box2[0])
y1 = max(box1[1], box2[1])
x2 = min(box1[2], box2[2])
y2 = min(box1[3], box2[3])
intersection = max(0.0, x2 - x1) * max(0.0, y2 - y1)
area1 = max(0.0, box1[2] - box1[0]) * max(0.0, box1[3] - box1[1])
area2 = max(0.0, box2[2] - box2[0]) * max(0.0, box2[3] - box2[1])
union = area1 + area2 - intersection
return (intersection / union) if union > 0.0 else 0.0
def dedup_detections(dets: list[dict[str, Any]], thresh: float = 0.45) -> list[dict[str, Any]]:
"""Deduplicate overlapping bounding boxes, preserving higher-confidence candidates."""
kept: list[dict[str, Any]] = []
for d in sorted(dets, key=lambda x: x["confidence"], reverse=True):
if not any(iou(d["bbox"], k["bbox"]) > thresh for k in kept):
kept.append(d)
return sorted(kept, key=lambda x: x["center_x"])
def red_ratio(img_bgr: np.ndarray, bbox: list[float]) -> float:
"""Calculate ratio of red pixels within a bounding box (indicating decimal/fraction digits)."""
x1, y1, x2, y2 = [int(v) for v in bbox]
pad_x = max(1, int((x2 - x1) * 0.15))
pad_y = max(1, int((y2 - y1) * 0.15))
crop = img_bgr[
max(0, y1 + pad_y): min(img_bgr.shape[0], y2 - pad_y),
max(0, x1 + pad_x): min(img_bgr.shape[1], x2 - pad_x),
]
if crop.size == 0:
return 0.0
hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV)
mask1 = cv2.inRange(hsv, (0, 40, 40), (12, 255, 255))
mask2 = cv2.inRange(hsv, (165, 40, 40), (180, 255, 255))
red_mask = mask1 | mask2
return float(np.mean(red_mask > 0))
def draw_annotated_image(
image_rgb: np.ndarray,
digits: list[dict[str, Any]],
best_angle: int = 0,
prep: str = "orig",
conf_reliable: float = 0.60,
show_on_rotated: bool = True,
) -> np.ndarray:
"""
Render bounding boxes, digit labels, and confidence tags on the image.
If show_on_rotated is True, renders on the best-detected rotation and contrast view.
Otherwise renders on the original orientation.
"""
if image_rgb is None or not image_rgb.size:
return image_rgb
h, w = image_rgb.shape[:2]
bgr = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2BGR)
if show_on_rotated and best_angle != 0:
canvas_bgr = rotate_image(bgr, best_angle)
if prep == "clahe":
canvas_bgr = apply_prep(canvas_bgr, "clahe")
elif prep == "histeq":
canvas_bgr = apply_prep(canvas_bgr, "histeq")
else:
canvas_bgr = bgr.copy()
canvas = cv2.cvtColor(canvas_bgr, cv2.COLOR_BGR2RGB)
cur_h, cur_w = canvas.shape[:2]
line_thickness = max(2, int(round(min(cur_w, cur_h) / 320)))
font_scale = max(0.45, min(cur_w, cur_h) / 850.0)
for d in digits:
bbox = d.get("bbox", [])
if len(bbox) != 4:
continue
if show_on_rotated and best_angle != 0:
rx1, ry1, rx2, ry2 = inverse_remap_bbox(bbox, best_angle, w, h)
else:
rx1, ry1, rx2, ry2 = bbox
ix1, iy1 = max(0, int(round(rx1))), max(0, int(round(ry1)))
ix2, iy2 = min(cur_w - 1, int(round(rx2))), min(cur_h - 1, int(round(ry2)))
conf = d.get("confidence", 0.0)
digit_val = d.get("digit", "?")
is_reliable = d.get("reliable", conf >= conf_reliable)
# Colors: Green for reliable (>= 0.60), Amber/Orange for warning (< 0.60)
box_color = (46, 204, 113) if is_reliable else (255, 140, 0)
# Draw bounding box
cv2.rectangle(canvas, (ix1, iy1), (ix2, iy2), box_color, line_thickness)
# Label tag
label = f"{digit_val} ({conf:.2f})"
(tw, th), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, 1)
tag_top = max(0, iy1 - th - 6)
tag_bottom = iy1
tag_right = min(cur_w, ix1 + tw + 8)
# Label background
cv2.rectangle(canvas, (ix1, tag_top), (tag_right, tag_bottom), box_color, -1)
# Text label (black on vibrant tag for clear contrast)
cv2.putText(
canvas,
label,
(ix1 + 4, tag_bottom - 4),
cv2.FONT_HERSHEY_SIMPLEX,
font_scale,
(0, 0, 0),
max(1, int(line_thickness / 2)),
cv2.LINE_AA,
)
return canvas