Coverage for paintmaskanimator/color_reduction.py: 8%

643 statements  

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

1import numpy as np 

2import time 

3from PySide6.QtCore import QEvent, QPointF, QRectF, QTimer, Qt, Signal 

4from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QPixmap 

5from PySide6.QtWidgets import ( 

6 QApplication, 

7 QComboBox, 

8 QDialog, 

9 QFormLayout, 

10 QHBoxLayout, 

11 QLabel, 

12 QMessageBox, 

13 QProgressDialog, 

14 QPushButton, 

15 QScrollArea, 

16 QSlider, 

17 QSpinBox, 

18 QVBoxLayout, 

19 QWidget, 

20) 

21from .i18n import tr 

22from .canvas import PaintCanvas 

23from .errors import OperationError 

24from .logging_setup import get_logger 

25 

26log = get_logger(__name__) 

27 

28 

29class ToneCurveWidget(QWidget): 

30 """両端固定・複数制御点対応の軽量トーンカーブ。""" 

31 

32 curveChanged = Signal(object) 

33 interactionStarted = Signal() 

34 interactionFinished = Signal() 

35 

36 def __init__(self, parent=None): 

37 super().__init__(parent) 

38 self._points = [ 

39 QPointF(0.0, 0.0), 

40 QPointF(1.0, 1.0), 

41 ] 

42 self._active_index = -1 

43 self._dragging = False 

44 self.setMinimumWidth(260) 

45 self.setFixedHeight(112) 

46 self.setCursor(Qt.CursorShape.CrossCursor) 

47 self.setToolTip( 

48 tr("空いている場所をクリック:点を追加/" 

49 "点をドラッグ:濃さを調整/" 

50 "右クリック:中間点を削除/" 

51 "ダブルクリック:標準へ戻す") 

52 ) 

53 

54 def points(self): 

55 return [ 

56 ( 

57 round(float(point.x()), 5), 

58 round(float(point.y()), 5), 

59 ) 

60 for point in self._points 

61 ] 

62 

63 def setPoints(self, points): 

64 normalized = self._normalized_points(points) 

65 if normalized == self.points(): 

66 return 

67 self._points = [ 

68 QPointF(x, y) for x, y in normalized 

69 ] 

70 self._active_index = -1 

71 self.update() 

72 self.curveChanged.emit(self.points()) 

73 

74 def resetCurve(self): 

75 self.setPoints([(0.0, 0.0), (1.0, 1.0)]) 

76 

77 @staticmethod 

78 def _normalized_points(points): 

79 normalized = [] 

80 try: 

81 iterable = list(points) 

82 except TypeError: 

83 iterable = [] 

84 

85 for point in iterable: 

86 try: 

87 if isinstance(point, QPointF): 

88 x, y = point.x(), point.y() 

89 else: 

90 x, y = point[:2] 

91 x = max(0.0, min(1.0, float(x))) 

92 y = max(0.0, min(1.0, float(y))) 

93 normalized.append((x, y)) 

94 except (TypeError, ValueError, IndexError): 

95 continue 

96 

97 normalized.extend([(0.0, 0.0), (1.0, 1.0)]) 

98 normalized.sort(key=lambda value: value[0]) 

99 

100 merged = [] 

101 for x, y in normalized: 

102 if merged and abs(x - merged[-1][0]) < 1e-5: 

103 merged[-1] = (x, y) 

104 else: 

105 merged.append((x, y)) 

106 

107 if not merged or merged[0][0] > 1e-5: 

108 merged.insert(0, (0.0, 0.0)) 

109 merged[0] = (0.0, 0.0) 

110 if merged[-1][0] < 1.0 - 1e-5: 

111 merged.append((1.0, 1.0)) 

112 merged[-1] = (1.0, 1.0) 

113 return merged 

114 

115 def _graph_rect(self): 

116 return QRectF(self.rect()).adjusted(9, 9, -9, -9) 

117 

118 def _point_to_widget(self, point): 

119 rect = self._graph_rect() 

120 return QPointF( 

121 rect.left() + rect.width() * point.x(), 

122 rect.bottom() - rect.height() * point.y(), 

123 ) 

124 

125 def _widget_to_point(self, position): 

126 rect = self._graph_rect() 

127 x = ( 

128 (position.x() - rect.left()) 

129 / max(1.0, rect.width()) 

130 ) 

131 y = ( 

132 (rect.bottom() - position.y()) 

133 / max(1.0, rect.height()) 

134 ) 

135 return QPointF( 

136 max(0.0, min(1.0, x)), 

137 max(0.0, min(1.0, y)), 

138 ) 

139 

140 def _nearest_point_index(self, position): 

141 radius_sq = 9.0 * 9.0 

142 best_index = -1 

143 best_distance = radius_sq 

144 for index, point in enumerate(self._points): 

145 widget_point = self._point_to_widget(point) 

146 distance = ( 

147 (widget_point.x() - position.x()) ** 2 

148 + (widget_point.y() - position.y()) ** 2 

149 ) 

150 if distance <= best_distance: 

151 best_distance = distance 

152 best_index = index 

153 return best_index 

154 

155 def _emit_curve(self): 

156 self.update() 

157 self.curveChanged.emit(self.points()) 

158 

159 def _move_active_point(self, position): 

160 index = int(self._active_index) 

161 if not (0 <= index < len(self._points)): 

162 return 

163 

164 point = self._widget_to_point(position) 

165 if index == 0: 

166 point = QPointF(0.0, 0.0) 

167 elif index == len(self._points) - 1: 

168 point = QPointF(1.0, 1.0) 

169 else: 

