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

1"""Line-art operations; owned by ``MainWindow`` as ``window.line_ops``. 

2 

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. 

7 

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. 

11 

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

13""" 

14from typing import TYPE_CHECKING 

15 

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 

36 

37if TYPE_CHECKING: 

38 from .main_window import MainWindow 

39 

40log = get_logger(__name__) 

41 

42_OPERATION_ERRORS = OPERATION_ERRORS 

43 

44 

45class LineOpsController: 

46 """Owned by ``MainWindow`` as ``window.line_ops``. 

47 

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

49 """ 

50 

51 def __init__(self, window: "MainWindow"): 

52 self.window = window 

53 

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 ) 

72 

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

79 

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

97 

98 outgoing = {} 

99 for start, end in edges: 

100 outgoing.setdefault(start, []).append(end) 

101 

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 

116 

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 ] 

128 

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) 

139 

140 next_point = min(candidates, key=turn_priority) 

141 unused.remove((current, next_point)) 

142 previous, current = current, next_point 

143 contour.append(current) 

144 

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

147 

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 

163 

164 @classmethod 

165 def _largest_mask_outer_contour(cls, mask): 

166 contours = cls._mask_contours(mask) 

167 return cls._largest_contour(contours) 

168 

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

173 

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

183 

184 return max(contours, key=area) 

185 

186 

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) 

203 

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) 

217 

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 

223 

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) 

228 

229 self.window.view_preview.sync_black_check(checked) 

230 

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) 

237 

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 ) 

246 

247 def choose_transform_mesh_grid(self): 

248 dialog = QDialog(self.window) 

249 dialog.setWindowTitle(tr("メッシュ格子数")) 

250 form = QFormLayout(dialog) 

251 

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) 

264 

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) 

272 

273 if dialog.exec() != QDialog.DialogCode.Accepted: 

274 return 

275 

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 ) 

291 

292 

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 

309 

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 ) 

325 

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 ) 

358 

359 @staticmethod 

360 def _qimage_rgba_array(image): 

361 return imaging.qimage_rgba_array(image) 

362 

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) 

373 

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 

381 

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 ) 

394 

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 

412 

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 

434 

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

443 

444 if not candidates: 

445 QMessageBox.warning( 

446 self.window, 

447 tr("同一画像を置換色に登録"), 

448 tr("同じタイムライン位置の上側レイヤーに、比較できる画像がありません。"), 

449 ) 

450 return 

451 

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 

472 

473 QMessageBox.warning( 

474 self.window, 

475 tr("同一画像を置換色に登録"), 

476 tr("上側レイヤーの画像とピクセルが一致していないため、" 

477 "置換色には登録できません。\n" 

478 "画像サイズ、透明度、色領域の形が同じか確認してください。"), 

479 ) 

480 

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 

493 

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} 

502 

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 ) 

511 

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 

540 

541 if not found_target: 

542 close_counter(progress) 

543 QMessageBox.warning( 

544 self.window, 

545 "MainLineRepaint", 

546 tr("選択した使用色が選択レイヤー内に見つかりませんでした。\n処理は適用しません。"), 

547 ) 

548 return 

549 

550 self.window.canvas.push_doc_undo() 

551 

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 

556 

557 for frame in self.window.canvas.frames: 

558 source_layer = frame.layers[source_index] 

559 source_layer.visible = False 

560 

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) 

577 

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 

589 

590 pixels = imaging.qimage_rgba_array(source.image) 

591 height, width = pixels.shape[:2] 

592 

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] 

606 

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 

611 

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 

640 

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 

650 

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) 

658 

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) 

666 

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 

670 

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 

674 

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

684 

685 self.window.statusBar().showMessage( 

686 tr("LINE/Paintを作成し、元レイヤーを非表示にしました。"), 

687 3800, 

688 ) 

689 

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

705 

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 

712 

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 

725 

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 

736 

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 

745 

746 stack = [(x0, y0)] 

747 pending[y0, x0] = False 

748 component = [] 

749 touches_edge = False 

750 

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 

760 

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

773 

774 yield component, touches_edge 

775 

776 changed_by_cell = {} 

777 

778 def despeckle(context): 

779 """1レイヤー分のゴミ取り/塗り抜け。純粋な op として渡す。 

780 

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 

789 

790 rgb = pixels[:,:,:3] 

791 pseudo_white = ( 

792 (pixels[:,:,3] == 0) 

793 | np.all(rgb == 255, axis=2) 

794 ) 

795 cell_changed = 0 

796 

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 ) 

826 

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 

836 

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 

843 

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 ) 

861 

862 if not border_positions: 

863 continue 

864 

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 ] 

874 

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 

892 

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 

932 

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 

942 

943 else: 

944 # ゴミ取り:小さな色点を白へ変更する。 

945 removal_mask = np.zeros( 

946 (height, width), 

947 dtype=bool, 

948 ) 

949 

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 

999 

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 

1006 

1007 

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) 

1013 

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 

1032 

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 

1042 

1043 # 表示キャッシュの破棄・セル通知・再描画は run_over_scope 済み。 

1044 # 進捗ダイアログを閉じた直後に旧表示が残らないよう、ここでは即時の 

1045 # 再描画だけを追加で行う。 

1046 self.window.canvas.repaint() 

1047 QApplication.processEvents() 

1048 

1049 self.window.used_color.refresh_with_counter( 

1050 tr("{label}後の使用色を更新しています").format(label=mode_label) 

1051 ) 

1052 

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 )