Coverage for paintmaskanimator/imaging.py: 84%

225 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""Pure image-conversion helpers (QImage <-> NumPy <-> PIL). 

2 

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 

9 

10import numpy as np 

11from PySide6.QtGui import QImage 

12 

13from .optional_deps import PILImage 

14from .constants import TP_MASK_PROXY_THRESHOLD 

15from . import native 

16 

17 

18def qimage_buffer(image: QImage, *, const: bool = False) -> Any: 

19 """Return a sized Python buffer for a QImage across PySide6 versions. 

20 

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 

30 

31 

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) 

40 

41 byte_count = int(converted.sizeInBytes()) 

42 ptr = qimage_buffer(converted, const=True) 

43 

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 ) 

58 

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 ) 

65 

66 rows = flat[:expected].reshape( 

67 (height, bytes_per_line) 

68 ) 

69 return rows[:, :width * 4].reshape( 

70 (height, width, 4) 

71 ).copy() 

72 

73 

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() 

84 

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 ) 

101 

102 

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() 

110 

111 

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() 

122 

123 

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

131 

132 

133def pil_rgba_to_qimage(image): 

134 rgba = np.asarray(image.convert("RGBA"), dtype=np.uint8) 

135 return rgba_array_to_qimage(rgba) 

136 

137 

138def tp_uses_proxy(target_width, target_height): 

139 return max(int(target_width), int(target_height)) >= TP_MASK_PROXY_THRESHOLD 

140 

141 

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] 

150 

151 

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] 

156 

157 

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) 

163 

164 

165def scanline_connected_region_numpy(passable, starts): 

166 """4近傍の塗りつぶし領域を、行ごとの連続区間(ラン)単位で求める(numpy 版)。 

167 

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 

185 

186 runs = {} 

187 

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 

194 

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) 

212 

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 

234 

235 

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

241 

242 

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) 

250 

251 

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] 

256 

257 

258#: 行ブロック単位で処理する行数。巨大な一時配列(10000×10000 で数百MB)を 

259#: 確保するとページフォールトだけで数百msかかるため、小分けにする。 

260_MASK_CHUNK_ROWS = 256 

261 

262 

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 

270 

271 

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) 

286 

287 

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 

293 

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) 

304 

305 

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 

313 

314 

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() 

328 

329 

330def white_to_transparent_qimage(image): 

331 """Convert exact #FFFFFF opaque pixels to alpha=0 (QImage in/out). 

332 

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) 

360 

361 

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

368 

369 

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

376 

377 

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 

390 

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 

406