170 left_x = self._points[index - 1].x() + 0.01 

171 right_x = self._points[index + 1].x() - 0.01 

172 point.setX( 

173 max(left_x, min(right_x, point.x())) 

174 ) 

175 

176 self._points[index] = point 

177 self._emit_curve() 

178 

179 def mousePressEvent(self, event): 

180 if event.button() == Qt.MouseButton.RightButton: 

181 index = self._nearest_point_index( 

182 event.position() 

183 ) 

184 if 0 < index < len(self._points) - 1: 

185 self._points.pop(index) 

186 self._active_index = -1 

187 self._emit_curve() 

188 event.accept() 

189 return 

190 

191 if event.button() == Qt.MouseButton.LeftButton: 

192 index = self._nearest_point_index( 

193 event.position() 

194 ) 

195 if index < 0: 

196 point = self._widget_to_point( 

197 event.position() 

198 ) 

199 self._points.append(point) 

200 self._points.sort( 

201 key=lambda item: item.x() 

202 ) 

203 index = min( 

204 range(len(self._points)), 

205 key=lambda candidate: abs( 

206 self._points[candidate].x() 

207 - point.x() 

208 ), 

209 ) 

210 self._active_index = index 

211 self._dragging = True 

212 self.interactionStarted.emit() 

213 self._move_active_point(event.position()) 

214 event.accept() 

215 return 

216 super().mousePressEvent(event) 

217 

218 def mouseMoveEvent(self, event): 

219 if ( 

220 self._dragging 

221 and event.buttons() 

222 & Qt.MouseButton.LeftButton 

223 ): 

224 self._move_active_point(event.position()) 

225 event.accept() 

226 return 

227 super().mouseMoveEvent(event) 

228 

229 def mouseReleaseEvent(self, event): 

230 if ( 

231 self._dragging 

232 and event.button() == Qt.MouseButton.LeftButton 

233 ): 

234 self._dragging = False 

235 self._move_active_point(event.position()) 

236 self.interactionFinished.emit() 

237 event.accept() 

238 return 

239 super().mouseReleaseEvent(event) 

240 

241 def mouseDoubleClickEvent(self, event): 

242 if event.button() == Qt.MouseButton.LeftButton: 

243 self.resetCurve() 

244 event.accept() 

245 return 

246 super().mouseDoubleClickEvent(event) 

247 

248 def paintEvent(self, event): 

249 painter = QPainter(self) 

250 painter.setRenderHint( 

251 QPainter.RenderHint.Antialiasing, 

252 True, 

253 ) 

254 

255 rect = self._graph_rect() 

256 painter.fillRect(rect, QColor("#20252a")) 

257 painter.setPen(QPen(QColor("#4a535a"), 1)) 

258 for fraction in (0.25, 0.5, 0.75): 

259 x = rect.left() + rect.width() * fraction 

260 y = rect.top() + rect.height() * fraction 

261 painter.drawLine( 

262 QPointF(x, rect.top()), 

263 QPointF(x, rect.bottom()), 

264 ) 

265 painter.drawLine( 

266 QPointF(rect.left(), y), 

267 QPointF(rect.right(), y), 

268 ) 

269 

270 painter.setPen(QPen(QColor("#7b858c"), 1)) 

271 painter.drawLine( 

272 QPointF(rect.left(), rect.bottom()), 

273 QPointF(rect.right(), rect.top()), 

274 ) 

275 

276 path = QPainterPath() 

277 sample_inputs = np.linspace( 

278 0.0, 

279 1.0, 

280 129, 

281 dtype=np.float64, 

282 ) 

283 sample_outputs = PaintCanvas.tone_curve_samples( 

284 self.points(), 

285 sample_inputs, 

286 ) 

287 for index, (input_value, output_value) in enumerate( 

288 zip(sample_inputs, sample_outputs) 

289 ): 

290 widget_point = QPointF( 

291 rect.left() + rect.width() * float(input_value), 

292 rect.bottom() - rect.height() * float(output_value), 

293 ) 

294 if index == 0: 

295 path.moveTo(widget_point) 

296 else: 

297 path.lineTo(widget_point) 

298 

299 painter.setPen(QPen(QColor("#67d5ff"), 2)) 

300 painter.drawPath(path) 

301 

302 for index, point in enumerate(self._points): 

303 widget_point = self._point_to_widget(point) 

304 painter.setPen(QPen(QColor("#ffffff"), 1)) 

305 painter.setBrush( 

306 QColor("#ffd65a") 

307 if index == self._active_index 

308 else QColor("#67d5ff") 

309 ) 

310 painter.drawEllipse(widget_point, 4.5, 4.5) 

311 painter.end() 

312 

313 

314class ColorReductionDialog(QDialog): 

315 """代表フレームからパレットを決め、全コマへ適用する。 

316 

317 プレビューは1枚目で見せるが、パレットは ``reference_images``(先頭・中間・ 

318 末尾などの代表フレーム)からまとめて作る。1枚だけで決めると、中間で色が 

319 増えるカットやフラッシュで色が潰れるため(Issue #10)。 

320 """ 

321 

322 def __init__( 

323 self, 

324 source_image, 

325 original_color_count, 

326 semi_transparent_count=0, 

327 opaque_background=False, 

328 background_rgb=None, 

329 parent=None, 

330 reference_images=(), 

331 ): 

332 super().__init__(parent) 

333 self.setWindowTitle(tr("2値化")) 

334 self.resize(820, 560) 

335 

336 self.source_image = source_image.copy() 

337 # パレットの手本にする代表フレーム(1枚目を除く)。 

