Coverage for paintmaskanimator/canvas_transform_mask.py: 68%

456 statements  

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

1"""TP-quality transform: color-mask preparation and deformation mesh. 

2 

3Split out of ``canvas.py`` as a mixin. These methods build and cache the 

4per-color masks used by the "TP quality" transform, render/project those masks 

5through the deformation mesh, and manage the mesh grid + preview. They run 

6against a live ``PaintCanvas`` instance and reuse its selection/transform state. 

7 

8The colors are held as a single *label image* (one id per pixel) rather than one 

9grayscale mask per color, and the deformation is resolved by 

10:mod:`.mask_transform`, which inverse-maps every output pixel and gives it to 

11whichever color covers most of it. That keeps the result strictly binary while 

12producing a regular staircase along rotated edges; see ``mask_transform`` for 

13why the previous forward-mapping + blur approach could not. 

14""" 

15import math 

16import numpy as np 

17from PySide6.QtCore import QPointF, QRectF, Qt 

18from PySide6.QtGui import QImage, QPainter 

19from .i18n import tr 

20from .optional_deps import PILImage, PILImageFilter 

21from .constants import TP_MASK_MAX_TRANSFORM_COLORS, TP_MASK_PROXY_SUBSAMPLES 

22from ._canvas_members import CanvasMembers 

23from . import geometry, imaging, mask_transform 

24from .utils import workspace_size 

25from .logging_setup import get_logger 

26 

27log = get_logger(__name__) 

28 

29 

30class TransformMaskMixin(CanvasMembers): 

31 def _invalidate_tp_preview_cache(self, geometry=True): 

32 """Invalidate TP output; geometry=False keeps transformed mask cache.""" 

33 self._tp_preview_cache_key = None 

34 self._tp_preview_cache_image = None 

35 if geometry: 

36 self._tp_geometry_cache_key = None 

37 self._tp_geometry_cache_bbox = None 

38 self._tp_geometry_cache_fill_overlay = None 

39 self._tp_geometry_cache_line_soft = [] 

40 

41 def _clear_tp_transform_masks(self, clear_proxy=True): 

42 self.transform_tp_palette = [] 

43 self.transform_tp_masks = [] 

44 self.transform_tp_line_masks = [] 

45 self.transform_tp_prepared_preview = None 

46 self._tp_label_image = None 

47 self._tp_label_colors = None 

48 self._tp_mask_source_key = None 

49 if clear_proxy: 

50 self._tp_proxy_source_key = None 

51 self._tp_proxy_source_image = None 

52 self._invalidate_tp_preview_cache() 

53 

54 def _tp_mask_key(self, source): 

55 if source is None or source.isNull(): 55 ↛ 56line 55 didn't jump to line 56 because the condition on line 55 was never true

56 return None 

57 return ( 

58 int(source.cacheKey()), 

59 int(source.width()), 

60 int(source.height()), 

61 tuple(self.transform_tp_line_colors), 

62 bool(self._tp_proxy_rendering), 

63 ) 

64 

65 @staticmethod 

66 def _tp_uses_proxy(*args, **kwargs): 

67 return imaging.tp_uses_proxy(*args, **kwargs) 

68 

69 def _tp_geometry_cache_is_current(self, source=None, target_width=None, target_height=None): 

70 source = source if source is not None else self.transform_source 

71 if source is None or source.isNull() or not self.transform_points: 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true

72 return False 

73 target_width = int( 

74 target_width if target_width is not None else self.active_layer.image.width() 

75 ) 

76 target_height = int( 

77 target_height if target_height is not None else self.active_layer.image.height() 

78 ) 

79 bbox, _raw_selection = self._tp_transform_bbox(target_width, target_height) 

80 if bbox.isEmpty(): 80 ↛ 81line 80 didn't jump to line 81 because the condition on line 80 was never true

81 return self._tp_geometry_cache_key is not None 

82 key = self._tp_geometry_key(source, target_width, target_height, bbox) 

83 return ( 

84 self._tp_geometry_cache_key == key 

85 and self._tp_geometry_cache_fill_overlay is not None 

86 ) 

87 

88 def init_mesh(self): 

89 w,h=workspace_size(); self.mesh_original=self.active_layer.image.copy(); self.mesh_points=[] 

90 for gy in range(self.mesh_grid): 

91 for gx in range(self.mesh_grid): self.mesh_points.append(QPointF(gx*w/(self.mesh_grid-1),gy*h/(self.mesh_grid-1))) 

92 

93 def nearest_mesh(self,p): 

94 if not self.mesh_points:return -1 

95 d=[(q.x()-p.x())**2+(q.y()-p.y())**2 for q in self.mesh_points]; i=int(np.argmin(d)); return i if d[i]<(40/max(self.zoom,.01))**2 else -1 

96 

97 def commit_mesh(self): 

98 if self.mesh_original is None or not self.mesh_points:return 

99 self.apply_mesh_release() 

100 

101 def apply_mesh_release(self): 

102 if self.mesh_original is None:return 

103 img=self.mesh_original.convertToFormat(QImage.Format.Format_RGBA8888); w,h=img.width(),img.height() 

104 ptr=img.bits(); arr=np.frombuffer(ptr,dtype=np.uint8,count=w*h*4).reshape((h,w,4)).copy() 

