Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,26 @@ jobs:
wc -l | grep -q "2" || \
(echo "Valgrind output:" && echo "$VALGRIND_OUT" && false)
done

- name: Run HDR Tone Mapping Valgrind Tests
run: |
cd examples
hdr_test_cases=(
"100 100 ../data/hdr-ohmama.png out-hdr-1.webp"
"200 200 ../data/hdr-ohmama.png out-hdr-2.png"
"150 150 ../data/hdr-ohmama.png out-hdr-3.webp"
)

for test_case in "${hdr_test_cases[@]}"; do
read -r height width input output <<< "$test_case"
echo "Testing HDR tone mapping with height=$height width=$width input=$input output=$output"
VALGRIND_OUT=$(valgrind --leak-check=full ./example \
-height "$height" \
-width "$width" \
-input "$input" \
-output "$output" \
-force-sdr 2>&1) && \
echo "$VALGRIND_OUT" | grep -E "definitely lost: 0 bytes in 0 blocks|indirectly lost: 0 bytes in 0 blocks" | \
wc -l | grep -q "2" || \
(echo "Valgrind output:" && echo "$VALGRIND_OUT" && false)
done
Binary file added data/hdr-ohmama.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed examples/example
Binary file not shown.
3 changes: 3 additions & 0 deletions examples/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ func main() {
var outputFilename string
var stretch bool
var encodeTimeoutOption string
var forceSdr bool

flag.StringVar(&inputFilename, "input", "", "name of input file to resize/transcode")
flag.StringVar(&outputFilename, "output", "", "name of output file, also determines output type")
flag.IntVar(&outputWidth, "width", 0, "width of output file")
flag.IntVar(&outputHeight, "height", 0, "height of output file")
flag.BoolVar(&stretch, "stretch", false, "perform stretching resize instead of cropping")
flag.StringVar(&encodeTimeoutOption, "timeout", "60s", "encode timeout for videos")
flag.BoolVar(&forceSdr, "force-sdr", false, "enable HDR to SDR tone mapping for images with PQ profiles")
flag.Parse()

if inputFilename == "" {
Expand Down Expand Up @@ -120,6 +122,7 @@ func main() {
NormalizeOrientation: true,
EncodeOptions: EncodeOptions[outputType],
EncodeTimeout: encodeTimeout,
ForceSdr: forceSdr,
}

transformStartTime := time.Now()
Expand Down
40 changes: 40 additions & 0 deletions opencv.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package lilliput
// #include "opencv.hpp"
// #include "avif.hpp"
// #include "webp.hpp"
// #include "tone_mapping.hpp"
import "C"

import (
Expand Down Expand Up @@ -141,6 +142,7 @@ type openCVEncoder struct {
encoder C.opencv_encoder // Native OpenCV encoder
dst C.opencv_mat // Destination OpenCV matrix
dstBuf []byte // Destination buffer for encoded data
icc []byte // ICC color profile from source image
}

// Depth returns the number of bits in the PixelType.
Expand Down Expand Up @@ -300,6 +302,40 @@ func (f *Framebuffer) OrientationTransform(orientation ImageOrientation) {
f.height = int(C.opencv_mat_get_height(f.mat))
}

// ApplyToneMapping applies HDR to SDR tone mapping if the ICC profile indicates HDR content.
// This is an in-place operation that replaces the framebuffer's contents with tone-mapped data.
// If the image is not HDR or tone mapping is not needed, the framebuffer is unchanged (copied in-place).
// Returns an error if tone mapping fails.
func (f *Framebuffer) ApplyToneMapping(icc []byte) error {
if f.mat == nil {
return ErrInvalidImage
}

var iccPtr unsafe.Pointer
if len(icc) > 0 {
iccPtr = unsafe.Pointer(&icc[0])
}

toneMappedMat := C.apply_tone_mapping_ffi(
f.mat,
(*C.uint8_t)(iccPtr),
C.size_t(len(icc)))

if toneMappedMat == nil {
return ErrInvalidImage
}

// Replace the current mat with the tone-mapped one
C.opencv_mat_release(f.mat)
f.mat = toneMappedMat

// Update dimensions in case they changed (they shouldn't, but be safe)
f.width = int(C.opencv_mat_get_width(f.mat))
f.height = int(C.opencv_mat_get_height(f.mat))

return nil
}

// ResizeTo performs a resizing transform on the Framebuffer and puts the result
// in the provided destination Framebuffer. This function does not preserve aspect
// ratio if the given dimensions differ in ratio from the source. Returns an error
Expand Down Expand Up @@ -777,10 +813,13 @@ func newOpenCVEncoder(ext string, decodedBy Decoder, dstBuf []byte) (*openCVEnco
return nil, ErrInvalidImage
}

icc := decodedBy.ICC()

return &openCVEncoder{
encoder: enc,
dst: dst,
dstBuf: dstBuf,
icc: icc,
}, nil
}

Expand All @@ -797,6 +836,7 @@ func (e *openCVEncoder) Encode(f *Framebuffer, opt map[int]int) ([]byte, error)
if len(optList) > 0 {
firstOpt = (*C.int)(unsafe.Pointer(&optList[0]))
}

if !C.opencv_encoder_write(e.encoder, f.mat, firstOpt, C.size_t(len(optList))) {
return nil, ErrInvalidImage
}
Expand Down
1 change: 1 addition & 0 deletions opencv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,4 @@ func TestICC(t *testing.T) {
})
}
}