338 self.reference_images = [ 

339 image.copy() for image in (reference_images or ()) 

340 if image is not None and not image.isNull() 

341 ] 

342 self.original_color_count = int(original_color_count) 

343 self.semi_transparent_count = int( 

344 semi_transparent_count 

345 ) 

346 self.opaque_background = bool(opaque_background) 

347 self.background_rgb = ( 

348 tuple(int(value) for value in background_rgb) 

349 if background_rgb is not None 

350 else None 

351 ) 

352 self.reduction_enabled = True 

353 self._palette = None 

354 self._palette_key = None 

355 self._base_palette_cache = {} 

356 self._base_reduced_cache = {} 

357 self._reduced_full_image = self.source_image.copy() 

358 self._confirmed_preview_image = self.source_image.copy() 

359 self._confirmed_preview_title = tr("2値化 100%") 

360 self._preview_dirty = False 

361 self._tone_drag_active = False 

362 self._tone_preview_source_rgba = None 

363 self._tone_preview_background_mask = None 

364 self._tone_adjusted_preview_image = self.source_image.copy() 

365 self._color_processing = False 

366 self._initial_preview = True 

367 self._last_color_processing_seconds = 0.0 

368 self._syncing_preview_scroll = False 

369 self._preview_pan_active = False 

370 self._preview_pan_widget = None 

371 self._preview_pan_origin = QPointF() 

372 self._preview_pan_start_h = 0 

373 self._preview_pan_start_v = 0 

374 

375 layout = QVBoxLayout(self) 

376 layout.setContentsMargins(8, 8, 8, 8) 

377 layout.setSpacing(5) 

378 

379 background_note = ( 

380 tr("白背景を自動検出") 

381 if self.opaque_background 

382 else tr("半透明 {count:,}px").format(count=self.semi_transparent_count) 

383 ) 

384 summary = QLabel( 

385 tr("代表{frames}枚:1枚目{count:,}色/{note} トーンカーブを元画像へ先に適用し、その後に色数を調整して2値化します。").format( 

386 frames=len(self.reference_images) + 1, 

387 count=self.original_color_count, 

388 note=background_note, 

389 ) 

390 ) 

391 summary.setWordWrap(True) 

392 layout.addWidget(summary) 

393 

394 previews = QHBoxLayout() 

395 previews.setSpacing(6) 

396 ( 

397 original_column, 

398 self.original_title, 

399 self.original_preview, 

400 self.original_scroll, 

401 ) = self._build_preview_column(tr("元画像(トーンカーブ) 100%")) 

402 ( 

403 reduced_column, 

404 self.reduced_title, 

405 self.reduced_preview, 

406 self.reduced_scroll, 

407 ) = self._build_preview_column( 

408 tr("2値化 100%") 

409 ) 

410 previews.addLayout(original_column, 1) 

411 previews.addLayout(reduced_column, 1) 

412 layout.addLayout(previews, 1) 

413 

414 controls = QFormLayout() 

415 controls.setContentsMargins(0, 0, 0, 0) 

416 controls.setHorizontalSpacing(8) 

417 controls.setVerticalSpacing(4) 

418 

419 self.extraction_mode = QComboBox() 

420 self.extraction_mode.addItem(tr("色面抽出"), "surface") 

421 self.extraction_mode.addItem(tr("ライン抽出"), "line") 

422 self.extraction_mode.setCurrentIndex(1) 

423 self.extraction_mode.setToolTip( 

424 tr("色面抽出:塗りの色面を保ちながらアンチエイリアスを除去します。\n" 

425 "ライン抽出:白背景と元の線色を保ちながら線画を2値化します。") 

426 ) 

427 self.extraction_mode.currentIndexChanged.connect( 

428 self._mark_preview_dirty 

429 ) 

430 controls.addRow(tr("2値化方式"), self.extraction_mode) 

431 

432 color_row = QWidget() 

433 color_layout = QHBoxLayout(color_row) 

434 color_layout.setContentsMargins(0, 0, 0, 0) 

435 color_layout.setSpacing(5) 

436 self.color_slider = QSlider(Qt.Orientation.Horizontal) 

437 self.color_slider.setRange(2, 100) 

438 self.color_slider.setSingleStep(1) 

439 self.color_slider.setPageStep(8) 

440 self.color_slider.setValue(8) 

441 self.color_count = QSpinBox() 

442 self.color_count.setRange(2, 100) 

443 self.color_count.setValue(8) 

444 self.color_count.setSuffix(tr(" 色")) 

445 self.color_count.setFixedWidth(76) 

446 self.color_slider.valueChanged.connect( 

447 self.color_count.setValue 

448 ) 

449 self.color_count.valueChanged.connect( 

450 self.color_slider.setValue 

451 ) 

452 self.color_count.valueChanged.connect( 

453 self._mark_preview_dirty 

454 ) 

455 color_layout.addWidget(self.color_slider, 1) 

456 color_layout.addWidget(self.color_count) 

457 controls.addRow(tr("色数"), color_row) 

458 

459 tone_row = QWidget() 

460 tone_layout = QHBoxLayout(tone_row) 

461 tone_layout.setContentsMargins(0, 0, 0, 0) 

462 tone_layout.setSpacing(6) 

463 

464 self.tone_curve = ToneCurveWidget() 

465 self.tone_curve_label = QLabel(tr("標準")) 

466 self.tone_curve_label.setFixedWidth(66) 

467 self.tone_curve_label.setAlignment( 

468 Qt.AlignmentFlag.AlignCenter 

469 ) 

470 tone_reset = QPushButton(tr("標準")) 

471 tone_reset.setFixedWidth(46) 

