Coverage for paintmaskanimator/reference_check.py: 93%
211 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"""見本のセルや前後のコマと比べて、塗り残し・塗り間違いの候補を探す(UI 非依存)。
31枚目だけ仕上げたカットを残りのコマへ塗り進めると、次のような取りこぼしが出る。
5- 見本で使っている色が、あるコマにだけ無い(星の中心を塗り忘れた、など)。
6- 見本に無い色が、あるコマに残っている(色トレス線を塗り色に変え忘れた、など)。
7- ある部分の色が、前後のコマの同じ部分と違う(頬を髪の色で塗った、など)。
9前の2つはセルの色の集計を見本と比べるだけで分かる。最後の1つは、塗った部分ごとに
10前後のコマで同じ部分(線画のずれを推定して動かした位置)の色を数え、前後の両方が
11別の同じ色ならそれを候補にする。前後で色が揃わない所(動きが大きい、形が変わった)は
12判断できないので挙げない。
14numpy 配列だけを扱う純粋な関数にしてある(``docs/design-principles.md``
15「将来は Rust へ置き換えていく」)。
16"""
17from __future__ import annotations
19from dataclasses import dataclass
20from typing import Dict, List, Optional, Tuple
22import numpy as np
24from . import fill_tracking
25from .bucket_fill import erode
27Rgb = Tuple[int, int, int]
28Bounds = Tuple[int, int, int, int]
30#: 見本で使っている色が、このセルに無い。
31KIND_MISSING = "missing"
32#: 見本に無い色が、このセルに残っている。
33KIND_EXTRA = "extra"
34#: この部分の色が、前後のコマの同じ部分と違う。
35KIND_MISMATCH = "mismatch"
36#: 自動塗りで、場所や色を決めきれず塗らなかった部分(透明のまま)。
37KIND_UNPAINTED = "unpainted"
38#: 自動塗りで、前後のセルと違う色で塗ってあった部分(塗り替えてはいない)。
39KIND_SUSPECT = "suspect"
40#: 自動塗りで、手本に無い形(開いた口など)なので塗らなかった部分。
41KIND_NEW_PART = "new_part"
42#: 自動塗りで、残すか塗るかを決める手がかりの無かった色トレス線。
43KIND_UNDECIDED_LINE = "undecided_line"
45#: 透明を表す値(:func:`fill_tracking._labels` と同じ)。
46_TRANSPARENT = 0
47#: 色の集計で、これより少ない画素しか無い色は数えない(見本側・セル側とも)。
48MIN_COLOR_PIXELS = 5
49#: 前後のコマと比べる部分の面積(px)の範囲。小さすぎる部分はずれの推定が
50#: 当てにならず、大きすぎる部分(髪全体など)は形が大きく変わる。
51MIN_REGION_AREA = 20
52MAX_REGION_AREA = 40000
53#: 前後のコマで、動かした範囲のうち一番多い色が占める割合の下限。
54MIN_AGREEMENT = 0.6
55#: この画素数だけ縮めると消える細い部分(色トレス線や斜線)は比べない。
56#: 少し動くだけで前後のコマと重ならなくなり、候補として当てにならない。
57THIN_RADIUS = 2
60@dataclass(frozen=True)
61class Finding:
62 """見つかった候補1つ。"""
64 kind: str
65 #: 問題の色(MISSING は見本の色、EXTRA は残っている色、MISMATCH はこの部分の色。
66 #: 透明は ``None``)。
67 rgb: Optional[Rgb]
68 #: 画素数(MISSING は見本での画素数)。
69 pixels: int
70 #: 場所 ``(x0, y0, x1, y1)``(x1, y1 は排他)。MISSING には無い。
71 bounds: Optional[Bounds] = None
72 #: MISMATCH で、前後のコマのその部分の色(透明は ``None``)。
73 expected: Optional[Rgb] = None
74 #: MISMATCH で、外接矩形の中のこの部分のマスク。
75 mask: Optional[np.ndarray] = None
78def _to_rgb(value) -> Optional[Rgb]:
79 value = int(value)
80 if value == _TRANSPARENT:
81 return None
82 # _labels は RGBA8888 をリトルエンディアンの uint32 として見た値。
83 return (value & 255, (value >> 8) & 255, (value >> 16) & 255)
86def _is_line(rgb) -> bool:
87 return rgb is not None and max(rgb) <= fill_tracking.LINE_MAX
90def color_counts(pixels) -> Dict[Rgb, int]:
91 """不透明な色ごとの画素数。白(消しゴム)と透明は数えない。"""
92 labels = fill_tracking._labels(pixels)
93 values, counts = np.unique(labels, return_counts=True)
94 result = {}
95 for value, count in zip(values.tolist(), counts.tolist()):
96 rgb = _to_rgb(value)
97 if rgb is None or rgb == (255, 255, 255):
98 continue
99 result[rgb] = int(count)
100 return result
103def compare_colors(reference_pixels, pixels, min_pixels=MIN_COLOR_PIXELS) -> List[Finding]:
104 """見本 ``reference_pixels`` とこのセル ``pixels`` の使用色の違い。"""
105 reference = color_counts(reference_pixels)
106 counts = color_counts(pixels)
107 findings = []
108 for rgb, count in sorted(reference.items(), key=lambda item: -item[1]):
109 if count >= min_pixels and counts.get(rgb, 0) == 0:
110 findings.append(Finding(KIND_MISSING, rgb, count))
111 extra = [
112 (rgb, count) for rgb, count in counts.items()
113 if count >= min_pixels and rgb not in reference
114 ]
115 if extra:
116 packed = fill_tracking._labels(pixels)
117 for rgb, count in sorted(extra, key=lambda item: -item[1]):
118 findings.append(Finding(
119 KIND_EXTRA, rgb, count, bounds=_bounds(packed == _pack(rgb))
120 ))
121 return findings
124def _pack(rgb) -> int:
125 return rgb[0] | (rgb[1] << 8) | (rgb[2] << 16) | (255 << 24)
128def color_mask(pixels, rgb):
129 """``pixels`` のうち色 ``rgb`` の画素(``None`` なら透明)。"""
130 packed = fill_tracking._labels(pixels)
131 return packed == (_TRANSPARENT if rgb is None else _pack(rgb))
134def _bounds(mask) -> Optional[Bounds]:
135 return fill_tracking.mask_bounds(mask)
138def label_regions(values):
139 """同じ値が4近傍でつながった部分に番号を振る。``(番号の配列, 部分の数)``。"""
140 labels, count, _runs = _label_runs(values)
141 return labels, count
144def _label_runs(values):
145 """:func:`label_regions` と、ランごとの ``(行, 始まり, 終わり, 番号)``。
147 行ごとに同じ値の連続区間(ラン)へ分け、上下の行で重なって値が同じランを
148 つなぐ。ランは各行を左から隙間なく覆うので、番号の配列はランの番号を長さ分
149 並べるだけで作れる。大きさや外接矩形も、画素ごとではなくランごとに数えれば
150 足りる(ランは画素よりずっと少ない)。
151 """
152 values = np.asarray(values)
153 height, width = values.shape
154 if height == 0 or width == 0: 154 ↛ 155line 154 didn't jump to line 155 because the condition on line 154 was never true
155 empty = np.zeros(0, dtype=np.int64)
156 return np.zeros((height, width), dtype=np.int32), 0, (empty, empty, empty, empty)
157 from . import native
158 labeled = native.label_runs(values)
159 if labeled is not None:
160 return labeled
161 change = np.ones((height, width), dtype=bool)
162 change[:, 1:] = values[:, 1:] != values[:, :-1]
163 run_y, run_x = np.nonzero(change)
164 count = run_x.size
165 run_end = np.empty(count, dtype=np.int64)
166 run_end[:-1] = run_x[1:]
167 last_in_row = np.ones(count, dtype=bool)
168 last_in_row[:-1] = run_y[1:] != run_y[:-1]
169 run_end[last_in_row] = width
170 run_value = values[run_y, run_x]
171 row_start = np.searchsorted(run_y, np.arange(height + 1))
173 connected = (native.connect_runs(run_x, run_end, run_value, row_start)
174 if values.dtype.kind in "biu" else None)
175 if connected is not None:
176 run_label, regions = connected
177 labels = np.repeat(run_label, run_end - run_x).reshape(height, width)
178 return labels, regions, (run_y, run_x, run_end, run_label)
180 parent = list(range(count))
182 def find(index):
183 while parent[index] != index:
184 parent[index] = parent[parent[index]]
185 index = parent[index]
186 return index
188 starts, ends, kinds = run_x.tolist(), run_end.tolist(), run_value.tolist()
189 rows = row_start.tolist()
190 for y in range(height - 1):
191 upper, upper_end = rows[y], rows[y + 1]
192 lower, lower_end = rows[y + 1], rows[y + 2]
193 while upper < upper_end and lower < lower_end:
194 if (
195 kinds[upper] == kinds[lower]
196 and starts[upper] < ends[lower]
197 and starts[lower] < ends[upper]
198 ):
199 root_a, root_b = find(upper), find(lower)
200 if root_a != root_b:
201 parent[max(root_a, root_b)] = min(root_a, root_b)
202 if ends[upper] < ends[lower]:
203 upper += 1
204 else:
205 lower += 1
206 roots = np.fromiter((find(index) for index in range(count)), dtype=np.int64, count=count)
207 _unique, compact = np.unique(roots, return_inverse=True)
208 run_label = compact.astype(np.int32).reshape(count)
209 labels = np.repeat(run_label, run_end - run_x).reshape(height, width)
210 return labels, int(_unique.size), (run_y, run_x, run_end, run_label)
213#: 前後のコマで対応する部分の面積比の上限(どちら向きにも)。
214MAX_AREA_RATIO = 2.0
215#: 前後のコマで対応する部分の、まわりの色の並びの似ている度合いの下限
216#: (:func:`fill_tracking.context_similarity`)。
217MIN_CONTEXT = 0.6
220class CellRegions:
221 """1セル分の、色ごとにつながった部分の情報。前後のコマと比べるときに使い回す。"""
223 def __init__(self, pixels, values=None):
224 """:param values: 部分分けに使う色の値。省略時は ``pixels`` から作る。"""
225 self.pixels = pixels
226 self.values = fill_tracking._labels(pixels) if values is None else values
227 self.lines = fill_tracking.line_art(pixels)
228 self.height, self.width = self.values.shape
229 self.labels, count, runs = _label_runs(self.values)
230 # 大きさと外接矩形は、画素ごとではなくランごとに数える。
231 run_y, run_x, run_end, run_label = runs
232 self._region_values = np.empty(count, dtype=self.values.dtype)
233 self._region_values[run_label] = self.values[run_y, run_x]
234 self.sizes = np.bincount(
235 run_label, weights=run_end - run_x, minlength=count,
236 ).astype(np.int64)
237 self.y0 = np.full(count, self.height)
238 self.y1 = np.zeros(count, dtype=np.int64)
239 self.x0 = np.full(count, self.width)
240 self.x1 = np.zeros(count, dtype=np.int64)
241 np.minimum.at(self.y0, run_label, run_y)
242 np.maximum.at(self.y1, run_label, run_y)
243 np.minimum.at(self.x0, run_label, run_x)
244 np.maximum.at(self.x1, run_label, run_end - 1)
245 self.on_edge = set(np.unique(np.concatenate([
246 self.labels[0], self.labels[-1], self.labels[:, 0], self.labels[:, -1]
247 ])).tolist())
249 def bounds(self, index) -> Bounds:
250 return (
251 int(self.x0[index]), int(self.y0[index]),
252 int(self.x1[index]) + 1, int(self.y1[index]) + 1,
253 )
255 def local_mask(self, index):
256 x0, y0, x1, y1 = self.bounds(index)
257 return self.labels[y0:y1, x0:x1] == index
259 def mask(self, index):
260 x0, y0, x1, y1 = self.bounds(index)
261 out = np.zeros((self.height, self.width), dtype=bool)
262 out[y0:y1, x0:x1] = self.labels[y0:y1, x0:x1] == index
263 return out
265 def value(self, index) -> int:
266 return int(self._region_values[index])
268 def region_under(self, mask):
269 """``mask`` の下(主線を除く)で一番多い部分の番号と、その割合。"""
270 sample = self.labels[mask & ~self.lines]
271 if not sample.size: 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 return None, 0.0
273 indices, counts = np.unique(sample, return_counts=True)
274 top = int(np.argmax(counts))
275 return int(indices[top]), float(counts[top]) / sample.size
278def _counterpart(current, region, neighbour, min_agreement):
279 """``neighbour`` のコマで ``region`` に当たる部分の番号。見つからなければ None。
281 線画のずれを推定して動かした位置で一番多い部分を選び、面積が近く、まわりの
282 色の並びもよく似ているときだけ「同じ部分」とみなす。
283 """
284 shift = fill_tracking.estimate_shift(current.lines, neighbour.lines, region)
285 moved = fill_tracking.shift_mask(region, *shift)
286 index, share = neighbour.region_under(moved)
287 if index is None or share < min_agreement: 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true
288 return None
289 area = int(np.count_nonzero(region))
290 ratio = int(neighbour.sizes[index]) / max(1, area)
291 if not 1.0 / MAX_AREA_RATIO <= ratio <= MAX_AREA_RATIO: 291 ↛ 292line 291 didn't jump to line 292 because the condition on line 291 was never true
292 return None
293 similarity = fill_tracking.context_similarity(
294 current.pixels, region, neighbour.pixels, neighbour.mask(index)
295 )
296 return index if similarity >= MIN_CONTEXT else None
299def find_mismatches(
300 previous, current, following,
301 min_area=MIN_REGION_AREA, max_area=MAX_REGION_AREA,
302 min_agreement=MIN_AGREEMENT,
303) -> List[Finding]:
304 """``current`` の部分のうち、前後のコマの同じ部分と色が違うものを挙げる。
306 引数はそれぞれのコマの :class:`CellRegions`。前後の両方で同じ部分が見つかり、
307 その色が揃っていて、このコマの色と違うものだけを返す。
308 """
309 if not ( 309 ↛ 312line 309 didn't jump to line 312 because the condition on line 309 was never true
310 previous.values.shape == current.values.shape == following.values.shape
311 ):
312 return []
313 findings = []
314 candidates = np.nonzero(
315 (current.sizes >= min_area) & (current.sizes <= max_area)
316 )[0]
317 for index in candidates.tolist():
318 value = current.value(index)
319 rgb = _to_rgb(value)
320 if _is_line(rgb) or rgb == (255, 255, 255):
321 continue
322 if rgb is None and index in current.on_edge: 322 ↛ 324line 322 didn't jump to line 324 because the condition on line 322 was never true
323 # キャンバスの端につながる透明は、絵の外(背景)。
324 continue
325 local = current.local_mask(index)
326 if not erode(local, THIN_RADIUS).any(): 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true
327 continue
328 region = current.mask(index)
329 # 同じ位置でもう同じ色なら、ずれを推定するまでもない。
330 consistent = False
331 for neighbour in (previous, following):
332 same, share = neighbour.region_under(region)
333 if same is not None and neighbour.value(same) == value and share >= min_agreement:
334 consistent = True
335 break
336 if consistent:
337 continue
338 expected = []
339 for neighbour in (previous, following):
340 other = _counterpart(current, region, neighbour, min_agreement)
341 if other is None: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true
342 break
343 expected.append(neighbour.value(other))
344 if len(expected) != 2 or expected[0] != expected[1] or expected[0] == value:
345 continue
346 expected_rgb = _to_rgb(expected[0])
347 if _is_line(expected_rgb): 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true
348 continue
349 findings.append(Finding(
350 KIND_MISMATCH, rgb, int(current.sizes[index]),
351 bounds=current.bounds(index), expected=expected_rgb, mask=local,
352 ))
353 findings.sort(key=lambda finding: -finding.pixels)
354 return findings
357__all__ = [
358 "Finding",
359 "KIND_MISSING",
360 "KIND_EXTRA",
361 "KIND_MISMATCH",
362 "color_counts",
363 "color_mask",
364 "compare_colors",
365 "label_regions",
366 "CellRegions",
367 "find_mismatches",
368]