Coverage for pyguymer3/image/makePng.py: 42%
121 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-08 18:47 +0000
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-08 18:47 +0000
1#!/usr/bin/env python3
3# Define function ...
4def makePng(
5 arrUint8,
6 /,
7 *,
8 calcAdaptive: bool = True,
9 calcAverage: bool = True,
10 calcNone: bool = True,
11 calcPaeth: bool = True,
12 calcSub: bool = True,
13 calcUp: bool = True,
14 choices: str = "all",
15 debug: bool = __debug__,
16 dpi: None | int = None,
17 levels: None | list[int] = None,
18 memLevels: None | list[int] = None,
19 modTime = None,
20 palUint8 = None,
21 strategies: None | list[int] = None,
22 wbitss: None | list[int] = None,
23) -> bytearray:
24 """Make a PNG
26 This function reads in a "height * width * colour" unsigned 8-bit integer
27 NumPy array and returns the Python bytearray which is the binary source of
28 the PNG file of the input.
30 By default, this function calculates the PNG image data stream using all
31 five filters (as defined in the PNG specification [2]_), as well as adaptive
32 filtering, and this function then uses the one filter which ends up having
33 the smallest compressed size.
35 This function also allows the user to declare sets of settings which are
36 tried when compressing the PNG image data stream and this function then uses
37 the one set of settings which ends up having the smallest compressed size.
38 This allows the user to: either choose to spend CPU time trying different
39 sets of settings in an attempt to find the one which produces the smallest
40 compressed size for the supplied input; or try a stab in the dark at knowing
41 the fastest (or best) set of settings.
43 Parameters
44 ----------
45 arrUint8 : numpy.ndarray
46 A "height * width * colour" unsigned 8-bit integer NumPy array.
47 calcAdaptive : bool, optional
48 Calculate the compressed PNG image data stream using an adaptive filter
49 type. Each of the five named filters is applied to a scanline and a
50 prediction is made as to which one will produce the smallest compressed
51 scanline. The chosen filtered uncompressed scanline is concatenated with
52 all of the other filtered uncompressed scanlines and a single
53 compression operation is performed once the whole image has been
54 processed.
55 calcAverage : bool, optional
56 Calculate the compressed PNG image data stream using the "average"
57 filter type, as defined in the PNG specification [2]_.
58 calcNone : bool, optional
59 Calculate the compressed PNG image data stream using the "none" filter
60 type, as defined in the PNG specification [2]_.
61 calcPaeth : bool, optional
62 Calculate the compressed PNG image data stream using the "Paeth" filter
63 type, as defined in the PNG specification [2]_.
64 calcSub : bool, optional
65 Calculate the compressed PNG image data stream using the "sub" filter
66 type, as defined in the PNG specification [2]_.
67 calcUp : bool, optional
68 Calculate the compressed PNG image data stream using the "up" filter
69 type, as defined in the PNG specification [2]_.
70 choices : str, optional
71 If any of the settings are not passed (or passed as ``None``) then this
72 string is used to set them. The accepted values are ``"fastest"``,
73 ``"best"`` and ``"all"``.
74 debug : bool, optional
75 Print debug messages.
76 dpi : None or float or int, optional
77 If a number is passed then the ancillary "pHYs" chunk will get created
78 and the resolution will be specified.
79 levels : None or list of int, optional
80 The list of compression levels to loop over when trying to find the
81 smallest compressed size. If not supplied, or ``None``, then the
82 value of ``choices`` will determine the value of ``levels``.
84 If ``levels is None and choices == "fastest"`` then ``levels = [0,]``.
86 If ``levels is None and choices == "best"`` then ``levels = [9,]``.
88 If ``levels is None and choices == "all"`` then ``levels = [0, 1, 2, 3,
89 4, 5, 6, 7, 8, 9,]``.
91 See :py:func:`zlib.compressobj` for what the valid compression levels
92 are.
93 memLevels : None or list of int, optional
94 The list of memory levels to loop over when trying to find the smallest
95 compressed size. If not supplied, or ``None``, then the value of
96 ``choices`` will determine the value of ``memLevels``.
98 If ``memLevels is None and choices == "fastest"`` then ``memLevels = [9,
99 ]``.
101 If ``memLevels is None and choices == "best"`` then ``memLevels = [9,]``
102 .
104 If ``memLevels is None and choices == "all"`` then ``memLevels = [1, 2,
105 3, 4, 5, 6, 7, 8, 9,]``.
107 See :py:func:`zlib.compressobj` for what the valid memory levels are.
108 modTime : None or datetime.datetime, optional
109 If a time is passed then the ancillary "tIME" chunk will get created and
110 the image last-modification time will be specified.
111 palUint8 : None or numpy.ndarray, optional
112 A "level * colour" unsigned 8-bit integer NumPy array. If the size of
113 the "colours" axis in ``arrUint8`` is ``1`` then ``arrUint8`` is assumed
114 to be either greyscale (if ``palUint8`` is ``None``) or paletted and
115 ``palUint8`` is the palette.
116 strategies : None or list of int, optional
117 The list of strategies to loop over when trying to find the smallest
118 compressed size. If not supplied, or ``None``, then the value of
119 ``choices`` will determine the value of ``strategies``.
121 If ``strategies is None and choices == "fastest"`` then ``strategies = [
122 zlib.Z_DEFAULT_STRATEGY,]``.
124 If ``strategies is None and choices == "best"`` then ``strategies = [
125 zlib.Z_DEFAULT_STRATEGY,]``.
127 If ``strategies is None and choices == "all"`` then ``strategies = [
128 zlib.Z_DEFAULT_STRATEGY, zlib.Z_FILTERED, zlib.Z_HUFFMAN_ONLY,
129 zlib.Z_RLE, zlib.Z_FIXED,]``.
131 See :py:func:`zlib.compressobj` for what the valid strategies are.
132 wbitss : None or list of int, optional
133 The list of window sizes to loop over when trying to find the smallest
134 compressed size. If not supplied, or ``None``, then the value of
135 ``choices`` will determine the value of ``wbitss``.
137 If ``wbitss is None and choices == "fastest"`` then ``wbitss = [15,]``.
139 If ``wbitss is None and choices == "best"`` then ``wbitss = [15,]``.
141 If ``wbitss is None and choices == "all"`` then ``wbitss = [9, 10, 11,
142 12, 13, 14, 15,]``.
144 See :py:func:`zlib.compressobj` for what the valid window sizes are.
146 Returns
147 -------
148 src : bytearray
149 The binary source of the PNG file of the input.
151 Notes
152 -----
153 This function only creates 8-bit images (either greyscale, paletted or
154 truecolour), without interlacing. It stores the entire image in a single
155 "IDAT" chunk.
157 This function always writes out three of the four critical chunks: "IHDR",
158 "IDAT" and "IEND". Depending on optional keyword arguments which may be
159 provided then this function may also write out the critical chunk "PLTE" as
160 well as the ancillary chunks "iTIM" and "pHYs".
162 Copyright 2017 Thomas Guymer [1]_
164 References
165 ----------
166 .. [1] PyGuymer3, https://github.com/Guymer/PyGuymer3
167 .. [2] PNG Specification (Third Edition), https://www.w3.org/TR/png-3/
168 """
170 # Import standard modules ...
171 import binascii
172 import sys
173 import zlib
175 # Import special modules ...
176 try:
177 import numpy
178 except:
179 raise Exception("\"numpy\" is not installed; run \"pip install --user numpy\"") from None
181 # Import sub-functions ...
182 from .makePngSrc import createStream
184 # **************************************************************************
186 # Populate compression levels if the user has not ...
187 if levels is None:
188 match choices:
189 case "fastest":
190 levels = [0,]
191 case "best":
192 levels = [9,]
193 case "all":
194 levels = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9,]
195 case _:
196 raise ValueError(f"\"choices\" was an unexpected value (\"{choices}\")") from None
198 # Populate memory levels if the user has not ...
199 if memLevels is None:
200 match choices:
201 case "fastest":
202 memLevels = [9,]
203 case "best":
204 memLevels = [9,]
205 case "all":
206 memLevels = [1, 2, 3, 4, 5, 6, 7, 8, 9,]
207 case _:
208 raise ValueError(f"\"choices\" was an unexpected value (\"{choices}\")") from None
210 # Populate strategies if the user has not ...
211 if strategies is None:
212 match choices:
213 case "fastest":
214 strategies = [zlib.Z_DEFAULT_STRATEGY,]
215 case "best":
216 strategies = [zlib.Z_DEFAULT_STRATEGY,]
217 case "all":
218 strategies = [zlib.Z_DEFAULT_STRATEGY, zlib.Z_FILTERED, zlib.Z_HUFFMAN_ONLY, zlib.Z_RLE, zlib.Z_FIXED,]
219 case _:
220 raise ValueError(f"\"choices\" was an unexpected value (\"{choices}\")") from None
222 # Populate window sizes if the user has not ...
223 if wbitss is None:
224 match choices:
225 case "fastest":
226 wbitss = [15,]
227 case "best":
228 wbitss = [15,]
229 case "all":
230 wbitss = [9, 10, 11, 12, 13, 14, 15,]
231 case _:
232 raise ValueError(f"\"choices\" was an unexpected value (\"{choices}\")") from None
234 # Check system ...
235 assert sys.byteorder == "little", "the system is not little-endian"
237 # Check input ...
238 assert arrUint8.dtype == "uint8", f"the NumPy array is not 8-bit (\"{arrUint8.dtype}\")"
239 assert arrUint8.ndim == 3, f"the NumPy array is not 3D (\"{arrUint8.ndim:d}\")"
240 match arrUint8.shape[2]:
241 case 1:
242 if palUint8 is None:
243 colourType = 0
244 else:
245 assert palUint8.dtype == "uint8", f"the NumPy palette is not 8-bit (\"{palUint8.dtype}\")"
246 assert palUint8.ndim == 2, f"the NumPy palette is not 2D (\"{palUint8.ndim:d}\")"
247 assert palUint8.shape[0] <= 256, f"the NumPy palette has more than 256 colours (\"{palUint8.shape[0]:,d}\")"
248 assert palUint8.shape[1] == 3, "the NumPy palette does not have 3 colour channels"
249 assert arrUint8.max() < palUint8.shape[0], f"the NumPy array references more colours than are in the NumPy palette (\"{arrUint8.max():d}\" -vs- \"{palUint8.shape[0]:d}\")"
250 colourType = 3
251 case 3:
252 colourType = 2
253 case _:
254 raise ValueError(f"the NumPy array does not have either 1 or 3 colour channels (\"{arrUint8.shape[2]:d}\")") from None
256 # **************************************************************************
258 # Create short-hand ...
259 arrInt16 = arrUint8.astype(numpy.int16)
261 # Make the file signature ...
262 pngSig = bytearray()
263 pngSig += binascii.unhexlify("89504E470D0A1A0A")
265 # Make the IHDR chunk ...
266 hdrChk = bytearray()
267 hdrChk += numpy.uint32(13).byteswap().tobytes() # Length
268 hdrChk += bytearray("IHDR", encoding = "ascii") # Chunk type
269 hdrChk += numpy.uint32(arrUint8.shape[1]).byteswap().tobytes() # IHDR : Width
270 hdrChk += numpy.uint32(arrUint8.shape[0]).byteswap().tobytes() # IHDR : Height
271 hdrChk += numpy.uint8(8).tobytes() # IHDR : Bit depth
272 hdrChk += numpy.uint8(colourType).tobytes() # IHDR : Colour type
273 hdrChk += numpy.uint8(0).tobytes() # IHDR : Compression method
274 hdrChk += numpy.uint8(0).tobytes() # IHDR : Filter method
275 hdrChk += numpy.uint8(0).tobytes() # IHDR : Interlace method
276 hdrChk += numpy.uint32(binascii.crc32(hdrChk[4:])).byteswap().tobytes() # CRC-32
278 # Make the IDAT chunk ...
279 datChk = bytearray()
280 datChk += numpy.uint32(0).byteswap().tobytes() # Length
281 datChk += bytearray("IDAT", encoding = "ascii") # Chunk type
282 datChk += createStream(
283 arrUint8,
284 arrInt16,
285 calcAdaptive = calcAdaptive,
286 calcAverage = calcAverage,
287 calcNone = calcNone,
288 calcPaeth = calcPaeth,
289 calcSub = calcSub,
290 calcUp = calcUp,
291 choices = choices,
292 debug = debug,
293 levels = levels,
294 memLevels = memLevels,
295 strategies = strategies,
296 wbitss = wbitss,
297 ) # IDAT : Data
298 datChk[:4] = numpy.uint32(len(datChk[8:])).byteswap().tobytes() # Length
299 datChk += numpy.uint32(binascii.crc32(datChk[4:])).byteswap().tobytes() # CRC-32
301 # Check if it is a paletted image ...
302 if colourType == 3:
303 # Make the PLTE chunk ...
304 palChk = bytearray()
305 palChk += numpy.uint32(palUint8.size).byteswap().tobytes() # Length
306 palChk += bytearray("PLTE", encoding = "ascii") # Chunk type
307 for lvl in range(palUint8.shape[0]):
308 palChk += palUint8[lvl, :].tobytes() # PLTE : Data
309 palChk += numpy.uint32(binascii.crc32(palChk[4:])).byteswap().tobytes() # CRC-32
311 # Prepend the PLTE chunk to the IDAT chunk (so that it is included in
312 # the result) ...
313 datChk = palChk + datChk
315 # Check if the user has supplied a DPI ...
316 if dpi is not None:
317 # Convert the dots-per-inch to dots-per-metre ...
318 dpm = round(dpi * 100.0 / 2.54) # [#/m]
320 # Make the pHYs chunk ...
321 phyChk = bytearray()
322 phyChk += numpy.uint32(9).byteswap().tobytes() # Length
323 phyChk += bytearray("pHYs", encoding = "ascii") # Chunk type
324 phyChk += numpy.uint32(dpm).byteswap().tobytes() # pHYs : Pixels per unit, x axis
325 phyChk += numpy.uint32(dpm).byteswap().tobytes() # pHYs : Pixels per unit, y axis
326 phyChk += numpy.uint8(1).tobytes() # pHYs : Unit specifier
327 phyChk += numpy.uint32(binascii.crc32(phyChk[4:])).byteswap().tobytes() # CRC-32
329 # Prepend the pHYs chunk to the IDAT chunk (so that it is included in
330 # the result) ...
331 datChk = phyChk + datChk
333 # Check if the user has supplied a last-modification time ...
334 if modTime is not None:
335 # Make the tIME chunk ...
336 timChk = bytearray()
337 timChk += numpy.uint32(7).byteswap().tobytes() # Length
338 timChk += bytearray("tIME", encoding = "ascii") # Chunk type
339 timChk += numpy.uint16(modTime.year).byteswap().tobytes() # tIME : Year
340 timChk += numpy.uint8(modTime.month).tobytes() # tIME : Month
341 timChk += numpy.uint8(modTime.day).tobytes() # tIME : Day
342 timChk += numpy.uint8(modTime.hour).tobytes() # tIME : Hour
343 timChk += numpy.uint8(modTime.minute).tobytes() # tIME : Minute
344 timChk += numpy.uint8(modTime.second).tobytes() # tIME : Second
345 timChk += numpy.uint32(binascii.crc32(timChk[4:])).byteswap().tobytes() # CRC-32
347 # Prepend the tIME chunk to the IDAT chunk (so that it is included in
348 # the result) ...
349 datChk = timChk + datChk
351 # Make the IEND chunk ...
352 endChk = bytearray()
353 endChk += numpy.uint32(0).byteswap().tobytes() # Length
354 endChk += bytearray("IEND", encoding = "ascii") # Chunk type
355 endChk += numpy.uint32(binascii.crc32(endChk[4:])).byteswap().tobytes() # CRC-32
357 # Return answer ...
358 return pngSig + hdrChk + datChk + endChk