105 yy,xx=np.mgrid[0:h,0:w].astype(np.float32); dx=np.zeros_like(xx);dy=np.zeros_like(yy);ws=np.zeros_like(xx) 

106 originals=[] 

107 for gy in range(self.mesh_grid): 

108 for gx in range(self.mesh_grid): originals.append(QPointF(gx*w/(self.mesh_grid-1),gy*h/(self.mesh_grid-1))) 

109 radius=max(w,h)/2.0 

110 for o,n in zip(originals,self.mesh_points): 

111 dist2=(xx-o.x())**2+(yy-o.y())**2; weight=np.exp(-dist2/(2*(radius/2.5)**2)); dx+=(n.x()-o.x())*weight;dy+=(n.y()-o.y())*weight;ws+=weight 

112 sx=np.clip(np.rint(xx-dx/np.maximum(ws,.001)),0,w-1).astype(np.int32); sy=np.clip(np.rint(yy-dy/np.maximum(ws,.001)),0,h-1).astype(np.int32) 

113 out=arr[sy,sx]; q=QImage(out.data,w,h,out.strides[0],QImage.Format.Format_RGBA8888).copy().convertToFormat(QImage.Format.Format_ARGB32_Premultiplied) 

114 self.active_layer.image=q; self.active_layer.has_content=True; self.mesh_original=None; self.mesh_points=[]; self.cellChanged.emit(self.current_frame,self.active_layer_index); self.update() 

115 

116 def cancel_mesh(self): 

117 self.mesh_original=None; self.mesh_points=[]; self.mesh_active=-1; self.update() 

118 

119 def _regular_mesh_reference_points(self, rect, cols, rows): 

120 cols = max(2, int(cols)) 

121 rows = max(2, int(rows)) 

122 source = getattr(self, "transform_source", None) 

123 

124 if source is not None and not source.isNull(): 

125 pixel_width = max(0.0, float(source.width() - 1)) 

126 pixel_height = max(0.0, float(source.height() - 1)) 

127 else: 

128 pixel_width = max(0.0, float(rect.width()) - 1.0) 

129 pixel_height = max(0.0, float(rect.height()) - 1.0) 

130 

131 return [ 

132 QPointF( 

133 float(rect.left()) 

134 + pixel_width * gx / float(cols - 1), 

135 float(rect.top()) 

136 + pixel_height * gy / float(rows - 1), 

137 ) 

138 for gy in range(rows) 

139 for gx in range(cols) 

140 ] 

141 

142 def set_transform_mesh_grid(self, cols, rows=None): 

143 from .undo_entries import TransformUndo 

144 before = TransformUndo.capture(self) if self.transform_active else None 

145 cols = max(2, min(12, int(cols))) 

146 rows = max(2, min(12, int(rows if rows is not None else cols))) 

147 self.transform_mesh_cols = cols 

148 self.transform_mesh_rows = rows 

149 self.transform_mesh_grid = cols 

150 if not self.transform_active or self.transform_mode != "mesh": 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true

151 return 

152 

153 old_points = list(self.transform_points) 

154 old_cols = max(2, int(getattr(self, "_active_mesh_cols", 0) or 0)) 

155 old_rows = max(2, int(getattr(self, "_active_mesh_rows", 0) or 0)) 

156 if old_cols * old_rows != len(old_points): 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true

157 old_cols = max(2, int(round(math.sqrt(len(old_points))))) if old_points else 0 

158 old_rows = old_cols if old_cols and old_cols * old_cols == len(old_points) else 0 

159 

160 if old_cols < 2 or old_rows < 2 or old_cols * old_rows != len(old_points): 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true

161 self.transform_points = self._regular_grid_points( 

162 self.transform_source_rect, cols, rows 

163 ) 

164 else: 

165 def sample(u, v): 

166 x = max(0.0, min(old_cols - 1.0, u * (old_cols - 1))) 

167 y = max(0.0, min(old_rows - 1.0, v * (old_rows - 1))) 

168 x0, y0 = int(math.floor(x)), int(math.floor(y)) 

169 x1, y1 = min(old_cols - 1, x0 + 1), min(old_rows - 1, y0 + 1) 

170 tx, ty = x - x0, y - y0 

171 p00 = old_points[y0 * old_cols + x0] 

172 p10 = old_points[y0 * old_cols + x1] 

173 p01 = old_points[y1 * old_cols + x0] 

174 p11 = old_points[y1 * old_cols + x1] 

175 return QPointF( 

176 (1 - ty) * ((1 - tx) * p00.x() + tx * p10.x()) 

177 + ty * ((1 - tx) * p01.x() + tx * p11.x()), 

178 (1 - ty) * ((1 - tx) * p00.y() + tx * p10.y()) 

179 + ty * ((1 - tx) * p01.y() + tx * p11.y()), 

180 ) 

181 

182 self.transform_points = [ 

183 sample(gx / (cols - 1), gy / (rows - 1)) 

184 for gy in range(rows) 

185 for gx in range(cols) 

186 ] 

187 

188 self.transform_mesh_reference_points = ( 

189 self._regular_mesh_reference_points( 

190 self.transform_source_rect, 

191 cols, 

192 rows, 

193 ) 

194 ) 

195 self._active_mesh_cols = cols 

196 self._active_mesh_rows = rows 

197 self.transform_handle = -1 

198 if before is not None: 198 ↛ 200line 198 didn't jump to line 200 because the condition on line 198 was always true