15 changes: 15 additions & 0 deletions ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ type ImageOptions struct {
// If set to 0, only the first frame will be extracted (default behavior).
// This option only applies to video formats (MP4, MOV, WEBM).
VideoFrameSampleIntervalMs int

// ForceSdr enables HDR to SDR tone mapping for images with PQ (Perceptual Quantizer) profiles.
// When enabled, images with HDR color profiles will be tone-mapped to SDR for better compatibility.
// Only applies to WebP and PNG output formats.
ForceSdr bool
}

// ImageOps is a reusable object that can resize and encode images.
Expand Down Expand Up @@ -337,6 +342,16 @@ func (o *ImageOps) Transform(d Decoder, opt *ImageOptions, dst []byte) ([]byte,
}
}

// Apply tone mapping if requested (before encoding)
if !emptyFrame && opt.ForceSdr {
icc := d.ICC()
if len(icc) > 0 {
if err := o.active().ApplyToneMapping(icc); err != nil {
return nil, err
}
}
}

// encode the frame to the output buffer
var content []byte
if emptyFrame {
Expand Down
168 changes: 168 additions & 0 deletions tone_mapping.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#include "tone_mapping.hpp"
#include <cstring>
#include <memory>
#include <algorithm>

// Tone mapping constants
constexpr float MIN_LUMA_THRESHOLD = 0.001f; // Threshold to avoid division by near-zero luminance
constexpr float REINHARD_LUMINANCE_SCALE = 0.85f; // Moderate compression for HDR content

// Helper function to calculate luminance from RGB using Rec.709 coefficients
static inline float calculate_luminance(float r, float g, float b) {
return 0.2126f * r + 0.7152f * g + 0.0722f * b;
}

cv::Mat* apply_hdr_to_sdr_tone_mapping(
const cv::Mat* src,
const uint8_t* icc_data,
size_t icc_len
)
{
if (!src || !icc_data || icc_len == 0) {
return nullptr;
}

// Only support 8-bit RGB or RGBA
int channels = src->channels();
if (src->depth() != CV_8U || (channels != 3 && channels != 4)) {
return nullptr;
}

// Load ICC profile
// NOTE: it may not be reliable to use just the icc profile for detecting HDR. some formats contain
// flags and not profiles, but going to go with this for a first pass
cmsHPROFILE src_profile = cmsOpenProfileFromMem(icc_data, icc_len);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it may not be reliable to use just the icc profile for detecting HDR. some formats contain flags and not profiles ... would recommend looking more into this. This possibly could get complex so it's probably fine to narrow the scope to what we've been seeing in the wild as long as we make a note of it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah jesus. okay, i'm going to kick this can down the road a bit for now

if (!src_profile) {
return nullptr;
}

// Check if this is a PQ/HDR profile
char profile_desc[256] = {0};
cmsGetProfileInfoASCII(src_profile, cmsInfoDescription, "en", "US", profile_desc, sizeof(profile_desc));

bool is_pq_profile = (strstr(profile_desc, "PQ") != nullptr ||
strstr(profile_desc, "2100") != nullptr ||
strstr(profile_desc, "2020") != nullptr);

// Done with profile, we only needed it for PQ detection
cmsCloseProfile(src_profile);

// If not PQ, just return a copy unchanged
if (!is_pq_profile) {
return new cv::Mat(*src);
}

// Handle alpha channel separately - alpha should NOT be tone mapped
bool has_alpha = (channels == 4);
std::unique_ptr<cv::Mat> bgr_only;
std::unique_ptr<cv::Mat> alpha_channel;
const cv::Mat* src_for_transform = src;

if (has_alpha) {
bgr_only = std::make_unique<cv::Mat>(src->rows, src->cols, CV_8UC3);
alpha_channel = std::make_unique<cv::Mat>(src->rows, src->cols, CV_8UC1);

cv::Mat channels_split[4];
cv::split(*src, channels_split);

cv::Mat bgr_channels[3] = {channels_split[0], channels_split[1], channels_split[2]};
cv::merge(bgr_channels, 3, *bgr_only);
*alpha_channel = channels_split[3];

src_for_transform = bgr_only.get();
}

// Analyze image brightness to adaptively tune tone mapping scale
// Calculate average luminance across the image
float total_luma = 0.0f;
int pixel_count = src_for_transform->rows * src_for_transform->cols;

for (int y = 0; y < src_for_transform->rows; y++) {
const uint8_t* src_row = src_for_transform->ptr<uint8_t>(y);
for (int x = 0; x < src_for_transform->cols; x++) {
int idx = x * 3;
float b = src_row[idx + 0] / 255.0f;
float g = src_row[idx + 1] / 255.0f;
float r = src_row[idx + 2] / 255.0f;
total_luma += calculate_luminance(r, g, b);
}
}
float avg_brightness = total_luma / pixel_count;

// Adaptive scale factor: brighter images get more compression (lower scale)
// Map brightness [0.0-1.0] to scale [0.85-1.1]
// Very bright images (0.7+) get compression (0.85-0.92)
// Moderate images (0.3-0.7) get balanced treatment (0.92-1.02)
// Dark images (0.0-0.3) get slight boost (1.02-1.1)
float adaptive_scale = 1.1f - (avg_brightness * 0.25f);
adaptive_scale = std::max(0.85f, std::min(1.1f, adaptive_scale));

// Apply Reinhard tone mapping
// Tried to use OpenCV's built in tone mapping, but ran into issues with
// dimming blown out/deep fried images. Using this as a first pass
std::unique_ptr<cv::Mat> dst_bgr = std::make_unique<cv::Mat>(src_for_transform->rows, src_for_transform->cols, CV_8UC3);

// Apply luminance-based tone mapping to preserve color relationships
// This prevents oversaturation by operating on brightness only
for (int y = 0; y < src_for_transform->rows; y++) {
const uint8_t* src_row = src_for_transform->ptr<uint8_t>(y);
uint8_t* dst_row = dst_bgr->ptr<uint8_t>(y);

for (int x = 0; x < src_for_transform->cols; x++) {
int idx = x * 3;
// BGR order
float b = src_row[idx + 0] / 255.0f;
float g = src_row[idx + 1] / 255.0f;
float r = src_row[idx + 2] / 255.0f;

// Calculate luminance using Rec.709 coefficients
float luma = calculate_luminance(r, g, b);

// Apply Reinhard tone mapping to luminance only with adaptive scale
float luma_scaled = luma * adaptive_scale;
float luma_mapped = luma_scaled / (1.0f + luma_scaled);

// Scale RGB channels by the luminance ratio to preserve color
float ratio = (luma > MIN_LUMA_THRESHOLD) ? (luma_mapped / luma) : 0.0f;

dst_row[idx + 0] = static_cast<uint8_t>(std::min(b * ratio * 255.0f, 255.0f));
dst_row[idx + 1] = static_cast<uint8_t>(std::min(g * ratio * 255.0f, 255.0f));
dst_row[idx + 2] = static_cast<uint8_t>(std::min(r * ratio * 255.0f, 255.0f));
}
}

if (has_alpha) {
auto result = std::make_unique<cv::Mat>(src->rows, src->cols, src->type());
cv::Mat bgr_channels_out[3];
cv::split(*dst_bgr, bgr_channels_out);
cv::Mat final_channels[4] = {bgr_channels_out[0], bgr_channels_out[1], bgr_channels_out[2], *alpha_channel};
cv::merge(final_channels, 4, *result);
return result.release();
} else {
return dst_bgr.release();
}
}

// C FFI wrapper for tone mapping
extern "C" {

opencv_mat apply_tone_mapping_ffi(
const opencv_mat src,
const uint8_t* icc_data,
size_t icc_len
)
{
auto mat = static_cast<const cv::Mat*>(src);
if (!mat || mat->empty()) {
return nullptr;
}

if (!icc_data || icc_len == 0) {
// No ICC profile, just return a copy
return new cv::Mat(*mat);
}

return apply_hdr_to_sdr_tone_mapping(mat, icc_data, icc_len);
}

}
50 changes: 50 additions & 0 deletions tone_mapping.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#pragma once

#include <stddef.h>
#include <stdint.h>

// FFI-safe type for opencv_mat
typedef void* opencv_mat;

#ifdef __cplusplus
extern "C" {
#endif

/**
* C wrapper for tone mapping - safe for FFI calls from Go/Rust.
* Returns a new opencv_mat (caller must release with opencv_mat_release).
* Returns NULL on error.
*/
opencv_mat apply_tone_mapping_ffi(
const opencv_mat src,
const uint8_t* icc_data,
size_t icc_len
);

#ifdef __cplusplus
}
#endif

// C++ implementation details (not visible to C/FFI)
#ifdef __cplusplus
#include <opencv2/opencv.hpp>
#include <lcms2.h>

/**
* Applies HDR to SDR tone mapping using Reinhard algorithm.
*
* This function detects PQ (Perceptual Quantizer) HDR profiles and applies
* luminance-based tone mapping to reduce brightness while preserving color
* relationships. Non-PQ images are returned unchanged (as a copy).
*
* @param src Source image (BGR or BGRA format, 8-bit)
* @param icc_data ICC profile data from the source image
* @param icc_len Length of ICC profile data in bytes
* @return Tone-mapped image (caller owns the pointer), or nullptr on error
*/
cv::Mat* apply_hdr_to_sdr_tone_mapping(
const cv::Mat* src,
const uint8_t* icc_data,
size_t icc_len
);
#endif