Venket / src /analysis /geometry_analyzer.py
venki101's picture
Add files using upload-large-folder tool
3f820d9 verified
Raw History Blame Contribute Delete
10.3 kB
"""Post-render signature detection and geometry/coverage metric computation.
Rather than searching the entire page (which would pick up printed field
labels like "By:" or "Print Name:"), detection is scoped to the known
signature field location -- exactly as a production pipeline would use the
document's layout annotation to know where to look. A tight pass is tried
first; if no ink is found (e.g. the signature was intentionally misplaced
far from its box) the search window progressively expands, bounded by the
page, before giving up.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import cv2
import numpy as np
from src.utils.geometry_utils import (
Box, box_center, box_to_polygon, centroid_of, containment_pct,
coverage_pct, distance_between, iou, margin_utilization, points_to_polygon, safe_area,
)
from src.utils.logging_utils import get_logger
logger = get_logger(__name__)
MIN_INK_PIXELS = 12
@dataclass
class GeometryResult:
"""Full geometric + photometric characterization of the detected signature."""
found: bool
signature_bbox_px: Box
min_rotated_rect: dict
signature_polygon_px: list[list[float]]
convex_hull_px: list[list[float]]
centroid_px: tuple[float, float]
orientation_deg: float
ink_pixels: int
target_box: Box
actual_box: Box
search_box: Box
box_coverage_pct: float
containment_pct: float
ink_fill_ratio: float
polygon_overlap_proxy: float
distance_from_box_center_px: float
margin_utilization: float
mean_dark_intensity: float
gray_stddev: float
signature_area_ratio: float
whitespace_ratio: float
def _expand_box(box: Box, canvas_size: tuple[int, int], factor: float, min_pad: float) -> Box:
x1, y1, x2, y2 = box
w, h = canvas_size
bw, bh = (x2 - x1), (y2 - y1)
pad_x = max(bw * (factor - 1.0) / 2.0, min_pad)
pad_y = max(bh * (factor - 1.0) / 2.0, min_pad)
return (
float(max(x1 - pad_x, 0)), float(max(y1 - pad_y, 0)),
float(min(x2 + pad_x, w)), float(min(y2 + pad_y, h)),
)
def _union_box(a: Box, b: Box) -> Box:
return (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3]))
def _is_printed_rule_line(width: int, height: int, area: int) -> bool:
"""Heuristic filter for printed "sign here" guideline rules vs. handwritten ink.
Printed rules are thin (<=3px tall), very wide relative to their height,
and nearly solid (high fill ratio) -- unlike handwriting, which varies in
stroke height and leaves gaps even in a single connected component.
"""
if height > 3:
return False
if width < 8 * max(height, 1):
return False
fill_ratio = area / max(width * height, 1)
return fill_ratio > 0.6
def _ink_mask(gray_crop: np.ndarray) -> "np.ndarray | None":
if gray_crop.size == 0:
return None
background = float(np.percentile(gray_crop, 90))
threshold = background - max(18.0, background * 0.15)
binary = (gray_crop.astype(np.float32) < threshold).astype(np.uint8) * 255
if binary.sum() == 0:
return binary
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2, 2))
opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)
n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(opened, connectivity=8)
cleaned = np.zeros_like(opened)
for label in range(1, n_labels):
area = stats[label, cv2.CC_STAT_AREA]
w = stats[label, cv2.CC_STAT_WIDTH]
h = stats[label, cv2.CC_STAT_HEIGHT]
if area < 2:
continue
if _is_printed_rule_line(w, h, area):
continue
cleaned[labels == label] = 255
return cleaned
class GeometryAnalyzer:
"""Detects the rendered signature within its known field and computes all geometry metrics.
Example:
>>> analyzer = GeometryAnalyzer()
>>> result = analyzer.analyze(final_image, target_box=(100, 100, 300, 160),
... canvas_size=(1024, 559))
"""
def analyze(
self,
image_rgb: np.ndarray,
target_box: Box,
canvas_size: tuple[int, int],
placement_hint_bbox: "Box | None" = None,
) -> GeometryResult:
"""Detect the rendered signature and compute every geometry/coverage metric.
Args:
image_rgb: The fully rendered (and augmented) document, ``(H, W, 3)`` uint8.
target_box: The annotated ``(x1, y1, x2, y2)`` signature field.
canvas_size: ``(width, height)`` of the document.
placement_hint_bbox: Optional pre-augmentation placement bounding
box, used to widen the search if a tight pass finds nothing
(e.g. after a large intentional misplacement).
Returns:
A :class:`GeometryResult` with detection status and every metric.
"""
gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY)
search_box = _expand_box(target_box, canvas_size, factor=1.15, min_pad=6)
mask, offset = self._try_detect(gray, search_box)
if mask is None or int((mask > 0).sum()) < MIN_INK_PIXELS:
union = _union_box(target_box, placement_hint_bbox) if placement_hint_bbox else target_box
search_box = _expand_box(union, canvas_size, factor=2.2, min_pad=50)
mask, offset = self._try_detect(gray, search_box)
if mask is None or int((mask > 0).sum()) < MIN_INK_PIXELS:
logger.warning("No signature ink detected near target box %s.", target_box)
return self._empty_result(target_box, search_box, canvas_size)
return self._build_result(mask, offset, gray, target_box, search_box, canvas_size)
@staticmethod
def _try_detect(gray: np.ndarray, box: Box) -> tuple["np.ndarray | None", tuple[int, int]]:
x1, y1, x2, y2 = (int(round(v)) for v in box)
crop = gray[y1:y2, x1:x2]
mask = _ink_mask(crop)
return mask, (x1, y1)
def _build_result(
self, mask: np.ndarray, offset: tuple[int, int], full_gray: np.ndarray,
target_box: Box, search_box: Box, canvas_size: tuple[int, int],
) -> GeometryResult:
ox, oy = offset
ys, xs = np.where(mask > 0)
points = np.column_stack([xs + ox, ys + oy]).astype(np.float32)
ink_pixels = int(len(points))
x1, y1 = float(points[:, 0].min()), float(points[:, 1].min())
x2, y2 = float(points[:, 0].max()) + 1.0, float(points[:, 1].max()) + 1.0
bbox: Box = (x1, y1, x2, y2)
rect = cv2.minAreaRect(points)
(rcx, rcy), (rw, rh), rangle = rect
rotated_rect = {
"center": [round(float(rcx), 2), round(float(rcy), 2)],
"size": [round(float(rw), 2), round(float(rh), 2)],
"angle_deg": round(float(rangle), 2),
}
contour_points = points.astype(np.int32)
hull = cv2.convexHull(contour_points)
hull_xy = [[int(p[0][0]), int(p[0][1])] for p in hull]
mean_pt, eigvecs, eigvals = cv2.PCACompute2(points, mean=None)
orientation_deg = float(np.degrees(np.arctan2(eigvecs[0][1], eigvecs[0][0])))
orientation_deg = ((orientation_deg + 90) % 180) - 90
centroid = (float(points[:, 0].mean()), float(points[:, 1].mean()))
bbox_crop = full_gray[int(y1):int(y2), int(x1):int(x2)]
mean_dark_intensity = float(bbox_crop[mask.astype(bool)].mean()) if bbox_crop.shape == mask.shape else float(
full_gray[oy:oy + mask.shape[0], ox:ox + mask.shape[1]][mask.astype(bool)].mean()
)
gray_stddev = float(bbox_crop.std()) if bbox_crop.size > 0 else 0.0
target_poly = box_to_polygon(target_box)
signature_poly = points_to_polygon(hull_xy)
bbox_area = max((x2 - x1) * (y2 - y1), 1.0)
ink_fill_ratio = float(np.clip(ink_pixels / bbox_area, 0.0, 1.0))
canvas_area = max(canvas_size[0] * canvas_size[1], 1)
signature_area_ratio = float(bbox_area / canvas_area)
return GeometryResult(
found=True,
signature_bbox_px=bbox,
min_rotated_rect=rotated_rect,
signature_polygon_px=hull_xy,
convex_hull_px=hull_xy,
centroid_px=(round(centroid[0], 2), round(centroid[1], 2)),
orientation_deg=round(orientation_deg, 2),
ink_pixels=ink_pixels,
target_box=target_box,
actual_box=bbox,
search_box=search_box,
box_coverage_pct=round(coverage_pct(target_poly, signature_poly), 3),
containment_pct=round(containment_pct(target_poly, signature_poly), 3),
ink_fill_ratio=round(ink_fill_ratio, 4),
polygon_overlap_proxy=round(iou(target_poly, signature_poly), 4),
distance_from_box_center_px=round(distance_between(box_center(target_box), centroid), 2),
margin_utilization=round(margin_utilization(target_box, signature_poly), 4),
mean_dark_intensity=round(mean_dark_intensity, 2),
gray_stddev=round(gray_stddev, 2),
signature_area_ratio=round(signature_area_ratio, 5),
whitespace_ratio=round(float(np.clip(1.0 - ink_fill_ratio, 0.0, 1.0)), 4),
)
@staticmethod
def _empty_result(target_box: Box, search_box: Box, canvas_size: tuple[int, int]) -> GeometryResult:
return GeometryResult(
found=False,
signature_bbox_px=target_box,
min_rotated_rect={"center": [0.0, 0.0], "size": [0.0, 0.0], "angle_deg": 0.0},
signature_polygon_px=[],
convex_hull_px=[],
centroid_px=(float("nan"), float("nan")),
orientation_deg=0.0,
ink_pixels=0,
target_box=target_box,
actual_box=target_box,
search_box=search_box,
box_coverage_pct=0.0,
containment_pct=0.0,
ink_fill_ratio=0.0,
polygon_overlap_proxy=0.0,
distance_from_box_center_px=float("nan"),
margin_utilization=0.0,
mean_dark_intensity=255.0,
gray_stddev=0.0,
signature_area_ratio=0.0,
whitespace_ratio=1.0,
)