199 self._record_transform_history(before) 

200 self._invalidate_tp_preview_cache() 

201 if self.transform_quality_active: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true

202 self.request_quality_preview_counter( 

203 tr("メッシュ格子のプレビューを生成しています") 

204 ) 

205 self.update() 

206 

207 @staticmethod 

208 def _mesh_catmull_scalar(p0, p1, p2, p3, t): 

209 return geometry.mesh_catmull_scalar(p0, p1, p2, p3, t) 

210 

211 def _mesh_reference_grid(self, cols, rows): 

212 reference = list( 

213 getattr( 

214 self, 

215 "transform_mesh_reference_points", 

216 [], 

217 ) 

218 ) 

219 if len(reference) == cols * rows: 219 ↛ 222line 219 didn't jump to line 222 because the condition on line 219 was always true

220 return [QPointF(point) for point in reference] 

221 

222 rect = self.transform_source_rect 

223 if rect is None: 

224 return [] 

225 return self._regular_mesh_reference_points( 

226 QRectF(rect), 

227 cols, 

228 rows, 

229 ) 

230 

231 def _mesh_curve_point(self, points, cols, rows, grid_x, grid_y): 

232 """基準格子からの変位だけを滑らかに補間する。""" 

233 if not points or cols < 2 or rows < 2: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true

234 return QPointF() 

235 

236 reference = self._mesh_reference_grid(cols, rows) 

237 if len(reference) != cols * rows: 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true

238 return QPointF() 

239 

240 grid_x = max( 

241 0.0, 

242 min(float(cols - 1), float(grid_x)), 

243 ) 

244 grid_y = max( 

245 0.0, 

246 min(float(rows - 1), float(grid_y)), 

247 ) 

248 cell_x = min(cols - 2, int(math.floor(grid_x))) 

249 cell_y = min(rows - 2, int(math.floor(grid_y))) 

250 local_x = grid_x - cell_x 

251 local_y = grid_y - cell_y 

252 

253 def clamped_index(row, col): 

254 row = max(0, min(rows - 1, int(row))) 

255 col = max(0, min(cols - 1, int(col))) 

256 return row * cols + col 

257 

258 def displacement_at(row, col): 

259 index = clamped_index(row, col) 

260 return QPointF( 

261 points[index].x() - reference[index].x(), 

262 points[index].y() - reference[index].y(), 

263 ) 

264 

265 horizontal_displacements = [] 

266 for row in range(cell_y - 1, cell_y + 3): 

267 p0 = displacement_at(row, cell_x - 1) 

268 p1 = displacement_at(row, cell_x) 

269 p2 = displacement_at(row, cell_x + 1) 

270 p3 = displacement_at(row, cell_x + 2) 

271 horizontal_displacements.append(QPointF( 

272 self._mesh_catmull_scalar( 

273 p0.x(), 

274 p1.x(), 

275 p2.x(), 

276 p3.x(), 

277 local_x, 

278 ), 

279 self._mesh_catmull_scalar( 

280 p0.y(), 

281 p1.y(), 

282 p2.y(), 

283 p3.y(), 

284 local_x, 

285 ), 

286 )) 

287 

288 displacement = QPointF( 

289 self._mesh_catmull_scalar( 

290 horizontal_displacements[0].x(), 

291 horizontal_displacements[1].x(), 

292 horizontal_displacements[2].x(), 

293 horizontal_displacements[3].x(), 

294 local_y, 

295 ), 

296 self._mesh_catmull_scalar( 

297 horizontal_displacements[0].y(), 

298 horizontal_displacements[1].y(), 

299 horizontal_displacements[2].y(), 

300 horizontal_displacements[3].y(), 

301 local_y, 

302 ), 

303 ) 

304 

305 # 基準格子上の位置は双線形補間する。 

306 i00 = cell_y * cols + cell_x 

307 i10 = cell_y * cols + cell_x + 1 

308 i01 = (cell_y + 1) * cols + cell_x 

309 i11 = (cell_y + 1) * cols + cell_x + 1 

310 r00 = reference[i00] 

311 r10 = reference[i10] 

312 r01 = reference[i01] 

313 r11 = reference[i11] 

314 

315 base = QPointF( 

316 (1.0 - local_y) 

317 * ( 

318 (1.0 - local_x) * r00.x() 

319 + local_x * r10.x() 

320 ) 

321 + local_y 

322 * ( 

323 (1.0 - local_x) * r01.x() 

324 + local_x * r11.x() 

325 ), 

326 (1.0 - local_y) 

327 * ( 

328 (1.0 - local_x) * r00.y() 

329 + local_x * r10.y() 

330 ) 

331 + local_y 

332 * ( 

333 (1.0 - local_x) * r01.y() 

334 + local_x * r11.y() 

335 ), 

336 ) 

337 

338 return QPointF( 

339 base.x() + displacement.x(), 

340 base.y() + displacement.y(), 

341 ) 

342 

343 def _curved_mesh_points( 

344 self, points, cols, rows, subdivisions=8 

345 ): 

346 """基準格子に滑らかな変位を加えた高密度メッシュを生成する。""" 

347 subdivisions = max(2, int(subdivisions)) 

348 dense_cols = (cols - 1) * subdivisions + 1 

349 dense_rows = (rows - 1) * subdivisions + 1 

350 dense = [] 

