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

1"""見本との違い・塗り間違いのチェック。``window.reference_check`` が持つ。 

2 

3アクティブレイヤーの今のセルを見本にして、ほかのセルについて 

4 

5* 見本にあってそのセルに無い色、見本に無いのにそのセルにある色 

6 (:func:`reference_check.compare_colors`) 

7* 前後のセルの同じ部分と色が違う部分(:func:`reference_check.find_mismatches`) 

8 

9を一覧にする。一覧の行を選ぶと、そのコマへ移ってその場所を選択範囲にする。 

10直すのは人の仕事で、ここは何も書き換えない。 

11 

12A collaborator rather than a mixin -- see main_window_export.py for why. 

13""" 

14from typing import TYPE_CHECKING, List, Optional, Tuple 

15 

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) 

22 

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 

29 

30if TYPE_CHECKING: 

31 from .main_window import MainWindow 

32 

33#: 一覧に載せる1件:(コマ, レイヤー, 見つかったもの)。 

34Entry = Tuple[int, int, reference_check.Finding] 

35 

36 

37def _cell_pixels(image): 

38 """セル画像を RGBA の ``(高さ, 幅, 4)`` 配列にする。""" 

39 return np.ascontiguousarray(qimage_rgba_array(image)) 

40 

41 

42def _color_label(rgb) -> str: 

43 if rgb is None: 

44 return tr("透明") 

45 return "#{:02X}{:02X}{:02X}".format(*rgb) 

46 

47 

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) 

55 

56 

57def _split_reference_only(entries: List[Entry], compared: int): 

58 """半分を超えるセルに無い見本の色は、見本だけの印(色指定のメモなど)とみなす。 

59 

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 

73 

74 

75class ReferenceCheckDialog(QDialog): 

76 """チェック結果の一覧。モードレスで、開いたまま絵を直せる。""" 

77 

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) 

129 

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

147 

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

153 

154 

155class ReferenceCheckController: 

156 """Owned by ``MainWindow`` as ``window.reference_check``.""" 

157 

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

163 

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 ] 

171 

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) 

194 

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

207 

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) 

249 

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

263 

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