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
« 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
26log = get_logger(__name__)
29class ToneCurveWidget(QWidget):
30 """両端固定・複数制御点対応の軽量トーンカーブ。"""
32 curveChanged = Signal(object)
33 interactionStarted = Signal()
34 interactionFinished = Signal()
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 )
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 ]
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())
74 def resetCurve(self):
75 self.setPoints([(0.0, 0.0), (1.0, 1.0)])
77 @staticmethod
78 def _normalized_points(points):
79 normalized = []
80 try:
81 iterable = list(points)
82 except TypeError:
83 iterable = []
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
97 normalized.extend([(0.0, 0.0), (1.0, 1.0)])
98 normalized.sort(key=lambda value: value[0])
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))
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
115 def _graph_rect(self):
116 return QRectF(self.rect()).adjusted(9, 9, -9, -9)
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 )
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 )
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
155 def _emit_curve(self):
156 self.update()
157 self.curveChanged.emit(self.points())
159 def _move_active_point(self, position):
160 index = int(self._active_index)
161 if not (0 <= index < len(self._points)):
162 return
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 )
176 self._points[index] = point
177 self._emit_curve()
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
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)
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)
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)
241 def mouseDoubleClickEvent(self, event):
242 if event.button() == Qt.MouseButton.LeftButton:
243 self.resetCurve()
244 event.accept()
245 return
246 super().mouseDoubleClickEvent(event)
248 def paintEvent(self, event):
249 painter = QPainter(self)
250 painter.setRenderHint(
251 QPainter.RenderHint.Antialiasing,
252 True,
253 )
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 )
270 painter.setPen(QPen(QColor("#7b858c"), 1))
271 painter.drawLine(
272 QPointF(rect.left(), rect.bottom()),
273 QPointF(rect.right(), rect.top()),
274 )
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)
299 painter.setPen(QPen(QColor("#67d5ff"), 2))
300 painter.drawPath(path)
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()
314class ColorReductionDialog(QDialog):
315 """代表フレームからパレットを決め、全コマへ適用する。
317 プレビューは1枚目で見せるが、パレットは ``reference_images``(先頭・中間・
318 末尾などの代表フレーム)からまとめて作る。1枚だけで決めると、中間で色が
319 増えるカットやフラッシュで色が潰れるため(Issue #10)。
320 """
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)
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
375 layout = QVBoxLayout(self)
376 layout.setContentsMargins(8, 8, 8, 8)
377 layout.setSpacing(5)
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)
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)
414 controls = QFormLayout()
415 controls.setContentsMargins(0, 0, 0, 0)
416 controls.setHorizontalSpacing(8)
417 controls.setVerticalSpacing(4)
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)
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)
459 tone_row = QWidget()
460 tone_layout = QHBoxLayout(tone_row)
461 tone_layout.setContentsMargins(0, 0, 0, 0)
462 tone_layout.setSpacing(6)
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 )
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)
491 note = QLabel(
492 tr("色数の変更中はプレビューを更新しません。"
493 "トーンカーブ操作中は元画像へ一時適用し、"
494 "離すと直前の2値化プレビューへ戻ります。")
495 )
496 note.setWordWrap(True)
497 note.setStyleSheet("font-size:10px;")
498 layout.addWidget(note)
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)
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)
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 )
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)
548 def tone_curve_points(self):
549 return self.tone_curve.points()
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
570 if not normalized:
571 normalized = [
572 (0.0, 0.0),
573 (1.0, 1.0),
574 ]
575 return tuple(normalized)
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)
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 )
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 )
612 def _tone_curve_drag_started(self):
613 self._tone_drag_active = True
614 self._show_tone_curve_live_preview()
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()
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
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 )
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
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 ]
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 )
680 preview = PaintCanvas._rgba_array_to_qimage(
681 result
682 )
683 if preview is None or preview.isNull():
684 return
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 )
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()
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()
729 def alpha_threshold_255(self):
730 # 透明背景のアルファ二値化は中央50%へ固定する。
731 return 128
733 def _build_preview_column(self, title_text):
734 column = QVBoxLayout()
735 column.setContentsMargins(0, 0, 0, 0)
736 column.setSpacing(2)
738 title = QLabel(title_text)
739 title.setAlignment(Qt.AlignmentFlag.AlignCenter)
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)
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 )
767 column.addWidget(title)
768 column.addWidget(scroll, 1)
769 return column, title, label, scroll
771 def _install_preview_event_filters(self):
772 """左右のプレビュー生成完了後にハンド操作を有効化する。"""
773 for widget in self._preview_event_widgets():
774 widget.installEventFilter(self)
776 def _preview_event_widgets(self):
777 """初期化途中でも安全に取得できるプレビュー部品一覧。"""
778 widgets = []
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)
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)
798 return tuple(widgets)
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 )
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
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 )
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)
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
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
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
936 return super().eventFilter(watched, event)
938 def _refresh_preview_pixmaps(self):
939 def set_image(label, image):
940 label.setPixmap(QPixmap.fromImage(image))
941 label.setFixedSize(image.size())
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)
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
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()
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()
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)
1004 def _update_preview(self):
1005 if self._color_processing:
1006 return False
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)
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)
1047 old_h = self.original_scroll.horizontalScrollBar().value()
1048 old_v = self.original_scroll.verticalScrollBar().value()
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 )
1070 if needs_color_processing:
1071 started_at = time.perf_counter()
1072 if not self._initial_preview:
1073 progress = self._create_color_progress()
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 )
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 )
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 )
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()
1161 self._palette = palette
1162 self._palette_key = cache_key
1163 self._reduced_full_image = reduced
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()
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
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()
1214 return success
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()
1232 def selected_extraction_mode(self):
1233 return (
1234 "line"
1235 if self.extraction_mode.currentData() == "line"
1236 else "surface"
1237 )
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()
1261 def _accept_without_reduction(self):
1262 self.reduction_enabled = False
1263 self.accept()