472 tone_reset.clicked.connect( 

473 self.tone_curve.resetCurve 

474 ) 

475 self.tone_curve.curveChanged.connect( 

476 self._tone_curve_changed 

477 ) 

478 self.tone_curve.interactionStarted.connect( 

479 self._tone_curve_drag_started 

480 ) 

481 self.tone_curve.interactionFinished.connect( 

482 self._tone_curve_drag_finished 

483 ) 

484 

485 tone_layout.addWidget(self.tone_curve, 1) 

486 tone_layout.addWidget(self.tone_curve_label) 

487 tone_layout.addWidget(tone_reset) 

488 controls.addRow(tr("トーンカーブ"), tone_row) 

489 layout.addLayout(controls) 

490 

491 note = QLabel( 

492 tr("色数の変更中はプレビューを更新しません。" 

493 "トーンカーブ操作中は元画像へ一時適用し、" 

494 "離すと直前の2値化プレビューへ戻ります。") 

495 ) 

496 note.setWordWrap(True) 

497 note.setStyleSheet("font-size:10px;") 

498 layout.addWidget(note) 

499 

500 confirm_row = QHBoxLayout() 

501 self.preview_status = QLabel(tr("プレビュー反映済み")) 

502 self.preview_status.setStyleSheet( 

503 "color:#28744a;font-weight:bold;" 

504 ) 

505 self.preview_confirm_button = QPushButton(tr("プレビュー")) 

506 self.preview_confirm_button.setToolTip( 

507 tr("現在のトーンカーブと色数を使って" 

508 "2値化プレビューを更新します。") 

509 ) 

510 self.preview_confirm_button.setMinimumWidth(110) 

511 self.preview_confirm_button.clicked.connect( 

512 self._confirm_preview_settings 

513 ) 

514 confirm_row.addWidget(self.preview_status) 

515 confirm_row.addStretch(1) 

516 confirm_row.addWidget(self.preview_confirm_button) 

517 layout.addLayout(confirm_row) 

518 

519 button_row = QHBoxLayout() 

520 self.apply_button = QPushButton(tr("全コマを2値化")) 

521 self.no_reduction_button = QPushButton(tr("そのまま読み込む")) 

522 cancel_button = QPushButton(tr("キャンセル")) 

523 self.apply_button.clicked.connect(self._accept_reduction) 

524 self.no_reduction_button.clicked.connect( 

525 self._accept_without_reduction 

526 ) 

527 cancel_button.clicked.connect(self.reject) 

528 button_row.addWidget(self.apply_button) 

529 button_row.addWidget(self.no_reduction_button) 

530 button_row.addStretch(1) 

531 button_row.addWidget(cancel_button) 

532 layout.addLayout(button_row) 

533 

534 # 操作中の元画像+トーンカーブ表示だけを軽く間引く。 

535 self._tone_live_timer = QTimer(self) 

536 self._tone_live_timer.setSingleShot(True) 

537 self._tone_live_timer.setInterval(24) 

538 self._tone_live_timer.timeout.connect( 

539 self._show_tone_curve_live_preview 

540 ) 

541 

542 self._install_preview_event_filters() 

543 self._connect_preview_scrolls() 

544 self._update_preview() 

545 self._initial_preview = False 

546 QTimer.singleShot(0, self._center_preview_position) 

547 

548 def tone_curve_points(self): 

549 return self.tone_curve.points() 

550 

551 def tone_curve_points_key(self): 

552 """複数点トーンカーブを安定したハッシュ可能キーへ変換する。""" 

553 normalized = [] 

554 for point in self.tone_curve_points(): 

555 try: 

556 x, y = point[:2] 

557 normalized.append( 

558 ( 

559 round(float(x), 5), 

560 round(float(y), 5), 

561 ) 

562 ) 

563 except ( 

564 TypeError, 

565 ValueError, 

566 IndexError, 

567 ): 

568 continue 

569 

570 if not normalized: 

571 normalized = [ 

572 (0.0, 0.0), 

573 (1.0, 1.0), 

574 ] 

575 return tuple(normalized) 

576 

577 def _tone_curve_changed(self, points): 

578 point_count = max(0, len(points) - 2) 

579 self.tone_curve_label.setText( 

580 tr("標準") 

581 if point_count == 0 

582 else tr("中間点 {count}").format(count=point_count) 

583 ) 

584 self._mark_preview_dirty() 

585 if self._tone_drag_active: 

586 self._tone_live_timer.start() 

587 else: 

588 self._show_tone_curve_live_preview(force=True) 

589 

590 def _current_preview_key(self): 

591 return ( 

592 self.extraction_mode.currentData(), 

593 int(self.color_count.value()), 

594 self.tone_curve_points_key(), 

595 ) 

596 

597 def _mark_preview_dirty(self, _value=None): 

598 if self._initial_preview: 

599 return 

600 self._preview_dirty = True 

601 self.preview_status.setText(tr("設定未反映")) 

602 self.preview_status.setStyleSheet( 

603 "color:#a15a00;font-weight:bold;" 

604 ) 

605 self.preview_confirm_button.setEnabled(True) 

606 self.apply_button.setEnabled(False) 

607 if not self._tone_drag_active: 

608 self._restore_confirmed_preview( 

609 show_dirty_state=True 

610 ) 

611 

612 def _tone_curve_drag_started(self): 

613 self._tone_drag_active = True 

614 self._show_tone_curve_live_preview() 

615 

616 def _tone_curve_drag_finished(self): 

617 self._show_tone_curve_live_preview() 

618 self._tone_drag_active = False 

619 self._tone_live_timer.stop() 

