Files
2026-10-16 09:39:30 +08:00

183 lines
5.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package storage
import (
"encoding/binary"
"image"
"image/color"
"image/draw"
)
// EXIF Orientation(TIFF tag 0x0112)的取值含义:描述「存储的像素」要如何变换
// 才是用户肉眼看到的方向。手机竖拍照片几乎都是 6 或 8。
const (
exifOrientTopLeft = 1 // 正常
exifOrientTopRight = 2 // 水平镜像
exifOrientBottomRight = 3 // 旋转 180
exifOrientBottomLeft = 4 // 垂直镜像
exifOrientLeftTop = 5 // 转置
exifOrientRightTop = 6 // 顺时针 90
exifOrientRightBottom = 7 // 反转置
exifOrientLeftBottom = 8 // 顺时针 270
)
// jpegOrientation 解析 JPEG 的 EXIF Orientation,返回 1~8。
//
// Go 标准库 image.Decode 不读 EXIF(Pillow 默认也不读,需显式 exif_transpose),
// 因此手机竖拍照片解出来的像素是横躺的。更严重的是转码成 WebP 时 EXIF 会被
// 整体丢弃,方向信息随之永久消失、无法事后补救,故必须在转码前归一。
//
// 未找到 Orientation、格式异常或非 JPEG 时返回 1(identity),调用方无需区分
// 「没有方向信息」和「解析失败」。
func jpegOrientation(data []byte) int {
if len(data) < 4 || data[0] != 0xFF || data[1] != 0xD8 || data[2] != 0xFF {
return exifOrientTopLeft
}
for i := 2; i+4 <= len(data); {
if data[i] != 0xFF {
return exifOrientTopLeft // 段结构损坏,放弃解析
}
marker := data[i+1]
// 0xFF 填充字节、SOI、Standalone 标记、RSTn 均无长度字段。
if marker == 0xFF || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD9) {
i += 2
continue
}
segLen := int(binary.BigEndian.Uint16(data[i+2 : i+4]))
if segLen < 2 || i+2+segLen > len(data) {
return exifOrientTopLeft
}
// APP1 (0xE1),载荷形如 "Exif\x00\x00" + TIFF 块。
if marker == 0xE1 && segLen >= 10 {
payload := data[i+4 : i+2+segLen]
if len(payload) >= 6 && string(payload[:6]) == "Exif\x00\x00" {
if o := tiffOrientation(payload[6:]); o != 0 {
return o
}
}
}
// SOS (0xDA) 之后是熵编码的像素数据,不再有元数据段。
if marker == 0xDA {
return exifOrientTopLeft
}
i += 2 + segLen
}
return exifOrientTopLeft
}
// tiffOrientation 从 TIFF 块的 IFD0 中取出 Orientation;取不到返回 0。
func tiffOrientation(tiff []byte) int {
if len(tiff) < 8 {
return 0
}
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 0
}
if bo.Uint16(tiff[2:4]) != 42 { // 42 是 TIFF 魔数
return 0
}
ifd := int(bo.Uint32(tiff[4:8]))
if ifd < 8 || ifd+2 > len(tiff) {
return 0
}
entries := int(bo.Uint16(tiff[ifd : ifd+2]))
pos := ifd + 2
for i := 0; i < entries; i++ {
if pos+12 > len(tiff) {
return 0
}
// SHORT(3) 且 count==1 时,值内联在 4 字节 value 字段的前 2 字节。
if bo.Uint16(tiff[pos:pos+2]) == 0x0112 &&
bo.Uint16(tiff[pos+2:pos+4]) == 3 &&
bo.Uint32(tiff[pos+4:pos+8]) == 1 {
v := int(bo.Uint16(tiff[pos+8 : pos+10]))
if v >= exifOrientTopLeft && v <= exifOrientLeftBottom {
return v
}
return 0
}
pos += 12
}
return 0
}
// orientationSwapsDimensions 报告该 Orientation 是否会交换宽高。
// 2/3/4 只是镜像或 180 度旋转,宽高不变;5/6/7/8 会把横躺的像素转正,宽高互换。
func orientationSwapsDimensions(orientation int) bool {
switch orientation {
case exifOrientLeftTop, exifOrientRightTop, exifOrientRightBottom, exifOrientLeftBottom:
return true
}
return false
}
// orientedImage 把源图按 EXIF Orientation 重映射为目标坐标,供 draw.Draw
// 一次性拷贝,避免逐像素重复计算源矩形。
type orientedImage struct {
src image.Image
sx0, sy0 int // 源矩形左上角(源图 Bounds().Min 通常为 0,但不作假设)
w, h int // 源宽高
dstW, dstH int // 目标宽高
ori int
}
func (o *orientedImage) ColorModel() color.Model { return o.src.ColorModel() }
func (o *orientedImage) Bounds() image.Rectangle { return image.Rect(0, 0, o.dstW, o.dstH) }
// At 按 Orientation 反查源像素坐标。目标 (x,y) 命中源 (sx,sy)。
func (o *orientedImage) At(x, y int) color.Color {
sx, sy := 0, 0
switch o.ori {
case exifOrientTopRight: // 水平镜像
sx, sy = o.w-1-x, y
case exifOrientBottomRight: // 旋转 180
sx, sy = o.w-1-x, o.h-1-y
case exifOrientBottomLeft: // 垂直镜像
sx, sy = x, o.h-1-y
case exifOrientLeftTop: // 转置
sx, sy = y, x
case exifOrientRightTop: // 顺时针 90
sx, sy = y, o.h-1-x
case exifOrientRightBottom: // 反转置
sx, sy = o.w-1-y, o.h-1-x
case exifOrientLeftBottom: // 顺时针 270
sx, sy = o.w-1-y, x
default: // exifOrientTopLeft
sx, sy = x, y
}
return o.src.At(o.sx0+sx, o.sy0+sy)
}
// applyJPEGOrientation 按 EXIF Orientation 重绘像素;orientation 为 1 时原样返回。
//
// 归一后方向信息已烘进像素,尺寸在 5/6/7/8 时宽高互换——调用方必须在此之后再
// 做尺寸上限判断,否则限边会作用在横躺的像素上。
func applyJPEGOrientation(img image.Image, orientation int) image.Image {
if orientation <= exifOrientTopLeft || orientation > exifOrientLeftBottom {
return img
}
b := img.Bounds()
sw, sh := b.Dx(), b.Dy()
if sw == 0 || sh == 0 {
return img
}
dstW, dstH := sw, sh
if orientationSwapsDimensions(orientation) {
dstW, dstH = sh, sw
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
draw.Draw(dst, dst.Bounds(), &orientedImage{
src: img, sx0: b.Min.X, sy0: b.Min.Y,
w: sw, h: sh, dstW: dstW, dstH: dstH,
ori: orientation,
}, image.Point{}, draw.Src)
return dst
}