Skip to content

Commit fe3e9a2

Browse files
matmenclaude
andcommitted
Decode BMP, ICO/CUR, QOI, Netpbm and Radiance HDR; encode BMP, QOI, PNM
BMPs of 1-64 bits with any channel masks, alpha, RLE4/RLE8 (skipped pixels transparent like in browsers), embedded JPEGs and PNGs and OS/2 headers, matching BMP Suite's reference images. Icons and cursors decode their largest image, bitmaps with their AND masks or PNGs. QOI decoding and encoding byte for byte like the reference encoder. Netpbm: PBM, PGM, PPM (plain and raw, up to 16 bits), PAM and PFM. Radiance HDRs: RGBE and XYZE, every orientation, both run length encodings. HDRs' and PFMs' linear light is tone mapped by Reinhard's extended operator by default (the brightest pixel white, no automatic exposure, which turns night scenes grey; images no brighter than white unchanged), DecodeOptions.toneMapping: 'clamp' clamps instead. encodeBMP picks a 1/4/8 bit palette, 24 bits, or 32 bits with an alpha mask; encodePNM the smallest of PBM, PGM, PPM and PAM. Fixtures: a selection of BMP Suite (public domain), and icons, HDRs and QOIs written by scripts/format-fixtures.py with Pillow and OpenCV as references. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
1 parent f2fc48b commit fe3e9a2

74 files changed

Lines changed: 1569 additions & 9 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,28 @@
11
# Changelog
22