351 for dense_y in range(dense_rows): 

352 grid_y = dense_y / float(subdivisions) 

353 for dense_x in range(dense_cols): 

354 grid_x = dense_x / float(subdivisions) 

355 dense.append( 

356 self._mesh_curve_point( 

357 points, 

358 cols, 

359 rows, 

360 grid_x, 

361 grid_y, 

362 ) 

363 ) 

364 return dense, dense_cols, dense_rows 

365 

366 def _mesh_preview_image( 

367 self, source_image=None, target_width=None, target_height=None, smooth=False 

368 ): 

369 # 変形では補間色を生成しない。呼び出し側の指定に関係なく最近傍に固定する。 

370 smooth = False 

371 source_image = source_image if source_image is not None else self.transform_source 

372 if source_image is None: 

373 return None 

374 source = source_image.convertToFormat(QImage.Format.Format_RGBA8888) 

375 sw, sh = source.width(), source.height() 

376 cw = int(target_width if target_width is not None else self.active_layer.image.width()) 

377 ch = int(target_height if target_height is not None else self.active_layer.image.height()) 

378 if sw <= 0 or sh <= 0 or cw <= 0 or ch <= 0: 

379 return None 

380 sp = imaging.qimage_buffer(source) 

381 source_rows = np.frombuffer(sp, dtype=np.uint8).reshape( 

382 (sh, source.bytesPerLine()) 

383 ) 

384 source_pixels = source_rows[:, :sw * 4].reshape((sh, sw, 4)).copy() 

385 output = np.zeros((ch, cw, 4), dtype=np.uint8) 

386 cols = max(2, int(getattr(self, "transform_mesh_cols", 4))) 

387 rows = max(2, int(getattr(self, "transform_mesh_rows", 4))) 

388 if len(self.transform_points) != cols * rows: 

389 return None 

390 

391 def raster_triangle(target_triangle, source_triangle): 

392 tx = np.array([p.x() for p in target_triangle], dtype=np.float64) 

393 ty = np.array([p.y() for p in target_triangle], dtype=np.float64) 

394 min_x = max(0, int(math.floor(float(tx.min())))) 

395 max_x = min(cw - 1, int(math.ceil(float(tx.max())))) 

396 min_y = max(0, int(math.floor(float(ty.min())))) 

397 max_y = min(ch - 1, int(math.ceil(float(ty.max())))) 

398 if max_x < min_x or max_y < min_y: 

399 return 

400 denominator = ( 

401 (ty[1] - ty[2]) * (tx[0] - tx[2]) 

402 + (tx[2] - tx[1]) * (ty[0] - ty[2]) 

403 ) 

404 if abs(denominator) < 1e-8: 

405 return 

406 yy, xx = np.mgrid[min_y:max_y + 1, min_x:max_x + 1] 

407 w0 = ( 

408 (ty[1] - ty[2]) * (xx - tx[2]) 

409 + (tx[2] - tx[1]) * (yy - ty[2]) 

410 ) / denominator 

411 w1 = ( 

412 (ty[2] - ty[0]) * (xx - tx[2]) 

413 + (tx[0] - tx[2]) * (yy - ty[2]) 

414 ) / denominator 

415 w2 = 1.0 - w0 - w1 

416 inside = (w0 >= -1e-6) & (w1 >= -1e-6) & (w2 >= -1e-6) 

417 if not np.any(inside): 

418 return 

419 sx_float = ( 

420 w0 * source_triangle[0][0] 

421 + w1 * source_triangle[1][0] 

422 + w2 * source_triangle[2][0] 

423 ) 

424 sy_float = ( 

425 w0 * source_triangle[0][1] 

426 + w1 * source_triangle[1][1] 

427 + w2 * source_triangle[2][1] 

428 ) 

429 region = output[min_y:max_y + 1, min_x:max_x + 1] 

430 if smooth: 

431 sx_float = np.clip(sx_float, 0.0, sw - 1.0) 

432 sy_float = np.clip(sy_float, 0.0, sh - 1.0) 

433 x0 = np.floor(sx_float).astype(np.int32) 

434 y0 = np.floor(sy_float).astype(np.int32) 

435 x1 = np.minimum(sw - 1, x0 + 1) 

436 y1 = np.minimum(sh - 1, y0 + 1) 

437 tx = (sx_float - x0)[..., None] 

438 ty = (sy_float - y0)[..., None] 

439 sampled = ( 

440 source_pixels[y0, x0].astype(np.float32) * (1.0 - tx) * (1.0 - ty) 

441 + source_pixels[y0, x1].astype(np.float32) * tx * (1.0 - ty) 

442 + source_pixels[y1, x0].astype(np.float32) * (1.0 - tx) * ty 

443 + source_pixels[y1, x1].astype(np.float32) * tx * ty 

444 ) 

445 region[inside] = np.clip( 

446 sampled[inside] + 0.5, 0, 255 

447 ).astype(np.uint8) 

448 else: 

449 sx = np.clip(np.rint(sx_float).astype(np.int32), 0, sw - 1) 

450 sy = np.clip(np.rint(sy_float).astype(np.int32), 0, sh - 1) 

451 region[inside] = source_pixels[sy[inside], sx[inside]] 

452 

453 curved, dense_cols, dense_rows = self._curved_mesh_points( 

454 self.transform_points, cols, rows, subdivisions=8 

455 ) 

