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

1"""キャンバスの見え方だけを変えるプレビュー(UI 非依存)。 

2 

3After Effects のコンポジションパネルにある「露光量の調整」と「チャンネルを表示」、 

4それに塗り漏れを探すための「隣接色分け」を、レイヤー画像の表示用の複製に掛ける。 

5画素データ・書き出し・サムネイルには一切影響しない。 

6 

7* **露光量**: RGB を ``2 ** 段数`` 倍する(AE と同じく段数で指定)。暗い所の 

8 わずかな色の違いや、白飛びしかけた所を見つけやすくする。 

9* **チャンネル**: 赤・緑・青のどれか1つをグレーで、またはアルファを白黒で見せる。 

10* **隣接色分け**: 同じ色でつながった部分を1つの領域とみなし、隣り合う領域が 

11 必ず違う色になるよう、見分けやすい仮の色で塗り分ける(地図の塗り分けと同じ)。 

12 元の色が同じ領域は、主線で分かれていても同じ仮の色になるので、 

13 どこが同じ色なのかがそのまま読める。 

14 ノーマルとほんの少し違う影色の点のような、目では区別しにくい塗り漏れが 

15 はっきり浮き上がる。主線(黒に近い色)は元の色のまま残す。 

16 

17numpy 配列だけを扱うので、GUI なしで単体テストできる。 

18""" 

19from __future__ import annotations 

20 

21from dataclasses import dataclass 

22from typing import Tuple 

23 

24import numpy as np 

25 

26from . import native 

27 

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) 

35 

36#: 露光量(段)の範囲。AE と同じ。 

37EXPOSURE_MIN = -10.0 

38EXPOSURE_MAX = 10.0 

39 

40#: 隣接色分けで、この距離(px)以内にある別の領域は隣り合うとみなす。 

41#: 主線をはさんで向かい合う領域も、隣どうしとして違う色にするため。 

42MAP_NEIGHBOR_GAP = 4 

43#: 隣接色分けで元の色のまま残す「主線」とみなす明るさの上限(各チャンネル)。 

44MAP_LINE_MAX = 48 

45 

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) 

61 

62 

63@dataclass(frozen=True) 

64class ViewPreview: 

65 """表示プレビューの設定。既定値はプレビューなし。""" 

66 

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 

75 

76 @property 

77 def active(self) -> bool: 

78 return self.adjusts_pixels or self.map_colors 

79 

80 @property 

81 def adjusts_pixels(self) -> bool: 

82 """露光量かチャンネルで、すべてのレイヤーの色を変えるか。""" 

83 return abs(self.exposure) > 1e-9 or self.channel != CHANNEL_RGB 

84 

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 ) 

90 

91 

92def clamp_exposure(value) -> float: 

93 return max(EXPOSURE_MIN, min(EXPOSURE_MAX, float(value))) 

94 

95 

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) 

101 

102 

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 

120 

121 

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]) 

129 

130 

131def label_regions(codes, valid) -> Tuple[np.ndarray, int]: 

132 """同じ値で4近傍につながった ``valid`` の画素に、1から始まる番号を付ける。 

133 

134 番号は、各領域の最初の画素(行優先)が現れた順。Rustで計算する。 

135 

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) 

141 

142 

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) 

182 

183 

184def region_neighbors(labels, count, gap=MAP_NEIGHBOR_GAP): 

185 """``gap`` px 以内に接する領域の組 ``(a, b)``(a < b)の配列。 

186 

187 numpy 版は向きと距離の組み合わせごとに画像全体を比べる(既定で16回)ので、 

188 ここは Rust で数える。:func:`region_neighbors_numpy` は突き合わせ用。 

189 """ 

190 del count # Rust 版は番号の数を使わない(組をそのまま集める)。 

191 return native.region_neighbors(labels, gap) 

192 

193 

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) 

218 

219 

220def assign_map_colors(count, neighbors, areas, palette_size=None, groups=None): 

221 """隣り合う領域が違う番号になるよう、各領域に色の番号を割り当てる。 

222 

223 大きい領域から順に、隣でまだ使っていない一番若い番号を選ぶ(貪欲法)。 

224 使える番号が尽きたら、隣で使っている回数が一番少ない番号にする。 

225 

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 

263 

264 

265def map_color_pixels(pixels, gap=MAP_NEIGHBOR_GAP, palette=MAP_PALETTE, color_mapping=None): 

266 """隣り合う領域が違う色になるよう、``pixels`` を仮の色で塗り分ける(その場で)。 

267 

268 不透明で、白(擬似透明)でも主線(黒に近い色)でもない画素が対象。 

269 

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 

342 

343 

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]