Coverage for paintmaskanimator/auto_part_tracking.py: 91%

290 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""確定した部位を保持し、線画の局所的な変形を使って再発見する。""" 

2from collections import OrderedDict 

3from dataclasses import dataclass 

4 

5import numpy as np 

6 

7from . import bucket_fill, line_flow, reference_check 

8 

9 

10MEMORY_BLOCK = 128 

11MAX_DONORS = 3 

12MAX_FLOW_PAIRS = 64 

13MAX_TRACK_STEPS = 12 

14MAX_TILE_STEPS = 2 

15 

16 

17@dataclass 

18class _FlowPair: 

19 forward: line_flow.Flow 

20 backward: line_flow.Flow 

21 x0: int 

22 y0: int 

23 

24 def coordinates(self, ys, xs): 

25 dy, dx = self.forward.at(ys - self.y0, xs - self.x0) 

26 sy, sx = ys + dy, xs + dx 

27 by, bx = self.backward.at(sy - self.y0, sx - self.x0) 

28 return (np.rint(sy).astype(np.int64), np.rint(sx).astype(np.int64), 

29 np.rint(sy + by).astype(np.int64), np.rint(sx + bx).astype(np.int64), 

30 np.ones(ys.shape, dtype=bool)) 

31 

32 

33@dataclass 

34class _FlowPath: 

35 pairs: list 

36 shape: tuple 

37 

38 def coordinates(self, ys, xs): 

39 sy, sx = ys.astype(np.float64), xs.astype(np.float64) 

40 valid = np.ones(ys.shape, dtype=bool) 

41 height, width = self.shape 

42 for pair in self.pairs: 

43 dy, dx = pair.forward.at(sy - pair.y0, sx - pair.x0) 

44 sy, sx = sy + dy, sx + dx 

45 valid &= (sy >= 0) & (sy < height) & (sx >= 0) & (sx < width) 

46 by, bx = sy.copy(), sx.copy() 

47 for pair in reversed(self.pairs): 

48 dy, dx = pair.backward.at(by - pair.y0, bx - pair.x0) 

49 by, bx = by + dy, bx + dx 

50 return (np.rint(sy).astype(np.int64), np.rint(sx).astype(np.int64), 

51 np.rint(by).astype(np.int64), np.rint(bx).astype(np.int64), valid) 

52 

53 

54def _in_mask(ys, xs, x0, y0, mask): 

55 y, x = ys - y0, xs - x0 

56 inside = (y >= 0) & (y < mask.shape[0]) & (x >= 0) & (x < mask.shape[1]) 

57 result = np.zeros(ys.shape, dtype=bool) 

58 result[inside] = mask[y[inside], x[inside]] 

59 return result 

60 

61 

62def _near_black(cell, ys, xs): 

63 height, width = cell.black.shape 

64 result = np.zeros(ys.shape, dtype=bool) 

65 for dy in (-1, 0, 1): 

66 for dx in (-1, 0, 1): 

67 result |= cell.black[np.clip(ys + dy, 0, height - 1), np.clip(xs + dx, 0, width - 1)] 

68 return result 

69 

70 

71class PartTracker: 

72 """色を確定した場所の索引。途中の未塗りセルで古い確定部分を消さない。""" 

73 

74 def __init__(self, shape, geometry_for=None, alignment=None): 

75 from .auto_alignment import AlignmentCache 

76 self.shape = shape 

77 self.geometry_for = geometry_for 

78 self.alignment = alignment if alignment is not None else AlignmentCache(gpu="off") 

79 self.coverage = {} 

80 self.pairs = OrderedDict() 

81 self.flow_solves = 0 

82 self.matches = 0 

83 self.long_matches = 0 

84 

85 def _coverage(self, bounds, mask): 

86 x0, y0, x1, y1 = bounds 

87 counts = {} 

88 for ty in range(y0 // MEMORY_BLOCK, (y1 - 1) // MEMORY_BLOCK + 1): 

89 for tx in range(x0 // MEMORY_BLOCK, (x1 - 1) // MEMORY_BLOCK + 1): 

90 ax0, ay0 = max(x0, tx * MEMORY_BLOCK), max(y0, ty * MEMORY_BLOCK) 

91 ax1, ay1 = min(x1, (tx + 1) * MEMORY_BLOCK), min(y1, (ty + 1) * MEMORY_BLOCK) 

92 count = int(mask[ay0 - y0:ay1 - y0, ax0 - x0:ax1 - x0].sum()) 

93 if count >= 8: 

94 counts[(ty, tx)] = count 

95 return counts 

96 

97 def remember_fixed(self, index, guide): 

98 known = (guide.values != 0) & ~guide.black & ~guide.primary & ~guide.open & ~guide.undecided 

99 height, width = self.shape 

100 self.coverage[index] = self._coverage((0, 0, width, height), known) 

101 

102 def remember(self, index, paint): 

103 counts = {} 

104 for _rgb, bounds, mask in paint.paints: 

105 for key, count in self._coverage(bounds, mask).items(): 

106 counts[key] = min(MEMORY_BLOCK ** 2, counts.get(key, 0) + count) 

107 # 更新するのはこのセルの確定部分だけ。他セルの最後の成功は保持する。 

108 self.coverage[index] = counts 

109 

110 def donors(self, index, filled): 

111 query = self._coverage(filled.bounds, filled.local) 

112 total = sum(query.values()) 

113 if not total: 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true

114 return [] 

115 ranked = [] 

116 for other, known in self.coverage.items(): 

117 if other == index or abs(other - index) > MAX_TRACK_STEPS or not known: 

118 continue 

119 overlap = 0 

120 for (ty, tx), count in query.items(): 

121 nearby = max((known.get((ty + dy, tx + dx), 0) 

122 for dy in (-1, 0, 1) for dx in (-1, 0, 1)), default=0) 

123 overlap += min(count, nearby) 

124 if overlap >= max(8, total * 0.2): 124 ↛ 116line 124 didn't jump to line 116 because the condition on line 124 was always true

125 ranked.append((overlap / total, -abs(other - index), -other, other)) 

126 return [item[-1] for item in sorted(ranked, reverse=True)[:MAX_DONORS]] 

127 

128 def _pair(self, target, source): 

129 from .auto_bucket import geometry_key 

130 key = (geometry_key(target), geometry_key(source)) 

131 if key in self.pairs: 

132 self.pairs.move_to_end(key) 

133 return self.pairs[key] 

134 reverse = key[1], key[0] 

135 if reverse in self.pairs: 

136 self.pairs.move_to_end(reverse) 

137 found = self.pairs[reverse] 

138 return _FlowPair(found.backward, found.forward, found.x0, found.y0) 

139 if target.values.shape != self.shape or source.values.shape != self.shape: 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true

140 return None 

141 first, second = bucket_fill.mask_bounds(target.boundary), bucket_fill.mask_bounds(source.boundary) 

142 if first is None or second is None: 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true

143 return None 

144 height, width = self.shape 

145 x0, y0 = max(0, min(first[0], second[0]) - 32), max(0, min(first[1], second[1]) - 32) 

146 x1, y1 = min(width, max(first[2], second[2]) + 32), min(height, max(first[3], second[3]) + 32) 

147 left, right = target.boundary[y0:y1, x0:x1], source.boundary[y0:y1, x0:x1] 

148 pair = _FlowPair(line_flow.estimate_flow(left, right), line_flow.estimate_flow(right, left), 

149 x0, y0) 

150 self.alignment.register_motion(target, source, pair.forward, x0, y0) 

151 self.alignment.register_motion(source, target, pair.backward, x0, y0) 

152 self.flow_solves += 1 

153 self.pairs[key] = pair 

154 if len(self.pairs) > MAX_FLOW_PAIRS: 154 ↛ 155line 154 didn't jump to line 155 because the condition on line 154 was never true

155 self.pairs.popitem(last=False) 

156 return pair 

157 

158 def _path(self, index, target, guide): 

159 if self.geometry_for is None or guide.index is None: 

160 return self._pair(target, guide.initial) 

161 direction = 1 if guide.index > index else -1 

162 pairs = [] 

163 for step in range(index, guide.index, direction): 

164 pair = self._pair(self.geometry_for(step), self.geometry_for(step + direction)) 

165 if pair is None: 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true

166 return None 

167 pairs.append(pair) 

168 return _FlowPath(pairs, self.shape) 

169 

170 def _color(self, index, target, filled, guide): 

171 rgb = self._flow_color(index, target, filled, guide) 

172 return rgb if rgb is not None else self._tiled_color(index, target, filled, guide) 

173 

174 def prime_motion(self, index, target, guides): 

175 """通常の領域照合より先に、隣接セル間の共有変形場を用意する。""" 

176 adjacent = [guide for guide in guides if guide.initial is not None 

177 and guide.index is not None and abs(guide.index - index) == 1] 

178 if adjacent: 

179 self._path(index, target, adjacent[0]) 

180 

181 def _flow_color(self, index, target, filled, guide): 

182 from . import auto_paint as ap 

183 source = guide.initial 

184 if source is None: 

185 return None 

186 pair = self._path(index, target, guide) 

187 if pair is None: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true

188 return None 

189 height, width = self.shape 

190 x0, y0, x1, y1 = filled.bounds 

191 ax0, ay0 = max(0, x0 - 1), max(0, y0 - 1) 

192 ax1, ay1 = min(width, x1 + 1), min(height, y1 + 1) 

193 local = np.zeros((ay1 - ay0, ax1 - ax0), dtype=bool) 

194 local[y0 - ay0:y1 - ay0, x0 - ax0:x1 - ax0] = filled.local 

195 outline = bucket_fill.dilate(local, 1) & ~local 

196 outline &= target.boundary[ay0:ay1, ax0:ax1] 

197 oy, ox = np.nonzero(outline) 

198 if len(oy) < 8: 

199 return None 

200 oy, ox = oy + ay0, ox + ax0 

201 sy, sx, by, bx, consistent = pair.coordinates(oy, ox) 

202 # 長い無地の輪郭では接線方向の位置は決まらない。同じ部位の輪郭へ戻ればよい。 

203 outline_band = bucket_fill.dilate(local, 1) & ~bucket_fill.erode(local, 2) 

204 consistent &= _in_mask(by, bx, ax0, ay0, outline_band) 

205 inside = (sy >= 0) & (sy < height) & (sx >= 0) & (sx < width) 

206 sy, sx = np.clip(sy, 0, height - 1), np.clip(sx, 0, width - 1) 

207 hits = inside & consistent & source.wide_boundary[sy, sx] 

208 hits &= np.where(target.black[oy, ox], _near_black(source, sy, sx), source.wide_primary[sy, sx]) 

209 if hits.mean() < 0.5: 

210 return None 

211 quadrants = (oy >= (y0 + y1) / 2).astype(np.uint8) * 2 + (ox >= (x0 + x1) / 2) 

212 for quadrant in range(4): 

213 selected = quadrants == quadrant 

214 if selected.sum() >= 4 and hits[selected].mean() < 0.35: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true

215 return None # 一辺だけの一致を、その部位全体の対応と取り違えない。 

216 ys, xs = np.nonzero(filled.local) 

217 ys, xs = ys + y0, xs + x0 

218 sy, sx, by, bx, consistent = pair.coordinates(ys, xs) 

219 consistent &= _in_mask(by, bx, x0, y0, filled.local) 

220 known = (sy >= 0) & (sy < height) & (sx >= 0) & (sx < width) & consistent 

221 sy, sx = np.clip(sy, 0, height - 1), np.clip(sx, 0, width - 1) 

222 known &= ~source.boundary[sy, sx] & ~guide.open[sy, sx] & ~guide.undecided[sy, sx] 

223 confirmed = guide.confirmed 

224 if confirmed is not None: 

225 known &= confirmed[sy, sx] 

226 known &= (guide.values[sy, sx] != 0) & ~guide.black[sy, sx] & ~guide.primary[sy, sx] 

227 size = len(ys) 

228 if known.sum() < size * 0.7: 

229 return None 

230 value, share = ap._majority(guide.values[sy[known], sx[known]]) 

231 if value is None or share < 0.95 or not ap._paintable(value): 

232 return None 

233 if size < 4096: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true

234 matched = known & (guide.values[sy, sx] == value) 

235 labels = np.unique(guide.regions.labels[sy[matched], sx[matched]]) 

236 other = sum(int(guide.regions.sizes[label]) for label in labels) 

237 if other > size * 4 or matched.sum() < other * 0.3: 

238 return None 

239 return reference_check._to_rgb(value) 

240 

241 def _tiled_color(self, index, target, filled, guide): 

242 """滑らかな動きで合わない部位は、小区画を独立に追跡して色だけを集計する。""" 

243 from . import auto_paint as ap 

244 source = guide.initial 

245 if source is None: 

246 return None 

247 if self.geometry_for is None or guide.index is None: 

248 steps = [(target, source)] 

249 else: 

250 if abs(guide.index - index) > MAX_TILE_STEPS: 

251 return None # 長い経路は共有した変形場で追い、同じFFT探索を区画ごとに繰り返さない。 

252 direction = 1 if guide.index > index else -1 

253 steps = [(self.geometry_for(step), self.geometry_for(step + direction)) 

254 for step in range(index, guide.index, direction)] 

255 if any(left.values.shape != self.shape or right.values.shape != self.shape 255 ↛ 257line 255 didn't jump to line 257 because the condition on line 255 was never true

256 for left, right in steps): 

257 return None 

258 height, width = self.shape 

259 x0, y0, x1, y1 = filled.bounds 

260 outline = bucket_fill.dilate(filled.local, 1) & ~bucket_fill.erode(filled.local, 1) 

261 samples, labels = [], set() 

262 size = int(filled.local.sum()) 

263 tile_size = 64 if size < 262144 else 96 

264 tiles = [] 

265 for ty in range(y0, y1, tile_size): 

266 for tx in range(x0, x1, tile_size): 

267 sub = filled.local[ty - y0:ty - y0 + tile_size, tx - x0:tx - x0 + tile_size] 

268 if sub.sum() < 16: 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true

269 continue 

270 oy, ox = np.nonzero(outline[ty - y0:ty - y0 + sub.shape[0], 

271 tx - x0:tx - x0 + sub.shape[1]]) 

272 real = target.wide_boundary[oy + ty, ox + tx] 

273 oy, ox = oy[real], ox[real] 

274 if len(oy) < 4: 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true

275 continue 

276 initial = (tx, ty, tx + sub.shape[1], ty + sub.shape[0]) 

277 tiles.append((tx, ty, sub, oy, ox, initial)) 

278 paths = self.alignment.track_many(steps, [tile[-1] for tile in tiles], self.shape) 

279 for (tx, ty, sub, oy, ox, _initial), path in zip(tiles, paths): 

280 if path is None: 

281 continue 

282 dy, dx, back_y, back_x = path 

283 sy, sx = oy + ty + dy, ox + tx + dx 

284 at = (sy >= 0) & (sy < height) & (sx >= 0) & (sx < width) 

285 hit = np.zeros(at.shape, dtype=bool) 

286 hit[at] = source.wide_boundary[sy[at], sx[at]] 

287 syc, sxc = np.clip(sy, 0, height - 1), np.clip(sx, 0, width - 1) 

288 hit &= np.where(_near_black(target, oy + ty, ox + tx), 

289 _near_black(source, syc, sxc), source.wide_primary[syc, sxc]) 

290 if hit.mean() < 0.5: 

291 continue 

292 ys, xs = np.nonzero(sub) 

293 ys, xs = ys + ty, xs + tx 

294 sy, sx = ys + dy, xs + dx 

295 known = (sy >= 0) & (sy < height) & (sx >= 0) & (sx < width) 

296 sy, sx = np.clip(sy, 0, height - 1), np.clip(sx, 0, width - 1) 

297 known &= _in_mask(sy + back_y, sx + back_x, x0, y0, filled.local) 

298 known &= ~source.boundary[sy, sx] & ~guide.open[sy, sx] & ~guide.undecided[sy, sx] 

299 known &= (guide.values[sy, sx] != 0) & ~guide.black[sy, sx] & ~guide.primary[sy, sx] 

300 if guide.confirmed is not None: 

301 known &= guide.confirmed[sy, sx] 

302 if known.sum() < len(ys) * 0.5: 

303 continue 

304 values = guide.values[sy[known], sx[known]] 

305 value, share = ap._majority(values) 

306 if value is None or share < 0.9 or not ap._paintable(value): 

307 continue 

308 samples.append(values) 

309 labels.update(np.unique(guide.regions.labels[sy[known], sx[known]]).tolist()) 

310 if not samples or sum(len(values) for values in samples) < size * 0.5: 

311 return None 

312 values = np.concatenate(samples) 

313 value, share = ap._majority(values) 

314 if value is None or share < 0.95 or not ap._paintable(value): 

315 return None 

316 if size < 4096 and sum(int(guide.regions.sizes[label]) for label in labels) > size * 4: 316 ↛ 317line 316 didn't jump to line 317 because the condition on line 316 was never true

317 return None 

318 return reference_check._to_rgb(value) 

319 

320 def color(self, index, target, filled, guide_for): 

321 if filled.target is not None or int(filled.local.sum()) < 16: 

322 return None 

323 # 外側背景を追跡対象にすると、全画像の空白を毎回処理することになる。 

324 height, width = self.shape 

325 if filled.bounds == (0, 0, width, height): 

326 return None 

327 answers = [] 

328 for other in self.donors(index, filled): 

329 rgb = self._color(index, target, filled, guide_for(other)) 

330 if rgb is not None: 

331 answers.append((other, rgb)) 

332 if not answers or any(rgb != answers[0][1] for _other, rgb in answers): 

333 return None 

334 self.matches += 1 

335 self.long_matches += int(min(abs(other - index) for other, _rgb in answers) > 4) 

336 return answers[0][1]