3-
## 3.3.1
3+
## 3.4.0
4+
5+
### Features
6+
7+
- `Image.decode` reads BMPs, ICOs and CURs, QOIs, Netpbm images (PBM, PGM, PPM, PAM, PFM) and Radiance HDRs, and
8+
`ImageType` recognizes them (`'bmp'`, `'ico'`, `'cur'`, `'qoi'`, `'pnm'`, `'hdr'`; `ImageType.isBMP` etc.):
9+
- BMPs of 1, 2, 4, 8, 16, 24, 32 and 64 bits, with channel masks of any size (scaled to full white, not shifted),
10+
alpha, RLE4 and RLE8 (pixels they skip are transparent, like in browsers), embedded JPEGs and PNGs, top-down rows,
11+
and OS/2's headers. Matches BMP Suite's reference images (32-bit channels wider than 8 bits within 1, they're
12+
rounded). Not supported: OS/2's Huffman and RLE24 compression.
13+
- Icons and cursors decode their largest image (of those as large, the one with the most bits): bitmaps with their
14+
AND masks (32-bit ones with alpha, unless it's zero everywhere), or PNGs.
15+
- Netpbm: plain and raw, samples of up to 16 bits, PAM's tuple types.
16+
- Radiance HDRs: RGBE and XYZE, every orientation, both run length encodings.
17+
- `DecodeOptions.toneMapping`: HDRs' and PFMs' linear light can be brighter than white. By default (`'reinhard'`) the
18+
brightest parts are compressed like Reinhard's extended tone mapping operator does, the brightest pixel becomes
19+
white (without the operator's automatic exposure, which turns night scenes grey); images no brighter than white
20+
stay as they are. `'clamp'` clamps everything brighter than white instead. Either way the result is sRGB.
21+
- `Image.encodeBMP`, `Image.encodeQOI` and `Image.encodePNM`:
22+
- BMPs with a palette of 2, 16 or 256 colors if an opaque image has at most that many, else 24-bit, or 32-bit with
23+
an alpha mask (a version 4 header) if the image has transparency
24+
- QOIs byte for byte like the reference encoder (3 channels if the image is opaque)
25+
- PBM for black and white images, PGM for greyscale ones, PPM, or PAM if the image has transparency
426

527
### Fixes
628

‎README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
2626
- JPEGs (baseline and progressive; grayscale, RGB, CMYK)
2727
- TIFFs (strips and tiles; uncompressed, LZW, deflate, PackBits and JPEG compressed; BigTIFF)
2828
- WebPs (lossy, lossless and with alpha; bit-exact with libwebp)
29+
- BMPs (1-64 bit, with any channel masks, alpha, RLE4 and RLE8, embedded JPEGs and PNGs; also OS/2 bitmaps)
30+
- ICOs and CURs (the largest image: bitmaps with their masks, or PNGs)
31+
- QOIs
32+
- Netpbm images: PBM, PGM, PPM (plain and raw, up to 16 bits), PAM and PFM
33+
- Radiance HDRs (RGBE and XYZE, every orientation), tone mapped to sRGB like Reinhard's operator does (or clamped,
34+
`toneMapping`)
2935
- TGAs (color mapped, truecolor and greyscale; uncompressed and run length encoded)
3036
- JPEGs, TIFFs, PNGs and WebPs are rotated as their Exif (or, for PNGs, XMP) orientation says
3137
- images larger than 268 megapixels (`maxPixels`) are rejected before decoding, so small files declaring huge images
@@ -54,6 +60,10 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
5460
- Encoding images as [PNGs](https://imagescript.matmen.dev/classes/Image.html#encode) (smallest lossless color type)
5561
, [JPEGs](https://imagescript.matmen.dev/classes/Image.html#encodejpeg)
5662
, lossless [WebPs](https://imagescript.matmen.dev/classes/Image.html#encodewebp)
63+
, [BMPs](https://imagescript.matmen.dev/classes/Image.html#encodebmp)
64+
, [QOIs](https://imagescript.matmen.dev/classes/Image.html#encodeqoi)
65+
, [Netpbm images](https://imagescript.matmen.dev/classes/Image.html#encodepnm) (PBM, PGM, PPM or PAM, whichever is
66+
smallest)
5767
and [TGAs](https://imagescript.matmen.dev/classes/Image.html#encodetga)
5868
- Encoding animations as [GIF](https://imagescript.matmen.dev/classes/Animation.html#encodegif),
5969
[APNG](https://imagescript.matmen.dev/classes/Animation.html#encodeapng) and lossless

‎THIRD_PARTY_NOTICES.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ following projects, under their own licenses. Each derived file says so in its h
1414
| [RFC 6386](https://www.rfc-editor.org/rfc/rfc6386) (VP8 Data Format and Decoding Guide, with the libvpx reference decoder) | `src/codecs/vp8-tables.ts` (probability and quantizer tables), `src/codecs/vp8.ts` (decoding procedures) | BSD-3-Clause |
1515
| [libwebp](https://chromium.googlesource.com/webm/libwebp) | `src/codecs/vp8.ts` (YUV to RGB conversion, "fancy" chroma upsampling), `src/codecs/webp.ts` (animation frame blending) | BSD-3-Clause |
1616
| [FreeType](https://freetype.org) | `src/render/hinting.ts` (behavior and parts of the logic of the TrueType interpreter) | FreeType License |
17+
| [QOI](https://github.com/phoboslab/qoi) by Dominic Szablewski | `src/codecs/qoi.ts` (the reference encoder's choices of operations) | MIT |
1718
| [Pillow](https://github.com/python-pillow/Pillow) | `src/util/resample.ts` (filter coefficients, box, bilinear and bicubic filters) | MIT-CMU |
1819
| [HarfBuzz](https://github.com/harfbuzz/harfbuzz) | `src/render/buffer.ts`, `src/render/layout.ts`, `src/render/shape.ts`, `src/render/shapers/*` (OpenType shaping: buffer, GSUB/GPOS, normalization, fallback positioning, the script shapers and syllable grammars), `scripts/unicode.js` (shaping category tables) | Old MIT |
1920
| [Universal Shaping Engine data](https://github.com/harfbuzz/harfbuzz/tree/main/src/ms-use) by Microsoft | `scripts/ms-use` (category overrides, invalid vowel sequences), the tables generated from them in `src/render/unicode-data.ts` | MIT |
@@ -159,6 +160,32 @@ interpolation of untouched points (IUP), the handling of near-perpendicular free
159160
tolerance of invalid references. FreeType is used under the FreeType License, included as
160161
[licenses/FTL.txt](licenses/FTL.txt).
161162

163+
## QOI
164+
165+
```
166+
MIT License
167+
168+
Copyright (c) 2021, 2022 Dominic Szablewski
169+
170+
Permission is hereby granted, free of charge, to any person obtaining a copy
171+
of this software and associated documentation files (the "Software"), to deal
172+
in the Software without restriction, including without limitation the rights
173+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
174+
copies of the Software, and to permit persons to whom the Software is
175+
furnished to do so, subject to the following conditions:
176+
177+
The above copyright notice and this permission notice shall be included in all
178+
copies or substantial portions of the Software.
179+
180+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
181+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
182+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
183+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
184+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
185+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
186+
SOFTWARE.
187+
```
188+
162189
## Pillow
163190

164191
```

‎package.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@
5454
"apng",
5555
"tiff",
5656
"tga",
57+
"bmp",
58+
"ico",
59+
"cur",
60+
"qoi",
61+
"netpbm",
62+
"ppm",
63+
"pgm",
64+
"pbm",
65+
"pam",
66+
"pfm",
67+
"hdr",
68+
"radiance",
5769
"animation",
5870
"text rendering",
5971
"font"

‎scripts/format-fixtures.py‎

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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

Comments
 (0)