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
« 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.
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.
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
27log = get_logger(__name__)
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 = []
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()
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 )
65 @staticmethod
66 def _tp_uses_proxy(*args, **kwargs):
67 return imaging.tp_uses_proxy(*args, **kwargs)
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 )
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)))
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
97 def commit_mesh(self):
98 if self.mesh_original is None or not self.mesh_points:return
99 self.apply_mesh_release()
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()
116 def cancel_mesh(self):
117 self.mesh_original=None; self.mesh_points=[]; self.mesh_active=-1; self.update()
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)
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)
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 ]
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
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
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 )
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 ]
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()
207 @staticmethod
208 def _mesh_catmull_scalar(p0, p1, p2, p3, t):
209 return geometry.mesh_catmull_scalar(p0, p1, p2, p3, t)
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]
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 )
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()
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()
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
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
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 )
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 ))
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 )
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]
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 )
338 return QPointF(
339 base.x() + displacement.x(),
340 base.y() + displacement.y(),
341 )
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
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
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]]
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)
496 @staticmethod
497 def _tp_transparent_to_white(*args, **kwargs):
498 return imaging.tp_transparent_to_white(*args, **kwargs)
500 @staticmethod
501 def _tp_white_to_transparent(*args, **kwargs):
502 return imaging.tp_white_to_transparent(*args, **kwargs)
504 @staticmethod
505 def _tp_prepare_palette_image(*args, **kwargs):
506 return imaging.tp_prepare_palette_image(*args, **kwargs)
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]
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 ]
531 def _build_tp_label_image(self, prepared_array, palette_rgba, line_colors):
532 """Turn the prepared image into one label id per pixel.
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.
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
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)
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
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 )
584 self._tp_label_image = labels
585 self._tp_label_colors = colors
586 return label_ids
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
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 ]
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)
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 )
676 def _tp_inverse_mapper(self, bbox, source_width, source_height):
677 """Build the output -> source map for the active transform mode.
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
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 )
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
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)
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()
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()
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]
788 # One progress scale for the whole build: every band, plus the final
789 # recombination step.
790 steps = [0, 1]
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 )
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 )
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
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 ))
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
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)
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 )
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
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()
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