Files
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

135 lines
5.2 KiB
Python

#!/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 <deg>` in ocr.go) per the recommendation that produced this rewrite.
Usage:
python3 hough_deskew.py <image-path>
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 <image-path>", 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())