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

1"""TP線の仕上げ色を、線色と周囲の面の組み合わせから学習する。""" 

2from collections import Counter 

3from hashlib import blake2b 

4 

5import numpy as np 

6 

7from . import bucket_fill, reference_check 

8 

9 

10LARGE_FACE_RATIO = 3 

11MIN_LARGE_FACE = 32 

12MAX_LARGE_FACE_THRESHOLD = 256 

13MIN_FINISH_EVIDENCE = 6 

14 

15 

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

35 

36 

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 

63 

64 

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 

91 

92 

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 

114 

115 

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) 

149 

150 

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 

195 

196 

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} 

214 

215 

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 

244 

245 

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