Coverage for paintmaskanimator/imaging.py: 84%
225 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1"""Pure image-conversion helpers (QImage <-> NumPy <-> PIL).
3These are stateless functions extracted from PaintCanvas so the numpy/Qt/PIL
4buffer-handling can be unit-tested without a widget. PaintCanvas keeps thin
5static/class-method wrappers that delegate here, so its call sites are
6unchanged.
7"""
8from typing import Any
10import numpy as np
11from PySide6.QtGui import QImage
13from .optional_deps import PILImage
14from .constants import TP_MASK_PROXY_THRESHOLD
15from . import native
18def qimage_buffer(image: QImage, *, const: bool = False) -> Any:
19 """Return a sized Python buffer for a QImage across PySide6 versions.
21 Older Shiboken bindings expose ``setsize`` while newer bindings already
22 return a correctly sized memoryview. Keeping the compatibility probe here
23 prevents every image operation from duplicating an exception-based check.
24 """
25 pointer: Any = image.constBits() if const else image.bits()
26 setsize = getattr(pointer, "setsize", None)
27 if callable(setsize): 27 ↛ 28line 27 didn't jump to line 28 because the condition on line 27 was never true
28 setsize(int(image.sizeInBytes()))
29 return pointer
32def qimage_rgba_array(image):
33 """PySide6の版差に依存しないQImage→NumPy変換。"""
34 converted = image.convertToFormat(
35 QImage.Format.Format_RGBA8888
36 )
37 width, height = converted.width(), converted.height()
38 if width <= 0 or height <= 0:
39 return np.zeros((0, 0, 4), dtype=np.uint8)
41 byte_count = int(converted.sizeInBytes())
42 ptr = qimage_buffer(converted, const=True)
44 try:
45 flat = np.frombuffer(
46 ptr,
47 dtype=np.uint8,
48 count=byte_count,
49 )
50 except (TypeError, BufferError, ValueError):
51 # Python 3.14/一部のPySide6でShibokenのバッファ公開形式が
52 #異なる場合に、bytesへ固定して読み取る。
53 flat = np.frombuffer(
54 bytes(ptr),
55 dtype=np.uint8,
56 count=byte_count,
57 )
59 bytes_per_line = int(converted.bytesPerLine())
60 expected = height * bytes_per_line
61 if flat.size < expected: 61 ↛ 62line 61 didn't jump to line 62 because the condition on line 61 was never true
62 raise ValueError(
63 "画像バッファのサイズが不足しています。"
64 )
66 rows = flat[:expected].reshape(
67 (height, bytes_per_line)
68 )
69 return rows[:, :width * 4].reshape(
70 (height, width, 4)
71 ).copy()
74def rgba_array_to_qimage(rgba):
75 """NumPyの寿命やbuffer仕様に依存しないQImage変換。"""
76 rgba = np.ascontiguousarray(rgba, dtype=np.uint8)
77 if rgba.ndim != 3 or rgba.shape[2] != 4:
78 raise ValueError(
79 "RGBA配列は高さ×幅×4である必要があります。"
80 )
81 height, width = rgba.shape[:2]
82 if width <= 0 or height <= 0: 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true
83 return QImage()
85 stride = int(rgba.strides[0])
86 raw = rgba.tobytes(order="C")
87 image = QImage(
88 raw,
89 width,
90 height,
91 stride,
92 QImage.Format.Format_RGBA8888,
93 )
94 if image.isNull(): 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true
95 raise ValueError(
96 "階調化画像をQImageへ変換できませんでした。"
97 )
98 return image.copy().convertToFormat(
99 QImage.Format.Format_ARGB32_Premultiplied
100 )
103def pil_l_to_qimage(mask):
104 gray = mask.convert("L")
105 width, height = gray.size
106 raw = gray.tobytes()
107 return QImage(
108 raw, width, height, width, QImage.Format.Format_Grayscale8
109 ).copy()
112def qimage_gray_array(image):
113 gray = image.convertToFormat(QImage.Format.Format_Grayscale8)
114 width, height = gray.width(), gray.height()
115 if width <= 0 or height <= 0: 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true
116 return np.zeros((0, 0), dtype=np.uint8)
117 ptr = qimage_buffer(gray)
118 rows = np.frombuffer(ptr, dtype=np.uint8).reshape(
119 (height, gray.bytesPerLine())
120 )
121 return rows[:, :width].copy()
124def qimage_to_pil_rgba(image):
125 if PILImage is None:
126 return None
127 rgba = qimage_rgba_array(image)
128 if rgba.size == 0: 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true
129 return PILImage.new("RGBA", (1, 1), (0, 0, 0, 0))
130 return PILImage.fromarray(rgba, "RGBA")
133def pil_rgba_to_qimage(image):
134 rgba = np.asarray(image.convert("RGBA"), dtype=np.uint8)
135 return rgba_array_to_qimage(rgba)
138def tp_uses_proxy(target_width, target_height):
139 return max(int(target_width), int(target_height)) >= TP_MASK_PROXY_THRESHOLD
142def _row_runs(row):
143 """1行の True の連続区間を (開始, 終了[排他]) の配列で返す。"""
144 padded = np.empty(row.size + 2, dtype=np.int8)
145 padded[0] = 0
146 padded[-1] = 0
147 padded[1:-1] = row
148 edges = np.flatnonzero(np.diff(padded))
149 return edges[0::2], edges[1::2]
152def _seed_list(starts):
153 if isinstance(starts, tuple) and len(starts) == 2 and isinstance(starts[0], (int, np.integer)):
154 return [(int(starts[0]), int(starts[1]))]
155 return [(int(x), int(y)) for x, y in starts]
158def scanline_connected_region(passable, starts):
159 """4近傍の塗りつぶし領域。Rustで求める。"""
160 passable = np.asarray(passable, dtype=bool)
161 seeds = _seed_list(starts)
162 return native.flood_fill(passable, seeds)
165def scanline_connected_region_numpy(passable, starts):
166 """4近傍の塗りつぶし領域を、行ごとの連続区間(ラン)単位で求める(numpy 版)。
168 画素ごとにPythonで走査すると大きなキャンバスで秒単位かかるため、
169 訪れた行だけランをnumpyで切り出し、ラン同士の重なりでつなぐ。
170 """
171 passable = np.asarray(passable, dtype=bool)
172 height, width = passable.shape
173 region = np.zeros((height, width), dtype=bool)
174 seeds = np.array(_seed_list(starts), dtype=np.int64)
175 if seeds.size == 0: 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true
176 return region
177 seeds = seeds.reshape(-1, 2)
178 inside = (
179 (seeds[:, 0] >= 0) & (seeds[:, 1] >= 0)
180 & (seeds[:, 0] < width) & (seeds[:, 1] < height)
181 )
182 seeds = seeds[inside]
183 if seeds.size == 0:
184 return region
186 runs = {}
188 def runs_of(y):
189 cached = runs.get(y)
190 if cached is None:
191 cached = _row_runs(passable[y])
192 runs[y] = cached
193 return cached
195 visited = set()
196 stack = []
197 for y in np.unique(seeds[:, 1]).tolist():
198 run_starts, run_ends = runs_of(y)
199 if run_starts.size == 0:
200 continue
201 xs = seeds[seeds[:, 1] == y, 0]
202 indices = np.searchsorted(run_ends, xs, side="right")
203 valid = indices < run_starts.size
204 indices = indices[valid]
205 xs = xs[valid]
206 indices = indices[run_starts[indices] <= xs]
207 for index in np.unique(indices).tolist():
208 key = (y, index)
209 if key not in visited: 209 ↛ 207line 209 didn't jump to line 207 because the condition on line 209 was always true
210 visited.add(key)
211 stack.append(key)
213 while stack:
214 y, index = stack.pop()
215 run_starts, run_ends = runs[y]
216 left = int(run_starts[index])
217 right = int(run_ends[index])
218 region[y, left:right] = True
219 for next_y in (y - 1, y + 1):
220 if next_y < 0 or next_y >= height:
221 continue
222 next_starts, next_ends = runs_of(next_y)
223 if next_starts.size == 0:
224 continue
225 # 列が重なるラン(4近傍でつながるラン)だけを辿る。
226 low = int(np.searchsorted(next_ends, left, side="right"))
227 high = int(np.searchsorted(next_starts, right, side="left"))
228 for next_index in range(low, high):
229 key = (next_y, next_index)
230 if key not in visited:
231 visited.add(key)
232 stack.append(key)
233 return region
236#: RGBA8888 の1画素を uint32 として読んだときのアルファのビット。
237#: リトルエンディアンでは R が最下位、A が最上位のバイトになる。
238_ALPHA_BITS = np.array([0, 0, 0, 255], dtype=np.uint8).view(np.uint32)[0]
239_RGB_BITS = np.array([255, 255, 255, 0], dtype=np.uint8).view(np.uint32)[0]
240_LITTLE_ENDIAN_ALPHA = bool(_ALPHA_BITS == np.uint32(0xFF000000))
243def rgba_packed_view(pixels):
244 """(h, w, 4) の RGBA8888 配列を、コピーせず (h, w) の uint32 として見る。"""
245 pixels = np.asarray(pixels)
246 height, width = pixels.shape[:2]
247 if not pixels.flags.c_contiguous:
248 pixels = np.ascontiguousarray(pixels)
249 return pixels.reshape(height, width * 4).view(np.uint32)
252def pack_rgb(rgb):
253 """(r, g, b) を不透明の RGBA8888 uint32 値にする。"""
254 r, g, b = (int(channel) for channel in rgb[:3])
255 return np.array([r, g, b, 255], dtype=np.uint8).view(np.uint32)[0]
258#: 行ブロック単位で処理する行数。巨大な一時配列(10000×10000 で数百MB)を
259#: 確保するとページフォールトだけで数百msかかるため、小分けにする。
260_MASK_CHUNK_ROWS = 256
263def _chunked_mask(packed, compute):
264 height = packed.shape[0]
265 result = np.empty(packed.shape, dtype=bool)
266 for top in range(0, height, _MASK_CHUNK_ROWS):
267 bottom = min(height, top + _MASK_CHUNK_ROWS)
268 result[top:bottom] = compute(packed[top:bottom])
269 return result
272def background_mask_packed(packed):
273 """透明(alpha=0)または #FFFFFF の画素。バケツの背景判定と同じ。"""
274 if _LITTLE_ENDIAN_ALPHA: 274 ↛ 281line 274 didn't jump to line 281 because the condition on line 274 was always true
275 # アルファが最上位バイトなので、alpha=0 は値が 0x01000000 未満と同値。
276 def compute(block):
277 return (block < np.uint32(0x01000000)) | (
278 (block | _ALPHA_BITS) == np.uint32(0xFFFFFFFF)
279 )
280 else:
281 def compute(block):
282 return ((block & _ALPHA_BITS) == 0) | (
283 (block & _RGB_BITS) == _RGB_BITS
284 )
285 return _chunked_mask(packed, compute)
288def opaque_rgb_mask_packed(packed, rgb):
289 """アルファが0でなく、RGB が rgb と一致する画素。"""
290 target = pack_rgb(rgb)
291 if _LITTLE_ENDIAN_ALPHA: 291 ↛ 299line 291 didn't jump to line 299 because the condition on line 291 was always true
292 target_with_alpha = target | _ALPHA_BITS
294 def compute(block):
295 return ((block | _ALPHA_BITS) == target_with_alpha) & (
296 block >= np.uint32(0x01000000)
297 )
298 else:
299 def compute(block):
300 return ((block & _RGB_BITS) == (target & _RGB_BITS)) & (
301 (block & _ALPHA_BITS) != 0
302 )
303 return _chunked_mask(packed, compute)
306def mask_bounds(mask):
307 """True の外接矩形を (x0, y0, x1, y1)(x1, y1 は排他)で返す。空なら None。"""
308 rows = np.flatnonzero(mask.any(axis=1))
309 if rows.size == 0:
310 return None
311 cols = np.flatnonzero(mask[rows[0]:rows[-1] + 1].any(axis=0))
312 return int(cols[0]), int(rows[0]), int(cols[-1]) + 1, int(rows[-1]) + 1
315def bool_mask_image(mask):
316 mask = np.asarray(mask, dtype=bool)
317 height, width = mask.shape
318 rgba = np.zeros((height, width, 4), dtype=np.uint8)
319 rgba[:, :, :3] = 255
320 rgba[:, :, 3] = mask.astype(np.uint8) * 255
321 return QImage(
322 rgba.data,
323 width,
324 height,
325 rgba.strides[0],
326 QImage.Format.Format_RGBA8888,
327 ).copy()
330def white_to_transparent_qimage(image):
331 """Convert exact #FFFFFF opaque pixels to alpha=0 (QImage in/out).
333 Used to migrate legacy layer data where pure white was the pseudo-transparent
334 eraser sentinel. Non-destructive to appearance: white was already displayed
335 as transparent under the old pseudo-transparency transform.
336 """
337 if image is None or image.isNull(): 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true
338 return image
339 rgba = image.convertToFormat(QImage.Format.Format_RGBA8888)
340 width, height = rgba.width(), rgba.height()
341 if width <= 0 or height <= 0: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true
342 return image
343 ptr = qimage_buffer(rgba)
344 rows = np.frombuffer(ptr, dtype=np.uint8).reshape(
345 (height, rgba.bytesPerLine())
346 )
347 pixels = rows[:, : width * 4].reshape((height, width, 4))
348 white = (
349 (pixels[:, :, 3] > 0)
350 & (pixels[:, :, 0] == 255)
351 & (pixels[:, :, 1] == 255)
352 & (pixels[:, :, 2] == 255)
353 )
354 if not white.any():
355 return image.convertToFormat(
356 QImage.Format.Format_ARGB32_Premultiplied
357 )
358 pixels[white] = 0
359 return rgba.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
362def tp_transparent_to_white(image):
363 """v0.7 rule: transparent source pixels become opaque #FFFFFF masks."""
364 rgba = np.asarray(image.convert("RGBA"), dtype=np.uint8).copy()
365 transparent = rgba[:, :, 3] == 0
366 rgba[transparent] = (255, 255, 255, 255)
367 return PILImage.fromarray(rgba, "RGBA")
370def tp_white_to_transparent(image):
371 """v0.7 rule: exact #FFFFFF is transparent in the TP result."""
372 rgba = np.asarray(image.convert("RGBA"), dtype=np.uint8).copy()
373 white = np.all(rgba[:, :, :3] == 255, axis=2)
374 rgba[white, 3] = 0
375 return PILImage.fromarray(rgba, "RGBA")
378def tp_prepare_palette_image(image, max_colors=64):
379 """Port of prepare_palette_image() from the v0.7 prototype."""
380 source = image.convert("RGBA")
381 colors = source.getcolors(maxcolors=max_colors + 1)
382 opaque = (
383 [(count, rgba) for count, rgba in colors if rgba[3] > 0]
384 if colors is not None
385 else []
386 )
387 if colors is not None and len(opaque) <= max_colors:
388 palette = [rgba for _count, rgba in sorted(opaque, reverse=True)]
389 return source, palette
391 alpha = source.getchannel("A")
392 rgb = source.convert("RGB").quantize(
393 colors=max_colors,
394 method=PILImage.Quantize.MEDIANCUT,
395 dither=PILImage.Dither.NONE,
396 ).convert("RGBA")
397 rgb.putalpha(alpha.point(lambda value: 255 if value >= 96 else 0))
398 colors = rgb.getcolors(maxcolors=1_000_000) or []
399 palette = [
400 rgba for _count, rgba in sorted(
401 ((count, rgba) for count, rgba in colors if rgba[3] > 0),
402 reverse=True,
403 )
404 ]
405 return rgb, palette