456 for dense_y in range(dense_rows - 1): 

457 source_y0 = ( 

458 (sh - 1) * dense_y / max(1, dense_rows - 1) 

459 ) 

460 source_y1 = ( 

461 (sh - 1) * (dense_y + 1) / max(1, dense_rows - 1) 

462 ) 

463 for dense_x in range(dense_cols - 1): 

464 source_x0 = ( 

465 (sw - 1) * dense_x / max(1, dense_cols - 1) 

466 ) 

467 source_x1 = ( 

468 (sw - 1) * (dense_x + 1) 

469 / max(1, dense_cols - 1) 

470 ) 

471 index = dense_y * dense_cols + dense_x 

472 p00 = curved[index] 

473 p10 = curved[index + 1] 

474 p01 = curved[index + dense_cols] 

475 p11 = curved[index + dense_cols + 1] 

476 raster_triangle( 

477 (p00, p10, p11), 

478 ( 

479 (source_x0, source_y0), 

480 (source_x1, source_y0), 

481 (source_x1, source_y1), 

482 ), 

483 ) 

484 raster_triangle( 

485 (p00, p11, p01), 

486 ( 

487 (source_x0, source_y0), 

488 (source_x1, source_y1), 

489 (source_x0, source_y1), 

490 ), 

491 ) 

492 return QImage( 

493 output.data, cw, ch, output.strides[0], QImage.Format.Format_RGBA8888 

494 ).copy().convertToFormat(QImage.Format.Format_ARGB32_Premultiplied) 

495 

496 @staticmethod 

497 def _tp_transparent_to_white(*args, **kwargs): 

498 return imaging.tp_transparent_to_white(*args, **kwargs) 

499 

500 @staticmethod 

501 def _tp_white_to_transparent(*args, **kwargs): 

502 return imaging.tp_white_to_transparent(*args, **kwargs) 

503 

504 @staticmethod 

505 def _tp_prepare_palette_image(*args, **kwargs): 

506 return imaging.tp_prepare_palette_image(*args, **kwargs) 

507 

508 @staticmethod 

509 def _tp_packed_rgb(prepared_array): 

510 """Pack RGB into one uint32 per pixel so colors compare in a single op.""" 

511 rgb = prepared_array[:, :, :3].astype(np.uint32) 

512 return (rgb[:, :, 0] << 16) | (rgb[:, :, 1] << 8) | rgb[:, :, 2] 

513 

514 @classmethod 

515 def _tp_exact_palette(cls, prepared_array): 

516 """Every exact opaque color in the prepared image, sorted.""" 

517 opaque = prepared_array[:, :, 3] > 0 

518 if not np.any(opaque): 518 ↛ 519line 518 didn't jump to line 519 because the condition on line 518 was never true

519 return [] 

520 packed = np.unique(cls._tp_packed_rgb(prepared_array)[opaque]) 

521 return [ 

522 ( 

523 int((value >> 16) & 0xFF), 

524 int((value >> 8) & 0xFF), 

525 int(value & 0xFF), 

526 255, 

527 ) 

528 for value in packed 

529 ] 

530 

531 def _build_tp_label_image(self, prepared_array, palette_rgba, line_colors): 

532 """Turn the prepared image into one label id per pixel. 

533 

534 Label ids double as the tie-break order used when two colors cover the 

535 same output pixel equally: line colors win first, then darker colors 

536 over lighter ones. Id 0 means "no source pixel here". Returns the 

537 ``rgb -> id`` mapping so the caller can record which id each color got. 

538 

539 The pixels are labelled by looking each packed color up in the palette, 

540 so the cost does not grow with the number of colors. 

541 """ 

542 def luminance(color): 

543 red, green, blue = (float(channel) for channel in color[:3]) 

544 return 0.299 * red + 0.587 * green + 0.114 * blue 

545 

546 # Semi-transparent pixels can put the same RGB in the palette twice; 

547 # they render identically here, so one label each is enough. 

548 ordered = [] 

549 seen = set() 

550 for rgb in list(line_colors) + sorted(palette_rgba, key=luminance): 

551 color = tuple(int(channel) for channel in rgb[:3]) 

552 if color not in seen: 552 ↛ 550line 552 didn't jump to line 550 because the condition on line 552 was always true

553 seen.add(color) 

554 ordered.append(color) 

555 

556 label_ids = {rgb: index for index, rgb in enumerate(ordered, 1)} 

557 colors = np.zeros((len(ordered) + 1, 4), dtype=np.uint8) 

558 for index, rgb in enumerate(ordered, 1): 

559 colors[index, :3] = rgb 

560 colors[index, 3] = 255 

561 

562 packed_palette = np.asarray( 

563 [(r << 16) | (g << 8) | b for r, g, b in ordered], 

564 dtype=np.uint32, 

565 ) 

566 order = np.argsort(packed_palette) 

567 sorted_palette = packed_palette[order] 

568 packed = self._tp_packed_rgb(prepared_array) 

569 position = np.searchsorted(sorted_palette, packed) 

570 np.clip(position, 0, max(0, len(ordered) - 1), out=position) 

571 labels = (order[position] + 1).astype(np.uint16) 

572 labels[prepared_array[:, :, 3] == 0] = 0 

573 if len(ordered): 573 ↛ 584line 573 didn't jump to line 584 because the condition on line 573 was always true

