Coverage for paintmaskanimator/auto_paint_dialog.py: 95%

310 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""完成したセルを絵で見比べて選ぶ、自動塗りの設定画面。""" 

2from typing import List, Optional, Tuple 

3 

4import numpy as np 

5from PySide6.QtCore import QItemSelectionModel, QPointF, QRect, QRectF, QSize, Qt 

6from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPen, QPixmap 

7from PySide6.QtWidgets import ( 

8 QAbstractItemView, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit, 

9 QListView, QListWidget, QListWidgetItem, QPushButton, QSizePolicy, QSlider, 

10 QSplitter, QStyledItemDelegate, QStyle, QVBoxLayout, QWidget, 

11) 

12 

13from .i18n import tr 

14from .imaging import qimage_rgba_array 

15from .time_display import frame_position 

16 

17Cell = Tuple[int, int] 

18TITLE_ROLE = int(Qt.ItemDataRole.UserRole) + 1 

19FRAME_ROLE = TITLE_ROLE + 1 

20 

21 

22def _content_rect(image: QImage) -> QRect: 

23 alpha = qimage_rgba_array(image)[:, :, 3] != 0 

24 ys, xs = np.flatnonzero(alpha.any(axis=1)), np.flatnonzero(alpha.any(axis=0)) 

25 if not ys.size or not xs.size: 25 ↛ 26line 25 didn't jump to line 26 because the condition on line 25 was never true

26 return image.rect() 

27 return QRect(int(xs[0]), int(ys[0]), int(xs[-1] - xs[0] + 1), int(ys[-1] - ys[0] + 1)) 

28 

29 

30def _draw_image(painter, bounds, image, source=None): 

31 """テーマによらず黒い線が見える、明るい透過背景。""" 

32 painter.fillRect(bounds, QColor("#ffffff")) 

33 painter.save() 

34 painter.setClipRect(bounds) 

35 for y in range(bounds.top(), bounds.bottom() + 1, 12): 

36 for x in range(bounds.left(), bounds.right() + 1, 12): 

37 if ((x - bounds.left()) // 12 + (y - bounds.top()) // 12) % 2 == 0: 

38 painter.fillRect(x, y, 12, 12, QColor("#eeeeee")) 

39 if not image.isNull(): 39 ↛ 47line 39 didn't jump to line 47 because the condition on line 39 was always true

40 source = image.rect() if source is None else source 

41 size = source.size().scaled(bounds.size() - QSize(12, 12), 

42 Qt.AspectRatioMode.KeepAspectRatio) 

43 rect = QRectF(0, 0, size.width(), size.height()) 

44 rect.moveCenter(QRectF(bounds).center()) 

45 painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) 

46 painter.drawImage(rect, image, QRectF(source)) 

47 painter.restore() 

48 

49 

50class _ImagePreview(QWidget): 

51 def __init__(self, parent=None): 

52 super().__init__(parent) 

53 self.image = QImage() 

54 self.source = QRect() 

55 self.setMinimumSize(200, 180) 

56 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 

57 

58 def set_image(self, image, source=None): 

59 self.image = image 

60 self.source = image.rect() if source is None else source 

61 self.update() 

62 

63 def paintEvent(self, event): # noqa: N802 - Qt override 

64 painter = QPainter(self) 

65 _draw_image(painter, self.rect(), self.image, self.source) 

66 painter.end() 

67 

68 

69class _ReferenceGrid(QListWidget): 

70 def keyPressEvent(self, event): # noqa: N802 - Qt override 

71 if event.key() == Qt.Key.Key_Space and self.currentItem() is not None: 71 ↛ 76line 71 didn't jump to line 76 because the condition on line 71 was always true

72 item = self.currentItem() 

73 item.setSelected(not item.isSelected()) 

74 event.accept() 

75 return 

76 super().keyPressEvent(event) 

77 

78 

79class _SearchBox(QLineEdit): 

80 def keyPressEvent(self, event): # noqa: N802 - Qt override 

81 # 検索を確定するつもりのEnterで、自動塗りを実行しない。 

82 if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): 82 ↛ 85line 82 didn't jump to line 85 because the condition on line 82 was always true

83 event.accept() 

84 return 

85 super().keyPressEvent(event) 

86 

87 

88class _CardDelegate(QStyledItemDelegate): 

89 def __init__(self, grid): 

90 super().__init__(grid) 

91 self.grid = grid 

92 

93 def sizeHint(self, option, index): # noqa: N802 - Qt override 

94 return self.grid.gridSize() - QSize(2, 2) 

95 

96 def paint(self, painter, option, index): 

97 painter.save() 

98 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 

99 rect = option.rect.adjusted(4, 4, -4, -4) 

100 checked = index.data(Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked 

101 # QVariant may expose CheckStateRole as an int on older PySide6 builds. 

102 checked = checked or index.data(Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked.value 

103 accent = option.palette.highlight().color() 

104 background = option.palette.base().color() 

105 border = option.palette.mid().color() 

106 if checked: 

107 background = option.palette.alternateBase().color() 

108 border = accent 

109 elif option.state & (QStyle.StateFlag.State_MouseOver | QStyle.StateFlag.State_HasFocus): 

110 border = option.palette.text().color() 

111 painter.setBrush(background) 

112 painter.setPen(QPen(border, 2 if checked else 1)) 

113 painter.drawRoundedRect(QRectF(rect), 9, 9) 

114 image_rect = rect.adjusted(9, 9, -9, -49) 

115 icon = index.data(Qt.ItemDataRole.DecorationRole) 

116 if isinstance(icon, QIcon): 116 ↛ 118line 116 didn't jump to line 118 because the condition on line 116 was always true

117 icon.paint(painter, image_rect, Qt.AlignmentFlag.AlignCenter) 

118 font = option.font 

119 font.setBold(True) 

120 painter.setFont(font) 

121 painter.setPen(option.palette.text().color()) 

122 painter.drawText(rect.adjusted(10, rect.height() - 43, -10, -23), 

123 Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, 

124 str(index.data(TITLE_ROLE))) 

125 font.setBold(False) 

126 painter.setFont(font) 

127 painter.setPen(option.palette.placeholderText().color()) 

128 painter.drawText(rect.adjusted(10, rect.height() - 22, -10, -3), 

129 Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, 

130 str(index.data(FRAME_ROLE))) 

131 if checked: 

132 badge = QRectF(rect.right() - 29, rect.top() + 7, 23, 23) 

133 painter.setPen(Qt.PenStyle.NoPen) 

134 painter.setBrush(accent) 

135 painter.drawEllipse(badge) 

136 painter.setPen(QPen(option.palette.highlightedText().color(), 2.2)) 

137 x, y = badge.left(), badge.top() 

138 painter.drawLine(QPointF(x + 6, y + 12), QPointF(x + 10, y + 16)) 

139 painter.drawLine(QPointF(x + 10, y + 16), QPointF(x + 17, y + 7)) 

140 painter.restore() 

141 

142 

143class AutoPaintDialog(QDialog): 

144 def __init__(self, parent, layer_name: str, cells: List[Cell], labels: List[str], 

145 current: int, note: str = "", images: Optional[List[QImage]] = None, 

146 numbers: Optional[List[Optional[int]]] = None, fps: int = 24): 

147 super().__init__(parent) 

148 self.cells, self.labels = cells, labels 

149 # 全セルの切り抜き画像を複製せず、元画像と表示する矩形だけを保持する。 

150 self.images = images or [] 

151 self.image_bounds = [_content_rect(image) for image in self.images] 

152 self.setWindowTitle(tr("自動で塗る")) 

153 self.resize(1060, 740) 

154 self.setMinimumSize(780, 580) 

155 layout = QVBoxLayout(self) 

156 layout.setContentsMargins(18, 16, 18, 16) 

157 layout.setSpacing(10) 

158 splitter = QSplitter(Qt.Orientation.Horizontal) 

159 gallery = QWidget() 

160 gallery_layout = QVBoxLayout(gallery) 

161 gallery_layout.setContentsMargins(0, 0, 0, 0) 

162 toolbar = QHBoxLayout() 

163 self.search = _SearchBox() 

164 self.search.setPlaceholderText(tr("コマを検索")) 

165 self.search.setClearButtonEnabled(True) 

166 toolbar.addWidget(self.search, 1) 

167 for text, row in ((tr("先頭へ"), 0), (tr("末尾へ"), len(cells) - 1)): 

168 button = QPushButton(text) 

169 button.setAutoDefault(False) 

170 button.setObjectName("reference-first" if row == 0 else "reference-last") 

171 button.clicked.connect(lambda _checked=False, row=row: self._inspect(row, reveal=True)) 

172 toolbar.addWidget(button) 

173 gallery_layout.addLayout(toolbar) 

174 self.references = _ReferenceGrid() 

175 self.references.setAccessibleName(tr("見本にするコマ")) 

176 self.references.setViewMode(QListView.ViewMode.IconMode) 

177 self.references.setResizeMode(QListView.ResizeMode.Adjust) 

178 self.references.setMovement(QListView.Movement.Static) 

179 self.references.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) 

180 self.references.setMouseTracking(True) 

181 self.references.setItemDelegate(_CardDelegate(self.references)) 

182 self.references.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) 

183 self._set_card_size(158) 

184 for index, label in enumerate(labels): 

185 item = QListWidgetItem(label, self.references) 

186 item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable) 

187 frame_text = frame_position(cells[index][0], fps) 

188 number = numbers[index] if numbers else None 

189 item.setData(TITLE_ROLE, f"{layer_name}{number if number is not None else index + 1}") 

190 item.setData(FRAME_ROLE, frame_text) 

191 item.setToolTip(label) 

192 item.setCheckState(Qt.CheckState.Checked if index == current else Qt.CheckState.Unchecked) 

193 if index < len(self.images): 193 ↛ 199line 193 didn't jump to line 199 because the condition on line 193 was always true

194 pixmap = QPixmap(280, 240) 

195 painter = QPainter(pixmap) 

196 _draw_image(painter, pixmap.rect(), self.images[index], self.image_bounds[index]) 

197 painter.end() 

198 item.setIcon(QIcon(pixmap)) 

199 item.setSelected(index == current) 

200 gallery_layout.addWidget(self.references, 1) 

201 zoom = QHBoxLayout() 

202 zoom.addWidget(QLabel(tr("表示サイズ"))) 

203 self.card_size = QSlider(Qt.Orientation.Horizontal) 

204 self.card_size.setRange(130, 230) 

205 self.card_size.setValue(158) 

206 self.card_size.setMaximumWidth(180) 

207 self.card_size.setAccessibleName(tr("サムネイルの大きさ")) 

208 self.card_size.valueChanged.connect(self._set_card_size) 

209 zoom.addWidget(self.card_size) 

210 zoom.addStretch() 

211 self.visible_status = QLabel() 

212 zoom.addWidget(self.visible_status) 

213 gallery_layout.addLayout(zoom) 

214 splitter.addWidget(gallery) 

215 

216 side = QWidget() 

217 side.setMinimumWidth(240) 

218 side_layout = QVBoxLayout(side) 

219 side_layout.setContentsMargins(12, 0, 0, 0) 

220 preview_header = QLabel(tr("プレビュー")) 

221 preview_header_font = preview_header.font() 

222 preview_header_font.setBold(True) 

223 preview_header.setFont(preview_header_font) 

224 side_layout.addWidget(preview_header) 

225 self.preview_title = QLabel() 

226 side_layout.addWidget(self.preview_title) 

227 self.preview = _ImagePreview() 

228 side_layout.addWidget(self.preview, 1) 

229 self.toggle_reference = QPushButton() 

230 self.toggle_reference.setAutoDefault(False) 

231 self.toggle_reference.clicked.connect(self._toggle_current) 

232 side_layout.addWidget(self.toggle_reference) 

233 selected_header = QHBoxLayout() 

234 self.selection_status = QLabel() 

235 selected_header.addWidget(self.selection_status, 1) 

236 self.clear_button = QPushButton(tr("すべて解除")) 

237 self.clear_button.setAutoDefault(False) 

238 self.clear_button.clicked.connect(self._clear) 

239 selected_header.addWidget(self.clear_button) 

240 side_layout.addLayout(selected_header) 

241 self.selected_refs = QListWidget() 

242 self.selected_refs.setAccessibleName(tr("選んだ見本")) 

243 self.selected_refs.setIconSize(QSize(42, 36)) 

244 self.selected_refs.setMinimumHeight(100) 

245 self.selected_refs.setMaximumHeight(160) 

246 self.selected_refs.itemClicked.connect( 

247 lambda item, _column=0: self._inspect(item.data(Qt.ItemDataRole.UserRole), reveal=True)) 

248 side_layout.addWidget(self.selected_refs) 

249 self.remove_button = QPushButton(tr("選んだ見本から外す")) 

250 self.remove_button.setAutoDefault(False) 

251 self.remove_button.clicked.connect(self._remove_selected) 

252 side_layout.addWidget(self.remove_button) 

253 splitter.addWidget(side) 

254 splitter.setStretchFactor(0, 3) 

255 splitter.setStretchFactor(1, 1) 

256 splitter.setSizes([710, 300]) 

257 layout.addWidget(splitter, 1) 

258 

259 footer = QHBoxLayout() 

260 self.summary = QLabel() 

261 footer.addWidget(self.summary, 1) 

262 buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel) 

263 buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(tr("キャンセル")) 

264 self.run_button = buttons.addButton(tr("この見本で塗る"), QDialogButtonBox.ButtonRole.AcceptRole) 

265 self.run_button.setMinimumSize(150, 34) 

266 self.run_button.setDefault(True) 

267 buttons.accepted.connect(self.accept) 

268 buttons.rejected.connect(self.reject) 

269 footer.addWidget(buttons) 

270 layout.addLayout(footer) 

271 self.references.itemSelectionChanged.connect(self._from_selection) 

272 self.references.itemChanged.connect(self._from_checks) 

273 self.references.currentRowChanged.connect(self._inspect) 

274 self.search.textChanged.connect(self._filter) 

275 self._selection_changed() 

276 self._inspect(current, reveal=True) 

277 self._filter("") 

278 

279 def _set_card_size(self, width): 

280 self.references.setGridSize(QSize(width + 10, int(width * 0.83) + 60)) 

281 self.references.setIconSize(QSize(width - 16, int(width * 0.83))) 

282 

283 def chosen(self) -> List[Cell]: 

284 return [self.cells[i] for i in range(len(self.cells)) 

285 if self.references.item(i).checkState() == Qt.CheckState.Checked] 

286 

287 def _from_selection(self): 

288 self.references.blockSignals(True) 

289 for i in range(len(self.cells)): 

290 item = self.references.item(i) 

291 item.setCheckState(Qt.CheckState.Checked if item.isSelected() else Qt.CheckState.Unchecked) 

292 self.references.blockSignals(False) 

293 self._selection_changed() 

294 

295 def _from_checks(self, item): 

296 self.references.blockSignals(True) 

297 item.setSelected(item.checkState() == Qt.CheckState.Checked) 

298 self.references.blockSignals(False) 

299 self._selection_changed() 

300 

301 def _selection_changed(self): 

302 indices = [i for i in range(len(self.cells)) 

303 if self.references.item(i).checkState() == Qt.CheckState.Checked] 

304 self.selected_refs.clear() 

305 for i in indices: 

306 entry = QListWidgetItem(self.references.item(i).icon(), self.labels[i], self.selected_refs) 

307 entry.setData(Qt.ItemDataRole.UserRole, i) 

308 count = len(indices) 

309 self.selection_status.setText(tr("選んだ見本 {count}枚").format(count=count)) 

310 self.clear_button.setEnabled(count > 0) 

311 self.remove_button.setEnabled(count > 0) 

312 self.run_button.setEnabled(0 < count < len(self.cells)) 

313 if count == 0: 

314 text = tr("見本を選択してください") 

315 elif count == len(self.cells): 

316 text = tr("塗るコマを残してください") 

317 else: 

318 text = tr("見本 {references}枚 · 塗るコマ {targets}枚").format( 

319 references=count, targets=len(self.cells) - count) 

320 self.summary.setText(text) 

321 self._update_toggle() 

322 self.references.viewport().update() 

323 

324 def _inspect(self, row, reveal=False): 

325 if not 0 <= row < len(self.cells): 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true

326 return 

327 self.preview_title.setText(self.labels[row]) 

328 if row < len(self.images): 328 ↛ 331line 328 didn't jump to line 331 because the condition on line 328 was always true

329 self.preview.set_image(self.images[row], self.image_bounds[row]) 

330 else: 

331 self.preview.set_image(QImage()) 

332 if reveal: 

333 self.search.clear() 

334 self.references.setCurrentRow(row, QItemSelectionModel.SelectionFlag.NoUpdate) 

335 self.references.scrollToItem(self.references.item(row)) 

336 self._update_toggle() 

337 

338 def _update_toggle(self): 

339 item = self.references.currentItem() 

340 checked = item is not None and item.checkState() == Qt.CheckState.Checked 

341 self.toggle_reference.setText(tr("見本から外す") if checked else tr("見本に追加")) 

342 self.toggle_reference.setEnabled(item is not None) 

343 

344 def _toggle_current(self): 

345 item = self.references.currentItem() 

346 if item is not None: 346 ↛ exitline 346 didn't return from function '_toggle_current' because the condition on line 346 was always true

347 item.setSelected(not item.isSelected()) 

348 

349 def _remove_selected(self): 

350 item = self.selected_refs.currentItem() 

351 if item is None and self.selected_refs.count(): 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true

352 item = self.selected_refs.item(0) 

353 if item is not None: 353 ↛ exitline 353 didn't return from function '_remove_selected' because the condition on line 353 was always true

354 self.references.item(item.data(Qt.ItemDataRole.UserRole)).setSelected(False) 

355 

356 def _clear(self): 

357 self.references.clearSelection() 

358 

359 def _filter(self, text): 

360 text = text.strip().casefold() 

361 visible = 0 

362 for i, label in enumerate(self.labels): 

363 hidden = text not in label.casefold() 

364 self.references.item(i).setHidden(hidden) 

365 visible += not hidden 

366 self.visible_status.setText(tr("表示 {visible} / {total}コマ").format( 

367 visible=visible, total=len(self.cells)))