test.sh: replace imagemagick with custom python script to produce bit-by-bit identical results on all architectures
This commit is contained in:
parent
8d7996709a
commit
d1f101c36a
2 changed files with 324 additions and 40 deletions
306
magick.py
Normal file
306
magick.py
Normal file
|
@ -0,0 +1,306 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import numpy
|
||||
import scipy.signal
|
||||
import zlib
|
||||
import struct
|
||||
|
||||
|
||||
def find_closest_palette_color(color, palette):
|
||||
if color.ndim == 0:
|
||||
idx = (numpy.abs(palette - color)).argmin()
|
||||
else:
|
||||
# naive distance function by computing the euclidean distance in RGB space
|
||||
idx = ((palette - color) ** 2).sum(axis=-1).argmin()
|
||||
return palette[idx]
|
||||
|
||||
|
||||
def floyd_steinberg(img, palette):
|
||||
for y in range(img.shape[0]):
|
||||
for x in range(img.shape[1]):
|
||||
oldpixel = img[y, x]
|
||||
newpixel = find_closest_palette_color(oldpixel, palette)
|
||||
quant_error = oldpixel - newpixel
|
||||
img[y, x] = newpixel
|
||||
if x + 1 < img.shape[1]:
|
||||
img[y, x + 1] += quant_error * 7 / 16
|
||||
if y + 1 < img.shape[0]:
|
||||
img[y + 1, x - 1] += quant_error * 3 / 16
|
||||
img[y + 1, x] += quant_error * 5 / 16
|
||||
if x + 1 < img.shape[1] and y + 1 < img.shape[0]:
|
||||
img[y + 1, x + 1] += quant_error * 1 / 16
|
||||
return img
|
||||
|
||||
|
||||
def convolve_rgba(img, kernel):
|
||||
return numpy.stack(
|
||||
(
|
||||
scipy.signal.convolve2d(img[:, :, 0], kernel, "same"),
|
||||
scipy.signal.convolve2d(img[:, :, 1], kernel, "same"),
|
||||
scipy.signal.convolve2d(img[:, :, 2], kernel, "same"),
|
||||
scipy.signal.convolve2d(img[:, :, 3], kernel, "same"),
|
||||
),
|
||||
axis=-1,
|
||||
)
|
||||
|
||||
|
||||
def rgb2gray(img):
|
||||
result = numpy.zeros((60, 60), dtype=numpy.dtype("int64"))
|
||||
for y in range(img.shape[0]):
|
||||
for x in range(img.shape[1]):
|
||||
clin = sum(img[y, x] * [0.2126, 0.7152, 0.0722]) / 0xFFFF
|
||||
if clin <= 0.0031308:
|
||||
csrgb = 12.92 * clin
|
||||
else:
|
||||
csrgb = 1.055 * clin ** (1 / 2.4) - 0.055
|
||||
result[y, x] = csrgb * 0xFFFF
|
||||
return result
|
||||
|
||||
|
||||
def palettize(img, pal):
|
||||
result = numpy.zeros((img.shape[0], img.shape[1]), dtype=numpy.dtype("int64"))
|
||||
for y in range(img.shape[0]):
|
||||
for x in range(img.shape[1]):
|
||||
for i, col in enumerate(pal):
|
||||
if numpy.array_equal(img[y, x], col):
|
||||
result[y, x] = i
|
||||
break
|
||||
else:
|
||||
raise Exception()
|
||||
return result
|
||||
|
||||
|
||||
def write_png(data, path, bitdepth, colortype, palette=None):
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"\x89PNG\r\n\x1A\n")
|
||||
# PNG image type Colour type Allowed bit depths
|
||||
# Greyscale 0 1, 2, 4, 8, 16
|
||||
# Truecolour 2 8, 16
|
||||
# Indexed-colour 3 1, 2, 4, 8
|
||||
# Greyscale with alpha 4 8, 16
|
||||
# Truecolour with alpha 6 8, 16
|
||||
block = b"IHDR" + struct.pack(
|
||||
">IIBBBBB",
|
||||
data.shape[1], # width
|
||||
data.shape[0], # height
|
||||
bitdepth, # bitdepth
|
||||
colortype, # colortype
|
||||
0, # compression
|
||||
0, # filtertype
|
||||
0, # interlaced
|
||||
)
|
||||
f.write(
|
||||
struct.pack(">I", len(block) - 4)
|
||||
+ block
|
||||
+ struct.pack(">I", zlib.crc32(block))
|
||||
)
|
||||
if palette is not None:
|
||||
block = b"PLTE"
|
||||
for col in palette:
|
||||
block += struct.pack(">BBB", col[0], col[1], col[2])
|
||||
f.write(
|
||||
struct.pack(">I", len(block) - 4)
|
||||
+ block
|
||||
+ struct.pack(">I", zlib.crc32(block))
|
||||
)
|
||||
raw = b""
|
||||
for y in range(data.shape[0]):
|
||||
raw += b"\0"
|
||||
if bitdepth == 16:
|
||||
raw += data[y].astype(">u2").tobytes()
|
||||
elif bitdepth == 8:
|
||||
raw += data[y].astype(">u1").tobytes()
|
||||
elif bitdepth in [4, 2, 1]:
|
||||
valsperbyte = 8 // bitdepth
|
||||
for x in range(0, data.shape[1], valsperbyte):
|
||||
val = 0
|
||||
for j in range(valsperbyte):
|
||||
if x + j >= data.shape[1]:
|
||||
break
|
||||
val |= (data[y, x + j].astype(">u2") & (2 ** bitdepth - 1)) << (
|
||||
(valsperbyte - j - 1) * bitdepth
|
||||
)
|
||||
raw += struct.pack(">B", val)
|
||||
else:
|
||||
raise Exception()
|
||||
compressed = zlib.compress(raw)
|
||||
block = b"IDAT" + compressed
|
||||
f.write(
|
||||
struct.pack(">I", len(compressed))
|
||||
+ block
|
||||
+ struct.pack(">I", zlib.crc32(block))
|
||||
)
|
||||
block = b"IEND"
|
||||
f.write(struct.pack(">I", 0) + block + struct.pack(">I", zlib.crc32(block)))
|
||||
|
||||
|
||||
def main():
|
||||
outdir = sys.argv[1]
|
||||
|
||||
# create a 256 color palette by first writing 16 shades of gray
|
||||
# and then writing an array of RGB colors with 6, 8 and 5 levels
|
||||
# for red, green and blue, respectively
|
||||
pal8 = numpy.zeros((256, 3), dtype=numpy.dtype("int64"))
|
||||
i = 0
|
||||
for gray in range(15, 255, 15):
|
||||
pal8[i] = [gray, gray, gray]
|
||||
i += 1
|
||||
for red in 0, 0x33, 0x66, 0x99, 0xCC, 0xFF:
|
||||
for green in 0, 0x24, 0x49, 0x6D, 0x92, 0xB6, 0xDB, 0xFF:
|
||||
for blue in 0, 0x40, 0x80, 0xBF, 0xFF:
|
||||
pal8[i] = [red, green, blue]
|
||||
i += 1
|
||||
assert i == 256
|
||||
|
||||
# windows 16 color palette
|
||||
pal4 = numpy.array(
|
||||
[
|
||||
[0x00, 0x00, 0x00],
|
||||
[0x80, 0x00, 0x00],
|
||||
[0x00, 0x80, 0x00],
|
||||
[0x80, 0x80, 0x00],
|
||||
[0x00, 0x00, 0x80],
|
||||
[0x80, 0x00, 0x80],
|
||||
[0x00, 0x80, 0x80],
|
||||
[0xC0, 0xC0, 0xC0],
|
||||
[0x80, 0x80, 0x80],
|
||||
[0xFF, 0x00, 0x00],
|
||||
[0x00, 0xFF, 0x00],
|
||||
[0xFF, 0x00, 0x00],
|
||||
[0x00, 0xFF, 0x00],
|
||||
[0xFF, 0x00, 0xFF],
|
||||
[0x00, 0xFF, 0x00],
|
||||
[0xFF, 0xFF, 0xFF],
|
||||
],
|
||||
dtype=numpy.dtype("int64"),
|
||||
)
|
||||
|
||||
# choose values slightly off red, lime and blue because otherwise
|
||||
# imagemagick will classify the image as Depth: 8/1-bit
|
||||
pal2 = numpy.array(
|
||||
[[0, 0, 0], [0xFE, 0, 0], [0, 0xFE, 0], [0, 0, 0xFE]],
|
||||
dtype=numpy.dtype("int64"),
|
||||
)
|
||||
|
||||
# don't choose black and white or otherwise imagemagick will classify the
|
||||
# image as bilevel with 8/1-bit depth instead of palette with 8-bit color
|
||||
# don't choose gray colors or otherwise imagemagick will classify the
|
||||
# image as grayscale
|
||||
pal1 = numpy.array(
|
||||
[[0x01, 0x02, 0x03], [0xFE, 0xFD, 0xFC]], dtype=numpy.dtype("int64")
|
||||
)
|
||||
|
||||
# gaussian kernel with sigma=3
|
||||
kernel = numpy.array(
|
||||
[
|
||||
[0.011362, 0.014962, 0.017649, 0.018648, 0.017649, 0.014962, 0.011362],
|
||||
[0.014962, 0.019703, 0.02324, 0.024556, 0.02324, 0.019703, 0.014962],
|
||||
[0.017649, 0.02324, 0.027413, 0.028964, 0.027413, 0.02324, 0.017649],
|
||||
[0.018648, 0.024556, 0.028964, 0.030603, 0.028964, 0.024556, 0.018648],
|
||||
[0.017649, 0.02324, 0.027413, 0.028964, 0.027413, 0.02324, 0.017649],
|
||||
[0.014962, 0.019703, 0.02324, 0.024556, 0.02324, 0.019703, 0.014962],
|
||||
[0.011362, 0.014962, 0.017649, 0.018648, 0.017649, 0.014962, 0.011362],
|
||||
],
|
||||
numpy.float,
|
||||
)
|
||||
|
||||
# constructs a 2D array of a circle with a width of 36
|
||||
circle = list()
|
||||
offsets_36 = [14, 11, 9, 7, 6, 5, 4, 3, 3, 2, 2, 1, 1, 1, 0, 0, 0, 0]
|
||||
for offs in offsets_36 + offsets_36[::-1]:
|
||||
circle.append([0] * offs + [1] * (len(offsets_36) - offs) * 2 + [0] * offs)
|
||||
|
||||
alpha = numpy.zeros((60, 60, 4), dtype=numpy.dtype("int64"))
|
||||
|
||||
# draw three circles
|
||||
for (xpos, ypos, color) in [
|
||||
(12, 3, [0xFFFF, 0, 0, 0xFFFF]),
|
||||
(21, 21, [0, 0xFFFF, 0, 0xFFFF]),
|
||||
(3, 21, [0, 0, 0xFFFF, 0xFFFF]),
|
||||
]:
|
||||
for x, row in enumerate(circle):
|
||||
for y, pos in enumerate(row):
|
||||
if pos:
|
||||
alpha[y + ypos, x + xpos] += color
|
||||
alpha = numpy.clip(alpha, 0, 0xFFFF)
|
||||
alpha = convolve_rgba(alpha, kernel)
|
||||
|
||||
write_png(alpha, outdir + "/alpha.png", 16, 6)
|
||||
|
||||
normal16 = alpha[:, :, 0:3]
|
||||
write_png(normal16, outdir + "/normal16.png", 16, 2)
|
||||
|
||||
write_png(normal16 / 0xFFFF * 0xFF, outdir + "/normal.png", 8, 2)
|
||||
|
||||
write_png(0xFF - normal16 / 0xFFFF * 0xFF, outdir + "/inverse.png", 8, 2)
|
||||
|
||||
gray16 = rgb2gray(normal16)
|
||||
|
||||
write_png(gray16, outdir + "/gray16.png", 16, 0)
|
||||
|
||||
write_png(gray16 / 0xFFFF * 0xFF, outdir + "/gray8.png", 8, 0)
|
||||
|
||||
write_png(
|
||||
floyd_steinberg(gray16, numpy.arange(16) / 0xF * 0xFFFF) / 0xFFFF * 0xF,
|
||||
outdir + "/gray4.png",
|
||||
4,
|
||||
0,
|
||||
)
|
||||
|
||||
write_png(
|
||||
floyd_steinberg(gray16, numpy.arange(4) / 0x3 * 0xFFFF) / 0xFFFF * 0x3,
|
||||
outdir + "/gray2.png",
|
||||
2,
|
||||
0,
|
||||
)
|
||||
|
||||
write_png(
|
||||
floyd_steinberg(gray16, numpy.arange(2) / 0x1 * 0xFFFF) / 0xFFFF * 0x1,
|
||||
outdir + "/gray1.png",
|
||||
1,
|
||||
0,
|
||||
)
|
||||
|
||||
write_png(
|
||||
palettize(
|
||||
floyd_steinberg(normal16, pal8 * 0xFFFF / 0xFF) / 0xFFFF * 0xFF, pal8
|
||||
),
|
||||
outdir + "/palette8.png",
|
||||
8,
|
||||
3,
|
||||
pal8,
|
||||
)
|
||||
|
||||
write_png(
|
||||
palettize(
|
||||
floyd_steinberg(normal16, pal4 * 0xFFFF / 0xFF) / 0xFFFF * 0xFF, pal4
|
||||
),
|
||||
outdir + "/palette4.png",
|
||||
4,
|
||||
3,
|
||||
pal4,
|
||||
)
|
||||
|
||||
write_png(
|
||||
palettize(
|
||||
floyd_steinberg(normal16, pal2 * 0xFFFF / 0xFF) / 0xFFFF * 0xFF, pal2
|
||||
),
|
||||
outdir + "/palette2.png",
|
||||
2,
|
||||
3,
|
||||
pal2,
|
||||
)
|
||||
|
||||
write_png(
|
||||
palettize(
|
||||
floyd_steinberg(normal16, pal1 * 0xFFFF / 0xFF) / 0xFFFF * 0xFF, pal1
|
||||
),
|
||||
outdir + "/palette1.png",
|
||||
1,
|
||||
3,
|
||||
pal1,
|
||||
)
|
||||
|
||||
|
||||
main()
|
58
test.sh
58
test.sh
|
@ -90,48 +90,26 @@ tempdir=$(mktemp --directory --tmpdir img2pdf.XXXXXXXXXX)
|
|||
|
||||
trap error EXIT
|
||||
|
||||
# we use -strip to remove all timestamps (tIME chunk and exif data)
|
||||
convert -size 60x60 \( xc:none -fill red -draw 'circle 30,21 30,3' -gaussian-blur 0x3 \) \
|
||||
\( \( xc:none -fill lime -draw 'circle 39,39 36,57' -gaussian-blur 0x3 \) \
|
||||
\( xc:none -fill blue -draw 'circle 21,39 24,57' -gaussian-blur 0x3 \) \
|
||||
-compose plus -composite \
|
||||
\) -compose plus -composite \
|
||||
-strip \
|
||||
"$tempdir/alpha.png"
|
||||
|
||||
convert "$tempdir/alpha.png" -background black -alpha remove -alpha off -strip "$tempdir/normal16.png"
|
||||
|
||||
convert "$tempdir/normal16.png" -depth 8 -strip "$tempdir/normal.png"
|
||||
|
||||
convert "$tempdir/normal.png" -negate -strip "$tempdir/inverse.png"
|
||||
|
||||
convert "$tempdir/normal16.png" -colorspace Gray -depth 16 -strip "$tempdir/gray16.png"
|
||||
convert "$tempdir/normal16.png" -colorspace Gray -dither FloydSteinberg -colors 256 -depth 8 -strip "$tempdir/gray8.png"
|
||||
convert "$tempdir/normal16.png" -colorspace Gray -dither FloydSteinberg -colors 16 -depth 4 -strip "$tempdir/gray4.png"
|
||||
convert "$tempdir/normal16.png" -colorspace Gray -dither FloydSteinberg -colors 4 -depth 2 -strip "$tempdir/gray2.png"
|
||||
convert "$tempdir/normal16.png" -colorspace Gray -dither FloydSteinberg -colors 2 -depth 1 -strip "$tempdir/gray1.png"
|
||||
|
||||
# use "-define png:exclude-chunk=bkgd" because otherwise, imagemagick will
|
||||
# add the background color (white) as an additional entry to the palette
|
||||
convert "$tempdir/normal.png" -dither FloydSteinberg -colors 2 -define png:exclude-chunk=bkgd -strip "$tempdir/palette1.png"
|
||||
convert "$tempdir/normal.png" -dither FloydSteinberg -colors 4 -define png:exclude-chunk=bkgd -strip "$tempdir/palette2.png"
|
||||
convert "$tempdir/normal.png" -dither FloydSteinberg -colors 16 -define png:exclude-chunk=bkgd -strip "$tempdir/palette4.png"
|
||||
convert "$tempdir/normal.png" -dither FloydSteinberg -colors 256 -define png:exclude-chunk=bkgd -strip "$tempdir/palette8.png"
|
||||
# instead of using imagemagick to craft the test input, we use a custom python
|
||||
# script. This is because the output of imagemagick is not bit-by-bit identical
|
||||
# across versions and architectures.
|
||||
# See https://gitlab.mister-muffin.de/josch/img2pdf/issues/56
|
||||
python3 magick.py "$tempdir"
|
||||
|
||||
cat << END | ( cd "$tempdir"; md5sum --check --status - )
|
||||
a99ef2a356c315090b6939fa4ce70516 alpha.png
|
||||
0df21ebbce5292654119b17f6e52bc81 gray16.png
|
||||
6faee81b8db446caa5004ad71bddcb5b gray1.png
|
||||
97e423da517ede069348484a1283aa6c gray2.png
|
||||
cbed1b6da5183aec0b86909e82b77c41 gray4.png
|
||||
c0df42fdd69ae2a16ad0c23adb39895e gray8.png
|
||||
ac6bb850fb5aaee9fa7dcb67525cd0fc inverse.png
|
||||
3f3f8579f5054270e79a39e7cc4e89e0 normal16.png
|
||||
cbe63b21443af8321b213bde6666951f normal.png
|
||||
2f00705cca05fd94406fc39ede4d7322 palette1.png
|
||||
6cb250d1915c2af99c324c43ff8286eb palette2.png
|
||||
ab7b3d3907a851692ee36f5349ed0b2c palette4.png
|
||||
03829af4af8776adf56ba2e68f5b111e palette8.png
|
||||
7ed200c092c726c68e889514fff0d8f1 alpha.png
|
||||
bf56e00465b98fb738f6edd2c58dac3b gray16.png
|
||||
f93c3e3c11dad3f8c11db4fd2d01c2cc gray1.png
|
||||
d63167c66e8a65bd5c15f68c8d554c48 gray2.png
|
||||
6bceb845d9c9946adad1526954973945 gray4.png
|
||||
7ba8152b9146eb7d9d50529189baf7db gray8.png
|
||||
75130ec7635919e40c7396d45899ddbe inverse.png
|
||||
bd1a2c9f9dfc51a827eafa3877cf7f83 normal16.png
|
||||
329eac79fec2e1bc30d7a50ba2e3f2a5 normal.png
|
||||
9ffd3f592b399f9f9c23892db82369cd palette1.png
|
||||
6d3a39fe5f2efea5975f048d11a5cb02 palette2.png
|
||||
57add39e5c278249b64ab23314a41c39 palette4.png
|
||||
192a3b298d812a156e6f248238d2bb52 palette8.png
|
||||
END
|
||||
|
||||
# use img2pdfprog environment variable if it is set
|
||||
|
|
Loading…
Reference in a new issue