Coverage for paintmaskanimator/auto_bucket.py: 80%
443 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"""色とクリック位置の選択を、通常バケツの領域判定から分離する。"""
2from dataclasses import dataclass, replace
3from hashlib import blake2b
5import numpy as np
7from . import bucket_fill, native, reference_check
8from .auto_alignment import AlignmentCache
11# 面の中に重なった見本の線は色の候補に数えない。形の伸縮は両方向の
12# 重なりで確かめるが、90%の完全一致は求めない。
13FACE_MATCH_SHARE = 0.72
14FACE_REVERSE_SHARE = 0.50
15FACE_SAMPLE_SHARE = 0.55
16TRACE_MATCH_SHARE = 0.75
17TRACE_REVERSE_SHARE = 0.45
18TRACE_RADIUS = 2
19FACE_REFINE_RADIUS = 5
22@dataclass(frozen=True)
23class BucketClick:
24 x: int
25 y: int
26 rgb: tuple[int, int, int]
29def execute_click(pixels, click, options, *, line_cache=None):
30 filled = bucket_fill.compute_fill_region(
31 pixels, click.x, click.y, options, line_cache=line_cache,
32 )
33 if not filled.ok: 33 ↛ 34line 33 didn't jump to line 34 because the condition on line 33 was never true
34 return filled, []
35 return filled, _paints(bucket_fill.CompactFill.from_result(filled), click.rgb)
38def _paints(filled, color):
39 # 含み塗りの島で外接矩形が広がっている場合も、通常バケツと同じ範囲。
40 x0, y0, x1, y1 = filled.bounds
41 local_bounds = bucket_fill.mask_bounds(filled.local)
42 if local_bounds is None: 42 ↛ 43line 42 didn't jump to line 43 because the condition on line 42 was never true
43 return []
44 lx0, ly0, lx1, ly1 = local_bounds
45 paints = [(color, (x0 + lx0, y0 + ly0, x0 + lx1, y0 + ly1),
46 filled.local[ly0:ly1, lx0:lx1].copy())]
47 paints.extend((rgb, bounds, local.copy()) for rgb, bounds, local in filled.islands)
48 return paints
51def geometry_key(cell):
52 key = getattr(cell, "_bucket_geometry_key", None)
53 if key is None:
54 key = (cell.values.shape, type(cell.regions).__name__,
55 blake2b(cell.regions.values.tobytes(), digest_size=16).digest())
56 cell._bucket_geometry_key = key
57 return key
60class BucketWork:
61 """対象の領域一覧・位置合わせ・色の判断を、塗り直しの回をまたいで保持する。"""
63 def __init__(self, target, options, *, alignment=None, reference_regions=None):
64 self.target = target
65 # 面の含み塗りでTP線まで同色にしない。手動バケツの設定は変更しない。
66 self.options = replace(options, adjacent=True, target_color=bucket_fill.TARGET_ALL,
67 include_masks=False, island_replacements=(), island_max_width=0)
68 # 色の照合は元の面で行い、書き込む直前に小さな塗り残しを拾う。
69 self.island_max_width = max(0, int(options.island_max_width))
70 self.alignment = alignment if alignment is not None else AlignmentCache(gpu="off")
71 self.entries = None
72 self.matches = {}
73 self.decisions = {}
74 self.reused_decisions = 0
75 self.decision_solves = 0
76 self.reference_regions = reference_regions
78 def _sample(self, *args, **kwargs):
79 return getattr(self.alignment, "sample_region", native.sample_region)(*args, **kwargs)
81 def _reference(self, geometry, x, y, sessions):
82 if self.reference_regions is not None and getattr(geometry, "_native_immutable", False):
83 return self.reference_regions.compact(geometry, x, y, self.options, active=sessions)
84 if geometry not in sessions:
85 sessions[geometry] = bucket_fill.BucketSession(
86 geometry.pixels, self.options, indexed=True,
87 gpu_regions=getattr(self.alignment, "regions", None),
88 )
89 result = sessions[geometry].compact(x, y)
90 return result
92 def collect(self):
93 if self.entries is not None:
94 return self.entries
95 from . import auto_paint as ap
96 target = self.target
97 visited = np.zeros(target.values.shape, dtype=bool)
98 session = bucket_fill.BucketSession(target.pixels, self.options, indexed=True,
99 gpu_regions=getattr(self.alignment, "regions", None))
100 entries = []
101 regions = target.regions
102 trace_labels = np.unique(regions.labels[target.primary])
103 eligible = (regions.sizes >= ap.MIN_REGION_PIXELS) | np.isin(
104 np.arange(len(regions.sizes)), trace_labels,
105 )
106 for index in np.nonzero(eligible)[0].tolist():
107 value = regions.value(index)
108 if value == ap.GAP_VALUE:
109 continue
110 x0, y0, x1, y1 = regions.bounds(index)
111 ys, xs = np.nonzero(regions.local_mask(index) & ~visited[y0:y1, x0:x1])
112 if not len(xs):
113 continue
114 middle = len(xs) // 2
115 x, y = int(xs[middle] + x0), int(ys[middle] + y0)
116 if target.black[y, x]:
117 continue
118 filled = session.compact(x, y)
119 if filled.local is not None and filled.bounds is not None: 119 ↛ 122line 119 didn't jump to line 122 because the condition on line 119 was always true
120 bx0, by0, bx1, by1 = filled.bounds
121 visited[by0:by1, bx0:bx1] |= filled.local
122 if filled.ok: 122 ↛ 106line 122 didn't jump to line 106 because the condition on line 122 was always true
123 entries.append((x, y, value, filled))
124 # 全画像の前処理・索引はここで解放し、小さな矩形の一覧だけを残す。
125 self.entries = entries
126 return entries
128 def choose_color(self, guide, ordinal, entry, margin, sessions):
129 geometries = [guide.initial, guide] if guide.initial is not None else [guide]
130 for geometry in geometries:
131 original_lines = geometry is guide.initial
132 key = (ordinal, geometry_key(geometry), margin, original_lines)
133 if key not in self.matches:
134 self.matches[key] = self._match(
135 geometry, entry[3], margin, sessions, original_lines=original_lines,
136 )
137 reference = self.matches[key]
138 if reference is None:
139 continue
140 sampled = self._sample(guide, reference)
141 if sampled is not None: 141 ↛ 158line 141 didn't jump to line 158 because the condition on line 141 was always true
142 from . import auto_paint as ap
143 _inside, total, value, count, _other, _bad, unknown, _y, _x = sampled
144 decision_key = ("native", ordinal, reference.bounds, total, value, count, unknown)
145 if decision_key in self.decisions:
146 self.reused_decisions += 1
147 rgb = self.decisions[decision_key]
148 else:
149 rgb = None
150 if (not unknown and value is not None and total
151 and count / total >= ap.HINT_SHARE and ap._paintable(value)):
152 rgb = reference_check._to_rgb(value)
153 self.decisions[decision_key] = rgb
154 self.decision_solves += 1
155 if rgb is not None:
156 return rgb
157 continue
158 x0, y0, x1, y1 = reference.bounds
159 sample = guide.values[y0:y1, x0:x1][reference.local]
160 opened = guide.open[y0:y1, x0:x1][reference.local]
161 undecided = guide.undecided[y0:y1, x0:x1][reference.local]
162 digest = blake2b(digest_size=16)
163 for values in (sample, opened, undecided):
164 digest.update(values.tobytes())
165 mask_key = getattr(reference, "_sample_mask_key", None)
166 if mask_key is None:
167 mask_key = (reference.bounds,
168 blake2b(reference.local.tobytes(), digest_size=16).digest())
169 reference._sample_mask_key = mask_key
170 # 対応する面を確認した後の色判定は、その面の画素だけに依存する。
171 # 別の面の色が変わって見本全体の領域番号が変わっても再集計しない。
172 decision_key = (ordinal, mask_key, digest.digest())
173 if decision_key in self.decisions:
174 self.reused_decisions += 1
175 rgb = self.decisions[decision_key]
176 else:
177 from . import auto_paint as ap
178 rgb = None
179 if not opened.any() and not undecided.any():
180 value, share = ap._majority(sample)
181 if value is not None and share >= ap.HINT_SHARE and ap._paintable(value):
182 rgb = reference_check._to_rgb(value)
183 self.decisions[decision_key] = rgb
184 self.decision_solves += 1
185 if rgb is not None:
186 return rgb
187 rgb = self._sample_face_color(guide, entry[3], margin)
188 if rgb is not None: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 return rgb
190 rgb = self._refine_face_color(guide, entry[3], margin)
191 return rgb if rgb is not None else self.part_color(guide, entry[3], margin)
193 def part_color(self, guide, filled, margin):
194 """未完成セルの確定した画素だけを使い、部位単位で引き継ぐ。"""
195 from . import auto_paint as ap
196 confirmed = getattr(guide, "confirmed", None)
197 if confirmed is None or guide.initial is None or filled.target is not None:
198 return None
199 size = int(filled.local.sum())
200 large = size >= 8192
201 shift, score = self.alignment.shift(self.target, guide.initial, filled.bounds, margin)
202 if score < (0.4 if large else 0.6):
203 return None
204 sampled = self._sample(guide, filled, shift, ignore=2 | 4, require=8)
205 if sampled is not None: 205 ↛ 214line 205 didn't jump to line 214 because the condition on line 205 was always true
206 _inside, known, value, count, other, _bad, _any, _y, _x = sampled
207 if (known < size * (0.8 if large else 0.65) or value is None or not known
208 or count / known < (0.98 if large else 0.95) or not ap._paintable(value)):
209 return None
210 if not large and (not size / ap.MAX_AREA_RATIO <= other <= size * ap.MAX_AREA_RATIO
211 or count < other * FACE_REVERSE_SHARE):
212 return None
213 return reference_check._to_rgb(value)
214 sy, sx, inside = ap._shifted(self.target, filled.local, filled.bounds, shift)
215 known = inside & confirmed[sy, sx] & ~guide.initial.boundary[sy, sx]
216 known &= ~guide.open[sy, sx] & ~guide.undecided[sy, sx]
217 if known.sum() < size * (0.8 if large else 0.65):
218 return None
219 value, share = ap._majority(guide.values[sy[known], sx[known]])
220 if value is None or share < (0.98 if large else 0.95) or not ap._paintable(value):
221 return None
222 if large:
223 return reference_check._to_rgb(value)
224 matched = known & (guide.values[sy, sx] == value)
225 labels = np.unique(guide.regions.labels[sy[matched], sx[matched]])
226 other = sum(int(guide.regions.sizes[label]) for label in labels)
227 if not size / ap.MAX_AREA_RATIO <= other <= size * ap.MAX_AREA_RATIO:
228 return None
229 if matched.sum() < other * FACE_REVERSE_SHARE:
230 return None
231 return reference_check._to_rgb(value)
233 def _refine_face_color(self, guide, filled, margin):
234 """局所的な伸縮で線の位置合わせがずれた小面を、完成領域でも照合する。"""
235 from . import auto_paint as ap
236 if guide.initial is None or filled.target is not None:
237 return None
238 size = int(filled.local.sum())
239 if not 8 <= size <= 4096:
240 return None
241 shift, score = self.alignment.shift(self.target, guide.initial, filled.bounds, margin)
242 if score < ap.MIN_LINE_OVERLAP:
243 return None
244 x0, y0, x1, y1 = filled.bounds
245 height, width = guide.values.shape
246 radius = FACE_REFINE_RADIUS
247 ax0, ay0 = max(0, x0 + shift[1] - radius), max(0, y0 + shift[0] - radius)
248 ax1, ay1 = min(width, x1 + shift[1] + radius), min(height, y1 + shift[0] + radius)
249 labels = guide.regions.labels[ay0:ay1, ax0:ax1]
250 if not np.any(guide.regions.sizes[labels] <= size * ap.MAX_AREA_RATIO):
251 return None # どのずれでも面積比を満たす仕上げ領域がない。
252 refined = native.refine_face(
253 guide, filled, shift, (ax0, ay0, ax1, ay1), FACE_REFINE_RADIUS,
254 (FACE_SAMPLE_SHARE, ap.HINT_SHARE, ap.MAX_AREA_RATIO, FACE_REVERSE_SHARE),
255 )
256 if refined is not None:
257 answers = {int(value): fit for value, fit in refined if ap._paintable(value)}
258 return self._refined_answer(answers)
259 answers = {}
260 for dy in range(-FACE_REFINE_RADIUS, FACE_REFINE_RADIUS + 1):
261 for dx in range(-FACE_REFINE_RADIUS, FACE_REFINE_RADIUS + 1):
262 sy, sx, inside = ap._shifted(self.target, filled.local, filled.bounds,
263 (shift[0] + dy, shift[1] + dx))
264 usable = inside & ~guide.initial.boundary[sy, sx]
265 if usable.sum() < size * FACE_SAMPLE_SHARE: 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true
266 continue
267 value, share = ap._majority(guide.values[sy[usable], sx[usable]])
268 if value is None or share < ap.HINT_SHARE or not ap._paintable(value):
269 continue
270 matched = usable & (guide.values[sy, sx] == value)
271 if (guide.open[sy[matched], sx[matched]].any()
272 or guide.undecided[sy[matched], sx[matched]].any()):
273 continue
274 labels = np.unique(guide.regions.labels[sy[matched], sx[matched]])
275 other = sum(int(guide.regions.sizes[label]) for label in labels)
276 if not size / ap.MAX_AREA_RATIO <= other <= size * ap.MAX_AREA_RATIO: 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true
277 continue
278 if matched.sum() < other * FACE_REVERSE_SHARE: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 continue
280 fit = 2 * int(matched.sum()) / (size + other)
281 answers[value] = max(answers.get(value, 0), fit)
282 return self._refined_answer(answers)
284 @staticmethod
285 def _refined_answer(answers):
286 ranked = sorted(answers, key=lambda value: answers[value], reverse=True)
287 if not ranked or (len(ranked) > 1 and answers[ranked[0]] - answers[ranked[1]] < 0.05):
288 return None
289 return reference_check._to_rgb(ranked[0])
291 def _sample_face_color(self, guide, filled, margin):
292 """初期線で面が分断・開放されても、仕上げの同色部分との対応を確かめる。"""
293 from . import auto_paint as ap
294 if guide.initial is None or filled.target is not None:
295 return None
296 shift, score = self.alignment.shift(self.target, guide.initial, filled.bounds, margin)
297 size = int(filled.local.sum())
298 large = size >= 8192
299 if score < (0.3 if large else ap.MIN_LINE_OVERLAP):
300 return None
301 sampled = self._sample(guide, filled, shift, ignore=2)
302 if sampled is not None: 302 ↛ 313line 302 didn't jump to line 313 because the condition on line 302 was always true
303 _inside, usable, value, count, other, bad, _any, _y, _x = sampled
304 if (usable < size * FACE_SAMPLE_SHARE or not usable or value is None
305 or count / usable < ap.HINT_SHARE or not ap._paintable(value) or bad):
306 return None
307 if large and count / usable >= 0.98 and count >= size * 0.9:
308 return reference_check._to_rgb(value)
309 if (not size / ap.MAX_AREA_RATIO <= other <= size * ap.MAX_AREA_RATIO 309 ↛ 312line 309 didn't jump to line 312 because the condition on line 309 was always true
310 or count < other * FACE_REVERSE_SHARE):
311 return None
312 return reference_check._to_rgb(value)
313 sy, sx, inside = ap._shifted(self.target, filled.local, filled.bounds, shift)
314 usable = inside & ~guide.initial.boundary[sy, sx]
315 if usable.sum() < size * FACE_SAMPLE_SHARE:
316 return None
317 values = guide.values[sy[usable], sx[usable]]
318 value, share = ap._majority(values)
319 if value is None or share < ap.HINT_SHARE or not ap._paintable(value):
320 return None
321 matched = usable & (guide.values[sy, sx] == value)
322 if (guide.open[sy[matched], sx[matched]].any()
323 or guide.undecided[sy[matched], sx[matched]].any()):
324 return None
325 # しわやTP線で分割された大面は、同じ仕上げ面の一部分に対応してよい。
326 if large and share >= 0.98 and matched.sum() >= size * 0.9:
327 return reference_check._to_rgb(value)
328 labels = np.unique(guide.regions.labels[sy[matched], sx[matched]])
329 other_size = sum(int(guide.regions.sizes[label]) for label in labels)
330 if not size / ap.MAX_AREA_RATIO <= other_size <= size * ap.MAX_AREA_RATIO:
331 return None
332 if matched.sum() < other_size * FACE_REVERSE_SHARE:
333 return None
334 return reference_check._to_rgb(value)
336 def _match(self, geometry, filled, margin, sessions, *, original_lines=False):
337 from . import auto_paint as ap
338 shift, overlap = self.alignment.shift(self.target, geometry, filled.bounds, margin)
339 if overlap < ap.MIN_LINE_OVERLAP:
340 return None
341 is_trace = filled.target is not None and ap._is_primary(filled.target)
342 if not is_trace:
343 sampled = self._sample(geometry, filled, shift, ignore=1 if original_lines else 0,
344 by_label=True)
345 if sampled is not None: 345 ↛ 356line 345 didn't jump to line 356 because the condition on line 345 was always true
346 inside, usable, index, count, other_size, _bad, _any, sy, sx = sampled
347 size = int(filled.local.sum())
348 if (inside < size * ap.HINT_SHARE or usable < size * FACE_SAMPLE_SHARE
349 or not usable or index is None
350 or count / usable < (FACE_MATCH_SHARE if original_lines else ap.HINT_SHARE)
351 or not size / ap.MAX_AREA_RATIO <= other_size <= size * ap.MAX_AREA_RATIO
352 or count < other_size * (FACE_REVERSE_SHARE if original_lines else ap.FILL_SHARE)):
353 return None
354 reference = self._reference(geometry, sx, sy, sessions)
355 return reference if reference.ok else None
356 local = filled.local
357 sy, sx, inside = ap._shifted(self.target, local, filled.bounds, shift)
358 size = int(local.sum())
359 if inside.sum() < size * ap.HINT_SHARE: 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true
360 return None
361 if is_trace and original_lines:
362 return self._match_trace(geometry, filled, sy, sx, inside, sessions)
363 # 形の変化を許容するのは、元の線画で対応を確かめる場合だけ。
364 # 仕上げ色の境目を領域として使う代替経路まで緩めると、誤塗りを増幅する。
365 usable = inside & ~geometry.boundary[sy, sx] if original_lines else inside
366 if usable.sum() < size * FACE_SAMPLE_SHARE: 366 ↛ 367line 366 didn't jump to line 367 because the condition on line 366 was never true
367 return None
368 labels = geometry.regions.labels[sy[usable], sx[usable]]
369 index, share = ap._majority(labels)
370 required = FACE_MATCH_SHARE if original_lines else ap.HINT_SHARE
371 if index is None or share < required:
372 return None
373 other_size = int(geometry.regions.sizes[index])
374 if not size / ap.MAX_AREA_RATIO <= other_size <= size * ap.MAX_AREA_RATIO:
375 return None
376 matched = usable & (geometry.regions.labels[sy, sx] == index)
377 reverse = FACE_REVERSE_SHARE if original_lines else ap.FILL_SHARE
378 if matched.sum() < other_size * reverse: 378 ↛ 379line 378 didn't jump to line 379 because the condition on line 378 was never true
379 return None
380 positions = np.flatnonzero(matched)
381 at = int(positions[len(positions) // 2])
382 reference = self._reference(geometry, int(sx[at]), int(sy[at]), sessions)
383 return reference if reference.ok else None
385 def _match_trace(self, geometry, filled, sy, sx, inside, sessions):
386 """同じTP色の線を近傍で照合する。塗る範囲自体はバケツから変えない。"""
387 from . import auto_paint as ap
388 height, width = geometry.values.shape
389 code = reference_check._pack(filled.target)
390 selected = np.full(sy.shape, -1, dtype=np.int32)
391 selected_y, selected_x = sy.copy(), sx.copy()
392 offsets = sorted(
393 ((dy, dx) for dy in range(-TRACE_RADIUS, TRACE_RADIUS + 1)
394 for dx in range(-TRACE_RADIUS, TRACE_RADIUS + 1)),
395 key=lambda item: (item[0] ** 2 + item[1] ** 2, item),
396 )
397 for dy, dx in offsets:
398 y, x = sy + dy, sx + dx
399 valid = inside & (selected < 0) & (y >= 0) & (y < height) & (x >= 0) & (x < width)
400 at = np.flatnonzero(valid)
401 at = at[geometry.values[y[at], x[at]] == code]
402 selected[at] = geometry.regions.labels[y[at], x[at]]
403 selected_y[at], selected_x[at] = y[at], x[at]
404 matched = selected >= 0
405 if matched.sum() < len(sy) * TRACE_MATCH_SHARE:
406 return None
407 index, share = ap._majority(selected[matched])
408 if index is None or share < ap.HINT_SHARE: 408 ↛ 409line 408 didn't jump to line 409 because the condition on line 408 was never true
409 return None
410 points = np.flatnonzero(selected == index)
411 other_size = int(geometry.regions.sizes[index])
412 size = int(filled.local.sum())
413 if not size / ap.MAX_AREA_RATIO <= other_size <= size * ap.MAX_AREA_RATIO: 413 ↛ 414line 413 didn't jump to line 414 because the condition on line 413 was never true
414 return None
415 unique = np.unique(selected_y[points] * width + selected_x[points]).size
416 if unique < other_size * TRACE_REVERSE_SHARE: 416 ↛ 417line 416 didn't jump to line 417 because the condition on line 416 was never true
417 return None
418 at = int(points[len(points) // 2])
419 reference = self._reference(geometry, int(selected_x[at]), int(selected_y[at]), sessions)
420 return reference if reference.ok else None
423def paint_cell(pixels, guides, options, *, target, margin, work=None, trace_guides=(),
424 trace_palette=None, part_guides=(), trace_face_rules=None, trace_contexts=None,
425 tracked_color=None):
426 from . import auto_paint as ap, auto_trace
427 if work is None:
428 work = BucketWork(target, options)
429 result = ap.CellPaint()
430 entries = work.collect()
431 sessions = {}
432 def record(entry, choices, *, trace=False):
433 x, y, value, filled = entry
434 result.checked += 1
435 choices = [rgb for rgb in choices if rgb is not None]
436 if not choices or any(rgb != choices[0] for rgb in choices[1:]):
437 local = filled.local
438 result.unsure += 1
439 if trace:
440 result.undecided.append((filled.bounds, local))
441 kind = reference_check.KIND_UNDECIDED_LINE
442 else:
443 result.unpainted.append((filled.bounds, local))
444 kind = reference_check.KIND_UNPAINTED
445 if local.sum() >= ap.REPORT_MIN_PIXELS:
446 result.notes.append(reference_check.Finding(
447 kind, reference_check._to_rgb(value), int(local.sum()),
448 bounds=filled.bounds, mask=local,
449 ))
450 return
451 click = BucketClick(x, y, choices[0])
452 result.clicks.append((click.x, click.y, click.rgb))
453 result.paints.extend(_paints(filled, click.rgb))
454 if trace:
455 result.line_pixels += int(filled.local.sum())
456 else:
457 result.filled += 1
459 traces = []
460 pending_faces = []
461 choose_color = work.choose_color
462 part_color = work.part_color
463 face_entries = [(ordinal, entry) for ordinal, entry in enumerate(entries)
464 if entry[3].target is None or not ap._is_primary(entry[3].target)]
465 face_requests = [(ordinal, entry, guide) for ordinal, entry in face_entries for guide in guides]
466 face_choices = iter(work.alignment.resolve([
467 lambda ordinal=ordinal, entry=entry, guide=guide:
468 choose_color(guide, ordinal, entry, margin, sessions)
469 for ordinal, entry, guide in face_requests
470 ]))
471 face_colors = {ordinal: [next(face_choices) for guide in guides]
472 for ordinal, _entry in face_entries}
473 part_requests = [(ordinal, entry) for ordinal, entry in face_entries
474 if not any(rgb is not None for rgb in face_colors[ordinal])]
475 part_choices = work.alignment.resolve([
476 lambda entry=entry: [part_color(guide, entry[3], margin) for guide in part_guides]
477 for _ordinal, entry in part_requests
478 ])
479 part_colors = {ordinal: choices for (ordinal, _entry), choices in zip(part_requests, part_choices)}
480 for ordinal, entry in enumerate(entries):
481 rgb = entry[3].target
482 if rgb is not None and ap._is_primary(rgb):
483 traces.append((ordinal, entry))
484 else:
485 choices = face_colors[ordinal]
486 if not any(rgb is not None for rgb in choices):
487 choices = part_colors[ordinal]
488 if not any(rgb is not None for rgb in choices) and tracked_color is not None:
489 choices = [tracked_color(entry[3])]
490 if not any(rgb is not None for rgb in choices):
491 pending_faces.append(entry)
492 else:
493 record(entry, choices)
494 # 面の色を先に確定し、その大きな面の色は線の候補から除く。
495 painted = target.values.copy()
496 for rgb, (x0, y0, x1, y1), mask in result.paints:
497 painted[y0:y1, x0:x1][mask] = reference_check._pack(rgb)
498 palette_guides = list(dict.fromkeys(trace_guides or guides))
499 palettes = (trace_contexts if trace_contexts is not None else
500 [auto_trace.palette(guide) for guide in palette_guides] if traces else [])
501 if trace_palette is None:
502 trace_palette = auto_trace.finish_palette(trace_guides or guides)
503 if trace_face_rules is None:
504 trace_face_rules = auto_trace.enclosed_face_rules(trace_guides or guides, trace_palette)
505 faces = {}
506 face_samples = {}
507 line_finishes = {}
508 trace_requests = []
509 for ordinal, entry in traces:
510 filled = entry[3]
511 hosts = auto_trace.large_face_colors(target, filled, painted, cache=faces)
512 allowed = auto_trace.candidates(filled.target, hosts, palettes)
513 all_candidates = auto_trace.candidates(filled.target, hosts, palettes, minimum=1)
514 finish_colors = {value: count for value, count in trace_palette.get(filled.target, {}).items()
515 if value not in hosts}
516 ambiguous = len(allowed) != 1 or len(all_candidates) != 1
517 trace_requests.append((ordinal, entry, hosts, allowed, finish_colors, ambiguous))
519 def trace_colors(request):
520 ordinal, entry, _hosts, allowed, finish_colors, ambiguous = request
521 if not ambiguous:
522 return []
523 direct = [work.choose_color(guide, ordinal, entry, margin, sessions) for guide in guides]
524 direct.extend(auto_trace.local_finish(
525 target, guide, entry[3], margin, work.alignment, finish_colors or allowed,
526 ) for guide in guides)
527 return direct
529 trace_choices = work.alignment.resolve([
530 lambda request=request: trace_colors(request) for request in trace_requests
531 ])
532 for request, direct in zip(trace_requests, trace_choices):
533 ordinal, entry, hosts, _allowed, finish_colors, _ambiguous = request
534 filled = entry[3]
535 direct.append(auto_trace.small_face_finish(
536 target, filled, painted, finish_colors, cache=face_samples,
537 ))
538 rgb = auto_trace.choose(filled.target, hosts, palettes, direct, finishes=trace_palette)
539 record(entry, [rgb], trace=True)
540 if rgb is not None:
541 x0, y0, x1, y1 = filled.bounds
542 for label in np.unique(target.regions.labels[y0:y1, x0:x1][filled.local]).tolist():
543 line_finishes[label] = reference_check._pack(rgb)
544 for entry in pending_faces:
545 rgb = auto_trace.enclosed_face_color(target, entry[3], line_finishes, trace_face_rules)
546 record(entry, [rgb])
547 if work.island_max_width:
548 # 自動塗りが確定したクリックを共通バケツで再現し、同じ島を拾う。
549 result.paints = []
550 session = bucket_fill.BucketSession(
551 target.pixels, replace(work.options, island_max_width=work.island_max_width), indexed=True,
552 )
553 for x, y, rgb in result.clicks:
554 filled = session.compact(x, y)
555 if filled.ok: 555 ↛ 553line 555 didn't jump to line 553 because the condition on line 555 was always true
556 result.paints.extend(_paints(filled, rgb))
557 return result