Coverage for paintmaskanimator/canvas_selection.py: 81%
525 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"""Selection state, transform, and clipboard for :class:`PaintCanvas`.
3Split out of ``canvas.py`` as a mixin. These methods own the selection mask,
4its fade overlay, the begin/commit/cancel free-transform + rotate flow, and
5copy/cut/paste of the selected region. They run against a live ``PaintCanvas``
6instance and reuse its frame/layer state and the TP-transform mask helpers.
7"""
8import math
9import numpy as np
10import time
11from typing import Any
12from PySide6.QtCore import QPoint, QPointF, QRect, QRectF, Qt
13from PySide6.QtGui import QColor, QImage, QPainter, QPolygonF, QRegion
14from PySide6.QtWidgets import QApplication
15from .i18n import tr
16from .time_display import frame_position
17from .optional_deps import PILImage, PILImageFilter
18from .constants import OUTSIDE_MARGIN
19from ._canvas_members import CanvasMembers
20from . import constants, imaging
21from .progress import close_counter, create_counter, update_counter
22from .logging_setup import get_logger
23from .undo_entries import LayerBatchUndo, LayerUndo, TransformUndo
25log = get_logger(__name__)
28class SelectionMixin(CanvasMembers):
29 def selection_mask_bool(self, width=None, height=None, offset_x=0, offset_y=0):
30 """Return a boolean mask for the active selection in the requested local area."""
31 if not self.selection_polygon:
32 return None
33 width = int(width if width is not None else self.active_layer.image.width())
34 height = int(height if height is not None else self.active_layer.image.height())
35 if width <= 0 or height <= 0: 35 ↛ 36line 35 didn't jump to line 36 because the condition on line 35 was never true
36 return np.zeros((max(0, height), max(0, width)), dtype=bool)
37 if self.selection_mask_override is not None:
38 source = np.asarray(self.selection_mask_override, dtype=bool)
39 result = np.zeros((height, width), dtype=bool)
40 source_x1 = max(0, int(offset_x))
41 source_y1 = max(0, int(offset_y))
42 source_x2 = min(source.shape[1], int(offset_x) + width)
43 source_y2 = min(source.shape[0], int(offset_y) + height)
44 if source_x2 > source_x1 and source_y2 > source_y1: 44 ↛ 51line 44 didn't jump to line 51 because the condition on line 44 was always true
45 target_x1 = source_x1 - int(offset_x)
46 target_y1 = source_y1 - int(offset_y)
47 result[
48 target_y1:target_y1 + source_y2 - source_y1,
49 target_x1:target_x1 + source_x2 - source_x1,
50 ] = source[source_y1:source_y2, source_x1:source_x2]
51 return result
52 mask = QImage(width, height, QImage.Format.Format_RGBA8888)
53 mask.fill(Qt.GlobalColor.transparent)
54 local_polygon = QPolygonF([
55 QPointF(point.x() - offset_x, point.y() - offset_y)
56 for point in self.selection_polygon
57 ])
58 painter = QPainter(mask)
59 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
60 painter.setPen(Qt.PenStyle.NoPen)
61 painter.setBrush(QColor(255, 255, 255, 255))
62 painter.drawPolygon(local_polygon)
63 painter.end()
64 ptr = imaging.qimage_buffer(mask)
65 rows = np.frombuffer(ptr, dtype=np.uint8).reshape(
66 (height, mask.bytesPerLine())
67 )
68 return rows[:, :width * 4].reshape((height, width, 4))[:, :, 3].copy() > 0
70 @staticmethod
71 def _bool_mask_image(*args, **kwargs):
72 return imaging.bool_mask_image(*args, **kwargs)
74 def _update_selection_fade(self):
75 phase = (
76 (time.monotonic() - self._selection_fade_started) % 2.0
77 ) / 2.0
78 self._selection_fade_opacity = (
79 0.08 + 0.92 * (0.5 + 0.5 * math.cos(phase * math.tau))
80 )
81 if self.selection_polygon:
82 if self.transform_active and not self.transform_outer_polygon().isEmpty():
83 selection_rect = self.transform_outer_polygon().boundingRect()
84 else:
85 selection_rect = (
86 self.selection_mask_rect
87 if self.selection_mask_rect is not None
88 else QRectF(self.selection_bounds())
89 )
90 corners = [
91 self.canvas_to_widget(selection_rect.topLeft()),
92 self.canvas_to_widget(selection_rect.topRight()),
93 self.canvas_to_widget(selection_rect.bottomRight()),
94 self.canvas_to_widget(selection_rect.bottomLeft()),
95 ]
96 xs = [point.x() for point in corners]
97 ys = [point.y() for point in corners]
98 dirty = QRectF(
99 min(xs), min(ys),
100 max(xs) - min(xs), max(ys) - min(ys),
101 ).adjusted(-4, -4, 4, 4).toAlignedRect()
102 region = QRegion(dirty.intersected(self.rect()))
103 if self._brush_cursor_inside:
104 cursor_radius = max(
105 10,
106 int(math.ceil(
107 float(self.pen_size) * max(self.zoom, 0.01) / 2.0
108 )) + 6,
109 )
110 cursor_rect = QRectF(
111 self._brush_cursor_widget_pos,
112 self._brush_cursor_widget_pos,
113 ).adjusted(
114 -cursor_radius, -cursor_radius,
115 cursor_radius, cursor_radius,
116 ).toAlignedRect()
117 region -= QRegion(cursor_rect)
118 if not region.isEmpty(): 118 ↛ exitline 118 didn't return from function '_update_selection_fade' because the condition on line 118 was always true
119 self.update(region)
121 def _clear_selection_state(self):
122 if self.transform_active: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true
123 self.cancel_selection_transform()
124 self.selection_polygon = []
125 self.selection_mask_override = None
126 self.selection_outline_polygons = []
127 self.selection_mask_rect = None
128 self.lasso = []
129 self.rect_start = None
130 self.rect_end = None
131 self.selectionChanged.emit()
132 self.update()
134 def clear_selection(self):
135 self._clear_selection_state()
136 self.selectionCleared.emit()
138 def set_selection_mask(self, mask):
139 """真偽マスク(レイヤー全体の大きさ)をそのまま選択範囲にする。
141 空なら選択を解除する。輪郭が取れなければ何もしないで False。
142 """
143 if not np.any(mask): 143 ↛ 144line 143 didn't jump to line 144 because the condition on line 143 was never true
144 self.clear_selection()
145 return True
146 # 輪郭の計算は MainWindow から line_ops へ移っている。
147 window: Any = self.window()
148 contour_owner = window.line_ops
149 contours = contour_owner._mask_contours(mask)
150 contour = contour_owner._largest_contour(contours)
151 if not contour: 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true
152 return False
153 ys, xs = np.nonzero(mask)
154 self.selection_polygon = contour
155 self.selection_mask_override = mask
156 self.selection_outline_polygons = contours
157 self.selection_mask_rect = QRectF(
158 int(xs.min()), int(ys.min()),
159 int(xs.max() - xs.min() + 1),
160 int(ys.max() - ys.min() + 1),
161 )
162 self._selection_fade_started = time.monotonic()
163 self.lasso = []
164 self.rect_start = None
165 self.rect_end = None
166 self.selectionChanged.emit()
167 self.update()
168 return True
170 def _canvas_area_rect(self):
171 """レイヤー画像のうち、キャンバス(外側の余白を除く)にあたる矩形。"""
172 return QRect(
173 OUTSIDE_MARGIN, OUTSIDE_MARGIN,
174 constants.CANVAS_WIDTH, constants.CANVAS_HEIGHT,
175 ).intersected(self.active_layer.image.rect())
177 def select_all(self):
178 """キャンバス全体を選択する(Ctrl+A)。"""
179 if not self.frames or not self.layers: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 return False
181 if self.transform_active: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true
182 return False
183 area = self._canvas_area_rect()
184 if area.isEmpty(): 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true
185 return False
186 self._clear_selection_state()
187 self.selection_polygon = [
188 QPointF(area.left(), area.top()),
189 QPointF(area.left() + area.width(), area.top()),
190 QPointF(area.left() + area.width(), area.top() + area.height()),
191 QPointF(area.left(), area.top() + area.height()),
192 ]
193 self._selection_fade_started = time.monotonic()
194 self.selectionChanged.emit()
195 self.update()
196 return True
198 def invert_selection(self):
199 """選択範囲を反転する(Ctrl+Shift+I)。選択が無ければ何もしない。"""
200 if not self.selection_polygon or self.transform_active:
201 return False
202 image = self.active_layer.image
203 width, height = image.width(), image.height()
204 current = self.selection_mask_bool(width, height)
205 if current is None: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true
206 return False
207 # 外側の余白は選択の対象にしないので、反転もキャンバスの中だけで行う。
208 area = self._canvas_area_rect()
209 inverted = np.zeros((height, width), dtype=bool)
210 y0, y1 = area.top(), area.top() + area.height()
211 x0, x1 = area.left(), area.left() + area.width()
212 inverted[y0:y1, x0:x1] = ~current[y0:y1, x0:x1]
213 return self.set_selection_mask(inverted)
215 def clear_selection_preserving_used_colors(self):
216 """内部処理用。使用色の選択を維持したまま画像選択だけ解除する。"""
217 self._clear_selection_state()
219 def _masked_selection_source(self, rect):
220 source = self.active_layer.image.copy(rect)
221 if not self.selection_polygon:
222 return source
223 if self.selection_mask_override is not None:
224 mask = self._bool_mask_image(self.selection_mask_bool(
225 rect.width(), rect.height(), rect.x(), rect.y()
226 ))
227 painter = QPainter(source)
228 painter.setCompositionMode(
229 QPainter.CompositionMode.CompositionMode_DestinationIn
230 )
231 painter.drawImage(0, 0, mask)
232 painter.end()
233 return source
234 mask = QImage(rect.width(), rect.height(), QImage.Format.Format_ARGB32_Premultiplied)
235 mask.fill(Qt.GlobalColor.transparent)
236 painter = QPainter(mask)
237 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
238 painter.setPen(Qt.PenStyle.NoPen)
239 painter.setBrush(QColor(255, 255, 255, 255))
240 painter.drawPolygon(QPolygonF([
241 QPointF(point.x() - rect.x(), point.y() - rect.y())
242 for point in self.selection_polygon
243 ]))
244 painter.end()
245 painter = QPainter(source)
246 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_DestinationIn)
247 painter.drawImage(0, 0, mask)
248 painter.end()
249 return source
251 def auto_select_used_area(self):
252 """Select the bounding area of every opaque pixel, including gray colors."""
253 if not self.frames or not self.layers: 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true
254 return False
255 self.ensure_editable_key()
256 image = self.active_layer.image.convertToFormat(
257 QImage.Format.Format_RGBA8888
258 )
259 width, height = image.width(), image.height()
260 if width <= 0 or height <= 0: 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true
261 return False
262 ptr = imaging.qimage_buffer(image)
263 rows = np.frombuffer(ptr, dtype=np.uint8).reshape(
264 (height, image.bytesPerLine())
265 )
266 pixels = rows[:, :width * 4].reshape((height, width, 4))
267 used = pixels[:, :, 3] > 0
268 ys, xs = np.nonzero(used)
269 if len(xs) == 0: 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true
270 return False
271 left = int(xs.min())
272 right = int(xs.max()) + 1
273 top = int(ys.min())
274 bottom = int(ys.max()) + 1
275 self.selection_polygon = [
276 QPointF(left, top),
277 QPointF(right, top),
278 QPointF(right, bottom),
279 QPointF(left, bottom),
280 ]
281 self.selectionChanged.emit()
282 self.update()
283 return True
285 def begin_selection_transform(self, mode):
286 if not self.selection_polygon: 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true
287 return False
288 selection_rect = self.selection_bounds().intersected(
289 self.active_layer.image.rect()
290 )
291 # 選択境界ぴったりで切り出すと、縮小時のサンプリングで
292 # 外周画素が透明側へ丸められて欠ける。変形元だけに透明な
293 # 安全余白を持たせ、選択マスク自体の形状は変更しない。
294 rect = selection_rect.adjusted(-2, -2, 2, 2).intersected(
295 self.active_layer.image.rect()
296 )
297 if rect.isEmpty(): 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true
298 return False
299 if self.transform_active: 299 ↛ 300line 299 didn't jump to line 300 because the condition on line 299 was never true
300 self.cancel_selection_transform()
302 mode = mode if mode in ("scale", "free", "mesh") else "free"
303 self.transform_active = True
304 self.transform_mode = mode
305 self.transform_quality_active = bool(getattr(self, "transform_quality", False))
306 self.transform_tp_line_colors = tuple(sorted(self.selected_used_colors()))
307 self._invalidate_tp_preview_cache()
308 self.transform_frame_index = self.current_frame
309 self.transform_layer_index = self.active_layer_index
310 self.transform_original_layer = self.active_layer.image.copy()
311 self.transform_original_has_content = self.active_layer.has_content
312 self.transform_source_rect = QRectF(rect)
313 self.transform_source = self._masked_selection_source(rect)
314 if self.transform_quality_active:
315 target_width = self.active_layer.image.width()
316 target_height = self.active_layer.image.height()
317 use_proxy = self._tp_uses_proxy(target_width, target_height)
318 if PILImage is None or PILImageFilter is None: 318 ↛ 321line 318 didn't jump to line 321 because the condition on line 318 was never true
319 # Keep the transform usable even when Pillow is unavailable,
320 # but do not pretend that the TP_mask method is active.
321 self.transform_quality_active = False
322 self.status_message.emit(
323 tr("クオリティ変形には Pillow が必要です。通常変形へ切り替えました。")
324 )
325 elif use_proxy:
326 self._clear_tp_transform_masks(clear_proxy=False)
327 self.status_message.emit(
328 tr("5000px以上の画像は軽量TpMaskプレビューで表示し、"
329 "確定時に原寸で処理します。")
330 )
331 elif not self._prepare_tp_transform_masks(): 331 ↛ 332line 331 didn't jump to line 332 because the condition on line 331 was never true
332 self.transform_quality_active = False
333 self.status_message.emit(
334 tr("TpMaskを準備できなかったため、通常変形へ切り替えました。")
335 )
336 self.transform_drag_kind = None
337 self.transform_drag_start = QPointF()
338 self.transform_drag_points = []
340 painter = QPainter(self.active_layer.image)
341 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
342 painter.setPen(Qt.PenStyle.NoPen)
343 painter.setBrush(QColor(0, 0, 0, 255))
344 if self.selection_mask_override is not None: 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true
345 painter.setCompositionMode(
346 QPainter.CompositionMode.CompositionMode_DestinationOut
347 )
348 local_mask = self.selection_mask_bool(
349 rect.width(), rect.height(), rect.x(), rect.y()
350 )
351 painter.drawImage(
352 rect.topLeft(), self._bool_mask_image(local_mask)
353 )
354 else:
355 painter.setCompositionMode(
356 QPainter.CompositionMode.CompositionMode_Clear
357 )
358 painter.drawPolygon(QPolygonF(self.selection_polygon))
359 painter.end()
361 if mode == "mesh":
362 cols = max(2, int(getattr(self, "transform_mesh_cols", 4)))
363 rows = max(2, int(getattr(self, "transform_mesh_rows", 4)))
364 self.transform_mesh_cols = cols
365 self.transform_mesh_rows = rows
366 self.transform_mesh_grid = cols
367 self.transform_points = (
368 self._regular_mesh_reference_points(
369 QRectF(rect),
370 cols,
371 rows,
372 )
373 )
374 self.transform_mesh_reference_points = [
375 QPointF(point) for point in self.transform_points
376 ]
377 self._active_mesh_cols = cols
378 self._active_mesh_rows = rows
379 else:
380 self.transform_mesh_reference_points = []
381 left, top = float(rect.left()), float(rect.top())
382 right, bottom = float(rect.right() + 1), float(rect.bottom() + 1)
383 self.transform_points = [
384 QPointF(left, top), QPointF(right, top),
385 QPointF(right, bottom), QPointF(left, bottom),
386 ]
387 self.transform_handle = -1
388 if self.transform_quality_active:
389 self.request_quality_preview_counter(
390 tr("最初のクオリティプレビューを生成しています")
391 )
392 self.update()
393 return True
395 def transformed_selection_outline(self, outline):
396 """選択輪郭を現在の変形プレビューと同じ座標へ写像する。"""
397 if ( 397 ↛ 402line 397 didn't jump to line 402 because the condition on line 397 was never true
398 not self.transform_active
399 or self.transform_source_rect is None
400 or not self.transform_points
401 ):
402 return [QPointF(point) for point in outline]
403 rect = QRectF(self.transform_source_rect)
404 width = max(1.0, float(rect.width()))
405 height = max(1.0, float(rect.height()))
406 if self.transform_mode == "mesh": 406 ↛ 407line 406 didn't jump to line 407 because the condition on line 406 was never true
407 cols = max(2, int(getattr(self, "transform_mesh_cols", 4)))
408 rows = max(2, int(getattr(self, "transform_mesh_rows", 4)))
409 if len(self.transform_points) != cols * rows:
410 return [QPointF(point) for point in outline]
411 return [
412 self._mesh_curve_point(
413 self.transform_points,
414 cols,
415 rows,
416 (float(point.x()) - rect.left()) / width * (cols - 1),
417 (float(point.y()) - rect.top()) / height * (rows - 1),
418 )
419 for point in outline
420 ]
421 if len(self.transform_points) != 4: 421 ↛ 422line 421 didn't jump to line 422 because the condition on line 421 was never true
422 return [QPointF(point) for point in outline]
423 transform = self._quad_homography(
424 [
425 QPointF(0, 0), QPointF(width, 0),
426 QPointF(width, height), QPointF(0, height),
427 ],
428 self.transform_points,
429 )
430 return [
431 transform.map(QPointF(
432 float(point.x()) - rect.left(),
433 float(point.y()) - rect.top(),
434 ))
435 for point in outline
436 ]
438 def rotate_selection_transform(self, degrees):
439 if not self.transform_active or not self.transform_points: 439 ↛ 440line 439 didn't jump to line 440 because the condition on line 439 was never true
440 return
441 before = TransformUndo.capture(self)
442 center = self.transform_center()
443 angle = math.radians(float(degrees))
444 cs, sn = math.cos(angle), math.sin(angle)
445 self.transform_points = [
446 QPointF(
447 center.x() + (p.x() - center.x()) * cs - (p.y() - center.y()) * sn,
448 center.y() + (p.x() - center.x()) * sn + (p.y() - center.y()) * cs,
449 )
450 for p in self.transform_points
451 ]
452 self._record_transform_history(before)
453 self._invalidate_tp_preview_cache()
454 if self.transform_quality_active: 454 ↛ 455line 454 didn't jump to line 455 because the condition on line 454 was never true
455 self.request_quality_preview_counter(
456 tr("回転後のクオリティプレビューを生成しています")
457 )
458 self.update()
460 def _selection_source_from_image(self, image):
461 rect = self.transform_source_rect.toAlignedRect().intersected(image.rect())
462 if rect.isEmpty(): 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true
463 return None
464 source = image.copy(rect)
465 if self.selection_polygon: 465 ↛ 492line 465 didn't jump to line 492 because the condition on line 465 was always true
466 if self.selection_mask_override is not None: 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true
467 mask = self._bool_mask_image(self.selection_mask_bool(
468 rect.width(), rect.height(), rect.x(), rect.y()
469 ))
470 painter = QPainter(source)
471 painter.setCompositionMode(
472 QPainter.CompositionMode.CompositionMode_DestinationIn
473 )
474 painter.drawImage(0, 0, mask)
475 painter.end()
476 return source
477 mask = QImage(rect.width(), rect.height(), QImage.Format.Format_ARGB32_Premultiplied)
478 mask.fill(Qt.GlobalColor.transparent)
479 painter = QPainter(mask)
480 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
481 painter.setPen(Qt.PenStyle.NoPen)
482 painter.setBrush(QColor(255, 255, 255, 255))
483 painter.drawPolygon(QPolygonF([
484 QPointF(point.x() - rect.x(), point.y() - rect.y())
485 for point in self.selection_polygon
486 ]))
487 painter.end()
488 painter = QPainter(source)
489 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_DestinationIn)
490 painter.drawImage(0, 0, mask)
491 painter.end()
492 return source
494 def _cleared_selection_base(self, image):
495 base = image.copy()
496 painter = QPainter(base)
497 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
498 painter.setPen(Qt.PenStyle.NoPen)
499 painter.setBrush(QColor(0, 0, 0, 255))
500 if self.selection_mask_override is not None: 500 ↛ 501line 500 didn't jump to line 501 because the condition on line 500 was never true
501 painter.setCompositionMode(
502 QPainter.CompositionMode.CompositionMode_DestinationOut
503 )
504 painter.drawImage(
505 0, 0, self._bool_mask_image(self.selection_mask_override)
506 )
507 elif self.selection_polygon: 507 ↛ 512line 507 didn't jump to line 512 because the condition on line 507 was always true
508 painter.setCompositionMode(
509 QPainter.CompositionMode.CompositionMode_Clear
510 )
511 painter.drawPolygon(QPolygonF(self.selection_polygon))
512 elif self.transform_source_rect is not None:
513 painter.setCompositionMode(
514 QPainter.CompositionMode.CompositionMode_Clear
515 )
516 painter.fillRect(self.transform_source_rect, QColor(0, 0, 0, 255))
517 painter.end()
518 return base
520 def _merge_quality_transform(self, original, preview):
521 """Composite transformed artwork over the remaining layer."""
522 result = self._cleared_selection_base(original)
523 painter = QPainter(result)
524 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
525 painter.drawImage(0, 0, preview)
526 painter.end()
527 return result
529 def _record_transform_history(self, before):
530 if before != TransformUndo.capture(self):
531 self.push_undo(before)
532 self.selectionChanged.emit()
534 def _discard_transform_history(self):
535 # Geometry is temporary; confirmation stores a single image edit.
536 old_stack = list(self.undo_stack)
537 for branch in self.history_branches: 537 ↛ 538line 537 didn't jump to line 538 because the loop on line 537 never started
538 branch.position = sum(not isinstance(e, TransformUndo)
539 for e in old_stack[:branch.position])
540 branch.entries[:] = [e for e in branch.entries
541 if not isinstance(e, TransformUndo)]
542 self.history_branches[:] = [b for b in self.history_branches if b.entries]
543 self.undo_stack[:] = [e for e in self.undo_stack if not isinstance(e, TransformUndo)]
544 self.redo_stack[:] = [e for e in self.redo_stack if not isinstance(e, TransformUndo)]
546 def _reset_selection_transform(self):
547 self._discard_transform_history()
548 self.transform_active = False
549 self.transform_mode = None
550 self.transform_original_layer = None
551 self.transform_original_has_content = False
552 self.transform_source = None
553 self.transform_source_rect = None
554 self.transform_points = []
555 self.transform_mesh_reference_points = []
556 self.transform_handle = -1
557 self.transform_drag_kind = None
558 self.transform_drag_points = []
559 self.transform_quality_active = False
560 self.transform_tp_line_colors = ()
561 self._clear_tp_transform_masks()
563 def commit_selection_transform(self, all_frames=None):
564 if not self.transform_active: 564 ↛ 565line 564 didn't jump to line 565 because the condition on line 564 was never true
565 return
566 window: Any = self.window()
567 quality_active = bool(getattr(self, "transform_quality_active", False))
568 if all_frames is None:
569 all_frames = bool(
570 window.tools.selection_all_frames.isChecked()
571 if hasattr(window, "tools")
572 else getattr(self, "transform_apply_all_frames", False)
573 )
574 if quality_active:
575 target_width = self.active_layer.image.width()
576 target_height = self.active_layer.image.height()
577 if self._tp_uses_proxy(target_width, target_height):
578 # 大画像の表示中キャッシュは縮小代理画像なので、確定時だけ
579 # 原寸マスクを生成する。代理画像を確定結果へ流用しない。
580 self._clear_tp_transform_masks(clear_proxy=False)
581 self.refresh_quality_preview_with_counter(
582 tr("変形確定用の原寸クオリティ画像を生成しています"),
583 full_resolution=True,
584 )
585 elif self._tp_preview_cache_image is None: 585 ↛ 589line 585 didn't jump to line 589 because the condition on line 585 was always true
586 self.refresh_quality_preview_with_counter(
587 tr("変形確定用のクオリティ画像を生成しています")
588 )
589 if self._tp_preview_cache_image is None: 589 ↛ 590line 589 didn't jump to line 590 because the condition on line 589 was never true
590 self.status_message.emit(
591 tr("クオリティ画像を生成できなかったため、変形確定を中止しました。")
592 )
593 return
595 layer_index = int(getattr(self, "transform_layer_index", self.active_layer_index))
596 current_index = int(getattr(self, "transform_frame_index", self.current_frame))
597 original_current = self.transform_original_layer
598 original_current_has_content = getattr(
599 self, "transform_original_has_content", self.active_layer.has_content
600 )
601 frame_indices = list(
602 range(len(self.frames)) if all_frames else [current_index]
603 )
604 undo_cells = []
605 changed_frames = []
606 progress = None
607 if all_frames and window is not None:
608 progress = create_counter(
609 window,
610 tr("すべてのコマに変形"),
611 len(frame_indices),
612 tr("変形を準備しています"),
613 )
615 for progress_index, frame_index in enumerate(frame_indices, 1):
616 if progress is not None:
617 update_counter(
618 progress,
619 progress_index - 1,
620 len(frame_indices),
621 tr("{position}を変形しています").format(
622 position=frame_position(frame_index)),
623 )
624 if not (0 <= frame_index < len(self.frames)): 624 ↛ 625line 624 didn't jump to line 625 because the condition on line 624 was never true
625 continue
626 frame = self.frames[frame_index]
627 if not (0 <= layer_index < len(frame.layers)): 627 ↛ 628line 627 didn't jump to line 628 because the condition on line 627 was never true
628 continue
629 layer = frame.layers[layer_index]
630 if frame_index == current_index and original_current is not None:
631 original = original_current.copy()
632 original_has_content = original_current_has_content
633 else:
634 if not layer.has_content: 634 ↛ 635line 634 didn't jump to line 635 because the condition on line 634 was never true
635 continue
636 original = layer.image.copy()
637 original_has_content = layer.has_content
639 source = (
640 self.transform_source
641 if quality_active and frame_index == current_index
642 and self.transform_source is not None
643 else self._selection_source_from_image(original)
644 )
645 if source is None or source.isNull(): 645 ↛ 646line 645 didn't jump to line 646 because the condition on line 645 was never true
646 continue
647 preview, _target = self.transform_preview_image(
648 source, original.width(), original.height(),
649 quality=quality_active,
650 )
651 if preview is None: 651 ↛ 652line 651 didn't jump to line 652 because the condition on line 651 was never true
652 continue
654 if quality_active:
655 base = self._merge_quality_transform(original, preview)
656 else:
657 base = self._cleared_selection_base(original)
658 painter = QPainter(base)
659 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
660 painter.drawImage(0, 0, preview)
661 painter.end()
663 undo_cells.append((frame_index, original.copy(), original_has_content))
664 layer.image = base
665 layer.has_content = True
666 changed_frames.append(frame_index)
668 if progress is not None:
669 close_counter(progress)
671 for frame_index in changed_frames:
672 self.sync_numbered_image_from_cell(
673 frame_index,
674 layer_index,
675 )
676 # 描画系ツールと同様にセル変更を通知する。これがないと
677 # ヒストリーパネル(cellChanged/changedで更新)が確定後も
678 # 再描画されず、変形の履歴が一覧に現れない。
679 self.cellChanged.emit(frame_index, layer_index)
681 self._discard_transform_history()
682 if undo_cells: 682 ↛ 692line 682 didn't jump to line 692 because the condition on line 682 was always true
683 if len(undo_cells) == 1:
684 frame_index, image, has_content = undo_cells[0]
685 self.push_undo(
686 LayerUndo(frame_index, layer_index, image, has_content)
687 )
688 else:
689 self.push_undo(LayerBatchUndo(layer_index, undo_cells))
690 # 変形ではコマ構造と使用色の種類は変わらないため、
691 # タイムライン全再構築・使用色全走査は行わない。
692 elif (
693 original_current is not None
694 and 0 <= current_index < len(self.frames)
695 and 0 <= layer_index < len(self.frames[current_index].layers)
696 ):
697 layer = self.frames[current_index].layers[layer_index]
698 layer.image = original_current
699 layer.has_content = original_current_has_content
701 self._reset_selection_transform()
702 self.transform_apply_all_frames = False
703 # V62: a confirmed transform finishes the operation completely.
704 self.selection_polygon = []
705 self.selection_mask_override = None
706 self.selection_outline_polygons = []
707 self.selection_mask_rect = None
708 self.lasso = []
709 self.rect_start = None
710 self.rect_end = None
711 self.selectionChanged.emit()
712 self._update_selection_clear_overlay()
713 self.update()
715 def cancel_selection_transform(self):
716 if not self.transform_active: 716 ↛ 717line 716 didn't jump to line 717 because the condition on line 716 was never true
717 return
718 if self.transform_original_layer is not None: 718 ↛ 730line 718 didn't jump to line 730 because the condition on line 718 was always true
719 frame_index = int(getattr(self, "transform_frame_index", self.current_frame))
720 layer_index = int(getattr(self, "transform_layer_index", self.active_layer_index))
721 if ( 721 ↛ 730line 721 didn't jump to line 730 because the condition on line 721 was always true
722 0 <= frame_index < len(self.frames)
723 and 0 <= layer_index < len(self.frames[frame_index].layers)
724 ):
725 layer = self.frames[frame_index].layers[layer_index]
726 layer.image = self.transform_original_layer
727 layer.has_content = getattr(
728 self, "transform_original_has_content", layer.has_content
729 )
730 self._reset_selection_transform()
731 self.transform_apply_all_frames = False
732 self.selectionChanged.emit()
733 self.update()
735 def selection_bounds(self):
736 if not self.selection_polygon:
737 return QRectF(0,0,self.active_layer.image.width(),self.active_layer.image.height()).toAlignedRect()
738 if self.selection_mask_rect is not None:
739 return QRectF(self.selection_mask_rect).toAlignedRect()
740 xs=[p.x() for p in self.selection_polygon]; ys=[p.y() for p in self.selection_polygon]
741 return QRectF(min(xs),min(ys),max(xs)-min(xs),max(ys)-min(ys)).toAlignedRect()
743 def copy_selection(self):
744 """選択範囲の形どおりに切り抜いてコピーする(外側は透明)。"""
745 rect=self.selection_bounds().intersected(self.active_layer.image.rect())
746 if rect.isEmpty(): return False 746 ↛ exitline 746 didn't return from function 'copy_selection' because the return on line 746 wasn't executed
747 # 外接矩形をそのまま写すと、自動選択・反転・投げ縄の外側の画素まで入る。
748 QApplication.clipboard().setImage(self._masked_selection_source(rect))
749 return True
751 def cut_selection(self):
752 """コピーしてから、選択範囲の中だけを消す(Undo 1回で戻る)。
754 以前は消すときに多角形しか見ていなかったので、マスクで持つ選択
755 (自動選択・反転)では消える範囲が選択の形とずれていた。
756 """
757 if not self.copy_selection(): 757 ↛ 758line 757 didn't jump to line 758 because the condition on line 757 was never true
758 return False
759 if self.selection_polygon:
760 return self.delete_selection_contents()
761 # 選択が無いときは、これまでどおりレイヤー全体を切り取る。
762 rect = self.active_layer.image.rect()
763 if not self.push_layer_region_undo(rect): 763 ↛ 764line 763 didn't jump to line 764 because the condition on line 763 was never true
764 return False
765 self.active_layer.image.fill(Qt.GlobalColor.transparent)
766 self.cellChanged.emit(self.current_frame, self.active_layer_index)
767 self.update()
768 return True
770 def delete_selection_contents(self):
771 """選択範囲の中を消す(透明にする)。選択が無ければ何もしない。"""
772 if not self.selection_polygon or self.transform_active:
773 return False
774 self.ensure_editable_key()
775 image = self.active_layer.image
776 width, height = image.width(), image.height()
777 mask = self.selection_mask_bool(width, height)
778 if mask is None or not mask.any(): 778 ↛ 779line 778 didn't jump to line 779 because the condition on line 778 was never true
779 return False
780 ys, xs = np.nonzero(mask)
781 rect = QRect(
782 int(xs.min()), int(ys.min()),
783 int(xs.max() - xs.min() + 1), int(ys.max() - ys.min() + 1),
784 )
785 if not self.push_layer_region_undo(rect): 785 ↛ 786line 785 didn't jump to line 786 because the condition on line 785 was never true
786 return False
787 # 自動選択や反転の選択はマスクで持っているので、多角形ではなくマスクで消す。
788 clear = QImage(rect.width(), rect.height(), QImage.Format.Format_ARGB32)
789 clear.fill(Qt.GlobalColor.transparent)
790 keep = imaging.bool_mask_image(
791 ~mask[rect.top():rect.bottom() + 1, rect.left():rect.right() + 1]
792 )
793 painter = QPainter(clear)
794 painter.drawImage(0, 0, image, rect.x(), rect.y(), rect.width(), rect.height())
795 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_DestinationIn)
796 painter.drawImage(0, 0, keep)
797 painter.end()
798 painter = QPainter(image)
799 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
800 painter.drawImage(rect.topLeft(), clear)
801 painter.end()
802 self.cellChanged.emit(self.current_frame, self.active_layer_index)
803 self.update()
804 return True
806 def paste_clipboard(self):
807 image=QApplication.clipboard().image()
808 if image.isNull(): return
809 self.ensure_editable_key()
810 rect=self.selection_bounds()
811 pos=rect.topLeft() if self.selection_polygon else QPoint(OUTSIDE_MARGIN,OUTSIDE_MARGIN)
812 changed_rect = QRect(pos, image.size()).intersected(
813 self.active_layer.image.rect()
814 )
815 if not self.push_layer_region_undo(changed_rect): return
816 p=QPainter(self.active_layer.image);p.drawImage(pos,image);p.end()
817 self.active_layer.has_content=True
818 self.cellChanged.emit(self.current_frame,self.active_layer_index);self.update()