Coverage for selenible/modules/imageproc.py: 30%

187 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-05 14:24 +0000

1import math 

2import os 

3import tarfile 

4import time 

5import zipfile 

6 

7import yaml 

8from PIL import ( 

9 Image, 

10 ImageChops, 

11 ImageColor, 

12 ImageDraw, 

13 ImageEnhance, 

14 ImageFilter, 

15 ImageFont, 

16 ImageOps, 

17) 

18 

19 

20def inout_fname(param): 

21 input_filename = param.get("input") 

22 output_filename = param.get("output", input_filename) 

23 if input_filename is None or output_filename is None: 23 ↛ 24line 23 didn't jump to line 24 because the condition on line 23 was never true

24 raise Exception(f"please set input and output: {param}") 

25 return input_filename, output_filename 

26 

27 

28image_crop_schema = yaml.safe_load(""" 

29allOf: 

30 - "$ref": "#/definitions/common/inout" 

31 - type: object 

32 properties: 

33 size: 

34 oneOf: 

35 - type: string 

36 enum: [auto] 

37 - type: array 

38 items: {type: integer} 

39 minItems: 4 

40 maxItems: 4 

41""") 

42 

43 

44def Base_image_crop(self, param): 

45 """ 

46 - name: crop local image (auto) 

47 image_crop: 

48 input: filename.png 

49 size: auto 

50 - name: crop local image (manual) 

51 image_crop: 

52 input: filename.png 

53 size: [100, 100, 200, 200] # left, upper, right, lower 

54 """ 

55 input_filename, filename = inout_fname(param) 

56 if filename is None: 56 ↛ 58line 56 didn't jump to line 58 because the condition on line 56 was never true

57 # generate filename 

58 ts = time.time() 

59 msec = math.modf(ts)[0] * 1000 

60 filename = param.get("prefix", "") 

61 filename += time.strftime("%Y%m%d_%H%M%S", time.localtime(ts)) 

62 filename += f"_{int(msec):03d}.png" 

63 self.log.debug("filename generated %s", filename) 

64 size = param.get("size", "auto") 

65 if size == "auto": 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true

66 img = Image.open(input_filename) 

67 bg = Image.new(img.mode, img.size, img.getpixel((0, 0))) 

68 diff = ImageChops.difference(img, bg) 

69 diff = ImageChops.add(diff, diff, 2.0, -100) 

70 box = diff.getbbox() 

71 self.log.info("auto crop: %s", box) 

72 crop = img.crop(box) 

73 crop.save(filename) 

74 elif isinstance(size, (tuple, list)): 74 ↛ 80line 74 didn't jump to line 80 because the condition on line 74 was always true

75 img = Image.open(input_filename) 

76 self.log.info("manual crop: %s", size) 

77 crop = img.crop(size) 

78 crop.save(filename) 

79 else: 

80 raise Exception(f"not implemented yet: crop {filename} {size}") 

81 

82 

83image_optimize_schema = yaml.safe_load(""" 

84allOf: 

85 - "$ref": "#/definitions/common/inout" 

86 - type: object 

87 properties: 

88 command: {type: string} 

89""") 

90 

91 

92def Base_image_optimize(self, param): 

93 """ 

94 - name: optimize local png using optipng 

95 image_optimize: 

96 input: filename.png 

97 """ 

98 input_filename, filename = inout_fname(param) 

99 command = param.get("command", "optipng") 

100 self.log.info("optimize image: %s -> %s", input_filename, filename) 

101 before = os.stat(input_filename) 

102 if before.st_size == 0: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true

103 raise Exception(f"image size is zero: {input_filename}") 

104 if filename != input_filename: 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true

105 cmd = [command, "-o9", "-out", filename, input_filename] 

106 else: 

107 cmd = [command, "-o9", filename] 

108 try: 

109 sout = self.runcmd(cmd) 

110 self.log.debug("result: %s", sout) 

111 after = os.stat(filename) 

112 self.log.info( 

113 "%s: before=%d, after=%d, reduce %d bytes (%.1f %%)", 

114 filename, 

115 before.st_size, 

116 after.st_size, 

117 before.st_size - after.st_size, 

118 100.0 * (before.st_size - after.st_size) / before.st_size, 

119 ) 

