Coverage for paintmaskanimator/main_window_line_ops.py: 41%
481 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1"""Line-art operations; owned by ``MainWindow`` as ``window.line_ops``.
3Operates on the drawn line art rather than on documents or the UI shell: contour
4extraction from binary masks, main/sub line swapping, silhouette display,
5wire/mesh transform entry points, same-image colour replacement registration,
6the main-line repaint pass, and the surrounding-dust fill.
8The modal progress counter these long passes use now lives in ``progress.py``:
9it is shared UI plumbing, and eight unrelated modules were reaching in here for
10it.
12A collaborator rather than a mixin -- see ``main_window_export.py`` for why.
13"""
14from typing import TYPE_CHECKING
16import numpy as np
17from PySide6.QtCore import QPointF
18from PySide6.QtGui import QColor, QImage
19from PySide6.QtWidgets import (
20 QApplication,
21 QDialog,
22 QDialogButtonBox,
23 QFormLayout,
24 QMessageBox,
25 QSpinBox,
26)
27from . import imaging, line_noise
28from .i18n import tr
29from .time_display import frame_position
30from .models import Layer
31from .frame_scope import resolve_cells
32from .utils import blank_image
33from .errors import OPERATION_ERRORS
34from .progress import close_counter, create_counter, update_counter
35from .logging_setup import get_logger
37if TYPE_CHECKING:
38 from .main_window import MainWindow
40log = get_logger(__name__)
42_OPERATION_ERRORS = OPERATION_ERRORS
45class LineOpsController:
46 """Owned by ``MainWindow`` as ``window.line_ops``.
48 A collaborator rather than a mixin -- see main_window_export.py for why.
49 """
51 def __init__(self, window: "MainWindow"):
52 self.window = window
54 @staticmethod
55 def _image_contains_rgb(image, rgb):
56 if image is None or image.isNull(): 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true
57 return False
58 pixels = imaging.qimage_rgba_array(image)
59 height, width = pixels.shape[:2]
60 if width <= 0 or height <= 0: 60 ↛ 61line 60 didn't jump to line 61 because the condition on line 60 was never true
61 return False
62 target = np.array(
63 [int(rgb[0]), int(rgb[1]), int(rgb[2])],
64 dtype=np.uint8,
65 )
66 return bool(
67 np.any(
68 (pixels[:, :, 3] > 0)
69 & np.all(pixels[:, :, :3] == target, axis=2)
70 )
71 )
73 @staticmethod
74 def _mask_contours(mask):
75 """2値マスクの外周と穴の輪郭を画素境界上のポリゴンとして返す。"""
76 mask = np.asarray(mask, dtype=bool)
77 if mask.ndim != 2 or not np.any(mask): 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true
78 return []
80 top = mask.copy()
81 top[1:, :] &= ~mask[:-1, :]
82 right = mask.copy()
83 right[:, :-1] &= ~mask[:, 1:]
84 bottom = mask.copy()
85 bottom[:-1, :] &= ~mask[1:, :]
86 left = mask.copy()
87 left[:, 1:] &= ~mask[:, :-1]
88 edges = set()
89 for y, x in zip(*np.nonzero(top)):
90 edges.add(((int(x), int(y)), (int(x) + 1, int(y))))
91 for y, x in zip(*np.nonzero(right)):
92 edges.add(((int(x) + 1, int(y)), (int(x) + 1, int(y) + 1)))
93 for y, x in zip(*np.nonzero(bottom)):
94 edges.add(((int(x) + 1, int(y) + 1), (int(x), int(y) + 1)))
95 for y, x in zip(*np.nonzero(left)):
96 edges.add(((int(x), int(y) + 1), (int(x), int(y))))
98 outgoing = {}
99 for start, end in edges:
100 outgoing.setdefault(start, []).append(end)
102 direction_index = {
103 (1, 0): 0,
104 (0, 1): 1,
105 (-1, 0): 2,
106 (0, -1): 3,
107 }
108 unused = set(edges)
109 contours = []
110 while unused:
111 start_edge = min(unused)
112 start, current = start_edge
113 unused.remove(start_edge)
114 contour = [start, current]
115 previous = start
117 while current != start:
118 candidates = [
119 end for end in outgoing.get(current, ())
120 if (current, end) in unused
121 ]
122 if not candidates: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true
123 contour = []
124 break
125 previous_direction = direction_index[
126 (current[0] - previous[0], current[1] - previous[1])
127 ]
129 def turn_priority(
130 end,
131 current=current,
132 previous_direction=previous_direction,
133 ):
134 next_direction = direction_index[
135 (end[0] - current[0], end[1] - current[1])
136 ]
137 turn = (next_direction - previous_direction) % 4
138 return ({1: 0, 0: 1, 3: 2, 2: 3}[turn], end)
140 next_point = min(candidates, key=turn_priority)
141 unused.remove((current, next_point))
142 previous, current = current, next_point
143 contour.append(current)
145 if len(contour) >= 4 and contour[-1] == contour[0]: 145 ↛ 110line 145 didn't jump to line 110 because the condition on line 145 was always true
146 contours.append(contour[:-1])
148 simplified_contours = []
149 for contour in contours:
150 simplified = []
151 for index, point in enumerate(contour):
152 previous = contour[index - 1]
153 following = contour[(index + 1) % len(contour)]
154 if (
155 (previous[0] == point[0] == following[0])
156 or (previous[1] == point[1] == following[1])
157 ):
158 continue
159 simplified.append(QPointF(point[0], point[1]))
160 if len(simplified) >= 3: 160 ↛ 149line 160 didn't jump to line 149 because the condition on line 160 was always true
161 simplified_contours.append(simplified)
162 return simplified_contours
164 @classmethod
165 def _largest_mask_outer_contour(cls, mask):
166 contours = cls._mask_contours(mask)
167 return cls._largest_contour(contours)
169 @staticmethod
170 def _largest_contour(contours):
171 if not contours: 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true
172 return []
174 def area(points):
175 values = [(point.x(), point.y()) for point in points]
176 return abs(0.5 * sum(
177 x1 * y2 - x2 * y1
178 for (x1, y1), (x2, y2) in zip(
179 values,
180 values[1:] + values[:1],
181 )
182 ))
184 return max(contours, key=area)
187 def swap_main_sub(self):
188 # X switches the active drawing colour. Keep each colour assigned to
189 # its swatch so the selected swatch itself can move to the foreground
190 # together with its accent border.
191 self.window.canvas.color_mode = (
192 "sub" if self.window.canvas.color_mode == "main" else "main"
193 )
194 self.window.tools.set_colors(
195 self.window.canvas.main_color,
196 self.window.canvas.sub_color,
197 self.window.canvas.color_mode,
198 self.window.canvas.transparent_display_color,
199 )
200 self.window.colors._sync_tool_selector_swatch()
201 self.window.canvas.update()
202 self.window.statusBar().showMessage(tr("メインカラーとサブカラーを切り替えました。"), 1800)
204 def reset_main_sub(self):
205 self.window.canvas.main_color = QColor("black")
206 self.window.canvas.sub_color = QColor("white")
207 if self.window.canvas.color_mode not in ("main", "sub"):
208 self.window.canvas.color_mode = "main"
209 self.window.tools.set_colors(
210 self.window.canvas.main_color,
211 self.window.canvas.sub_color,
212 self.window.canvas.color_mode,
213 self.window.canvas.transparent_display_color,
214 )
215 self.window.canvas.update()
216 self.window.statusBar().showMessage(tr("メイン色とサブ色を初期化しました。"), 1800)
218 def toggle_silhouette(self, checked=None):
219 if checked is None: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true
220 checked = not self.window.canvas.silhouette_non_background
221 checked = bool(checked)
222 self.window.canvas.silhouette_non_background = checked
224 if hasattr(self.window, "a_silhouette"): 224 ↛ 229line 224 didn't jump to line 229 because the condition on line 224 was always true
225 self.window.a_silhouette.blockSignals(True)
226 self.window.a_silhouette.setChecked(checked)
227 self.window.a_silhouette.blockSignals(False)
229 self.window.view_preview.sync_black_check(checked)
231 # 編集して残っている古い組み込みアクションのボタンがあれば、それも合わせる。
232 silhouette_button = self.window.action_panel.button("silhouette")
233 if silhouette_button is not None: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
234 silhouette_button.blockSignals(True)
235 silhouette_button.setChecked(checked)
236 silhouette_button.blockSignals(False)
238 self.window.canvas._silhouette_cache.clear()
239 self.window.canvas.update()
240 self.window.statusBar().showMessage(
241 tr("背景以外を黒シルエット表示しています。")
242 if checked else
243 tr("黒シルエット表示を解除しました。"),
244 2200,
245 )
247 def choose_transform_mesh_grid(self):
248 dialog = QDialog(self.window)
249 dialog.setWindowTitle(tr("メッシュ格子数"))
250 form = QFormLayout(dialog)
252 cols_spin = QSpinBox(dialog)
253 rows_spin = QSpinBox(dialog)
254 for spin in (cols_spin, rows_spin):
255 spin.setRange(2, 12)
256 cols_spin.setValue(
257 max(2, int(getattr(self.window.canvas, "transform_mesh_cols", 4)))
258 )
259 rows_spin.setValue(
260 max(2, int(getattr(self.window.canvas, "transform_mesh_rows", 4)))
261 )
262 form.addRow(tr("横の格子数"), cols_spin)
263 form.addRow(tr("縦の格子数"), rows_spin)
265 buttons = QDialogButtonBox(
266 QDialogButtonBox.StandardButton.Ok
267 | QDialogButtonBox.StandardButton.Cancel
268 )
269 buttons.accepted.connect(dialog.accept)
270 buttons.rejected.connect(dialog.reject)
271 form.addRow(buttons)
273 if dialog.exec() != QDialog.DialogCode.Accepted:
274 return
276 cols = cols_spin.value()
277 rows = rows_spin.value()
278 for spin, value in (
279 (self.window.tools.transform_mesh_grid_x, cols),
280 (self.window.tools.transform_mesh_grid_y, rows),
281 ):
282 spin.blockSignals(True)
283 spin.setValue(value)
284 spin.blockSignals(False)
285 self.window.canvas.set_transform_mesh_grid(cols, rows)
286 if not self.window.canvas.transform_active:
287 self.window.statusBar().showMessage(
288 tr("次回のメッシュ変形を 横{cols}×縦{rows} 格子に設定しました。").format(cols=cols, rows=rows),
289 2200,
290 )
293 def start_wire_transform(self, mode, line_colors_override=None):
294 if not self.window.canvas.selection_polygon: 294 ↛ 295line 294 didn't jump to line 295 because the condition on line 294 was never true
295 if not self.window.canvas.auto_select_used_area():
296 QMessageBox.warning(
297 self.window,
298 tr("変形"),
299 tr("使用色がある領域を検出できないため、変形を開始できません。"),
300 )
301 return
302 self.window.statusBar().showMessage(
303 tr("選択範囲がなかったため、使用色がある領域を自動選択しました。"),
304 2600,
305 )
306 self.window.canvas.transform_mesh_cols = self.window.tools.transform_mesh_grid_x.value()
307 self.window.canvas.transform_mesh_rows = self.window.tools.transform_mesh_grid_y.value()
308 self.window.canvas.transform_mesh_grid = self.window.canvas.transform_mesh_cols
310 active_line_colors = (
311 {
312 tuple(int(channel) for channel in rgb[:3])
313 for rgb in line_colors_override
314 }
315 if line_colors_override is not None
316 else {
317 tuple(int(channel) for channel in rgb[:3])
318 for rgb in self.window.palette.selected_rgbs
319 }
320 )
321 self.window.canvas.set_transform_line_colors(active_line_colors)
322 self.window.tools.set_transform_line_colors_available(
323 bool(active_line_colors)
324 )
326 quality_active = self.window.tools.transform_quality.isChecked()
327 self.window.canvas.set_transform_quality(quality_active)
328 self.window.canvas.set_transform_line_threshold(
329 self.window.tools.transform_line_width.value()
330 )
331 self.window.canvas.transform_apply_all_frames = (
332 self.window.tools.selection_all_frames.isChecked()
333 )
334 if not self.window.canvas.begin_selection_transform(mode): 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true
335 self.window.canvas.transform_apply_all_frames = False
336 QMessageBox.warning(
337 self.window, tr("変形"),
338 tr("選択範囲から変形対象を作成できませんでした。")
339 )
340 return
341 self.window.canvas.setFocus()
342 target_note = (
343 (tr("クオリティ方式/") if quality_active else "")
344 + (
345 tr("すべてのコマへ適用します。")
346 if self.window.canvas.transform_apply_all_frames
347 else tr("現在のコマへ適用します。")
348 )
349 )
350 line_note = (
351 tr(" 選択中の使用色{len}色を実線として処理します。").format(len=len(self.window.canvas.transform_tp_line_colors))
352 if quality_active and self.window.canvas.transform_tp_line_colors else ""
353 )
354 self.window.statusBar().showMessage(
355 tr("白い点:変形/枠内:移動/黄色い点:回転。Enterで確定、Escでキャンセルできます。{note}{note2}").format(note=target_note, note2=line_note),
356 5000,
357 )
359 @staticmethod
360 def _qimage_rgba_array(image):
361 return imaging.qimage_rgba_array(image)
363 def _replacement_mapping_for_aligned_images(self, source_image, target_image):
364 """Return source->target RGB mapping when every aligned pixel is consistent."""
365 if (
366 source_image.isNull()
367 or target_image.isNull()
368 or source_image.size() != target_image.size()
369 ):
370 return None
371 source = self._qimage_rgba_array(source_image)
372 target = self._qimage_rgba_array(target_image)
374 # A palette-swapped copy must occupy exactly the same pixels and retain
375 # the same alpha values. Color values may differ.
376 if not np.array_equal(source[:, :, 3], target[:, :, 3]):
377 return None
378 opaque = source[:, :, 3] > 0
379 if not np.any(opaque):
380 return None
382 source_rgb = source[:, :, :3][opaque]
383 target_rgb = target[:, :, :3][opaque]
384 source_packed = (
385 source_rgb[:, 0].astype(np.uint32) << 16
386 | source_rgb[:, 1].astype(np.uint32) << 8
387 | source_rgb[:, 2].astype(np.uint32)
388 )
389 target_packed = (
390 target_rgb[:, 0].astype(np.uint32) << 16
391 | target_rgb[:, 1].astype(np.uint32) << 8
392 | target_rgb[:, 2].astype(np.uint32)
393 )
395 mapping = {}
396 for packed in np.unique(source_packed):
397 mapped_values = np.unique(target_packed[source_packed == packed])
398 if len(mapped_values) != 1:
399 return None
400 mapped = int(mapped_values[0])
401 source_value = int(packed)
402 mapping[(
403 (source_value >> 16) & 255,
404 (source_value >> 8) & 255,
405 source_value & 255,
406 )] = (
407 (mapped >> 16) & 255,
408 (mapped >> 8) & 255,
409 mapped & 255,
410 )
411 return mapping
413 def register_same_image_replacements(self):
414 if not self.window.canvas.frames or not self.window.canvas.layers:
415 return
416 frame_index = self.window.canvas.current_frame
417 source_index = self.window.canvas.active_layer_index
418 source_key = self.window.canvas.resolve_key_frame(frame_index, source_index)
419 if source_key is None:
420 QMessageBox.warning(
421 self.window,
422 tr("同一画像を置換色に登録"),
423 tr("選択レイヤーの現在位置に画像がありません。"),
424 )
425 return
426 source_layer = self.window.canvas.frames[source_key].layers[source_index]
427 if not source_layer.has_content:
428 QMessageBox.warning(
429 self.window,
430 tr("同一画像を置換色に登録"),
431 tr("選択レイヤーの現在位置に画像がありません。"),
432 )
433 return
435 candidates = []
436 for layer_index in range(source_index + 1, len(self.window.canvas.layers)):
437 key_frame = self.window.canvas.resolve_key_frame(frame_index, layer_index)
438 if key_frame is None:
439 continue
440 layer = self.window.canvas.frames[key_frame].layers[layer_index]
441 if layer.has_content:
442 candidates.append((layer_index, key_frame, layer))
444 if not candidates:
445 QMessageBox.warning(
446 self.window,
447 tr("同一画像を置換色に登録"),
448 tr("同じタイムライン位置の上側レイヤーに、比較できる画像がありません。"),
449 )
450 return
452 for _layer_index, _key_frame, target_layer in candidates:
453 mapping = self._replacement_mapping_for_aligned_images(
454 source_layer.image,
455 target_layer.image,
456 )
457 if mapping is None:
458 continue
459 registered = self.window.palette.register_replacements(mapping)
460 if registered <= 0:
461 QMessageBox.warning(
462 self.window,
463 tr("同一画像を置換色に登録"),
464 tr("一致する画像は見つかりましたが、登録できる使用色がありません。"),
465 )
466 return
467 self.window.statusBar().showMessage(
468 tr("上側レイヤー「{name}」から{registered}色を置換色に登録しました。").format(name=target_layer.name, registered=registered),
469 4000,
470 )
471 return
473 QMessageBox.warning(
474 self.window,
475 tr("同一画像を置換色に登録"),
476 tr("上側レイヤーの画像とピクセルが一致していないため、"
477 "置換色には登録できません。\n"
478 "画像サイズ、透明度、色領域の形が同じか確認してください。"),
479 )
481 def main_line_repaint(self):
482 if not self.window.canvas.frames:
483 return
484 answer = QMessageBox.question(
485 self.window,
486 "MainLineRepaint",
487 tr("選択した使用色と、それ以外の部分を別レイヤーに分離します。"),
488 QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
489 QMessageBox.StandardButton.Cancel,
490 )
491 if answer != QMessageBox.StandardButton.Ok:
492 return
494 selected = list(self.window.palette.selected_rgb_set())
495 if not selected:
496 QMessageBox.information(
497 self.window, "MainLineRepaint",
498 tr("使用色欄で分離したい色を1色以上選択してください。")
499 )
500 return
501 selected_set = {tuple(map(int, rgb)) for rgb in selected}
503 source_index = self.window.canvas.active_layer_index
504 frame_total = max(1, len(self.window.canvas.frames))
505 progress = create_counter(
506 self.window,
507 "MainLineRepaint",
508 frame_total * 2,
509 tr("対象色を確認しています"),
510 )
512 found_target = False
513 for scan_index, frame in enumerate(self.window.canvas.frames, 1):
514 update_counter(
515 progress,
516 scan_index - 1,
517 frame_total * 2,
518 tr("{position}の対象色を確認しています").format(
519 position=frame_position(scan_index - 1)),
520 )
521 source = frame.layers[source_index]
522 if not source.has_content:
523 continue
524 pixels = imaging.qimage_rgba_array(source.image)
525 height, width = pixels.shape[:2]
526 visible = pixels[:, :, 3] > 0
527 rgb24 = (
528 pixels[:, :, 0].astype(np.uint32) << 16
529 | pixels[:, :, 1].astype(np.uint32) << 8
530 | pixels[:, :, 2].astype(np.uint32)
531 )
532 selected24 = np.array(
533 [(r << 16) | (g << 8) | b for r, g, b in selected_set],
534 dtype=np.uint32,
535 )
536 match = visible & np.isin(rgb24, selected24)
537 if np.any(match):
538 found_target = True
539 break
541 if not found_target:
542 close_counter(progress)
543 QMessageBox.warning(
544 self.window,
545 "MainLineRepaint",
546 tr("選択した使用色が選択レイヤー内に見つかりませんでした。\n処理は適用しません。"),
547 )
548 return
550 self.window.canvas.push_doc_undo()
552 # Add two layers directly above the source layer:
553 # source (hidden) -> Paint -> LINE
554 paint_index = source_index + 1
555 line_index = source_index + 2
557 for frame in self.window.canvas.frames:
558 source_layer = frame.layers[source_index]
559 source_layer.visible = False
561 paint_layer = Layer(
562 "Paint",
563 blank_image(),
564 visible=True,
565 opacity=1.0,
566 has_content=False,
567 )
568 line_layer = Layer(
569 "LINE",
570 blank_image(),
571 visible=True,
572 opacity=1.0,
573 has_content=False,
574 )
575 frame.layers.insert(paint_index, paint_layer)
576 frame.layers.insert(line_index, line_layer)
578 for frame_index, frame in enumerate(self.window.canvas.frames):
579 update_counter(
580 progress,
581 frame_total + frame_index,
582 frame_total * 2,
583 tr("{position}をレイヤー分離しています").format(
584 position=frame_position(frame_index)),
585 )
586 source = frame.layers[source_index]
587 if not source.has_content:
588 continue
590 pixels = imaging.qimage_rgba_array(source.image)
591 height, width = pixels.shape[:2]
593 visible = pixels[:, :, 3] > 0
594 rgb24 = (
595 pixels[:, :, 0].astype(np.uint32) << 16
596 | pixels[:, :, 1].astype(np.uint32) << 8
597 | pixels[:, :, 2].astype(np.uint32)
598 )
599 selected24 = np.array(
600 [(r << 16) | (g << 8) | b for r, g, b in selected_set],
601 dtype=np.uint32,
602 )
603 line_mask = visible & np.isin(rgb24, selected24)
604 line_pixels = np.zeros_like(pixels)
605 line_pixels[line_mask] = pixels[line_mask]
607 # Paint: remove the line pixels, then fill those gaps using
608 # the most frequent adjacent existing color. No intermediate colors.
609 paint_pixels = pixels.copy()
610 paint_pixels[line_mask, 3] = 0
612 pending = [tuple(v) for v in np.argwhere(line_mask)]
613 for _pass in range(12):
614 if not pending:
615 break
616 remaining = []
617 for y, x in pending:
618 neighbors = []
619 for nx, ny in (
620 (x - 1, y),
621 (x + 1, y),
622 (x, y - 1),
623 (x, y + 1),
624 ):
625 if (
626 0 <= nx < width
627 and 0 <= ny < height
628 and paint_pixels[ny, nx, 3] > 0
629 ):
630 neighbors.append(
631 (
632 int(paint_pixels[ny, nx, 0]),
633 int(paint_pixels[ny, nx, 1]),
634 int(paint_pixels[ny, nx, 2]),
635 )
636 )
637 if not neighbors:
638 remaining.append((y, x))
639 continue
641 counts = {}
642 for color in neighbors:
643 counts[color] = counts.get(color, 0) + 1
644 fill_color = max(counts, key=lambda c: counts[c])
645 paint_pixels[y, x, 0] = fill_color[0]
646 paint_pixels[y, x, 1] = fill_color[1]
647 paint_pixels[y, x, 2] = fill_color[2]
648 paint_pixels[y, x, 3] = 255
649 pending = remaining
651 line_image = QImage(
652 line_pixels.data,
653 width,
654 height,
655 line_pixels.strides[0],
656 QImage.Format.Format_RGBA8888,
657 ).copy().convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
659 paint_image = QImage(
660 paint_pixels.data,
661 width,
662 height,
663 paint_pixels.strides[0],
664 QImage.Format.Format_RGBA8888,
665 ).copy().convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
667 frame.layers[line_index].image = line_image
668 frame.layers[line_index].has_content = bool(np.any(line_mask))
669 frame.layers[line_index].exposure = source.exposure
671 frame.layers[paint_index].image = paint_image
672 frame.layers[paint_index].has_content = bool(np.any(paint_pixels[:, :, 3] > 0))
673 frame.layers[paint_index].exposure = source.exposure
675 close_counter(progress)
676 self.window.canvas.active_layer_index = line_index
677 self.window._used_color_cache.clear()
678 self.window.canvas._color_filter_cache.clear()
679 self.window.canvas._silhouette_cache.clear()
680 self.window._suppress_used_color_refresh_once = True
681 self.window.canvas.changed.emit()
682 self.window.canvas.selectionChanged.emit()
683 self.window.canvas.update()
685 self.window.statusBar().showMessage(
686 tr("LINE/Paintを作成し、元レイヤーを非表示にしました。"),
687 3800,
688 )
690 def remove_dust_fill_surrounding(self):
691 """選択レイヤー内でゴミ取り/塗り抜けを実行する。"""
692 # The data is what we branch on; the text is what the user reads in the
693 # history panel and the status bar.
694 mode = self.window.tools.dust_mode.currentData()
695 mode_label = self.window.tools.dust_mode.currentText()
696 max_area = max(
697 1,
698 min(100, int(self.window.tools.dust_size.value())),
699 )
700 selected_only = (
701 self.window.tools.dust_selected_only.isChecked()
702 )
703 selected_colors = self.window.canvas.selected_used_colors()
704 selected_colors.discard((255,255,255))
706 if selected_only and not selected_colors: 706 ↛ 707line 706 didn't jump to line 707 because the condition on line 706 was never true
707 self.window.statusBar().showMessage(
708 tr("使用色パネルで対象色を選択してください。"),
709 2800,
710 )
711 return
713 # 適用範囲は一括処理ランナーへ渡すスコープ1つで表す。保持区間の畳み込み
714 # (同じキーフレーム画像を1度だけ処理する)はランナー側の責務。
715 scope = self.window.scope.current_frame(
716 self.window.tools.dust_all_frames.isChecked(),
717 selection_only=selected_only,
718 )
719 if not resolve_cells(self.window.canvas, scope): 719 ↛ 720line 719 didn't jump to line 720 because the condition on line 719 was never true
720 self.window.statusBar().showMessage(
721 tr("選択レイヤーに処理できるキーフレームがありません。"),
722 2800,
723 )
724 return
726 def component_list(mask):
727 """4方向接続成分を返す。各画素は1度だけ走査する。"""
728 pending = np.asarray(
729 mask,
730 dtype=bool,
731 ).copy()
732 candidate_y, candidate_x = np.nonzero(
733 pending
734 )
735 height, width = pending.shape
737 for y0, x0 in zip(
738 candidate_y,
739 candidate_x,
740 ):
741 y0 = int(y0)
742 x0 = int(x0)
743 if not pending[y0, x0]:
744 continue
746 stack = [(x0, y0)]
747 pending[y0, x0] = False
748 component = []
749 touches_edge = False
751 while stack:
752 x, y = stack.pop()
753 component.append((y, x))
754 if ( 754 ↛ 759line 754 didn't jump to line 759 because the condition on line 754 was never true
755 x == 0 or y == 0
756 or x == width - 1
757 or y == height - 1
758 ):
759 touches_edge = True
761 if x > 0 and pending[y, x - 1]: 761 ↛ 762line 761 didn't jump to line 762 because the condition on line 761 was never true
762 pending[y, x - 1] = False
763 stack.append((x - 1, y))
764 if x + 1 < width and pending[y, x + 1]:
765 pending[y, x + 1] = False
766 stack.append((x + 1, y))
767 if y > 0 and pending[y - 1, x]: 767 ↛ 768line 767 didn't jump to line 768 because the condition on line 767 was never true
768 pending[y - 1, x] = False
769 stack.append((x, y - 1))
770 if y + 1 < height and pending[y + 1, x]:
771 pending[y + 1, x] = False
772 stack.append((x, y + 1))
774 yield component, touches_edge
776 changed_by_cell = {}
778 def despeckle(context):
779 """1レイヤー分のゴミ取り/塗り抜け。純粋な op として渡す。
781 入力画像は書き換えず、変更後の画像を返す(変更なしは None)。
782 対象セルの決定・進捗・Undo のまとめ上げはランナー側が持つ。
783 """
784 layer = context.layer
785 pixels = imaging.qimage_rgba_array(layer.image)
786 height, width = pixels.shape[:2]
787 if width <= 0 or height <= 0: 787 ↛ 788line 787 didn't jump to line 788 because the condition on line 787 was never true
788 return None
790 rgb = pixels[:,:,:3]
791 pseudo_white = (
792 (pixels[:,:,3] == 0)
793 | np.all(rgb == 255, axis=2)
794 )
795 cell_changed = 0
797 if mode == "fill_holes": 797 ↛ 799line 797 didn't jump to line 799 because the condition on line 797 was never true
798 # 外周につながらない小さな白領域を周囲色で埋める。
799 starts = []
800 starts.extend(
801 (int(x), 0)
802 for x in np.flatnonzero(
803 pseudo_white[0,:]
804 )
805 )
806 if height > 1:
807 starts.extend(
808 (int(x), height - 1)
809 for x in np.flatnonzero(
810 pseudo_white[-1,:]
811 )
812 )
813 if width > 1:
814 starts.extend(
815 (0, int(y))
816 for y in np.flatnonzero(
817 pseudo_white[:,0]
818 )
819 )
820 starts.extend(
821 (width - 1, int(y))
822 for y in np.flatnonzero(
823 pseudo_white[:,-1]
824 )
825 )
827 outside = (
828 self.window.canvas._scanline_connected_region(
829 pseudo_white,
830 starts,
831 )
832 if starts
833 else np.zeros_like(pseudo_white)
834 )
835 holes = pseudo_white & ~outside
837 for component, _touches_edge in component_list(
838 holes
839 ):
840 area = len(component)
841 if area == 0 or area > max_area:
842 continue
844 border_positions = set()
845 for y, x in component:
846 for ny in range(
847 max(0, y - 1),
848 min(height, y + 2),
849 ):
850 for nx in range(
851 max(0, x - 1),
852 min(width, x + 2),
853 ):
854 if (
855 (ny != y or nx != x)
856 and not pseudo_white[ny, nx]
857 ):
858 border_positions.add(
859 (ny, nx)
860 )
862 if not border_positions:
863 continue
865 border_yx = np.asarray(
866 tuple(border_positions),
867 dtype=np.int32,
868 )
869 border_rgb = pixels[
870 border_yx[:,0],
871 border_yx[:,1],
872 :3,
873 ]
875 if selected_only:
876 keep = np.zeros(
877 len(border_rgb),
878 dtype=bool,
879 )
880 for selected_color in selected_colors:
881 keep |= np.all(
882 border_rgb
883 == np.asarray(
884 selected_color,
885 dtype=np.uint8,
886 ),
887 axis=1,
888 )
889 border_rgb = border_rgb[keep]
890 if border_rgb.size == 0:
891 continue
893 packed = (
894 (
895 border_rgb[:,0].astype(
896 np.uint32
897 ) << 16
898 )
899 | (
900 border_rgb[:,1].astype(
901 np.uint32
902 ) << 8
903 )
904 | border_rgb[:,2].astype(
905 np.uint32
906 )
907 )
908 values, counts = np.unique(
909 packed,
910 return_counts=True,
911 )
912 selected = int(
913 values[int(np.argmax(counts))]
914 )
915 fill = np.asarray(
916 [
917 (selected >> 16) & 255,
918 (selected >> 8) & 255,
919 selected & 255,
920 ],
921 dtype=np.uint8,
922 )
923 coordinates = np.asarray(
924 component,
925 dtype=np.int32,
926 )
927 cy = coordinates[:,0]
928 cx = coordinates[:,1]
929 pixels[cy,cx,:3] = fill
930 pixels[cy,cx,3] = 255
931 cell_changed += area
933 elif mode == "line_noise":
934 # 線周りのノイズ除去(Issue #19)。適用サイズは線からの距離に
935 # 読み替える。線そのものと色面のふちには触れない。
936 cleaned, cell_changed = line_noise.remove_line_noise(
937 pixels,
938 radius=max(1, min(8, max_area)),
939 )
940 if cell_changed: 940 ↛ 1008line 940 didn't jump to line 1008 because the condition on line 940 was always true
941 pixels[:] = cleaned
943 else:
944 # ゴミ取り:小さな色点を白へ変更する。
945 removal_mask = np.zeros(
946 (height, width),
947 dtype=bool,
948 )
950 if selected_only: 950 ↛ 953line 950 didn't jump to line 953 because the condition on line 950 was never true
951 # 選択色ごとに独立判定する。
952 # 青1pxが黒に接していても青成分は1pxとして消える。
953 for selected_color in selected_colors:
954 color_mask = (
955 ~pseudo_white
956 & np.all(
957 rgb
958 == np.asarray(
959 selected_color,
960 dtype=np.uint8,
961 ),
962 axis=2,
963 )
964 )
965 for component, _edge in component_list(
966 color_mask
967 ):
968 if (
969 0 < len(component)
970 <= max_area
971 ):
972 coordinates = np.asarray(
973 component,
974 dtype=np.int32,
975 )
976 removal_mask[
977 coordinates[:,0],
978 coordinates[:,1],
979 ] = True
980 else:
981 # 未選択時は、白背景から独立した小さな色塊を削除。
982 foreground = ~pseudo_white
983 for component, touches_edge in component_list(
984 foreground
985 ):
986 if ( 986 ↛ 983line 986 didn't jump to line 983 because the condition on line 986 was always true
987 not touches_edge
988 and 0 < len(component)
989 <= max_area
990 ):
991 coordinates = np.asarray(
992 component,
993 dtype=np.int32,
994 )
995 removal_mask[
996 coordinates[:,0],
997 coordinates[:,1],
998 ] = True
1000 cell_changed = int(
1001 np.count_nonzero(removal_mask)
1002 )
1003 if cell_changed: 1003 ↛ 1008line 1003 didn't jump to line 1008 because the condition on line 1003 was always true
1004 pixels[removal_mask,:3] = 255
1005 pixels[removal_mask,3] = 255
1008 if not cell_changed: 1008 ↛ 1009line 1008 didn't jump to line 1009 because the condition on line 1008 was never true
1009 return None
1010 changed_by_cell[(context.frame, context.layer_index)] = cell_changed
1011 # ○化や未使用化はせず、キーフレーム構造を維持する。
1012 return imaging.rgba_array_to_qimage(pixels)
1014 result = self.window.scope.run_over(
1015 scope,
1016 despeckle,
1017 label=mode_label,
1018 progress_label=tr("{label}対象を解析しています").format(label=mode_label),
1019 count_pixels=lambda context, _image: changed_by_cell.get(
1020 (context.frame, context.layer_index), 0
1021 ),
1022 cancellable=True,
1023 )
1024 changed_cells = result.changed_cells
1025 changed_pixels = result.changed_pixels
1026 if result.cancelled: 1026 ↛ 1027line 1026 didn't jump to line 1027 because the condition on line 1026 was never true
1027 self.window.statusBar().showMessage(
1028 tr("{label}を中止しました。画像は変更していません。").format(label=mode_label),
1029 2800,
1030 )
1031 return
1033 if not changed_pixels: 1033 ↛ 1034line 1033 didn't jump to line 1034 because the condition on line 1033 was never true
1034 target_text = (
1035 tr("選択色の") if selected_only else ""
1036 )
1037 self.window.statusBar().showMessage(
1038 tr("指定サイズ以内の{text}{label}対象は見つかりませんでした。").format(text=target_text, label=mode_label),
1039 3000,
1040 )
1041 return
1043 # 表示キャッシュの破棄・セル通知・再描画は run_over_scope 済み。
1044 # 進捗ダイアログを閉じた直後に旧表示が残らないよう、ここでは即時の
1045 # 再描画だけを追加で行う。
1046 self.window.canvas.repaint()
1047 QApplication.processEvents()
1049 self.window.used_color.refresh_with_counter(
1050 tr("{label}後の使用色を更新しています").format(label=mode_label)
1051 )
1053 # 使用色の再走査後にも再描画を予約し、進捗ダイアログの
1054 # 閉鎖後に旧表示へ戻ることを防ぐ。
1055 self.window.canvas.update()
1056 self.window.statusBar().showMessage(
1057 tr("選択レイヤーの{cells}コマで{pixels:,}ピクセルへ{label}を適用しました。").format(cells=changed_cells, pixels=changed_pixels, label=mode_label),
1058 3600,
1059 )