|
| 1 | +# Writes the icons, cursors, Radiance HDRs and QOIs of tests/ico, tests/hdr and tests/qoi from a part of |
| 2 | +# tests/targets/external.png, and prints the sha256 of their pixels as the references decode them: Pillow (icons, |
| 3 | +# QOIs) and OpenCV (HDRs, converted to sRGB like ImageScript does). Needs Pillow, numpy and opencv-python-headless: |
| 4 | +# python scripts/format-fixtures.py |
| 5 | +import hashlib, io, struct |
| 6 | +from pathlib import Path |
| 7 | +import numpy as np |
| 8 | +from PIL import Image, IcoImagePlugin |
| 9 | + |
| 10 | +root = Path(__file__).resolve().parent.parent |
| 11 | +source = Image.open(root / 'tests' / 'targets' / 'external.png').convert('RGB') |
| 12 | +sha = lambda pixels: hashlib.sha256(np.ascontiguousarray(pixels, np.uint8).tobytes()).hexdigest() |
| 13 | +hashes = {} |
| 14 | + |
| 15 | + |
| 16 | +def part(size, alpha=True): |
| 17 | + """a part of the source scaled to size, with an alpha gradient and a transparent corner""" |
| 18 | + im = source.crop((180, 160, 440, 420)).resize((size, size), Image.LANCZOS).convert('RGBA') |
| 19 | + if alpha: |
| 20 | + a = np.zeros((size, size), np.uint8) |
| 21 | + for y in range(size): |
| 22 | + for x in range(size): |
| 23 | + a[y, x] = 0 if x + y < size // 3 else min(255, 64 + (x * 191) // max(1, size - 1)) |
| 24 | + im.putalpha(Image.fromarray(a)) |
| 25 | + return im |
| 26 | + |
| 27 | + |
| 28 | +# ---- icons and cursors: bitmaps of every depth with their AND masks, 32 bit ones with alpha, PNGs |
| 29 | +def dib(im, bits, zero_alpha=False): |
| 30 | + """a bitmap of an icon: the info header (twice the height), palette, pixels and AND mask, bottom up""" |
| 31 | + w, h = im.size |
| 32 | + rgba = np.asarray(im) |
| 33 | + palette = b'' |
| 34 | + if bits <= 8: |
| 35 | + q = im.convert('RGB').quantize(1 << bits, dither=Image.Dither.NONE) |
| 36 | + colors = q.getpalette()[:3 << bits] + [0] * (3 * (1 << bits) - len(q.getpalette()[:3 << bits])) |
| 37 | + palette = b''.join(bytes([colors[i * 3 + 2], colors[i * 3 + 1], colors[i * 3], 0]) for i in range(1 << bits)) |
| 38 | + indices = np.asarray(q) |
| 39 | + stride = (w * bits + 31) // 32 * 4 |
| 40 | + rows = b'' |
| 41 | + for y in reversed(range(h)): |
| 42 | + row = bytearray(stride) |
| 43 | + for x in range(w): |
| 44 | + r, g, b, a = (int(v) for v in rgba[y, x]) |
| 45 | + if bits <= 8: |
| 46 | + bit = x * bits |
| 47 | + row[bit >> 3] |= int(indices[y, x]) << (8 - bits - (bit & 7)) |
| 48 | + elif bits == 24: row[x * 3:x * 3 + 3] = bytes([b, g, r]) |
| 49 | + else: row[x * 4:x * 4 + 4] = bytes([b, g, r, 0 if zero_alpha else a]) |
| 50 | + rows += bytes(row) |
| 51 | + mask_stride = (w + 31) // 32 * 4 |
| 52 | + mask = b'' |
| 53 | + for y in reversed(range(h)): |
| 54 | + row = bytearray(mask_stride) |
| 55 | + for x in range(w): |
| 56 | + if rgba[y, x, 3] < 128: row[x >> 3] |= 128 >> (x & 7) |
| 57 | + mask += bytes(row) |
| 58 | + header = struct.pack('<IiiHHIIiiII', 40, w, h * 2, 1, bits, 0, len(rows) + len(mask), 0, 0, 1 << bits if bits <= 8 else 0, 0) |
| 59 | + return header + palette + rows + mask |
| 60 | + |
| 61 | + |
| 62 | +def png(im): |
| 63 | + out = io.BytesIO() |
| 64 | + im.save(out, 'PNG') |
| 65 | + return out.getvalue() |
| 66 | + |
| 67 | + |
| 68 | +def icon(entries, cursor=False): |
| 69 | + """entries: (width, height, bits, data); cursors have a hot spot instead of planes and bits""" |
| 70 | + out = struct.pack('<HHH', 0, 2 if cursor else 1, len(entries)) |
| 71 | + offset = 6 + 16 * len(entries) |
| 72 | + for i, (w, h, bits, data) in enumerate(entries): |
| 73 | + a, b = (3 + i, 5) if cursor else (1, bits) |
| 74 | + out += struct.pack('<BBBBHHII', w & 255, h & 255, 0, 0, a, b, len(data), offset) |
| 75 | + offset += len(data) |
| 76 | + return out + b''.join(e[3] for e in entries) |
| 77 | + |
| 78 | + |
| 79 | +ico = root / 'tests' / 'ico' |
| 80 | +ico.mkdir(exist_ok=True) |
| 81 | +im32, im48 = part(32), part(48) |
| 82 | +files = { |
| 83 | + '1bit.ico': [(32, 32, 1, dib(im32, 1))], |
| 84 | + '4bit.ico': [(32, 32, 4, dib(im32, 4))], |
| 85 | + '8bit.ico': [(32, 32, 8, dib(im32, 8))], |
| 86 | + '24bit.ico': [(32, 32, 24, dib(im32, 24))], |
| 87 | + '32bit.ico': [(32, 32, 32, dib(im32, 32))], |
| 88 | + # alpha that is zero everywhere: the AND mask says what's transparent, like for 24 bits |
| 89 | + '32bit-zero-alpha.ico': [(32, 32, 32, dib(im32, 32, zero_alpha=True))], |
| 90 | + # the largest image, of those as large the one with the most bits, not the first |
| 91 | + 'sizes.ico': [(16, 16, 8, dib(part(16), 8)), (48, 48, 4, dib(im48, 4)), (48, 48, 32, dib(im48, 32)), (24, 24, 32, png(part(24)))], |
| 92 | + 'png.ico': [(16, 16, 32, dib(part(16), 32)), (64, 64, 32, png(part(64)))], |
| 93 | + 'cursor.cur': [(32, 32, 32, dib(im32, 32)), (16, 16, 4, dib(part(16), 4))], |
| 94 | +} |
| 95 | +for name, entries in files.items(): |
| 96 | + (ico / name).write_bytes(icon(entries, name.endswith('.cur'))) |
| 97 | + with open(ico / name, 'rb') as f: |
| 98 | + if name == '32bit-zero-alpha.ico': |
| 99 | + continue # (Pillow makes it all transparent) |
| 100 | + w, h, bits, _ = max(entries, key=lambda e: (e[0] * e[1], e[2])) |
| 101 | + im = (Image.open(f) if name.endswith('.cur') else IcoImagePlugin.IcoFile(f).getimage((w, h), bits)).convert('RGBA') |
| 102 | + a = np.asarray(im).copy() |
| 103 | + # the pixels the AND mask makes transparent are transparent black |
| 104 | + if bits < 32: a[a[..., 3] == 0] = 0 |
| 105 | + hashes[f'ico/{name}'] = sha(a) |
| 106 | + |
| 107 | +# ---- Radiance HDR: values above 1 (clamped), zeros, runs; the same pixels in every orientation and encoding |
| 108 | +hdr = root / 'tests' / 'hdr' |
| 109 | +hdr.mkdir(exist_ok=True) |
| 110 | +W, H = 23, 9 |
| 111 | +linear = (np.asarray(part(W, alpha=False).convert('RGB').resize((W, H)), np.float64) / 255) ** 2.2 * 1.6 |
| 112 | +linear[:, 15:] = linear[:, 15:16] # runs |
| 113 | +linear[0, :4] = 0 |
| 114 | + |
| 115 | + |
| 116 | +def rgbe(v): |
| 117 | + """floats to RGBE like Radiance (truncated mantissas)""" |
| 118 | + m = v.max(axis=-1) |
| 119 | + out = np.zeros(v.shape[:-1] + (4,), np.uint8) |
| 120 | + nz = m > 1e-32 |
| 121 | + mant, exp = np.frexp(m[nz]) |
| 122 | + scale = mant * 256 / m[nz] |
| 123 | + out[nz, :3] = (v[nz] * scale[:, None]).astype(np.uint8) |
| 124 | + out[nz, 3] = exp + 128 |
| 125 | + return out |
| 126 | + |
| 127 | + |
| 128 | +def rle_line(line): |
| 129 | + """a scan line run length encoded by channel""" |
| 130 | + out = bytes([2, 2, len(line) >> 8, len(line) & 255]) |
| 131 | + for c in range(4): |
| 132 | + v, i = line[:, c], 0 |
| 133 | + while i < len(v): |
| 134 | + run = 1 |
| 135 | + while i + run < len(v) and run < 127 and v[i + run] == v[i]: run += 1 |
| 136 | + if run >= 4: |
| 137 | + out += bytes([128 + run, v[i]]); i += run |
| 138 | + else: |
| 139 | + n = 1 |
| 140 | + while i + n < len(v) and n < 128 and not (i + n + 3 < len(v) and v[i + n] == v[i + n + 1] == v[i + n + 2] == v[i + n + 3]): n += 1 |
| 141 | + out += bytes([n]) + bytes(v[i:i + n]); i += n |
| 142 | + return out |
| 143 | + |
| 144 | + |
| 145 | +def write_hdr(name, pixels, resolution, lines=None, fmt='32-bit_rle_rgbe', encode=rle_line): |
| 146 | + """pixels: RGBE in display order; lines: the scan lines in file order (default: rows top down)""" |
| 147 | + head = f'#?RADIANCE\n# ImageScript test\nFORMAT={fmt}\nEXPOSURE=1.0\n\n{resolution}\n'.encode() |
| 148 | + (hdr / name).write_bytes(head + b''.join(encode(l) for l in (pixels if lines is None else lines))) |
| 149 | + |
| 150 | + |
| 151 | +pixels = rgbe(linear) |
| 152 | +write_hdr('rle.hdr', pixels, f'-Y {H} +X {W}') |
| 153 | +write_hdr('flat.hdr', pixels, f'-Y {H} +X {W}', encode=lambda l: l.tobytes()) |
| 154 | +write_hdr('bottom-up.hdr', pixels, f'+Y {H} +X {W}', pixels[::-1]) |
| 155 | +write_hdr('mirrored.hdr', pixels, f'-Y {H} -X {W}', pixels[:, ::-1]) |
| 156 | +write_hdr('columns.hdr', pixels, f'+X {W} -Y {H}', pixels.transpose(1, 0, 2)) |
| 157 | +write_hdr('columns-flipped.hdr', pixels, f'-X {W} +Y {H}', pixels.transpose(1, 0, 2)[::-1, ::-1]) |
| 158 | + |
| 159 | + |
| 160 | +# the old run length encoding: 1, 1, 1, n repeats the previous pixel n times, consecutive ones n << 8 more |
| 161 | +def old_rle(line): |
| 162 | + out, i = b'', 0 |
| 163 | + while i < len(line): |
| 164 | + out += line[i].tobytes() |
| 165 | + j = i + 1 |
| 166 | + while j < len(line) and (line[j] == line[i]).all(): j += 1 |
| 167 | + run = j - i - 1 |
| 168 | + while run: |
| 169 | + out += bytes([1, 1, 1, run & 255]); run >>= 8 |
| 170 | + i = j |
| 171 | + return out |
| 172 | + |
| 173 | + |
| 174 | +write_hdr('old-rle.hdr', pixels, f'-Y {H} +X {W}', encode=old_rle) |
| 175 | +# up to 40 times brighter than white, for the tone mapping |
| 176 | +bright = rgbe(linear * 25) |
| 177 | +write_hdr('bright.hdr', bright, f'-Y {H} +X {W}') |
| 178 | +long = np.tile(rgbe(np.array([[0.2, 0.5, 0.9]])), (2, 600, 1)) |
| 179 | +long[1, 300:] = rgbe(np.array([[1.0, 0.25, 0.0]])) |
| 180 | +write_hdr('old-rle-long.hdr', long, f'-Y 2 +X 600', encode=old_rle) |
| 181 | + |
| 182 | +# XYZE: the pixels as CIE XYZ, by Radiance's primaries and equal energy white |
| 183 | +prim = np.array([[0.640, 0.330], [0.290, 0.600], [0.150, 0.060]]) |
| 184 | +P = np.array([[x / y, 1, (1 - x - y) / y] for x, y in prim]).T |
| 185 | +M = P * np.linalg.solve(P, np.ones(3)) |
| 186 | +write_hdr('xyze.hdr', rgbe(linear @ M.T), f'-Y {H} +X {W}', fmt='32-bit_rle_xyze') |
| 187 | + |
| 188 | + |
| 189 | +def srgb(v): |
| 190 | + v = np.clip(v, 0, 1) |
| 191 | + return np.round(255 * np.where(v <= 0.0031308, 12.92 * v, 1.055 * v ** (1 / 2.4) - 0.055)) |
| 192 | + |
| 193 | + |
| 194 | +def decoded(pixels, reinhard=True): |
| 195 | + """RGBE as ImageScript decodes it: linear light, tone mapped by Reinhard's extended operator (the brightest |
| 196 | + luminance, at least 1, is white) or clamped, to sRGB, opaque""" |
| 197 | + e = pixels[..., 3:].astype(np.float64) |
| 198 | + v = np.where(e > 0, pixels[..., :3] * 2.0 ** (e - 136), 0) |
| 199 | + if reinhard: |
| 200 | + lum = np.maximum(0, v @ [0.2126, 0.7152, 0.0722]) |
| 201 | + white = max(1, lum.max()) |
| 202 | + v = v * ((1 + lum / white ** 2) / (1 + lum))[..., None] |
| 203 | + return np.concatenate([srgb(v), np.full(v.shape[:2] + (1,), 255)], axis=2) |
| 204 | + |
| 205 | + |
| 206 | +# OpenCV reads the same (it can't read the old run length encoding) |
| 207 | +import cv2 |
| 208 | +f = cv2.imread(str(hdr / 'rle.hdr'), cv2.IMREAD_UNCHANGED) |
| 209 | +assert (srgb(f[..., ::-1]) == decoded(pixels, False)[..., :3]).all() |
| 210 | +hashes['hdr/rle.hdr'] = sha(decoded(pixels)) |
| 211 | +hashes['hdr/bright.hdr (clamp, as OpenCV)'] = sha(decoded(bright, False)) |
| 212 | +f = cv2.imread(str(hdr / 'bright.hdr'), cv2.IMREAD_UNCHANGED) |
| 213 | +assert (srgb(f[..., ::-1]) == decoded(bright, False)[..., :3]).all() |
| 214 | +hashes['hdr/bright.hdr (reinhard)'] = sha(decoded(bright)) |
| 215 | +hashes['hdr/old-rle-long.hdr'] = sha(decoded(long)) |
| 216 | + |
| 217 | +# ---- QOI: Pillow writes the same as the reference encoder except the colorspace byte (it says linear) |
| 218 | +qoi = root / 'tests' / 'qoi' |
| 219 | +qoi.mkdir(exist_ok=True) |
| 220 | +for name, im in [('rgba.qoi', part(64)), ('rgb.qoi', part(64, alpha=False).convert('RGB'))]: |
| 221 | + out = io.BytesIO() |
| 222 | + im.save(out, 'QOI') |
| 223 | + data = bytearray(out.getvalue()) |
| 224 | + data[13] = 0 |
| 225 | + (qoi / name).write_bytes(data) |
| 226 | + im.save(qoi / name.replace('.qoi', '.png')) |
| 227 | + hashes[f'qoi/{name}'] = sha(np.asarray(Image.open(qoi / name).convert('RGBA'))) |
| 228 | + |
| 229 | +for name, digest in hashes.items(): print(f"'{name}': '{digest}',") |
0 commit comments