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