120 except FileNotFoundError as e: 

121 self.log.info("cannot exec %s: %s", cmd, e) 

122 if filename != input_filename: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 os.rename(input_filename, filename) 

124 

125 

126image_resize_schema = yaml.safe_load(""" 

127allOf: 

128 - "$ref": "#/definitions/common/inout" 

129 - type: object 

130 properties: 

131 size: 

132 type: array 

133 items: {type: integer} 

134 minItems: 2 

135 maxItems: 2 

136 percent: 

137 oneOf: 

138 - type: array 

139 items: {type: number} 

140 minItems: 2 

141 maxItems: 2 

142 - type: number 

143 algorithm: 

144 type: string 

145 enum: [NEAREST, BOX, BILINEAR, HAMMING, BICUBIC, LANCZOS] 

146""") 

147 

148 

149def Base_image_resize(self, param): 

150 """ 

151 - name: resize local image 

152 image_resize: 

153 input: filename.png 

154 size: [100, 200] # width, height 

155 algorithm: LANCZOS 

156 """ 

157 input_filename, filename = inout_fname(param) 

158 self.log.info("resize image: %s %s -> %s", input_filename, param, filename) 

159 img = Image.open(input_filename) 

160 size = param.get("size") 

161 if size is None: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true

162 pct = param.get("percent") 

163 if pct is None: 

164 raise Exception("missing size: [width, height]") 

165 if isinstance(pct, (tuple, list)): 

166 pctX = pct[0] 

167 pctY = pct[1] 

168 else: 

169 pctX = pctY = float(pct) 

170 size = (int(img.width * pctX / 100), int(img.height * pctY / 100)) 

171 algostr = param.get("algorithm", "NEAREST") 

172 if not hasattr(Image, algostr): 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true

173 raise Exception(f"algorighm not found: {algostr}") 

174 rst = img.resize(tuple(size), getattr(Image, algostr)) 

175 rst.save(filename) 

176 

177 

178def Base_image_writetext(self, param): 

179 """ 

180 - name: write text to local image 

181 image_writetext: 

182 input: filename.png 

183 font: /path/to/file.ttc 

184 text: hello world 

185 color: blue 

186 position: [100, 200] # x, y 

187 """ 

188 text = self.getvalue(param) 

189 input_filename, filename = inout_fname(param) 

190 pos = param.get("position", (0, 0)) 

191 fontname = param.get("font") 

192 fontsize = param.get("size", 10) 

193 color = param.get("color", "red") 

194 if fontname is None: 

195 font = ImageFont.load_default() 

196 else: 

197 font = ImageFont.truetype(fontname, size=fontsize) 

198 img = Image.open(input_filename) 

199 draw = ImageDraw.Draw(img) 

200 fillcolor = ImageColor.getcolor(color) 

201 draw.text(tuple(pos), text, fill=fillcolor, font=font) 

202 del draw 

203 img.save(filename) 

204 

205 

206def Base_image_filter(self, param): 

207 """ 

208 - name: image filter 

209 image_filter: 

210 input: input.png 

211 output: output.png 

212 filter: 

213 - ModeFilter: 12 

214 - GaussianBlur: 5 

215 - ModeFilter: 12 

216 - GaussianBlur: 1 

217 """ 

218 input_filename, filename = inout_fname(param) 

219 img = Image.open(input_filename) 

220 for f in param.get("filter", []): 

221 if not isinstance(f, dict): 

222 raise Exception(f"invalid parameter: {f}") 

223 for k, v in f.items(): 

224 fn = getattr(ImageFilter, k) 

225 if not callable(fn): 

226 raise Exception(f"filter {k}({v}) not found") 

227 self.log.debug("filter %s %s", k, v) 

228 if v is None: 

229 img = img.filter(fn) 

230 elif isinstance(v, (tuple, list)): 

231 img = img.filter(fn(*v)) 

232 elif isinstance(v, dict): 

233 img = img.filter(fn(**v)) 

234 else: 

235 img = img.filter(fn(v)) 

236 img.save(filename) 

237 

238 

