Coverage for paintmaskanimator/canvas_transform_geometry.py: 63%
229 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"""Free-transform geometry for PaintCanvas: handles, drag state and preview.
3Split out of ``canvas.py`` as a mixin, and a companion to
4``canvas_transform_mask.py`` (which owns the mask side). These methods answer the
5geometric questions of a transform: where the handles and rotation grip are, what
6polygon the transform currently covers, how a drag updates the corner points, and
7how the source image is projected -- including the reduced-size proxy used while
8dragging. They run against a live ``PaintCanvas``.
9"""
10import math
11import numpy as np
12from PySide6.QtCore import QPointF, QRectF, Qt
13from PySide6.QtGui import QImage, QPainter, QPolygonF
14from .i18n import tr
15from .constants import TP_MASK_PROXY_MAX_DIMENSION
16from ._canvas_members import CanvasMembers
17from . import geometry
18from .logging_setup import get_logger
20log = get_logger(__name__)
23class TransformGeometryMixin(CanvasMembers):
24 @staticmethod
25 def _regular_grid_points(rect, cols, rows=None):
26 return geometry.regular_grid_points(rect, cols, rows)
29 def nearest_transform_handle(self, point):
30 if not self.transform_active or not self.transform_points: 30 ↛ 31line 30 didn't jump to line 31 because the condition on line 30 was never true
31 return -1
32 radius = 14 / max(self.zoom, 0.01)
33 distances = [
34 (handle.x() - point.x()) ** 2 + (handle.y() - point.y()) ** 2
35 for handle in self.transform_points
36 ]
37 index = int(np.argmin(distances))
38 return index if distances[index] <= radius * radius else -1
40 def transform_center(self):
41 if not self.transform_points: 41 ↛ 42line 41 didn't jump to line 42 because the condition on line 41 was never true
42 return QPointF()
43 return QPointF(
44 sum(point.x() for point in self.transform_points) / len(self.transform_points),
45 sum(point.y() for point in self.transform_points) / len(self.transform_points),
46 )
48 def transform_rotation_handle(self):
49 if not self.transform_points: 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true
50 return None
51 if self.transform_mode == "mesh": 51 ↛ 52line 51 didn't jump to line 52 because the condition on line 51 was never true
52 cols = max(2, int(getattr(self, "transform_mesh_cols", 4)))
53 if len(self.transform_points) < cols:
54 return None
55 p0, p1 = self.transform_points[0], self.transform_points[cols - 1]
56 elif len(self.transform_points) == 4: 56 ↛ 59line 56 didn't jump to line 59 because the condition on line 56 was always true
57 p0, p1 = self.transform_points[0], self.transform_points[1]
58 else:
59 return None
60 midpoint = QPointF((p0.x() + p1.x()) / 2.0, (p0.y() + p1.y()) / 2.0)
61 center = self.transform_center()
62 dx, dy = midpoint.x() - center.x(), midpoint.y() - center.y()
63 length = math.hypot(dx, dy) or 1.0
64 distance = 36.0 / max(self.zoom, 0.01)
65 return QPointF(
66 midpoint.x() + dx / length * distance,
67 midpoint.y() + dy / length * distance,
68 )
70 def transform_outer_polygon(self):
71 if not self.transform_points: 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true
72 return QPolygonF()
73 if self.transform_mode != "mesh":
74 return QPolygonF(self.transform_points[:4])
75 cols = max(2, int(getattr(self, "transform_mesh_cols", 4)))
76 rows = max(2, int(getattr(self, "transform_mesh_rows", 4)))
77 points = self.transform_points
78 if len(points) != cols * rows: 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true
79 return QPolygonF()
80 subdivisions = 8
81 outline = []
83 for step in range((cols - 1) * subdivisions + 1):
84 outline.append(
85 self._mesh_curve_point(
86 points, cols, rows,
87 step / float(subdivisions), 0.0,
88 )
89 )
90 for step in range(1, (rows - 1) * subdivisions + 1):
91 outline.append(
92 self._mesh_curve_point(
93 points, cols, rows,
94 float(cols - 1),
95 step / float(subdivisions),
96 )
97 )
98 for step in range(
99 (cols - 1) * subdivisions - 1, -1, -1
100 ):
101 outline.append(
102 self._mesh_curve_point(
103 points, cols, rows,
104 step / float(subdivisions),
105 float(rows - 1),
106 )
107 )
108 for step in range(
109 (rows - 1) * subdivisions - 1, 0, -1
110 ):
111 outline.append(
112 self._mesh_curve_point(
113 points, cols, rows,
114 0.0,
115 step / float(subdivisions),
116 )
117 )
118 return QPolygonF(outline)
121 def begin_transform_drag(self, point):
122 if not self.transform_active: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true
123 return False
124 from .undo_entries import TransformUndo
125 self._transform_drag_history = TransformUndo.capture(self)
126 rotation_handle = self.transform_rotation_handle()
127 radius = 16 / max(self.zoom, 0.01)
128 if rotation_handle is not None: 128 ↛ 138line 128 didn't jump to line 138 because the condition on line 128 was always true
129 if ( 129 ↛ 134line 129 didn't jump to line 134 because the condition on line 129 was never true
130 (rotation_handle.x() - point.x()) ** 2
131 + (rotation_handle.y() - point.y()) ** 2
132 <= radius * radius
133 ):
134 self.transform_drag_kind = ("rotate", -1)
135 self.transform_drag_start = QPointF(point)
136 self.transform_drag_points = [QPointF(p) for p in self.transform_points]
137 return True
138 handle = self.nearest_transform_handle(point)
139 if handle >= 0:
140 self.transform_drag_kind = ("handle", handle)
141 self.transform_drag_start = QPointF(point)
142 self.transform_drag_points = [QPointF(p) for p in self.transform_points]
143 self.transform_handle = handle
144 return True
145 if self.transform_outer_polygon().containsPoint( 145 ↛ 152line 145 didn't jump to line 152 because the condition on line 145 was always true
146 point, Qt.FillRule.OddEvenFill
147 ):
148 self.transform_drag_kind = ("move", -1)
149 self.transform_drag_start = QPointF(point)
150 self.transform_drag_points = [QPointF(p) for p in self.transform_points]
151 return True
152 self.transform_drag_kind = None
153 return False
155 def update_transform_drag(self, point):
156 if not self.transform_drag_kind: 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true
157 return
158 kind, index = self.transform_drag_kind
159 start_points = self.transform_drag_points
160 if kind == "move":
161 delta = point - self.transform_drag_start
162 self.transform_points = [p + delta for p in start_points]
163 elif kind == "rotate": 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true
164 center = QPointF(
165 sum(p.x() for p in start_points) / len(start_points),
166 sum(p.y() for p in start_points) / len(start_points),
167 )
168 a0 = math.atan2(
169 self.transform_drag_start.y() - center.y(),
170 self.transform_drag_start.x() - center.x(),
171 )
172 a1 = math.atan2(point.y() - center.y(), point.x() - center.x())
173 angle = a1 - a0
174 cs, sn = math.cos(angle), math.sin(angle)
175 self.transform_points = [
176 QPointF(
177 center.x() + (p.x() - center.x()) * cs - (p.y() - center.y()) * sn,
178 center.y() + (p.x() - center.x()) * sn + (p.y() - center.y()) * cs,
179 )
180 for p in start_points
181 ]
182 elif kind == "handle": 182 ↛ 202line 182 didn't jump to line 202 because the condition on line 182 was always true
183 if self.transform_mode in ("free", "mesh"): 183 ↛ 186line 183 didn't jump to line 186 because the condition on line 183 was always true
184 self.transform_points[index] = QPointF(point)
185 else:
186 opposite_index = (index + 2) % 4
187 opposite = start_points[opposite_index]
188 original = start_points[index] - opposite
189 current = point - opposite
190 denominator = original.x() ** 2 + original.y() ** 2
191 scale = (
192 (current.x() * original.x() + current.y() * original.y())
193 / denominator
194 if denominator > 1e-8 else 1.0
195 )
196 if abs(scale) < 0.02:
197 scale = 0.02 if scale >= 0 else -0.02
198 self.transform_points = [
199 opposite + (p - opposite) * scale
200 for p in start_points
201 ]
202 self._invalidate_tp_preview_cache()
203 self.update()
205 def end_transform_drag(self):
206 before = getattr(self, "_transform_drag_history", None)
207 if self.transform_drag_kind and before is not None: 207 ↛ 209line 207 didn't jump to line 209 because the condition on line 207 was always true
208 self._record_transform_history(before)
209 self._transform_drag_history = None
210 self.transform_drag_kind = None
211 self.transform_handle = -1
212 self.transform_drag_points = []
213 if self.transform_quality_active:
214 self.request_quality_preview_counter(
215 tr("変形後のクオリティプレビューを生成しています")
216 )
217 self.update()
220 @staticmethod
221 def _quad_homography(source_points, target_points):
222 return geometry.quad_homography(source_points, target_points)
225 # Pure QImage/NumPy/PIL conversions now live in imaging.py; these wrappers
226 # keep the existing call sites (self._.../cls._...) working unchanged.
227 def _project_transform_source(
228 self, source, target_width, target_height, smooth=False
229 ):
230 canvas_rect = QRectF(0, 0, target_width, target_height)
231 if self.transform_mode == "mesh": 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true
232 return (
233 self._mesh_preview_image(
234 source, target_width, target_height, smooth=smooth
235 ),
236 canvas_rect,
237 )
238 if len(self.transform_points) != 4: 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true
239 return None, canvas_rect
240 width, height = source.width(), source.height()
241 if width <= 0 or height <= 0: 241 ↛ 242line 241 didn't jump to line 242 because the condition on line 241 was never true
242 return None, canvas_rect
243 source_quad = [
244 QPointF(0, 0), QPointF(width, 0),
245 QPointF(width, height), QPointF(0, height),
246 ]
247 transform = self._quad_homography(source_quad, self.transform_points)
248 preview = QImage(
249 target_width,
250 target_height,
251 QImage.Format.Format_ARGB32_Premultiplied,
252 )
253 preview.fill(Qt.GlobalColor.transparent)
254 painter = QPainter(preview)
255 painter.setRenderHint(
256 QPainter.RenderHint.SmoothPixmapTransform, False
257 )
258 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
259 painter.setTransform(transform)
260 painter.drawImage(0, 0, source)
261 painter.end()
262 return preview, canvas_rect
264 def _transform_is_reducing(self, source=None):
265 """変形のどこかに縮小があり、補間が必要かを返す。"""
266 source = source if source is not None else self.transform_source
267 if source is None or source.isNull() or not self.transform_points:
268 return False
270 if self.transform_mode == "mesh":
271 cols = max(2, int(getattr(self, "transform_mesh_cols", 4)))
272 rows = max(2, int(getattr(self, "transform_mesh_rows", 4)))
273 reference = self._mesh_reference_grid(cols, rows)
274 if (
275 len(self.transform_points) != cols * rows
276 or len(reference) != cols * rows
277 ):
278 return False
279 pairs = []
280 for row in range(rows):
281 for col in range(cols - 1):
282 index = row * cols + col
283 pairs.append((index, index + 1))
284 for row in range(rows - 1):
285 for col in range(cols):
286 index = row * cols + col
287 pairs.append((index, index + cols))
288 for first, second in pairs:
289 current = math.hypot(
290 self.transform_points[second].x()
291 - self.transform_points[first].x(),
292 self.transform_points[second].y()
293 - self.transform_points[first].y(),
294 )
295 original = math.hypot(
296 reference[second].x() - reference[first].x(),
297 reference[second].y() - reference[first].y(),
298 )
299 if original > 1e-8 and current < original * 0.9999:
300 return True
301 return False
303 if len(self.transform_points) != 4:
304 return False
305 source_width = max(1.0, float(source.width()))
306 source_height = max(1.0, float(source.height()))
307 p0, p1, p2, p3 = self.transform_points[:4]
308 horizontal_edges = (
309 math.hypot(p1.x() - p0.x(), p1.y() - p0.y()),
310 math.hypot(p2.x() - p3.x(), p2.y() - p3.y()),
311 )
312 vertical_edges = (
313 math.hypot(p3.x() - p0.x(), p3.y() - p0.y()),
314 math.hypot(p2.x() - p1.x(), p2.y() - p1.y()),
315 )
316 return (
317 min(horizontal_edges) < source_width * 0.9999
318 or min(vertical_edges) < source_height * 0.9999
319 )
322 def _proxy_transform_preview_image(
323 self,
324 source,
325 target_width,
326 target_height,
327 quality,
328 progress_callback=None,
329 ):
330 """大画像の表示用変形を縮小座標で生成し、UIの負荷を抑える。"""
331 scale = min(
332 1.0,
333 TP_MASK_PROXY_MAX_DIMENSION
334 / float(max(1, target_width, target_height)),
335 )
336 proxy_width = max(1, int(round(target_width * scale)))
337 proxy_height = max(1, int(round(target_height * scale)))
338 scale_x = proxy_width / float(max(1, target_width))
339 scale_y = proxy_height / float(max(1, target_height))
340 source_width = max(1, int(round(source.width() * scale_x)))
341 source_height = max(1, int(round(source.height() * scale_y)))
342 proxy_source_key = (
343 int(source.cacheKey()),
344 source_width,
345 source_height,
346 )
347 if ( 347 ↛ 360line 347 didn't jump to line 360 because the condition on line 347 was always true
348 self._tp_proxy_source_key != proxy_source_key
349 or self._tp_proxy_source_image is None
350 or self._tp_proxy_source_image.isNull()
351 ):
352 self._tp_proxy_source_image = source.scaled(
353 source_width,
354 source_height,
355 Qt.AspectRatioMode.IgnoreAspectRatio,
356 Qt.TransformationMode.FastTransformation,
357 )
358 self._tp_proxy_source_key = proxy_source_key
360 proxy_source = self._tp_proxy_source_image
361 original_source = self.transform_source
362 original_points = self.transform_points
363 original_source_rect = self.transform_source_rect
364 original_proxy_rendering = self._tp_proxy_rendering
365 self.transform_source = proxy_source
366 self.transform_points = [
367 QPointF(point.x() * scale_x, point.y() * scale_y)
368 for point in original_points
369 ]
370 if original_source_rect is not None: 370 ↛ 377line 370 didn't jump to line 377 because the condition on line 370 was always true
371 self.transform_source_rect = QRectF(
372 original_source_rect.x() * scale_x,
373 original_source_rect.y() * scale_y,
374 original_source_rect.width() * scale_x,
375 original_source_rect.height() * scale_y,
376 )
377 self._tp_proxy_rendering = True
378 try:
379 if quality: 379 ↛ 386line 379 didn't jump to line 386 because the condition on line 379 was always true
380 return self._tp_mask_preview_image(
381 proxy_source,
382 proxy_width,
383 proxy_height,
384 progress_callback=progress_callback,
385 )
386 return self._project_transform_source(
387 proxy_source,
388 proxy_width,
389 proxy_height,
390 smooth=False,
391 )
392 finally:
393 self.transform_source = original_source
394 self.transform_points = original_points
395 self.transform_source_rect = original_source_rect
396 self._tp_proxy_rendering = original_proxy_rendering
398 def transform_preview_image(
399 self,
400 source_image=None,
401 target_width=None,
402 target_height=None,
403 quality=None,
404 preview_only=False,
405 progress_callback=None,
406 ):
407 source = source_image if source_image is not None else self.transform_source
408 if not self.transform_active or source is None: 408 ↛ 409line 408 didn't jump to line 409 because the condition on line 408 was never true
409 return None, QRectF()
410 target_width = int(
411 target_width if target_width is not None else self.active_layer.image.width()
412 )
413 target_height = int(
414 target_height if target_height is not None else self.active_layer.image.height()
415 )
416 if quality is None: 416 ↛ 417line 416 didn't jump to line 417 because the condition on line 416 was never true
417 quality = bool(getattr(self, "transform_quality_active", False))
418 if (
419 preview_only
420 and self._tp_uses_proxy(target_width, target_height)
421 ):
422 return self._proxy_transform_preview_image(
423 source,
424 target_width,
425 target_height,
426 bool(quality),
427 progress_callback=progress_callback,
428 )
429 if quality:
430 return self._tp_mask_preview_image(
431 source,
432 target_width,
433 target_height,
434 progress_callback=progress_callback,
435 )
436 return self._project_transform_source(
437 source,
438 target_width,
439 target_height,
440 smooth=False,
441 )