|
| 1 | +# Writes the JPEG XLs of tests/jxl and their references: parts of tests/targets encoded by cjxl (libjxl) with its coding |
| 2 | +# tools (lossless efforts, palettes, VarDCT, lossy Modular, alpha, progressive passes and LF frames, resampling, photon |
| 3 | +# noise, patches, JPEG recompression, animations, the container), a few conformance files (github.com/libjxl/conformance, |
| 4 | +# BSD-3-Clause, for what cjxl doesn't write: splines, blend modes, small trees, premultiplied alpha), and what djxl decodes them to as 8-bit |
| 5 | +# sRGB PNGs (APNGs for animations; premultiplied alpha as djxl writes it). Needs Pillow, cjxl and djxl: |
| 6 | +# CJXL=path/to/cjxl DJXL=path/to/djxl CONFORMANCE=path/to/conformance python scripts/jxl-fixtures.py |
| 7 | +import json, os, shutil, struct, subprocess, tempfile |
| 8 | +from pathlib import Path |
| 9 | +import numpy as np |
| 10 | +from PIL import Image, ImageDraw |
| 11 | + |
| 12 | +root = Path(__file__).resolve().parent.parent |
| 13 | +out = root / 'tests' / 'jxl' |
| 14 | +out.mkdir(exist_ok=True) |
| 15 | +CJXL, DJXL = os.environ.get('CJXL', 'cjxl'), os.environ.get('DJXL', 'djxl') |
| 16 | +conformance = Path(os.environ.get('CONFORMANCE', 'conformance')) |
| 17 | +tmp = Path(tempfile.mkdtemp()) |
| 18 | + |
| 19 | + |
| 20 | +def source(name, w, h, box=None, mode='RGB'): |
| 21 | + """a part of a test target scaled to w x h""" |
| 22 | + im = Image.open(root / 'tests' / 'targets' / name).convert('RGBA') |
| 23 | + return im.crop(box or (0, 0) + im.size).resize((w, h), Image.LANCZOS).convert(mode) |
| 24 | + |
| 25 | + |
| 26 | +def encode(name, im, *args, fmt='png', **save): |
| 27 | + src = tmp / f'{name}.{fmt}' |
| 28 | + if isinstance(im, Path): |
| 29 | + src = im |
| 30 | + else: |
| 31 | + im.save(src, **save) |
| 32 | + subprocess.run([CJXL, '--quiet', str(src), str(out / f'{name}.jxl'), *args], check=True, stdout=subprocess.DEVNULL) |
| 33 | + |
| 34 | + |
| 35 | +def write_pfm(path, im): |
| 36 | + """a float PFM of an RGB image (bottom up)""" |
| 37 | + w, h = im.size |
| 38 | + data = b''.join(struct.pack('<3f', *(v / 255 for v in im.getpixel((x, y)))) for y in reversed(range(h)) for x in range(w)) |
| 39 | + path.write_bytes(f'PF\n{w} {h}\n-1.0\n'.encode() + data) |
| 40 | + |
| 41 | + |
| 42 | +photo = lambda w, h: source('external.png', w, h, (200, 120, 520, 440)) |
| 43 | +text = lambda w, h: source('readme.png', w, h, (40, 40, 40 + w, 40 + h)) |
| 44 | + |
| 45 | +# lossless (Modular): the fast efforts (gradient, RLE), MA trees and the weighted predictor, palettes, alpha, 16 bits, |
| 46 | +# grey, floats |
| 47 | +encode('lossless-e1', photo(64, 48), '-d', '0', '-e', '1') |
| 48 | +encode('lossless-e3', photo(64, 48), '-d', '0', '-e', '3') |
| 49 | +encode('lossless-e7', photo(64, 48), '-d', '0', '-e', '7') |
| 50 | +encode('lossless-alpha', source('circle.png', 48, 48, mode='RGBA'), '-d', '0', '-e', '9') |
| 51 | +encode('palette', text(96, 64), '-d', '0', '-e', '7') |
| 52 | +# (16 bits: the grey values spread by a gradient over the low byte) |
| 53 | +grey = np.asarray(photo(48, 48).convert('L'), np.uint16) * 256 + np.arange(48, dtype=np.uint16)[None, :] * 5 |
| 54 | +encode('grey16', Image.fromarray(grey.astype('<u2'), 'I;16'), '-d', '0', '-e', '7') |
| 55 | +write_pfm(tmp / 'float.pfm', photo(32, 24)) |
| 56 | +encode('float', tmp / 'float.pfm', '-d', '0', fmt='pfm') |
| 57 | +# lossy: VarDCT (Gaborish and the edge-preserving filter by default), larger varblocks, alpha, |
| 58 | +# grey, lossy Modular (squeeze), progressive passes and LF frames, resampling, photon noise, patches |
| 59 | +encode('lossy', photo(64, 48), '-d', '1') |
| 60 | +encode('lossy-d4', photo(64, 48), '-d', '4', '-e', '7', '--epf=3') |
| 61 | +encode('lossy-alpha', source('circle.png', 64, 64, mode='RGBA'), '-d', '1.5') |
| 62 | +encode('lossy-grey', photo(64, 64).convert('L'), '-d', '1') |
| 63 | +encode('lossy-modular', photo(64, 48), '--modular=1', '-d', '1') |
| 64 | +encode('progressive', photo(64, 48), '-d', '1', '-p') |
| 65 | +encode('lf-frame', photo(64, 48), '-d', '1', '--progressive_dc=1') |
| 66 | +encode('resampling', photo(64, 48), '-d', '2', '--resampling=2') |
| 67 | +encode('resampling8', photo(64, 48), '-d', '2', '--resampling=8') |
| 68 | +encode('noise', photo(64, 48), '-d', '1', '--photon_noise_iso=12800') |
| 69 | +# (patches of repeated text on a flat background, from a reference frame) |
| 70 | +page = Image.new('RGB', (160, 72), (250, 250, 245)) |
| 71 | +for y in range(4, 72, 14): |
| 72 | + ImageDraw.Draw(page).text((4, y), 'JPEG XL patches ImageScript', fill=(20, 20, 60)) |
| 73 | +encode('patches', page, '-d', '1', '-e', '7', '--patches=1') |
| 74 | +encode('container', photo(48, 32), '-d', '1', '--container=1') |
| 75 | +# JPEG recompression (chroma subsampled, grey) |
| 76 | +photo(64, 48).save(tmp / 'in.jpg', quality=70, subsampling=2) |
| 77 | +encode('jpeg', tmp / 'in.jpg', fmt='jpg') |
| 78 | +photo(64, 48).convert('L').save(tmp / 'grey.jpg', quality=70) |
| 79 | +encode('jpeg-grey', tmp / 'grey.jpg', fmt='jpg') |
| 80 | +# animations (from an APNG with alpha and a timing) |
| 81 | +frames = [source('circle.png', 32, 32, (i * 40, 0, 512, 512 - i * 40), 'RGBA') for i in range(4)] |
| 82 | +frames[0].save(tmp / 'anim.png', save_all=True, append_images=frames[1:], duration=[40, 80, 40, 120], loop=0) |
| 83 | +encode('animation', tmp / 'anim.png', '-d', '0') |
| 84 | +encode('animation-lossy', tmp / 'anim.png', '-d', '2') |
| 85 | +# conformance files: [the step of the reference's samples, its frames] |
| 86 | +conformances = {'alpha_nonpremultiplied': [4, [0]], 'alpha_premultiplied': [8, [0]], 'alpha_triangles': [4, [0]], 'blendmodes': [8, [0]], 'sunset_logo': [11, [0]], |
| 87 | + 'mul_no_extra_channels': [1, [0]], 'animation_spline': [4, [0, 17, 59]]} |
| 88 | +for name in conformances: |
| 89 | + shutil.copy(conformance / 'testcases' / name / 'input.jxl', out / f'{name.replace("_", "-")}.jxl') |
| 90 | + |
| 91 | +# the references (the large conformance ones as every step-th sample of some frames, stacked) |
| 92 | +manifest = {} |
| 93 | +for jxl in sorted(out.glob('*.jxl')): |
| 94 | + png = jxl.with_suffix('.png') |
| 95 | + subprocess.run([DJXL, str(jxl), str(png), '--bits_per_sample=8', '--color_space=RGB_D65_SRG_Rel_SRG', '--quiet'], |
| 96 | + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) |
| 97 | + sampled = conformances.get(jxl.stem.replace('-', '_')) |
| 98 | + if sampled: |
| 99 | + step, frames = sampled |
| 100 | + im = Image.open(png) |
| 101 | + parts = [] |
| 102 | + for f in frames: |
| 103 | + im.seek(f) |
| 104 | + parts.append(np.asarray(im.convert('RGBA'))[::step, ::step]) |
| 105 | + im.close() |
| 106 | + Image.fromarray(np.concatenate(parts)).save(png, optimize=True) |
| 107 | + manifest[jxl.stem] = {'step': step, 'frames': frames} |
| 108 | + print(jxl.name, jxl.stat().st_size, png.stat().st_size) |
| 109 | +(out / 'sampled.json').write_text(json.dumps(manifest, indent=4) + '\n') |
0 commit comments