620 

621 def _prepare_tone_preview_source(self): 

622 if self._tone_preview_source_rgba is not None: 

623 return 

624 self._tone_preview_source_rgba = ( 

625 PaintCanvas._qimage_rgba_array( 

626 self.source_image 

627 ) 

628 ) 

629 if self.background_rgb is None: 

630 self._tone_preview_background_mask = None 

631 return 

632 

633 background = np.asarray( 

634 self.background_rgb, 

635 dtype=np.int16, 

636 ) 

637 difference = ( 

638 self._tone_preview_source_rgba[:, :, :3] 

639 .astype(np.int16) 

640 - background 

641 ) 

642 self._tone_preview_background_mask = ( 

643 np.sum( 

644 difference.astype(np.int32) 

645 * difference.astype(np.int32), 

646 axis=2, 

647 ) 

648 <= (5 * 5 * 3) 

649 ) 

650 

651 def _show_tone_curve_live_preview(self, force=False): 

652 """元画像へ現在のトーンカーブを軽量適用して表示する。""" 

653 if not self._tone_drag_active and not force: 

654 return 

655 try: 

656 self._prepare_tone_preview_source() 

657 source_rgba = self._tone_preview_source_rgba 

658 if source_rgba is None or source_rgba.size == 0: 

659 return 

660 

661 lut = PaintCanvas.tone_curve_lut( 

662 self.tone_curve_points() 

663 ) 

664 result = source_rgba.copy() 

665 result[:, :, :3] = lut[ 

666 result[:, :, :3] 

667 ] 

668 

669 if ( 

670 self._tone_preview_background_mask is not None 

671 and self.background_rgb is not None 

672 ): 

673 result[:, :, :3][ 

674 self._tone_preview_background_mask 

675 ] = np.asarray( 

676 self.background_rgb, 

677 dtype=np.uint8, 

678 ) 

679 

680 preview = PaintCanvas._rgba_array_to_qimage( 

681 result 

682 ) 

683 if preview is None or preview.isNull(): 

684 return 

685 

686 self._tone_adjusted_preview_image = preview.copy() 

687 self.original_title.setText( 

688 tr("元画像(トーンカーブ) 100%") 

689 ) 

690 self.original_preview.setPixmap( 

691 QPixmap.fromImage(preview) 

692 ) 

693 self.original_preview.setFixedSize(preview.size()) 

694 except (ValueError, IndexError, TypeError, RuntimeError, AttributeError) as exc: 

695 # 操作中の簡易表示失敗は、確定済み表示へ静かに戻す。 

696 log.debug("tone-curve live preview failed: %s", exc) 

697 self._restore_confirmed_preview( 

698 show_dirty_state=True 

699 ) 

700 

701 def _restore_confirmed_preview( 

702 self, 

703 show_dirty_state=False, 

704 ): 

705 confirmed = getattr( 

706 self, 

707 "_confirmed_preview_image", 

708 None, 

709 ) 

710 if confirmed is None or confirmed.isNull(): 

711 return 

712 self._reduced_full_image = confirmed.copy() 

713 title = self._confirmed_preview_title 

714 if show_dirty_state and self._preview_dirty: 

715 title += tr("(設定未反映)") 

716 self.reduced_title.setText(title) 

717 self._refresh_preview_pixmaps() 

718 

719 def _confirm_preview_settings(self): 

720 if self._color_processing: 

721 return 

722 self._tone_drag_active = False 

723 self._tone_live_timer.stop() 

724 self._restore_confirmed_preview( 

725 show_dirty_state=True 

726 ) 

727 self._update_preview() 

728 

729 def alpha_threshold_255(self): 

730 # 透明背景のアルファ二値化は中央50%へ固定する。 

731 return 128 

732 

733 def _build_preview_column(self, title_text): 

734 column = QVBoxLayout() 

735 column.setContentsMargins(0, 0, 0, 0) 

736 column.setSpacing(2) 

737 

738 title = QLabel(title_text) 

739 title.setAlignment(Qt.AlignmentFlag.AlignCenter) 

740 

741 label = QLabel() 

742 label.setAlignment( 

743 Qt.AlignmentFlag.AlignLeft 

744 | Qt.AlignmentFlag.AlignTop 

745 ) 

746 label.setStyleSheet("background:white;") 

747 label.setScaledContents(False) 

748 label.setCursor(Qt.CursorShape.OpenHandCursor) 

749 

750 scroll = QScrollArea() 

751 scroll.setWidget(label) 

752 scroll.setWidgetResizable(False) 

753 scroll.setAlignment(Qt.AlignmentFlag.AlignCenter) 

754 scroll.setMinimumSize(350, 340) 

755 scroll.setHorizontalScrollBarPolicy( 

756 Qt.ScrollBarPolicy.ScrollBarAlwaysOff 

757 ) 

758 scroll.setVerticalScrollBarPolicy( 

759 Qt.ScrollBarPolicy.ScrollBarAlwaysOff 

760 ) 

761 scroll.viewport().setCursor(Qt.CursorShape.OpenHandCursor) 

762 scroll.setStyleSheet( 

763 "QScrollArea{border:1px solid #777;" 

764 "background:#d8d8d8;}" 

765 ) 

766 

767 column.addWidget(title) 

768 column.addWidget(scroll, 1) 

769 return column, title, label, scroll 

770 

771 def _install_preview_event_filters(self): 

772 """左右のプレビュー生成完了後にハンド操作を有効化する。""" 

773 for widget in self._preview_event_widgets(): 

774 widget.installEventFilter(self) 

775 

776 def _preview_event_widgets(self): 

