Coverage for paintmaskanimator/canvas_paint_tools.py: 66%
637 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"""Pixel-writing tools for PaintCanvas: brush, fill, line, shape and lasso.
3Split out of ``canvas.py`` as a mixin. Everything here turns a pointer gesture
4into pixels on the active layer -- the pressure-driven brush stroke, the flood
5fill and its region search, the auto-select-by-region helper, the line/shape tool
6previews and their commits, and lasso polygon filling. Undo entries are pushed
7through ``PaintCanvas.push_undo`` as usual; they run against a live
8``PaintCanvas``.
9"""
10import dataclasses
11import math
12import numpy as np
13from typing import Any
14from PySide6.QtCore import QPoint, QPointF, QRect, QRectF, Qt
15from PySide6.QtGui import QColor, QImage, QPainter, QPainterPath, QPen, QPolygonF
16from PySide6.QtWidgets import QApplication
17from .i18n import tr
18from ._canvas_members import CanvasMembers
19from . import bucket_fill, imaging
20from .undo_entries import LayerRegionUndo
21from .pressure import _pressure_bezier_at
22from .brush_raster import paint_segment
23from .logging_setup import get_logger
25log = get_logger(__name__)
28#: ドラッグ塗りで、端に接した領域を「外の背景ではない」とみなす大きさの上限
29#: (画像全体に対する割合)。最初にクリックした部分の3倍までもよしとする。
30BUCKET_DRAG_EDGE_AREA = 0.1
33class PaintToolsMixin(CanvasMembers):
34 # バケツのドラッグ塗りの途中経過(押している間だけ持つ)。
35 _bucket_drag: Any = None
37 def pressure_size_scale(self, pressure):
38 """筆圧から線幅倍率だけを返す。不透明度には使用しない。"""
39 if not self.pressure_enabled:
40 return 1.0
41 pressure = max(
42 0.0,
43 min(1.0, float(pressure)),
44 )
45 points = getattr(
46 self,
47 "pressure_curve_points",
48 None,
49 )
50 if points and len(points) >= 2: 50 ↛ 56line 50 didn't jump to line 56 because the condition on line 50 was always true
51 value = _pressure_bezier_at(
52 points,
53 pressure,
54 )
55 else:
56 value = pressure ** self.pressure_curve
57 return (
58 max(self.pressure_min, value)
59 * self.pressure_max
60 )
62 def pressure_value(self, pressure):
63 """旧呼び出し互換。返す値は線幅倍率のみ。"""
64 return self.pressure_size_scale(pressure)
66 def _update_stroke_region(self, a, b, width):
67 """Repaint only the widget area touched by a brush segment."""
68 wa = self.canvas_to_widget(a)
69 wb = self.canvas_to_widget(b)
70 margin = max(4, int(math.ceil(float(width) * max(self.zoom, 0.01) / 2.0)) + 3)
71 dirty = QRectF(wa, wb).normalized().adjusted(
72 -margin, -margin, margin, margin
73 ).toAlignedRect()
74 self.update(dirty)
76 def draw_line(self,a,b,pressure):
77 """完全な非AAマスクで、下地色と均一にRGB合成する。"""
78 painter_tool = self.effective_tool()
79 source_color = self.paint_source_color()
80 # 筆圧はここで線幅にだけ使用する。
81 width = max(
82 0.5,
83 self.pen_size
84 * self.pressure_size_scale(pressure)
85 )
86 draw_width = max(0.5, float(width))
87 raster_a = QPointF(float(a.x()), float(a.y()))
88 raster_b = QPointF(float(b.x()), float(b.y()))
90 margin = int(math.ceil(draw_width / 2.0)) + 3
91 left = max(
92 0,
93 int(math.floor(min(a.x(), b.x()))) - margin,
94 )
95 top = max(
96 0,
97 int(math.floor(min(a.y(), b.y()))) - margin,
98 )
99 right = min(
100 self.active_layer.image.width(),
101 int(math.ceil(max(a.x(), b.x()))) + margin + 1,
102 )
103 bottom = min(
104 self.active_layer.image.height(),
105 int(math.ceil(max(a.y(), b.y()))) + margin + 1,
106 )
107 if right <= left or bottom <= top:
108 return
110 rect = QRectF(
111 left,
112 top,
113 right - left,
114 bottom - top,
115 ).toAlignedRect()
116 self._ensure_before_region(rect)
117 before_region = self._stroke_before_region(rect)
118 overlay = QImage(
119 rect.width(),
120 rect.height(),
121 QImage.Format.Format_ARGB32_Premultiplied,
122 )
123 overlay.fill(Qt.GlobalColor.transparent)
125 painter = QPainter(overlay)
126 painter.setRenderHint(
127 QPainter.RenderHint.Antialiasing,
128 False,
129 )
130 painter.setRenderHint(
131 QPainter.RenderHint.TextAntialiasing,
132 False,
133 )
134 self.apply_selection_clip(
135 painter,
136 rect.x(),
137 rect.y(),
138 )
139 local_a = QPointF(
140 raster_a.x() - rect.x(),
141 raster_a.y() - rect.y(),
142 )
143 local_b = QPointF(
144 raster_b.x() - rect.x(),
145 raster_b.y() - rect.y(),
146 )
147 paint_segment(painter, local_a, local_b, source_color, draw_width)
148 painter.end()
150 sub_only = bool(
151 painter_tool == "brush"
152 and not getattr(self, "mask_all_enabled", True)
153 )
154 if sub_only: 154 ↛ 155line 154 didn't jump to line 155 because the condition on line 154 was never true
155 overlay_rgba = self._qimage_rgba_array(overlay)
156 base_region = before_region
157 base_rgba = self._qimage_rgba_array(base_region)
159 selected_rgbs = self.selected_mask_colors()
160 eligible = np.zeros(
161 base_rgba.shape[:2],
162 dtype=bool,
163 )
164 if selected_rgbs:
165 packed = (
166 (
167 base_rgba[:, :, 0].astype(np.uint32)
168 << 16
169 )
170 | (
171 base_rgba[:, :, 1].astype(np.uint32)
172 << 8
173 )
174 | base_rgba[:, :, 2].astype(np.uint32)
175 )
176 selected_values = np.fromiter(
177 (
178 (r << 16) | (g << 8) | b
179 for r, g, b in selected_rgbs
180 ),
181 dtype=np.uint32,
182 count=len(selected_rgbs),
183 )
184 eligible |= (
185 (base_rgba[:, :, 3] > 0)
186 & np.isin(packed, selected_values)
187 )
188 if self.background_mask_rgb in selected_rgbs:
189 eligible |= (
190 (base_rgba[:, :, 3] == 0)
191 | np.all(
192 base_rgba[:, :, :3] == 255,
193 axis=2,
194 )
195 )
197 overlay_rgba[:, :, 3][~eligible] = 0
198 overlay = self._rgba_array_to_qimage(
199 overlay_rgba
200 )
202 colors = self._blend_overlay_into_active_layer(
203 overlay,
204 rect.topLeft(),
205 exact_colors=(source_color,),
206 )
207 color_set = getattr(
208 self,
209 "_brush_blended_colors",
210 None,
211 )
212 if color_set is not None: 212 ↛ 215line 212 didn't jump to line 215 because the condition on line 212 was always true
213 color_set.update(colors)
215 self.active_layer.has_content = True
216 self._update_stroke_region(a, b, draw_width)
218 @staticmethod
219 def _scanline_connected_region(*args, **kwargs):
220 return imaging.scanline_connected_region(*args, **kwargs)
222 def _bucket_tools(self):
223 """ツールパネル。ウィンドウを持たないテスト用キャンバスでは ``None``。"""
224 window: Any = self.window()
225 return getattr(window, "tools", None)
227 def bucket_options(self, *, respect_modifiers=True):
228 """現在のバケツ設定を UI から読み出す。
230 ``respect_modifiers`` が真なら、Shift を押しながらの塗りは含み塗りを
231 行わない(Issue #43)。ペイントマンと同じく、含み塗りを ON にしたまま
232 1回だけ外せるようにするため。自動塗りのように人の操作と切り離して
233 呼ぶところは偽を渡す。
234 """
235 # 含み塗りの対象は、ツールパネルの「含む色」に登録した色。未登録なら
236 # サブカラー。使用色パネルでの選択に依存させると、何を含むのかを
237 # 塗る前に別の場所で準備する必要があり分かりにくい。
238 tools = self._bucket_tools()
239 slots = getattr(tools, "bucket_include_colors", None)
240 mask_colors = tuple(slots.colors()) if slots is not None else ()
241 if not mask_colors:
242 sub = QColor(self.sub_color)
243 mask_colors = ((sub.red(), sub.green(), sub.blue()),)
244 options = bucket_fill.BucketOptions.from_tools(
245 tools,
246 mask_colors=mask_colors,
247 background_selected=self.background_mask_rgb in mask_colors,
248 island_replacements=self._island_replacements(),
249 )
250 if respect_modifiers and options.include_masks and self._shift_held():
251 options = dataclasses.replace(options, include_masks=False)
252 if self.effective_tool() == "closed_fill":
253 # 閉領域フィル(Issue #41):線の隙間を閉じ、開いた線の先も伸ばして
254 # から塗る。1ドットの隙間でこぼれるところを拾うための道具なので、
255 # 隙間閉じを設定任せにしない。逆に「塗り残し拾い」は必要以上に
256 # 広がる元になるため、こちらでは使わない。
257 options = dataclasses.replace(
258 options,
259 close_gap=True,
260 extend_lines=True,
261 island_max_width=0,
262 )
263 return options
265 @staticmethod
266 def _shift_held():
267 if QApplication.instance() is None: 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true
268 return False
269 return bool(
270 QApplication.keyboardModifiers() & Qt.KeyboardModifier.ShiftModifier
271 )
273 def _island_replacements(self):
274 """使用色パネルに登録した置換色 ``((元の RGB, 置換色の RGB), ...)``。"""
275 window: Any = self.window()
276 palette = getattr(window, "palette", None)
277 replacements = getattr(palette, "replacements", None) or {}
278 return tuple(
279 (tuple(int(value) for value in source[:3]),
280 (color.red(), color.green(), color.blue()))
281 for source, color in replacements.items()
282 )
284 def _fill_islands(self, patch, islands, bounds):
285 """囲まれた島を、それぞれの置換色で ``patch``(外接矩形の部分)へ塗る。"""
286 x0, y0, x1, y1 = bounds
287 written = set()
288 for rgb, mask in islands:
289 local = mask[y0:y1, x0:x1]
290 if local.any(): 290 ↛ 288line 290 didn't jump to line 288 because the condition on line 290 was always true
291 written.update(self._fill_patch(patch, local, QColor(*rgb)))
292 return written
294 def _layer_fill_pixels(self, image):
295 """RGBA8888 に変換した複製と、その画素ビューを返す。"""
296 converted = image.convertToFormat(QImage.Format.Format_RGBA8888)
297 width, height = converted.width(), converted.height()
298 rows = np.frombuffer(
299 imaging.qimage_buffer(converted), dtype=np.uint8
300 ).reshape((height, converted.bytesPerLine()))
301 return converted, rows[:, :width * 4].reshape((height, width, 4))
303 # ------------------------------------------------------------------
304 # 参照レイヤー:どの絵を見て塗る範囲を決めるか。
305 # ------------------------------------------------------------------
306 def bucket_reference_pixels(self, options, frame, layer_index,
307 layer_image, layer_pixels):
308 """判定に使う ``(保持用の QImage, RGBA8888 の画素)`` を返す。
310 塗る先はいつもアクティブレイヤーなので、ここで返すのは「どこを塗るか」を
311 決めるための絵だけ。参照が「選択したレイヤーのみ」なら、呼び出し側が
312 すでに持っている画素をそのまま返す(余分な変換をしない)。
314 画素は返した QImage のバッファを指すので、使い終わるまで QImage を
315 手放さないように両方を返す。
316 """
317 image = self._bucket_reference_image(
318 options, frame, layer_index, layer_image
319 )
320 if image is layer_image:
321 return layer_image, layer_pixels
322 drag = self._bucket_drag
323 if drag is not None:
324 key = image.cacheKey()
325 cached = drag["reference_pixels"].get(key)
326 if cached is None:
327 cached = self._layer_fill_pixels(image)
328 drag["reference_pixels"].clear()
329 drag["reference_pixels"][key] = cached
330 return cached
331 return self._layer_fill_pixels(image)
333 def _bucket_reference_image(self, options, frame, layer_index, fallback):
334 """参照レイヤーの設定に合う絵を返す。無ければ ``fallback`` のまま。"""
335 reference = getattr(
336 options, "reference", bucket_fill.REFERENCE_LAYER
337 )
338 if reference == bucket_fill.REFERENCE_CANVAS:
339 return self._bucket_canvas_reference(frame, fallback)
340 if reference == bucket_fill.REFERENCE_INITIAL:
341 key_frame = self.resolve_key_frame(frame, layer_index)
342 if key_frame is None: 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true
343 return fallback
344 initial = self.frames[key_frame].layers[layer_index].initial_image
345 # 初期状態を保持していないセルは、今の絵で判定するしかない。
346 if initial is None or initial.size() != fallback.size(): 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true
347 return fallback
348 return initial
349 return fallback
351 def _bucket_canvas_reference(self, frame, fallback):
352 """表示しているレイヤーを重ねた絵。別のレイヤーの線でも区切れるように。
354 用紙レイヤーは外す。キャンバス一面の不透明な色で覆われると、透明を
355 たどる対象色が働かなくなるため。表示プレビューを通さない生の絵を重ねる。
356 """
357 if not (0 <= frame < len(self.frames)): 357 ↛ 358line 357 didn't jump to line 358 because the condition on line 357 was never true
358 return fallback
359 result = QImage(
360 fallback.size(), QImage.Format.Format_ARGB32_Premultiplied
361 )
362 result.fill(Qt.GlobalColor.transparent)
363 painter = QPainter(result)
364 for layer_index in range(len(self.frames[frame].layers)):
365 key_frame = self.resolve_key_frame(frame, layer_index)
366 if key_frame is None: 366 ↛ 367line 366 didn't jump to line 367 because the condition on line 366 was never true
367 continue
368 layer = self.frames[key_frame].layers[layer_index]
369 if not layer.visible or layer.is_paper: 369 ↛ 370line 369 didn't jump to line 370 because the condition on line 369 was never true
370 continue
371 if layer.image is None or layer.image.isNull(): 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true
372 continue
373 painter.setOpacity(layer.opacity)
374 painter.drawImage(0, 0, layer.image)
375 painter.end()
376 return result
378 def _fill_patch(self, patch, patch_region, replacement):
379 """``patch`` の ``patch_region`` を塗る。書き込んだ RGB の組を返す。
381 白は消しゴムとして alpha を抜き、空の組を返す(使用色に登録しない)。
382 """
383 source_rgb = np.asarray(
384 [replacement.red(), replacement.green(), replacement.blue()],
385 dtype=np.uint8,
386 )
387 if bool(np.all(source_rgb == 255)):
388 # 白バケツは本物の消しゴム:該当領域を alpha=0 へ抜く。
389 patch[patch_region] = 0
390 return ()
392 opacity = max(0.0, min(1.0, float(getattr(self, "fill_opacity", 1.0))))
393 if opacity >= 0.999999:
394 # 100%は正規RGBをそのまま書き込む(近似色を生成しない)。
395 patch[patch_region, :3] = source_rgb
396 written_colors = (
397 (int(source_rgb[0]), int(source_rgb[1]), int(source_rgb[2])),
398 )
399 else:
400 # 100%未満だけ下地RGB(透明は白とみなす)と混色する。
401 base_rgb = patch[patch_region, :3].astype(np.float32)
402 base_rgb[patch[patch_region, 3] == 0] = 255.0
403 mixed = np.clip(
404 np.rint(
405 base_rgb * (1.0 - opacity)
406 + source_rgb.astype(np.float32)[None, :] * opacity
407 ),
408 0,
409 255,
410 ).astype(np.uint8)
411 # 混色の結果が白になった画素を消しゴム扱いにしない。
412 mixed[np.all(mixed == 255, axis=1)] = 254
413 patch[patch_region, :3] = mixed
414 written_colors = tuple(
415 tuple(int(channel) for channel in row)
416 for row in np.unique(mixed, axis=0)
417 )
418 patch[patch_region, 3] = 255
419 return written_colors
421 def flood_fill(self, p):
422 if self._bucket_all_frames_enabled():
423 self.flood_fill_over_frames(p)
424 return
425 # 串刺し塗りの指定待ち中にチェックを外したら、途中の串刺し塗りは捨てる。
426 self.abort_skewer_fill()
427 self._bucket_fill_at(p, push_undo=True)
429 def _bucket_fill_at(self, p, push_undo=True, quiet=False, accept=None, line_cache=None):
430 """``p`` を種に1か所塗る。``(塗ったマスク, 塗らずに済ませた領域)`` を返す。
432 塗れなければ塗ったマスクは None。``push_undo`` が偽なら Undo を積まない
433 (ドラッグ塗りが最後にまとめて積む)。``accept`` を渡すと、塗る前に
434 ``accept(結果)`` で塗ってよいかを決め、偽ならその領域を2つ目に返す。
435 """
436 drag = self._bucket_drag
437 active = self.active_layer.image
438 if (drag is not None and drag["frame"] == self.current_frame
439 and drag["layer"] == self.active_layer_index
440 and drag["pixels_key"] == active.cacheKey()):
441 image, pixels = drag["pixels"]
442 else:
443 image, pixels = self._layer_fill_pixels(active)
444 start_x, start_y = int(p.x()), int(p.y())
445 selection_mask = self.selection_mask_bool(image.width(), image.height())
446 options = self.bucket_options()
447 # 判定は参照レイヤーの絵で行い、書き込みはアクティブレイヤーへ。
448 _reference_image, reference_pixels = self.bucket_reference_pixels(
449 options, self.current_frame, self.active_layer_index, image, pixels
450 )
451 result = bucket_fill.compute_fill_region(
452 reference_pixels, start_x, start_y, options, selection_mask, line_cache=line_cache,
453 )
454 if not result.ok: 454 ↛ 455line 454 didn't jump to line 455 because the condition on line 454 was never true
455 message = None if quiet else self._bucket_failure_message(result.reason)
456 if message:
457 self.status_message.emit(message)
458 return None, result.region
459 if accept is not None and not accept(result):
460 return None, result.mask
462 # Undo は塗る範囲が決まってから、その外接矩形だけを保存する。
463 # 大きなキャンバスでレイヤー全体をコピーしないため。
464 assert result.bounds is not None # ok なら必ず外接矩形がある
465 x0, y0, x1, y1 = result.bounds
466 fill_rect = QRect(x0, y0, x1 - x0, y1 - y0)
467 if push_undo and not self.push_layer_region_undo(fill_rect): 467 ↛ 468line 467 didn't jump to line 468 because the condition on line 467 was never true
468 return None, None
469 # 以降の書き込みは外接矩形の中だけで行い、その部分だけをレイヤーへ戻す。
470 patch = pixels[y0:y1, x0:x1]
471 written_colors = set(self._fill_patch(
472 patch, result.mask[y0:y1, x0:x1], self.paint_source_color()
473 ))
474 written_colors.update(self._fill_islands(patch, result.islands, result.bounds))
475 written_colors = tuple(sorted(written_colors))
476 self._write_rgba_patch(patch, fill_rect)
477 if drag is not None:
478 drag["pixels"] = (image, pixels)
479 drag["pixels_key"] = self.active_layer.image.cacheKey()
480 self.active_layer.has_content = True
481 if written_colors:
482 self._emit_actual_paint_colors(written_colors)
483 self.cellChanged.emit(self.current_frame, self.active_layer_index)
484 self.update()
485 return result.mask, None
487 # ------------------------------------------------------------------
488 # ドラッグ塗り:なぞった軌跡が通った囲まれた部分を、それぞれ塗る。
489 # ------------------------------------------------------------------
490 def begin_bucket_drag(self, p):
491 """バケツを押した。最初の1か所を塗り、なぞりの準備をする。"""
492 self.finish_bucket_drag()
493 layer = self.active_layer
494 # 画像は暗黙共有なので、ここでは複製しない(塗った時点で分かれる)。
495 before = QImage(layer.image)
496 converted, layer_pixels = self._layer_fill_pixels(before)
497 width, height = before.width(), before.height()
498 x, y = int(p.x()), int(p.y())
499 if not (0 <= x < width and 0 <= y < height): 499 ↛ 500line 499 didn't jump to line 500 because the condition on line 499 was never true
500 self._bucket_drag = None
501 return
502 options = self.bucket_options()
503 # なぞる先の判定も、1か所塗るときと同じ絵を見る。
504 _reference_image, original = self.bucket_reference_pixels(
505 options, self.current_frame, self.active_layer_index,
506 converted, layer_pixels,
507 )
508 category = bucket_fill.seed_category(original, x, y)
509 # なぞった先で塗り始めてよい画素。線の上を通っても線を塗らないよう、
510 # 「すべての色」では最初にクリックした色の種類、それ以外は対象色の画素に限る。
511 eligible = bucket_fill.fill_target_mask(
512 original, options.target_color, category
513 )
514 self._bucket_drag = {
515 "frame": int(self.current_frame),
516 "layer": int(self.active_layer_index),
517 "before": before,
518 "has_content": bool(layer.has_content),
519 "eligible": eligible,
520 # 試した場所(塗れなかった点も含む)と、実際に塗った範囲。
521 "tried": np.zeros((height, width), dtype=bool),
522 "painted": np.zeros((height, width), dtype=bool),
523 "last": QPointF(p),
524 "line_cache": {},
525 "pixels": (converted, layer_pixels),
526 "pixels_key": layer.image.cacheKey(),
527 "reference_pixels": {},
528 }
529 # 最初の1か所はクリックと同じ(開いた領域も設定どおりに塗る)。
530 self._bucket_drag_fill(p, first=True)
532 def continue_bucket_drag(self, p):
533 """なぞった区間上の、まだ塗っていない囲まれた部分を塗る。"""
534 drag = self._bucket_drag
535 if drag is None: 535 ↛ 536line 535 didn't jump to line 536 because the condition on line 535 was never true
536 return
537 start = drag["last"]
538 distance = math.hypot(p.x() - start.x(), p.y() - start.y())
539 steps = max(1, int(math.ceil(distance)))
540 for step in range(1, steps + 1):
541 t = step / steps
542 self._bucket_drag_fill(
543 QPointF(
544 start.x() + (p.x() - start.x()) * t,
545 start.y() + (p.y() - start.y()) * t,
546 ),
547 first=False,
548 )
549 drag["last"] = QPointF(p)
551 def _bucket_drag_fill(self, p, first):
552 drag = self._bucket_drag
553 x, y = int(p.x()), int(p.y())
554 tried = drag["tried"]
555 height, width = tried.shape
556 if not (0 <= x < width and 0 <= y < height): 556 ↛ 557line 556 didn't jump to line 557 because the condition on line 556 was never true
557 return
558 if tried[y, x] or not drag["eligible"][y, x]:
559 return
560 mask, skipped = self._bucket_fill_at(
561 QPointF(x, y), push_undo=False, quiet=not first,
562 accept=None if first else self._bucket_drag_accepts,
563 line_cache=drag["line_cache"],
564 )
565 # 塗れなかった点・塗らなかった領域も、同じ場所で何度も判定しない。
566 tried[y, x] = True
567 if skipped is not None:
568 tried |= skipped
569 if mask is not None:
570 tried |= mask
571 drag["painted"] |= mask
572 if first:
573 drag["first_area"] = int(np.count_nonzero(mask))
575 def _bucket_drag_accepts(self, result):
576 """なぞった先の領域を塗るか。外の背景だけは塗らない。
578 余白の無いキャンバスでは、フレームの端で閉じた部分(端まで線を引いた所)も
579 外の背景も「端に接した領域」になる。外の背景はふつう画面の大半を占めるので、
580 端に接していて大きすぎる領域だけを外の背景とみなして塗らない。
581 """
582 region = result.mask
583 touches_edge = bool(
584 region[0].any() or region[-1].any()
585 or region[:, 0].any() or region[:, -1].any()
586 )
587 if not touches_edge:
588 return True
589 area = int(np.count_nonzero(region))
590 first_area = int(self._bucket_drag.get("first_area", 0)) if self._bucket_drag else 0
591 limit = max(3 * first_area, int(region.size * BUCKET_DRAG_EDGE_AREA))
592 return area <= limit
594 def finish_bucket_drag(self):
595 """ドラッグで塗った分を、Undo 1回で戻せるようにまとめて積む。"""
596 drag = self._bucket_drag
597 self._bucket_drag = None
598 if drag is None:
599 return
600 if not self._bucket_drag_target_exists(drag): 600 ↛ 601line 600 didn't jump to line 601 because the condition on line 600 was never true
601 return
602 ys, xs = np.nonzero(drag["painted"])
603 if not xs.size: 603 ↛ 604line 603 didn't jump to line 604 because the condition on line 603 was never true
604 return
605 rect = QRect(
606 int(xs.min()), int(ys.min()),
607 int(xs.max() - xs.min() + 1), int(ys.max() - ys.min() + 1),
608 )
609 self.push_undo(LayerRegionUndo(
610 drag["frame"], drag["layer"], rect,
611 drag["before"].copy(rect), drag["has_content"],
612 ))
614 def _bucket_drag_target_exists(self, drag):
615 return (
616 0 <= drag["frame"] < len(self.frames)
617 and 0 <= drag["layer"] < len(self.frames[drag["frame"]].layers)
618 )
620 @staticmethod
621 def _bucket_failure_message(reason):
622 """塗れなかった理由のうち、ユーザーに伝える価値があるものだけ文言にする。"""
623 if reason == bucket_fill.REASON_OUTSIDE_SELECTION: 623 ↛ 624line 623 didn't jump to line 624 because the condition on line 623 was never true
624 return tr("選択範囲の外側なので塗りを開始しませんでした。")
625 if reason == bucket_fill.REASON_OPEN_REGION: 625 ↛ 627line 625 didn't jump to line 627 because the condition on line 625 was always true
626 return tr("領域がキャンバス端まで開いているため、塗りを開始しませんでした。")
627 if reason == bucket_fill.REASON_NOT_TARGET:
628 return tr("クリックした場所は「対象色」で塗る対象にしていない色です。")
629 return None
631 def _bucket_all_frames_enabled(self):
632 tools = self._bucket_tools()
633 checkbox = getattr(tools, "bucket_all_frames", None)
634 return bool(checkbox is not None and checkbox.isChecked())
636 def _seed_in_bounds(self, start_x, start_y, image=None):
637 image = image if image is not None else self.active_layer.image
638 return 0 <= start_x < image.width() and 0 <= start_y < image.height()
640 def _write_rgba_patch(self, patch, rect):
641 """RGBA8888 の部分配列を、アクティブレイヤーの rect へそのまま書き戻す。"""
642 patch_image = imaging.rgba_array_to_qimage(patch)
643 painter = QPainter(self.active_layer.image)
644 painter.setCompositionMode(
645 QPainter.CompositionMode.CompositionMode_Source
646 )
647 painter.drawImage(rect.topLeft(), patch_image)
648 painter.end()
650 def auto_select_region(self, point, modifiers=Qt.KeyboardModifier.NoModifier):
651 """バケツと同じ連続領域判定で選択マスクを作成・加減算する。"""
652 image = self.active_layer.image.convertToFormat(
653 QImage.Format.Format_RGBA8888
654 )
655 width, height = image.width(), image.height()
656 start_x, start_y = int(point.x()), int(point.y())
657 if not (0 <= start_x < width and 0 <= start_y < height): 657 ↛ 658line 657 didn't jump to line 658 because the condition on line 657 was never true
658 return False
660 ptr = imaging.qimage_buffer(image)
661 rows = np.frombuffer(ptr, dtype=np.uint8).reshape(
662 (height, image.bytesPerLine())
663 )
664 pixels = rows[:, :width * 4].reshape((height, width, 4))
665 target = pixels[start_y, start_x]
666 pseudo_background = (
667 (pixels[:, :, 3] == 0)
668 | np.all(pixels[:, :, :3] == 255, axis=2)
669 )
670 target_is_background = bool(
671 int(target[3]) == 0
672 or np.all(target[:3] == 255)
673 )
674 target_mask = (
675 pseudo_background.copy()
676 if target_is_background
677 else (
678 (pixels[:, :, 3] > 0)
679 & np.all(pixels[:, :, :3] == target[:3], axis=2)
680 )
681 )
683 window = self.window()
684 tools = getattr(window, "tools", None)
685 adjacent = bool(
686 tools is None or tools.bucket_adjacent.isChecked()
687 )
688 close_gap = bool(
689 tools is not None
690 and tools.bucket_close_gap.isChecked()
691 and adjacent
692 )
694 if adjacent: 694 ↛ 749line 694 didn't jump to line 749 because the condition on line 694 was always true
695 passable = target_mask
696 if close_gap and tools is not None: 696 ↛ 697line 696 didn't jump to line 697 because the condition on line 696 was never true
697 radius = max(1, int(tools.bucket_gap_width.value()))
698 boundary = ~target_mask
699 expanded = boundary.copy()
700 for _ in range(radius):
701 source = expanded.copy()
702 expanded[1:, :] |= source[:-1, :]
703 expanded[:-1, :] |= source[1:, :]
704 expanded[:, 1:] |= source[:, :-1]
705 expanded[:, :-1] |= source[:, 1:]
706 expanded[1:, 1:] |= source[:-1, :-1]
707 expanded[:-1, :-1] |= source[1:, 1:]
708 expanded[1:, :-1] |= source[:-1, 1:]
709 expanded[:-1, 1:] |= source[1:, :-1]
710 virtual_boundary = expanded
711 for _ in range(radius):
712 padded = np.pad(
713 virtual_boundary,
714 ((1, 1), (1, 1)),
715 mode="constant",
716 constant_values=False,
717 )
718 virtual_boundary = (
719 padded[0:-2, 0:-2]
720 & padded[0:-2, 1:-1]
721 & padded[0:-2, 2:]
722 & padded[1:-1, 0:-2]
723 & padded[1:-1, 1:-1]
724 & padded[1:-1, 2:]
725 & padded[2:, 0:-2]
726 & padded[2:, 1:-1]
727 & padded[2:, 2:]
728 )
729 passable = target_mask & ~virtual_boundary
730 passable[start_y, start_x] = bool(
731 target_mask[start_y, start_x]
732 )
733 region = self._scanline_connected_region(
734 passable, (start_x, start_y)
735 )
736 require_closed = bool(
737 tools is not None
738 and tools.bucket_require_closed.isChecked()
739 )
740 if require_closed and ( 740 ↛ 744line 740 didn't jump to line 744 because the condition on line 740 was never true
741 np.any(region[0, :]) or np.any(region[-1, :])
742 or np.any(region[:, 0]) or np.any(region[:, -1])
743 ):
744 self.status_message.emit(
745 tr("領域がキャンバス端まで開いているため選択しませんでした。")
746 )
747 return False
748 else:
749 region = target_mask.copy()
751 current = self.selection_mask_bool(width, height)
752 if current is None: 752 ↛ 754line 752 didn't jump to line 754 because the condition on line 752 was always true
753 current = np.zeros((height, width), dtype=bool)
754 if modifiers & Qt.KeyboardModifier.AltModifier: 754 ↛ 755line 754 didn't jump to line 755 because the condition on line 754 was never true
755 combined = current & ~region
756 elif modifiers & Qt.KeyboardModifier.ShiftModifier: 756 ↛ 757line 756 didn't jump to line 757 because the condition on line 756 was never true
757 combined = current | region
758 else:
759 combined = region
761 return self.set_selection_mask(combined)
764 def _line_preview_path(self):
765 if self.line_start is None or self.line_end is None: 765 ↛ 766line 765 didn't jump to line 766 because the condition on line 765 was never true
766 return None
767 path = QPainterPath(QPointF(self.line_start))
768 if self.line_curve_stage == 2 and self.line_control is not None:
769 path.quadTo(QPointF(self.line_control), QPointF(self.line_end))
770 else:
771 path.lineTo(QPointF(self.line_end))
772 return path
774 def _draw_tapered_path(
775 self,
776 painter,
777 path,
778 color,
779 base_width,
780 start_width=None,
781 end_width=None,
782 start_curve=1.0,
783 end_curve=1.0,
784 ):
785 """入り抜き幅を変化させながら、非AA線分として描画する。"""
786 try:
787 length = max(1.0, float(path.length()))
788 except (AttributeError, RuntimeError, TypeError, ValueError) as exc:
789 log.debug("path.length() failed, using endpoint distance: %s", exc)
790 length = max(
791 1.0,
792 math.hypot(
793 self.line_end.x() - self.line_start.x(),
794 self.line_end.y() - self.line_start.y(),
795 ),
796 )
797 steps = max(2, min(4096, int(math.ceil(length * 1.5))))
798 previous = path.pointAtPercent(0.0)
799 for index in range(1, steps + 1):
800 ratio = index / steps
801 point = path.pointAtPercent(ratio)
802 width = float(base_width)
803 if start_width is not None and ratio <= 0.5:
804 local = max(0.0, min(1.0, ratio * 2.0))
805 eased = local ** max(0.05, float(start_curve))
806 width = float(start_width) + (
807 float(base_width) - float(start_width)
808 ) * eased
809 elif end_width is not None and ratio >= 0.5:
810 local = max(0.0, min(1.0, (ratio - 0.5) * 2.0))
811 eased = local ** max(0.05, float(end_curve))
812 width = float(base_width) + (
813 float(end_width) - float(base_width)
814 ) * eased
815 width = max(0.5, width)
816 pen = QPen(
817 color,
818 width,
819 Qt.PenStyle.SolidLine,
820 Qt.PenCapStyle.RoundCap,
821 Qt.PenJoinStyle.RoundJoin,
822 )
823 painter.setPen(pen)
824 painter.drawLine(previous, point)
825 previous = point
827 def _begin_brush_straight_line(self, point):
828 anchor = getattr(self, "_brush_line_anchor", None)
829 key = (self.current_frame, self.active_layer_index)
830 self._brush_shift_line = True
831 self.line_start = QPointF(anchor[1] if anchor is not None and anchor[0] == key else point)
832 self.line_end = QPointF(point)
833 self.line_control = None
834 self.line_curve_stage = 1
835 self.drawing = True
836 self.update()
838 def commit_line_tool(self):
839 path = self._line_preview_path()
840 if path is None: 840 ↛ 841line 840 didn't jump to line 841 because the condition on line 840 was never true
841 return
842 self.ensure_editable_key()
843 panel = self._active_tool_panel()
844 base_width = max(0.5, float(self.pen_size))
845 start_width = None
846 end_width = None
847 start_curve = 1.0
848 end_curve = 1.0
849 if panel is not None and not getattr(self, "_brush_shift_line", False):
850 if panel.line_taper_in.isChecked(): 850 ↛ 851line 850 didn't jump to line 851 because the condition on line 850 was never true
851 start_width = (
852 panel.line_taper_in_size.value() / 2.0
853 )
854 start_curve = (
855 panel.line_taper_in_curve.value() / 100.0
856 )
857 if panel.line_taper_out.isChecked(): 857 ↛ 858line 857 didn't jump to line 858 because the condition on line 857 was never true
858 end_width = (
859 panel.line_taper_out_size.value() / 2.0
860 )
861 end_curve = (
862 1.0
863 / max(
864 0.05,
865 panel.line_taper_out_curve.value() / 100.0,
866 )
867 )
869 source_color = self.paint_source_color()
870 maximum_width = max(
871 base_width,
872 float(start_width or 0.0),
873 float(end_width or 0.0),
874 )
875 undo_margin = int(math.ceil(maximum_width / 2.0)) + 3
876 undo_rect = path.boundingRect().adjusted(
877 -undo_margin, -undo_margin, undo_margin, undo_margin
878 ).toAlignedRect()
879 if not self.push_layer_region_undo(undo_rect): 879 ↛ 880line 879 didn't jump to line 880 because the condition on line 879 was never true
880 return
881 overlay = QImage(
882 self.active_layer.image.width(),
883 self.active_layer.image.height(),
884 QImage.Format.Format_ARGB32_Premultiplied,
885 )
886 overlay.fill(Qt.GlobalColor.transparent)
888 painter = QPainter(overlay)
889 painter.setRenderHint(
890 QPainter.RenderHint.Antialiasing,
891 False,
892 )
893 painter.setRenderHint(
894 QPainter.RenderHint.TextAntialiasing,
895 False,
896 )
897 painter.setRenderHint(
898 QPainter.RenderHint.SmoothPixmapTransform,
899 False,
900 )
901 self.apply_selection_clip(painter)
902 if start_width is None and end_width is None: 902 ↛ 914line 902 didn't jump to line 914 because the condition on line 902 was always true
903 painter.setPen(
904 QPen(
905 source_color,
906 base_width,
907 Qt.PenStyle.SolidLine,
908 Qt.PenCapStyle.RoundCap,
909 Qt.PenJoinStyle.RoundJoin,
910 )
911 )
912 painter.drawPath(path)
913 else:
914 self._draw_tapered_path(
915 painter,
916 path,
917 source_color,
918 base_width,
919 start_width,
920 end_width,
921 start_curve,
922 end_curve,
923 )
924 painter.end()
926 colors = self._blend_overlay_into_active_layer(
927 overlay,
928 QPoint(0, 0),
929 exact_colors=(source_color,),
930 )
931 self._emit_actual_paint_colors(colors)
933 self.active_layer.has_content = True
934 self.cellChanged.emit(
935 self.current_frame,
936 self.active_layer_index,
937 )
938 if getattr(self, "_brush_shift_line", False):
939 self._brush_line_anchor = (
940 (self.current_frame, self.active_layer_index), QPointF(self.line_end),
941 )
942 self._brush_shift_line = False
943 self.line_start = None
944 self.line_end = None
945 self.line_control = None
946 self.line_curve_stage = 0
947 self.drawing = False
948 self.update()
950 def _shape_rect(self):
951 if self.shape_start is None or self.shape_end is None:
952 return QRectF()
953 start = QPointF(self.shape_start)
954 end = QPointF(self.shape_end)
955 panel = self._active_tool_panel()
956 if panel is not None and panel.shape_lock_ratio.isChecked():
957 dx = end.x() - start.x()
958 dy = end.y() - start.y()
959 size = max(abs(dx), abs(dy))
960 end = QPointF(
961 start.x() + (size if dx >= 0 else -size),
962 start.y() + (size if dy >= 0 else -size),
963 )
964 return QRectF(start, end).normalized()
966 def _shape_path(self):
967 rect = self._shape_rect()
968 if rect.isEmpty():
969 return None
970 panel = self._active_tool_panel()
971 shape_type = panel.shape_type.currentData() if panel is not None else "polygon"
972 path = QPainterPath()
973 if shape_type == "ellipse":
974 path.addEllipse(rect)
975 return path
977 corners = max(3, int(panel.shape_corners.value()) if panel is not None else 4)
978 center = rect.center()
979 radius_x = rect.width() / 2.0
980 radius_y = rect.height() / 2.0
981 if corners == 4:
982 # 4角はひし形ではなく、ドラッグ範囲に沿う□(長方形)にする。
983 points = [
984 QPointF(rect.left(), rect.top()),
985 QPointF(rect.right(), rect.top()),
986 QPointF(rect.right(), rect.bottom()),
987 QPointF(rect.left(), rect.bottom()),
988 ]
989 else:
990 points = []
991 for index in range(corners):
992 angle = -math.pi / 2.0 + (2.0 * math.pi * index / corners)
993 points.append(
994 QPointF(
995 center.x() + math.cos(angle) * radius_x,
996 center.y() + math.sin(angle) * radius_y,
997 )
998 )
999 if points:
1000 path.moveTo(points[0])
1001 for point in points[1:]:
1002 path.lineTo(point)
1003 path.closeSubpath()
1004 return path
1006 def commit_shape_tool(self):
1007 path = self._shape_path()
1008 if path is None:
1009 self.shape_start = None
1010 self.shape_end = None
1011 self.drawing = False
1012 self.update()
1013 return
1015 self.ensure_editable_key()
1016 panel = self._active_tool_panel()
1017 use_split_colors = bool(
1018 panel is not None
1019 and panel.shape_sub_outline_main_fill.isChecked()
1020 )
1021 fill_inside = bool(
1022 panel is not None
1023 and panel.shape_fill_inside.isChecked()
1024 )
1025 outline_width = (
1026 panel.shape_outline_width.value() / 2.0
1027 if panel is not None
1028 else 1.0
1029 )
1030 undo_margin = int(math.ceil(float(outline_width) / 2.0)) + 3
1031 undo_rect = path.boundingRect().adjusted(
1032 -undo_margin, -undo_margin, undo_margin, undo_margin
1033 ).toAlignedRect()
1034 if not self.push_layer_region_undo(undo_rect):
1035 return
1037 if use_split_colors:
1038 outline_color = self.paint_source_color(
1039 self.sub_color
1040 )
1041 fill_color = self.paint_source_color(
1042 self.main_color
1043 )
1044 else:
1045 outline_color = self.paint_source_color()
1046 fill_color = QColor(outline_color)
1048 overlay = QImage(
1049 self.active_layer.image.width(),
1050 self.active_layer.image.height(),
1051 QImage.Format.Format_ARGB32_Premultiplied,
1052 )
1053 overlay.fill(Qt.GlobalColor.transparent)
1055 painter = QPainter(overlay)
1056 painter.setRenderHint(
1057 QPainter.RenderHint.Antialiasing,
1058 False,
1059 )
1060 painter.setRenderHint(
1061 QPainter.RenderHint.TextAntialiasing,
1062 False,
1063 )
1064 painter.setRenderHint(
1065 QPainter.RenderHint.SmoothPixmapTransform,
1066 False,
1067 )
1068 self.apply_selection_clip(painter)
1069 if fill_inside:
1070 painter.setPen(Qt.PenStyle.NoPen)
1071 painter.setBrush(fill_color)
1072 painter.drawPath(path)
1073 painter.setBrush(Qt.BrushStyle.NoBrush)
1074 painter.setPen(
1075 QPen(
1076 outline_color,
1077 max(0.5, float(outline_width)),
1078 Qt.PenStyle.SolidLine,
1079 Qt.PenCapStyle.RoundCap,
1080 Qt.PenJoinStyle.RoundJoin,
1081 )
1082 )
1083 painter.drawPath(path)
1084 painter.end()
1086 exact_shape_colors = (
1087 (outline_color, fill_color)
1088 if fill_inside
1089 else (outline_color,)
1090 )
1091 colors = self._blend_overlay_into_active_layer(
1092 overlay,
1093 QPoint(0, 0),
1094 exact_colors=exact_shape_colors,
1095 )
1096 self._emit_actual_paint_colors(colors)
1098 self.active_layer.has_content = True
1099 self.cellChanged.emit(
1100 self.current_frame,
1101 self.active_layer_index,
1102 )
1103 self.shape_start = None
1104 self.shape_end = None
1105 self.drawing = False
1106 self.update()
1108 def fill_lasso_polygon(self, points):
1109 if len(points) < 3: 1109 ↛ 1110line 1109 didn't jump to line 1110 because the condition on line 1109 was never true
1110 return
1112 self.ensure_editable_key()
1113 window: Any = self.window()
1114 outline_and_fill = bool(
1115 hasattr(window, "tools")
1116 and window.tools.lasso_main_outline_sub_fill.isChecked()
1117 )
1118 mask_only = not getattr(self, "mask_all_enabled", True)
1119 inside_boundary = bool(
1120 hasattr(window, "tools")
1121 and window.tools.lasso_inside_boundary.isChecked()
1122 )
1123 outline_width = (
1124 window.tools.lasso_outline_width.value() / 2.0
1125 if outline_and_fill and hasattr(window, "tools")
1126 else 0.0
1127 )
1129 polygon = QPolygonF(points)
1130 margin = int(math.ceil(outline_width / 2.0)) + 2
1131 rect = polygon.boundingRect().adjusted(
1132 -margin, -margin, margin, margin
1133 ).toAlignedRect().intersected(self.active_layer.image.rect())
1134 if rect.isEmpty(): 1134 ↛ 1135line 1134 didn't jump to line 1135 because the condition on line 1134 was never true
1135 return
1136 local_polygon = QPolygonF([
1137 QPointF(point.x() - rect.x(), point.y() - rect.y())
1138 for point in points
1139 ])
1140 overlay = QImage(
1141 rect.width(),
1142 rect.height(),
1143 QImage.Format.Format_ARGB32_Premultiplied,
1144 )
1145 overlay.fill(Qt.GlobalColor.transparent)
1147 fill_color = (
1148 self.paint_source_color(self.main_color)
1149 if outline_and_fill
1150 else self.paint_source_color()
1151 )
1153 painter = QPainter(overlay)
1154 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
1155 painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
1156 self.apply_selection_clip(painter, rect.x(), rect.y())
1157 painter.setPen(Qt.PenStyle.NoPen)
1158 painter.setBrush(fill_color)
1159 painter.drawPolygon(local_polygon)
1161 line_color = None
1162 if outline_and_fill: 1162 ↛ 1163line 1162 didn't jump to line 1163 because the condition on line 1162 was never true
1163 line_color = self.paint_source_color(self.sub_color)
1164 line_pen = QPen(line_color)
1165 line_pen.setWidthF(max(0.5, float(outline_width)))
1166 line_pen.setStyle(Qt.PenStyle.SolidLine)
1167 line_pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
1168 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
1169 painter.setPen(line_pen)
1170 painter.setBrush(Qt.BrushStyle.NoBrush)
1171 painter.drawPolygon(local_polygon)
1172 painter.end()
1174 width, height = rect.width(), rect.height()
1175 mask_image = overlay.convertToFormat(
1176 QImage.Format.Format_RGBA8888
1177 )
1179 mask_ptr = imaging.qimage_buffer(mask_image)
1180 mask_rows = np.frombuffer(
1181 mask_ptr, dtype=np.uint8
1182 ).reshape((height, mask_image.bytesPerLine()))
1183 mask_pixels = mask_rows[:, :width * 4].reshape(
1184 (height, width, 4)
1185 )
1187 options = bucket_fill.BucketOptions.from_tools(
1188 window.tools if hasattr(window, "tools") else None,
1189 )
1190 reference = self._bucket_reference_image(
1191 options, self.current_frame, self.active_layer_index, self.active_layer.image,
1192 )
1193 reference_owner, reference_pixels = self._layer_fill_pixels(reference.copy(rect))
1194 if options.target_color != bucket_fill.TARGET_ALL:
1195 if options.target_color in (
1196 bucket_fill.TARGET_OPAQUE_AND_INNER_TRANSPARENT,
1197 bucket_fill.TARGET_TRANSPARENT_AND_CLOSED_OPAQUE,
1198 ):
1199 # 内部の穴は投げ縄の外側まで含む元の参照画像で判断する。
1200 full_owner, full_pixels = self._layer_fill_pixels(reference)
1201 eligible = bucket_fill.fill_target_mask(full_pixels, options.target_color, None)
1202 eligible |= bucket_fill._enclosed_by(eligible)
1203 eligible = eligible[rect.y():rect.y() + height, rect.x():rect.x() + width]
1204 else:
1205 eligible = bucket_fill.fill_target_mask(reference_pixels, options.target_color, None)
1206 mask_pixels[:, :, 3][~eligible] = 0
1208 if mask_only: 1208 ↛ 1209line 1208 didn't jump to line 1209 because the condition on line 1208 was never true
1209 selected_rgbs = self.selected_mask_colors()
1210 if not selected_rgbs:
1211 target_mask = np.zeros((height, width), dtype=bool)
1212 else:
1213 packed_source = (
1214 (reference_pixels[:, :, 0].astype(np.uint32) << 16)
1215 | (reference_pixels[:, :, 1].astype(np.uint32) << 8)
1216 | reference_pixels[:, :, 2].astype(np.uint32)
1217 )
1218 selected_values = np.fromiter(
1219 ((r << 16) | (g << 8) | b for r, g, b in selected_rgbs),
1220 dtype=np.uint32,
1221 count=len(selected_rgbs),
1222 )
1223 target_mask = (
1224 (reference_pixels[:, :, 3] > 0)
1225 & np.isin(packed_source, selected_values)
1226 )
1227 if self.background_mask_rgb in selected_rgbs:
1228 target_mask |= (
1229 (reference_pixels[:, :, 3] == 0)
1230 | np.all(
1231 reference_pixels[:, :, :3] == 255,
1232 axis=2,
1233 )
1234 )
1235 mask_pixels[:, :, 3][~target_mask] = 0
1237 if inside_boundary:
1238 polygon_area = mask_pixels[:, :, 3] > 0
1239 passable = polygon_area.copy()
1240 if options.target_color == bucket_fill.TARGET_ALL: 1240 ↛ 1241line 1240 didn't jump to line 1241 because the condition on line 1240 was never true
1241 passable &= (
1242 (reference_pixels[:, :, 3] == 0)
1243 | np.all(reference_pixels[:, :, :3] == 255, axis=2)
1244 )
1245 candidates = np.argwhere(passable)
1246 if candidates.size: 1246 ↛ 1262line 1246 didn't jump to line 1262 because the condition on line 1246 was always true
1247 center = np.mean(
1248 np.array([[point.x() - rect.x(), point.y() - rect.y()]
1249 for point in points]),
1250 axis=0,
1251 )
1252 distances = (
1253 (candidates[:, 1] - center[0]) ** 2
1254 + (candidates[:, 0] - center[1]) ** 2
1255 )
1256 seed_y, seed_x = candidates[int(np.argmin(distances))]
1257 region = self._scanline_connected_region(
1258 passable, (int(seed_x), int(seed_y))
1259 )
1260 mask_pixels[:, :, 3][~region] = 0
1261 else:
1262 mask_pixels[:, :, 3] = 0
1264 if not mask_pixels[:, :, 3].any():
1265 return
1266 self.push_layer_region_undo(rect)
1267 overlay = mask_image.convertToFormat(
1268 QImage.Format.Format_ARGB32_Premultiplied
1269 )
1270 exact_lasso_colors = (
1271 (fill_color, line_color)
1272 if line_color is not None
1273 else (fill_color,)
1274 )
1275 colors = self._blend_overlay_into_active_layer(
1276 overlay,
1277 rect.topLeft(),
1278 exact_colors=exact_lasso_colors,
1279 opacity=getattr(self, "fill_opacity", 1.0),
1280 )
1282 self.active_layer.has_content = True
1283 self._emit_actual_paint_colors(colors)
1284 self.cellChanged.emit(
1285 self.current_frame,
1286 self.active_layer_index,
1287 )
1288 self.update()