Coverage for paintmaskanimator/canvas_onion_render.py: 84%
168 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"""Onion-skin rendering for :class:`PaintCanvas`.
3Split out of ``canvas.py`` as a mixin. These methods build the tinted onion
4images for neighbouring frames, resolve which frames to show, and draw the
5onion range beneath the active frame. They run against a live ``PaintCanvas``
6instance and are called from its ``paintEvent``.
7"""
8import numpy as np
9from PySide6.QtCore import QRectF, Qt
10from PySide6.QtGui import QColor, QImage, QPainter, QPen
11from ._canvas_members import CanvasMembers
12from . import imaging, native
13from .utils import blank_image
14from .logging_setup import get_logger
16log = get_logger(__name__)
19class OnionRenderMixin(CanvasMembers):
20 def _cache_onion_image(self, key, image):
21 # A 4K onion is ~32 MiB. A count-only limit of 48 could retain
22 # over 1.5 GiB; bound bytes as well as entries.
23 budget = 128 * 1024 * 1024
24 size = image.sizeInBytes()
25 if size > budget: 25 ↛ 26line 25 didn't jump to line 26 because the condition on line 25 was never true
26 return image
27 used = sum(value.sizeInBytes() for value in self._onion_cache.values())
28 while self._onion_cache and (used + size > budget or len(self._onion_cache) >= 48): 28 ↛ 29line 28 didn't jump to line 29 because the condition on line 28 was never true
29 oldest = self._onion_cache.pop(next(iter(self._onion_cache)))
30 used -= oldest.sizeInBytes()
31 self._onion_cache[key] = image
32 return image
34 def _tinted_onion_image(
35 self, frame_index, color, color_enabled=True, selected_colors_only=False,
36 target_size=None,
37 ):
38 signature = []
39 for layer_index in range(len(self.frames[frame_index].layers)):
40 if not self.onion_all_layers and layer_index != self.active_layer_index:
41 continue
42 key_frame = self.resolve_key_frame(frame_index, layer_index)
43 if key_frame is None: 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true
44 signature.append(None)
45 continue
46 layer = self.frames[key_frame].layers[layer_index]
47 signature.append((
48 layer_index, key_frame, int(layer.image.cacheKey()), bool(layer.visible),
49 float(layer.opacity),
50 bool(layer.color_filter_enabled),
51 tuple(layer.color_filter_rgb) if layer.color_filter_rgb is not None else None,
52 bool(layer.is_paper), bool(layer.is_draft),
53 ))
54 visible_signature = (
55 None if self.visible_color_rgbs is None
56 else tuple(sorted(self.visible_color_rgbs))
57 )
58 selected_signature = tuple(sorted(self.selected_used_color_rgbs))
59 cache_key = (
60 int(QColor(color).rgba()), bool(color_enabled),
61 bool(selected_colors_only), bool(self.onion_all_layers),
62 int(self.active_layer_index), selected_signature,
63 tuple(signature), bool(self.silhouette_non_background),
64 visible_signature,
65 tuple(sorted((self.preview_color_remap or {}).items())),
66 target_size,
67 )
68 cached = self._onion_cache.pop(cache_key, None)
69 if cached is not None:
70 self._onion_cache[cache_key] = cached
71 return cached
73 if target_size is not None:
74 # Only the on-screen onion needs this preview. Keep full-resolution
75 # composite/export paths intact and account for device pixel ratio
76 # at the call site. Use nearest sampling, as the canvas does.
77 width, height = target_size
78 image = QImage(width, height, QImage.Format.Format_ARGB32_Premultiplied)
79 image.fill(Qt.GlobalColor.transparent)
80 painter = QPainter(image)
81 painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
82 destination = QRectF(0, 0, width, height)
83 for layer_index in range(len(self.frames[frame_index].layers)):
84 if not self.onion_all_layers and layer_index != self.active_layer_index:
85 continue
86 key_frame = self.resolve_key_frame(frame_index, layer_index)
87 if key_frame is None: 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true
88 continue
89 layer = self.frames[key_frame].layers[layer_index]
90 if layer.visible and layer.opacity > 0.0:
91 source = self._display_layer_image(layer, layer_index)
92 if source is not None: 92 ↛ 83line 92 didn't jump to line 83 because the condition on line 92 was always true
93 painter.setOpacity(max(0.0, min(1.0, float(layer.opacity))))
94 painter.drawImage(destination, source)
95 painter.end()
96 elif self.onion_all_layers:
97 image = self.composite(frame_index, False)
98 else:
99 image = blank_image()
100 key_frame = self.resolve_key_frame(frame_index, self.active_layer_index)
101 if key_frame is not None: 101 ↛ 115line 101 didn't jump to line 115 because the condition on line 101 was always true
102 layer = self.frames[key_frame].layers[self.active_layer_index]
103 if layer.visible:
104 painter = QPainter(image)
105 painter.setOpacity(max(0.0, min(1.0, float(layer.opacity))))
106 painter.drawImage(
107 0,
108 0,
109 self._display_layer_image(
110 layer,
111 self.active_layer_index,
112 ),
113 )
114 painter.end()
115 image = image.convertToFormat(QImage.Format.Format_RGBA8888)
116 width, height = image.width(), image.height()
117 ptr = imaging.qimage_buffer(image)
118 rows = np.frombuffer(ptr, dtype=np.uint8).reshape(
119 (height, image.bytesPerLine())
120 )
121 rgba = rows[:, :width * 4].reshape((height, width, 4))
122 tint_color = QColor(color)
123 native_rgba = native.tint_onion(
124 rgba, (tint_color.red(), tint_color.green(), tint_color.blue()),
125 color_enabled,
126 self.selected_used_color_rgbs if selected_colors_only else None,
127 )
128 result = QImage(
129 native_rgba.data, width, height, native_rgba.strides[0],
130 QImage.Format.Format_RGBA8888,
131 ).copy().convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
132 return self._cache_onion_image(cache_key, result)
134 def onion_shift_values(self, direction):
135 if int(direction) < 0: 135 ↛ 141line 135 didn't jump to line 141 because the condition on line 135 was always true
136 return (
137 float(self.onion_previous_shift_x),
138 float(self.onion_previous_shift_y),
139 float(self.onion_previous_rotation),
140 )
141 return (
142 float(self.onion_next_shift_x),
143 float(self.onion_next_shift_y),
144 float(self.onion_next_rotation),
145 )
147 def onion_transform_values(self, direction):
148 shift_x, shift_y, rotation = self.onion_shift_values(
149 direction
150 )
151 scale = (
152 float(self.onion_previous_scale)
153 if int(direction) < 0
154 else float(self.onion_next_scale)
155 )
156 return (
157 shift_x,
158 shift_y,
159 rotation,
160 max(0.01, scale / 100.0),
161 )
163 def _onion_frame_identity(self, frame_index):
164 """指定位置で表示される絵を、保持フレームと区別せず識別する。"""
165 frame_index = int(frame_index)
166 if not (0 <= frame_index < len(self.frames)): 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true
167 return None
169 if not self.onion_all_layers: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true
170 layer_index = int(self.active_layer_index)
171 key_frame = self.resolve_key_frame(frame_index, layer_index)
172 if key_frame is None:
173 return None
174 layer = self.frames[key_frame].layers[layer_index]
175 return (layer_index, int(key_frame)) if layer.visible else None
177 identity = []
178 layer_count = len(self.frames[frame_index].layers)
179 for layer_index in range(layer_count):
180 key_frame = self.resolve_key_frame(frame_index, layer_index)
181 if key_frame is None: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true
182 continue
183 layer = self.frames[key_frame].layers[layer_index]
184 if layer.visible: 184 ↛ 179line 184 didn't jump to line 179 because the condition on line 184 was always true
185 identity.append((layer_index, int(key_frame)))
186 return tuple(identity) if identity else None
188 def _onion_frame_indices(self, direction, count):
189 """空セルと同じ絵の保持区間を飛ばし、前後の実画像位置を返す。"""
190 direction = -1 if int(direction) < 0 else 1
191 count = max(0, int(count))
192 if count <= 0: 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true
193 return []
195 indices = []
196 previous_identity = self._onion_frame_identity(
197 self.current_frame
198 )
199 frame_index = int(self.current_frame) + direction
200 while 0 <= frame_index < len(self.frames) and len(indices) < count:
201 identity = self._onion_frame_identity(frame_index)
202 if identity is not None and identity != previous_identity: 202 ↛ 205line 202 didn't jump to line 205 because the condition on line 202 was always true
203 indices.append(frame_index)
204 previous_identity = identity
205 frame_index += direction
206 return indices
208 def _draw_onion_range(
209 self, painter, work_rect, direction, count, opacity, color,
210 color_enabled=True, selected_colors_only=False
211 ):
212 count = max(0, int(count))
213 shift_x, shift_y, local_rotation, local_scale = (
214 self.onion_transform_values(direction)
215 )
216 levels = (
217 list(self.onion_previous_levels)
218 if int(direction) < 0
219 else list(self.onion_next_levels)
220 )
221 transform_center = work_rect.center()
222 outline_rect = QRectF(self.canvas_rect()).adjusted(
223 -0.5, -0.5, 0.5, 0.5
224 )
225 frame_indices = self._onion_frame_indices(direction, count)
226 target_size = self._playback_cache_dimensions(work_rect)
227 # Local onion zoom can enlarge the preview independently of the canvas.
228 # Never reduce below the canvas resolution or exceed source resolution.
229 preview_scale = max(1.0, local_scale * float(self.onion_tu_tb_scale))
230 if preview_scale > 1.0:
231 from .utils import workspace_size
232 source_width, source_height = workspace_size()
233 target_size = (
234 min(source_width, max(1, round(target_size[0] * preview_scale))),
235 min(source_height, max(1, round(target_size[1] * preview_scale))),
236 )
238 # 遠い絵から描き、近い絵を手前へ重ねる。
239 for distance, frame_index in reversed(list(enumerate(
240 frame_indices,
241 start=1,
242 ))):
244 if distance - 1 < len(levels): 244 ↛ 250line 244 didn't jump to line 250 because the condition on line 244 was always true
245 level = max(
246 0.0,
247 min(1.0, float(levels[distance - 1]) / 100.0),
248 )
249 else:
250 level = max(
251 0.0,
252 min(
253 1.0,
254 1.0
255 - ((distance - 1) / max(1, count)) * 0.55,
256 ),
257 )
258 draw_opacity = max(
259 0.0,
260 min(1.0, float(opacity) * level),
261 )
263 if draw_opacity > 0.0:
264 onion = self._tinted_onion_image(
265 frame_index, color, color_enabled, selected_colors_only,
266 target_size=target_size,
267 )
268 if self.flip_horizontal or self.flip_vertical: 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true
269 onion = onion.mirrored(self.flip_horizontal, self.flip_vertical)
270 painter.save()
271 painter.translate(
272 transform_center.x()
273 + shift_x * float(self.zoom),
274 transform_center.y()
275 + shift_y * float(self.zoom),
276 )
277 painter.rotate(local_rotation)
278 painter.scale(local_scale, local_scale)
279 painter.translate(
280 -transform_center.x(),
281 -transform_center.y(),
282 )
283 painter.setOpacity(draw_opacity)
284 painter.drawImage(work_rect, onion)
285 painter.restore()
287 if frame_indices: 287 ↛ exitline 287 didn't return from function '_draw_onion_range' because the condition on line 287 was always true
288 # 外周は全体濃度・コマ別濃度・色適用設定とは分離し、
289 # 常に100%不透明度の1px線で表示する。
290 painter.save()
291 painter.translate(
292 transform_center.x()
293 + shift_x * float(self.zoom),
294 transform_center.y()
295 + shift_y * float(self.zoom),
296 )
297 painter.rotate(local_rotation)
298 painter.scale(local_scale, local_scale)
299 painter.translate(
300 -transform_center.x(),
301 -transform_center.y(),
302 )
303 painter.setOpacity(1.0)
304 outline_color = QColor(color)
305 outline_color.setAlpha(255)
306 outline_pen = QPen(outline_color, 1.0)
307 outline_pen.setCosmetic(True)
308 outline_pen.setStyle(Qt.PenStyle.SolidLine)
309 painter.setPen(outline_pen)
310 painter.setBrush(Qt.BrushStyle.NoBrush)
311 painter.drawRect(outline_rect)
312 painter.restore()