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

1"""色とクリック位置の選択を、通常バケツの領域判定から分離する。""" 

2from dataclasses import dataclass, replace 

3from hashlib import blake2b 

4 

5import numpy as np 

6 

7from . import bucket_fill, native, reference_check 

8from .auto_alignment import AlignmentCache 

9 

10 

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 

20 

21 

22@dataclass(frozen=True) 

23class BucketClick: 

24 x: int 

25 y: int 

26 rgb: tuple[int, int, int] 

27 

28 

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) 

36 

37 

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 

49 

50 

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 

58 

59 

60class BucketWork: 

61 """対象の領域一覧・位置合わせ・色の判断を、塗り直しの回をまたいで保持する。""" 

62 

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 

77 

78 def _sample(self, *args, **kwargs): 

79 return getattr(self.alignment, "sample_region", native.sample_region)(*args, **kwargs) 

80 

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 

91 

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 

127 

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) 

192 

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) 

232 

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) 

283 

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

290 

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) 

335 

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 

384 

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 

421 

422 

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 

458 

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

518 

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 

528 

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