574 # Quantization can leave a few pixels off-palette; searchsorted then 

575 # lands on a neighbour, which is the nearest color in packed order 

576 # and keeps the transform from punching holes the source lacks. 

577 missed = sorted_palette[position] != packed 

578 if np.any(missed): 578 ↛ 579line 578 didn't jump to line 579 because the condition on line 578 was never true

579 log.debug( 

580 "%d pixel(s) were not an exact palette color", 

581 int(np.count_nonzero(missed)), 

582 ) 

583 

584 self._tp_label_image = labels 

585 self._tp_label_colors = colors 

586 return label_ids 

587 

588 def _prepare_tp_transform_masks(self, source_image=None): 

589 """Build the same exact-color masks used by TP Mask Transform v0.7.""" 

590 self._clear_tp_transform_masks(clear_proxy=False) 

591 source = source_image if source_image is not None else self.transform_source 

592 if ( 592 ↛ 596line 592 didn't jump to line 596 because the condition on line 592 was never true

593 PILImage is None or PILImageFilter is None 

594 or source is None or source.isNull() 

595 ): 

596 return False 

597 

598 source_rgba = self._qimage_to_pil_rgba(source).convert("RGBA") 

599 prepared = self._tp_transparent_to_white(source_rgba) 

600 prepared_array = np.asarray(prepared.convert("RGBA"), dtype=np.uint8) 

601 palette_rgba = self._tp_exact_palette(prepared_array) 

602 if len(palette_rgba) > TP_MASK_MAX_TRANSFORM_COLORS: 602 ↛ 606line 602 didn't jump to line 606 because the condition on line 602 was never true

603 # Beyond this the source is not flat-colored art, and one label per 

604 # color stops being meaningful. Reducing is still better than 

605 # refusing, but say so instead of changing the colors silently. 

606 prepared, palette_rgba = self._tp_prepare_palette_image( 

607 source_rgba, TP_MASK_MAX_TRANSFORM_COLORS 

608 ) 

609 prepared_array = np.asarray( 

610 prepared.convert("RGBA"), dtype=np.uint8 

611 ) 

612 palette_rgba = self._tp_exact_palette(prepared_array) 

613 self.status_message.emit( 

614 tr("色数が多いため {len} 色へ減色して変形します。先に2値化しておくと元の色のまま変形できます。").format(len=len(palette_rgba)) 

615 ) 

616 line_colors = [] 

617 for line_color in self.transform_tp_line_colors: 

618 present = np.any( 

619 (prepared_array[:, :, 3] > 0) 

620 & np.all( 

621 prepared_array[:, :, :3] 

622 == np.asarray( 

623 tuple(int(channel) for channel in line_color), 

624 dtype=np.uint8, 

625 ), 

626 axis=2, 

627 ) 

628 ) 

629 if present: 629 ↛ 617line 629 didn't jump to line 617 because the condition on line 629 was always true

630 line_colors.append(tuple(int(c) for c in line_color)) 

631 palette_rgba = [ 

632 color for color in palette_rgba 

633 if tuple(color[:3]) != tuple(line_color) 

634 ] 

635 

636 label_ids = self._build_tp_label_image( 

637 prepared_array, palette_rgba, line_colors 

638 ) 

639 self.transform_tp_palette = list(palette_rgba) 

640 # These stay parallel to the palette so callers that only need their 

641 # length (progress totals) or truthiness keep working; the pixels now 

642 # live in the shared label image instead of one mask per color. 

643 self.transform_tp_masks = [ 

644 label_ids[tuple(color[:3])] for color in palette_rgba 

645 ] 

646 self.transform_tp_line_masks = [ 

647 (color, label_ids[color]) for color in line_colors 

648 ] 

649 self.transform_tp_prepared_preview = self._pil_rgba_to_qimage( 

650 self._tp_white_to_transparent(prepared) 

651 ) 

652 self._tp_mask_source_key = self._tp_mask_key(source) 

653 self._invalidate_tp_preview_cache() 

654 return bool(self.transform_tp_masks or self.transform_tp_line_masks) 

655 

656 def _tp_transform_bbox(self, target_width, target_height): 

657 outline = self.transform_outer_polygon() 

658 if outline.isEmpty(): 658 ↛ 659line 658 didn't jump to line 659 because the condition on line 658 was never true

659 return QRectF(), QRectF() 

660 raw_bounds = outline.boundingRect() 

661 canvas = QRectF(0, 0, target_width, target_height) 

662 expanded = raw_bounds.adjusted(-3, -3, 3, 3).intersected(canvas) 

663 if expanded.isEmpty(): 663 ↛ 664line 663 didn't jump to line 664 because the condition on line 663 was never true

664 return QRectF(), raw_bounds.intersected(canvas) 

665 left = max(0, int(math.floor(expanded.left()))) 

666 top = max(0, int(math.floor(expanded.top()))) 

667 right = min(target_width, int(math.ceil(expanded.right()))) 

668 bottom = min(target_height, int(math.ceil(expanded.bottom()))) 

669 if right <= left or bottom <= top: 669 ↛ 670line 669 didn't jump to line 670 because the condition on line 669 was never true

670 return QRectF(), raw_bounds.intersected(canvas) 

671 return ( 

672 QRectF(left, top, right-left, bottom-top), 

673 raw_bounds.intersected(canvas), 

674 ) 

