Add withGainMap to process HDR JPEGs with embedded gain map #4314

This commit is contained in:
Lovell Fuller
2025-12-19 15:04:19 +00:00
parent f6cdd36559
commit aaeded2b67
16 changed files with 197 additions and 2 deletions

View File

@@ -50,6 +50,7 @@ A `Promise` is returned when `callback` is not provided.
- `tifftagPhotoshop`: Buffer containing raw TIFFTAG_PHOTOSHOP data, if present
- `formatMagick`: String containing format for images loaded via *magick
- `comments`: Array of keyword/text pairs representing PNG text blocks, if present.
- `gainMap.image`: HDR gain map, if present, as compressed JPEG image.

View File

@@ -250,6 +250,27 @@ const outputWithP3 = await sharp(input)
```
## withGainMap
> withGainMap() ⇒ <code>Sharp</code>
If the input contains gain map metadata, use it to convert the main image to HDR (High Dynamic Range) before further processing.
The input gain map is discarded.
If the output is JPEG, generate and attach a new ISO 21496-1 gain map.
JPEG output options other than `quality` are ignored.
This feature is experimental and the API may change.
**Since**: 0.35.0
**Example**
```js
const outputWithGainMap = await sharp(inputWithGainMap)
.withGainMap()
.toBuffer();
```
## keepXmp
> keepXmp() ⇒ <code>Sharp</code>

View File

@@ -6,3 +6,6 @@ slug: changelog/v0.35.0
* Upgrade to libvips v8.18.0 for upstream bug fixes.
* Drop support for Node.js 18, now requires Node.js >= 20.9.0.
* Add `withGainMap` to process HDR JPEG images with embedded gain maps.
[#4314](https://github.com/lovell/sharp/issues/4314)

View File

@@ -313,6 +313,7 @@ const Sharp = function (input, options) {
withExif: {},
withExifMerge: true,
withXmp: '',
withGainMap: false,
resolveWithObject: false,
loop: -1,
delay: [],

7
lib/index.d.ts vendored
View File

@@ -1277,6 +1277,8 @@ declare namespace sharp {
formatMagick?: string | undefined;
/** Array of keyword/text pairs representing PNG text blocks, if present. */
comments?: CommentsMetadata[] | undefined;
/** HDR gain map, if present */
gainMap?: GainMapMetadata | undefined;
}
interface LevelMetadata {
@@ -1289,6 +1291,11 @@ declare namespace sharp {
text: string;
}
interface GainMapMetadata {
/** JPEG image */
image: Buffer;
}
interface Stats {
/** Array of channel statistics for each channel in the image. */
channels: ChannelStats[];

View File

@@ -607,6 +607,7 @@ function _isStreamInput () {
* - `tifftagPhotoshop`: Buffer containing raw TIFFTAG_PHOTOSHOP data, if present
* - `formatMagick`: String containing format for images loaded via *magick
* - `comments`: Array of keyword/text pairs representing PNG text blocks, if present.
* - `gainMap.image`: HDR gain map, if present, as compressed JPEG image.
*
* @example
* const metadata = await sharp(input).metadata();

View File

@@ -319,6 +319,30 @@ function withIccProfile (icc, options) {
return this;
}
/**
* If the input contains gain map metadata, use it to convert the main image to HDR (High Dynamic Range) before further processing.
* The input gain map is discarded.
*
* If the output is JPEG, generate and attach a new ISO 21496-1 gain map.
* JPEG output options other than `quality` are ignored.
*
* This feature is experimental and the API may change.
*
* @since 0.35.0
*
* @example
* const outputWithGainMap = await sharp(inputWithGainMap)
* .withGainMap()
* .toBuffer();
*
* @returns {Sharp}
*/
function withGainMap() {
this.options.withGainMap = true;
this.options.colourspace = 'scrgb';
return this;
}
/**
* Keep XMP metadata from the input image in the output image.
*
@@ -1640,6 +1664,7 @@ module.exports = (Sharp) => {
withExifMerge,
keepIccProfile,
withIccProfile,
withGainMap,
keepXmp,
withXmp,
keepMetadata,

View File

@@ -289,6 +289,7 @@ namespace sharp {
case ImageType::JXL: id = "jxl"; break;
case ImageType::RAD: id = "rad"; break;
case ImageType::DCRAW: id = "dcraw"; break;
case ImageType::UHDR: id = "uhdr"; break;
case ImageType::VIPS: id = "vips"; break;
case ImageType::RAW: id = "raw"; break;
case ImageType::UNKNOWN: id = "unknown"; break;
@@ -339,6 +340,9 @@ namespace sharp {
{ "VipsForeignLoadRadBuffer", ImageType::RAD },
{ "VipsForeignLoadDcRawFile", ImageType::DCRAW },
{ "VipsForeignLoadDcRawBuffer", ImageType::DCRAW },
{ "VipsForeignLoadUhdr", ImageType::UHDR },
{ "VipsForeignLoadUhdrFile", ImageType::UHDR },
{ "VipsForeignLoadUhdrBuffer", ImageType::UHDR },
{ "VipsForeignLoadVips", ImageType::VIPS },
{ "VipsForeignLoadVipsFile", ImageType::VIPS },
{ "VipsForeignLoadRaw", ImageType::RAW }
@@ -356,6 +360,9 @@ namespace sharp {
imageType = it->second;
}
}
if (imageType == ImageType::UHDR) {
imageType = ImageType::JPEG;
}
return imageType;
}
@@ -375,6 +382,9 @@ namespace sharp {
imageType = ImageType::MISSING;
}
}
if (imageType == ImageType::UHDR) {
imageType = ImageType::JPEG;
}
return imageType;
}
@@ -1127,4 +1137,20 @@ namespace sharp {
}
return image;
}
/*
Does this image have a gain map?
*/
bool HasGainMap(VImage image) {
return image.get_typeof("gainmap-data") == VIPS_TYPE_BLOB;
}
/*
Removes gain map, if any.
*/
VImage RemoveGainMap(VImage image) {
VImage copy = image.copy();
copy.remove("gainmap-data");
return copy;
}
} // namespace sharp

View File

@@ -173,6 +173,7 @@ namespace sharp {
JXL,
RAD,
DCRAW,
UHDR,
VIPS,
RAW,
UNKNOWN,
@@ -397,6 +398,16 @@ namespace sharp {
*/
VImage StaySequential(VImage image, bool condition = true);
/*
Does this image have a gain map?
*/
bool HasGainMap(VImage image);
/*
Removes gain map, if any.
*/
VImage RemoveGainMap(VImage image);
} // namespace sharp
#endif // SRC_COMMON_H_

View File

@@ -141,6 +141,14 @@ class MetadataWorker : public Napi::AsyncWorker {
memcpy(baton->tifftagPhotoshop, tifftagPhotoshop, tifftagPhotoshopLength);
baton->tifftagPhotoshopLength = tifftagPhotoshopLength;
}
// Gain Map
if (image.get_typeof("gainmap-data") == VIPS_TYPE_BLOB) {
size_t gainMapLength;
void const *gainMap = image.get_blob("gainmap-data", &gainMapLength);
baton->gainMap = static_cast<char *>(g_malloc(gainMapLength));
memcpy(baton->gainMap, gainMap, gainMapLength);
baton->gainMapLength = gainMapLength;
}
// PNG comments
vips_image_map(image.get_image(), readPNGComment, &baton->comments);
}
@@ -276,6 +284,12 @@ class MetadataWorker : public Napi::AsyncWorker {
Napi::Buffer<char>::NewOrCopy(env, baton->tifftagPhotoshop,
baton->tifftagPhotoshopLength, sharp::FreeCallback));
}
if (baton->gainMapLength > 0) {
Napi::Object gainMap = Napi::Object::New(env);
info.Set("gainMap", gainMap);
gainMap.Set("image",
Napi::Buffer<char>::NewOrCopy(env, baton->gainMap, baton->gainMapLength, sharp::FreeCallback));
}
if (baton->comments.size() > 0) {
int i = 0;
Napi::Array comments = Napi::Array::New(env, baton->comments.size());

View File

@@ -53,6 +53,8 @@ struct MetadataBaton {
size_t xmpLength;
char *tifftagPhotoshop;
size_t tifftagPhotoshopLength;
char *gainMap;
size_t gainMapLength;
MetadataComments comments;
std::string err;
@@ -82,7 +84,9 @@ struct MetadataBaton {
xmp(nullptr),
xmpLength(0),
tifftagPhotoshop(nullptr),
tifftagPhotoshopLength(0) {}
tifftagPhotoshopLength(0),
gainMap(nullptr),
gainMapLength(0) {}
};
Napi::Value metadata(const Napi::CallbackInfo& info);

View File

@@ -296,6 +296,14 @@ class PipelineWorker : public Napi::AsyncWorker {
if (baton->input->autoOrient) {
image = sharp::RemoveExifOrientation(image);
}
if (sharp::HasGainMap(image)) {
if (baton->withGainMap) {
image = image.uhdr2scRGB();
}
image = sharp::RemoveGainMap(image);
} else {
baton->withGainMap = false;
}
// Any pre-shrinking may already have been done
inputWidth = image.width();
@@ -335,7 +343,7 @@ class PipelineWorker : public Napi::AsyncWorker {
image.interpretation() != VIPS_INTERPRETATION_LABS &&
image.interpretation() != VIPS_INTERPRETATION_GREY16 &&
baton->colourspacePipeline != VIPS_INTERPRETATION_CMYK &&
!baton->input->ignoreIcc
!baton->input->ignoreIcc && !baton->withGainMap
) {
// Convert to sRGB/P3 using embedded profile
try {
@@ -1706,6 +1714,7 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) {
}
baton->withExifMerge = sharp::AttrAsBool(options, "withExifMerge");
baton->withXmp = sharp::AttrAsStr(options, "withXmp");
baton->withGainMap = sharp::AttrAsBool(options, "withGainMap");
baton->timeoutSeconds = sharp::AttrAsUint32(options, "timeoutSeconds");
baton->loop = sharp::AttrAsUint32(options, "loop");
baton->delay = sharp::AttrAsInt32Vector(options, "delay");

View File

@@ -208,6 +208,7 @@ struct PipelineBaton {
std::unordered_map<std::string, std::string> withExif;
bool withExifMerge;
std::string withXmp;
bool withGainMap;
int timeoutSeconds;
std::vector<double> convKernel;
int convKernelWidth;
@@ -381,6 +382,7 @@ struct PipelineBaton {
withMetadataOrientation(-1),
withMetadataDensity(0.0),
withExifMerge(true),
withGainMap(false),
timeoutSeconds(0),
convKernelWidth(0),
convKernelHeight(0),

BIN
test/fixtures/gain-map.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -70,6 +70,7 @@ module.exports = {
inputJpgRandom: getPath('random.jpg'), // convert -size 200x200 xc: +noise Random random.jpg
inputJpgThRandom: getPath('thRandom.jpg'), // convert random.jpg -channel G -threshold 5% -separate +channel -negate thRandom.jpg
inputJpgLossless: getPath('testimgl.jpg'), // Lossless JPEG from ftp://ftp.fu-berlin.de/unix/X11/graphics/ImageMagick/delegates/ljpeg-6b.tar.gz
inputJpgWithGainMap: getPath('gain-map.jpg'), // https://github.com/libvips/libvips/issues/3799
inputPng: getPath('50020484-00001.png'), // http://c.searspartsdirect.com/lis_png/PLDM/50020484-00001.png
inputPngGradients: getPath('gradients-rgb8.png'),

69
test/unit/gain-map.js Normal file
View File

@@ -0,0 +1,69 @@
/*!
Copyright 2013 Lovell Fuller and others.
SPDX-License-Identifier: Apache-2.0
*/
const { describe, it } = require('node:test');
const sharp = require('../../');
const fixtures = require('../fixtures');
describe('Gain maps', () => {
it('Metadata contains gainMap', async (t) => {
t.plan(4);
const { format, gainMap } = await sharp(
fixtures.inputJpgWithGainMap,
).metadata();
t.assert.strictEqual(format, 'jpeg');
t.assert.strictEqual(typeof gainMap, 'object');
t.assert.ok(Buffer.isBuffer(gainMap.image));
t.assert.strictEqual(gainMap.image.length, 31738);
});
it('Can be regenerated', async (t) => {
t.plan(4);
const data = await sharp(fixtures.inputJpgWithGainMap)
.withGainMap()
.toBuffer();
const metadata = await sharp(data).metadata();
t.assert.strictEqual(metadata.format, 'jpeg');
t.assert.strictEqual(typeof metadata.gainMap, 'object');
t.assert.ok(Buffer.isBuffer(metadata.gainMap.image));
const {
format,
width,
height,
channels,
depth,
space,
hasProfile,
chromaSubsampling,
} = await sharp(metadata.gainMap.image).metadata();
t.assert.deepEqual(
{
format,
width,
height,
channels,
depth,
space,
hasProfile,
chromaSubsampling,
},
{
format: 'jpeg',
width: 1920,
height: 1080,
channels: 1,
depth: 'uchar',
space: 'b-w',
hasProfile: true,
chromaSubsampling: '4:4:4',
},
);
});
});