Coverage for paintmaskanimator/auto_trace.py: 94%
218 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"""TP線の仕上げ色を、線色と周囲の面の組み合わせから学習する。"""
2from collections import Counter
3from hashlib import blake2b
5import numpy as np
7from . import bucket_fill, reference_check
10LARGE_FACE_RATIO = 3
11MIN_LARGE_FACE = 32
12MAX_LARGE_FACE_THRESHOLD = 256
13MIN_FINISH_EVIDENCE = 6
16def finish_palette(guides):
17 """人が選んだ見本の元TP画素から、形状照合に依存しない色表を先に作る。"""
18 from . import auto_paint as ap
19 colors = {}
20 for guide in guides:
21 geometry = guide.initial
22 if geometry is None:
23 continue
24 known = geometry.primary & ~guide.open & ~guide.undecided
25 for original in np.unique(geometry.values[known]).tolist():
26 rgb = reference_check._to_rgb(original)
27 selected = known & (geometry.values == original)
28 values, counts = np.unique(guide.values[selected], return_counts=True)
29 votes = colors.setdefault(rgb, Counter())
30 for value, count in zip(values.tolist(), counts.tolist()):
31 if ap._paintable(value):
32 votes[int(value)] += int(count)
33 return {rgb: {value: count for value, count in votes.items()
34 if count >= MIN_FINISH_EVIDENCE} for rgb, votes in colors.items()}
37def small_face_finish(geometry, filled, painted, allowed, *, cache=None):
38 """囲みの内側が確定していれば、同じ仕上げ色のTP線を形によらず選べる。"""
39 from . import auto_paint as ap
40 x0, y0, x1, y1 = filled.bounds
41 height, width = geometry.values.shape
42 ax0, ay0 = max(0, x0 - 2), max(0, y0 - 2)
43 ax1, ay1 = min(width, x1 + 2), min(height, y1 + 2)
44 local = np.zeros((ay1 - ay0, ax1 - ax0), dtype=bool)
45 local[y0 - ay0:y1 - ay0, x0 - ax0:x1 - ax0] = filled.local
46 labels = geometry.regions.labels[ay0:ay1, ax0:ax1][bucket_fill.dilate(local, 2) & ~local]
47 answers = set()
48 for label in np.unique(labels).tolist():
49 if geometry.regions.value(label) != 0 or geometry.regions.sizes[label] < 3:
50 continue
51 if cache is None or label not in cache:
52 bx0, by0, bx1, by1 = geometry.regions.bounds(label)
53 sample = painted[by0:by1, bx0:bx1][geometry.regions.local_mask(label)]
54 answer = ap._majority(sample)
55 if cache is not None:
56 cache[label] = answer
57 else:
58 answer = cache[label]
59 value, share = answer
60 if value in allowed and share >= ap.HINT_SHARE:
61 answers.add(value)
62 return reference_check._to_rgb(next(iter(answers))) if len(answers) == 1 else None
65def enclosed_face_rules(guides, finishes):
66 """見本でTP線と小さな内側の面が同色の用例だけを覚える。"""
67 from . import auto_paint as ap
68 rules = set()
69 for guide in guides:
70 geometry = guide.initial
71 if geometry is None:
72 continue
73 faces = {}
74 for label in np.unique(geometry.regions.labels[geometry.primary]).tolist():
75 rgb = reference_check._to_rgb(geometry.regions.value(label))
76 bounds = geometry.regions.bounds(label)
77 x0, y0, x1, y1 = bounds
78 local = geometry.regions.local_mask(label)
79 if (guide.open[y0:y1, x0:x1][local].any() 79 ↛ 81line 79 didn't jump to line 81 because the condition on line 79 was never true
80 or guide.undecided[y0:y1, x0:x1][local].any()):
81 continue
82 value, share = ap._majority(guide.values[y0:y1, x0:x1][local])
83 if value not in finishes.get(rgb, {}) or share < ap.HINT_SHARE:
84 continue
85 filled = bucket_fill.CompactFill(bucket_fill.REASON_OK, rgb, bounds, local, (), bounds)
86 if value in large_face_colors(geometry, filled, guide.values, cache=faces):
87 continue
88 if small_face_finish(geometry, filled, guide.values, {value: 1}) is not None:
89 rules.add((rgb, value))
90 return rules
93def enclosed_face_color(geometry, filled, line_finishes, rules):
94 """見本と同じ用途のTP境界に囲まれた小面へ、その仕上げ色を引き継ぐ。"""
95 x0, y0, x1, y1 = filled.bounds
96 height, width = geometry.values.shape
97 if (filled.target is not None or int(filled.local.sum()) > 4096
98 or x0 == 0 or y0 == 0 or x1 == width or y1 == height):
99 return None
100 ax0, ay0, ax1, ay1 = x0 - 1, y0 - 1, x1 + 1, y1 + 1
101 local = np.zeros((ay1 - ay0, ax1 - ax0), dtype=bool)
102 local[1:-1, 1:-1] = filled.local
103 ring = bucket_fill.dilate(local, 1) & ~local
104 labels, counts = np.unique(geometry.regions.labels[ay0:ay1, ax0:ax1][ring],
105 return_counts=True)
106 votes = Counter()
107 for label, count in zip(labels.tolist(), counts.tolist()):
108 value = line_finishes.get(label)
109 original = reference_check._to_rgb(geometry.regions.value(label))
110 if value is not None and (original, value) in rules:
111 votes[value] += count
112 answers = [value for value, count in votes.items() if count >= max(3, int(ring.sum() * 0.35))]
113 return reference_check._to_rgb(answers[0]) if len(answers) == 1 else None
116def large_face_colors(geometry, filled, painted, *, cache):
117 """線に接する大きな面の色。これらは線の仕上げ色として使わない。"""
118 from . import auto_paint as ap
119 x0, y0, x1, y1 = filled.bounds
120 height, width = geometry.values.shape
121 ax0, ay0 = max(0, x0 - 2), max(0, y0 - 2)
122 ax1, ay1 = min(width, x1 + 2), min(height, y1 + 2)
123 local = np.zeros((ay1 - ay0, ax1 - ax0), dtype=bool)
124 local[y0 - ay0:y1 - ay0, x0 - ax0:x1 - ax0] = filled.local
125 ring = bucket_fill.dilate(local, 2) & ~local
126 ring &= ~geometry.boundary[ay0:ay1, ax0:ax1]
127 labels = geometry.regions.labels[ay0:ay1, ax0:ax1][ring]
128 minimum = max(MIN_LARGE_FACE, min(MAX_LARGE_FACE_THRESHOLD,
129 int(filled.local.sum()) * LARGE_FACE_RATIO))
130 colors = set()
131 neighbours = np.unique(labels).tolist()
132 # 小さな囲みの内側(頬など)を、外側の肌と同じ「大きな面」にしない。
133 largest = max((int(geometry.regions.sizes[label]) for label in neighbours
134 if geometry.regions.value(label) != ap.GAP_VALUE), default=0)
135 minimum = max(minimum, largest * 0.25)
136 for label in neighbours:
137 if geometry.regions.sizes[label] < minimum:
138 continue
139 if geometry.regions.value(label) == ap.GAP_VALUE: 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true
140 continue
141 if label not in cache:
142 bx0, by0, bx1, by1 = geometry.regions.bounds(label)
143 values = painted[by0:by1, bx0:bx1][geometry.regions.local_mask(label)]
144 value, share = ap._majority(values)
145 cache[label] = value if value is not None and share >= ap.FILL_SHARE and ap._paintable(value) else None
146 if cache[label] is not None:
147 colors.add(cache[label])
148 return frozenset(colors)
151def palette(guide):
152 """見本の初期TP線が何色で仕上げられたかを集計する。"""
153 from . import auto_paint as ap
154 from .auto_bucket import geometry_key
155 geometry = guide.initial
156 if geometry is None:
157 return []
158 digest = blake2b(digest_size=16)
159 for values in (guide.values, guide.open, guide.undecided):
160 digest.update(values.tobytes())
161 signature = geometry_key(geometry), digest.digest()
162 cached = getattr(guide, "_trace_palette", None)
163 if cached is not None and cached[0] == signature: 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true
164 return cached[1]
165 rules = Counter()
166 faces = {}
167 for index in np.unique(geometry.regions.labels[geometry.primary]).tolist():
168 original = geometry.regions.value(index)
169 rgb = reference_check._to_rgb(original)
170 if rgb is None or not ap._is_primary(rgb): 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true
171 continue
172 bounds = geometry.regions.bounds(index)
173 x0, y0, x1, y1 = bounds
174 local = geometry.regions.local_mask(index)
175 if guide.open[y0:y1, x0:x1][local].any() or guide.undecided[y0:y1, x0:x1][local].any():
176 continue
177 filled = bucket_fill.CompactFill(bucket_fill.REASON_OK, rgb, bounds, local, (), bounds)
178 hosts = large_face_colors(geometry, filled, guide.values, cache=faces)
179 sample = guide.values[y0:y1, x0:x1][local]
180 values, counts = np.unique(sample, return_counts=True)
181 candidates = [(int(value), int(count)) for value, count in zip(values, counts)
182 if value not in hosts and ap._paintable(value)]
183 if not candidates:
184 continue
185 color, count = max(candidates, key=lambda item: item[1])
186 # 周囲の面を拾った画素を除いた後、線の仕上げ色として十分な根拠があること。
187 if count < min(3, len(sample)) or count < len(sample) * 0.2: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 continue
189 if count < sum(amount for _value, amount in candidates) * 0.8: 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true
190 continue
191 rules[(rgb, hosts, color)] += count
192 result = [(rgb, hosts, color, count) for (rgb, hosts, color), count in rules.items()]
193 guide._trace_palette = (signature, result)
194 return result
197def candidates(trace_rgb, hosts, palettes, *, minimum=MIN_FINISH_EVIDENCE):
198 votes = Counter()
199 contextual = []
200 for rules in palettes:
201 for rgb, context, color, count in rules:
202 if rgb != trace_rgb or color in hosts:
203 continue
204 if hosts and context and not hosts.intersection(context): 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true
205 continue
206 score = len(hosts.intersection(context)) / len(hosts.union(context)) if hosts else 0.0
207 contextual.append((score, color, count))
208 # 周囲が一致する用例を優先し、文脈の無い別用途の線を混ぜない。
209 best = max((score for score, _color, _count in contextual), default=0.0)
210 for score, color, count in contextual:
211 if score == best:
212 votes[color] += count
213 return {color: count for color, count in votes.items() if count >= minimum}
216def local_finish(target, guide, filled, margin, alignment, allowed):
217 """線全体の形が変わった場合も、同色の初期TP線上の仕上げ色を採る。"""
218 from . import auto_paint as ap
219 geometry = guide.initial
220 if geometry is None or not allowed:
221 return None
222 shift, score = alignment.shift(target, geometry, filled.bounds, margin)
223 if score < ap.MIN_COLOR_LINE_OVERLAP: 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true
224 return None
225 sy, sx, inside = ap._shifted(target, filled.local, filled.bounds, shift)
226 height, width = geometry.values.shape
227 code = reference_check._pack(filled.target)
228 selected = np.zeros(sy.shape, dtype=np.uint32)
229 found = np.zeros(sy.shape, dtype=bool)
230 offsets = sorted(((dy, dx) for dy in range(-2, 3) for dx in range(-2, 3)),
231 key=lambda item: (item[0] ** 2 + item[1] ** 2, item))
232 for dy, dx in offsets:
233 y, x = sy + dy, sx + dx
234 at = np.flatnonzero(inside & ~found & (y >= 0) & (y < height) & (x >= 0) & (x < width))
235 at = at[geometry.values[y[at], x[at]] == code]
236 found[at] = True
237 known = ~(guide.open[y[at], x[at]] | guide.undecided[y[at], x[at]])
238 selected[at[known]] = guide.values[y[at[known]], x[at[known]]]
239 values = selected[np.isin(selected, list(allowed))]
240 if len(values) < max(2, int(len(sy) * 0.35)): 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true
241 return None
242 value, share = ap._majority(values)
243 return reference_check._to_rgb(value) if share >= 0.9 else None
246def choose(trace_rgb, hosts, palettes, direct, *, finishes=None):
247 """近くで照合できた線を優先し、無ければ同じ文脈の仕上げ色を使う。"""
248 votes = candidates(trace_rgb, hosts, palettes)
249 direct_votes = candidates(trace_rgb, hosts, palettes, minimum=1)
250 if finishes is not None:
251 direct_votes.update({value: count for value, count in finishes.get(trace_rgb, {}).items()
252 if value not in hosts})
253 accepted = [reference_check._pack(rgb) for rgb in direct
254 if rgb is not None and reference_check._pack(rgb) in direct_votes]
255 if accepted and all(value == accepted[0] for value in accepted):
256 return reference_check._to_rgb(accepted[0])
257 if not accepted and len(votes) == 1:
258 return reference_check._to_rgb(next(iter(votes)))
259 if not accepted and finishes is not None:
260 remaining = set(finishes.get(trace_rgb, {})) - set(hosts)
261 if len(remaining) == 1:
262 return reference_check._to_rgb(next(iter(remaining)))
263 return None