Coverage for paintmaskanimator/main_window_auto_paint.py: 74%
142 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"""自動で塗る。``window.auto_paint`` が持つ。
3黒の主線と原色の赤・緑・青(と黄・水色・赤紫)の色トレス線を色分けの境界にして、見本のセル(既定は
4タイムラインで選んでいるセル)から前後へ1セルずつ、同じ場所の色で塗り進める。
5一通り塗ったら迷った所の少ないセルを見本に加えて塗り直す。アクティブレイヤー
6だけを塗り、Undo は1回にまとめる。塗り残しと塗り間違いかもしれない所は、
7「見本との違いチェック」と同じ一覧で知らせる。塗る範囲と色を決めるのは
8:mod:`auto_paint`、ここは画面と書き込みを受け持つ。
10A collaborator rather than a mixin -- see main_window_export.py for why.
11"""
12import math
13from dataclasses import replace
14from time import monotonic
15from typing import TYPE_CHECKING, List, Optional, Tuple
17import numpy as np
18from PySide6.QtGui import QImage
19from PySide6.QtWidgets import QDialog
21from .auto_paint_dialog import AutoPaintDialog
23from . import auto_paint, bucket_fill, time_display
24from .frame_scope import FrameScope, resolve_cells
25from .i18n import tr
26from .imaging import qimage_rgba_array
27from .progress import close_counter, create_counter, update_counter
29if TYPE_CHECKING:
30 from .main_window import MainWindow
32Cell = Tuple[int, int]
35def cell_label(layer, frame_index, fps=24) -> str:
36 """設定画面の一覧に出す ``レイヤー名+絵番号・時間位置``。"""
37 number = getattr(layer, "sequence_number", None)
38 cell = f"{layer.name}{number}" if number is not None else str(layer.name)
39 return f"{cell} · {time_display.frame_position(frame_index, fps)}"
42class AutoPaintController:
43 """Owned by ``MainWindow`` as ``window.auto_paint``."""
45 def __init__(self, window: "MainWindow"):
46 self.window = window
48 def cells(self) -> List[Cell]:
49 """アクティブレイヤーの、絵のあるセル(保持はキーフレームへ畳む)。"""
50 canvas = self.window.canvas
51 return [
52 (frame, layer) for frame, layer in resolve_cells(canvas, FrameScope.all_frames(canvas))
53 if canvas.frames[frame].layers[layer].has_content
54 ]
56 def open(self) -> None:
57 """設定画面を出し、「実行」なら塗る。"""
58 canvas = self.window.canvas
59 status = self.window.statusBar()
60 canvas.abort_skewer_fill()
61 cells = self.cells()
62 if len(cells) < 2:
63 status.showMessage(tr("塗れるセルが2つ以上ありません。"), 3000)
64 return
65 layer_index = int(canvas.active_layer_index)
66 key = canvas.resolve_key_frame(int(canvas.current_frame), layer_index)
67 current = 0
68 if key is not None and (key, layer_index) in cells:
69 current = cells.index((key, layer_index))
70 fps = int(self.window.timeline.fps.value())
71 labels = [
72 cell_label(canvas.frames[frame].layers[layer], frame, fps) for frame, layer in cells
73 ]
74 layer_name = canvas.frames[cells[0][0]].layers[layer_index].name
75 dialog = AutoPaintDialog(
76 self.window, layer_name, cells, labels, current,
77 images=[canvas.frames[frame].layers[layer].image for frame, layer in cells],
78 numbers=[canvas.frames[frame].layers[layer].sequence_number for frame, layer in cells],
79 fps=fps,
80 )
81 if dialog.exec() != QDialog.DialogCode.Accepted:
82 return
83 chosen = [cells.index(cell) for cell in dialog.chosen()]
84 self.run(cells, chosen[0], reference_indices=chosen)
86 def run(self, cells: List[Cell], reference_index: int,
87 second_reference_index: Optional[int] = None, *,
88 reference_indices: Optional[List[int]] = None) -> bool:
89 """``cells[reference_index]`` を見本に塗る。何か塗れたら真。"""
90 canvas = self.window.canvas
91 status = self.window.statusBar()
92 pixels = []
93 hints = []
94 lines = []
95 references = {reference_index, second_reference_index}
96 references.update(reference_indices or [])
97 for index, (frame, layer) in enumerate(cells):
98 cell = canvas.frames[frame].layers[layer]
99 current = np.ascontiguousarray(qimage_rgba_array(cell.image))
100 initial = None
101 if cell.initial_image is not None:
102 initial = auto_paint.line_only(
103 np.ascontiguousarray(qimage_rgba_array(cell.initial_image))
104 )
105 # 見本は人が塗った今の絵。ほかは色塗り前の初期状態があれば、その線だけで
106 # 部分を分け、今の絵で手で塗ってある部分はその色を答え(前後のセルの手本)
107 # にする。セル同士の動きも、初期状態の線(見本では塗りつぶした色トレス線も
108 # 残っている)で求める。
109 if index not in references and initial is not None:
110 pixels.append(initial)
111 hints.append(current)
112 else:
113 pixels.append(current)
114 hints.append(None)
115 lines.append(initial)
116 plan = self._plan(
117 pixels, reference_index,
118 hints if any(hint is not None for hint in hints) else None,
119 lines if any(line is not None for line in lines) else None,
120 second_reference_index=second_reference_index,
121 reference_indices=reference_indices,
122 )
123 if plan is None: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 status.showMessage(tr("自動で塗るのを中止しました。"), 2400)
125 return False
126 targets = {cells[index]: paint for index, paint in plan.cells.items() if paint.paints}
127 if not targets: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true
128 status.showMessage(tr("自動で塗る:塗るところはありませんでした。"), 4000)
129 return False
130 written = set()
132 def op(context):
133 paint = targets.get((context.frame, context.layer_index))
134 if paint is None: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true
135 return None
136 image, view = canvas._layer_fill_pixels(context.layer.image)
137 for rgb, (x0, y0, x1, y1), mask in paint.paints:
138 patch = view[y0:y1, x0:x1]
139 patch[mask, :3] = rgb
140 patch[mask, 3] = 255
141 written.add(rgb)
142 return image.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
144 scope = FrameScope.cells(sorted({frame for frame, _layer in targets}), (cells[0][1],))
145 result = self.window.scope.run_over(
146 scope, op, label=tr("自動で塗る"), cancellable=True,
147 )
148 canvas.update()
149 if result.cancelled: 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true
150 status.showMessage(tr("自動で塗るのを中止しました。"), 2400)
151 return False
152 if written: 152 ↛ 154line 152 didn't jump to line 154 because the condition on line 152 was always true
153 canvas._emit_actual_paint_colors(tuple(sorted(written)))
154 entries = self.notes(cells, plan)
155 message = tr("自動で塗る:{cells}セルを塗りました。").format(cells=result.changed_cells)
156 if plan.rounds > 1: 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true
157 message += tr("見本を{added}セルに増やして{rounds}回塗り直しました。").format(
158 added=len(plan.references), rounds=plan.rounds - 1,
159 )
160 if entries: 160 ↛ 162line 160 didn't jump to line 162 because the condition on line 160 was always true
161 message += tr("確かめたい所が{count}か所あります。").format(count=len(entries))
162 status.showMessage(message, 8000)
163 if entries: 163 ↛ 174line 163 didn't jump to line 174 because the condition on line 163 was always true
164 self.window.reference_check.show_list(
165 entries, cells[reference_index][0], title=tr("自動で塗った、確かめたい所"),
166 intro=tr(
167 "塗り残し(場所や色を決めきれなかった部分)、新しく出てきた部分(開いた口"
168 "など、手本に無い形)、色を決められなかった色トレス線と、前後のセルと違う色で"
169 "塗ってあった部分(塗り替えていません)です。行を選ぶと、そのコマへ移って"
170 "場所を選択します。"
171 ),
172 reference_only=[],
173 )
174 return bool(result.changed)
176 @staticmethod
177 def notes(cells: List[Cell], plan: auto_paint.AutoPlan):
178 """一覧に載せる ``(コマ, レイヤー, 見つかったもの)`` の並び。"""
179 entries = []
180 for index in sorted(plan.cells):
181 frame, layer = cells[index]
182 for finding in plan.cells[index].notes:
183 entries.append((frame, layer, finding))
184 return entries
186 def _plan(self, pixels, reference_index, hints=None,
187 lines=None, second_reference_index=None,
188 reference_indices=None) -> Optional[auto_paint.AutoPlan]:
189 references = {reference_index}
190 references.update(reference_indices or [])
191 if second_reference_index is not None:
192 references.add(second_reference_index)
193 count = sum(index not in references and image.shape == pixels[reference_index].shape
194 for index, image in enumerate(pixels))
195 total_steps = max(1, count * auto_paint.MAX_ROUNDS)
196 started = monotonic()
197 dialog = create_counter(
198 self.window, tr("自動で塗る"), total_steps,
199 label=tr("全体 0%・残り時間を計算中"), cancellable=True,
200 )
202 def progress(done, total):
203 if dialog.wasCanceled(): 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
204 return False
205 total = max(1, total)
206 value = max(0, min(done, total))
207 percent = value * 100 // total
208 if done >= total:
209 label = tr("自動塗りの計算が終わりました")
210 else:
211 label = tr("{round}回目:対象{count}セルを塗っています").format(
212 round=done // max(1, count) + 1, count=count,
213 )
214 if value:
215 remaining = math.ceil(max(0.0, monotonic() - started) / value * (total - value))
216 minutes, seconds = divmod(remaining, 60)
217 timing = tr("全体 {percent}%・残り約{minutes}分{seconds:02d}秒").format(
218 percent=percent, minutes=minutes, seconds=seconds,
219 )
220 else:
221 timing = tr("全体 0%・残り時間を計算中")
222 label = f"{label}\n{timing}"
223 self.window.statusBar().showMessage(label.replace("\n", " "))
224 update_counter(dialog, value, total, label)
225 return True
227 try:
228 return auto_paint.plan_auto_paint(
229 pixels, reference_index, progress=progress, hints=hints, lines=lines,
230 second_reference_index=second_reference_index,
231 reference_indices=reference_indices,
232 bucket_options=replace(
233 self.window.canvas.bucket_options(respect_modifiers=False),
234 adjacent=True,
235 reference=bucket_fill.REFERENCE_INITIAL,
236 ),
237 )
238 finally:
239 close_counter(dialog)
240 self.window.statusBar().clearMessage()