Coverage for paintmaskanimator/main_window_reference_check.py: 83%
186 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.reference_check`` が持つ。
3アクティブレイヤーの今のセルを見本にして、ほかのセルについて
5* 見本にあってそのセルに無い色、見本に無いのにそのセルにある色
6 (:func:`reference_check.compare_colors`)
7* 前後のセルの同じ部分と色が違う部分(:func:`reference_check.find_mismatches`)
9を一覧にする。一覧の行を選ぶと、そのコマへ移ってその場所を選択範囲にする。
10直すのは人の仕事で、ここは何も書き換えない。
12A collaborator rather than a mixin -- see main_window_export.py for why.
13"""
14from typing import TYPE_CHECKING, List, Optional, Tuple
16import numpy as np
17from PySide6.QtCore import Qt
18from PySide6.QtGui import QColor, QPixmap, QIcon
19from PySide6.QtWidgets import (
20 QDialog, QDialogButtonBox, QLabel, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
21)
23from . import reference_check
24from .frame_scope import FrameScope, resolve_cells
25from .i18n import tr
26from .time_display import frame_position
27from .imaging import qimage_rgba_array
28from .progress import close_counter, create_counter, update_counter
30if TYPE_CHECKING:
31 from .main_window import MainWindow
33#: 一覧に載せる1件:(コマ, レイヤー, 見つかったもの)。
34Entry = Tuple[int, int, reference_check.Finding]
37def _cell_pixels(image):
38 """セル画像を RGBA の ``(高さ, 幅, 4)`` 配列にする。"""
39 return np.ascontiguousarray(qimage_rgba_array(image))
42def _color_label(rgb) -> str:
43 if rgb is None:
44 return tr("透明")
45 return "#{:02X}{:02X}{:02X}".format(*rgb)
48def _swatch(rgb) -> QIcon:
49 pixmap = QPixmap(14, 14)
50 if rgb is None:
51 pixmap.fill(Qt.GlobalColor.transparent)
52 else:
53 pixmap.fill(QColor(*rgb))
54 return QIcon(pixmap)
57def _split_reference_only(entries: List[Entry], compared: int):
58 """半分を超えるセルに無い見本の色は、見本だけの印(色指定のメモなど)とみなす。
60 セルごとに並べると一覧が埋まるので、1行の要約へまとめる。
61 """
62 missing = {}
63 for _frame, _layer, finding in entries:
64 if finding.kind == reference_check.KIND_MISSING:
65 missing[finding.rgb] = missing.get(finding.rgb, 0) + 1
66 common = {rgb for rgb, count in missing.items() if count * 2 > compared}
67 kept = [
68 entry for entry in entries
69 if not (entry[2].kind == reference_check.KIND_MISSING and entry[2].rgb in common)
70 ]
71 summary = [(rgb, missing[rgb], compared) for rgb in sorted(common)]
72 return kept, summary
75class ReferenceCheckDialog(QDialog):
76 """チェック結果の一覧。モードレスで、開いたまま絵を直せる。"""
78 def __init__(self, controller: "ReferenceCheckController", entries: List[Entry],
79 reference_frame: int, title: Optional[str] = None,
80 intro: Optional[str] = None, reference_only=None):
81 super().__init__(controller.window)
82 self.controller = controller
83 self.entries = entries
84 fps = int(controller.window.timeline.fps.value())
85 self.setWindowTitle(title or tr("見本との違いチェック"))
86 self.setModal(False)
87 self.resize(520, 420)
88 layout = QVBoxLayout(self)
89 intro_label = QLabel(intro or tr(
90 "見本:{frame}。行を選ぶと、そのコマへ移って場所を選択します。"
91 ).format(frame=frame_position(reference_frame, fps)))
92 intro_label.setWordWrap(True)
93 layout.addWidget(intro_label)
94 if reference_only is None:
95 reference_only = controller.reference_only
96 if reference_only: 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true
97 colors = tr("、").join(
98 tr("{color}({missing}/{compared}セルに無し)").format(
99 color=_color_label(rgb), missing=missing, compared=compared,
100 )
101 for rgb, missing, compared in reference_only
102 )
103 note = QLabel(tr("見本だけにある色:{colors}").format(colors=colors))
104 note.setWordWrap(True)
105 layout.addWidget(note)
106 self.tree = QTreeWidget()
107 self.tree.setHeaderLabels([tr("コマ"), tr("内容"), tr("色"), tr("画素数")])
108 self.tree.setRootIsDecorated(False)
109 for row, (frame_index, _layer, finding) in enumerate(entries):
110 item = QTreeWidgetItem([
111 frame_position(frame_index, fps),
112 self._describe(finding),
113 _color_label(finding.rgb),
114 f"{finding.pixels:,}",
115 ])
116 item.setIcon(2, _swatch(finding.rgb))
117 item.setData(0, Qt.ItemDataRole.UserRole, row)
118 self.tree.addTopLevelItem(item)
119 for column in range(4):
120 self.tree.resizeColumnToContents(column)
121 # 表示時にQtが先頭行を現在項目にしても、キャンバスの選択を変更しない。
122 # 場所へ移るのはユーザーが行をクリック/明示的に実行したときだけ。
123 self.tree.itemClicked.connect(self._on_current)
124 self.tree.itemActivated.connect(self._on_current)
125 layout.addWidget(self.tree)
126 buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
127 buttons.rejected.connect(self.close)
128 layout.addWidget(buttons)
130 @staticmethod
131 def _describe(finding) -> str:
132 if finding.kind == reference_check.KIND_MISSING:
133 return tr("見本の色がありません")
134 if finding.kind == reference_check.KIND_EXTRA:
135 return tr("見本に無い色があります")
136 if finding.kind == reference_check.KIND_UNPAINTED:
137 return tr("塗り残し(決めきれず塗っていません)")
138 if finding.kind == reference_check.KIND_NEW_PART: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 return tr("新しく出てきた部分(手本に無い形なので塗っていません)")
140 if finding.kind == reference_check.KIND_UNDECIDED_LINE:
141 return tr("色を決められなかった色トレス線(線のまま残しています)")
142 if finding.kind == reference_check.KIND_SUSPECT: 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true
143 return tr("塗り間違いかも:前後のセルでは {color}").format(
144 color=_color_label(finding.expected)
145 )
146 return tr("前後のコマでは {color}").format(color=_color_label(finding.expected))
148 def _on_current(self, item, _previous=None):
149 if item is None: 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true
150 return
151 row = int(item.data(0, Qt.ItemDataRole.UserRole))
152 self.controller.show_entry(self.entries[row])
155class ReferenceCheckController:
156 """Owned by ``MainWindow`` as ``window.reference_check``."""
158 def __init__(self, window: "MainWindow"):
159 self.window = window
160 self.dialog: Optional[ReferenceCheckDialog] = None
161 #: 見本にはあるが、ほとんどのセルに無い色:(色, 無いセルの数, 比べたセルの数)。
162 self.reference_only: List[Tuple[Tuple[int, int, int], int, int]] = []
164 def _cells(self) -> List[Tuple[int, int]]:
165 canvas = self.window.canvas
166 cells = resolve_cells(canvas, FrameScope.all_frames(canvas))
167 return [
168 (frame, layer) for frame, layer in cells
169 if canvas.frames[frame].layers[layer].has_content
170 ]
172 def run(self) -> None:
173 """今のセルを見本にして、アクティブレイヤーの全セルを調べる。"""
174 canvas = self.window.canvas
175 status = self.window.statusBar()
176 layer_index = int(canvas.active_layer_index)
177 reference_frame = canvas.resolve_key_frame(int(canvas.current_frame), layer_index)
178 cells = self._cells()
179 if reference_frame is None or (reference_frame, layer_index) not in cells: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 status.showMessage(tr("見本にするセル(塗ってあるコマ)を表示してから実行してください。"), 3200)
181 return
182 if len(cells) < 2: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true
183 status.showMessage(tr("比べるセルがありません。"), 2800)
184 return
185 entries = self.collect(cells, (reference_frame, layer_index))
186 if entries is None: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true
187 status.showMessage(tr("チェックを中止しました。"), 2400)
188 return
189 if not entries and not self.reference_only: 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true
190 status.showMessage(tr("見本との違い・塗り間違いは見つかりませんでした。"), 3200)
191 return
192 self.show_list(entries, reference_frame)
193 status.showMessage(tr("{count}件の候補が見つかりました。").format(count=len(entries)), 3200)
195 def show_list(self, entries: List[Entry], reference_frame: int,
196 title: Optional[str] = None, intro: Optional[str] = None,
197 reference_only=None) -> None:
198 """一覧を開く(前の一覧は閉じる)。自動塗りの「確かめたい所」の一覧にも使う。"""
199 if self.dialog is not None: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 self.dialog.close()
201 self.dialog.deleteLater()
202 self.dialog = ReferenceCheckDialog(
203 self, entries, reference_frame, title=title, intro=intro,
204 reference_only=reference_only,
205 )
206 self.dialog.show()
208 def collect(self, cells, reference) -> Optional[List[Entry]]:
209 """候補を集める。中止されたら ``None``。"""
210 canvas = self.window.canvas
211 title = tr("見本との違いチェック")
212 total = len(cells) * 2
213 dialog = create_counter(self.window, title, total, cancellable=True)
214 try:
215 pixels = {}
216 for done, (frame, layer) in enumerate(cells):
217 if dialog.wasCanceled(): 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true
218 return None
219 update_counter(dialog, done, total, tr("色を数えています"))
220 pixels[(frame, layer)] = _cell_pixels(canvas.frames[frame].layers[layer].image)
221 entries: List[Entry] = []
222 reference_pixels = pixels[reference]
223 for cell in cells:
224 if cell == reference:
225 continue
226 for finding in reference_check.compare_colors(reference_pixels, pixels[cell]):
227 entries.append((cell[0], cell[1], finding))
228 entries, self.reference_only = _split_reference_only(entries, len(cells) - 1)
229 # 前後のセルとの比べ合い。部分分けは重いので、3つ分だけ持っておく。
230 regions = {}
231 for position in range(1, len(cells) - 1):
232 if dialog.wasCanceled(): 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true
233 return None
234 update_counter(dialog, len(cells) + position, total, tr("前後のコマと比べています"))
235 window = cells[position - 1:position + 2]
236 for key in list(regions): 236 ↛ 237line 236 didn't jump to line 237 because the loop on line 236 never started
237 if key not in window:
238 del regions[key]
239 for key in window:
240 if key not in regions: 240 ↛ 239line 240 didn't jump to line 239 because the condition on line 240 was always true
241 regions[key] = reference_check.CellRegions(pixels[key])
242 previous, current, following = (regions[key] for key in window)
243 for finding in reference_check.find_mismatches(previous, current, following):
244 entries.append((window[1][0], window[1][1], finding))
245 entries.sort(key=lambda entry: (entry[0], entry[2].kind))
246 return entries
247 finally:
248 close_counter(dialog)
250 def show_entry(self, entry: Entry) -> None:
251 """そのコマへ移り、見つかった場所を選択範囲にする。"""
252 frame, layer, finding = entry
253 canvas = self.window.canvas
254 canvas.current_frame = int(frame)
255 canvas.active_layer_index = int(layer)
256 canvas.selectionChanged.emit()
257 mask = self._finding_mask(canvas, frame, layer, finding)
258 if mask is not None: 258 ↛ 261line 258 didn't jump to line 261 because the condition on line 258 was always true
259 canvas.set_selection_mask(mask)
260 else:
261 canvas.clear_selection()
262 canvas.update()
264 @staticmethod
265 def _finding_mask(canvas, frame, layer, finding):
266 if finding.bounds is None: 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true
267 return None
268 x0, y0, x1, y1 = finding.bounds
269 image = canvas.frames[frame].layers[layer].image
270 mask = np.zeros((image.height(), image.width()), dtype=bool)
271 if finding.mask is not None: 271 ↛ 275line 271 didn't jump to line 275 because the condition on line 271 was always true
272 mask[y0:y1, x0:x1] = finding.mask
273 else:
274 # 色そのものの画素を選ぶ(見本に無い色)。
275 pixels = _cell_pixels(image)[y0:y1, x0:x1]
276 mask[y0:y1, x0:x1] = reference_check.color_mask(pixels, finding.rgb)
277 return mask