675 

676 def _tp_inverse_mapper(self, bbox, source_width, source_height): 

677 """Build the output -> source map for the active transform mode. 

678 

679 Returns ``None`` when the transform state cannot describe a mapping. 

680 Coordinates are relative to ``bbox`` so the caller renders only the 

681 region the transform actually touches. 

682 """ 

683 left, top = float(bbox.left()), float(bbox.top()) 

684 if self.transform_mode == "mesh": 

685 cols = max(2, int(getattr(self, "transform_mesh_cols", 4))) 

686 rows = max(2, int(getattr(self, "transform_mesh_rows", 4))) 

687 if len(self.transform_points) != cols * rows: 687 ↛ 688line 687 didn't jump to line 688 because the condition on line 687 was never true

688 return None 

689 # Curve the grid in canvas coordinates, matching the plain preview: 

690 # the reference grid it interpolates against lives in that space. 

691 curved, dense_cols, dense_rows = self._curved_mesh_points( 

692 self.transform_points, cols, rows, subdivisions=8 

693 ) 

694 dense = [ 

695 (point.x() - left, point.y() - top) for point in curved 

696 ] 

697 return mask_transform.MeshMapper( 

698 dense, dense_cols, dense_rows, source_width, source_height 

699 ) 

700 if len(self.transform_points) != 4: 700 ↛ 701line 700 didn't jump to line 701 because the condition on line 700 was never true

701 return None 

702 target_quad = [ 

703 (point.x() - left, point.y() - top) 

704 for point in self.transform_points[:4] 

705 ] 

706 try: 

707 return mask_transform.QuadMapper( 

708 target_quad, source_width, source_height 

709 ) 

710 except np.linalg.LinAlgError: 

711 # A collapsed quad has no inverse; the caller falls back to the 

712 # plain projection rather than showing a blank preview. 

713 return None 

714 

715 

716 def _tp_geometry_key(self, source, target_width, target_height, bbox): 

717 points = tuple( 

718 (round(point.x(), 5), round(point.y(), 5)) 

719 for point in self.transform_points 

720 ) 

721 return ( 

722 int(source.cacheKey()), 

723 int(target_width), int(target_height), 

724 self.transform_mode, 

725 points, 

726 int(getattr(self, "transform_mesh_cols", 4)), 

727 int(getattr(self, "transform_mesh_rows", 4)), 

728 int(bbox.left()), int(bbox.top()), 

729 int(bbox.width()), int(bbox.height()), 

730 tuple(tuple(color) for color in self.transform_tp_palette), 

731 tuple(tuple(color) for color, _mask in self.transform_tp_line_masks), 

732 ) 

733 

734 def _build_tp_geometry_cache( 

735 self, source, target_width, target_height, progress_callback=None 

736 ): 

737 bbox, _raw_selection = self._tp_transform_bbox( 

738 target_width, target_height 

739 ) 

740 if bbox.isEmpty(): 740 ↛ 741line 740 didn't jump to line 741 because the condition on line 740 was never true

741 self._tp_geometry_cache_bbox = QRectF() 

742 self._tp_geometry_cache_fill_overlay = PILImage.new( 

743 "RGBA", (1, 1), (0, 0, 0, 0) 

744 ) 

745 self._tp_geometry_cache_line_soft = [] 

746 return bbox 

747 

748 cache_key = self._tp_geometry_key( 

749 source, target_width, target_height, bbox 

750 ) 

751 if ( 

752 self._tp_geometry_cache_key == cache_key 

753 and self._tp_geometry_cache_fill_overlay is not None 

754 ): 

755 return QRectF(self._tp_geometry_cache_bbox) 

756 

757 width = max(1, int(math.ceil(bbox.width()))) 

758 height = max(1, int(math.ceil(bbox.height()))) 

759 label_image = self._tp_label_image 

760 label_colors = self._tp_label_colors 

761 if label_image is None or label_colors is None: 761 ↛ 762line 761 didn't jump to line 762 because the condition on line 761 was never true

762 self._tp_geometry_cache_bbox = QRectF() 

763 self._tp_geometry_cache_fill_overlay = PILImage.new( 

764 "RGBA", (1, 1), (0, 0, 0, 0) 

765 ) 

766 self._tp_geometry_cache_line_soft = [] 

767 return QRectF() 

768 

769 source_height, source_width = label_image.shape 

770 mapper = self._tp_inverse_mapper(bbox, source_width, source_height) 

771 if mapper is None: 

772 self._tp_geometry_cache_bbox = QRectF() 

773 self._tp_geometry_cache_fill_overlay = PILImage.new( 

774 "RGBA", (1, 1), (0, 0, 0, 0) 

775 ) 

776 self._tp_geometry_cache_line_soft = [] 

777 return QRectF() 

778 

779 # The proxy only feeds the drag-time preview, so it trades coverage 

780 # resolution for speed; the committed render always uses the full grid. 

781 subsamples = ( 

782 TP_MASK_PROXY_SUBSAMPLES 

783 if self._tp_proxy_rendering 

784 else mask_transform.DEFAULT_SUBSAMPLES 

785 ) 

786 line_labels = [label for _color, label in self.transform_tp_line_masks] 

787 

788 # One progress scale for the whole build: every band, plus the final 

789 # recombination step. 

790 steps = [0, 1] 

791 

792 def report(done, total): 

