#!/usr/bin/env python3 """hough_deskew.py — fine-skew angle detector for the archivdms OCR pipeline. Sidecar script for internal/ocr/ocr.go's `hough` deskew method (config.OCRConfig.DeskewMethod == "hough"), an ALTERNATIVE to the default ImageMagick `-deskew` peak/valley text-line projection analysis (deskewImage() in ocr.go). ImageMagick's approach needs surrounding background/margin to find the page's background rows/columns and fails on tightly-cropped phone photos of receipts (no margin context) — see project_deskew_disable_for_photos_tested_negative and project_deskew_border_trick_tested_negative in agent memory for two previously-tried and rejected workarounds. This script separates ANGLE DETECTION (via OpenCV, this file) from angle APPLICATION (plain `convert -rotate ` in ocr.go) per the recommendation that produced this rewrite. Usage: python3 hough_deskew.py Behavior: - Reads the image with OpenCV, grayscale + Otsu threshold. - Finds the largest contour by area and takes cv2.minAreaRect() of it. This is deliberately NOT text-line-projection-based (that is exactly what ImageMagick already does and what fails on cropped photos) — minAreaRect degrades gracefully to "the boundary of whatever content is in frame" even when that content fills the whole image, which is normally the case for a tightly-cropped phone photo. - Falls back to cv2.HoughLinesP() long-line-angle voting if no usable contour is found (e.g. near-blank background, no single dominant shape) — takes the median angle of detected line segments within +/-45 degrees of horizontal. - Prints exactly one float (the skew angle in degrees, ImageMagick `-rotate` sign convention: positive = clockwise) to stdout and exits 0 on success. - On any failure (bad path, unreadable image, no contours/lines found), prints nothing to stdout, writes a one-line reason to stderr, and exits non-zero. ocr.go's houghDeskewAngle treats this as "angle 0, keep going" — never a fatal OCR error. Dependencies: opencv-python (or the Debian python3-opencv apt package, which pulls in numpy as a transitive dependency) — no other third-party packages. Deliberately not using the `deskew` PyPI package: it wraps a very similar Radon/Hough approach but pulls in scikit-image, a much heavier dependency tree, for no accuracy benefit found in testing. """ import sys try: import cv2 import numpy as np except ImportError as exc: # pragma: no cover - environment/dependency issue print(f"hough_deskew: missing dependency: {exc}", file=sys.stderr) sys.exit(2) def _angle_from_min_area_rect(gray: "np.ndarray"): """Return a skew angle in degrees via Otsu threshold + largest contour's minAreaRect, or None if no usable contour was found.""" _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) contours, _ = cv2.findContours(thresh, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None largest = max(contours, key=cv2.contourArea) # Ignore contours covering too little of the frame — noise/artifacts, not # the document itself. img_area = gray.shape[0] * gray.shape[1] if cv2.contourArea(largest) < 0.05 * img_area: return None rect = cv2.minAreaRect(largest) angle = rect[2] # OpenCV: angle in (-90, 0] for cv2.minAreaRect # Normalize to the smallest rotation that would make the rect's long side # horizontal (matches ImageMagick -deskew / -rotate's small-angle # convention rather than cv2's raw (-90, 0] range). w, h = rect[1] if w < h: angle = angle + 90 if angle > 45: angle -= 90 elif angle < -45: angle += 90 return angle def _angle_from_hough_lines(gray: "np.ndarray"): """Fallback: median angle of long line segments detected via HoughLinesP, restricted to +/-45 degrees of horizontal. Returns None if no usable lines were found.""" edges = cv2.Canny(gray, 50, 150, apertureSize=3) lines = cv2.HoughLinesP( edges, 1, np.pi / 180, threshold=100, minLineLength=gray.shape[1] // 4, maxLineGap=20 ) if lines is None or len(lines) == 0: return None angles = [] for line in lines: x1, y1, x2, y2 = line[0] dx, dy = x2 - x1, y2 - y1 if dx == 0: continue angle = np.degrees(np.arctan2(dy, dx)) if -45 <= angle <= 45: angles.append(angle) if not angles: return None return float(np.median(angles)) def main() -> int: if len(sys.argv) != 2: print("hough_deskew: usage: hough_deskew.py ", file=sys.stderr) return 2 path = sys.argv[1] img = cv2.imread(path, cv2.IMREAD_GRAYSCALE) if img is None: print(f"hough_deskew: could not read image: {path}", file=sys.stderr) return 1 angle = _angle_from_min_area_rect(img) if angle is None: angle = _angle_from_hough_lines(img) if angle is None: print("hough_deskew: no usable contour or line angle found", file=sys.stderr) return 1 print(f"{angle:.4f}") return 0 if __name__ == "__main__": sys.exit(main())