EXIF orientation: bake it in when normalizing images (#103)
imagenorm decoded and re-encoded to JPEG but ignored the EXIF Orientation tag. Phone cameras store the sensor pixels one way and set an EXIF flag to rotate on display, so a "portrait" JPEG is really a landscape bitmap tagged "rotate 90°" — and our re-encode strips EXIF, so without baking the rotation in, a packet photographed in portrait reaches the vision model sideways (bad OCR) and any future thumbnail is wrong. - exifOrientation: a small pure-Go parser that walks the JPEG APP1/Exif segment for tag 0x0112, returning 1 (normal) for non-JPEG or unparseable input — never guess a rotation onto a correct image. No cgo, no new dep. - applyOrientation: bakes in all 8 orientations (the 4 rotations + mirrors) after downscale (cheaper to rotate the small image; a 90° turn swaps the sides but not the longest edge, so the downscale bound still holds). - Non-JPEG paths (HEIC/webp/png) are untouched — their decoders own orientation and they carry no JPEG EXIF. Tests build oriented JPEGs (a corner marker + a spliced Exif APP1) and assert the marker lands where each orientation says, dims swapping for the quarter-turns; plus parser defaults for non-JPEG / no-EXIF / garbage. Stays CGO_ENABLED=0. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/binary"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"os"
|
||||
@@ -200,3 +201,120 @@ func TestNormalizeRejectsPixelBomb(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// orientedJPEG builds a JPEG whose top-left quadrant is white and the rest black
|
||||
// — a marker to track through a rotation — tagged with the given EXIF orientation
|
||||
// (1..8). The marker lets a test assert the pixels actually moved to where that
|
||||
// orientation says they should.
|
||||
func orientedJPEG(t *testing.T, orient int) []byte {
|
||||
t.Helper()
|
||||
const w, h = 40, 24
|
||||
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
c := color.RGBA{0, 0, 0, 255}
|
||||
if x < w/2 && y < h/2 {
|
||||
c = color.RGBA{255, 255, 255, 255}
|
||||
}
|
||||
m.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
var jb bytes.Buffer
|
||||
if err := jpeg.Encode(&jb, m, &jpeg.Options{Quality: 95}); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
if orient == 0 {
|
||||
return jb.Bytes() // caller wants a plain JPEG with no EXIF
|
||||
}
|
||||
return spliceExifOrientation(t, jb.Bytes(), orient)
|
||||
}
|
||||
|
||||
// spliceExifOrientation inserts a minimal little-endian Exif APP1 segment
|
||||
// carrying just the Orientation tag right after the JPEG SOI marker.
|
||||
func spliceExifOrientation(t *testing.T, jpg []byte, orient int) []byte {
|
||||
t.Helper()
|
||||
var tiff bytes.Buffer
|
||||
tiff.WriteString("II") // little-endian
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0x2A))
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint32(8)) // IFD0 offset
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(1)) // one entry
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0x0112))
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(3)) // SHORT
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint32(1)) // count
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(orient)) // value
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0)) // value pad
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint32(0)) // next IFD
|
||||
payload := append([]byte("Exif\x00\x00"), tiff.Bytes()...)
|
||||
|
||||
segLen := len(payload) + 2
|
||||
seg := []byte{0xFF, 0xE1, byte(segLen >> 8), byte(segLen)}
|
||||
seg = append(seg, payload...)
|
||||
|
||||
out := make([]byte, 0, len(jpg)+len(seg))
|
||||
out = append(out, jpg[:2]...) // SOI
|
||||
out = append(out, seg...)
|
||||
return append(out, jpg[2:]...)
|
||||
}
|
||||
|
||||
func bright(c color.Color) bool {
|
||||
r, g, b, _ := c.RGBA() // 16-bit
|
||||
return (r+g+b)/3 > 0x8000
|
||||
}
|
||||
|
||||
// TestNormalizeAppliesExifOrientation is the #103 regression: a phone photo tagged
|
||||
// "rotate 90°" must come out of Normalize with the pixels upright, not sideways —
|
||||
// the re-encode strips EXIF, so the rotation has to be baked into the bitmap.
|
||||
func TestNormalizeAppliesExifOrientation(t *testing.T) {
|
||||
// The white marker starts centred at (10,6) in the 40x24 source. For each
|
||||
// orientation, wantW/H is the corrected canvas and (mx,my) is where that
|
||||
// marker must land — derived from the same transform Normalize applies.
|
||||
cases := []struct {
|
||||
orient int
|
||||
wantW, wantH int
|
||||
mx, my int
|
||||
}{
|
||||
{0, 40, 24, 10, 6}, // no EXIF → unchanged, marker top-left
|
||||
{1, 40, 24, 10, 6}, // normal → unchanged
|
||||
{3, 40, 24, 29, 17}, // 180 → bottom-right
|
||||
{6, 24, 40, 17, 10}, // 90° CW → top-right (dims swap)
|
||||
{8, 24, 40, 6, 29}, // 90° CCW → bottom-left (dims swap)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
out, format, err := Normalize(bytes.NewReader(orientedJPEG(t, tc.orient)), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("orient %d: Normalize err = %v", tc.orient, err)
|
||||
}
|
||||
if format != "jpeg" {
|
||||
t.Errorf("orient %d: format = %q, want jpeg", tc.orient, format)
|
||||
}
|
||||
m, _, err := image.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("orient %d: decode output: %v", tc.orient, err)
|
||||
}
|
||||
if m.Bounds().Dx() != tc.wantW || m.Bounds().Dy() != tc.wantH {
|
||||
t.Errorf("orient %d: output %dx%d, want %dx%d",
|
||||
tc.orient, m.Bounds().Dx(), m.Bounds().Dy(), tc.wantW, tc.wantH)
|
||||
}
|
||||
if !bright(m.At(m.Bounds().Min.X+tc.mx, m.Bounds().Min.Y+tc.my)) {
|
||||
t.Errorf("orient %d: white marker not at (%d,%d) — orientation not applied",
|
||||
tc.orient, tc.mx, tc.my)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExifOrientationParsing pins the parser against non-JPEG and no-EXIF inputs,
|
||||
// which must default to 1 (never guess a rotation onto a correct image).
|
||||
func TestExifOrientationParsing(t *testing.T) {
|
||||
if o := exifOrientation(pngBytes(t, 8, 8)); o != 1 {
|
||||
t.Errorf("PNG orientation = %d, want 1 (no JPEG EXIF path)", o)
|
||||
}
|
||||
if o := exifOrientation(orientedJPEG(t, 0)); o != 1 {
|
||||
t.Errorf("JPEG without EXIF orientation = %d, want 1", o)
|
||||
}
|
||||
if o := exifOrientation(orientedJPEG(t, 6)); o != 6 {
|
||||
t.Errorf("JPEG tagged 6 → %d, want 6", o)
|
||||
}
|
||||
if o := exifOrientation([]byte("not an image")); o != 1 {
|
||||
t.Errorf("garbage bytes → %d, want 1", o)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user