Coverage for paintmaskanimator/view_preview.py: 94%
200 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 非依存)。
3After Effects のコンポジションパネルにある「露光量の調整」と「チャンネルを表示」、
4それに塗り漏れを探すための「隣接色分け」を、レイヤー画像の表示用の複製に掛ける。
5画素データ・書き出し・サムネイルには一切影響しない。
7* **露光量**: RGB を ``2 ** 段数`` 倍する(AE と同じく段数で指定)。暗い所の
8 わずかな色の違いや、白飛びしかけた所を見つけやすくする。
9* **チャンネル**: 赤・緑・青のどれか1つをグレーで、またはアルファを白黒で見せる。
10* **隣接色分け**: 同じ色でつながった部分を1つの領域とみなし、隣り合う領域が
11 必ず違う色になるよう、見分けやすい仮の色で塗り分ける(地図の塗り分けと同じ)。
12 元の色が同じ領域は、主線で分かれていても同じ仮の色になるので、
13 どこが同じ色なのかがそのまま読める。
14 ノーマルとほんの少し違う影色の点のような、目では区別しにくい塗り漏れが
15 はっきり浮き上がる。主線(黒に近い色)は元の色のまま残す。
17numpy 配列だけを扱うので、GUI なしで単体テストできる。
18"""
19from __future__ import annotations
21from dataclasses import dataclass
22from typing import Tuple
24import numpy as np
26from . import native
28#: チャンネル表示の選択肢。
29CHANNEL_RGB = "rgb"
30CHANNEL_RED = "red"
31CHANNEL_GREEN = "green"
32CHANNEL_BLUE = "blue"
33CHANNEL_ALPHA = "alpha"
34CHANNELS = (CHANNEL_RGB, CHANNEL_RED, CHANNEL_GREEN, CHANNEL_BLUE, CHANNEL_ALPHA)
36#: 露光量(段)の範囲。AE と同じ。
37EXPOSURE_MIN = -10.0
38EXPOSURE_MAX = 10.0
40#: 隣接色分けで、この距離(px)以内にある別の領域は隣り合うとみなす。
41#: 主線をはさんで向かい合う領域も、隣どうしとして違う色にするため。
42MAP_NEIGHBOR_GAP = 4
43#: 隣接色分けで元の色のまま残す「主線」とみなす明るさの上限(各チャンネル)。
44MAP_LINE_MAX = 48
46#: 隣接色分けの仮の色。黒(主線)・白(背景)・灰色を避け、互いに見分けやすい色。
47MAP_PALETTE: Tuple[Tuple[int, int, int], ...] = (
48 (230, 25, 75),
49 (60, 180, 75),
50 (255, 225, 25),
51 (0, 130, 200),
52 (245, 130, 48),
53 (145, 30, 180),
54 (70, 240, 240),
55 (240, 50, 230),
56 (210, 245, 60),
57 (250, 190, 212),
58 (0, 128, 128),
59 (170, 110, 40),
60)
63@dataclass(frozen=True)
64class ViewPreview:
65 """表示プレビューの設定。既定値はプレビューなし。"""
67 #: 露光量(段)。0 で変えない。
68 exposure: float = 0.0
69 #: どのチャンネルを見せるか(``CHANNEL_*``)。
70 channel: str = CHANNEL_RGB
71 #: 隣接色分け(アクティブレイヤーだけに掛ける)。
72 map_colors: bool = False
73 #: 隣接色分けの仮の色(環境設定で変えられる)。
74 palette: Tuple[Tuple[int, int, int], ...] = MAP_PALETTE
76 @property
77 def active(self) -> bool:
78 return self.adjusts_pixels or self.map_colors
80 @property
81 def adjusts_pixels(self) -> bool:
82 """露光量かチャンネルで、すべてのレイヤーの色を変えるか。"""
83 return abs(self.exposure) > 1e-9 or self.channel != CHANNEL_RGB
85 def key(self):
86 return (
87 round(float(self.exposure), 3), self.channel, bool(self.map_colors),
88 self.palette if self.map_colors else None,
89 )
92def clamp_exposure(value) -> float:
93 return max(EXPOSURE_MIN, min(EXPOSURE_MAX, float(value)))
96def exposure_lut(stops) -> np.ndarray:
97 """0〜255 の値を ``2 ** stops`` 倍した対応表(uint8)。"""
98 scale = 2.0 ** clamp_exposure(stops)
99 values = np.arange(256, dtype=np.float64) * scale
100 return np.clip(np.rint(values), 0, 255).astype(np.uint8)
103def adjust_pixels(pixels, preview: ViewPreview):
104 """``pixels``(RGBA8888、非プレマルチプライ)に露光量とチャンネルを掛ける(その場で)。"""
105 if abs(preview.exposure) > 1e-9:
106 lut = exposure_lut(preview.exposure)
107 pixels[:, :, :3] = lut[pixels[:, :, :3]]
108 channel = preview.channel
109 if channel in (CHANNEL_RED, CHANNEL_GREEN, CHANNEL_BLUE):
110 index = (CHANNEL_RED, CHANNEL_GREEN, CHANNEL_BLUE).index(channel)
111 gray = pixels[:, :, index].copy()
112 pixels[:, :, 0] = gray
113 pixels[:, :, 1] = gray
114 pixels[:, :, 2] = gray
115 elif channel == CHANNEL_ALPHA:
116 # 不透明度を白の濃さで見せる。黒い背景(:func:`background_color`)の上に
117 # 重ねるので、透明は黒、不透明は白になる。
118 pixels[:, :, :3] = 255
119 return pixels
122def background_color(rgb, preview: ViewPreview) -> Tuple[int, int, int]:
123 """キャンバスの背景(擬似透明の表示色)に同じプレビューを掛けた色。"""
124 if preview.channel == CHANNEL_ALPHA:
125 return (0, 0, 0)
126 pixel = np.array([[[rgb[0], rgb[1], rgb[2], 255]]], dtype=np.uint8)
127 adjust_pixels(pixel, preview)
128 return int(pixel[0, 0, 0]), int(pixel[0, 0, 1]), int(pixel[0, 0, 2])
131def label_regions(codes, valid) -> Tuple[np.ndarray, int]:
132 """同じ値で4近傍につながった ``valid`` の画素に、1から始まる番号を付ける。
134 番号は、各領域の最初の画素(行優先)が現れた順。Rustで計算する。
136 :param codes: 0〜0xFFFFFF の値の2次元配列(RGB をまとめた値など)。
137 :returns: ``(番号の int32 配列, 領域の数)``。``valid`` でない画素は 0。
138 """
139 marked = np.where(valid, codes.astype(np.uint32), np.uint32(native.INVALID_CODE))
140 return native.label_regions(marked)
143def label_regions_numpy(codes, valid) -> Tuple[np.ndarray, int]:
144 """:func:`label_regions` の numpy 版。"""
145 height, width = codes.shape
146 labels = np.zeros((height, width), dtype=np.int64)
147 if not valid.any(): 147 ↛ 148line 147 didn't jump to line 148 because the condition on line 147 was never true
148 return labels.astype(np.int32), 0
149 # 行ごとの「同じ値が続く区間(ラン)」に番号を振る。行をまたぐランはない。
150 starts = valid.copy()
151 starts[:, 1:] &= ~(valid[:, :-1] & (codes[:, 1:] == codes[:, :-1]))
152 run_ids = np.cumsum(starts.reshape(-1), dtype=np.int64).reshape(height, width)
153 run_ids[~valid] = 0
154 run_count = int(run_ids.max())
155 # 上下に同じ値で接するランどうしをつなぐ。
156 joined = valid[:-1, :] & valid[1:, :] & (codes[:-1, :] == codes[1:, :])
157 upper = run_ids[:-1, :][joined]
158 lower = run_ids[1:, :][joined]
159 parent = np.arange(run_count + 1, dtype=np.int64)
160 if upper.size:
161 pairs = np.unique(upper * (run_count + 1) + lower)
162 upper, lower = pairs // (run_count + 1), pairs % (run_count + 1)
163 # 小さい番号へ寄せることと、親をたどって縮めることを、変わらなくなるまで繰り返す。
164 while True:
165 root_upper, root_lower = parent[upper], parent[lower]
166 low = np.minimum(root_upper, root_lower)
167 before = parent.copy()
168 np.minimum.at(parent, root_upper, low)
169 np.minimum.at(parent, root_lower, low)
170 while True:
171 jumped = parent[parent]
172 if np.array_equal(jumped, parent):
173 break
174 parent = jumped
175 if np.array_equal(parent, before):
176 break
177 # 根の番号を 1 から詰め直す。
178 roots, compact = np.unique(parent[1:], return_inverse=True)
179 mapping = np.zeros(run_count + 1, dtype=np.int32)
180 mapping[1:] = compact.astype(np.int32) + 1
181 return mapping[run_ids], int(roots.size)
184def region_neighbors(labels, count, gap=MAP_NEIGHBOR_GAP):
185 """``gap`` px 以内に接する領域の組 ``(a, b)``(a < b)の配列。
187 numpy 版は向きと距離の組み合わせごとに画像全体を比べる(既定で16回)ので、
188 ここは Rust で数える。:func:`region_neighbors_numpy` は突き合わせ用。
189 """
190 del count # Rust 版は番号の数を使わない(組をそのまま集める)。
191 return native.region_neighbors(labels, gap)
194def region_neighbors_numpy(labels, count, gap=MAP_NEIGHBOR_GAP):
195 """:func:`region_neighbors` の numpy 版(参照実装)。"""
196 pairs = []
197 height, width = labels.shape
198 for distance in range(1, max(1, int(gap)) + 1):
199 for dy, dx in ((0, distance), (distance, 0), (distance, distance), (distance, -distance)):
200 if dy >= height or abs(dx) >= width:
201 continue
202 if dx >= 0:
203 a = labels[:height - dy, :width - dx]
204 b = labels[dy:, dx:]
205 else:
206 a = labels[:height - dy, -dx:]
207 b = labels[dy:, :width + dx]
208 touching = (a > 0) & (b > 0) & (a != b)
209 if touching.any(): 209 ↛ 199line 209 didn't jump to line 199 because the condition on line 209 was always true
210 first, second = a[touching], b[touching]
211 low = np.minimum(first, second).astype(np.int64)
212 high = np.maximum(first, second).astype(np.int64)
213 pairs.append(np.unique(low * (count + 1) + high))
214 if not pairs: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true
215 return np.zeros((0, 2), dtype=np.int64)
216 merged = np.unique(np.concatenate(pairs))
217 return np.stack((merged // (count + 1), merged % (count + 1)), axis=1)
220def assign_map_colors(count, neighbors, areas, palette_size=None, groups=None):
221 """隣り合う領域が違う番号になるよう、各領域に色の番号を割り当てる。
223 大きい領域から順に、隣でまだ使っていない一番若い番号を選ぶ(貪欲法)。
224 使える番号が尽きたら、隣で使っている回数が一番少ない番号にする。
226 :param areas: 領域ごとの面積(``areas[番号]``、0 番は使わない)。
227 :param groups: 領域ごとの組の番号(``groups[番号]``)。同じ組の領域には
228 同じ色の番号を割り当てる(元の色が同じ領域をまとめるため)。
229 ``None`` なら領域ごとに別々に割り当てる。
230 :returns: ``colors[番号]`` が色の番号の配列。0 番は -1。
231 """
232 if palette_size is None: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true
233 palette_size = len(MAP_PALETTE)
234 areas = np.asarray(areas, dtype=np.int64)
235 if groups is None: 235 ↛ 236line 235 didn't jump to line 236 because the condition on line 235 was never true
236 group_of = np.arange(count + 1, dtype=np.int64)
237 group_count = count
238 else:
239 group_of = np.asarray(groups, dtype=np.int64)
240 group_count = int(group_of[1:].max()) if count else 0
241 # 組どうしの隣り合いに言い換える(同じ組どうしは色をそろえるので無視)。
242 adjacency = [set() for _ in range(group_count + 1)]
243 for a, b in neighbors.tolist():
244 ga, gb = int(group_of[a]), int(group_of[b])
245 if ga != gb:
246 adjacency[ga].add(gb)
247 adjacency[gb].add(ga)
248 # 組の面積(合計)が大きい順に決める。
249 group_areas = np.bincount(group_of[1:], weights=areas[1:], minlength=group_count + 1)
250 group_colors = np.full(group_count + 1, -1, dtype=np.int32)
251 order = np.argsort(-group_areas[1:], kind="stable") + 1
252 for group in order.tolist():
253 used = [0] * palette_size
254 for other in adjacency[group]:
255 color = group_colors[other]
256 if color >= 0:
257 used[color] += 1
258 free = [index for index, value in enumerate(used) if value == 0]
259 group_colors[group] = free[0] if free else int(np.argmin(used))
260 colors = group_colors[group_of]
261 colors[0] = -1
262 return colors
265def map_color_pixels(pixels, gap=MAP_NEIGHBOR_GAP, palette=MAP_PALETTE, color_mapping=None):
266 """隣り合う領域が違う色になるよう、``pixels`` を仮の色で塗り分ける(その場で)。
268 不透明で、白(擬似透明)でも主線(黒に近い色)でもない画素が対象。
270 :param palette: 仮の色(RGB のタプル列)。先頭の色ほど多く使われる。
271 :param color_mapping: 元の RGB 値からパレット番号への対応表。コマ間で共有すると、
272 一度割り当てた色を維持する。新しい色の対応だけを追加する。
273 """
274 if not palette: 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true
275 palette = MAP_PALETTE
276 rgb = pixels[:, :, :3]
277 codes = (
278 rgb[:, :, 0].astype(np.int32) << 16
279 | rgb[:, :, 1].astype(np.int32) << 8
280 | rgb[:, :, 2].astype(np.int32)
281 )
282 line = (
283 (rgb[:, :, 0] <= MAP_LINE_MAX)
284 & (rgb[:, :, 1] <= MAP_LINE_MAX)
285 & (rgb[:, :, 2] <= MAP_LINE_MAX)
286 )
287 valid = (pixels[:, :, 3] > 0) & (codes != 0xFFFFFF) & ~line
288 if not valid.any(): 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true
289 return pixels
290 if color_mapping is not None:
291 # 割り当て済みの RGB は領域の形や面積によらず同じ表示色。
292 # 描画中も連結成分・隣接関係を毎回計算せず、最新の画素へ適用する。
293 # 色を集計せずに済むよう、1 画素ずつの引き当ては Rust で行う。
294 mapped = native.map_known_colors(
295 pixels, palette, color_mapping, MAP_LINE_MAX
296 )
297 if mapped is not None:
298 pixels[:] = mapped
299 return pixels
300 labels, count = label_regions(codes, valid)
301 areas = np.bincount(labels.reshape(-1), minlength=count + 1)
302 neighbors = region_neighbors(labels, count, gap)
303 # 元の色が同じ領域は同じ組にして、同じ仮の色で塗る。
304 region_codes = np.zeros(count + 1, dtype=np.int64)
305 region_codes[labels[valid]] = codes[valid]
306 groups = np.zeros(count + 1, dtype=np.int64)
307 if count: 307 ↛ 309line 307 didn't jump to line 309 because the condition on line 307 was always true
308 groups[1:] = np.unique(region_codes[1:], return_inverse=True)[1].reshape(-1).astype(np.int64) + 1
309 if color_mapping is None:
310 colors = assign_map_colors(count, neighbors, areas, len(palette), groups)
311 else:
312 # 面積や隣接関係が変わっても、既存の色は割り当て直さない。
313 unique_codes = np.unique(region_codes[1:])
314 group_count = len(unique_codes)
315 adjacency = [set() for _ in range(group_count + 1)]
316 for a, b in neighbors.tolist():
317 ga, gb = int(groups[a]), int(groups[b])
318 if ga != gb: 318 ↛ 316line 318 didn't jump to line 316 because the condition on line 318 was always true
319 adjacency[ga].add(gb)
320 adjacency[gb].add(ga)
321 group_colors = np.full(group_count + 1, -1, dtype=np.int32)
322 for group, code in enumerate(unique_codes, 1):
323 if int(code) in color_mapping:
324 group_colors[group] = color_mapping[int(code)]
325 group_areas = np.bincount(groups[1:], weights=areas[1:], minlength=group_count + 1)
326 for group in (np.argsort(-group_areas[1:], kind="stable") + 1).tolist():
327 if group_colors[group] >= 0:
328 continue
329 used = np.zeros(len(palette), dtype=np.int64)
330 for other in adjacency[group]:
331 if group_colors[other] >= 0:
332 used[group_colors[other]] += 1
333 # 同じ隣接条件なら、過去に使った色の少ない番号を優先する。
334 usage = np.bincount(list(color_mapping.values()), minlength=len(palette))
335 chosen = min(range(len(palette)), key=lambda index: (used[index], usage[index], index))
336 group_colors[group] = chosen
337 color_mapping[int(unique_codes[group - 1])] = chosen
338 colors = group_colors[groups]
339 table = np.asarray(palette, dtype=np.uint8)
340 pixels[valid, :3] = table[colors[labels[valid]]]
341 return pixels
344__all__ = [
345 "CHANNELS",
346 "ViewPreview",
347 "adjust_pixels",
348 "background_color",
349 "assign_map_colors",
350 "exposure_lut",
351 "label_regions",
352 "label_regions_numpy",
353 "region_neighbors",
354 "region_neighbors_numpy",
355 "map_color_pixels",
356]