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.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user