Coverage for paintmaskanimator/colors.py: 93%
175 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"""Pure color / palette / tone-curve helpers extracted from PaintCanvas.
3Stateless numeric functions over colors and pixel samples (median-cut and
4priority palettes, HSV features, tone-curve normalization, palette painting).
5No widget or document state. PaintCanvas keeps delegating wrappers so its call
6sites are unchanged.
7"""
8import math
10import numpy as np
11from PySide6.QtGui import QColor
13from . import constants # noqa: F401
16def is_pseudo_transparent_color(color):
17 color = QColor(color)
18 return (
19 color.red() == 255
20 and color.green() == 255
21 and color.blue() == 255
22 )
25def paint_rgb_palette(colors):
26 """QColor/RGB列を重複のないuint8パレットへ変換する。"""
27 normalized = []
28 seen = set()
29 for value in colors or ():
30 try:
31 if isinstance(value, QColor): 31 ↛ 38line 31 didn't jump to line 38 because the condition on line 31 was always true
32 rgb = (
33 int(value.red()),
34 int(value.green()),
35 int(value.blue()),
36 )
37 else:
38 rgb = tuple(
39 max(0, min(255, int(channel)))
40 for channel in value[:3]
41 )
42 except (
43 TypeError,
44 ValueError,
45 IndexError,
46 ):
47 continue
48 if len(rgb) != 3 or rgb in seen:
49 continue
50 seen.add(rgb)
51 normalized.append(rgb)
53 if not normalized:
54 return np.zeros((0, 3), dtype=np.uint8)
55 return np.asarray(
56 normalized,
57 dtype=np.uint8,
58 )
61def local_color_variation(rgb):
62 """上下左右との差が大きい境界画素を抽出する。"""
63 source = rgb.astype(np.int16)
64 variation = np.zeros(source.shape[:2], dtype=np.int16)
66 if source.shape[0] > 1: 66 ↛ 78line 66 didn't jump to line 78 because the condition on line 66 was always true
67 vertical = np.max(
68 np.abs(source[1:, :, :] - source[:-1, :, :]),
69 axis=2,
70 )
71 variation[1:, :] = np.maximum(
72 variation[1:, :], vertical
73 )
74 variation[:-1, :] = np.maximum(
75 variation[:-1, :], vertical
76 )
78 if source.shape[1] > 1: 78 ↛ 90line 78 didn't jump to line 90 because the condition on line 78 was always true
79 horizontal = np.max(
80 np.abs(source[:, 1:, :] - source[:, :-1, :]),
81 axis=2,
82 )
83 variation[:, 1:] = np.maximum(
84 variation[:, 1:], horizontal
85 )
86 variation[:, :-1] = np.maximum(
87 variation[:, :-1], horizontal
88 )
90 return variation
93def median_cut_palette_from_samples(
94 sample,
95 color_count,
96):
97 """頻度付きMedian-Cutで代表色を作る。"""
98 color_count = max(1, int(color_count))
99 sample = np.asarray(
100 sample,
101 dtype=np.uint8,
102 ).reshape((-1, 3))
103 if sample.size == 0: 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 return np.zeros((0, 3), dtype=np.uint8)
106 colors, counts = np.unique(
107 sample,
108 axis=0,
109 return_counts=True,
110 )
111 if len(colors) <= color_count:
112 return colors.astype(np.uint8, copy=True)
114 colors_i32 = colors.astype(np.int32)
115 counts_i64 = counts.astype(np.int64)
116 boxes = [np.arange(len(colors_i32), dtype=np.int32)]
118 while len(boxes) < color_count:
119 best_box_index = -1
120 best_score = -1
121 best_axis = 0
123 for box_index, indices in enumerate(boxes):
124 if len(indices) <= 1: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true
125 continue
126 box_colors = colors_i32[indices]
127 ranges = (
128 box_colors.max(axis=0)
129 - box_colors.min(axis=0)
130 )
131 axis = int(np.argmax(ranges))
132 population = int(counts_i64[indices].sum())
133 score = int(ranges[axis]) * max(1, population)
134 if score > best_score:
135 best_score = score
136 best_box_index = box_index
137 best_axis = axis
139 if best_box_index < 0: 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true
140 break
142 indices = boxes.pop(best_box_index)
143 order = np.argsort(
144 colors_i32[indices, best_axis],
145 kind="stable",
146 )
147 ordered = indices[order]
148 ordered_counts = counts_i64[ordered]
149 cumulative = np.cumsum(ordered_counts)
150 half = cumulative[-1] / 2.0
151 split = int(np.searchsorted(cumulative, half)) + 1
152 split = max(1, min(len(ordered) - 1, split))
153 boxes.append(ordered[:split])
154 boxes.append(ordered[split:])
156 palette = []
157 for indices in boxes:
158 weights = counts_i64[indices].astype(np.float64)
159 total = max(1.0, float(weights.sum()))
160 averaged = (
161 colors_i32[indices].astype(np.float64)
162 * weights[:, None]
163 ).sum(axis=0) / total
164 palette.append(
165 np.clip(
166 np.rint(averaged),
167 0,
168 255,
169 ).astype(np.uint8)
170 )
172 return np.asarray(palette, dtype=np.uint8)
175def normalize_tone_curve_points(points):
176 normalized = []
177 try:
178 iterable = list(points)
179 except TypeError:
180 iterable = []
182 for point in iterable:
183 try:
184 x, y = point[:2]
185 normalized.append((
186 max(0.0, min(1.0, float(x))),
187 max(0.0, min(1.0, float(y))),
188 ))
189 except (TypeError, ValueError, IndexError):
190 continue
192 normalized.extend([(0.0, 0.0), (1.0, 1.0)])
193 normalized.sort(key=lambda value: value[0])
194 merged = []
195 for x, y in normalized:
196 if merged and abs(x - merged[-1][0]) < 1e-5:
197 merged[-1] = (x, y)
198 else:
199 merged.append((x, y))
200 merged[0] = (0.0, 0.0)
201 merged[-1] = (1.0, 1.0)
202 return merged
205def rgb_hsv_features(rgb):
206 """近い色相を近いベクトルへ写像する。"""
207 values = np.asarray(
208 rgb,
209 dtype=np.float32,
210 ).reshape((-1, 3)) / 255.0
211 maximum = values.max(axis=1)
212 minimum = values.min(axis=1)
213 delta = maximum - minimum
215 saturation = np.divide(
216 delta,
217 np.maximum(maximum, 1e-6),
218 )
219 hue = np.zeros_like(maximum)
220 chromatic = delta > 1e-6
222 red_mask = chromatic & (
223 values[:, 0] >= values[:, 1]
224 ) & (
225 values[:, 0] >= values[:, 2]
226 )
227 green_mask = chromatic & ~red_mask & (
228 values[:, 1] >= values[:, 2]
229 )
230 blue_mask = chromatic & ~red_mask & ~green_mask
232 hue[red_mask] = (
233 (values[red_mask, 1] - values[red_mask, 2])
234 / delta[red_mask]
235 ) % 6.0
236 hue[green_mask] = (
237 (values[green_mask, 2] - values[green_mask, 0])
238 / delta[green_mask]
239 ) + 2.0
240 hue[blue_mask] = (
241 (values[blue_mask, 0] - values[blue_mask, 1])
242 / delta[blue_mask]
243 ) + 4.0
244 hue = (hue / 6.0) % 1.0
246 angle = hue * (2.0 * math.pi)
247 hue_strength = np.power(saturation, 0.72)
248 return np.column_stack([
249 np.cos(angle) * hue_strength * 2.35,
250 np.sin(angle) * hue_strength * 2.35,
251 saturation * 0.45,
252 maximum * 0.52,
253 ]).astype(np.float32)
256def priority_palette_colors_from_samples(
257 sample,
258 maximum,
259):
260 """少面積でも残したい黒・主要色相を実在色から選ぶ。"""
261 sample = np.asarray(
262 sample,
263 dtype=np.uint8,
264 ).reshape((-1, 3))
265 maximum = max(0, int(maximum))
266 if sample.size == 0 or maximum <= 0:
267 return np.zeros((0, 3), dtype=np.uint8)
269 colors, counts = np.unique(
270 sample,
271 axis=0,
272 return_counts=True,
273 )
274 values = colors.astype(np.float32) / 255.0
275 value = values.max(axis=1)
276 minimum = values.min(axis=1)
277 delta = value - minimum
278 saturation = np.divide(
279 delta,
280 np.maximum(value, 1e-6),
281 )
283 hue = np.zeros_like(value)
284 chromatic = delta > 1e-6
285 red_mask = chromatic & (
286 values[:, 0] >= values[:, 1]
287 ) & (
288 values[:, 0] >= values[:, 2]
289 )
290 green_mask = chromatic & ~red_mask & (
291 values[:, 1] >= values[:, 2]
292 )
293 blue_mask = chromatic & ~red_mask & ~green_mask
294 hue[red_mask] = (
295 (values[red_mask, 1] - values[red_mask, 2])
296 / delta[red_mask]
297 ) % 6.0
298 hue[green_mask] = (
299 (values[green_mask, 2] - values[green_mask, 0])
300 / delta[green_mask]
301 ) + 2.0
302 hue[blue_mask] = (
303 (values[blue_mask, 0] - values[blue_mask, 1])
304 / delta[blue_mask]
305 ) + 4.0
306 hue = (hue / 6.0) % 1.0
308 minimum_support = max(
309 2,
310 int(math.ceil(len(sample) * 0.00005)),
311 )
312 chromatic_minimum_support = max(
313 8,
314 int(math.ceil(len(sample) * 0.001)),
315 )
316 chromatic_mask = (
317 (saturation >= 0.38)
318 & (value >= 0.18)
319 )
320 color_groups = [
321 (np.asarray([0, 0, 0]), value <= 0.24),
322 (np.asarray([255, 0, 0]), None),
323 (np.asarray([0, 0, 255]), None),
324 (np.asarray([0, 255, 0]), None),
325 (np.asarray([255, 0, 255]), None),
326 (np.asarray([255, 255, 0]), None),
327 ]
328 target_hues = (None, 0.0, 2.0 / 3.0, 1.0 / 3.0, 5.0 / 6.0, 1.0 / 6.0)
329 candidates_by_group = []
331 for group_index, (target_rgb, fixed_mask) in enumerate(color_groups):
332 if fixed_mask is None:
333 target_hue = target_hues[group_index]
334 assert target_hue is not None
335 hue_distance = np.abs(hue - target_hue)
336 hue_distance = np.minimum(
337 hue_distance,
338 1.0 - hue_distance,
339 )
340 hue_tolerance = (
341 1.0 / 8.0
342 if group_index == 3
343 else 1.0 / 12.0
344 )
345 mask = chromatic_mask & (hue_distance <= hue_tolerance)
346 else:
347 mask = fixed_mask
349 group_support = int(counts[mask].sum())
350 required_support = (
351 minimum_support
352 if group_index == 0
353 else chromatic_minimum_support
354 )
355 if group_support < required_support:
356 continue
357 candidates = colors[mask].astype(np.int32)
358 candidate_counts = counts[mask].astype(np.float64)
359 difference = candidates - target_rgb[None, :]
360 distance = np.sum(difference * difference, axis=1)
361 if group_index == 0:
362 # 黒線は面積が小さくても、暗い有彩色ではなく実在する
363 # 最暗色を選ぶ。頻度ボーナスで青へ寄るのを防ぐ。
364 luminance = (
365 candidates[:, 0] * 0.2126
366 + candidates[:, 1] * 0.7152
367 + candidates[:, 2] * 0.0722
368 )
369 chroma = candidates.max(axis=1) - candidates.min(axis=1)
370 best = int(np.argmin(luminance + chroma * 0.20))
371 else:
372 frequency_bonus = 1.0 + 0.30 * np.log1p(candidate_counts)
373 best = int(np.argmin(distance / frequency_bonus))
374 # 固定された色相順で枠を使い切らず、画像内での支持画素数が
375 # 多い主要色を優先する。低色数でも実在する緑が脱落しにくい。
376 candidates_by_group.append((
377 group_support,
378 group_index,
379 candidates[best].astype(np.uint8),
380 ))
382 candidates_by_group.sort(
383 key=lambda item: (-item[0], item[1])
384 )
385 selected = [
386 color for _support, _group, color
387 in candidates_by_group[:maximum]
388 ]
390 return np.asarray(selected, dtype=np.uint8).reshape((-1, 3))