239image_convert_schema = { 

240 "allOf": [ 

241 {"$ref": "#/definitions/common/inout"}, 

242 { 

243 "type": "object", 

244 "properties": { 

245 "mode": { 

246 "type": "string", 

247 "enum": Image.MODES, 

248 } 

249 }, 

250 }, 

251 ] 

252} 

253 

254 

255def Base_image_convert(self, param): 

256 """ 

257 - name: grayscale 

258 image_convert: 

259 input: filename.png 

260 mode: L 

261 """ 

262 input_filename, filename = inout_fname(param) 

263 img = Image.open(input_filename) 

264 mode = param.get("mode") 

265 if mode is None: 

266 raise Exception(f"invalid parameter: {param}") 

267 img = img.convert(mode) 

268 img.save(filename) 

269 

270 

271def Base_image_chops(self, param): 

272 """ 

273 - name: blend image 

274 image_chops: 

275 input: filename.png 

276 filter: 

277 - blend: [addimage.png, 0.5] 

278 - darker: darklimit.png 

279 """ 

280 input_filename, filename = inout_fname(param) 

281 img = Image.open(input_filename) 

282 for f in param.get("filter", []): 

283 if not isinstance(f, dict): 

284 raise Exception(f"invalid parameter: {param}") 

285 for k, v in f.items(): 

286 fn = getattr(ImageChops, k) 

287 if not callable(fn): 

288 raise Exception(f"chop {k}({v}) not found") 

289 self.log.debug("chop %s %s", k, v) 

290 if isinstance(v, (list, tuple)): 

291 fname = v[0] 

292 args = v[1:] 

293 else: 

294 fname = v 

295 args = [] 

296 img2 = Image.open(fname) 

297 img = fn(img, img2, *args) 

298 img.save(filename) 

299 

300 

301def Base_image_enhance(self, param): 

302 """ 

303 - name: enhance image 

304 image_enhance: 

305 input: filename.png 

306 filter: 

307 - Sharpness: 0.5 

308 - Brightness: 1.2 

309 """ 

310 input_filename, filename = inout_fname(param) 

311 img = Image.open(input_filename) 

312 for f in param.get("filter", []): 

313 if not isinstance(f, dict): 

314 raise Exception(f"invalid parameter: {param}") 

315 for k, v in f.items(): 

316 fn = getattr(ImageEnhance, k) 

317 if not callable(fn): 

318 raise Exception(f"enhance {k}({v}) not found") 

319 self.log.debug("enhance %s %s", k, v) 

320 enhancer = fn(img) 

321 img = enhancer.enhance(v) 

322 img.save(filename) 

323 

324 

325def Base_image_ops(self, param): 

326 """ 

327 - name: image op 

328 image_ops: 

329 input: filename.png 

330 filter: 

331 - autocontrast: [] 

332 - crop: [0] 

333 - mirror: [] 

334 """ 

335 input_filename, filename = inout_fname(param) 

336 img = Image.open(input_filename) 

337 for f in param.get("filter", []): 

338 if not isinstance(f, dict): 

339 raise Exception(f"invalid parameter: {param}") 

340 for k, v in f.items(): 

341 fn = getattr(ImageOps, k) 

342 if not callable(fn): 

343 raise Exception(f"ops {k}({v}) not found") 

344 self.log.debug("ops %s %s", k, v) 

345 img = fn(img, *v) 

346 img.save(filename) 

347 

348 

349def Base_image_archive(self, param): 

350 input_filename, filename = inout_fname(param) 

351 delflag = param.get("delete", True) 

352 assert input_filename != filename 

353 _base, ext = os.path.splitext(filename) 

354 if ext in (".zip", ".cbz"): 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true

355 with zipfile.ZipFile(filename, "a") as zf: 

356 self.log.debug("zip %s %s", filename, input_filename) 

357 zf.write(input_filename) 

358 if delflag: 

359 os.unlink(input_filename) 

360 elif ext in (".tar"): 360 ↛ 367line 360 didn't jump to line 367 because the condition on line 360 was always true

361 with tarfile.open(filename, "a") as tf: 

362 self.log.debug("tar %s %s", filename, input_filename) 

363 tf.add(input_filename) 

364 if delflag: 364 ↛ exitline 364 didn't jump to the function exit

365 os.unlink(input_filename) 

366 else: 

367 raise Exception(f"not implemented yet: archive {param}")