Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
135 lines
5.2 KiB
Python
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())
|