Coverage for paintmaskanimator/canvas.py: 72%
1212 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"""The ``PaintCanvas`` widget — the drawing surface and its irreducible core.
3Architecture (see also the domain modules ``document``/``imaging``/``geometry``/
4``colors``/``color_ops``/``project_io``): cohesive method clusters were extracted
5into ``canvas_<topic>.py`` as ``*Mixin`` classes and composed onto ``PaintCanvas``
6below. What remains in this file is the irreducible widget core — ``__init__``,
7``paintEvent``, the document-backed properties (``frames``/``current_frame``/
8``active_layer_index``), view transforms (zoom/pan/canvas<->widget mapping),
9colour resolution for painting, layer compositing, and the delegating
10class-method surface that keeps ``imaging``/``color_reduction`` reachable through
11``PaintCanvas``. Timeline structure editing, the pixel-writing tools and the
12free-transform geometry live in ``canvas_timeline_ops.py``,
13``canvas_paint_tools.py`` and ``canvas_transform_geometry.py``.
15MRO note: ``InputEventMixin`` provides Qt event overrides (``mousePressEvent`` &c.)
16and therefore MUST precede ``QWidget`` in the base list, or the overrides lose to
17``QWidget``'s defaults. The reverse hazard applies to *non*-virtual Qt names: a
18plain method on a mixin that shares its name with a ``QWidget`` method (e.g.
19``connect``, ``scroll``) is shadowed by Qt's own during attribute lookup, so a
20mixin must never reuse one. Type-only member declarations for attributes set in
21``__init__`` (invisible to a checker inspecting one mixin in isolation) live in
22``_canvas_members.py``; a genuinely undefined name still surfaces there.
23"""
24import dataclasses
25import math
26import numpy as np
27import time
28from typing import Any
29from PySide6.QtCore import QPoint, QPointF, QRect, QRectF, QTimer, Qt, Signal
30from PySide6.QtGui import (
31 QColor,
32 QCursor,
33 QImage,
34 QPainter,
35 QPainterPath,
36 QPen,
37 QPixmap,
38 QPolygonF,
39 QRegion,
40)
41from PySide6.QtWidgets import QPushButton, QWidget
42from .i18n import tr
43from .constants import OUTSIDE_MARGIN
44from . import constants
45from . import (color_ops, colors, display_shrink, geometry, imaging, native,
46 preferences, view_preview)
47from .document import Document
48from .brush_raster import stamp_outline
49from .toolpanel import tool_label
50from .utils import blank_image, magnifier_cursor, tool_cursor, workspace_size
51from .progress import close_counter, create_counter, update_counter
52from .logging_setup import get_logger
53from .canvas_brush_stabilizer import BrushStabilizerMixin
54from .canvas_image_import import ImageImportMixin
55from .canvas_input_events import InputEventMixin
56from .canvas_key_frame import KeyFrameMixin
57from .canvas_onion_interaction import OnionInteractionMixin
58from .canvas_onion_render import OnionRenderMixin
59from .canvas_paint_tools import PaintToolsMixin
60from .canvas_skewer_fill import SkewerFillMixin
61from .canvas_playback import PlaybackMixin
62from .canvas_selection import SelectionMixin
63from .canvas_stroke_display import StrokeDisplayMixin
64from .canvas_timeline_ops import TimelineStructureMixin
65from .canvas_transform_geometry import TransformGeometryMixin
66from .canvas_transform_mask import TransformMaskMixin
67from .canvas_undo import UndoMixin
68from .undo_entries import (
69 TransformUndo,
70 DocUndo,
71 ExposureUndo,
72 InitialStateUndo,
73 LayerBatchUndo,
74 LayerInsertUndo,
75 LayerRegionUndo,
76 LayerRemoveUndo,
77 LayerTilesUndo,
78 LayerUndo,
79 PaletteStateUndo,
80 ScopedBatchUndo,
81 TweenBatchUndo,
82)
84log = get_logger(__name__)
88#: 色索引・表示フィルタのキャッシュが保持する画素数の上限。10000×10000 の
89#: レイヤーは1枚で数百MBになるため、件数だけでなく画素数でも制限する
90#: (最低1件は保持する)。
91_CACHE_PIXEL_BUDGET = 120_000_000
94def _bounded_cache_put(cache, key, value, max_entries):
95 """key[1], key[2] を幅・高さとして、件数と画素数の上限内でキャッシュする。"""
96 pixels = int(key[1]) * int(key[2])
97 total = sum(int(k[1]) * int(k[2]) for k in cache)
98 while cache and ( 98 ↛ 101line 98 didn't jump to line 101 because the condition on line 98 was never true
99 len(cache) >= max_entries or total + pixels > _CACHE_PIXEL_BUDGET
100 ):
101 oldest = next(iter(cache))
102 total -= int(oldest[1]) * int(oldest[2])
103 cache.pop(oldest)
104 cache[key] = value
106class PaintCanvas(
107 ImageImportMixin, OnionInteractionMixin, OnionRenderMixin, SelectionMixin,
108 TransformMaskMixin, TransformGeometryMixin, SkewerFillMixin, PaintToolsMixin,
109 TimelineStructureMixin, BrushStabilizerMixin, StrokeDisplayMixin,
110 InputEventMixin, UndoMixin, PlaybackMixin,
111 KeyFrameMixin, QWidget
112):
113 status_message=Signal(str)
114 colorUsed=Signal(QColor)
115 viewChanged=Signal(float, float)
116 onionInteractionChanged=Signal()
117 onionInteractionFinished=Signal()
118 changed=Signal(); selectionChanged=Signal(); selectionCleared=Signal(); cellChanged=Signal(int,int); imagesDropped=Signal(object); projectDropped=Signal(str); timeRemapDropped=Signal(str); clipAnimationDropped=Signal(str); colorSampled=Signal(QColor)
119 def __init__(self):
120 super().__init__(); self.setFocusPolicy(Qt.FocusPolicy.StrongFocus); self.setMouseTracking(True); self.setTabletTracking(True); self.setMinimumSize(320,120); self.setAcceptDrops(True)
121 self._document=Document()
122 self._playback_active=False
123 self._playback_frame_cache={}
124 self._playback_cache_limit=14
125 self._playback_resolved_keys=[]
126 # 再生する尺。終了は排他的。None は文書の現在の長さを使う。
127 self.playback_range_start = 0
128 self.playback_range_end: int | None = None
129 # 連番読込直後の画像を、タイムリマップ再配置用に保持する。
130 self._sequence_source_bank=[]
131 self._sequence_source_bank_layer_index=-1
132 self._sequence_source_bank_layer_name=""
133 self.clip_studio_source_metadata: dict | None = None
134 # シートから外した絵番号を、右クリックで再配置するため保持する。
135 self._sequence_archive={}
136 self.timeline_mode="sheet"
137 self.tool="brush"; self.temp_tool=None; self.main_color=QColor("black"); self.sub_color=QColor(255,0,0); self.color_mode="main"; self.transparent_display_color=QColor("white"); self.background_mask_rgb=(255,255,255); self.mask_color_rgbs=set(); self.mask_color_rgb=None; self.mask_all_enabled=True; self.selected_used_color_rgbs=set(); self.visible_color_rgbs=None
138 # 使用色の親子プレビュー:{子rgb: 親rgb}。表示時のみ子を親色へ塗り替える(非破壊)。
139 self.preview_color_remap={}
140 self.selection_polygon=[]
141 self.selection_mask_override=None
142 self.selection_outline_polygons=[]
143 self.selection_mask_rect=None
144 self._selection_fade_opacity=1.0
145 self._selection_fade_started=time.monotonic()
146 self._selection_blink_timer=QTimer(self)
147 self._selection_blink_timer.setInterval(40)
148 self._selection_blink_timer.timeout.connect(
149 self._update_selection_fade
150 )
151 self._selection_blink_timer.start()
152 self.rect_start=None
153 self.rect_end=None
154 self.transform_active=False
155 self.transform_mode=None
156 self.transform_original_layer=None
157 self.transform_source=None
158 self.transform_source_rect=None
159 self.transform_points=[]
160 self.transform_handle=-1
161 self.transform_drag_kind=None
162 self.transform_drag_start=QPointF()
163 self.transform_drag_points=[]
164 self.transform_original_has_content=False
165 self.transform_frame_index=0
166 self.transform_layer_index=0
167 self.transform_apply_all_frames=False
168 self.tween_pending = None
169 self.transform_quality=True
170 self.transform_quality_active=False
171 self.transform_line_threshold=96
172 self.transform_tp_line_colors=()
173 # TP_mask v0.7 compatible data. The selected image is reduced to exact
174 # colors and stored as one label id per pixel; the deformation gives
175 # each output pixel to whichever color covers most of it, so no
176 # interpolation colors are ever produced.
177 self.transform_tp_palette=[]
178 self.transform_tp_masks=[]
179 self.transform_tp_line_masks=[]
180 self.transform_tp_prepared_preview=None
181 self._tp_label_image=None
182 self._tp_label_colors=None
183 self._tp_geometry_cache_key=None
184 self._tp_geometry_cache_bbox=None
185 self._tp_geometry_cache_fill_overlay=None
186 self._tp_geometry_cache_line_soft=[]
187 self._tp_preview_cache_key=None
188 self._tp_preview_cache_image=None
189 self._tp_mask_source_key=None
190 self._tp_proxy_source_key=None
191 self._tp_proxy_source_image=None
192 self._tp_proxy_rendering=False
193 # V62: quality preview is generated outside paintEvent. While the
194 # per-color TP masks are transformed, a counter popup reports progress.
195 self._tp_preview_progress_busy=False
196 self._tp_preview_progress_scheduled=False
197 self._transform_line_adjusting=False
198 self._transform_line_preview_timer=QTimer(self)
199 self._transform_line_preview_timer.setSingleShot(True)
200 self._transform_line_preview_timer.setInterval(120)
201 self._transform_line_preview_timer.timeout.connect(
202 self._render_deferred_transform_line_preview
203 )
204 self.transform_mesh_cols=4
205 self.transform_mesh_rows=4
206 self.transform_mesh_grid=4 # legacy compatibility
207 self.transform_mesh_reference_points=[]
208 self._brush_resize_anchor = None
209 self.pen_size=8
210 self.brush_stabilizer_strength = 0
211 # バケツ・投げ縄塗りの不透明度(0〜1)。ブラシ等は常に100%。
212 self.fill_opacity = 1.0
213 self._stabilized_canvas = None
214 self._last_raw_canvas = None
215 self._brush_stabilizer_history = []
216 self._last_brush_pressure = 1.0
217 self._pressure_input_history = []
218 self._brush_follow_settle = 0.0
219 self._brush_follow_timer = QTimer(self)
220 self._brush_follow_timer.setInterval(16)
221 self._brush_follow_timer.timeout.connect(
222 self._advance_stabilized_brush
223 )
224 self.pressure_enabled=True; self.pressure_min=.1; self.pressure_max=1.0; self.pressure_curve=1.0; self.pressure_curve_points=[[0.0,0.0],[0.5,0.5],[1.0,1.0]]
225 self.zoom=1.0; self.pan=QPointF(); self.rotation=0.0; self.flip_horizontal=False; self.flip_vertical=False; self.onion_skin=False
226 self.onion_previous_count=1
227 self.onion_next_count=1
228 self.onion_previous_opacity=0.22
229 self.onion_next_opacity=0.22
230 self.onion_previous_color=QColor(255, 92, 92)
231 self.onion_next_color=QColor(92, 160, 255)
232 self.onion_previous_color_enabled=False
233 self.onion_next_color_enabled=False
234 self.onion_selected_colors_only=False
235 self.onion_previous_shift_x=0.0
236 self.onion_previous_shift_y=0.0
237 self.onion_previous_rotation=0.0
238 self.onion_previous_scale=100.0
239 self.onion_next_shift_x=0.0
240 self.onion_next_shift_y=0.0
241 self.onion_next_rotation=0.0
242 self.onion_next_scale=100.0
243 # TU/TB用のカメラ倍率。表示用デジタルズームself.zoomとは分離。
244 self.onion_tu_tb_scale=1.0
245 self.onion_previous_levels=[100]
246 self.onion_next_levels=[100]
247 self.onion_center_percent=50.0
248 self._onion_interaction_mode=None
249 self._onion_interaction_direction=-1
250 self._onion_interaction_axis=None
251 self._onion_interaction_operation=None
252 self._onion_interaction_dragging=False
253 self._onion_interaction_start_widget=QPointF()
254 self._onion_interaction_start_pan=QPointF()
255 self._onion_interaction_start_view_rotation=0.0
256 self._onion_interaction_start_previous=(0.0, 0.0)
257 self._onion_interaction_start_next=(0.0, 0.0)
258 self._onion_interaction_start_previous_rotation=0.0
259 self._onion_interaction_start_next_rotation=0.0
260 self._onion_interaction_start_previous_scale=100.0
261 self._onion_interaction_start_next_scale=100.0
262 self._onion_interaction_start_tu_tb_scale=1.0
263 self.onion_all_layers=True
264 self._color_filter_cache = {}
265 # 画像ごとのRGBインデックスを保持し、表示チェックのたびの再計算を避ける。
266 self._color_index_cache = {}
267 self.silhouette_non_background = False
268 # 比較表示:今の絵の代わりに、保持した色塗り前の初期状態を描く。
269 self.show_initial_state = False
270 self._silhouette_cache = {}
271 # 露光量・チャンネル・隣接色分けの表示プレビュー(画面だけ。書き出しには掛けない)。
272 self.view_preview = view_preview.ViewPreview(palette=preferences.map_palette())
273 self._view_preview_cache = {}
274 self._view_map_color_mappings = {}
275 self._onion_cache = {}
276 self.drawing=False; self.last_canvas=QPointF(); self.last_widget=QPointF(); self.lasso=[]; self.middle_hand=False
277 self._brush_cursor_widget_pos = QPointF(-1000, -1000)
278 self._brush_cursor_inside = False
279 # ブラシ確定時にタイムライン全体を作り直さないための状態。
280 self._brush_started_with_content = True
281 self._brush_blended_colors = set()
282 # Undo用の変更前画素はタイル単位で遅延保存する。全画面サイズの
283 # QImageをストローク開始時に確保しない。
284 self._stroke_before_tiles = None
285 self._stroke_dirty_rect = None
286 self._stroke_undo_frame = 0
287 self._stroke_undo_layer = 0
288 self._stroke_prev_has_content = False
289 self._brush_runtime_warmed = False
290 self._cell_structure_dirty = True
291 # ライン/図形ツールのプレビュー状態
292 self.line_start=None
293 self.line_end=None
294 self.line_control=None
295 self.line_curve_stage=0 # 0:待機、1:基準線ドラッグ、2:曲率指定
296 self.shape_start=None
297 self.shape_end=None
298 self.undo_stack=[]; self.redo_stack=[]; self.stroke_before=None
299 # 使用色パネルへの参照。MainWindow が初期化時に差し込む。
300 # palette_state のUndo/Redoでパネル状態を復元するために使う。
301 self._palette: Any=None # set by MainWindow to the shared UsedColorPanel
302 self.mesh_points=[]; self.mesh_original=None; self.mesh_active=-1; self.mesh_grid=4
303 self.checker_light=QColor(255,255,255); self.checker_dark=QColor(255,255,255)
305 self.selection_clear_overlay = QPushButton(tr("選択解除"), self)
306 self.selection_clear_overlay.setToolTip(tr("現在の選択範囲を解除します。"))
307 self.selection_clear_overlay.setFixedHeight(26)
308 self.selection_clear_overlay.setStyleSheet(
309 "QPushButton{background:rgba(35,35,35,215);color:white;"
310 "border:1px solid white;border-radius:4px;padding:2px 8px;}"
311 "QPushButton:hover{background:rgba(70,70,70,235);}"
312 )
313 self.selection_clear_overlay.setFocusPolicy(
314 Qt.FocusPolicy.NoFocus
315 )
316 self.selection_clear_overlay.setAttribute(
317 Qt.WidgetAttribute.WA_TransparentForMouseEvents,
318 False,
319 )
320 self.selection_clear_overlay.clicked.connect(
321 self.clear_selection
322 )
323 self.selection_clear_overlay.hide()
324 self.selectionChanged.connect(
325 self._update_selection_clear_overlay
326 )
327 self.changed.connect(self._invalidate_exposure_index)
328 QTimer.singleShot(0, self.warm_up_brush_runtime)
329 def _invalidate_exposure_index(self):
330 self._exposure_index_signature = None
332 def _position_selection_clear_overlay(self):
333 button = getattr(self, "selection_clear_overlay", None)
334 if button is None: 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true
335 return
336 button.adjustSize()
337 margin = 10
338 button.move(
339 max(margin, self.width() - button.width() - margin),
340 margin,
341 )
342 button.raise_()
344 def _update_selection_clear_overlay(self):
345 """選択中は、使用ツールに関係なく右上の解除を有効化する。"""
346 button = getattr(
347 self,
348 "selection_clear_overlay",
349 None,
350 )
351 if button is None: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true
352 return
353 visible = bool(self.selection_polygon)
354 button.setEnabled(True)
355 button.setVisible(visible)
356 if visible:
357 self._position_selection_clear_overlay()
358 button.raise_()
360 def resizeEvent(self, event):
361 self._position_selection_clear_overlay()
362 super().resizeEvent(event)
364 # Core document state lives in self._document; these properties delegate so
365 # the many existing `self.frames` / `self.current_frame` call sites keep
366 # working unchanged.
367 @property
368 def frames(self): return self._document.frames
369 @frames.setter
370 def frames(self, value):
371 self._document.frames = value
372 self._invalidate_exposure_index()
373 @property
374 def current_frame(self): return self._document.current_frame
375 @current_frame.setter
376 def current_frame(self, value): self._document.current_frame = value
377 @property
378 def active_layer_index(self): return self._document.active_layer_index
379 @active_layer_index.setter
380 def active_layer_index(self, value): self._document.active_layer_index = value
381 @property
382 def layers(self): return self._document.layers
383 @property
384 def active_layer(self): return self._document.active_layer
385 def current_state_for(self, entry):
386 """Capture the inverse of ``entry``: the state undoing it would replace.
388 Returns ``None`` when the entry targets a cell that no longer exists
389 (timeline deletions leave stale history behind), telling the caller to
390 drop it instead of applying it.
391 """
392 def _cell_exists(fi, li):
393 return (
394 0 <= fi < len(self.frames)
395 and 0 <= li < len(self.frames[fi].layers)
396 )
398 if isinstance(entry, TransformUndo):
399 return TransformUndo.capture(self) if self.transform_active else None
400 if isinstance(entry, ExposureUndo):
401 return ExposureUndo.capture(self, entry.layer, entry.column,
402 tuple((dst, src) for src, dst in entry.moves))
403 if isinstance(entry, DocUndo):
404 return DocUndo(self.document_snapshot())
405 if isinstance(entry, InitialStateUndo):
406 cells = [
407 (fi, li, QImage(image) if (image := self.frames[fi].layers[li].initial_image) is not None else None)
408 for fi, li, _ in entry.cells if _cell_exists(fi, li)
409 ]
410 archived = [
411 (li, number, QImage(image) if (image := self._sequence_archive[(li, number)].initial_image) is not None else None)
412 for li, number, _ in entry.archived if (li, number) in self._sequence_archive
413 ]
414 return InitialStateUndo(cells, archived) if cells or archived else None
415 if isinstance(entry, PaletteStateUndo):
416 palette = getattr(self, "_palette", None)
417 if palette is None: 417 ↛ 418line 417 didn't jump to line 418 because the condition on line 417 was never true
418 return None
419 # 反対側スタックには「現在のパレット状態」を積む。ラベルは対称。
420 return PaletteStateUndo(palette.capture_history_state(), entry.label)
421 if isinstance(entry, LayerRegionUndo):
422 if not _cell_exists(entry.frame, entry.layer): 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true
423 return None
424 layer = self.frames[entry.frame].layers[entry.layer]
425 return LayerRegionUndo(
426 entry.frame,
427 entry.layer,
428 QRect(entry.rect),
429 layer.image.copy(entry.rect),
430 bool(layer.has_content),
431 )
432 if isinstance(entry, LayerTilesUndo):
433 if not _cell_exists(entry.frame, entry.layer): 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true
434 return None
435 layer = self.frames[entry.frame].layers[entry.layer]
436 return LayerTilesUndo(
437 entry.frame,
438 entry.layer,
439 tuple(
440 (QRect(rect), layer.image.copy(QRect(rect)))
441 for rect, _image in entry.tiles
442 ),
443 bool(layer.has_content),
444 )
445 if isinstance(entry, ScopedBatchUndo):
446 current = [
447 (fi, li, QImage(self.frames[fi].layers[li].image),
448 bool(self.frames[fi].layers[li].has_content))
449 for fi, li, _img, _hc in entry.cells
450 if _cell_exists(fi, li)
451 ]
452 return ScopedBatchUndo(current, entry.label) if current else None
453 if isinstance(entry, LayerBatchUndo): 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true
454 li = entry.layer
455 current = [
456 (fi, QImage(self.frames[fi].layers[li].image),
457 self.frames[fi].layers[li].has_content)
458 for fi, _img, _hc in entry.cells
459 if _cell_exists(fi, li)
460 ]
461 return LayerBatchUndo(li, current) if current else None
462 if isinstance(entry, LayerRemoveUndo):
463 index = entry.index
464 stored = [
465 frame.layers[index].clone()
466 for frame in self.frames
467 if 0 <= index < len(frame.layers)
468 ]
469 return LayerInsertUndo(
470 int(index),
471 stored,
472 int(self.active_layer_index),
473 )
474 if isinstance(entry, LayerInsertUndo):
475 return LayerRemoveUndo(
476 int(entry.index),
477 int(self.active_layer_index),
478 )
479 if isinstance(entry, TweenBatchUndo): 479 ↛ 480line 479 didn't jump to line 480 because the condition on line 479 was never true
480 li = entry.layer
481 current = []
482 for fi in range(int(entry.start), min(int(entry.end) + 1, len(self.frames))):
483 if 0 <= li < len(self.frames[fi].layers):
484 layer = self.frames[fi].layers[li]
485 current.append((
486 fi,
487 QImage(layer.image),
488 bool(layer.has_content),
489 int(layer.exposure),
490 ))
491 return TweenBatchUndo(
492 int(li),
493 len(self.frames),
494 int(entry.start),
495 int(entry.end),
496 current,
497 )
498 if isinstance(entry, LayerUndo): 498 ↛ 508line 498 didn't jump to line 508 because the condition on line 498 was always true
499 if not _cell_exists(entry.frame, entry.layer): 499 ↛ 500line 499 didn't jump to line 500 because the condition on line 499 was never true
500 return None
501 layer = self.frames[entry.frame].layers[entry.layer]
502 return LayerUndo(
503 entry.frame,
504 entry.layer,
505 QImage(layer.image),
506 layer.has_content,
507 )
508 log.warning("unknown undo entry type: %r", type(entry).__name__)
509 return None
511 def set_tool(self, t):
512 if t != self.tool or t == "shape":
513 self.abort_skewer_fill()
514 self._stop_brush_follow_timer()
515 self.line_start = None
516 self.line_end = None
517 self.line_control = None
518 self.line_curve_stage = 0
519 self.shape_start = None
520 self.shape_end = None
521 self.drawing = False
522 self._brush_line_anchor = None
523 self.tool = t
524 self._brush_shift_line = False
525 self.update_tool_cursor()
526 self.update()
528 def _eyedropper_cursor(self):
529 pixmap = QPixmap(24, 24)
530 pixmap.fill(Qt.GlobalColor.transparent)
531 painter = QPainter(pixmap)
532 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
533 pen = QPen(QColor("black"), 2)
534 painter.setPen(pen)
535 painter.drawLine(5, 18, 16, 7)
536 painter.drawLine(8, 21, 19, 10)
537 painter.drawLine(15, 6, 20, 11)
538 painter.drawEllipse(QPointF(5, 19), 2, 2)
539 painter.end()
540 return QCursor(pixmap, 5, 19)
543 @staticmethod
544 def _normalized_angle(angle):
545 return geometry.normalized_angle(angle)
547 @staticmethod
548 def _rotated_vector(x, y, angle_degrees):
549 return geometry.rotated_vector(x, y, angle_degrees)
552 def update_tool_cursor(self):
553 if self._onion_interaction_mode in ( 553 ↛ 558line 553 didn't jump to line 558 because the condition on line 553 was never true
554 "canvas",
555 "onion_shift",
556 "onion_transform",
557 ):
558 if (
559 self._onion_interaction_dragging
560 and self._onion_interaction_operation == "rotate"
561 ):
562 self.setCursor(Qt.CursorShape.SizeHorCursor)
563 elif self._onion_interaction_mode == "canvas":
564 self.setCursor(
565 Qt.CursorShape.ClosedHandCursor
566 if self._onion_interaction_dragging
567 else Qt.CursorShape.OpenHandCursor
568 )
569 elif self._onion_interaction_axis == "x":
570 self.setCursor(Qt.CursorShape.SizeHorCursor)
571 elif self._onion_interaction_axis == "y":
572 self.setCursor(Qt.CursorShape.SizeVerCursor)
573 else:
574 self.setCursor(Qt.CursorShape.SizeAllCursor)
575 return
577 tool = self.effective_tool()
578 if tool == "hand":
579 self.setCursor(
580 Qt.CursorShape.ClosedHandCursor
581 if self.drawing or self.middle_hand
582 else Qt.CursorShape.OpenHandCursor
583 )
584 elif tool == "zoom":
585 self.setCursor(magnifier_cursor())
586 elif tool == "rotate":
587 self.setCursor(Qt.CursorShape.SizeAllCursor)
588 elif tool == "eyedropper":
589 self.setCursor(self._eyedropper_cursor())
590 elif tool == "brush" and self._brush_cursor_inside:
591 # 同じBlankCursorをマウス移動のたびに再設定すると、Windowsで
592 # OSカーソルと描画リングが一瞬切り替わって見えることがある。
593 if self.cursor().shape() != Qt.CursorShape.BlankCursor:
594 self.setCursor(Qt.CursorShape.BlankCursor)
595 else:
596 shaped = tool_cursor(tool)
597 if shaped is not None:
598 self.setCursor(shaped)
599 else:
600 self.unsetCursor()
602 def set_pen_size(self, value):
603 self.pen_size = max(0.5, float(value))
604 self.update()
607 def effective_tool(self):
608 tool = self.temp_tool or self.tool
609 if tool == "brush" and getattr(self, "_brush_shift_line", False):
610 return "line"
611 if tool == "shape":
612 panel = self._active_tool_panel()
613 if panel is not None and panel.shape_type.currentData() in ("straight", "curve"): 613 ↛ 615line 613 didn't jump to line 615 because the condition on line 613 was always true
614 return "line"
615 return tool
616 def work_rect(self):
617 w,h=workspace_size(); return QRectF(self.pan.x(),self.pan.y(),w*self.zoom,h*self.zoom)
619 def set_zoom_around_canvas_center(self, zoom):
620 """Change zoom without moving the canvas center on screen."""
621 old_zoom = max(0.0001, float(self.zoom))
622 new_zoom = max(0.05, min(16.0, float(zoom)))
623 width, height = workspace_size()
624 canvas_center = QPointF(width / 2.0, height / 2.0)
625 screen_center = self.pan + canvas_center * old_zoom
626 self.zoom = new_zoom
627 self.pan = screen_center - canvas_center * new_zoom
629 def set_zoom_around_widget_point(self, zoom, widget_pos):
630 """Change zoom keeping the canvas point under ``widget_pos`` in place.
632 Zooming about the cursor lets the user magnify exactly where they are
633 looking. Rotation, flip and the camera scale are all applied about
634 the work rect, which moves rigidly with ``pan``; so zooming about any
635 point and then shifting ``pan`` by the drift of the anchor is exact.
636 """
637 anchor = QPointF(widget_pos)
638 canvas_point = self.widget_to_canvas(anchor)
639 self.set_zoom_around_canvas_center(zoom)
640 self.pan += anchor - self.canvas_to_widget(canvas_point)
642 def center_canvas(self):
643 """Place the workspace center at the center of the canvas widget."""
644 width, height = workspace_size()
645 self.pan = QPointF(
646 (self.width() - width * self.zoom) / 2.0,
647 (self.height() - height * self.zoom) / 2.0,
648 )
649 def canvas_rect(self):
650 return QRectF(self.pan.x()+OUTSIDE_MARGIN*self.zoom,self.pan.y()+OUTSIDE_MARGIN*self.zoom,constants.CANVAS_WIDTH*self.zoom,constants.CANVAS_HEIGHT*self.zoom)
651 def widget_to_canvas(self,p):
652 c=self.work_rect().center(); q=QPointF(p)
653 if self.rotation:
654 a=math.radians(-self.rotation); dx=q.x()-c.x();dy=q.y()-c.y();q=QPointF(c.x()+dx*math.cos(a)-dy*math.sin(a),c.y()+dx*math.sin(a)+dy*math.cos(a))
655 camera_scale=max(0.0001,float(self.onion_tu_tb_scale))
656 if abs(camera_scale-1.0)>1e-9: 656 ↛ 657line 656 didn't jump to line 657 because the condition on line 656 was never true
657 q=QPointF(
658 c.x()+(q.x()-c.x())/camera_scale,
659 c.y()+(q.y()-c.y())/camera_scale,
660 )
661 x=(q.x()-self.pan.x())/self.zoom; y=(q.y()-self.pan.y())/self.zoom
662 if self.flip_horizontal:x=workspace_size()[0]-x
663 if self.flip_vertical:y=workspace_size()[1]-y
664 return QPointF(x,y)
665 def canvas_to_widget(self,p):
666 w,h=workspace_size()
667 x=w-p.x() if self.flip_horizontal else p.x()
668 y=h-p.y() if self.flip_vertical else p.y()
669 q=QPointF(self.pan.x()+x*self.zoom,self.pan.y()+y*self.zoom)
670 c=self.work_rect().center()
671 camera_scale=max(0.0001,float(self.onion_tu_tb_scale))
672 if abs(camera_scale-1.0)>1e-9: 672 ↛ 673line 672 didn't jump to line 673 because the condition on line 672 was never true
673 q=QPointF(
674 c.x()+(q.x()-c.x())*camera_scale,
675 c.y()+(q.y()-c.y())*camera_scale,
676 )
677 if self.rotation:
678 a=math.radians(self.rotation);dx=q.x()-c.x();dy=q.y()-c.y();q=QPointF(c.x()+dx*math.cos(a)-dy*math.sin(a),c.y()+dx*math.sin(a)+dy*math.cos(a))
679 return q
680 def inside(self,p): w,h=workspace_size(); return 0<=p.x()<w and 0<=p.y()<h
681 def color(self):
682 if self.color_mode=="transparent": 682 ↛ 683line 682 didn't jump to line 683 because the condition on line 682 was never true
683 return QColor(0,0,0,0)
684 return QColor(self.main_color if self.color_mode=="main" else self.sub_color)
687 def paint_source_color(self, base_color=None):
688 """描画元のRGB色を、常にα255で返す。"""
689 source = QColor(
690 self.color() if base_color is None else base_color
691 )
692 if source.alpha() == 0: 692 ↛ 694line 692 didn't jump to line 694 because the condition on line 692 was never true
693 # 透明色モードは、キャンバス上では疑似透明色の白。
694 source = QColor(255, 255, 255)
695 source.setAlpha(255)
696 return source
698 def opaque_paint_color(self, base_color=None):
699 """選択RGBをα255のまま返す。"""
700 return self.paint_source_color(base_color)
702 def _emit_actual_paint_colors(self, colors, maximum=64):
703 """実際に生成されたRGBを安全に使用色へ通知する。"""
704 if colors is None: 704 ↛ 705line 704 didn't jump to line 705 because the condition on line 704 was never true
705 return
706 try:
707 iterator = iter(colors)
708 except TypeError:
709 return
711 emitted = 0
712 seen = set()
713 for value in iterator:
714 try:
715 rgb = tuple(
716 max(0, min(255, int(channel)))
717 for channel in value[:3]
718 )
719 except (
720 TypeError,
721 ValueError,
722 IndexError,
723 ):
724 continue
725 if rgb == (255, 255, 255) or rgb in seen: 725 ↛ 726line 725 didn't jump to line 726 because the condition on line 725 was never true
726 continue
727 seen.add(rgb)
728 red, green, blue = (
729 int(rgb[0]),
730 int(rgb[1]),
731 int(rgb[2]),
732 )
733 self.colorUsed.emit(
734 QColor(red, green, blue)
735 )
736 emitted += 1
737 if emitted >= max(1, int(maximum)): 737 ↛ 738line 737 didn't jump to line 738 because the condition on line 737 was never true
738 break
740 def _binary_paint_overlay_rgba(self, overlay):
741 """描画マスクの境界を完全な0/255へ2値化する。
743 筆圧による線幅変化、曲線、ラインの入り抜きで発生した
744 半端なアルファ値を、RGB合成の前に除去する。
745 """
746 rgba = self._qimage_rgba_array(overlay)
747 if rgba.size == 0: 747 ↛ 748line 747 didn't jump to line 748 because the condition on line 747 was never true
748 return rgba
750 alpha = rgba[:, :, 3]
751 active = alpha >= 128
753 # 非描画部分は完全な透明、描画部分は完全な不透明に固定する。
754 rgba[~active, :3] = 0
755 rgba[:, :, 3][~active] = 0
756 rgba[:, :, 3][active] = 255
757 return rgba
759 @staticmethod
760 def _paint_rgb_palette(*args, **kwargs):
761 return colors.paint_rgb_palette(*args, **kwargs)
763 @classmethod
764 def _snap_overlay_to_exact_rgbs(cls, *args, **kwargs):
765 return color_ops._snap_overlay_to_exact_rgbs(*args, **kwargs)
767 def _blend_overlay_into_active_layer(
768 self,
769 overlay,
770 top_left=None,
771 exact_colors=None,
772 opacity=1.0,
773 ):
774 """選択RGBをそのままレイヤーへ直書きする。
776 白(#FFFFFF)は本物の消しゴムとして該当画素を alpha=0 へ抜く。
777 ``opacity`` が 1 未満のとき(バケツ・投げ縄塗りの不透明度)だけ、
778 下地RGB(透明は白とみなす)と混色し、アルファは 255 のまま書く。
779 """
780 if overlay is None or overlay.isNull(): 780 ↛ 781line 780 didn't jump to line 781 because the condition on line 780 was never true
781 return ()
783 destination_image = self.active_layer.image
784 if ( 784 ↛ 788line 784 didn't jump to line 788 because the condition on line 784 was never true
785 destination_image is None
786 or destination_image.isNull()
787 ):
788 return ()
790 point = (
791 top_left
792 if top_left is not None
793 else QPoint(0, 0)
794 )
795 origin_x = int(point.x())
796 origin_y = int(point.y())
798 source_x = max(0, -origin_x)
799 source_y = max(0, -origin_y)
800 destination_x = max(0, origin_x)
801 destination_y = max(0, origin_y)
802 width = min(
803 overlay.width() - source_x,
804 destination_image.width() - destination_x,
805 )
806 height = min(
807 overlay.height() - source_y,
808 destination_image.height() - destination_y,
809 )
810 if width <= 0 or height <= 0: 810 ↛ 811line 810 didn't jump to line 811 because the condition on line 810 was never true
811 return ()
813 overlay_rgba = self._binary_paint_overlay_rgba(
814 overlay
815 )
816 overlay_rgba = overlay_rgba[
817 source_y:source_y + height,
818 source_x:source_x + width,
819 ]
820 active_mask = (
821 overlay_rgba[:, :, 3] == 255
822 )
823 if not np.any(active_mask): 823 ↛ 824line 823 didn't jump to line 824 because the condition on line 823 was never true
824 return ()
826 # プレマルチプライ変換の±1誤差を、正規RGBへ必ず吸着する。
827 overlay_rgba = self._snap_overlay_to_exact_rgbs(
828 overlay_rgba,
829 active_mask,
830 exact_colors,
831 )
833 amount = max(0.0, min(1.0, float(opacity)))
834 result_rgba = np.zeros_like(overlay_rgba)
835 # 100%は下地を参照せず正規RGBを直書きする(近似色を生成しない)。
836 result_rgba[active_mask, :3] = overlay_rgba[active_mask, :3]
837 result_rgba[active_mask, 3] = 255
838 blended = amount < 0.999999
839 if blended:
840 source_white = active_mask & np.all(
841 overlay_rgba[:, :, :3] == 255, axis=2
842 )
843 blend_mask = active_mask & ~source_white
844 base_rgba = self._qimage_rgba_array(
845 destination_image.copy(
846 destination_x, destination_y, width, height
847 )
848 )
849 base_rgb = base_rgba[:, :, :3].astype(np.float32)
850 base_rgb[base_rgba[:, :, 3] == 0] = 255.0
851 mixed = np.clip(
852 np.rint(
853 base_rgb * (1.0 - amount)
854 + overlay_rgba[:, :, :3].astype(np.float32) * amount
855 ),
856 0,
857 255,
858 ).astype(np.uint8)
859 result_rgba[blend_mask, :3] = mixed[blend_mask]
860 # 混色の結果がたまたま白になった画素は消しゴム扱いにしない。
861 accidental_white = blend_mask & np.all(
862 result_rgba[:, :, :3] == 255, axis=2
863 )
864 result_rgba[accidental_white, :3] = 254
865 written_rgb = result_rgba[active_mask, :3]
867 # 白(#FFFFFF)ストロークは疑似透明ではなく本物の消しゴムとして扱う。
868 # 該当画素をレイヤーへ書かず、alpha=0 へ抜く(下のレイヤーが透ける)。
869 erase_mask = active_mask & np.all(
870 result_rgba[:, :, :3] == 255,
871 axis=2,
872 )
873 erase_rgba = None
874 if np.any(erase_mask):
875 erase_rgba = np.zeros_like(result_rgba)
876 erase_rgba[erase_mask, 3] = 255
877 # SourceOver では白を書き込まない(消去はこの後 DestinationOut で行う)。
878 result_rgba[erase_mask, 3] = 0
880 result_image = self._rgba_array_to_qimage(
881 result_rgba
882 )
883 painter = QPainter(destination_image)
884 painter.setCompositionMode(
885 QPainter.CompositionMode.CompositionMode_SourceOver
886 )
887 painter.drawImage(
888 QPoint(destination_x, destination_y),
889 result_image,
890 )
891 if erase_rgba is not None:
892 erase_image = self._rgba_array_to_qimage(erase_rgba)
893 painter.setCompositionMode(
894 QPainter.CompositionMode.CompositionMode_DestinationOut
895 )
896 painter.drawImage(
897 QPoint(destination_x, destination_y),
898 erase_image,
899 )
900 painter.end()
902 if written_rgb.size == 0: 902 ↛ 903line 902 didn't jump to line 903 because the condition on line 902 was never true
903 return ()
905 # 正規RGBを通知する。白は消しゴム扱いなので使用色に含めない。
906 palette = self._paint_rgb_palette(exact_colors)
907 if palette.size and not blended:
908 return tuple(
909 rgb
910 for rgb in (
911 tuple(int(channel) for channel in np.asarray(row).tolist())
912 for row in palette
913 )
914 if rgb != (255, 255, 255)
915 )
917 unique = np.unique(
918 written_rgb,
919 axis=0,
920 )
921 return tuple(
922 rgb
923 for rgb in (
924 tuple(int(channel) for channel in row)
925 for row in unique
926 )
927 if rgb != (255, 255, 255)
928 )
931 def _ensure_before_region(self, rect):
932 """Lazily preserve pre-stroke pixels in fixed-size touched tiles."""
933 tiles = self._stroke_before_tiles
934 if tiles is None: 934 ↛ 935line 934 didn't jump to line 935 because the condition on line 934 was never true
935 return
936 live = self.active_layer.image
937 bounds = QRect(
938 0, 0, live.width(), live.height()
939 )
940 rect = QRect(rect).intersected(bounds)
941 if rect.isEmpty(): 941 ↛ 942line 941 didn't jump to line 942 because the condition on line 941 was never true
942 return
943 tile_size = 256
944 first_x = rect.left() // tile_size
945 last_x = rect.right() // tile_size
946 first_y = rect.top() // tile_size
947 last_y = rect.bottom() // tile_size
948 for tile_y in range(first_y, last_y + 1):
949 for tile_x in range(first_x, last_x + 1):
950 key = (tile_x, tile_y)
951 if key in tiles:
952 continue
953 tile_rect = QRect(
954 tile_x * tile_size,
955 tile_y * tile_size,
956 tile_size,
957 tile_size,
958 ).intersected(bounds)
959 tiles[key] = (tile_rect, live.copy(tile_rect))
960 self._stroke_dirty_rect = self._stroke_dirty_rect.united(rect)
961 if getattr(self, "_stroke_view_preview", None) is not None:
962 self._stroke_view_dirty = self._stroke_view_dirty.united(rect)
965 def selected_mask_colors(self):
966 values = {
967 tuple(int(channel) for channel in rgb[:3])
968 for rgb in getattr(self, "mask_color_rgbs", set())
969 if rgb is not None and len(rgb) >= 3
970 }
971 legacy = getattr(self, "mask_color_rgb", None)
972 if legacy is not None and not values:
973 values.add(tuple(int(channel) for channel in legacy[:3]))
974 return values
975 def selected_used_colors(self):
976 return {
977 tuple(int(channel) for channel in rgb[:3])
978 for rgb in getattr(self, "selected_used_color_rgbs", set())
979 if rgb is not None and len(rgb) >= 3
980 }
983 def request_quality_preview_counter(self, label=None):
984 label = tr("クオリティプレビューを生成しています") if label is None else label
985 """Schedule quality rendering after the current paint/input event."""
986 if (
987 self._tp_preview_progress_busy
988 or self._tp_preview_progress_scheduled
989 or not self.transform_active
990 or not self.transform_quality_active
991 ):
992 return
993 self._tp_preview_progress_scheduled = True
994 QTimer.singleShot(
995 0,
996 lambda text=str(label): self.refresh_quality_preview_with_counter(text),
997 )
999 def refresh_quality_preview_with_counter(self, label=None, full_resolution=False):
1000 """Build the expensive TP_mask preview with a visible mask counter."""
1001 # Not a default argument: tr() there would run at import time, before the
1002 # translator is installed.
1003 label = tr("クオリティプレビューを生成しています") if label is None else label
1004 self._tp_preview_progress_scheduled = False
1005 if (
1006 self._tp_preview_progress_busy
1007 or not self.transform_active
1008 or not self.transform_quality_active
1009 or self._transform_line_adjusting
1010 or self.transform_source is None
1011 or self.transform_source.isNull()
1012 ):
1013 return
1015 target_width = self.active_layer.image.width()
1016 target_height = self.active_layer.image.height()
1017 use_proxy = (
1018 not full_resolution
1019 and self._tp_uses_proxy(target_width, target_height)
1020 )
1021 if use_proxy: 1021 ↛ 1022line 1021 didn't jump to line 1022 because the condition on line 1021 was never true
1022 if self._tp_preview_cache_image is not None:
1023 return
1024 else:
1025 geometry_current = self._tp_geometry_cache_is_current(
1026 self.transform_source, target_width, target_height
1027 )
1028 if geometry_current and self._tp_preview_cache_image is not None: 1028 ↛ 1029line 1028 didn't jump to line 1029 because the condition on line 1028 was never true
1029 return
1031 total = max(
1032 1,
1033 len(self.transform_tp_masks)
1034 + len(self.transform_tp_line_masks)
1035 + 2,
1036 )
1037 window: Any = self.window()
1038 progress = None
1040 # create_counter() calls processEvents(). Mark the renderer as busy
1041 # first so a repaint during popup creation uses the lightweight preview
1042 # instead of recursively starting another TP_mask calculation.
1043 self._tp_preview_progress_busy = True
1044 try:
1045 if window is not None: 1045 ↛ 1057line 1045 didn't jump to line 1057 because the condition on line 1045 was always true
1046 progress = create_counter(
1047 window,
1048 (
1049 tr("Tp_mask 軽量プレビュー")
1050 if use_proxy
1051 else tr("Tp_mask クオリティプレビュー")
1052 ),
1053 total,
1054 label,
1055 )
1057 def report(value, report_total, message):
1058 if progress is not None: 1058 ↛ exitline 1058 didn't return from function 'report' because the condition on line 1058 was always true
1059 update_counter(
1060 progress, value, report_total, message
1061 )
1063 self.transform_preview_image(
1064 self.transform_source,
1065 target_width,
1066 target_height,
1067 quality=True,
1068 preview_only=use_proxy,
1069 progress_callback=report,
1070 )
1071 if progress is not None: 1071 ↛ 1084line 1071 didn't jump to line 1084 because the condition on line 1071 was always true
1072 update_counter(
1073 progress,
1074 total,
1075 total,
1076 tr("プレビューを更新しました"),
1077 )
1078 except (ValueError, IndexError, TypeError, RuntimeError, AttributeError, MemoryError) as exc:
1079 log.warning("quality preview generation failed: %s", exc, exc_info=True)
1080 self.status_message.emit(
1081 tr("クオリティプレビューを生成できませんでした: {exc}").format(exc=exc)
1082 )
1083 finally:
1084 if progress is not None: 1084 ↛ 1086line 1084 didn't jump to line 1086 because the condition on line 1084 was always true
1085 close_counter(progress)
1086 self._tp_preview_progress_busy = False
1087 self.update()
1089 def set_transform_quality(self, enabled):
1090 self.transform_quality = bool(enabled)
1091 if self.transform_active: 1091 ↛ 1092line 1091 didn't jump to line 1092 because the condition on line 1091 was never true
1092 self.transform_quality_active = self.transform_quality
1093 self.transform_tp_line_colors = tuple(sorted(self.selected_used_colors()))
1094 if self.transform_quality_active:
1095 target_width = self.active_layer.image.width()
1096 target_height = self.active_layer.image.height()
1097 if self._tp_uses_proxy(target_width, target_height):
1098 self._clear_tp_transform_masks(clear_proxy=False)
1099 else:
1100 self._prepare_tp_transform_masks()
1101 else:
1102 self._clear_tp_transform_masks()
1103 self._invalidate_tp_preview_cache()
1104 if self.transform_active and self.transform_quality_active: 1104 ↛ 1105line 1104 didn't jump to line 1105 because the condition on line 1104 was never true
1105 self.request_quality_preview_counter(
1106 tr("クオリティ方式へ切り替えています")
1107 )
1108 self.update()
1110 def set_transform_line_threshold(self, value):
1111 self.transform_line_threshold = max(1, min(254, int(value)))
1112 # v0.7 caches transformed soft masks. A line-width change only
1113 # reapplies the threshold and does not transform every color again.
1114 self._invalidate_tp_preview_cache(geometry=False)
1115 if ( 1115 ↛ 1121line 1115 didn't jump to line 1121 because the condition on line 1115 was never true
1116 self.transform_active
1117 and self.transform_quality_active
1118 and not self._tp_preview_progress_busy
1119 and self._tp_geometry_cache_fill_overlay is not None
1120 ):
1121 target_width = self.active_layer.image.width()
1122 target_height = self.active_layer.image.height()
1123 self.transform_preview_image(
1124 self.transform_source,
1125 target_width,
1126 target_height,
1127 quality=True,
1128 preview_only=self._tp_uses_proxy(
1129 target_width, target_height
1130 ),
1131 )
1132 self.update()
1134 def begin_transform_line_adjustment(self):
1135 """スライダー操作中は重いクオリティ再生成を保留する。"""
1136 self._transform_line_adjusting = True
1137 self._transform_line_preview_timer.stop()
1138 self._tp_preview_progress_scheduled = False
1140 def finish_transform_line_adjustment(self):
1141 """スライダーを離した時にクオリティプレビューを一度だけ更新する。"""
1142 was_adjusting = self._transform_line_adjusting
1143 self._transform_line_adjusting = False
1144 if (
1145 was_adjusting
1146 and self.transform_active
1147 and self.transform_quality_active
1148 ):
1149 # マウスリリース処理を先に完了させ、クリックが確定操作の
1150 # ように見えないよう少し遅らせて一度だけ生成する。
1151 self._transform_line_preview_timer.start()
1152 self.update()
1154 def _render_deferred_transform_line_preview(self):
1155 if self.transform_active and self.transform_quality_active:
1156 self.request_quality_preview_counter(
1157 tr("実線の太さをプレビューへ反映しています")
1158 )
1160 def set_transform_line_colors(self, colors):
1161 normalized = {
1162 tuple(int(channel) for channel in rgb[:3])
1163 for rgb in (colors or set())
1164 if rgb is not None and len(rgb) >= 3
1165 }
1166 self.selected_used_color_rgbs = normalized
1167 if self.transform_active: 1167 ↛ 1168line 1167 didn't jump to line 1168 because the condition on line 1167 was never true
1168 self.transform_tp_line_colors = tuple(sorted(normalized))
1169 if self.transform_quality_active:
1170 target_width = self.active_layer.image.width()
1171 target_height = self.active_layer.image.height()
1172 self._clear_tp_transform_masks(clear_proxy=False)
1173 if not self._tp_uses_proxy(target_width, target_height):
1174 self._prepare_tp_transform_masks()
1175 self.request_quality_preview_counter(
1176 tr("実線色を反映しています")
1177 )
1178 else:
1179 self._invalidate_tp_preview_cache()
1180 self.update()
1182 def apply_selection_clip(self, painter, offset_x=0.0, offset_y=0.0):
1183 """Apply the active selection without relying on PySide overload-specific arguments."""
1184 if not self.selection_polygon: 1184 ↛ 1186line 1184 didn't jump to line 1186 because the condition on line 1184 was always true
1185 return
1186 if self.selection_mask_override is not None:
1187 device = painter.device()
1188 mask = self.selection_mask_bool(
1189 device.width(),
1190 device.height(),
1191 int(round(offset_x)),
1192 int(round(offset_y)),
1193 )
1194 if mask is None:
1195 return
1196 region = QRegion()
1197 for y in range(mask.shape[0]):
1198 row = mask[y] # pyright: ignore[reportOptionalSubscript]
1199 changes = np.diff(
1200 np.pad(row.astype(np.int8), (1, 1))
1201 )
1202 starts = np.flatnonzero(changes == 1)
1203 ends = np.flatnonzero(changes == -1)
1204 for start, end in zip(starts, ends):
1205 region += QRegion(int(start), y, int(end - start), 1)
1206 painter.setClipRegion(region)
1207 return
1208 polygon = QPolygonF([
1209 QPointF(float(point.x()) - float(offset_x), float(point.y()) - float(offset_y))
1210 for point in self.selection_polygon
1211 ])
1212 if polygon.count() >= 3:
1213 path = QPainterPath()
1214 path.addPolygon(polygon)
1215 path.closeSubpath()
1216 painter.setClipPath(path)
1218 @classmethod
1219 def image_alpha_statistics(cls, *args, **kwargs):
1220 return color_ops.image_alpha_statistics(*args, **kwargs)
1222 @classmethod
1223 def binarize_alpha_for_pixel_art(cls, *args, **kwargs):
1224 return color_ops.binarize_alpha_for_pixel_art(*args, **kwargs)
1226 @classmethod
1227 def opaque_rgb_color_count(cls, *args, **kwargs):
1228 return color_ops.opaque_rgb_color_count(*args, **kwargs)
1230 @classmethod
1231 def detect_opaque_border_background(cls, *args, **kwargs):
1232 return color_ops.detect_opaque_border_background(*args, **kwargs)
1234 @staticmethod
1235 def _local_color_variation(*args, **kwargs):
1236 return colors.local_color_variation(*args, **kwargs)
1238 @classmethod
1239 def estimate_mixed_boundary_pixels(cls, *args, **kwargs):
1240 return color_ops.estimate_mixed_boundary_pixels(*args, **kwargs)
1242 @staticmethod
1243 def _median_cut_palette_from_samples(*args, **kwargs):
1244 return colors.median_cut_palette_from_samples(*args, **kwargs)
1246 @classmethod
1247 def _surface_guided_palette_labels(cls, *args, **kwargs):
1248 return color_ops._surface_guided_palette_labels(*args, **kwargs)
1250 @staticmethod
1251 def _line_palette_labels(*args, **kwargs):
1252 return color_ops._line_palette_labels(*args, **kwargs)
1255 @staticmethod
1256 def normalize_tone_curve_points(*args, **kwargs):
1257 return colors.normalize_tone_curve_points(*args, **kwargs)
1259 @classmethod
1260 def tone_curve_samples(cls, *args, **kwargs):
1261 return color_ops.tone_curve_samples(*args, **kwargs)
1263 @classmethod
1264 def tone_curve_lut(cls, *args, **kwargs):
1265 return color_ops.tone_curve_lut(*args, **kwargs)
1267 @classmethod
1268 def apply_tone_curve(cls, *args, **kwargs):
1269 return color_ops.apply_tone_curve(*args, **kwargs)
1271 @staticmethod
1272 def _rgb_hsv_features(*args, **kwargs):
1273 return colors.rgb_hsv_features(*args, **kwargs)
1275 @classmethod
1276 def _hue_cluster_palette_from_samples(cls, *args, **kwargs):
1277 return color_ops._hue_cluster_palette_from_samples(*args, **kwargs)
1279 @staticmethod
1280 def _priority_palette_colors_from_samples(*args, **kwargs):
1281 return colors.priority_palette_colors_from_samples(*args, **kwargs)
1284 @classmethod
1285 def build_color_reduction_palette(cls, *args, **kwargs):
1286 return color_ops.build_color_reduction_palette(*args, **kwargs)
1289 @classmethod
1290 def apply_color_reduction_palette(cls, *args, **kwargs):
1291 return color_ops.apply_color_reduction_palette(*args, **kwargs)
1294 @staticmethod
1295 def _qimage_rgba_array(image):
1296 return imaging.qimage_rgba_array(image)
1298 @staticmethod
1299 def _rgba_array_to_qimage(rgba):
1300 return imaging.rgba_array_to_qimage(rgba)
1302 @staticmethod
1303 def _pil_l_to_qimage(mask):
1304 return imaging.pil_l_to_qimage(mask)
1306 @staticmethod
1307 def _qimage_gray_array(image):
1308 return imaging.qimage_gray_array(image)
1310 @classmethod
1311 def _qimage_to_pil_rgba(cls, image):
1312 return imaging.qimage_to_pil_rgba(image)
1314 @classmethod
1315 def _pil_rgba_to_qimage(cls, image):
1316 return imaging.pil_rgba_to_qimage(image)
1319 def sample_color(self, p, include_canvas_background=True):
1320 x, y = int(p.x()), int(p.y())
1321 width, height = workspace_size()
1322 if not (0 <= x < width and 0 <= y < height): 1322 ↛ 1323line 1322 didn't jump to line 1323 because the condition on line 1322 was never true
1323 return
1325 # 上から順に実画像を調べる。レイヤー表示不透明度は色データへ
1326 # 混ぜず、スポイトでは元のRGBA色を取得する。
1327 color = None
1328 layer_count = len(self.frames[self.current_frame].layers)
1329 for layer_index in range(layer_count - 1, -1, -1):
1330 key_frame = self.resolve_key_frame(
1331 self.current_frame,
1332 layer_index,
1333 )
1334 if key_frame is None: 1334 ↛ 1336line 1334 didn't jump to line 1336 because the condition on line 1334 was always true
1335 continue
1336 layer = self.frames[key_frame].layers[layer_index]
1337 if not layer.visible or float(layer.opacity) <= 0.0:
1338 continue
1339 source = layer.image
1340 if not (0 <= x < source.width() and 0 <= y < source.height()):
1341 continue
1342 # 使用色の表示OFFなどで見えていない画素は飛ばすが、
1343 # 表示不透明度やシルエット色はスポイト色へ混ぜない。
1344 display_source = self._display_layer_image(layer, layer_index)
1345 if display_source is None:
1346 continue
1347 if display_source.pixelColor(x, y).alpha() == 0:
1348 continue
1349 candidate = source.pixelColor(x, y)
1350 if candidate.alpha() > 0:
1351 color = QColor(candidate)
1352 break
1354 if color is None or color.alpha() == 0: 1354 ↛ 1359line 1354 didn't jump to line 1359 because the condition on line 1354 was always true
1355 if not include_canvas_background: 1355 ↛ 1356line 1355 didn't jump to line 1356 because the condition on line 1355 was never true
1356 return
1357 # Emit the background sentinel, independent of its view colour.
1358 color = QColor("white")
1359 color.setAlpha(255)
1360 sampled_background = (
1361 color.red(), color.green(), color.blue()
1362 ) == (255, 255, 255)
1363 if sampled_background: 1363 ↛ 1366line 1363 didn't jump to line 1366 because the condition on line 1363 was always true
1364 changed = self.color_mode != "transparent"
1365 self.color_mode = "transparent"
1366 elif self.color_mode == "sub":
1367 changed = QColor(self.sub_color) != color
1368 self.sub_color = QColor(color)
1369 else:
1370 changed = self.color_mode == "transparent" or QColor(self.main_color) != color
1371 self.main_color = QColor(color)
1372 if self.color_mode == "transparent":
1373 self.color_mode = "main"
1374 if changed: 1374 ↛ exitline 1374 didn't return from function 'sample_color' because the condition on line 1374 was always true
1375 self.colorSampled.emit(QColor(color))
1376 self.update()
1379 def silhouette_layer_image(self, layer, apply_palette_filter=True):
1380 base = self.filtered_layer_image(
1381 layer,
1382 apply_palette_filter,
1383 )
1384 if base is None:
1385 return QImage()
1386 if not self.silhouette_non_background:
1387 return base
1388 try:
1389 key = (int(base.cacheKey()), base.width(), base.height())
1390 except (AttributeError, RuntimeError, TypeError) as exc:
1391 log.debug("cacheKey() unavailable, using id() fallback: %s", exc)
1392 key = (id(base), base.width(), base.height())
1393 cached = self._silhouette_cache.get(key)
1394 if cached is not None:
1395 return cached
1396 rgba = base.convertToFormat(QImage.Format.Format_RGBA8888)
1397 width, height = rgba.width(), rgba.height()
1398 ptr = imaging.qimage_buffer(rgba)
1399 rows = np.frombuffer(ptr, dtype=np.uint8).reshape((height, rgba.bytesPerLine()))
1400 pixels = rows[:, :width * 4].reshape((height, width, 4))
1401 visible = pixels[:, :, 3] > 0
1402 pixels[:, :, 0][visible] = 0
1403 pixels[:, :, 1][visible] = 0
1404 pixels[:, :, 2][visible] = 0
1405 result = rgba.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
1406 if len(self._silhouette_cache) >= 64:
1407 self._silhouette_cache.clear()
1408 self._silhouette_cache[key] = result
1409 return result
1411 def _color_index_for_image(self, image):
1412 """Return the cached RGBA/pixel index used by palette filtering."""
1413 try:
1414 image_key = int(image.cacheKey())
1415 except (AttributeError, RuntimeError, TypeError) as exc:
1416 log.debug("cacheKey() unavailable, using id() fallback: %s", exc)
1417 image_key = id(image)
1418 width, height = image.width(), image.height()
1419 index_key = (image_key, width, height)
1420 indexed = self._color_index_cache.get(index_key)
1421 if indexed is not None: 1421 ↛ 1422line 1421 didn't jump to line 1422 because the condition on line 1421 was never true
1422 return indexed
1424 if width <= 0 or height <= 0: 1424 ↛ 1425line 1424 didn't jump to line 1425 because the condition on line 1424 was never true
1425 return image, None, None
1426 # ARGB32(非プレマルチプライ)は uint32 で見ると 0xAARRGGBB なので、
1427 # 0xRRGGBB の索引とアルファの判定がそれぞれ1回の演算で求まる。
1428 argb = image.convertToFormat(QImage.Format.Format_ARGB32)
1429 values = np.frombuffer(
1430 imaging.qimage_buffer(argb), dtype=np.uint32
1431 ).reshape((height, argb.bytesPerLine() // 4))[:, :width]
1432 packed = values & np.uint32(0x00FFFFFF)
1433 opaque = values >= np.uint32(0x01000000)
1434 # 先頭要素は元画像(暗黙共有なのでコピーは発生しない)。
1435 indexed = (image, packed, opaque)
1436 _bounded_cache_put(self._color_index_cache, index_key, indexed, 24)
1437 return indexed
1439 def filtered_layer_image(self, layer, apply_palette_filter=True):
1440 """Return a cached display-only image honoring this layer's palette visibility."""
1441 visible = (
1442 getattr(self, "visible_color_rgbs", None)
1443 if apply_palette_filter else None
1444 )
1445 legacy_rgb = (
1446 tuple(int(value) for value in layer.color_filter_rgb[:3])
1447 if getattr(layer, "color_filter_enabled", False) and layer.color_filter_rgb is not None
1448 else None
1449 )
1450 # 使用色の親子プレビュー:表示時のみ子色を親色へ塗り替える(非破壊)。
1451 remap = (
1452 dict(getattr(self, "preview_color_remap", None) or {})
1453 if apply_palette_filter else {}
1454 )
1455 if visible is None and legacy_rgb is None and not remap:
1456 return layer.image
1457 rgb = legacy_rgb
1458 try:
1459 image_key = int(layer.image.cacheKey())
1460 except (AttributeError, RuntimeError, TypeError) as exc:
1461 log.debug("cacheKey() unavailable, using id() fallback: %s", exc)
1462 image_key = id(layer.image)
1463 width, height = layer.image.width(), layer.image.height()
1464 visible_key = None if visible is None else tuple(sorted(visible))
1465 remap_key = tuple(sorted(remap.items())) if remap else None
1466 key = (image_key, width, height, rgb, visible_key, remap_key)
1467 cached = self._color_filter_cache.get(key)
1468 if cached is not None:
1469 return cached
1471 if width <= 0 or height <= 0: 1471 ↛ 1472line 1471 didn't jump to line 1472 because the condition on line 1471 was never true
1472 return layer.image
1473 rgba = layer.image.convertToFormat(QImage.Format.Format_RGBA8888)
1474 ptr = imaging.qimage_buffer(rgba)
1475 rows = np.frombuffer(ptr, dtype=np.uint8).reshape((height, rgba.bytesPerLine()))
1476 pixels = rows[:, :width * 4].reshape((height, width, 4))
1478 result = native.filter_palette(pixels, visible, rgb, remap)
1479 filtered = QImage(
1480 result.data, width, height, width * 4, QImage.Format.Format_RGBA8888
1481 ).convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
1482 _bounded_cache_put(self._color_filter_cache, key, filtered, 96)
1483 return filtered
1485 def _layer_display_source(self, layer, layer_index):
1486 """画面に出すレイヤー画像。比較表示のときは色塗り前の初期状態。
1488 初期状態を保持していないセルは今の絵をそのまま出す(:mod:`bucket_fill`
1489 などの既存の約束と同じ)。初期状態は塗る前の線画そのものなので、
1490 パレット絞り込みなどの表示フィルタは掛けない。
1491 """
1492 if self.show_initial_state and layer.initial_image is not None:
1493 return layer.initial_image
1494 return self._display_layer_image(layer, layer_index)
1496 def _display_layer_image(self, layer, layer_index):
1497 """パレット/シルエット等の表示フィルタを適用したレイヤー画像を返す。
1499 消しゴム(白)は既にレイヤーデータ上で alpha=0 のため、疑似透明の表示変換は
1500 不要。ストローク中もアクティブレイヤーの実画素へ直接合成しているので、
1501 ここで ``layer.image`` を返せばそのまま最新の描画結果が表示される。
1502 """
1503 # 下書きレイヤーは色数削減の対象外。プレビューの色をそのまま出力する
1504 # ため、パレット絞り込みやシルエット等の表示変換を一切適用しない。
1505 if getattr(layer, "is_draft", False):
1506 return layer.image
1507 apply_palette_filter = (
1508 layer_index == self.active_layer_index
1509 )
1510 if ( 1510 ↛ 1514line 1510 didn't jump to line 1514 because the condition on line 1510 was never true
1511 self.silhouette_non_background
1512 and not layer.is_paper
1513 ):
1514 return self.silhouette_layer_image(
1515 layer,
1516 apply_palette_filter,
1517 )
1519 return self.filtered_layer_image(
1520 layer,
1521 apply_palette_filter,
1522 )
1524 def view_layer_image(self, layer, layer_index, image):
1525 """``image`` に表示プレビュー(露光量・チャンネル・隣接色分け)を掛けた複製。
1527 画面に描くときだけ使う。:meth:`composite` は書き出しやサムネイルにも
1528 使われるので、ここは通さない。隣接色分けはアクティブレイヤーだけに掛け、
1529 描いている最中も割り当て済みの色を維持する。
1530 """
1531 preview = self.view_preview
1532 if image is None or image.isNull() or not preview.active:
1533 return image
1534 if getattr(layer, "is_draft", False): 1534 ↛ 1535line 1534 didn't jump to line 1535 because the condition on line 1534 was never true
1535 return image
1536 map_colors = (
1537 preview.map_colors
1538 and layer_index == self.active_layer_index
1539 and not layer.is_paper
1540 )
1541 if not map_colors and not preview.adjusts_pixels: 1541 ↛ 1542line 1541 didn't jump to line 1542 because the condition on line 1541 was never true
1542 return image
1543 stroke_preview = getattr(self, "_stroke_view_preview", None)
1544 if (map_colors and stroke_preview is not None
1545 and image is layer.image and stroke_preview[0] is layer
1546 and stroke_preview[1] == preview.key()):
1547 # 筆が触れた範囲だけを変換し、ストローク専用の表示画像へ戻す。
1548 # 過去のプレビューキャッシュは共有しない。
1549 result = stroke_preview[2]
1550 dirty = self._stroke_view_dirty.intersected(image.rect())
1551 if not dirty.isEmpty():
1552 patch = self.view_layer_image(layer, layer_index, image.copy(dirty))
1553 if patch is not None: 1553 ↛ 1560line 1553 didn't jump to line 1560 because the condition on line 1553 was always true
1554 painter = QPainter(result)
1555 painter.setCompositionMode(
1556 QPainter.CompositionMode.CompositionMode_Source
1557 )
1558 painter.drawImage(dirty.topLeft(), patch)
1559 painter.end()
1560 self._stroke_view_dirty = QRect()
1561 return result
1562 try:
1563 image_key = int(image.cacheKey())
1564 except (AttributeError, RuntimeError, TypeError) as exc:
1565 log.debug("cacheKey() unavailable, using id() fallback: %s", exc)
1566 image_key = id(image)
1567 key = (image_key, image.width(), image.height(), preview.key(), map_colors)
1568 cached = self._view_preview_cache.get(key)
1569 if cached is not None:
1570 return cached
1571 rgba = image.convertToFormat(QImage.Format.Format_RGBA8888)
1572 width, height = rgba.width(), rgba.height()
1573 rows = np.frombuffer(
1574 imaging.qimage_buffer(rgba), dtype=np.uint8
1575 ).reshape((height, rgba.bytesPerLine()))
1576 pixels = rows[:, :width * 4].reshape((height, width, 4))
1577 if map_colors: 1577 ↛ 1580line 1577 didn't jump to line 1580 because the condition on line 1577 was always true
1578 mapping = self._view_map_color_mappings.setdefault(preview.palette, {})
1579 view_preview.map_color_pixels(pixels, palette=preview.palette, color_mapping=mapping)
1580 view_preview.adjust_pixels(pixels, preview)
1581 result = rgba.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
1582 _bounded_cache_put(self._view_preview_cache, key, result, 32)
1583 return result
1585 def view_background_color(self):
1586 """キャンバスの背景(擬似透明)の表示色に、表示プレビューを掛けた色。"""
1587 color = QColor(self.transparent_display_color)
1588 if not self.view_preview.adjusts_pixels: 1588 ↛ 1590line 1588 didn't jump to line 1590 because the condition on line 1588 was always true
1589 return color
1590 red, green, blue = view_preview.background_color(
1591 (color.red(), color.green(), color.blue()), self.view_preview
1592 )
1593 return QColor(red, green, blue)
1595 def paint_view_background(self, painter, rect):
1596 """Draw the view-only checker when the configured background matches."""
1597 from PySide6.QtGui import QBrush
1599 color = self.transparent_display_color
1600 if (not preferences.background_checker_enabled()
1601 or (color.red(), color.green(), color.blue())
1602 != preferences.background_checker_color()):
1603 painter.fillRect(rect, self.view_background_color())
1604 return
1605 tile = QPixmap(16, 16)
1606 tile.fill(QColor(240, 240, 240))
1607 tile_painter = QPainter(tile)
1608 tile_painter.fillRect(0, 0, 8, 8, QColor(192, 192, 192))
1609 tile_painter.fillRect(8, 8, 8, 8, QColor(192, 192, 192))
1610 tile_painter.end()
1611 painter.fillRect(rect, QBrush(tile))
1613 def set_view_preview(self, **changes):
1614 """表示プレビューの設定を変えて描き直す(``exposure=1.0`` など)。"""
1615 if "exposure" in changes:
1616 changes["exposure"] = view_preview.clamp_exposure(changes["exposure"])
1617 updated = dataclasses.replace(self.view_preview, **changes)
1618 if updated == self.view_preview: 1618 ↛ 1619line 1618 didn't jump to line 1619 because the condition on line 1618 was never true
1619 return
1620 self.view_preview = updated
1621 self.update()
1623 def composite(self, fi, white=True):
1624 result = blank_image(QColor("white") if white else Qt.GlobalColor.transparent)
1625 painter = QPainter(result)
1626 layer_count = len(self.frames[fi].layers)
1627 for layer_index in range(layer_count):
1628 key_frame = self.resolve_key_frame(fi, layer_index)
1629 if key_frame is None:
1630 continue
1631 layer = self.frames[key_frame].layers[layer_index]
1632 if layer.visible: 1632 ↛ 1627line 1632 didn't jump to line 1627 because the condition on line 1632 was always true
1633 display_image = self._display_layer_image(layer, layer_index)
1634 if display_image is not None: 1634 ↛ 1627line 1634 didn't jump to line 1627 because the condition on line 1634 was always true
1635 painter.setOpacity(layer.opacity)
1636 painter.drawImage(0, 0, display_image)
1637 painter.end()
1638 return result
1641 def _display_image_for_rect(self, image, target_rect):
1642 """Keep bounded, device-resolution previews; source pixels stay intact."""
1643 if image is None or image.isNull(): 1643 ↛ 1644line 1643 didn't jump to line 1644 because the condition on line 1643 was never true
1644 return image
1645 if self.drawing:
1646 return image.mirrored(self.flip_horizontal, self.flip_vertical) if (
1647 self.flip_horizontal or self.flip_vertical
1648 ) else image
1649 ratio = max(1.0, float(self.devicePixelRatioF()))
1650 width = min(image.width(), max(1, round(target_rect.width() * ratio)))
1651 height = min(image.height(), max(1, round(target_rect.height() * ratio)))
1652 if (width, height) == (image.width(), image.height()) and not (
1653 self.flip_horizontal or self.flip_vertical
1654 ):
1655 return image
1656 mode = preferences.display_shrink_mode()
1657 key = (
1658 int(image.cacheKey()), width, height,
1659 self.flip_horizontal, self.flip_vertical, mode,
1660 )
1661 cache = getattr(self, "_display_image_cache", None)
1662 if cache is None:
1663 self._display_image_cache = cache = {}
1664 self._display_image_cache_bytes = 0
1665 cached = cache.pop(key, None)
1666 if cached is not None:
1667 cache[key] = cached
1668 return cached
1669 if mode == display_shrink.MODE_KEEP_LINES: 1669 ↛ 1671line 1669 didn't jump to line 1671 because the condition on line 1669 was never true
1670 # 縮小だけを任せる。拡大や等倍はニアレストのままでよい。
1671 result = display_shrink.shrink_keeping_lines(image, width, height)
1672 else:
1673 result = image.scaled(width, height, Qt.AspectRatioMode.IgnoreAspectRatio,
1674 Qt.TransformationMode.FastTransformation)
1675 if self.flip_horizontal or self.flip_vertical:
1676 result = result.mirrored(self.flip_horizontal, self.flip_vertical)
1677 size = result.sizeInBytes()
1678 budget = 64 * 1024 * 1024
1679 if size <= budget: 1679 ↛ 1685line 1679 didn't jump to line 1685 because the condition on line 1679 was always true
1680 while cache and (self._display_image_cache_bytes + size > budget or len(cache) >= 96):
1681 oldest = cache.pop(next(iter(cache)))
1682 self._display_image_cache_bytes -= oldest.sizeInBytes()
1683 cache[key] = result
1684 self._display_image_cache_bytes += size
1685 return result
1687 def draw_frame_direct(self, painter, target_rect, frame_index):
1688 """Draw the current frame directly to the widget, avoiding a full-size
1689 temporary composite on every brush mouse move."""
1690 # A repaint can be scheduled while the document is mid-swap (e.g. during
1691 # new_doc/replace_doc), leaving frame_index transiently out of range.
1692 # Skip the draw rather than crash the paintEvent.
1693 if not (0 <= frame_index < len(self.frames)): 1693 ↛ 1694line 1693 didn't jump to line 1694 because the condition on line 1693 was never true
1694 return
1695 layer_count = len(self.frames[frame_index].layers)
1696 for layer_index in range(layer_count):
1697 key_frame = self.resolve_key_frame(frame_index, layer_index)
1698 if key_frame is None:
1699 continue
1700 layer = self.frames[key_frame].layers[layer_index]
1701 if not layer.visible: 1701 ↛ 1702line 1701 didn't jump to line 1702 because the condition on line 1701 was never true
1702 continue
1703 draw_image = self._layer_display_source(layer, layer_index)
1704 draw_image = self.view_layer_image(layer, layer_index, draw_image)
1705 if draw_image is None: 1705 ↛ 1706line 1705 didn't jump to line 1706 because the condition on line 1705 was never true
1706 continue
1707 draw_image = self._display_image_for_rect(draw_image, target_rect)
1708 painter.setOpacity(layer.opacity)
1709 painter.drawImage(target_rect, draw_image)
1710 painter.setOpacity(1.0)
1711 def _active_tool_panel(self):
1712 window: Any = self.window()
1713 return window.tools if hasattr(window, "tools") else None
1715 def _prepare_draw_painter(self, painter, transparent=False):
1716 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
1717 painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
1718 self.apply_selection_clip(painter)
1719 painter.setCompositionMode(
1720 QPainter.CompositionMode.CompositionMode_Source
1721 )
1723 @staticmethod
1724 def is_pseudo_transparent_color(color):
1725 """True when ``color`` is the eraser sentinel (#FFFFFF).
1727 White strokes erase (alpha=0) rather than painting, so callers use this
1728 to avoid recording white as a used colour.
1729 """
1730 return colors.is_pseudo_transparent_color(color)
1732 def _current_stroke_color(self):
1733 color = self.opaque_paint_color()
1734 return color, self.is_pseudo_transparent_color(color)
1736 def paintEvent(self,e):
1737 p=QPainter(self)
1738 p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
1739 p.fillRect(self.rect(),QColor(42,42,42)); wr=self.work_rect(); cr=self.canvas_rect()
1740 p.save(); c=wr.center(); p.translate(c); p.rotate(self.rotation); p.scale(max(0.0001,float(self.onion_tu_tb_scale)),max(0.0001,float(self.onion_tu_tb_scale))); p.translate(-c)
1741 p.fillRect(wr,QColor(72,72,72))
1742 self.paint_view_background(p, cr)
1743 p.setOpacity(1)
1744 # 再生中は表示領域サイズの合成キャッシュを使用し、
1745 # 大画像をレイヤーごとに毎フレーム再縮小しない。
1746 if self._playback_active:
1747 playback_image = self.playback_frame_image(
1748 self.current_frame,
1749 wr,
1750 )
1751 if playback_image is not None: 1751 ↛ 1757line 1751 didn't jump to line 1757 because the condition on line 1751 was always true
1752 p.drawImage(wr, playback_image)
1753 else:
1754 # 編集時は元解像度のレイヤーを直接描画する。
1755 self.draw_frame_direct(p, wr, self.current_frame)
1756 # オニオンスキンは現在コマより手前へ重ねる。
1757 if self.onion_skin: 1757 ↛ 1758line 1757 didn't jump to line 1758 because the condition on line 1757 was never true
1758 self._draw_onion_range(
1759 p, wr, -1, self.onion_previous_count,
1760 self.onion_previous_opacity, self.onion_previous_color,
1761 self.onion_previous_color_enabled,
1762 self.onion_selected_colors_only,
1763 )
1764 self._draw_onion_range(
1765 p, wr, 1, self.onion_next_count,
1766 self.onion_next_opacity, self.onion_next_color,
1767 self.onion_next_color_enabled,
1768 self.onion_selected_colors_only,
1769 )
1770 p.setOpacity(1)
1771 if self.transform_active and self.transform_points:
1772 quality_requested = (
1773 bool(getattr(self, "transform_quality_active", False))
1774 and self.transform_drag_kind is None
1775 and not self._transform_line_adjusting
1776 )
1777 quality_ready = (
1778 quality_requested
1779 and self._tp_preview_cache_image is not None
1780 and not self._tp_preview_progress_busy
1781 )
1782 if quality_requested and not quality_ready:
1783 if not self._tp_preview_progress_busy: 1783 ↛ 1784line 1783 didn't jump to line 1784 because the condition on line 1783 was never true
1784 self.request_quality_preview_counter()
1785 # Dragging/repainting stays responsive while the scheduled
1786 # TP_mask job displays its counter popup.
1787 transform_preview, _ = self.transform_preview_image(
1788 quality=False,
1789 preview_only=True,
1790 )
1791 else:
1792 transform_preview, _ = self.transform_preview_image(
1793 quality=quality_ready,
1794 preview_only=True,
1795 )
1796 if transform_preview is not None: 1796 ↛ 1811line 1796 didn't jump to line 1811 because the condition on line 1796 was always true
1797 if quality_ready and self.transform_original_layer is not None:
1798 transform_preview = self._merge_quality_transform(
1799 self.transform_original_layer,
1800 transform_preview,
1801 )
1802 if transform_preview is None: 1802 ↛ 1803line 1802 didn't jump to line 1803 because the condition on line 1802 was never true
1803 transform_preview = QImage()
1804 transform_preview = (
1805 transform_preview.mirrored(self.flip_horizontal, self.flip_vertical)
1806 if self.flip_horizontal or self.flip_vertical else transform_preview
1807 )
1808 p.setOpacity(1 if quality_ready else .85)
1809 p.drawImage(wr, transform_preview)
1810 p.setOpacity(1)
1811 self.paint_skewer_guide(p, wr)
1812 p.setPen(QPen(QColor(15,15,15),1));p.drawRect(cr);p.restore()
1813 self.paint_skewer_banner(p)
1814 # ライン/図形の確定前プレビュー
1815 if self.line_start is not None and self.line_end is not None: 1815 ↛ 1816line 1815 didn't jump to line 1816 because the condition on line 1815 was never true
1816 preview_color = self.opaque_paint_color()
1817 preview_color.setAlpha(220)
1818 p.setPen(QPen(
1819 preview_color,
1820 max(0.5, float(self.pen_size) * self.zoom),
1821 Qt.PenStyle.SolidLine,
1822 Qt.PenCapStyle.RoundCap,
1823 Qt.PenJoinStyle.RoundJoin,
1824 ))
1825 line_path = QPainterPath(self.canvas_to_widget(self.line_start))
1826 if self.line_curve_stage == 2 and self.line_control is not None:
1827 line_path.quadTo(
1828 self.canvas_to_widget(self.line_control),
1829 self.canvas_to_widget(self.line_end),
1830 )
1831 else:
1832 line_path.lineTo(self.canvas_to_widget(self.line_end))
1833 p.drawPath(line_path)
1835 if self.shape_start is not None and self.shape_end is not None: 1835 ↛ 1836line 1835 didn't jump to line 1836 because the condition on line 1835 was never true
1836 rect_preview = self._shape_rect()
1837 panel = self._active_tool_panel()
1838 shape_type = panel.shape_type.currentData() if panel is not None else "polygon"
1839 preview_points = []
1840 if not rect_preview.isEmpty():
1841 center = rect_preview.center()
1842 if shape_type == "ellipse":
1843 point_count = 64
1844 for index in range(point_count):
1845 angle = -math.pi / 2.0 + 2.0 * math.pi * index / point_count
1846 canvas_point = QPointF(
1847 center.x() + math.cos(angle) * rect_preview.width() / 2.0,
1848 center.y() + math.sin(angle) * rect_preview.height() / 2.0,
1849 )
1850 preview_points.append(self.canvas_to_widget(canvas_point))
1851 else:
1852 point_count = max(
1853 3,
1854 int(panel.shape_corners.value()) if panel is not None else 4,
1855 )
1856 if point_count == 4:
1857 canvas_points = [
1858 QPointF(rect_preview.left(), rect_preview.top()),
1859 QPointF(rect_preview.right(), rect_preview.top()),
1860 QPointF(rect_preview.right(), rect_preview.bottom()),
1861 QPointF(rect_preview.left(), rect_preview.bottom()),
1862 ]
1863 preview_points.extend(
1864 self.canvas_to_widget(point) for point in canvas_points
1865 )
1866 else:
1867 for index in range(point_count):
1868 angle = -math.pi / 2.0 + 2.0 * math.pi * index / point_count
1869 canvas_point = QPointF(
1870 center.x() + math.cos(angle) * rect_preview.width() / 2.0,
1871 center.y() + math.sin(angle) * rect_preview.height() / 2.0,
1872 )
1873 preview_points.append(self.canvas_to_widget(canvas_point))
1874 if preview_points:
1875 preview_color = self.opaque_paint_color()
1876 preview_color.setAlpha(220)
1877 width = (
1878 panel.shape_outline_width.value() / 2.0
1879 if panel is not None else 1.0
1880 )
1881 p.setPen(QPen(
1882 preview_color,
1883 max(0.5, float(width) * self.zoom),
1884 Qt.PenStyle.SolidLine,
1885 ))
1886 p.setBrush(Qt.BrushStyle.NoBrush)
1887 preview_path = QPainterPath(preview_points[0])
1888 for point in preview_points[1:]:
1889 preview_path.lineTo(point)
1890 preview_path.closeSubpath()
1891 p.drawPath(preview_path)
1893 # overlays are drawn in widget coordinates so rotation is applied only once
1894 preview_selection = self.lasso
1895 if preview_selection: 1895 ↛ 1896line 1895 didn't jump to line 1896 because the condition on line 1895 was never true
1896 p.setPen(QPen(QColor(255,70,70),2,Qt.PenStyle.DashLine));
1897 pts=[self.canvas_to_widget(q) for q in preview_selection]
1898 for a,b in zip(pts,pts[1:]):p.drawLine(a,b)
1899 elif self.selection_polygon:
1900 outlines = (
1901 self.selection_outline_polygons
1902 if self.selection_outline_polygons
1903 else [self.selection_polygon]
1904 )
1905 p.save()
1906 p.setOpacity(self._selection_fade_opacity)
1907 p.setCompositionMode(
1908 QPainter.CompositionMode.CompositionMode_Difference
1909 )
1910 p.setPen(QPen(QColor(255,255,255),2,Qt.PenStyle.DashLine))
1911 for outline in outlines:
1912 display_outline = (
1913 self.transformed_selection_outline(outline)
1914 if self.transform_active else outline
1915 )
1916 pts = [
1917 self.canvas_to_widget(point)
1918 for point in display_outline
1919 ]
1920 for first, second in zip(pts, pts[1:]):
1921 p.drawLine(first, second)
1922 if len(pts) > 2: 1922 ↛ 1911line 1922 didn't jump to line 1911 because the condition on line 1922 was always true
1923 p.drawLine(pts[-1], pts[0])
1924 p.restore()
1925 if self.rect_start is not None and self.rect_end is not None: 1925 ↛ 1926line 1925 didn't jump to line 1926 because the condition on line 1925 was never true
1926 a=self.canvas_to_widget(self.rect_start); b=self.canvas_to_widget(self.rect_end)
1927 p.setPen(QPen(QColor(255,70,70),2,Qt.PenStyle.DashLine))
1928 p.drawRect(QRectF(a,b).normalized())
1929 if self.transform_active and self.transform_points:
1930 p.setPen(QPen(QColor(0,220,255),2))
1931 points=[self.canvas_to_widget(q) for q in self.transform_points]
1932 if self.transform_mode=="mesh": 1932 ↛ 1933line 1932 didn't jump to line 1933 because the condition on line 1932 was never true
1933 cols=max(2,int(getattr(self, "transform_mesh_cols", 4)))
1934 rows=max(2,int(getattr(self, "transform_mesh_rows", 4)))
1935 if len(points)==cols*rows:
1936 for gy in range(rows):
1937 for gx in range(cols-1):
1938 p.drawLine(points[gy*cols+gx],points[gy*cols+gx+1])
1939 for gx in range(cols):
1940 for gy in range(rows-1):
1941 p.drawLine(points[gy*cols+gx],points[(gy+1)*cols+gx])
1942 elif len(points)==4: 1942 ↛ 1945line 1942 didn't jump to line 1945 because the condition on line 1942 was always true
1943 for index in range(4):
1944 p.drawLine(points[index],points[(index+1)%4])
1945 rotation_handle=self.transform_rotation_handle()
1946 if rotation_handle is not None: 1946 ↛ 1962line 1946 didn't jump to line 1962 because the condition on line 1946 was always true
1947 rotation_widget=self.canvas_to_widget(rotation_handle)
1948 if self.transform_mode == "mesh": 1948 ↛ 1949line 1948 didn't jump to line 1949 because the condition on line 1948 was never true
1949 cols=max(2,int(getattr(self, "transform_mesh_cols", 4)))
1950 left=self.transform_points[0]
1951 right=self.transform_points[cols-1]
1952 else:
1953 left=self.transform_points[0]
1954 right=self.transform_points[1]
1955 top_mid=self.canvas_to_widget(QPointF(
1956 (left.x()+right.x())/2,
1957 (left.y()+right.y())/2,
1958 ))
1959 p.drawLine(top_mid,rotation_widget)
1960 p.setBrush(QColor(255,220,70))
1961 p.drawEllipse(rotation_widget,6,6)
1962 p.setBrush(QColor("white"))
1963 for point in points:p.drawEllipse(point,5,5)
1964 if self.mesh_points: 1964 ↛ 1965line 1964 didn't jump to line 1965 because the condition on line 1964 was never true
1965 p.setPen(QPen(QColor(0,210,255),1)); g=self.mesh_grid
1966 for gy in range(g):
1967 for gx in range(g-1):p.drawLine(self.canvas_to_widget(self.mesh_points[gy*g+gx]),self.canvas_to_widget(self.mesh_points[gy*g+gx+1]))
1968 for gx in range(g):
1969 for gy in range(g-1):p.drawLine(self.canvas_to_widget(self.mesh_points[gy*g+gx]),self.canvas_to_widget(self.mesh_points[(gy+1)*g+gx]))
1970 p.setBrush(QColor("white"));
1971 for q in self.mesh_points:
1972 wq=self.canvas_to_widget(q);p.drawEllipse(wq,5,5)
1973 if (
1974 self.effective_tool() in ("brush", "line")
1975 and self._brush_cursor_inside
1976 and self.rect().contains(self._brush_cursor_widget_pos.toPoint())
1977 ):
1978 center = self.widget_to_canvas(self._brush_cursor_widget_pos)
1979 outline = stamp_outline(center, max(0.5, float(self.pen_size)))
1980 p.save()
1981 p.setBrush(Qt.BrushStyle.NoBrush)
1982 p.setRenderHint(QPainter.RenderHint.Antialiasing, False)
1983 p.setCompositionMode(QPainter.CompositionMode.CompositionMode_Difference)
1984 cursor_pen = QPen(QColor("white"), 1.0)
1985 cursor_pen.setCosmetic(True)
1986 p.setPen(cursor_pen)
1987 for polygon in outline.toSubpathPolygons():
1988 p.drawPolygon(QPolygonF([self.canvas_to_widget(polygon.at(i)) for i in range(polygon.size())]))
1989 p.restore()
1991 # Hold-to-use canvas operations are not entries in ToolPanel.TOOLS.
1992 hold_labels = {
1993 "hand": tr("ハンド"),
1994 "zoom": tr("拡大縮小"),
1995 "rotate": tr("回転"),
1996 "eyedropper": tr("スポイト"),
1997 }
1998 tool = self.effective_tool()
1999 tool_name = hold_labels.get(tool) or tool_label(tool)
2000 p.setPen(QColor("white"))
2001 p.drawText(
2002 10, self.height()-10,
2003 f"{tool_name} | {self.zoom*100:.0f}% | Frame {self.current_frame+1}"
2004 )
2005 p.end()