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

1#!/usr/bin/env python3 

2 

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 

25 

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. 

29 

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. 

34 

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. 

42 

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``. 

83 

84 If ``levels is None and choices == "fastest"`` then ``levels = [0,]``. 

85 

86 If ``levels is None and choices == "best"`` then ``levels = [9,]``. 

87 

88 If ``levels is None and choices == "all"`` then ``levels = [0, 1, 2, 3, 

89 4, 5, 6, 7, 8, 9,]``. 

90 

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``. 

97 

98 If ``memLevels is None and choices == "fastest"`` then ``memLevels = [9, 

99 ]``. 

100 

101 If ``memLevels is None and choices == "best"`` then ``memLevels = [9,]`` 

102 . 

103 

104 If ``memLevels is None and choices == "all"`` then ``memLevels = [1, 2, 

105 3, 4, 5, 6, 7, 8, 9,]``. 

106 

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``. 

120 

121 If ``strategies is None and choices == "fastest"`` then ``strategies = [ 

122 zlib.Z_DEFAULT_STRATEGY,]``. 

123 

124 If ``strategies is None and choices == "best"`` then ``strategies = [ 

125 zlib.Z_DEFAULT_STRATEGY,]``. 

126 

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,]``. 

130 

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``. 

136 

137 If ``wbitss is None and choices == "fastest"`` then ``wbitss = [15,]``. 

138 

139 If ``wbitss is None and choices == "best"`` then ``wbitss = [15,]``. 

140 

141 If ``wbitss is None and choices == "all"`` then ``wbitss = [9, 10, 11, 

142 12, 13, 14, 15,]``. 

143 

144 See :py:func:`zlib.compressobj` for what the valid window sizes are. 

145 

146 Returns 

147 ------- 

148 src : bytearray 

149 The binary source of the PNG file of the input. 

150 

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. 

156 

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". 

161 

162 Copyright 2017 Thomas Guymer [1]_ 

163 

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 """ 

169 

170 # Import standard modules ... 

171 import binascii 

172 import sys 

173 import zlib 

174 

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 

180 

181 # Import sub-functions ... 

182 from .makePngSrc import createStream 

183 

184 # ************************************************************************** 

185 

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 

197 

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 

209 

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 

221 

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 

233 

234 # Check system ... 

235 assert sys.byteorder == "little", "the system is not little-endian" 

236 

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 

255 

256 # ************************************************************************** 

257 

258 # Create short-hand ... 

259 arrInt16 = arrUint8.astype(numpy.int16) 

260 

261 # Make the file signature ... 

262 pngSig = bytearray() 

263 pngSig += binascii.unhexlify("89504E470D0A1A0A") 

264 

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 

277 

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 

300 

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 

310 

311 # Prepend the PLTE chunk to the IDAT chunk (so that it is included in 

312 # the result) ... 

313 datChk = palChk + datChk 

314 

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] 

319 

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 

328 

329 # Prepend the pHYs chunk to the IDAT chunk (so that it is included in 

330 # the result) ... 

331 datChk = phyChk + datChk 

332 

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 

346 

347 # Prepend the tIME chunk to the IDAT chunk (so that it is included in 

348 # the result) ... 

349 datChk = timChk + datChk 

350 

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 

356 

357 # Return answer ... 

358 return pngSig + hdrChk + datChk + endChk