Skip to content

Commit aa24a09

Browse files
authored
fix: swap tone mapping logic for icc profile set (#281)
1 parent d265aee commit aa24a09

File tree

14 files changed

+129
-271
lines changed

14 files changed

+129
-271
lines changed

avif.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,17 @@ func (d *avifDecoder) Close() {
155155
// Encoder Implementation
156156
// ----------------------------------------
157157

158-
func newAvifEncoder(decodedBy Decoder, dstBuf []byte) (*avifEncoder, error) {
158+
func newAvifEncoder(decodedBy Decoder, dstBuf []byte, config *EncodeConfig) (*avifEncoder, error) {
159159
dstBuf = dstBuf[:1]
160-
icc := decodedBy.ICC()
160+
161+
// Use ICC override from config if provided, otherwise use decoder's ICC
162+
var icc []byte
163+
if config != nil && len(config.ICCOverride) > 0 {
164+
icc = config.ICCOverride
165+
} else {
166+
icc = decodedBy.ICC()
167+
}
168+
161169
loopCount := decodedBy.LoopCount()
162170
bgColor := decodedBy.BackgroundColor()
163171

avif_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ func testNewAvifEncoder(t *testing.T) {
164164
defer decoder.Close()
165165

166166
dstBuf := make([]byte, destinationBufferSize)
167-
encoder, err := newAvifEncoder(decoder, dstBuf)
167+
encoder, err := newAvifEncoder(decoder, dstBuf, nil)
168168
if err != nil {
169169
t.Fatalf("Unexpected error: %v", err)
170170
}
@@ -252,7 +252,7 @@ func testAvifEncoderEncode(t *testing.T) {
252252
}
253253

254254
dstBuf := make([]byte, destinationBufferSize)
255-
encoder, err := newAvifEncoder(decoder, dstBuf)
255+
encoder, err := newAvifEncoder(decoder, dstBuf, nil)
256256
if err != nil {
257257
t.Fatalf("Failed to create a new AVIF encoder: %v", err)
258258
}

color_info.cpp

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#include "color_info.hpp"
2+
#include <lcms2.h>
3+
4+
// Maximum ICC profile size we're willing to parse (1MB)
5+
static const size_t MAX_ICC_PROFILE_SIZE = 1024 * 1024;
6+
7+
// Check if ICC profile indicates HDR (PQ or HLG transfer function)
8+
bool is_hdr_transfer_function(const uint8_t* icc_data, size_t icc_len)
9+
{
10+
if (!icc_data || icc_len == 0 || icc_len > MAX_ICC_PROFILE_SIZE) {
11+
return false;
12+
}
13+
14+
cmsHPROFILE profile = cmsOpenProfileFromMem(icc_data, icc_len);
15+
if (!profile) {
16+
return false;
17+
}
18+
19+
uint8_t transfer = CICP_TRANSFER_UNSPECIFIED;
20+
cmsVideoSignalType* cicp = (cmsVideoSignalType*)cmsReadTag(profile, cmsSigcicpTag);
21+
if (cicp && cicp->TransferCharacteristics != 0) {
22+
transfer = static_cast<uint8_t>(cicp->TransferCharacteristics);
23+
}
24+
25+
cmsCloseProfile(profile);
26+
return (transfer == CICP_TRANSFER_PQ) || (transfer == CICP_TRANSFER_HLG);
27+
}

color_info.hpp

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#pragma once
2+
3+
#include <stddef.h>
4+
#include <stdint.h>
5+
6+
// CICP Transfer Characteristics (ITU-T H.273)
7+
#define CICP_TRANSFER_UNSPECIFIED 0
8+
#define CICP_TRANSFER_PQ 16 // SMPTE ST 2084 (HDR10)
9+
#define CICP_TRANSFER_HLG 18 // ARIB STD-B67 (HLG)
10+
11+
#ifdef __cplusplus
12+
extern "C" {
13+
#endif
14+
15+
/**
16+
* Check if an ICC profile indicates HDR content (PQ or HLG transfer function).
17+
* Returns true if the profile's CICP tag indicates PQ or HLG transfer characteristics.
18+
*/
19+
bool is_hdr_transfer_function(
20+
const uint8_t* icc_data,
21+
size_t icc_len
22+
);
23+
24+
#ifdef __cplusplus
25+
}
26+
#endif

giflib.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,8 @@ func (d *gifDecoder) SkipFrame() error {
235235

236236
// newGifEncoder creates a new GIF encoder that will write to the provided buffer.
237237
// Requires the original decoder that was used to decode the source GIF.
238-
func newGifEncoder(decodedBy Decoder, buf []byte) (*gifEncoder, error) {
238+
// config is accepted for API uniformity but not used by GIF encoder.
239+
func newGifEncoder(decodedBy Decoder, buf []byte, config *EncodeConfig) (*gifEncoder, error) {
239240
// we must have a decoder since we can't build our own palettes
240241
// so if we don't get a gif decoder, bail out
241242
if decodedBy == nil {

lilliput.go

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package lilliput
44

55
import (
66
"bytes"
7+
_ "embed"
78
"errors"
89
"strings"
910
"time"
@@ -14,6 +15,12 @@ const (
1415
ICCProfileBufferSize = 32768
1516
)
1617

18+
// SRGBICCProfile is the sRGB ICC profile (v4) - used when force_sdr overrides HDR ICC profiles.
19+
// Source: https://github.com/saucecontrol/Compact-ICC-Profiles
20+
//
21+
//go:embed icc_profiles/srgb_profile.icc
22+
var SRGBICCProfile []byte
23+
1724
var (
1825
ErrInvalidImage = errors.New("unrecognized image format")
1926
ErrDecodingFailed = errors.New("failed to decode image")
@@ -156,30 +163,40 @@ func NewDecoderWithOptionalToneMapping(buf []byte, toneMappingEnabled bool) (Dec
156163
return newAVCodecDecoder(buf)
157164
}
158165

159-
// NewEncoder returns an Encode which can be used to encode Framebuffer
166+
// EncodeConfig provides configuration options for encoders.
167+
// This struct provides a clean extension point for future encoding config
168+
// without changing function signatures.
169+
type EncodeConfig struct {
170+
// ICCOverride overrides the decoder's ICC profile when set.
171+
// Used for HDR→SDR conversion to force sRGB output.
172+
ICCOverride []byte
173+
}
174+
175+
// NewEncoder returns an Encoder which can be used to encode Framebuffer
160176
// into compressed image data. ext should be a string like ".jpeg" or
161177
// ".png". decodedBy is optional and can be the Decoder used to make
162178
// the Framebuffer. dst is where an encoded image will be written.
163-
func NewEncoder(ext string, decodedBy Decoder, dst []byte) (Encoder, error) {
179+
// config can be nil to use default settings.
180+
func NewEncoder(ext string, decodedBy Decoder, dst []byte, config *EncodeConfig) (Encoder, error) {
164181
if strings.ToLower(ext) == ".gif" {
165-
return newGifEncoder(decodedBy, dst)
182+
return newGifEncoder(decodedBy, dst, config)
166183
}
167184

168185
if strings.ToLower(ext) == ".webp" {
169-
return newWebpEncoder(decodedBy, dst)
186+
return newWebpEncoder(decodedBy, dst, config)
170187
}
171188

172189
if strings.ToLower(ext) == ".avif" {
173-
return newAvifEncoder(decodedBy, dst)
190+
return newAvifEncoder(decodedBy, dst, config)
174191
}
175192

176193
if strings.ToLower(ext) == ".mp4" || strings.ToLower(ext) == ".webm" {
177194
return nil, errors.New("Encoder cannot encode into video types")
178195
}
179196

180197
if strings.ToLower(ext) == ".thumbhash" {
181-
return newThumbhashEncoder(decodedBy, dst)
198+
return newThumbhashEncoder(decodedBy, dst, config)
182199
}
183200

184-
return newOpenCVEncoder(ext, decodedBy, dst)
201+
return newOpenCVEncoder(ext, decodedBy, dst, config)
185202
}

opencv.go

Lines changed: 10 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package lilliput
33
// #include "opencv.hpp"
44
// #include "avif.hpp"
55
// #include "webp.hpp"
6-
// #include "tone_mapping.hpp"
6+
// #include "color_info.hpp"
77
import "C"
88

99
import (
@@ -268,38 +268,13 @@ func (f *Framebuffer) OrientationTransform(orientation ImageOrientation) {
268268
f.height = int(C.opencv_mat_get_height(f.mat))
269269
}
270270

271-
// ApplyToneMapping applies HDR to SDR tone mapping if the ICC profile indicates HDR content.
272-
// This is an in-place operation that replaces the framebuffer's contents with tone-mapped data.
273-
// If the image is not HDR or tone mapping is not needed, the framebuffer is unchanged (copied in-place).
274-
// Returns an error if tone mapping fails.
275-
func (f *Framebuffer) ApplyToneMapping(icc []byte) error {
276-
if f.mat == nil {
277-
return ErrInvalidImage
278-
}
279-
280-
var iccPtr unsafe.Pointer
281-
if len(icc) > 0 {
282-
iccPtr = unsafe.Pointer(&icc[0])
283-
}
284-
285-
toneMappedMat := C.apply_tone_mapping(
286-
f.mat,
287-
(*C.uint8_t)(iccPtr),
288-
C.size_t(len(icc)))
289-
290-
if toneMappedMat == nil {
291-
return ErrInvalidImage
271+
// IsHDRICCProfile checks if an ICC profile indicates HDR content (PQ or HLG transfer function).
272+
// Returns true if the profile's CICP tag indicates PQ or HLG transfer characteristics.
273+
func IsHDRICCProfile(icc []byte) bool {
274+
if len(icc) == 0 {
275+
return false
292276
}
293-
294-
// Replace the current mat with the tone-mapped one
295-
C.opencv_mat_release(f.mat)
296-
f.mat = toneMappedMat
297-
298-
// Update dimensions in case they changed (they shouldn't, but be safe)
299-
f.width = int(C.opencv_mat_get_width(f.mat))
300-
f.height = int(C.opencv_mat_get_height(f.mat))
301-
302-
return nil
277+
return bool(C.is_hdr_transfer_function((*C.uint8_t)(unsafe.Pointer(&icc[0])), C.size_t(len(icc))))
303278
}
304279

305280
// ResizeTo performs a resizing transform on the Framebuffer and puts the result
@@ -764,7 +739,9 @@ func (d *openCVDecoder) SkipFrame() error {
764739
return ErrSkipNotSupported
765740
}
766741

767-
func newOpenCVEncoder(ext string, decodedBy Decoder, dstBuf []byte) (*openCVEncoder, error) {
742+
// newOpenCVEncoder creates an OpenCV-based encoder.
743+
// config is accepted for API uniformity but not used by OpenCV encoder.
744+
func newOpenCVEncoder(ext string, decodedBy Decoder, dstBuf []byte, config *EncodeConfig) (*openCVEncoder, error) {
768745
dstBuf = dstBuf[:1]
769746
dst := C.opencv_mat_create_empty_from_data(C.int(cap(dstBuf)), unsafe.Pointer(&dstBuf[0]))
770747

opencv_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ func TestICC(t *testing.T) {
269269

270270
// try encoding a WebP image, including ICC profile data when available
271271
dstBuf := make([]byte, destinationBufferSize)
272-
encoder, err := newWebpEncoder(decoder, dstBuf)
272+
encoder, err := newWebpEncoder(decoder, dstBuf, nil)
273273
if err != nil {
274274
t.Fatalf("Failed to create a new webp encoder: %v", err)
275275
}

ops.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -331,16 +331,6 @@ func (o *ImageOps) Transform(d Decoder, opt *ImageOptions, dst []byte) ([]byte,
331331
}
332332
}
333333

334-
// Apply tone mapping if requested (before encoding)
335-
if !emptyFrame && opt.ForceSdr {
336-
icc := d.ICC()
337-
if len(icc) > 0 {
338-
if err := o.active().ApplyToneMapping(icc); err != nil {
339-
return nil, err
340-
}
341-
}
342-
}
343-
344334
// encode the frame to the output buffer
345335
var content []byte
346336
if emptyFrame {
@@ -415,7 +405,18 @@ func (o *ImageOps) initializeTransform(d Decoder, opt *ImageOptions, dst []byte)
415405
return nil, nil, err
416406
}
417407

418-
enc, err := NewEncoder(opt.FileType, d, dst)
408+
// Build encode config, including ICC override for HDR→SDR conversion
409+
var encodeConfig *EncodeConfig
410+
if opt.ForceSdr {
411+
icc := d.ICC()
412+
if len(icc) > 0 && IsHDRICCProfile(icc) {
413+
encodeConfig = &EncodeConfig{
414+
ICCOverride: SRGBICCProfile,
415+
}
416+
}
417+
}
418+
419+
enc, err := NewEncoder(opt.FileType, d, dst, encodeConfig)
419420
if err != nil {
420421
return nil, nil, err
421422
}

thumbhash.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ type thumbhashEncoder struct {
1818
// newThumbhashEncoder creates a new ThumbHash encoder instance.
1919
// It takes a decoder and a buffer as input, initializing the C-based encoder.
2020
// Returns an error if the provided buffer is too small.
21-
func newThumbhashEncoder(decodedBy Decoder, buf []byte) (*thumbhashEncoder, error) {
21+
// config is accepted for API uniformity but not used by ThumbHash encoder.
22+
func newThumbhashEncoder(decodedBy Decoder, buf []byte, config *EncodeConfig) (*thumbhashEncoder, error) {
2223
buf = buf[:1]
2324
enc := C.thumbhash_encoder_create(unsafe.Pointer(&buf[0]), C.size_t(cap(buf)))
2425
if enc == nil {

0 commit comments

Comments
 (0)