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
« 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
7import yaml
8from PIL import (
9 Image,
10 ImageChops,
11 ImageColor,
12 ImageDraw,
13 ImageEnhance,
14 ImageFilter,
15 ImageFont,
16 ImageOps,
17)
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
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""")
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}")
83image_optimize_schema = yaml.safe_load("""
84allOf:
85 - "$ref": "#/definitions/common/inout"
86 - type: object
87 properties:
88 command: {type: string}
89""")
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)
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""")
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)
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)
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)
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}
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)
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)
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)
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)
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}")