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,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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user