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

1"""Onion-skin rendering for :class:`PaintCanvas`. 

2 

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 

15 

16log = get_logger(__name__) 

17 

18 

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 

33 

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 

72 

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) 

133 

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 ) 

146 

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 ) 

162 

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 

168 

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 

176 

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 

187 

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 [] 

194 

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 

207 

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 ) 

237 

238 # 遠い絵から描き、近い絵を手前へ重ねる。 

239 for distance, frame_index in reversed(list(enumerate( 

240 frame_indices, 

241 start=1, 

242 ))): 

243 

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 ) 

262 

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() 

286 

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()