Files
archivdms/internal/ocr/exif.go
T
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

225 lines
7.6 KiB
Go

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))
}
}