JPEG: Embed Adobe RGB ICC profile with an InteropIndex tag #5178

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2025-11-23 10:07:30 +01:00
commit ab2ba2e72a
6 changed files with 145 additions and 0 deletions

21
internal/thumb/icc.go Normal file
View file

@ -0,0 +1,21 @@
package thumb
import (
"os"
"path"
)
/*
Possible TODO: move this into a shared pkg/ so non-thumb
consumers can also use it. However, it looks fiddly to hook that
up to `assets`, so I'm punting on that for now.
*/
func MustGetAdobeRGB1998Path() string {
p := path.Join(IccProfilesPath, "adobe_rgb_compat.icc")
_, err := os.Stat(p)
if err != nil {
panic(err)
}
return p
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

View file

@ -77,6 +77,10 @@ func Vips(imageName string, imageBuffer []byte, hash, thumbPath string, width, h
size = vips.SizeBoth
}
if err = vipsSetIccProfileForInteropIndex(img, clean.Log(filepath.Base(imageName))); err != nil {
log.Debugf("vips: %s in %s (set icc profile for interop index tag)", err, clean.Log(filepath.Base(imageName)))
}
// Create thumbnail image.
if err = img.ThumbnailWithSize(width, height, crop, size); err != nil {
log.Debugf("vips: %s in %s (create thumbnail)", err, clean.Log(filepath.Base(imageName)))
@ -157,3 +161,47 @@ func VipsJpegExportParams(width, height int) *vips.JpegExportParams {
return params
}
func vipsSetIccProfileForInteropIndex(img *vips.ImageRef, logName string) error {
// Many cameras will define a JPEG's colour space by setting the InteroperabilityIndex
// tag instead of embedding an inline ICC profile.
// We detect this and embed explicit icc profiles for thumbs of such images, for the benefit of Vips
// and web browsers, none of which pay any attention to the InteropIndex tag.
iiFull := img.GetString("exif-ifd4-InteroperabilityIndex")
if iiFull == "" {
return nil
}
// according to my reading, I think [:4] should be e.g. "R98\x00".
// However, vips always returns [:4] = "R98 ", e.g. space instead of null.
// I'm pulling [:3] instead to paper over this - the exif spec says "4 bytes
// incl null terminator" so I think this is safe.
ii := iiFull[:3]
log.Tracef("interopindex: %s read exif and got interopindex %s, %s", logName, ii, iiFull)
if img.HasICCProfile() {
log.Debugf("interopindex: %s has both an interop index tag and an embedded ICC profile. ignoring.", logName)
return nil
}
fallbackProfile := ""
switch ii {
case "R03":
// adobe rgb
fallbackProfile = MustGetAdobeRGB1998Path()
case "R98":
// srgb
// we could logically embed an srgb profile in the image here, but
// there's no value in doing so; everything assumes srgb anyway.
case "THM":
// a thumbnail file. I can't find a ref on what colour space
// this is, so I'm assuming without evidence that they are also srgb.
default:
log.Debugf("interopindex: %s has unknown interop index %s", logName, ii)
}
if fallbackProfile == "" {
return nil
}
return img.TransformICCProfileWithFallback(fallbackProfile, fallbackProfile) // icc profile gets embedded here
}

View file

@ -25,6 +25,28 @@ func TestVips(t *testing.T) {
assert.True(t, strings.HasSuffix(fileName, dst))
assert.FileExists(t, dst)
})
t.Run("InteropIndexColors", func(t *testing.T) {
thumb := Sizes[Tile500]
src := "testdata/interop_index.jpg"
dst := "testdata/vips/1/3/3/133456789098765432_500x500_center.jpg"
assert.FileExists(t, src)
fileName, _, err := Vips(src, nil, "133456789098765432", "testdata/vips", thumb.Width, thumb.Height, thumb.Options...)
if err != nil {
t.Fatal(err)
}
assert.True(t, strings.HasSuffix(fileName, dst))
assert.Equal(t, fileName, dst)
assert.FileExists(t, dst)
dstimg, err := vips.LoadImageFromFile(dst, vips.NewImportParams())
assert.NoError(t, err)
assert.True(t, dstimg.HasICCProfile())
assert.True(t, dstimg.IsColorSpaceSupported())
})
t.Run("Left224", func(t *testing.T) {
thumb := SizeLeft224
src := "testdata/fixed.jpg"