793 steps[:] = [int(done), int(total)] 

794 if progress_callback is not None: 

795 progress_callback( 

796 min(int(done), int(total)), 

797 max(1, int(total)) + 1, 

798 tr("色マスクを変形しています"), 

799 ) 

800 

801 labels, coverage = mask_transform.render_labels( 

802 label_image, 

803 mapper, 

804 width, 

805 height, 

806 subsamples=subsamples, 

807 coverage_labels=line_labels, 

808 protect_labels=line_labels, 

809 progress=report, 

810 ) 

811 

812 final = max(1, steps[1]) + 1 

813 if progress_callback is not None: 

814 progress_callback(final, final, tr("色マスクを再合成しています")) 

815 output = mask_transform.labels_to_rgba(labels, label_colors) 

816 # v0.7 rule, unchanged: exact #FFFFFF is the transform's transparency. 

817 white = (output[:, :, 3] > 0) & np.all( 

818 output[:, :, :3] == 255, axis=2 

819 ) 

820 output[white, 3] = 0 

821 

822 line_soft = [] 

823 for line_color, label in self.transform_tp_line_masks: 

824 line_soft.append(( 

825 tuple(line_color), 

826 PILImage.fromarray(coverage[label], "L"), 

827 )) 

828 

829 self._tp_geometry_cache_key = cache_key 

830 self._tp_geometry_cache_bbox = QRectF(bbox) 

831 self._tp_geometry_cache_fill_overlay = PILImage.fromarray( 

832 output, "RGBA" 

833 ) 

834 self._tp_geometry_cache_line_soft = line_soft 

835 return bbox 

836 

837 

838 def _tp_mask_preview_image( 

839 self, source_image=None, target_width=None, target_height=None, 

840 progress_callback=None 

841 ): 

842 source = ( 

843 source_image if source_image is not None else self.transform_source 

844 ) 

845 if source is None or source.isNull(): 845 ↛ 846line 845 didn't jump to line 846 because the condition on line 845 was never true

846 return None, QRectF() 

847 target_width = int( 

848 target_width 

849 if target_width is not None 

850 else self.active_layer.image.width() 

851 ) 

852 target_height = int( 

853 target_height 

854 if target_height is not None 

855 else self.active_layer.image.height() 

856 ) 

857 canvas_rect = QRectF(0, 0, target_width, target_height) 

858 

859 expected_mask_key = self._tp_mask_key(source) 

860 if ( 

861 self._tp_mask_source_key != expected_mask_key 

862 or ( 

863 not self.transform_tp_masks 

864 and not self.transform_tp_line_masks 

865 ) 

866 ): 

867 if not self._prepare_tp_transform_masks(source): 867 ↛ 868line 867 didn't jump to line 868 because the condition on line 867 was never true

868 return self._project_transform_source( 

869 source, target_width, target_height, smooth=False 

870 ) 

871 

872 threshold = max( 

873 1, min(254, int(getattr(self, "transform_line_threshold", 96))) 

874 ) 

875 bbox, raw_selection = self._tp_transform_bbox( 

876 target_width, target_height 

877 ) 

878 point_key = tuple( 

879 (round(point.x(), 5), round(point.y(), 5)) 

880 for point in self.transform_points 

881 ) 

882 preview_key = ( 

883 int(source.cacheKey()), target_width, target_height, 

884 self.transform_mode, point_key, 

885 int(getattr(self, "transform_mesh_cols", 4)), 

886 int(getattr(self, "transform_mesh_rows", 4)), 

887 threshold, 

888 tuple(self.transform_tp_line_colors), 

889 ) 

890 if ( 

891 source is self.transform_source 

892 and self._tp_preview_cache_key == preview_key 

893 and self._tp_preview_cache_image is not None 

894 ): 

895 return self._tp_preview_cache_image, canvas_rect 

896 

897 bbox = self._build_tp_geometry_cache( 

898 source, target_width, target_height, progress_callback 

899 ) 

900 full = QImage( 

901 target_width, target_height, 

902 QImage.Format.Format_ARGB32_Premultiplied, 

903 ) 

904 full.fill(Qt.GlobalColor.transparent) 

905 if not bbox.isEmpty(): 

906 width = max(1, int(math.ceil(bbox.width()))) 

907 height = max(1, int(math.ceil(bbox.height()))) 

908 overlay = self._tp_geometry_cache_fill_overlay.copy() 

909 for line_color, line_soft in self._tp_geometry_cache_line_soft: 

910 binary = line_soft.point( 

911 lambda value, limit=threshold: 

912 255 if value >= limit else 0 

913 ) 

914 red, green, blue = line_color 

915 line_fill = PILImage.new( 

916 "RGBA", (width, height), (red, green, blue, 255) 

917 ) 

918 overlay.paste(line_fill, (0, 0), binary) 

919 painter = QPainter(full) 

920 painter.setCompositionMode( 

921 QPainter.CompositionMode.CompositionMode_SourceOver 

922 ) 

923 painter.drawImage( 

924 QPointF(math.floor(bbox.left()), math.floor(bbox.top())), 

925 self._pil_rgba_to_qimage(overlay), 

926 ) 

927 painter.end() 

928 

929 if source is self.transform_source: 

930 self._tp_preview_cache_key = preview_key 

931 self._tp_preview_cache_image = full 

932 return full, canvas_rect