Coverage for paintmaskanimator/canvas_skewer_fill.py: 75%
321 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"""串刺し塗り(バケツの全コマ一括適用)を PaintCanvas に足す mixin。
3串刺し塗りは、クリックした図形を選択レイヤーの各コマで追いかけて塗る。
4コマごとに絵は動くので、同じ座標を種にし続けると途中で図形の外へ出て背景を
5塗り始める。そこで現在コマから前後へ1コマずつ進み、直前のコマで塗った領域と
6重なる図形を選び直す(:func:`bucket_fill.track_fill_region`)。
8追跡の信用度が下がったコマでは勝手に塗らずに止まり、そのコマを表示して
9ユーザーに塗る場所をクリックしてもらう(Esc でそのコマを飛ばす)。
10すべてのコマが決まってから一度に塗るので、Undo は1回で全コマ戻る。
12既存のセルにだけ塗る。保持セルを新しいキーに分けたり、空セルに絵を
13作ったりはしない。
14"""
15from __future__ import annotations
17from dataclasses import dataclass, field
18from typing import Any, Dict, List, Optional, Tuple
20import numpy as np
21from PySide6.QtCore import QRectF, Qt
22from PySide6.QtGui import QColor, QImage, QPainter
24from . import bucket_fill, frame_scope, imaging
25from ._canvas_members import CanvasMembers
26from .i18n import tr
27from .time_display import frame_position
28from .logging_setup import get_logger
29from .progress import close_counter, create_counter, update_counter
31log = get_logger(__name__)
33# 追跡の重なり(IoU)がこれ未満のコマは、自動では塗らずにユーザーに指定してもらう。
34TRACK_CONFIDENT_IOU = 0.5
36# 手動指定を待っているコマで、前のコマの塗り範囲を示す色。
37_GUIDE_RGBA = (255, 64, 160, 90)
38# 追跡が「たぶんここ」と推測した範囲を示す色(Enter で採用できる)。
39_GUESS_RGBA = (40, 140, 255, 110)
41Cell = Tuple[int, int]
44@dataclass
45class SkewerFillSession:
46 """串刺し塗り1回分の途中経過。手動指定で止まっている間も保持する。"""
48 scope: Any
49 reference: Optional[Tuple[int, int, int]]
50 options: bucket_fill.BucketOptions
51 selection_mask: Optional[np.ndarray]
52 replacement: Any
53 # 塗り始めたときに表示していたコマ。終わったらここへ戻す。
54 origin_frame: int
55 # これから見るセル。前方向と後ろ方向の2本。
56 queues: List[List[Cell]]
57 # 方向ごとの「直前に塗れたコマの領域」。
58 previous: List[np.ndarray]
59 # 方向ごとの「直前に塗れたコマの画素」。コマ間のずれの推定に使う。
60 previous_pixels: List[Optional[np.ndarray]]
61 # 走査対象のセル一覧。途中でタイムラインが変わっていないかの確認用。
62 cells: List[Cell]
63 direction: int = 0
64 # ``id(image)`` → ``(外接矩形, 切り出したマスク, ((置換色, 切り出した島), ...))``。
65 regions: Dict[
66 int,
67 Tuple[
68 Tuple[int, int, int, int],
69 np.ndarray,
70 Tuple[Tuple[Tuple[int, int, int], np.ndarray], ...],
71 ],
72 ] = field(default_factory=dict)
73 # ``id(image)`` → ``(image, 計画したときの cacheKey)``。途中で描き換えられて
74 # いないかの確認用。
75 images: Dict[int, Tuple[QImage, int]] = field(default_factory=dict)
76 skipped: int = 0
77 # 手動指定を待っているセル ``(frame, layer)``。
78 pending: Optional[Cell] = None
79 # 手動指定を待つコマに重ねる、推測(無ければ前のコマの塗り範囲)。
80 guide: Optional[QImage] = None
81 # 自信はないが追跡が推測した範囲と、そのコマの画素。Enter で採用する。
82 guess: Optional[bucket_fill.FillResult] = None
83 guess_pixels: Optional[np.ndarray] = None
86class SkewerFillMixin(CanvasMembers):
87 _skewer_session: Optional[SkewerFillSession] = None
89 # ------------------------------------------------------------------
90 # 入口
91 # ------------------------------------------------------------------
92 def flood_fill_over_frames(self, p):
93 """串刺し塗りのクリック。手動指定待ちなら、その指定として扱う。"""
94 if self._skewer_session is not None:
95 self._skewer_resume_with_click(p)
96 return
97 start_x, start_y = int(p.x()), int(p.y())
98 if not self._seed_in_bounds(start_x, start_y): 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true
99 return
100 window: Any = self.window()
101 scope_controller = getattr(window, "scope", None)
102 if scope_controller is None: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 return
104 scope = scope_controller.current_frame(True, selection_only=False)
105 cells = frame_scope.resolve_cells(self, scope)
106 layer_index = int(self.active_layer_index)
107 key_frame = self.resolve_key_frame(self.current_frame, layer_index)
108 if key_frame is None or (key_frame, layer_index) not in cells: 108 ↛ 110line 108 didn't jump to line 110 because the condition on line 108 was never true
109 # 空セルには塗らない(新しいセルを作らない)。
110 self.status_message.emit(
111 tr("串刺し塗り:このコマには塗れる絵がありません。")
112 )
113 return
114 pivot_image = self.frames[key_frame].layers[layer_index].image
116 options = self.bucket_options()
117 selection_mask = self.selection_mask_bool(
118 pivot_image.width(), pivot_image.height()
119 )
120 _converted, layer_pixels = self._layer_fill_pixels(pivot_image)
121 # 判定は参照レイヤーの絵で行う(塗る先は変わらない)。
122 _reference, pixels = self.bucket_reference_pixels(
123 options, key_frame, layer_index, _converted, layer_pixels
124 )
125 start_result = bucket_fill.compute_fill_region(
126 pixels, start_x, start_y, options, selection_mask
127 )
128 if not start_result.ok:
129 message = self._bucket_failure_message(start_result.reason)
130 self.status_message.emit(
131 message or tr("串刺し塗りの対象になるコマがありませんでした。")
132 )
133 return
135 pivot = cells.index((key_frame, layer_index))
136 session = SkewerFillSession(
137 scope=scope,
138 reference=bucket_fill.seed_category(pixels, start_x, start_y),
139 options=options,
140 selection_mask=selection_mask,
141 replacement=self.paint_source_color(),
142 origin_frame=int(self.current_frame),
143 queues=[cells[pivot + 1:], list(reversed(cells[:pivot]))],
144 previous=[start_result.mask, start_result.mask],
145 previous_pixels=[pixels.copy()] * 2,
146 cells=list(cells),
147 )
148 self._skewer_remember(session, pivot_image, start_result)
149 self._skewer_session = session
150 self._skewer_advance()
152 def skewer_fill_pending(self):
153 """手動指定を待っているか。"""
154 return bool(
155 self._skewer_session is not None
156 and self._skewer_session.pending is not None
157 )
159 def skip_skewer_fill_frame(self):
160 """手動指定を待っているコマを塗らずに飛ばし、続きを追いかける。"""
161 session = self._skewer_session
162 if session is None or session.pending is None: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true
163 return False
164 if not self._skewer_session_valid(session): 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
165 self.abort_skewer_fill(tr("絵が変わったため、串刺し塗りを取り消しました。"))
166 return True
167 session.skipped += 1
168 self._skewer_clear_pending(session)
169 self._skewer_advance()
170 return True
172 def skip_skewer_fill_direction(self):
173 """手動指定を待っているコマと、同じ向きの残りのコマを塗らずに飛ばす(Shift+Esc)。
175 図形が画面から消えたあとは、どのコマも前に塗れたコマと比べることになり、
176 1コマずつ飛ばしても止まり続ける。そのときに残りをまとめて飛ばす。
177 """
178 session = self._skewer_session
179 if session is None or session.pending is None:
180 return False
181 if not self._skewer_session_valid(session): 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true
182 self.abort_skewer_fill(tr("絵が変わったため、串刺し塗りを取り消しました。"))
183 return True
184 queue = session.queues[session.direction]
185 session.skipped += 1 + len(queue)
186 queue.clear()
187 self._skewer_clear_pending(session)
188 self._skewer_advance()
189 return True
191 def abort_skewer_fill(self, message=None):
192 """途中の串刺し塗りを、何も塗らずに取り消す。"""
193 if self._skewer_session is None:
194 return
195 self._skewer_session = None
196 self.update()
197 self.status_message.emit(message or tr("串刺し塗りを取り消しました。"))
199 # ------------------------------------------------------------------
200 # 追跡
201 # ------------------------------------------------------------------
202 def _skewer_advance(self):
203 """次のコマから追跡を続ける。信用度が低いコマに来たら止まって待つ。"""
204 session = self._skewer_session
205 if session is None: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true
206 return
207 total = sum(len(queue) for queue in session.queues)
208 dialog = (
209 create_counter(self, tr("串刺し塗り"), total, cancellable=True)
210 if total
211 else None
212 )
213 done = 0
214 try:
215 while session.direction < len(session.queues):
216 queue = session.queues[session.direction]
217 while queue:
218 if dialog is not None and dialog.wasCanceled(): 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true
219 self.abort_skewer_fill(tr("串刺し塗りを中止しました。"))
220 return
221 frame_index, layer_index = queue[0]
222 update_counter(
223 dialog, done, total,
224 tr("{position}の塗る範囲を探しています").format(
225 position=frame_position(frame_index)
226 ),
227 )
228 done += 1
229 image = self.frames[frame_index].layers[layer_index].image
230 if id(image) in session.regions: 230 ↛ 231line 230 didn't jump to line 231 because the condition on line 230 was never true
231 queue.pop(0)
232 continue
233 result, pixels = self._skewer_locate(
234 session,
235 self._skewer_reference_image(
236 session, frame_index, layer_index, image
237 ),
238 )
239 if result is not None and result.ok and (
240 result.score is None
241 or result.score >= TRACK_CONFIDENT_IOU
242 ):
243 self._skewer_remember(session, image, result)
244 session.previous[session.direction] = result.mask
245 session.previous_pixels[session.direction] = pixels
246 queue.pop(0)
247 continue
248 if result is None: 248 ↛ 250line 248 didn't jump to line 250 because the condition on line 248 was never true
249 # 追跡しない設定(「隣接」OFF)で塗れなかったコマは飛ばす。
250 session.skipped += 1
251 queue.pop(0)
252 continue
253 # 自信がないので、このコマはユーザーに指定してもらう。
254 # 推測があれば見せて、Enter だけで採用できるようにする。
255 queue.pop(0)
256 if result.ok:
257 session.guess = result
258 session.guess_pixels = pixels
259 self._skewer_wait_for(session, (frame_index, layer_index))
260 return
261 session.direction += 1
262 finally:
263 close_counter(dialog)
264 self._skewer_apply()
266 def _skewer_reference_image(self, session, frame_index, layer_index, image):
267 """``frame_index`` のコマで判定に使う絵。参照レイヤーの設定に従う。"""
268 return self._bucket_reference_image(
269 session.options, frame_index, layer_index, image
270 )
272 def _skewer_locate(self, session, image):
273 """追跡で塗る範囲を探す。``(結果, そのコマの画素)`` を返す。
275 結果が ``None`` なのは「追跡しない設定で塗れなかった」とき。
276 """
277 _converted, view = self._layer_fill_pixels(image)
278 # 次のコマとのずれの推定に使うので、画像に縛られない複製にしておく。
279 pixels = view.copy()
280 previous = session.previous[session.direction]
281 if pixels.shape[:2] != previous.shape: 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true
282 return None, pixels
283 if session.options.adjacent: 283 ↛ 290line 283 didn't jump to line 290 because the condition on line 283 was always true
284 return bucket_fill.track_fill_region(
285 pixels, previous, session.reference, session.options,
286 session.selection_mask,
287 previous_pixels=session.previous_pixels[session.direction],
288 ), pixels
289 # 「隣接」OFF はレイヤー内の同色を一括で塗るので、位置を追う必要がない。
290 reference = session.reference
291 if reference is None:
292 target = bucket_fill.background_mask(pixels)
293 else:
294 target = np.all(pixels[:, :, :3] == reference, axis=2)
295 target &= pixels[:, :, 3] > 0
296 points = np.argwhere(target)
297 if not points.size:
298 return None, pixels
299 start_y, start_x = (int(value) for value in points[0])
300 result = bucket_fill.compute_fill_region(
301 pixels, start_x, start_y, session.options, session.selection_mask
302 )
303 return (result if result.ok else None), pixels
305 def _skewer_remember(self, session, image, result):
306 assert result.bounds is not None # ok なら必ず外接矩形がある
307 x0, y0, x1, y1 = result.bounds
308 session.regions[id(image)] = (
309 result.bounds,
310 result.mask[y0:y1, x0:x1].copy(),
311 tuple(
312 (rgb, island[y0:y1, x0:x1].copy()) for rgb, island in result.islands
313 ),
314 )
315 session.images[id(image)] = (image, int(image.cacheKey()))
317 def _skewer_wait_for(self, session, cell):
318 frame_index, layer_index = cell
319 session.pending = cell
320 if session.guess is not None:
321 session.guide = self._skewer_guide_image(session.guess.mask, _GUESS_RGBA)
322 else:
323 session.guide = self._skewer_guide_image(
324 session.previous[session.direction]
325 )
326 self.current_frame = int(frame_index)
327 self.active_layer_index = int(layer_index)
328 self.selectionChanged.emit()
329 self.update()
330 self.status_message.emit(self._skewer_pending_message(session))
332 @staticmethod
333 def _skewer_pending_message(session):
334 assert session.pending is not None
335 if session.guess is not None:
336 return tr(
337 "串刺し塗り:{position}は自信がありません。推測した範囲を青で"
338 "表示しています(Enter:この範囲で塗る/クリック:塗る場所を指定"
339 "/Esc:このコマを飛ばす/Shift+Esc:この向きの残りを飛ばす)。"
340 ).format(position=frame_position(session.pending[0]))
341 return tr(
342 "串刺し塗り:{position}は追いかけきれませんでした。"
343 "塗る場所をクリックしてください(Esc:このコマを飛ばす"
344 "/Shift+Esc:この向きの残りを飛ばす)。"
345 ).format(position=frame_position(session.pending[0]))
347 def skewer_fill_has_guess(self):
348 session = self._skewer_session
349 return bool(session is not None and session.pending is not None and session.guess)
351 def accept_skewer_guess(self):
352 """手動指定を待っているコマで、追跡の推測をそのまま採用する(Enter)。"""
353 session = self._skewer_session
354 if session is None or session.pending is None or session.guess is None: 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true
355 return False
356 if not self._skewer_session_valid(session): 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true
357 self.abort_skewer_fill(tr("絵が変わったため、串刺し塗りを取り消しました。"))
358 return True
359 frame_index, layer_index = session.pending
360 image = self.frames[frame_index].layers[layer_index].image
361 self._skewer_remember(session, image, session.guess)
362 session.previous[session.direction] = session.guess.mask
363 session.previous_pixels[session.direction] = session.guess_pixels
364 self._skewer_clear_pending(session)
365 self._skewer_advance()
366 return True
368 @staticmethod
369 def _skewer_clear_pending(session):
370 session.pending = None
371 session.guide = None
372 session.guess = None
373 session.guess_pixels = None
375 def _skewer_resume_with_click(self, p):
376 session = self._skewer_session
377 assert session is not None
378 if not self._skewer_session_valid(session): 378 ↛ 379line 378 didn't jump to line 379 because the condition on line 378 was never true
379 self.abort_skewer_fill(tr("絵が変わったため、串刺し塗りを取り消しました。"))
380 return
381 if session.pending is None: 381 ↛ 382line 381 didn't jump to line 382 because the condition on line 381 was never true
382 return
383 frame_index, layer_index = session.pending
384 pending_image = self.frames[frame_index].layers[layer_index].image
385 key_frame = self.resolve_key_frame(self.current_frame, layer_index)
386 if ( 386 ↛ 391line 386 didn't jump to line 391 because the condition on line 386 was never true
387 key_frame is None
388 or self.frames[key_frame].layers[layer_index].image is not pending_image
389 ):
390 # 別のコマを見ているときは、待っているコマへ戻す。
391 self._skewer_wait_for(session, session.pending)
392 return
393 start_x, start_y = int(p.x()), int(p.y())
394 if not self._seed_in_bounds(start_x, start_y, pending_image): 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true
395 return
396 _converted, view = self._layer_fill_pixels(
397 self._skewer_reference_image(
398 session, frame_index, layer_index, pending_image
399 )
400 )
401 pixels = view.copy()
402 result = bucket_fill.compute_fill_region(
403 pixels, start_x, start_y, session.options, session.selection_mask
404 )
405 if not result.ok: 405 ↛ 406line 405 didn't jump to line 406 because the condition on line 405 was never true
406 message = self._bucket_failure_message(result.reason)
407 if message:
408 self.status_message.emit(message)
409 return
410 self._skewer_remember(session, pending_image, result)
411 session.previous[session.direction] = result.mask
412 session.previous_pixels[session.direction] = pixels
413 self._skewer_clear_pending(session)
414 self._skewer_advance()
416 def _skewer_session_valid(self, session):
417 """待っている間にタイムラインや絵が変わっていないか。"""
418 try:
419 cells = frame_scope.resolve_cells(self, session.scope)
420 except (IndexError, AttributeError):
421 return False
422 if cells != session.cells: 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true
423 return False
424 for image, cache_key in session.images.values():
425 if int(image.cacheKey()) != cache_key: 425 ↛ 426line 425 didn't jump to line 426 because the condition on line 425 was never true
426 return False
427 live = {
428 id(self.frames[frame_index].layers[layer_index].image)
429 for frame_index, layer_index in cells
430 }
431 return all(image_id in live for image_id in session.images)
433 # ------------------------------------------------------------------
434 # 書き込み
435 # ------------------------------------------------------------------
436 def _skewer_apply(self):
437 session = self._skewer_session
438 self._skewer_session = None
439 if session is None: 439 ↛ 440line 439 didn't jump to line 440 because the condition on line 439 was never true
440 return
441 window: Any = self.window()
442 scope_controller = getattr(window, "scope", None)
443 if scope_controller is None or not self._skewer_session_valid(session): 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true
444 self.update()
445 self.status_message.emit(
446 tr("絵が変わったため、串刺し塗りを取り消しました。")
447 )
448 return
449 written_colors = set()
451 def op(context):
452 planned = session.regions.get(id(context.layer.image))
453 if planned is None:
454 return None
455 bounds, mask, islands = planned
456 image, pixels = self._layer_fill_pixels(context.layer.image)
457 x0, y0, x1, y1 = bounds
458 patch = pixels[y0:y1, x0:x1]
459 written_colors.update(self._fill_patch(patch, mask, session.replacement))
460 for rgb, island in islands: 460 ↛ 461line 460 didn't jump to line 461 because the loop on line 460 never started
461 written_colors.update(self._fill_patch(patch, island, QColor(*rgb)))
462 return image.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
464 if 0 <= session.origin_frame < len(self.frames): 464 ↛ 467line 464 didn't jump to line 467 because the condition on line 464 was always true
465 self.current_frame = session.origin_frame
466 self.selectionChanged.emit()
467 result = scope_controller.run_over(
468 session.scope,
469 op,
470 label=tr("串刺し塗り"),
471 cancellable=True,
472 )
473 self.update()
474 if result.cancelled: 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true
475 self.status_message.emit(tr("串刺し塗りを中止しました。"))
476 return
477 if not result.changed: 477 ↛ 478line 477 didn't jump to line 478 because the condition on line 477 was never true
478 self.status_message.emit(
479 tr("串刺し塗りの対象になるコマがありませんでした。")
480 )
481 return
482 if written_colors:
483 self._emit_actual_paint_colors(tuple(sorted(written_colors)))
484 if session.skipped:
485 self.status_message.emit(
486 tr("串刺し塗り:{filled} コマを塗り、{skipped} コマは対象外でした。").format(
487 filled=result.changed_cells, skipped=session.skipped
488 )
489 )
490 else:
491 self.status_message.emit(
492 tr("串刺し塗り:{filled} コマを塗りました。").format(
493 filled=result.changed_cells
494 )
495 )
497 # ------------------------------------------------------------------
498 # 表示
499 # ------------------------------------------------------------------
500 @staticmethod
501 def _skewer_guide_image(mask, color=_GUIDE_RGBA):
502 rgba = np.zeros(mask.shape + (4,), dtype=np.uint8)
503 rgba[mask] = color
504 return imaging.rgba_array_to_qimage(rgba)
506 def paint_skewer_guide(self, painter: QPainter, work_rect):
507 """手動指定を待つコマに、前のコマの塗り範囲を重ねる(キャンバス座標系)。"""
508 session = self._skewer_session
509 if session is None or session.pending is None or session.guide is None: 509 ↛ 511line 509 didn't jump to line 511 because the condition on line 509 was always true
510 return
511 painter.drawImage(work_rect, session.guide)
513 def paint_skewer_banner(self, painter: QPainter):
514 """手動指定を待っている間、何をすればよいかをキャンバス上部に出す。"""
515 session = self._skewer_session
516 if session is None or session.pending is None: 516 ↛ 518line 516 didn't jump to line 518 because the condition on line 516 was always true
517 return
518 text = self._skewer_pending_message(session)
519 metrics = painter.fontMetrics()
520 margin = 8
521 width = min(self.width() - margin * 2, metrics.horizontalAdvance(text) + 20)
522 rect = QRectF(margin, margin, max(40, width), metrics.height() + 12)
523 painter.save()
524 painter.setPen(Qt.PenStyle.NoPen)
525 painter.setBrush(QColor(20, 20, 20, 210))
526 painter.drawRoundedRect(rect, 6, 6)
527 painter.setPen(QColor(255, 255, 255))
528 painter.drawText(
529 rect.adjusted(10, 0, -10, 0),
530 Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
531 metrics.elidedText(text, Qt.TextElideMode.ElideRight, int(rect.width()) - 20),
532 )
533 painter.restore()
536__all__ = ["SkewerFillMixin", "SkewerFillSession", "TRACK_CONFIDENT_IOU"]