777 """初期化途中でも安全に取得できるプレビュー部品一覧。""" 

778 widgets = [] 

779 

780 for attribute_name in ( 

781 "original_preview", 

782 "reduced_preview", 

783 ): 

784 widget = getattr(self, attribute_name, None) 

785 if widget is not None: 

786 widgets.append(widget) 

787 

788 for attribute_name in ( 

789 "original_scroll", 

790 "reduced_scroll", 

791 ): 

792 scroll = getattr(self, attribute_name, None) 

793 if scroll is not None: 

794 viewport = scroll.viewport() 

795 if viewport is not None: 

796 widgets.append(viewport) 

797 

798 return tuple(widgets) 

799 

800 def _connect_preview_scrolls(self): 

801 for bar in ( 

802 self.original_scroll.horizontalScrollBar(), 

803 self.reduced_scroll.horizontalScrollBar(), 

804 ): 

805 bar.valueChanged.connect( 

806 lambda value, axis="h": self._sync_preview_scroll( 

807 axis, value 

808 ) 

809 ) 

810 for bar in ( 

811 self.original_scroll.verticalScrollBar(), 

812 self.reduced_scroll.verticalScrollBar(), 

813 ): 

814 bar.valueChanged.connect( 

815 lambda value, axis="v": self._sync_preview_scroll( 

816 axis, value 

817 ) 

818 ) 

819 

820 def _sync_preview_scroll(self, axis, value): 

821 if self._syncing_preview_scroll: 

822 return 

823 self._syncing_preview_scroll = True 

824 try: 

825 if axis == "h": 

826 bars = ( 

827 self.original_scroll.horizontalScrollBar(), 

828 self.reduced_scroll.horizontalScrollBar(), 

829 ) 

830 else: 

831 bars = ( 

832 self.original_scroll.verticalScrollBar(), 

833 self.reduced_scroll.verticalScrollBar(), 

834 ) 

835 for bar in bars: 

836 target = max( 

837 bar.minimum(), 

838 min(bar.maximum(), int(value)), 

839 ) 

840 if bar.value() != target: 

841 bar.setValue(target) 

842 finally: 

843 self._syncing_preview_scroll = False 

844 

845 def _center_preview_position(self): 

846 original_scroll = getattr( 

847 self, "original_scroll", None 

848 ) 

849 if original_scroll is None: 

850 return 

851 horizontal = original_scroll.horizontalScrollBar() 

852 vertical = original_scroll.verticalScrollBar() 

853 self._sync_preview_scroll( 

854 "h", 

855 int(round((horizontal.minimum() + horizontal.maximum()) / 2)), 

856 ) 

857 self._sync_preview_scroll( 

858 "v", 

859 int(round((vertical.minimum() + vertical.maximum()) / 2)), 

860 ) 

861 

862 def eventFilter(self, watched, event): 

863 # macOSではウィジェット生成中にもイベントが届くため、 

864 # 未作成の属性を直接参照しない。 

865 preview_widgets = self._preview_event_widgets() 

866 if watched in preview_widgets: 

867 if ( 

868 event.type() == QEvent.Type.MouseButtonPress 

869 and event.button() == Qt.MouseButton.LeftButton 

870 ): 

871 self._preview_pan_active = True 

872 self._preview_pan_widget = watched 

873 self._preview_pan_origin = QPointF( 

874 event.globalPosition() 

875 ) 

876 original_scroll = getattr( 

877 self, "original_scroll", None 

878 ) 

879 if original_scroll is None: 

880 return super().eventFilter(watched, event) 

881 

882 self._preview_pan_start_h = ( 

883 original_scroll.horizontalScrollBar().value() 

884 ) 

885 self._preview_pan_start_v = ( 

886 original_scroll.verticalScrollBar().value() 

887 ) 

888 watched.setCursor(Qt.CursorShape.ClosedHandCursor) 

889 try: 

890 watched.grabMouse() 

891 except RuntimeError as exc: 

892 log.debug("grabMouse() failed: %s", exc) 

893 event.accept() 

894 return True 

895 

896 if ( 

897 event.type() == QEvent.Type.MouseMove 

898 and self._preview_pan_active 

899 and event.buttons() & Qt.MouseButton.LeftButton 

900 ): 

901 delta = QPointF(event.globalPosition()) - ( 

902 self._preview_pan_origin 

903 ) 

904 self._sync_preview_scroll( 

905 "h", 

906 self._preview_pan_start_h 

907 - int(round(delta.x())), 

908 ) 

909 self._sync_preview_scroll( 

910 "v", 

911 self._preview_pan_start_v 

912 - int(round(delta.y())), 

913 ) 

914 event.accept() 

915 return True 

916 

917 if ( 

918 event.type() == QEvent.Type.MouseButtonRelease 

919 and event.button() == Qt.MouseButton.LeftButton 

920 and self._preview_pan_active 

921 ): 

922 self._preview_pan_active = False 

923 pan_widget = self._preview_pan_widget 

924 self._preview_pan_widget = None 

925 if pan_widget is not None: 

926 try: 

927 pan_widget.releaseMouse() 

928 except RuntimeError as exc: 

929 log.debug("releaseMouse() failed: %s", exc) 

930 pan_widget.setCursor( 

931 Qt.CursorShape.OpenHandCursor 

932 ) 

933 event.accept() 

934 return True 

935 

936 return super().eventFilter(watched, event) 

937 

938 def _refresh_preview_pixmaps(self): 

939 def set_image(label, image): 

940 label.setPixmap(QPixmap.fromImage(image)) 

941 label.setFixedSize(image.size()) 

