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:
2026-08-11 21:27:53 +02:00
parent 40ed80da71
commit 9a24ea29e1
274 changed files with 53708 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
package ocr
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"html"
"io"
"mime"
"log/slog"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// officeMimeTypes are the document formats we route through LibreOffice
// (soffice --headless --convert-to pdf) before OCR. Mirrors the Paperless-ngx
// Gotenberg / Docspell LibreOffice conversion stage: the resulting PDF is then
// fed to the normal pdftotext / pdftoppm+tesseract pipeline (ocrPDF), so both
// text-layer PDFs and scanned-image content inside the office file are covered.
var officeMimeTypes = map[string]bool{
// Word / text processing
"application/msword": true,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
"application/vnd.oasis.opendocument.text": true,
"application/rtf": true,
"text/rtf": true,
// Spreadsheets
"application/vnd.ms-excel": true,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
"application/vnd.oasis.opendocument.spreadsheet": true,
// Presentations
"application/vnd.ms-powerpoint": true,
"application/vnd.openxmlformats-officedocument.presentationml.presentation": true,
"application/vnd.oasis.opendocument.presentation": true,
}
// isOfficeMime reports whether mimeType names an Office document format that
// Extract routes through LibreOffice conversion.
func isOfficeMime(mimeType string) bool {
return officeMimeTypes[strings.ToLower(strings.TrimSpace(mimeType))]
}
// officeConvertTimeout bounds a single LibreOffice conversion. Deliberately
// more generous than the per-OCR-call timeout: a cold soffice start plus a
// large spreadsheet can legitimately take longer than a tesseract page.
func (e *Extractor) officeConvertTimeout() time.Duration {
base := e.timeout()
if base < 120*time.Second {
return 120 * time.Second
}
return base
}
// officeToPDF converts an Office document at filePath to a temporary PDF via
// LibreOffice headless, returning the PDF path and a cleanup func the caller
// must defer. LibreOffice needs a private user-profile dir to run reliably and
// concurrently (multiple soffice instances sharing the default profile clash),
// so each conversion gets its own scratch dir under TmpDir.
func (e *Extractor) officeToPDF(ctx context.Context, filePath string) (string, func(), error) {
bin := e.sofficePath()
if _, err := exec.LookPath(bin); err != nil {
return "", nil, fmt.Errorf("ocr: libreoffice (%s) not found in PATH: %w", bin, err)
}
workDir := filepath.Join(e.tmpDir(), "office-"+randomID())
if err := os.MkdirAll(workDir, 0o700); err != nil {
return "", nil, fmt.Errorf("ocr: create office convert dir: %w", err)
}
cleanup := func() { os.RemoveAll(workDir) }
profileDir := filepath.Join(workDir, "profile")
cctx, cancel := context.WithTimeout(ctx, e.officeConvertTimeout())
defer cancel()
args := []string{
"--headless", "--norestore", "--nologo", "--nolockcheck",
"-env:UserInstallation=file://" + profileDir,
"--convert-to", "pdf", "--outdir", workDir, filePath,
}
cmd := exec.CommandContext(cctx, bin, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
cleanup()
if cctx.Err() == context.DeadlineExceeded {
return "", nil, fmt.Errorf("ocr: libreoffice conversion timed out after %s", e.officeConvertTimeout())
}
return "", nil, fmt.Errorf("ocr: libreoffice conversion failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
// soffice names the output <input-basename>.pdf in --outdir. Prefer that
// exact name, but fall back to the first *.pdf in the dir in case the base
// name was sanitized.
base := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
pdfPath := filepath.Join(workDir, base+".pdf")
if _, err := os.Stat(pdfPath); err != nil {
matches, _ := filepath.Glob(filepath.Join(workDir, "*.pdf"))
if len(matches) == 0 {
cleanup()
return "", nil, fmt.Errorf("ocr: libreoffice produced no pdf for %s", filepath.Base(filePath))
}
pdfPath = matches[0]
}
e.log(slog.LevelInfo, "office document converted to pdf",
"src", filepath.Base(filePath), "pdf", filepath.Base(pdfPath))
return pdfPath, cleanup, nil
}
// extractEML parses an .eml file and returns its human-readable text: a short
// header block (Date/From/To/Cc/Subject, MIME-word-decoded) followed by the
// concatenated text of every text/plain and (tag-stripped) text/html body part.
// Binary attachments are ignored. Best-effort throughout — a malformed message
// yields whatever could be parsed rather than an error, so an e-mail is never
// silently dropped from full-text search.
func (e *Extractor) extractEML(filePath string) (*Result, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("ocr: read eml: %w", err)
}
msg, err := mail.ReadMessage(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("ocr: parse eml: %w", err)
}
var sb strings.Builder
dec := new(mime.WordDecoder)
for _, h := range []string{"Date", "From", "To", "Cc", "Subject"} {
v := msg.Header.Get(h)
if v == "" {
continue
}
if d, derr := dec.DecodeHeader(v); derr == nil {
v = d
}
sb.WriteString(h)
sb.WriteString(": ")
sb.WriteString(v)
sb.WriteString("\n")
}
sb.WriteString("\n")
body := mailPartText(msg.Header.Get("Content-Type"), msg.Header.Get("Content-Transfer-Encoding"), msg.Body)
sb.WriteString(body)
return &Result{Text: strings.TrimSpace(sb.String())}, nil
}
// mailPartText recursively extracts readable text from a MIME part. multipart/*
// containers are walked; text/plain is decoded verbatim, text/html is decoded
// and tag-stripped; everything else (attachments, images) is skipped.
func mailPartText(contentType, cte string, body io.Reader) string {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil || mediaType == "" {
// No/invalid Content-Type: assume text/plain.
return decodeMailBody(body, cte)
}
if strings.HasPrefix(mediaType, "multipart/") {
boundary := params["boundary"]
if boundary == "" {
return ""
}
mr := multipart.NewReader(body, boundary)
var parts []string
for {
p, perr := mr.NextPart()
if perr != nil {
break
}
pt := mailPartText(p.Header.Get("Content-Type"), p.Header.Get("Content-Transfer-Encoding"), p)
p.Close()
if strings.TrimSpace(pt) != "" {
parts = append(parts, pt)
}
}
return strings.Join(parts, "\n")
}
switch {
case strings.HasPrefix(mediaType, "text/plain"):
return decodeMailBody(body, cte)
case strings.HasPrefix(mediaType, "text/html"):
return stripHTML(decodeMailBody(body, cte))
default:
return "" // attachment / binary part
}
}
// decodeMailBody reads a leaf MIME part, undoing base64 / quoted-printable
// transfer encoding.
func decodeMailBody(r io.Reader, cte string) string {
switch strings.ToLower(strings.TrimSpace(cte)) {
case "base64":
r = base64.NewDecoder(base64.StdEncoding, r)
case "quoted-printable":
r = quotedprintable.NewReader(r)
}
b, err := io.ReadAll(r)
if err != nil {
return string(b) // return whatever decoded before the error
}
return string(b)
}
var (
htmlTagRe = regexp.MustCompile(`(?s)<(script|style)[^>]*>.*?</(script|style)>`)
anyTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
wsRe = regexp.MustCompile(`[ \t\f\v]+`)
blankRe = regexp.MustCompile(`\n{3,}`)
)
// stripHTML reduces an HTML body to readable plain text: drops script/style
// blocks and all tags, unescapes entities, and collapses runaway whitespace.
func stripHTML(s string) string {
s = htmlTagRe.ReplaceAllString(s, " ")
s = anyTagRe.ReplaceAllString(s, " ")
s = html.UnescapeString(s)
s = wsRe.ReplaceAllString(s, " ")
s = blankRe.ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}
+359
View File
@@ -0,0 +1,359 @@
// Word-level bounding boxes for OCR text-highlight/overlay (Phase 1 —
// datengrundlage only, see project memory
// project_ocr_textmarkierung_overlay.md). This file adds:
//
// - WordBox / TSV extraction (tesseract's `tsv` output mode)
// - a small geometry-transform mechanism to map word boxes from the
// coordinate space of the final, fully-preprocessed image tesseract
// actually recognized text on, back into the coordinate space of the
// file the frontend actually displays to the user.
//
// Koordinatenraum (why this file exists at all): runTesseract's
// preprocessing pipeline (clampImageSize -> deskewImage -> normalizeContrast
// -> rotateForOSD, see ocr.go) can resize and rotate the image before
// tesseract ever sees it. The frontend, however, always renders the
// untouched original upload (internal/api/document_handlers.go
// handleGetDocumentFile serves doc.StoragePath byte-for-byte; verified
// 2026-07-30 — no transformed copy is ever persisted or served). Word boxes
// from tesseract are therefore in the WRONG coordinate space for direct use
// against the displayed image unless mapped back.
//
// Full forward order in runTesseract (each step optional):
//
// clampImageSize -> deskewImage | deskewImageHough -> normalizeContrast
// -> rotateForOSD -> binarizeImage
//
// and, for image uploads only, one final forward step applied in ocrImage
// AFTER the inversion above: the file's EXIF Orientation (see exif.go), which
// moves the boxes from raw-pixel space into the space the browser actually
// renders. Inversion happens strictly last-forward-step-first
// (mapWordsToOriginal iterates the slice backwards), so any combination —
// e.g. clamp + hough-deskew + OSD 90 degrees + EXIF 6 — composes correctly:
// the geometric chain is undone in reverse, then EXIF is applied once on top.
//
// What is handled exactly vs. approximately:
// - clampImageSize: pure uniform scale -> inverted exactly (simple ratio).
// - normalizeContrast / binarizeImage: no geometry change; still measured
// (geomChain.recordScale) rather than assumed, and dropped as identity.
// - rotateForOSD: our own rotate90CW, always an exact multiple of 90
// degrees -> inverted with pixel-exact integer math (mirrors the forward
// loop in rotateImageFile step for step, no trig/rounding involved).
// - deskewImage (ImageMagick `-deskew`, arbitrary small angle + canvas
// resize to bound the rotated image): inverted via the standard
// rotate-about-center formula using the angle ImageMagick reports via
// `-print "%[deskew:angle]"` plus before/after pixel dimensions. This is
// geometrically the correct construction for a generic "rotate and
// expand canvas" operation. Sign convention reviewed 2026-07-30 against
// ImageMagick's source behaviour: DeskewImage derives the `deskew:angle`
// artifact from the same `degrees` it feeds into the affine matrix
// [[cos,-sin],[sin,cos]], and AffineTransformImage expands the canvas
// symmetrically about the centre (auto-crop off by default) — so the
// centre-to-centre inverse with rad = -angleDeg below is the exact
// transpose. Still not validated against a real deskewed sample's pixel
// output, so treat it as reviewed-but-not-field-verified.
// Per project memory (two prior deskew-angle tuning attempts were tested
// against the doc id 4-9 corpus and rejected — see
// project_deskew_border_trick_tested_negative.md and
// project_deskew_disable_for_photos_tested_negative.md), do NOT blindly
// adjust this formula's sign/rounding by trial and error; instead verify
// against a real deskewed sample (overlay the mapped word boxes on the
// original image) before touching it, and record the result either way.
// - deskewImageHough: the angle is detected by hough_deskew.py but APPLIED
// by `convert -rotate <angle>`, whose sign convention is documented and
// unambiguous (positive = clockwise). The inverse below therefore IS
// verified for this path — the unverified sign caveat above applies only
// to ImageMagick's own `-deskew`/%[deskew:angle] pair.
// - Steps whose geometry cannot be measured (image.DecodeConfig only knows
// the formats this package imports, i.e. JPEG and PNG — a TIFF/BMP/WebP
// upload fails every measurement while ImageMagick still processes it)
// invalidate the whole chain via geomChain, and the document then gets NO
// word boxes. Silently skipping such a step used to leave the remaining
// transforms mapping into a coordinate space that no longer existed.
//
// PDF scope note: for the pdftoppm raster-fallback OCR path, WordBox
// coordinates are mapped back to the *rasterized page PNG's* pixel space
// (post-preprocessing -> pre-preprocessing raster), not further back into
// PDF point/MediaBox coordinate space. The frontend currently renders PDFs
// via the browser's native PDF viewer (iframe over the original file), which
// uses PDF page-coordinate space, not raster pixels — mapping raster pixels
// into that space is a straightforward additional scale step (raster DPI vs.
// MediaBox size, both knowable via pdftoppm's -r 300 and `pdfinfo`) but is
// left for whoever builds the overlay UI in a later phase, since it depends
// on how that phase chooses to render PDF pages (canvas render at a chosen
// DPI vs. native iframe).
package ocr
import (
"image"
"log/slog"
"math"
"os"
)
// WordBox is a single OCR-recognized word with its bounding box, already
// mapped (best-effort — see package doc comment above) into the coordinate
// space of the file the frontend actually displays for the document this
// word was found in.
type WordBox struct {
Text string
Left int
Top int
Width int
Height int
Confidence float64
// Line, Block, Par come straight from tesseract's TSV line_num/block_num/
// par_num columns, useful for later grouping words into lines/paragraphs
// (e.g. for the eventual highlight-overlay UI) without re-deriving that
// from raw positions.
Line int
Block int
Par int
// Page is the 1-based PDF page number this word was found on. Always 1
// for image uploads (a single "page"; there is no page 0 in output).
Page int
}
// geomTransform describes one preprocessing step's effect on image geometry,
// used to invert tesseract's word bounding boxes back towards the originally
// displayed file. See the package doc comment for what is exact vs.
// best-effort here.
type geomTransform struct {
oldW, oldH int
newW, newH int
// angleDeg is the clockwise rotation applied around the image center, in
// degrees. Zero for a pure resize/no-op step.
angleDeg float64
// exact90 marks a rotation known to be an exact multiple of 90 degrees,
// produced by our own rotate90CW (rotateForOSD) — inverted with
// pixel-exact integer math rather than the trig formula used for
// deskew's arbitrary angle.
exact90 bool
}
// invert maps a point (x, y) from the "new" (post-step) image's pixel space
// back into the "old" (pre-step) image's pixel space.
func (t geomTransform) invert(x, y float64) (float64, float64) {
if t.angleDeg == 0 {
if t.newW == 0 || t.newH == 0 {
return x, y
}
scaleX := float64(t.oldW) / float64(t.newW)
scaleY := float64(t.oldH) / float64(t.newH)
return x * scaleX, y * scaleY
}
if t.exact90 {
steps := (int(math.Round(t.angleDeg)) / 90) % 4
if steps < 0 {
steps += 4
}
curW, curH := t.newW, t.newH
cx, cy := x, y
for i := 0; i < steps; i++ {
// Forward step (rotateImageFile/rotate90CW) was, on pixel
// INDICES: src(w,h) -> dst(h,w), src(x,y) -> dst(h-1-y, x).
// mapWordsToOriginal feeds box EDGE coordinates (left..left+width,
// i.e. a continuous [0,w] range, not indices [0,w-1]), so the
// continuous form of the same rotation is used here:
// dst(x,y) = (h - y, x) => src = (cy, curW - cx)
// (Identical convention to applyEXIFOrientation in exif.go; using
// the index form on edge coordinates would shift every box by one
// pixel per rotation step.)
nx := cy
ny := float64(curW) - cx
curW, curH = curH, curW
cx, cy = nx, ny
}
return cx, cy
}
// General case (ImageMagick -deskew): rotation about the image center
// with the canvas expanded to bound the rotated image. Sign convention:
// positive angleDeg == clockwise (ImageMagick `-rotate`), so the inverse
// rotates by -angleDeg about the new centre and re-centres on the old
// canvas. See package doc comment for how far this is verified per path
// (hough: yes; ImageMagick's own -deskew: source-reviewed only).
rad := -t.angleDeg * math.Pi / 180
cxNew, cyNew := float64(t.newW)/2, float64(t.newH)/2
cxOld, cyOld := float64(t.oldW)/2, float64(t.oldH)/2
dx, dy := x-cxNew, y-cyNew
cos, sin := math.Cos(rad), math.Sin(rad)
rx := dx*cos - dy*sin
ry := dx*sin + dy*cos
return rx + cxOld, ry + cyOld
}
// isIdentity reports whether this step changed no geometry at all (same
// dimensions, no rotation) and can therefore be dropped from the chain.
func (t geomTransform) isIdentity() bool {
return t.angleDeg == 0 && t.oldW == t.newW && t.oldH == t.newH
}
// mapWordsToOriginal applies transforms in reverse (last-applied-preprocessing-
// step-first) order, mutating words in place to convert their bounding boxes
// from final-tesseract-image space into the coordinate space of the file
// before any of these transforms ran.
//
// ALL FOUR corners are inverted, not just top-left/bottom-right. That matters
// as soon as a non-90-degree rotation (deskew) is in the chain: under a
// rotation the two opposite corners alone no longer span the rotated
// rectangle's axis-aligned bounding box — for a typical 2-3 degree deskew the
// resulting box is systematically too narrow/short and offset, and at angles
// approaching 45 degrees it collapses towards zero size. The result here is
// the true axis-aligned bounding box of the back-rotated word quad, which is
// what the frontend overlay draws.
func mapWordsToOriginal(words []WordBox, transforms []geomTransform) {
if len(transforms) == 0 {
return
}
for i := range words {
l, t := float64(words[i].Left), float64(words[i].Top)
r, b := float64(words[i].Left+words[i].Width), float64(words[i].Top+words[i].Height)
corners := [4][2]float64{{l, t}, {r, t}, {r, b}, {l, b}}
for c := range corners {
x, y := corners[c][0], corners[c][1]
for j := len(transforms) - 1; j >= 0; j-- {
x, y = transforms[j].invert(x, y)
}
corners[c][0], corners[c][1] = x, y
}
minX, maxX := corners[0][0], corners[0][0]
minY, maxY := corners[0][1], corners[0][1]
for c := 1; c < 4; c++ {
minX = math.Min(minX, corners[c][0])
maxX = math.Max(maxX, corners[c][0])
minY = math.Min(minY, corners[c][1])
maxY = math.Max(maxY, corners[c][1])
}
words[i].Left = int(math.Round(minX))
words[i].Top = int(math.Round(minY))
words[i].Width = int(math.Round(maxX - minX))
words[i].Height = int(math.Round(maxY - minY))
}
}
// geomChain collects the geometry-changing preprocessing steps of a single
// runTesseract pass, so word boxes can be inverted back into the source
// image's coordinate space afterwards.
//
// The important property it enforces (this was a real, silent bug before):
// a preprocessing step that DID change geometry but whose geometry could not
// be measured must invalidate the whole chain, not just be skipped. Skipping
// it leaves the remaining transforms mapping into a coordinate space that no
// longer exists, and the frontend then draws a confidently wrong overlay.
// The realistic trigger is an upload format image.DecodeConfig cannot read:
// this package only registers image/jpeg and image/png, so TIFF/BMP/WebP/GIF
// uploads (all accepted as image/*) fail every decodeImageDims call while
// ImageMagick happily processes them. Rather than misplace boxes we return
// none for those documents.
type geomChain struct {
steps []geomTransform
broken bool
log func(level slog.Level, msg string, args ...any)
}
// recordScale books a step that may only scale the image uniformly
// (clampImageSize) or must not change geometry at all (normalizeContrast,
// binarizeImage). Identity steps are dropped.
func (c *geomChain) recordScale(step, oldPath, newPath string) {
t, ok := buildScaleTransform(oldPath, newPath)
if !ok {
c.fail(step, "image dimensions unreadable (unsupported format for image.DecodeConfig?)")
return
}
if t.isIdentity() {
return
}
c.steps = append(c.steps, t)
}
// recordRotation books a rotation step (deskewImage/deskewImageHough/
// rotateForOSD). A reported angle of 0 combined with changed dimensions means
// the angle was lost (e.g. an ImageMagick build not populating
// %[deskew:angle]) while a rotation really was applied — unrecoverable, so
// the chain is invalidated instead of silently mapping with angle 0.
func (c *geomChain) recordRotation(step, oldPath, newPath string, angleDeg float64, exact90 bool) {
t, ok := buildRotationTransform(oldPath, newPath, angleDeg, exact90)
if !ok {
c.fail(step, "image dimensions unreadable (unsupported format for image.DecodeConfig?)")
return
}
if angleDeg == 0 && (t.oldW != t.newW || t.oldH != t.newH) {
c.fail(step, "rotation applied but angle unknown (0) — cannot invert")
return
}
if t.isIdentity() {
return
}
c.steps = append(c.steps, t)
}
func (c *geomChain) fail(step, reason string) {
c.broken = true
if c.log != nil {
c.log(slog.LevelWarn, "ocr word boxes disabled: preprocessing geometry not invertible",
"step", step, "reason", reason)
}
}
// transforms returns the collected chain; ok is false when any step could not
// be recorded reliably, in which case callers must not emit word boxes at all.
func (c *geomChain) transforms() ([]geomTransform, bool) {
if c.broken {
return nil, false
}
return c.steps, true
}
// decodeImageDims returns the pixel width/height of the image at path
// without decoding full pixel data (image.DecodeConfig only reads the
// header).
func decodeImageDims(path string) (w, h int, err error) {
f, err := os.Open(path)
if err != nil {
return 0, 0, err
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0, err
}
return cfg.Width, cfg.Height, nil
}
// buildScaleTransform records a pure-resize geometry step (clampImageSize)
// by decoding both images' dimensions. ok is false if either image's
// dimensions cannot be read, in which case the caller should skip recording
// a transform (best-effort, same tolerance as the rest of this package).
func buildScaleTransform(oldPath, newPath string) (geomTransform, bool) {
oldW, oldH, err := decodeImageDims(oldPath)
if err != nil {
return geomTransform{}, false
}
newW, newH, err := decodeImageDims(newPath)
if err != nil {
return geomTransform{}, false
}
return geomTransform{oldW: oldW, oldH: oldH, newW: newW, newH: newH}, true
}
// buildRotationTransform records a rotation geometry step (deskewImage or
// rotateForOSD) by decoding both images' dimensions plus the rotation angle
// applied. exact90 distinguishes rotateForOSD's pixel-exact 90-degree
// rotations from deskewImage's arbitrary-angle, best-effort inverse.
func buildRotationTransform(oldPath, newPath string, angleDeg float64, exact90 bool) (geomTransform, bool) {
oldW, oldH, err := decodeImageDims(oldPath)
if err != nil {
return geomTransform{}, false
}
newW, newH, err := decodeImageDims(newPath)
if err != nil {
return geomTransform{}, false
}
return geomTransform{
oldW: oldW, oldH: oldH,
newW: newW, newH: newH,
angleDeg: angleDeg,
exact90: exact90,
}, true
}
+224
View File
@@ -0,0 +1,224 @@
package ocr
// EXIF-Orientierung für den OCR-Wortbox-Koordinatenraum.
//
// Warum diese Datei existiert (Root Cause des Overlay-Versatzes, 2026-07-30):
// Die Vorverarbeitungskette in runTesseract arbeitet ausschließlich auf ROHEN
// Pixeln — weder tesseract noch ImageMagick `convert` wenden das EXIF-Tag
// `Orientation` von selbst an (dafür bräuchte es explizit `-auto-orient`).
// mapWordsToOriginal rechnet die Wortboxen folglich in den ROH-Pixelraum der
// gespeicherten Datei zurück.
//
// Der Browser tut aber genau das Gegenteil: seit der Vereinheitlichung von
// `image-orientation: from-image` als Default (Chrome 81+, Firefox 26+,
// Safari 13.1+) rendert er ein <img> IMMER EXIF-orientiert und meldet auch
// naturalWidth/naturalHeight bereits gedreht. Bei einem Handyfoto mit
// Orientation 6/8 (Hochkant aufgenommen, Sensor liefert Querformat-Pixel)
// zeigt das Frontend also ein 3000x4000-Bild, während jede Wortbox in
// 4000x3000-Rohkoordinaten vorliegt: das Overlay ist um 90 Grad verdreht und
// liegt zum Teil komplett außerhalb des Bildes. Genau das ist das gemeldete
// "passt nicht mit den OCR-Feldern" — kein Subpixel-/Deskew-Problem, sondern
// ein kompletter Raumwechsel.
//
// Lösung: nach der Rücktransformation in den Rohraum wird hier EINMAL die
// EXIF-Orientierung vorwärts angewandt, damit die gespeicherten Koordinaten im
// tatsächlich DARGESTELLTEN Raum liegen (das ist auch die dokumentierte
// Semantik der ocr_words-Spalten und der API — "Koordinatenraum der
// angezeigten Datei"). Orientation 1 (bzw. kein EXIF, PNG, PDF-Raster) ist ein
// No-Op, betrifft also nur genau die Fotos, bei denen der Browser dreht.
//
// Der EXIF-Parser ist bewusst minimal und dependency-frei (nur stdlib): er
// sucht den APP1/"Exif\0\0"-Marker, liest den TIFF-Header und die IFD0-Einträge
// und gibt Tag 0x0112 zurück. Alles andere (XMP, MakerNotes, Thumbnails) wird
// nicht angefasst.
import (
"encoding/binary"
"errors"
"io"
"math"
"os"
)
// errNoEXIFOrientation signalisiert "kein verwertbares Orientation-Tag" —
// Aufrufer behandeln das wie Orientation 1.
var errNoEXIFOrientation = errors.New("ocr: no exif orientation")
// maxEXIFScan begrenzt, wie weit wir im JPEG nach dem APP1-Segment suchen.
// EXIF steht per Spezifikation direkt hinter SOI; die Grenze verhindert nur,
// dass eine kaputte Datei uns durch das ganze Bild laufen lässt.
const maxEXIFScan = 1 << 20 // 1 MiB
// jpegEXIFOrientation liefert den Wert des EXIF-Tags Orientation (1..8) der
// Datei an path. Für Nicht-JPEGs, JPEGs ohne EXIF, unlesbare oder unplausible
// Werte wird 1 (= keine Drehung) zurückgegeben; ein Fehler wird nur zur
// optionalen Diagnose mitgegeben und ist für Aufrufer nicht fatal.
func jpegEXIFOrientation(path string) (int, error) {
f, err := os.Open(path)
if err != nil {
return 1, err
}
defer f.Close()
var soi [2]byte
if _, err := io.ReadFull(f, soi[:]); err != nil {
return 1, err
}
if soi[0] != 0xFF || soi[1] != 0xD8 { // kein JPEG (PNG/TIFF/…): kein EXIF-Handling
return 1, errNoEXIFOrientation
}
scanned := 0
var hdr [4]byte
for scanned < maxEXIFScan {
// Marker suchen: beliebig viele 0xFF-Füllbytes, dann der Markercode.
var b [1]byte
if _, err := io.ReadFull(f, b[:]); err != nil {
return 1, errNoEXIFOrientation
}
scanned++
if b[0] != 0xFF {
continue
}
for {
if _, err := io.ReadFull(f, b[:]); err != nil {
return 1, errNoEXIFOrientation
}
scanned++
if b[0] != 0xFF {
break
}
}
marker := b[0]
switch {
case marker == 0xDA || marker == 0xD9:
// SOS (Bilddaten) bzw. EOI (Dateiende) erreicht: ab hier kann kein
// APP1/EXIF-Segment mehr kommen. Muss VOR der Prüfung auf
// längenlose Marker stehen — 0xD9 fällt sonst in den
// RST/D0..D7-Bereich und wir würden durch die Bilddaten weiterlaufen.
return 1, errNoEXIFOrientation
case marker == 0x00 || marker == 0xFF:
continue // Byte-Stuffing/Füllbyte, kein echter Marker
case marker == 0xD8 || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7):
continue // SOI/TEM/RSTn: längenlose Marker
}
if _, err := io.ReadFull(f, hdr[:2]); err != nil {
return 1, errNoEXIFOrientation
}
segLen := int(binary.BigEndian.Uint16(hdr[:2]))
if segLen < 2 {
return 1, errNoEXIFOrientation
}
payload := make([]byte, segLen-2)
if _, err := io.ReadFull(f, payload); err != nil {
return 1, errNoEXIFOrientation
}
scanned += segLen
if marker != 0xE1 || len(payload) < 6 || string(payload[:6]) != "Exif\x00\x00" {
continue
}
return orientationFromTIFF(payload[6:])
}
return 1, errNoEXIFOrientation
}
// orientationFromTIFF liest Tag 0x0112 aus dem IFD0 eines TIFF-Headers (der
// Nutzlast eines EXIF-APP1-Segments ohne "Exif\0\0"-Präfix).
func orientationFromTIFF(tiff []byte) (int, error) {
if len(tiff) < 8 {
return 1, errNoEXIFOrientation
}
var bo binary.ByteOrder
switch {
case tiff[0] == 'I' && tiff[1] == 'I':
bo = binary.LittleEndian
case tiff[0] == 'M' && tiff[1] == 'M':
bo = binary.BigEndian
default:
return 1, errNoEXIFOrientation
}
if bo.Uint16(tiff[2:4]) != 42 {
return 1, errNoEXIFOrientation
}
offset := int(bo.Uint32(tiff[4:8]))
if offset < 8 || offset+2 > len(tiff) {
return 1, errNoEXIFOrientation
}
count := int(bo.Uint16(tiff[offset : offset+2]))
entry := offset + 2
for i := 0; i < count; i++ {
if entry+12 > len(tiff) {
break
}
tag := bo.Uint16(tiff[entry : entry+2])
typ := bo.Uint16(tiff[entry+2 : entry+4])
if tag == 0x0112 && typ == 3 /* SHORT */ {
v := int(bo.Uint16(tiff[entry+8 : entry+10]))
if v >= 1 && v <= 8 {
return v, nil
}
return 1, errNoEXIFOrientation
}
entry += 12
}
return 1, errNoEXIFOrientation
}
// applyEXIFOrientation überführt Wortboxen aus dem ROH-Pixelraum eines Bildes
// (Breite rawW, Höhe rawH) in den vom Browser DARGESTELLTEN Raum, indem die
// EXIF-Orientierung orientation (1..8) vorwärts angewandt wird. orientation 1
// sowie ungültige Werte/Dimensionen sind ein No-Op.
//
// Die acht EXIF-Fälle entsprechen den üblichen Definitionen (2/4/5/7 enthalten
// eine Spiegelung; sie kommen bei Kameras praktisch nicht vor, werden aber der
// Vollständigkeit halber korrekt behandelt, damit hier nie stillschweigend ein
// falscher Raum entsteht):
//
// 1 (x, y) 2 (W-x, y) 3 (W-x, H-y) 4 (x, H-y)
// 5 (y, x) 6 (H-y, x) 7 (H-y, W-x) 8 (y, W-x)
//
// Bei 5..8 tauschen Breite und Höhe die Rollen — genau der Fall, in dem das
// Overlay ohne diese Korrektur komplett neben dem Bild landet.
func applyEXIFOrientation(words []WordBox, orientation, rawW, rawH int) {
if orientation <= 1 || orientation > 8 || rawW <= 0 || rawH <= 0 {
return
}
w, h := float64(rawW), float64(rawH)
mapPoint := func(x, y float64) (float64, float64) {
switch orientation {
case 2:
return w - x, y
case 3:
return w - x, h - y
case 4:
return x, h - y
case 5:
return y, x
case 6:
return h - y, x
case 7:
return h - y, w - x
case 8:
return y, w - x
default:
return x, y
}
}
for i := range words {
x0, y0 := mapPoint(float64(words[i].Left), float64(words[i].Top))
x1, y1 := mapPoint(float64(words[i].Left+words[i].Width), float64(words[i].Top+words[i].Height))
if x0 > x1 {
x0, x1 = x1, x0
}
if y0 > y1 {
y0, y1 = y1, y0
}
// math.Round statt int(v+0.5): Wortboxen können nach der
// Rücktransformation aus einem Deskew-Schritt knapp negative
// Randkoordinaten haben, dort rundet int(v+0.5) in die falsche Richtung.
words[i].Left = int(math.Round(x0))
words[i].Top = int(math.Round(y0))
words[i].Width = int(math.Round(x1 - x0))
words[i].Height = int(math.Round(y1 - y0))
}
}
+1060
View File
File diff suppressed because it is too large Load Diff
+134
View File
@@ -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())