Coverage for paintmaskanimator/native.py: 97%
134 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"""Required Rust backend for timeline state and image computations.
3Missing or outdated extensions fail at startup with a rebuild instruction.
4NumPy remains in use for array conversion and algorithms not yet moved to Rust.
5"""
6from __future__ import annotations
8import numpy as np
10from .logging_setup import get_logger
12log = get_logger(__name__)
14REQUIRED_API = (
15 "TimelineCell", "resolve_timeline", "ClipAnimationReader", "GuideSampler",
16 "resolve_exposures", "tint_onion", "unique_colors", "filter_palette",
17 "line_flow_scores", "connect_runs", "label_runs", "refine_face", "thin",
18 "render_quad_labels", "render_mesh_labels",
19 "surface_guided_palette_labels", "map_known_colors", "region_neighbors",
20 "flood_fill", "dilate", "erode", "label_regions",
21)
22REBUILD_MESSAGE = (
23 "PaintMaskAnimator requires its Rust extension. "
24 "Install or rebuild it from the project directory: python -m pip install ./native"
25)
28def validate_backend(module):
29 missing = [name for name in REQUIRED_API if not callable(getattr(module, name, None))]
30 if missing:
31 raise ImportError(f"{REBUILD_MESSAGE} (missing API: {', '.join(missing)})")
34try:
35 import pma_native as _native
36except ImportError as error:
37 raise ImportError(REBUILD_MESSAGE) from error
38validate_backend(_native)
41def available() -> bool:
42 """Compatibility probe: the required backend was validated on import."""
43 return True
46def clip_animation_reader(path: str, max_dimension: int, max_raster_bytes: int):
47 """CLIP parsing has one implementation, supplied by clipfile-rs."""
48 return _native.ClipAnimationReader(path, max_dimension, max_raster_bytes)
51def resolve_exposures(states, exposures):
52 count, layers = states.shape
53 data = _native.resolve_exposures(
54 states.tobytes(), exposures.tobytes(), count, layers,
55 )
56 return np.frombuffer(data, dtype=np.int64).reshape(count, layers)
59def resolve_timeline(frames):
60 """Read Rust-owned cell metadata directly, without packing NumPy inputs."""
61 rows = [[layer.__dict__.get("_timeline_metadata") for layer in frame.layers]
62 for frame in frames]
63 layers = max(map(len, rows), default=0)
64 data = _native.resolve_timeline(rows)
65 return np.frombuffer(data, dtype=np.int64).reshape(len(rows), layers)
68def tint_onion(rgba, color, color_enabled, selected):
69 data = _native.tint_onion(
70 np.ascontiguousarray(rgba).tobytes(), tuple(color), bool(color_enabled),
71 None if selected is None else [(int(rgb[0]), int(rgb[1]), int(rgb[2])) for rgb in selected],
72 )
73 return np.frombuffer(data, dtype=np.uint8).reshape(rgba.shape)
76def unique_colors(pixels, limit):
77 """Return the smallest sorted packed RGB colors, using the required Rust backend."""
78 return _native.unique_colors(
79 np.ascontiguousarray(pixels, dtype=np.uint32).tobytes(), int(limit)
80 )
83def filter_palette(rgba, visible, legacy, remap):
84 """Filter original RGB colors and remap visible pixels in one Rust pass."""
85 def rgb_tuple(rgb):
86 return (int(rgb[0]), int(rgb[1]), int(rgb[2]))
88 data = _native.filter_palette(
89 np.ascontiguousarray(rgba, dtype=np.uint8).tobytes(),
90 None if visible is None else [rgb_tuple(rgb) for rgb in visible],
91 None if legacy is None else rgb_tuple(legacy),
92 [(rgb_tuple(child), rgb_tuple(parent)) for child, parent in remap.items()],
93 )
94 return np.frombuffer(data, dtype=np.uint8).reshape(rgba.shape)
97def line_flow_scores(target, guide, block, base_y, base_x, reach):
98 """位置合わせの一致数をRustで計算する。"""
99 height, width = target.shape
100 result = _native.line_flow_scores(
101 _as_bytes(target), _as_bytes(guide), width, height, int(block),
102 np.ascontiguousarray(base_y, dtype=np.int64).tobytes(),
103 np.ascontiguousarray(base_x, dtype=np.int64).tobytes(), int(reach),
104 )
105 side = 2 * reach + 1
106 return np.frombuffer(result, dtype=np.uint32).reshape(
107 side * side, height // block, width // block,
108 ).astype(np.float64)
111def _as_bytes(mask):
112 return np.ascontiguousarray(mask, dtype=bool).tobytes()
115def connect_runs(starts, ends, kinds, rows):
116 """同色の上下ランをRustで結合する。"""
117 data, count = _native.connect_runs(*(
118 np.ascontiguousarray(values, dtype=np.uint64).tobytes()
119 for values in (starts, ends, kinds, rows)
120 ))
121 return np.frombuffer(data, dtype=np.int32).copy(), count
124def label_runs(values):
125 """連結領域の走査・結合・番号画像の作成をRustでまとめて行う。"""
126 # 大画像は転送・番号画像の複製が支配的になるため、NumPyの走査と
127 # connect_runsのRust結合を併用する方が速い(実データで確認)。
128 if (values.dtype.kind not in "biu" or not values.dtype.isnative
129 or values.size > 65536):
130 return None
131 height, width = values.shape
132 labels, count, ys, xs, ends, runs = _native.label_runs(
133 np.ascontiguousarray(values).tobytes(), width, height, values.dtype.itemsize,
134 )
135 return (np.frombuffer(labels, dtype=np.int32).reshape(height, width).copy(), count,
136 (np.frombuffer(ys, dtype=np.int64).copy(), np.frombuffer(xs, dtype=np.int64).copy(),
137 np.frombuffer(ends, dtype=np.int64).copy(), np.frombuffer(runs, dtype=np.int32).copy()))
140def refine_face(guide, filled, shift, bounds, radius, thresholds):
141 """必要な小矩形だけを渡し、ずれの探索・採色・面積比確認を一括実行する。"""
142 ax0, ay0, ax1, ay1 = bounds
143 ys, xs = np.nonzero(filled.local)
144 points = np.column_stack((ys + filled.bounds[1] + shift[0] - ay0,
145 xs + filled.bounds[0] + shift[1] - ax0))
146 roi = np.s_[ay0:ay1, ax0:ax1]
147 return _native.refine_face(
148 np.ascontiguousarray(guide.values[roi], dtype=np.uint64).tobytes(),
149 np.ascontiguousarray(guide.regions.labels[roi], dtype=np.uint32).tobytes(),
150 _as_bytes(~guide.initial.boundary[roi]),
151 _as_bytes(guide.open[roi] | guide.undecided[roi]),
152 np.ascontiguousarray(guide.regions.sizes, dtype=np.uint64).tobytes(),
153 np.ascontiguousarray(points, dtype=np.int64).tobytes(),
154 ax1 - ax0, ay1 - ay0, radius, thresholds,
155 )
158def sample_region(guide, filled, shift=(0, 0), *, ignore=0, require=0, by_label=False):
159 """不変の見本をラン圧縮して保持し、面の重なりと色をRustで数える。"""
160 immutable = getattr(guide, "_native_immutable", False)
161 sampler = getattr(guide, "_native_sampler", None) if immutable else None
162 if sampler is None:
163 initial = guide.initial if guide.initial is not None else guide
164 flags = guide.boundary.astype(np.uint8)
165 flags |= initial.boundary.astype(np.uint8) * 2
166 flags |= (guide.open | guide.undecided).astype(np.uint8) * 4
167 confirmed = getattr(guide, "confirmed", None)
168 flags |= 8 if confirmed is None else confirmed.astype(np.uint8) * 8
169 sampler = _native.GuideSampler(
170 np.ascontiguousarray(guide.values, dtype=np.uint64).tobytes(),
171 np.ascontiguousarray(guide.regions.labels, dtype=np.uint32).tobytes(),
172 flags.tobytes(),
173 np.ascontiguousarray(guide.regions.sizes, dtype=np.uint64).tobytes(),
174 guide.values.shape[1], guide.values.shape[0],
175 )
176 if immutable:
177 guide._native_sampler = sampler
178 runs = getattr(filled, "_native_runs", None)
179 if runs is None:
180 padded = np.pad(filled.local, ((0, 0), (1, 1)))
181 ys, xs = np.nonzero(padded[:, 1:] != padded[:, :-1])
182 points = np.column_stack((ys[::2] + filled.bounds[1], xs[::2] + filled.bounds[0],
183 xs[1::2] + filled.bounds[0]))
184 runs = np.ascontiguousarray(points, dtype=np.int64).tobytes()
185 filled._native_runs = runs
186 return sampler.sample(runs, int(shift[0]), int(shift[1]), ignore, require, by_label)
189def thin(mask, iterations):
190 """線端検出用の細線化をRustで計算する。"""
191 height, width = mask.shape
192 return _as_mask(_native.thin(_as_bytes(mask), width, height, int(iterations)), height, width)
195def _as_mask(data, height, width):
196 # 返ってきた bytes は読み取り専用なので、呼び出し側が書き換えられるよう複製する。
197 return np.frombuffer(data, dtype=bool).reshape(height, width).copy()
200def flood_fill(passable, seeds):
201 """4近傍で ``seeds`` からつながる ``passable`` の領域。"""
202 height, width = passable.shape
203 seeds = [(int(x), int(y)) for x, y in seeds]
204 return _as_mask(
205 _native.flood_fill(_as_bytes(passable), width, height, seeds), height, width
206 )
209def dilate(mask, iterations):
210 """8近傍で ``iterations`` 回膨張させる。"""
211 height, width = mask.shape
212 return _as_mask(
213 _native.dilate(_as_bytes(mask), width, height, int(iterations)), height, width
214 )
217def erode(mask, iterations):
218 """8近傍で ``iterations`` 回収縮させる(範囲外は偽)。"""
219 height, width = mask.shape
220 return _as_mask(
221 _native.erode(_as_bytes(mask), width, height, int(iterations)), height, width
222 )
225#: :func:`label_regions` で番号を付けない画素の値。
226INVALID_CODE = 0xFFFFFFFF
229def label_regions(codes):
230 """同じ値で4近傍につながった画素に1からの番号を付ける。
232 :param codes: uint32 の2次元配列。:data:`INVALID_CODE` の画素は番号 0。
233 :returns: ``(番号の int32 配列, 領域の数)``。
234 """
235 height, width = codes.shape
236 data, count = _native.label_regions(
237 np.ascontiguousarray(codes, dtype=np.uint32).tobytes(), width, height
238 )
239 labels = np.frombuffer(data, dtype=np.uint32).reshape(height, width)
240 return labels.astype(np.int32), int(count)
243def render_quad_labels(label_image, matrix, out_width, out_height, subsamples, coverage_labels):
244 """平面四角形の逆写像で番号の画像を変形する。
246 :param label_image: uint16 の2次元配列。0 は「何もない」。
247 :param matrix: 出力座標から元座標への 3x3(逆写像)。
248 :param coverage_labels: 被覆(0-255)も返す番号の並び。
249 :returns: ``(uint16 の番号, 番号 -> uint8 の被覆)``。
250 """
251 height, width = label_image.shape
252 labels = np.ascontiguousarray(label_image, dtype=np.uint16)
253 coverage_labels = [int(label) for label in coverage_labels]
254 data, coverage = _native.render_quad_labels(
255 labels.tobytes(), width, height,
256 [float(value) for value in np.asarray(matrix, dtype=np.float64).reshape(9)],
257 int(out_width), int(out_height), int(subsamples), coverage_labels,
258 )
259 result = np.frombuffer(data, dtype=np.uint16).reshape(out_height, out_width)
260 return result.copy(), {
261 label: np.frombuffer(plane, dtype=np.uint8).reshape(out_height, out_width).copy()
262 for label, plane in zip(coverage_labels, coverage)
263 }
266def render_mesh_labels(
267 label_image, points, dense_cols, dense_rows, out_width, out_height,
268 subsamples, band_rows, coverage_labels,
269):
270 """曲がった格子(メッシュ変形)で番号の画像を変形する。
272 :param points: 出力座標の格子点 ``(x, y)`` を行優先で並べたもの。
273 :param band_rows: 一度に解く行数。使う記憶領域の上限になる。
274 :returns: ``(uint16 の番号, 番号 -> uint8 の被覆)``。
275 """
276 height, width = label_image.shape
277 labels = np.ascontiguousarray(label_image, dtype=np.uint16)
278 coverage_labels = [int(label) for label in coverage_labels]
279 grid = np.asarray(points, dtype=np.float64).reshape(-1, 2)
280 data, coverage = _native.render_mesh_labels(
281 labels.tobytes(), width, height,
282 [(float(x), float(y)) for x, y in grid],
283 int(dense_cols), int(dense_rows), int(out_width), int(out_height),
284 int(subsamples), int(band_rows), coverage_labels,
285 )
286 result = np.frombuffer(data, dtype=np.uint16).reshape(out_height, out_width)
287 return result.copy(), {
288 label: np.frombuffer(plane, dtype=np.uint8).reshape(out_height, out_width).copy()
289 for label, plane in zip(coverage_labels, coverage)
290 }
293def surface_guided_palette_labels(rgb, visible, palette, spatial_refinement=True):
294 """画素ごとに割り当てるパレットの番号を決める(色面を種に境界を二分する)。
296 :param rgb: ``(高さ, 幅, 3)`` の uint8 配列。
297 :param visible: ``(高さ, 幅)`` の真偽配列。偽の画素は -1 のまま。
298 :param palette: ``(色数, 3)`` の uint8 配列。
299 :returns: ``(高さ, 幅)`` の int16 配列。未確定は -1。
300 """
301 height, width = visible.shape
302 pixels = np.ascontiguousarray(rgb, dtype=np.uint8).reshape(height, width, 3)
303 colors = np.ascontiguousarray(palette, dtype=np.uint8).reshape(-1, 3)
304 data = _native.surface_guided_palette_labels(
305 pixels.tobytes(), width, height,
306 np.ascontiguousarray(visible, dtype=bool).tobytes(), colors.tobytes(),
307 bool(spatial_refinement),
308 )
309 return np.frombuffer(data, dtype=np.int16).reshape(height, width).copy()
312def map_known_colors(pixels, palette, color_mapping, line_max):
313 """隣接色分けの仮の色を、割り当て済みの対応表どおりに塗る。
315 :param pixels: ``(高さ, 幅, 4)`` の RGBA8888 配列。
316 :param color_mapping: 元の RGB をまとめた値から仮の色の番号への辞書。
317 :returns: 塗った ``(高さ, 幅, 4)`` の配列。対応のない色があれば ``None``。
318 """
319 height, width = pixels.shape[:2]
320 codes = sorted(int(code) for code in color_mapping)
321 data = _native.map_known_colors(
322 np.ascontiguousarray(pixels, dtype=np.uint8).tobytes(),
323 codes, [int(color_mapping[code]) for code in codes],
324 [(int(color[0]), int(color[1]), int(color[2])) for color in palette],
325 int(line_max),
326 )
327 if data is None:
328 return None
329 # 読み取り専用のまま返す。呼び出し側が自分の配列へ書き写すので複製は要らない。
330 return np.frombuffer(data, dtype=np.uint8).reshape(height, width, 4)
333def region_neighbors(labels, gap):
334 """``gap`` 画素以内で向かい合う領域の組 ``(a, b)``(``a < b``)の配列。"""
335 height, width = labels.shape
336 pairs = _native.region_neighbors(
337 np.ascontiguousarray(labels, dtype=np.int32).tobytes(), width, height, int(gap)
338 )
339 return np.asarray(pairs, dtype=np.int64).reshape(-1, 2)