942 

943 self.original_title.setText(tr("元画像(トーンカーブ) 100%")) 

944 original_image = getattr( 

945 self, 

946 "_tone_adjusted_preview_image", 

947 self.source_image, 

948 ) 

949 set_image(self.original_preview, original_image) 

950 set_image(self.reduced_preview, self._reduced_full_image) 

951 

952 def _create_color_progress(self): 

953 progress = QProgressDialog( 

954 tr("色相を分類しています"), 

955 "", 

956 0, 

957 3, 

958 self, 

959 ) 

960 progress.setWindowTitle(tr("2値化")) 

961 progress.setCancelButton(None) 

962 progress.setWindowModality( 

963 Qt.WindowModality.WindowModal 

964 ) 

965 progress.setMinimumDuration(0) 

966 progress.setAutoClose(False) 

967 progress.setAutoReset(False) 

968 progress.setMinimumWidth(330) 

969 progress.setValue(0) 

970 progress.show() 

971 QApplication.processEvents() 

972 return progress 

973 

974 @staticmethod 

975 def _update_color_progress( 

976 progress, 

977 value, 

978 label, 

979 ): 

980 if progress is None: 

981 return 

982 progress.setLabelText( 

983 f"{label}\n{int(value)} / 3" 

984 ) 

985 progress.setValue(int(value)) 

986 QApplication.processEvents() 

987 

988 @staticmethod 

989 def _close_color_progress(progress): 

990 if progress is None: 

991 return 

992 progress.setValue(progress.maximum()) 

993 progress.close() 

994 progress.deleteLater() 

995 QApplication.processEvents() 

996 

997 def _trim_color_preview_cache(self, maximum=8): 

998 maximum = max(1, int(maximum)) 

999 while len(self._base_palette_cache) > maximum: 

1000 oldest = next(iter(self._base_palette_cache)) 

1001 self._base_palette_cache.pop(oldest, None) 

1002 self._base_reduced_cache.pop(oldest, None) 

1003 

1004 def _update_preview(self): 

1005 if self._color_processing: 

1006 return False 

1007 

1008 target = int(self.color_count.value()) 

1009 alpha_threshold = self.alpha_threshold_255() 

1010 tone_points = self.tone_curve_points() 

1011 try: 

1012 tone_key = self.tone_curve_points_key() 

1013 except (TypeError, ValueError, IndexError, AttributeError) as exc: 

1014 log.debug("tone_curve_points_key() failed, using identity: %s", exc) 

1015 tone_points = [ 

1016 (0.0, 0.0), 

1017 (1.0, 1.0), 

1018 ] 

1019 tone_key = tuple(tone_points) 

1020 

1021 extraction_mode = ( 

1022 "line" 

1023 if self.extraction_mode.currentData() == "line" 

1024 else "surface" 

1025 ) 

1026 cache_key = (extraction_mode, target, tone_key) 

1027 processing_opaque_background = ( 

1028 True 

1029 if extraction_mode == "line" 

1030 else self.opaque_background 

1031 ) 

1032 processing_background_rgb = ( 

1033 (255, 255, 255) 

1034 if extraction_mode == "line" 

1035 else self.background_rgb 

1036 ) 

1037 needs_color_processing = ( 

1038 cache_key not in self._base_palette_cache 

1039 ) 

1040 progress = None 

1041 started_at = None 

1042 success = False 

1043 self._color_processing = True 

1044 self.preview_confirm_button.setEnabled(False) 

1045 self.apply_button.setEnabled(False) 

1046 

1047 old_h = self.original_scroll.horizontalScrollBar().value() 

1048 old_v = self.original_scroll.verticalScrollBar().value() 

1049 

1050 QApplication.setOverrideCursor( 

1051 Qt.CursorShape.WaitCursor 

1052 ) 

1053 try: 

1054 tone_adjusted_source = PaintCanvas.apply_tone_curve( 

1055 self.source_image, 

1056 tone_curve_points=tone_points, 

1057 background_rgb=processing_background_rgb, 

1058 ) 

1059 if ( 

1060 tone_adjusted_source is None 

1061 or tone_adjusted_source.isNull() 

1062 ): 

1063 raise OperationError( 

1064 tr("トーンカーブ適用後の画像を生成できませんでした。") 

1065 ) 

1066 self._tone_adjusted_preview_image = ( 

1067 tone_adjusted_source.copy() 

1068 ) 

1069 

1070 if needs_color_processing: 

1071 started_at = time.perf_counter() 

1072 if not self._initial_preview: 

1073 progress = self._create_color_progress() 

1074 

1075 self._update_color_progress( 

1076 progress, 

1077 0, 

1078 tr("トーンカーブを元画像へ適用しています"), 

1079 ) 

1080 self._update_color_progress( 

1081 progress, 

1082 1, 

1083 tr("近い色相をまとめて代表色を作成しています"), 

1084 ) 

1085 base_palette = ( 

1086 PaintCanvas.build_color_reduction_palette( 

1087 tone_adjusted_source, 

1088 target, 

1089 alpha_threshold=alpha_threshold, 

1090 max_samples=131072, 

1091 opaque_background=processing_opaque_background, 

1092 background_rgb=processing_background_rgb, 

1093 extraction_mode=extraction_mode, 

1094 # 代表フレームにも同じトーンカーブをかけて手本にする。 

1095 extra_images=[ 

1096 PaintCanvas.apply_tone_curve( 

1097 reference, 

1098 tone_curve_points=tone_points, 

1099 background_rgb=processing_background_rgb, 

1100 ) 

1101 for reference in self.reference_images 

1102 ], 

1103 ) 

1104 ) 

