Skip to content

Commit 82e56cc

Browse files
matmenclaude
andcommitted
JPEG XL: CMYK images as their colors times black
The black extra channel was ignored, so black ink came out white. Without applying the CMYK profile, the color channels (1 - ink) are multiplied by black, like CMYK JPEGs. scripts/jxl-cmyk.c writes a small CMYK test file with libjxl, since cjxl reads no CMYK input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent eea9f40 commit 82e56cc

7 files changed

Lines changed: 93 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,16 @@
2626
whose shown layer is inter coded the highest intra coded layer is shown if it has the image's size (else they
2727
throw).
2828
- `Image.decode` reads JPEG XLs, `Animation.decode` their animations (composited frames, durations, loop count), and
29-
`ImageType` recognizes them (`'jxl'`, `ImageType.isJXL`). Ported from jxl-oxide and libjxl: bare codestreams and
30-
the container (`jxlc`, `jxlp`), Modular (meta-adaptive trees, every predictor including the self-correcting one,
31-
reversible color transforms, palettes with delta and implicit colors, squeeze, LZ77) and VarDCT (every varblock
32-
type, chroma from luma, adaptive LF smoothing, chroma subsampled JPEG recompression), progressive passes and LF
33-
frames, Gaborish and the edge-preserving filter, patches, splines, noise, upsampling, cropped and blended frames
34-
and reference frames, alpha (also premultiplied), spot colors, the orientation. XYB images are rendered to sRGB, HDR
35-
ones (PQ, HLG, or brighter than SDR white) tone mapped with `toneMapping`; others stay in their color space. Every
36-
file of the JPEG XL conformance suite decodes within 1 of libjxl's djxl (as 8-bit sRGB). Not supported: ICC
37-
profiles aren't applied (CMYK images aren't converted), previews are skipped.
29+
`ImageType` recognizes them (`'jxl'`, `ImageType.isJXL`). Ported from jxl-oxide and libjxl: bare codestreams and the
30+
container (`jxlc`, `jxlp`), Modular (meta-adaptive trees, every predictor including the self-correcting one,
31+
reversible color transforms, palettes with delta and implicit colors, squeeze, LZ77) and VarDCT (every varblock type,
32+
chroma from luma, adaptive LF smoothing, chroma subsampled JPEG recompression), progressive passes and LF frames,
33+
Gaborish and the edge-preserving filter, patches, splines, noise, upsampling, cropped and blended frames and reference
34+
frames, alpha (also premultiplied), spot colors, CMYK, the orientation. XYB images are rendered to sRGB, HDR ones (PQ,
35+
HLG, or brighter than SDR white) tone mapped with `toneMapping`; others stay in their color space, CMYK ones become
36+
their colors times black (like CMYK JPEGs). Every file of the JPEG XL conformance suite decodes within 1 of libjxl's
37+
djxl (as 8-bit sRGB; but djxl ignores black without a CMS). Not supported: ICC profiles aren't applied, previews are
38+
skipped.
3839
- `Image.encodeWebP({ quality })` encodes lossy WebPs (VP8, with a losslessly compressed alpha plane if the image
3940
isn't opaque): ported from libwebp's encoder with its default settings, the VP8 bitstream is byte for byte the one
4041
cwebp writes for the same quality (0-100). `effort` (0-6, default 4) is cwebp's `-m`: 0-2 decide by distortion only

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
3636
- AVIFs (4:2:0, 4:2:2, 4:4:4 and monochrome, 8-12 bit; grids, alpha, crop, rotation and mirroring; the first frame
3737
of image sequences; bit-exact with dav1d and libavif), HDR ones (PQ, HLG) tone mapped to sRGB
3838
- JPEG XLs (Modular and VarDCT, lossless and lossy, every coding tool: progressive passes and LF frames, patches,
39-
splines, noise, restoration filters, upsampling, JPEG recompression, layers and blending, alpha and spot colors;
40-
animations; within 1 of libjxl's djxl on the conformance suite), XYB ones as sRGB, HDR ones tone mapped to sRGB
39+
splines, noise, restoration filters, upsampling, JPEG recompression, layers and blending, alpha and spot colors,
40+
CMYK; animations; within 1 of libjxl's djxl on the conformance suite), XYB ones as sRGB, HDR ones tone mapped to
41+
sRGB
4142
- JPEGs, TIFFs, PNGs and WebPs are rotated as their Exif (or, for PNGs, XMP) orientation says
4243
- images larger than 268 megapixels (`maxPixels`) are rejected before decoding, so small files declaring huge images
4344
can't exhaust the memory

‎scripts/jxl-cmyk.c‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Encodes a small lossless CMYK JPEG XL for tests/jxl (cjxl can't: it reads no CMYK input). Reads width * height
2+
// samples of C, M, Y, K (8-bit, 1 - ink, interleaved) from stdin, writes the JPEG XL to stdout: the color channels with
3+
// a black extra channel and, to keep the file small, the sRGB color encoding instead of a CMYK profile. Built and run by
4+
// scripts/jxl-fixtures.py:
5+
// cc -o jxl-cmyk scripts/jxl-cmyk.c -ljxl && ./jxl-cmyk 32 32 < cmyk.raw > cmyk.jxl
6+
#include <jxl/encode.h>
7+
#include <stdio.h>
8+
#include <stdlib.h>
9+
10+
int main(int argc, char **argv) {
11+
if (argc < 3) return 2;
12+
const uint32_t w = atoi(argv[1]), h = atoi(argv[2]);
13+
uint8_t *in = malloc(w * h * 4), *color = malloc(w * h * 3), *black = malloc(w * h);
14+
if (fread(in, 4, w * h, stdin) != w * h) return 1;
15+
for (size_t i = 0; i < w * h; i++) {
16+
for (int c = 0; c < 3; c++) color[3 * i + c] = in[4 * i + c];
17+
black[i] = in[4 * i + 3];
18+
}
19+
20+
JxlEncoder *enc = JxlEncoderCreate(NULL);
21+
JxlBasicInfo info;
22+
JxlEncoderInitBasicInfo(&info);
23+
info.xsize = w;
24+
info.ysize = h;
25+
info.bits_per_sample = 8;
26+
info.num_extra_channels = 1;
27+
info.uses_original_profile = JXL_TRUE;
28+
if (JxlEncoderSetBasicInfo(enc, &info)) return 1;
29+
JxlExtraChannelInfo ec;
30+
JxlEncoderInitExtraChannelInfo(JXL_CHANNEL_BLACK, &ec);
31+
ec.bits_per_sample = 8;
32+
if (JxlEncoderSetExtraChannelInfo(enc, 0, &ec)) return 1;
33+
JxlColorEncoding srgb;
34+
JxlColorEncodingSetToSRGB(&srgb, JXL_FALSE);
35+
if (JxlEncoderSetColorEncoding(enc, &srgb)) return 1;
36+
37+
JxlEncoderFrameSettings *settings = JxlEncoderFrameSettingsCreate(enc, NULL);
38+
JxlEncoderSetFrameLossless(settings, JXL_TRUE);
39+
JxlPixelFormat rgb = {3, JXL_TYPE_UINT8, JXL_NATIVE_ENDIAN, 0}, grey = {1, JXL_TYPE_UINT8, JXL_NATIVE_ENDIAN, 0};
40+
if (JxlEncoderAddImageFrame(settings, &rgb, color, w * h * 3)) return 1;
41+
if (JxlEncoderSetExtraChannelBuffer(settings, &grey, black, w * h, 0)) return 1;
42+
JxlEncoderCloseInput(enc);
43+
44+
uint8_t out[1 << 16], *next = out;
45+
size_t avail = sizeof out;
46+
if (JxlEncoderProcessOutput(enc, &next, &avail) != JXL_ENC_SUCCESS) return 1;
47+
fwrite(out, 1, next - out, stdout);
48+
JxlEncoderDestroy(enc);
49+
return 0;
50+
}

‎scripts/jxl-fixtures.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ def write_pfm(path, im):
8383
frames[0].save(tmp / 'anim.png', save_all=True, append_images=frames[1:], duration=[40, 80, 40, 120], loop=0)
8484
encode('animation', tmp / 'anim.png', '-d', '0')
8585
encode('animation-lossy', tmp / 'anim.png', '-d', '2')
86+
# CMYK (scripts/jxl-cmyk.c, a black channel): the reference is the color times black, which djxl ignores without a
87+
# CMYK profile
88+
# (1 - ink: black is the darkest channel, the colors are divided by it)
89+
rgb = np.asarray(photo(32, 32), np.float64)
90+
k = np.maximum(rgb.max(axis=2, keepdims=True), 1)
91+
cmyk = np.round(np.dstack([rgb / k * 255, k])).astype(np.uint8)
92+
subprocess.run(['cc', '-o', str(tmp / 'jxl-cmyk'), str(root / 'scripts' / 'jxl-cmyk.c'), '-ljxl'], check=True)
93+
(out / 'cmyk.jxl').write_bytes(subprocess.run([str(tmp / 'jxl-cmyk'), '32', '32'], input=cmyk.tobytes(), check=True,
94+
stdout=subprocess.PIPE).stdout)
95+
8696
# conformance files: the step of the reference's samples
8797
conformances = {'alpha_nonpremultiplied': 4, 'alpha_premultiplied': 8, 'blendmodes': 8, 'mul_no_extra_channels': 1,
8898
'animation_spline': 4}
@@ -93,6 +103,10 @@ def write_pfm(path, im):
93103
manifest = {}
94104
for jxl in sorted(out.glob('*.jxl')):
95105
png = jxl.with_suffix('.png')
106+
if jxl.stem == 'cmyk':
107+
Image.fromarray(np.round(cmyk[..., :3] * (cmyk[..., 3:] / 255)).astype(np.uint8)).save(png)
108+
print(jxl.name, jxl.stat().st_size, png.stat().st_size)
109+
continue
96110
subprocess.run([DJXL, str(jxl), str(png), '--bits_per_sample=8', '--color_space=RGB_D65_SRG_Rel_SRG', '--quiet'],
97111
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
98112
step = conformances.get(jxl.stem.replace('-', '_'))

‎src/codecs/jxl/color.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* (jxl-oxide's crates/jxl-color/src/ycbcr.rs); and the decoded image as 8-bit RGBA. Copyright (c) the JPEG XL Project
44
* Authors, BSD-3-Clause License; Copyright (c) Wonwoo Choi, MIT License (see THIRD_PARTY_NOTICES.md)
55
*/
6-
import { ImageHeader, TF_PQ, TF_HLG, PRI_BT2100, PRI_P3, EXTRA_ALPHA, EXTRA_SPOT } from './headers.ts';
6+
import { ImageHeader, TF_PQ, TF_HLG, PRI_BT2100, PRI_P3, EXTRA_ALPHA, EXTRA_SPOT, EXTRA_BLACK } from './headers.ts';
77
import { toSRGB, hdrToLinear, ToneMapping, REFERENCE_WHITE } from '../hdr.ts';
88
import type { Planes } from './frame.ts';
99

@@ -67,6 +67,21 @@ export class ColorOutput {
6767
const n = p.width * p.height, ch = p.channels, image = this.image;
6868
const numColor = ch.length - image.extraChannels.length, grey = numColor === 1;
6969
let [r, g, b] = grey ? [ch[0], ch[0], ch[0]] : ch;
70+
// CMYK (a black channel): the color channels and black are stored as 1 - ink, the color becomes their product
71+
// (without the CMYK profile, like JPEGs' CMYK)
72+
const black = image.extraChannels.findIndex(e => e.type === EXTRA_BLACK);
73+
if (black >= 0) {
74+
const k = ch[numColor + black];
75+
if (grey) r = g = b = r.slice();
76+
else [r, g, b] = [r.slice(), g.slice(), b.slice()];
77+
for (let i = 0; i < n; i++) {
78+
r[i] *= k[i];
79+
if (!grey) {
80+
g[i] *= k[i];
81+
b[i] *= k[i];
82+
}
83+
}
84+
}
7085
// spot colors (onto color images), mixed in by their solidity
7186
image.extraChannels.forEach((e, i) => {
7287
if (e.type !== EXTRA_SPOT || grey) return;

‎tests/jxl/cmyk.jxl‎

2.46 KB
Binary file not shown.

‎tests/jxl/cmyk.png‎

2.21 KB
Loading

0 commit comments

Comments
 (0)