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