1105 

1106 self._update_color_progress( 

1107 progress, 

1108 2, 

1109 ( 

1110 tr("線のアンチエイリアスを線色と白背景へ分けています") 

1111 if extraction_mode == "line" 

1112 else tr("色面の境界を2色へ分けています") 

1113 ), 

1114 ) 

1115 base_reduced = ( 

1116 PaintCanvas.apply_color_reduction_palette( 

1117 tone_adjusted_source, 

1118 base_palette, 

1119 alpha_threshold=alpha_threshold, 

1120 opaque_background=processing_opaque_background, 

1121 background_rgb=processing_background_rgb, 

1122 tone_curve_points=[ 

1123 (0.0, 0.0), 

1124 (1.0, 1.0), 

1125 ], 

1126 extraction_mode=extraction_mode, 

1127 ) 

1128 ) 

1129 if base_reduced is None or base_reduced.isNull(): 

1130 raise OperationError( 

1131 tr("2値化画像を生成できませんでした。") 

1132 ) 

1133 

1134 self._base_palette_cache[cache_key] = ( 

1135 np.asarray( 

1136 base_palette, 

1137 dtype=np.uint8, 

1138 ).copy() 

1139 ) 

1140 self._base_reduced_cache[cache_key] = ( 

1141 base_reduced.copy() 

1142 ) 

1143 self._trim_color_preview_cache() 

1144 self._update_color_progress( 

1145 progress, 

1146 3, 

1147 tr("プレビューを更新しています"), 

1148 ) 

1149 self._last_color_processing_seconds = ( 

1150 time.perf_counter() - started_at 

1151 ) 

1152 

1153 palette = np.asarray( 

1154 self._base_palette_cache[cache_key], 

1155 dtype=np.uint8, 

1156 ).copy() 

1157 reduced = self._base_reduced_cache[ 

1158 cache_key 

1159 ].copy() 

1160 

1161 self._palette = palette 

1162 self._palette_key = cache_key 

1163 self._reduced_full_image = reduced 

1164 

1165 statistics = PaintCanvas.image_alpha_statistics( 

1166 reduced 

1167 ) 

1168 actual = len(palette) 

1169 mode_name = self.extraction_mode.currentText() 

1170 title = ( 

1171 tr("{name} 100%({actual}色/半透明 {transparent}px)").format(name=mode_name, actual=actual, transparent=statistics['semi_transparent']) 

1172 ) 

1173 self.reduced_title.setText(title) 

1174 self._refresh_preview_pixmaps() 

1175 

1176 self._confirmed_preview_image = reduced.copy() 

1177 self._confirmed_preview_title = title 

1178 self._preview_dirty = False 

1179 self.preview_status.setText(tr("プレビュー反映済み")) 

1180 self.preview_status.setStyleSheet( 

1181 "color:#28744a;font-weight:bold;" 

1182 ) 

1183 success = True 

1184 

1185 QTimer.singleShot( 

1186 0, 

1187 lambda: ( 

1188 self._sync_preview_scroll("h", old_h), 

1189 self._sync_preview_scroll("v", old_v), 

1190 ), 

1191 ) 

1192 except (ValueError, IndexError, TypeError, RuntimeError, AttributeError, MemoryError) as exc: 

1193 log.warning("binarization preview generation failed: %s", exc, exc_info=True) 

1194 self._preview_dirty = True 

1195 self._restore_confirmed_preview( 

1196 show_dirty_state=True 

1197 ) 

1198 QMessageBox.warning( 

1199 self, 

1200 tr("2値化"), 

1201 tr("2値化プレビューの生成中にエラーが発生しました。\n\n{exc}").format(exc=exc), 

1202 ) 

1203 finally: 

1204 self._close_color_progress(progress) 

1205 self._color_processing = False 

1206 self.preview_confirm_button.setEnabled( 

1207 self._preview_dirty 

1208 ) 

1209 self.apply_button.setEnabled( 

1210 success and not self._preview_dirty 

1211 ) 

1212 QApplication.restoreOverrideCursor() 

1213 

1214 return success 

1215 

1216 def selected_palette(self): 

1217 expected_key = self._current_preview_key() 

1218 if ( 

1219 self._preview_dirty 

1220 or self._palette is None 

1221 or self._palette_key != expected_key 

1222 ): 

1223 raise OperationError( 

1224 tr("現在の設定はまだプレビューへ反映されていません。" 

1225 "先に「プレビュー」を押してください。") 

1226 ) 

1227 return np.asarray( 

1228 self._palette, 

1229 dtype=np.uint8, 

1230 ).copy() 

1231 

1232 def selected_extraction_mode(self): 

1233 return ( 

1234 "line" 

1235 if self.extraction_mode.currentData() == "line" 

1236 else "surface" 

1237 ) 

1238 

1239 def _accept_reduction(self): 

1240 if self._preview_dirty: 

1241 QMessageBox.information( 

1242 self, 

1243 tr("2値化"), 

1244 tr("トーンカーブまたは色数が未反映です。\n" 

1245 "先に下の「プレビュー」を押してください。"), 

1246 ) 

1247 return 

1248 try: 

1249 self.selected_palette() 

1250 except ValueError as exc: 

1251 log.info("palette not ready for apply: %s", exc) 

1252 QMessageBox.warning( 

1253 self, 

1254 tr("2値化"), 

1255 str(exc), 

1256 ) 

1257 return 

1258 self.reduction_enabled = True 

1259 self.accept() 

1260 

1261 def _accept_without_reduction(self): 

1262 self.reduction_enabled = False 

1263 self.accept()