Coverage for paintmaskanimator/color_ops.py: 72%
426 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"""Color-reduction, tone-curve and alpha-analysis algorithms.
3A self-contained algorithmic subsystem extracted from PaintCanvas: it depends
4only on the pure helpers in colors.py / imaging.py and on each other, never on
5widget state. PaintCanvas keeps delegating class/static-method wrappers so its
6callers (and color_reduction.py) are unchanged.
7"""
8import math
9import numpy as np
10from PySide6.QtGui import QImage
11from . import imaging
12from . import native
13from . import colors as _colors # noqa: F401
16def _snap_overlay_to_exact_rgbs(
17 overlay_rgba,
18 active_mask,
19 exact_colors,
20):
21 """境界の丸め誤差を、指定された正規RGBへ吸着する。"""
22 rgba = np.asarray(
23 overlay_rgba,
24 dtype=np.uint8,
25 ).copy()
26 mask = np.asarray(
27 active_mask,
28 dtype=bool,
29 )
30 palette = _colors.paint_rgb_palette(
31 exact_colors
32 )
33 if ( 33 ↛ 37line 33 didn't jump to line 37 because the condition on line 33 was never true
34 palette.size == 0
35 or not np.any(mask)
36 ):
37 return rgba
39 if len(palette) == 1: 39 ↛ 45line 39 didn't jump to line 45 because the condition on line 39 was always true
40 rgba[mask, :3] = palette[0]
41 return rgba
43 # 図形など複数色を使う場合は、丸め誤差を含む画素を
44 # 最も近い正規RGBへ割り当てる。
45 samples = rgba[mask, :3].astype(np.int16)
46 palette_values = palette.astype(np.int16)
47 delta = (
48 samples[:, None, :]
49 - palette_values[None, :, :]
50 )
51 distance = np.sum(
52 delta * delta,
53 axis=2,
54 dtype=np.int32,
55 )
56 nearest = np.argmin(
57 distance,
58 axis=1,
59 )
60 rgba[mask, :3] = palette[nearest]
61 return rgba
64def image_alpha_statistics(image):
65 """透明・半透明・不透明画素数を返す。"""
66 if image is None or image.isNull():
67 return {
68 "transparent": 0,
69 "semi_transparent": 0,
70 "opaque": 0,
71 }
72 rgba = imaging.qimage_rgba_array(image)
73 if rgba.size == 0: 73 ↛ 74line 73 didn't jump to line 74 because the condition on line 73 was never true
74 return {
75 "transparent": 0,
76 "semi_transparent": 0,
77 "opaque": 0,
78 }
79 alpha = rgba[:, :, 3]
80 return {
81 "transparent": int(np.count_nonzero(alpha == 0)),
82 "semi_transparent": int(
83 np.count_nonzero((alpha > 0) & (alpha < 255))
84 ),
85 "opaque": int(np.count_nonzero(alpha == 255)),
86 }
89def binarize_alpha_for_pixel_art(
90 image,
91 alpha_threshold=128,
92):
93 """半透明を完全透明/完全不透明へ二値化する。"""
94 if image is None or image.isNull():
95 return QImage()
97 rgba = imaging.qimage_rgba_array(image)
98 if rgba.size == 0: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true
99 return image.copy()
101 threshold = max(
102 1,
103 min(254, int(alpha_threshold)),
104 )
105 alpha = rgba[:, :, 3]
106 keep = alpha >= threshold
108 result = np.zeros_like(rgba)
109 result_rgb = result[:, :, :3]
110 source_rgb = rgba[:, :, :3]
111 result_rgb[keep] = source_rgb[keep]
112 result[:, :, 3][keep] = 255
113 return imaging.rgba_array_to_qimage(result)
116def opaque_rgb_color_count(image):
117 """透明画素を除外したRGB色数を返す。"""
118 if image is None or image.isNull(): 118 ↛ 119line 118 didn't jump to line 119 because the condition on line 118 was never true
119 return 0
120 rgba = imaging.qimage_rgba_array(image)
121 if rgba.size == 0: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true
122 return 0
123 visible = rgba[:, :, 3] > 0
124 if not np.any(visible):
125 return 0
126 rgb = rgba[:, :, :3][visible].astype(np.uint32)
127 packed = (
128 (rgb[:, 0] << 16)
129 | (rgb[:, 1] << 8)
130 | rgb[:, 2]
131 )
132 return int(np.unique(packed).size)
135def detect_opaque_border_background(image):
136 """外周につながる不透明な明色背景を検出する。"""
137 if image is None or image.isNull(): 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true
138 return None
140 rgba = imaging.qimage_rgba_array(image)
141 if rgba.size == 0: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 return None
144 alpha = rgba[:, :, 3]
145 # 半透明・透明を含む画像は従来のアルファ二値化を使う。
146 if np.any(alpha < 250):
147 return None
149 rgb = rgba[:, :, :3]
150 height, width = rgb.shape[:2]
151 if width <= 0 or height <= 0: 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true
152 return None
154 border_parts = [
155 rgb[0, :, :],
156 rgb[height - 1, :, :],
157 ]
158 if height > 2: 158 ↛ 163line 158 didn't jump to line 163 because the condition on line 158 was always true
159 border_parts.extend([
160 rgb[1:height - 1, 0, :],
161 rgb[1:height - 1, width - 1, :],
162 ])
163 border = np.concatenate(border_parts, axis=0)
164 if border.size == 0: 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
165 return None
167 colors, counts = np.unique(
168 border.astype(np.uint8),
169 axis=0,
170 return_counts=True,
171 )
172 dominant = colors[int(np.argmax(counts))].astype(np.int32)
174 # 白~明るい無彩色背景だけを自動背景として扱う。
175 if int(dominant.min()) < 218:
176 return None
177 if int(dominant.max() - dominant.min()) > 24: 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true
178 return None
180 delta = border.astype(np.int32) - dominant[None, :]
181 near = np.sum(delta * delta, axis=1) <= (26 * 26 * 3)
182 if float(np.count_nonzero(near)) / max(1, len(border)) < 0.52: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true
183 return None
185 return tuple(int(value) for value in dominant)
188def estimate_mixed_boundary_pixels(
189 image,
190 background_rgb=None,
191):
192 """白背景上のアンチエイリアス境界のおおよその画素数。"""
193 if image is None or image.isNull():
194 return 0
196 rgba = imaging.qimage_rgba_array(image)
197 if rgba.size == 0:
198 return 0
200 rgb = rgba[:, :, :3]
201 alpha = rgba[:, :, 3]
202 variation = _colors.local_color_variation(rgb)
203 candidate = (alpha >= 250) & (variation >= 10)
205 if background_rgb is not None:
206 background = np.asarray(
207 background_rgb,
208 dtype=np.int16,
209 )
210 delta = rgb.astype(np.int16) - background
211 distance = np.sqrt(
212 np.sum(
213 delta.astype(np.int32)
214 * delta.astype(np.int32),
215 axis=2,
216 )
217 )
218 # 背景そのものと十分離れた内部色は除き、
219 # 背景との混合が疑われる帯域を数える。
220 candidate &= (distance > 5.0) & (distance < 190.0)
222 return int(np.count_nonzero(candidate))
225def _surface_guided_palette_labels(
226 rgb,
227 visible,
228 palette,
229 spatial_refinement=True,
230):
231 """色面内部を種にし、混合境界を隣接する2色へ分割する。
233 画素ごとの繰り返しなので計算はRustで行う。
234 :func:`_surface_guided_palette_labels_numpy` は突き合わせ用の参照実装。
235 """
236 return native.surface_guided_palette_labels(
237 rgb, visible, palette, spatial_refinement
238 )
241def _surface_guided_palette_labels_numpy(
242 rgb,
243 visible,
244 palette,
245 spatial_refinement=True,
246):
247 """:func:`_surface_guided_palette_labels` のnumpy版(参照実装)。"""
248 height, width = visible.shape
249 palette_array = np.asarray(
250 palette,
251 dtype=np.uint8,
252 ).reshape((-1, 3))
253 palette_features = _colors.rgb_hsv_features(
254 palette_array
255 )
257 flat_rgb = rgb.reshape((-1, 3))
258 flat_visible = visible.reshape(-1)
259 nearest_labels = np.full(
260 flat_visible.shape,
261 -1,
262 dtype=np.int16,
263 )
264 nearest_distance = np.full(
265 flat_visible.shape,
266 np.inf,
267 dtype=np.float32,
268 )
270 visible_indices = np.flatnonzero(flat_visible)
271 chunk_size = 8192
272 for chunk_start in range(
273 0, len(visible_indices), chunk_size
274 ):
275 indices = visible_indices[
276 chunk_start:chunk_start + chunk_size
277 ]
278 pixel_features = _colors.rgb_hsv_features(
279 flat_rgb[indices]
280 )
281 distance = np.sum(
282 (
283 pixel_features[:, None, :]
284 - palette_features[None, :, :]
285 ) ** 2,
286 axis=2,
287 )
288 labels = np.argmin(distance, axis=1)
289 nearest_labels[indices] = labels.astype(np.int16)
290 nearest_distance[indices] = distance[
291 np.arange(len(indices)),
292 labels,
293 ]
295 nearest_labels = nearest_labels.reshape(
296 (height, width)
297 )
298 nearest_distance = nearest_distance.reshape(
299 (height, width)
300 )
301 if not spatial_refinement:
302 return nearest_labels
304 variation = _colors.local_color_variation(rgb)
305 # 平坦な色面を確定し、境界の混合色は未確定にする。
306 core = (
307 visible
308 & (variation <= 12)
309 & (nearest_distance <= 0.12)
310 )
311 labels = np.full(
312 (height, width),
313 -1,
314 dtype=np.int16,
315 )
316 labels[core] = nearest_labels[core]
318 exactish = (
319 visible
320 & (labels < 0)
321 & (nearest_distance <= 0.015)
322 )
323 labels[exactish] = nearest_labels[exactish]
325 pixel_features_all = _colors.rgb_hsv_features(
326 rgb.reshape((-1, 3))
327 ).reshape((height, width, -1))
329 # 隣接する色面候補だけを比較するため、
330 # 2色の混合帯は中間位置で明確に二分される。
331 for _iteration in range(12):
332 unresolved = visible & (labels < 0)
333 if not np.any(unresolved):
334 break
336 candidates = []
337 up = np.full_like(labels, -1)
338 up[1:, :] = labels[:-1, :]
339 candidates.append(up)
340 down = np.full_like(labels, -1)
341 down[:-1, :] = labels[1:, :]
342 candidates.append(down)
343 left = np.full_like(labels, -1)
344 left[:, 1:] = labels[:, :-1]
345 candidates.append(left)
346 right = np.full_like(labels, -1)
347 right[:, :-1] = labels[:, 1:]
348 candidates.append(right)
350 best_label = np.full_like(labels, -1)
351 best_distance = np.full(
352 (height, width),
353 np.inf,
354 dtype=np.float32,
355 )
357 for candidate in candidates:
358 valid = unresolved & (candidate >= 0)
359 if not np.any(valid):
360 continue
361 candidate_features = palette_features[
362 np.maximum(candidate, 0)
363 ]
364 distance = np.sum(
365 (
366 pixel_features_all
367 - candidate_features
368 ) ** 2,
369 axis=2,
370 )
371 better = valid & (distance < best_distance)
372 best_distance[better] = distance[better]
373 best_label[better] = candidate[better]
375 assign = unresolved & (best_label >= 0)
376 if not np.any(assign): 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true
377 break
378 labels[assign] = best_label[assign]
380 unresolved = visible & (labels < 0)
381 labels[unresolved] = nearest_labels[unresolved]
382 return labels
385def _line_palette_labels(rgb, visible, palette):
386 """ライン抽出用。色相よりRGB差と明度差を優先して実線を保つ。"""
387 palette_array = np.asarray(palette, dtype=np.uint8).reshape((-1, 3))
388 source = rgb.reshape((-1, 3)).astype(np.float32) / 255.0
389 targets = palette_array.astype(np.float32) / 255.0
390 source_luma = (
391 source[:, 0] * 0.2126
392 + source[:, 1] * 0.7152
393 + source[:, 2] * 0.0722
394 )
395 target_luma = (
396 targets[:, 0] * 0.2126
397 + targets[:, 1] * 0.7152
398 + targets[:, 2] * 0.0722
399 )
400 source_value = source.max(axis=1)
401 source_saturation = (
402 (source_value - source.min(axis=1))
403 / np.maximum(source_value, 1e-6)
404 )
405 target_value = targets.max(axis=1)
406 target_saturation = (
407 (target_value - targets.min(axis=1))
408 / np.maximum(target_value, 1e-6)
409 )
410 flat_visible = visible.reshape(-1)
411 labels = np.full(flat_visible.shape, -1, dtype=np.int16)
412 indices = np.flatnonzero(flat_visible)
413 for start in range(0, len(indices), 8192):
414 chunk = indices[start:start + 8192]
415 rgb_distance = np.sum(
416 (source[chunk, None, :] - targets[None, :, :]) ** 2,
417 axis=2,
418 )
419 luma_distance = (
420 source_luma[chunk, None] - target_luma[None, :]
421 ) ** 2
422 saturation_excess = np.maximum(
423 target_saturation[None, :]
424 - source_saturation[chunk, None]
425 - 0.12,
426 0.0,
427 )
428 distance = (
429 rgb_distance
430 + luma_distance * 2.4
431 + saturation_excess * saturation_excess * 4.0
432 )
433 labels[chunk] = np.argmin(distance, axis=1).astype(np.int16)
435 # 暗い実線はアンチエイリアス周辺の色相へ吸収させない。
436 darkest_index = int(np.argmin(target_luma))
437 darkest_luma = float(target_luma[darkest_index])
438 if darkest_luma <= 0.32:
439 source_luma_2d = source_luma.reshape(visible.shape)
440 variation = _colors.local_color_variation(rgb)
441 strong_dark_line = visible & (
442 (source_luma_2d <= max(0.20, darkest_luma + 0.12))
443 & ((variation >= 28) | (source_luma_2d <= 0.10))
444 )
445 labels.reshape(visible.shape)[strong_dark_line] = darkest_index
446 return labels.reshape(visible.shape)
449def tone_curve_samples(
450 tone_curve_points,
451 input_values,
452):
453 """制御点を通る形状保持型3次曲線を評価する。"""
454 points = _colors.normalize_tone_curve_points(
455 tone_curve_points
456 if tone_curve_points is not None
457 else [(0.0, 0.0), (1.0, 1.0)]
458 )
459 xs = np.asarray(
460 [point[0] for point in points],
461 dtype=np.float64,
462 )
463 ys = np.asarray(
464 [point[1] for point in points],
465 dtype=np.float64,
466 )
467 values = np.asarray(
468 input_values,
469 dtype=np.float64,
470 )
472 if len(xs) <= 2:
473 return np.clip(
474 np.interp(values, xs, ys),
475 0.0,
476 1.0,
477 )
479 intervals = np.diff(xs)
480 slopes = np.diff(ys) / np.maximum(
481 intervals,
482 1e-12,
483 )
484 tangents = np.zeros_like(xs)
486 # 内部点はFritsch-Carlsonの重み付き調和平均。
487 for index in range(1, len(xs) - 1):
488 left_slope = slopes[index - 1]
489 right_slope = slopes[index]
490 if ( 490 ↛ 495line 490 didn't jump to line 495 because the condition on line 490 was never true
491 left_slope == 0.0
492 or right_slope == 0.0
493 or left_slope * right_slope <= 0.0
494 ):
495 tangents[index] = 0.0
496 else:
497 left_weight = (
498 2.0 * intervals[index]
499 + intervals[index - 1]
500 )
501 right_weight = (
502 intervals[index]
503 + 2.0 * intervals[index - 1]
504 )
505 tangents[index] = (
506 left_weight + right_weight
507 ) / (
508 left_weight / left_slope
509 + right_weight / right_slope
510 )
512 def endpoint_tangent(
513 first_interval,
514 second_interval,
515 first_slope,
516 second_slope,
517 ):
518 tangent = (
519 (2.0 * first_interval + second_interval)
520 * first_slope
521 - first_interval * second_slope
522 ) / max(
523 first_interval + second_interval,
524 1e-12,
525 )
526 if tangent * first_slope <= 0.0:
527 return 0.0
528 if ( 528 ↛ 532line 528 didn't jump to line 532 because the condition on line 528 was never true
529 first_slope * second_slope < 0.0
530 and abs(tangent) > abs(3.0 * first_slope)
531 ):
532 return 3.0 * first_slope
533 return tangent
535 tangents[0] = endpoint_tangent(
536 intervals[0],
537 intervals[1],
538 slopes[0],
539 slopes[1],
540 )
541 tangents[-1] = endpoint_tangent(
542 intervals[-1],
543 intervals[-2],
544 slopes[-1],
545 slopes[-2],
546 )
548 segment_indices = np.searchsorted(
549 xs,
550 values,
551 side="right",
552 ) - 1
553 segment_indices = np.clip(
554 segment_indices,
555 0,
556 len(xs) - 2,
557 )
558 x0 = xs[segment_indices]
559 x1 = xs[segment_indices + 1]
560 interval = np.maximum(x1 - x0, 1e-12)
561 t = np.clip(
562 (values - x0) / interval,
563 0.0,
564 1.0,
565 )
566 t2 = t * t
567 t3 = t2 * t
569 y0 = ys[segment_indices]
570 y1 = ys[segment_indices + 1]
571 m0 = tangents[segment_indices]
572 m1 = tangents[segment_indices + 1]
574 outputs = (
575 (2.0 * t3 - 3.0 * t2 + 1.0) * y0
576 + (t3 - 2.0 * t2 + t) * interval * m0
577 + (-2.0 * t3 + 3.0 * t2) * y1
578 + (t3 - t2) * interval * m1
579 )
580 return np.clip(outputs, 0.0, 1.0)
583def tone_curve_lut(tone_curve_points=None):
584 inputs = (
585 np.arange(256, dtype=np.float64) / 255.0
586 )
587 outputs = tone_curve_samples(
588 tone_curve_points,
589 inputs,
590 )
591 return np.clip(
592 np.rint(outputs * 255.0),
593 0,
594 255,
595 ).astype(np.uint8)
598def apply_tone_curve(
599 image,
600 tone_curve_points=None,
601 background_rgb=None,
602):
603 """256段階LUTだけで高速に複数点トーンカーブを適用する。"""
604 if image is None or image.isNull(): 604 ↛ 605line 604 didn't jump to line 605 because the condition on line 604 was never true
605 return image
607 points = _colors.normalize_tone_curve_points(
608 tone_curve_points
609 if tone_curve_points is not None
610 else [(0.0, 0.0), (1.0, 1.0)]
611 )
612 if points == [(0.0, 0.0), (1.0, 1.0)]:
613 return image.copy()
615 rgba = imaging.qimage_rgba_array(image)
616 if rgba.size == 0: 616 ↛ 617line 616 didn't jump to line 617 because the condition on line 616 was never true
617 return image.copy()
619 lut = tone_curve_lut(points)
620 result = rgba.copy()
621 result[:, :, :3] = lut[result[:, :, :3]]
623 # 白背景の完全な白は端点固定でも変化しないが、
624 # 自動検出した背景色も念のためそのまま保持する。
625 if background_rgb is not None: 625 ↛ 626line 625 didn't jump to line 626 because the condition on line 625 was never true
626 background = np.asarray(
627 background_rgb,
628 dtype=np.int16,
629 )
630 difference = (
631 rgba[:, :, :3].astype(np.int16)
632 - background
633 )
634 near_background = np.sum(
635 difference.astype(np.int32)
636 * difference.astype(np.int32),
637 axis=2,
638 ) <= (5 * 5 * 3)
639 result[:, :, :3][near_background] = (
640 np.asarray(background_rgb, dtype=np.uint8)
641 )
643 return imaging.rgba_array_to_qimage(result)
646def _hue_cluster_palette_from_samples(
647 sample,
648 color_count,
649):
650 """色相を主軸に、近い色を同じ原色面へまとめる。"""
651 sample = np.asarray(
652 sample,
653 dtype=np.uint8,
654 ).reshape((-1, 3))
655 color_count = max(1, int(color_count))
656 if sample.size == 0: 656 ↛ 657line 656 didn't jump to line 657 because the condition on line 656 was never true
657 return np.zeros((0, 3), dtype=np.uint8)
659 colors, counts = np.unique(
660 sample,
661 axis=0,
662 return_counts=True,
663 )
664 if len(colors) <= color_count:
665 return colors.astype(np.uint8, copy=True)
667 features = _colors.rgb_hsv_features(colors)
668 weights = counts.astype(np.float64)
670 first_index = int(np.argmax(weights))
671 centers = [features[first_index].copy()]
672 minimum_distance = np.sum(
673 (features - centers[0]) ** 2,
674 axis=1,
675 )
677 while len(centers) < color_count:
678 score = minimum_distance * (
679 1.0 + np.log1p(weights)
680 )
681 next_index = int(np.argmax(score))
682 centers.append(features[next_index].copy())
683 distance = np.sum(
684 (features - centers[-1]) ** 2,
685 axis=1,
686 )
687 minimum_distance = np.minimum(
688 minimum_distance,
689 distance,
690 )
692 centers = np.asarray(centers, dtype=np.float32)
693 labels = np.zeros(len(colors), dtype=np.int32)
695 for _iteration in range(12):
696 distance = np.sum(
697 (
698 features[:, None, :]
699 - centers[None, :, :]
700 ) ** 2,
701 axis=2,
702 )
703 new_labels = np.argmin(distance, axis=1)
704 if np.array_equal(new_labels, labels) and _iteration > 0: 704 ↛ 705line 704 didn't jump to line 705 because the condition on line 704 was never true
705 break
706 labels = new_labels
708 for cluster_index in range(len(centers)):
709 members = labels == cluster_index
710 if not np.any(members): 710 ↛ 711line 710 didn't jump to line 711 because the condition on line 710 was never true
711 farthest = int(
712 np.argmax(np.min(distance, axis=1))
713 )
714 centers[cluster_index] = features[farthest]
715 continue
716 cluster_weights = weights[members]
717 centers[cluster_index] = np.average(
718 features[members],
719 axis=0,
720 weights=cluster_weights,
721 )
723 palette = []
724 for cluster_index in range(len(centers)):
725 members = labels == cluster_index
726 if not np.any(members): 726 ↛ 727line 726 didn't jump to line 727 because the condition on line 726 was never true
727 continue
728 member_indices = np.flatnonzero(members)
729 cluster_weights = weights[members]
730 rgb_center = np.average(
731 colors[members].astype(np.float64),
732 axis=0,
733 weights=cluster_weights,
734 )
735 feature_distance = np.sum(
736 (
737 features[members]
738 - centers[cluster_index][None, :]
739 ) ** 2,
740 axis=1,
741 )
742 rgb_distance = np.sum(
743 (
744 colors[members].astype(np.float64)
745 - rgb_center[None, :]
746 ) ** 2,
747 axis=1,
748 ) / (255.0 * 255.0 * 3.0)
749 # RGB平均色を新しく作ると、異なる色相の中間色へ変化してしまう。
750 # 色相クラスタ中心に近く、かつ画像内で支持の多い実在色を代表にする。
751 frequency_bonus = 1.0 + 0.12 * np.log1p(cluster_weights)
752 score = (
753 feature_distance + rgb_distance * 0.08
754 ) / frequency_bonus
755 representative_index = member_indices[int(np.argmin(score))]
756 palette.append(
757 colors[representative_index].astype(np.uint8, copy=True)
758 )
760 return np.asarray(palette, dtype=np.uint8)
763def _palette_training_samples(
764 image,
765 alpha_threshold=128,
766 opaque_background=False,
767 background_rgb=None,
768):
769 """1枚から、パレットの手本にする画素 ``(N, 3)`` を取り出す。
771 背景そのものと、境界のにじみ(局所の色の揺れが大きい画素)を外す。
772 返すのは色の並びだけなので、複数枚ぶんをつなげて1つのパレットを作れる。
773 """
774 threshold = max(1, min(254, int(alpha_threshold)))
775 rgba = imaging.qimage_rgba_array(image)
776 if rgba.size == 0:
777 return np.zeros((0, 3), dtype=np.uint8)
779 rgb = rgba[:, :, :3]
780 alpha = rgba[:, :, 3]
781 visible = (
782 np.ones(alpha.shape, dtype=bool)
783 if opaque_background
784 else alpha >= threshold
785 )
786 if not np.any(visible): 786 ↛ 787line 786 didn't jump to line 787 because the condition on line 786 was never true
787 return np.zeros((0, 3), dtype=np.uint8)
789 variation = _colors.local_color_variation(rgb)
790 stable = visible & (variation <= 12)
792 reserved_background = None
793 foreground_mask = stable.copy()
794 if opaque_background and background_rgb is not None: 794 ↛ 795line 794 didn't jump to line 795 because the condition on line 794 was never true
795 reserved_background = np.asarray(
796 background_rgb,
797 dtype=np.uint8,
798 )
799 difference = (
800 rgb.astype(np.int16)
801 - reserved_background.astype(np.int16)
802 )
803 background_distance = np.sum(
804 difference.astype(np.int32)
805 * difference.astype(np.int32),
806 axis=2,
807 )
808 foreground_mask &= (
809 background_distance > (18 * 18 * 3)
810 )
812 if np.count_nonzero(foreground_mask) < 32: 812 ↛ 813line 812 didn't jump to line 813 because the condition on line 812 was never true
813 foreground_mask = visible.copy()
814 if reserved_background is not None:
815 difference = (
816 rgb.astype(np.int16)
817 - reserved_background.astype(np.int16)
818 )
819 background_distance = np.sum(
820 difference.astype(np.int32)
821 * difference.astype(np.int32),
822 axis=2,
823 )
824 foreground_mask &= (
825 background_distance > (26 * 26 * 3)
826 )
828 training_rgb = rgb[foreground_mask]
829 if training_rgb.size == 0: 829 ↛ 830line 829 didn't jump to line 830 because the condition on line 829 was never true
830 training_rgb = rgb[visible]
831 return training_rgb
834def build_color_reduction_palette(
835 image,
836 color_count,
837 alpha_threshold=128,
838 max_samples=262144,
839 opaque_background=False,
840 background_rgb=None,
841 tone_curve_points=None,
842 extraction_mode="surface",
843 extra_images=(),
844):
845 """近い色相を同じ原色面としてまとめる共通パレット。
847 ``extra_images`` に代表フレームを渡すと、それらの画素もまとめて手本にする。
848 1枚だけから作ると、中間で色が増えるカットやフラッシュで、その1枚に無い色が
849 最近傍へ潰れてしまうため(Issue #10)。
850 """
851 color_count = max(2, min(100, int(color_count)))
852 reserved_background = (
853 np.asarray(background_rgb, dtype=np.uint8)
854 if opaque_background and background_rgb is not None
855 else None
856 )
857 samples = [
858 _palette_training_samples(
859 source,
860 alpha_threshold=alpha_threshold,
861 opaque_background=opaque_background,
862 background_rgb=background_rgb,
863 )
864 for source in (image, *(extra_images or ()))
865 ]
866 samples = [block for block in samples if block.size]
867 if not samples:
868 return np.asarray([[0, 0, 0]], dtype=np.uint8)
869 training_rgb = (
870 samples[0] if len(samples) == 1 else np.vstack(samples)
871 )
873 if len(training_rgb) > int(max_samples): 873 ↛ 874line 873 didn't jump to line 874 because the condition on line 873 was never true
874 sample_indices = np.linspace(
875 0,
876 len(training_rgb) - 1,
877 int(max_samples),
878 dtype=np.int64,
879 )
880 training_rgb = training_rgb[sample_indices]
882 foreground_count = (
883 color_count - 1
884 if reserved_background is not None
885 else color_count
886 )
887 foreground_count = max(1, foreground_count)
888 extraction_mode = (
889 "line" if extraction_mode == "line" else "surface"
890 )
891 if extraction_mode == "line": 891 ↛ 892line 891 didn't jump to line 892 because the condition on line 891 was never true
892 priority_palette = _colors.priority_palette_colors_from_samples(
893 training_rgb,
894 foreground_count,
895 )
896 else:
897 priority_palette = _colors.priority_palette_colors_from_samples(
898 training_rgb,
899 foreground_count if foreground_count >= 6 else 0,
900 )
901 clustered_palette = _hue_cluster_palette_from_samples(
902 training_rgb,
903 foreground_count,
904 )
905 foreground_colors = []
906 for color in np.vstack([
907 priority_palette,
908 clustered_palette,
909 ]):
910 color_i32 = color.astype(np.int32)
911 if extraction_mode == "line": 911 ↛ 912line 911 didn't jump to line 912 because the condition on line 911 was never true
912 color_value = float(color_i32.max())
913 color_chroma = float(
914 color_i32.max() - color_i32.min()
915 )
916 color_saturation = (
917 color_chroma / max(1.0, color_value)
918 )
919 if color_saturation >= 0.25:
920 differences = (
921 training_rgb.astype(np.int16)
922 - color_i32.astype(np.int16)
923 )
924 nearby = np.sum(
925 differences.astype(np.int32)
926 * differences.astype(np.int32),
927 axis=1,
928 ) <= (55 * 55 * 3)
929 required = max(
930 8,
931 int(math.ceil(len(training_rgb) * 0.001)),
932 )
933 if int(np.count_nonzero(nearby)) < required:
934 continue
935 if any(
936 np.sum(
937 (color_i32 - existing.astype(np.int32)) ** 2
938 ) <= (10 * 10 * 3)
939 for existing in foreground_colors
940 ):
941 continue
942 foreground_colors.append(color)
943 if len(foreground_colors) >= foreground_count:
944 break
945 foreground_palette = np.asarray(
946 foreground_colors,
947 dtype=np.uint8,
948 ).reshape((-1, 3))
950 if reserved_background is None: 950 ↛ 953line 950 didn't jump to line 953 because the condition on line 950 was always true
951 palette = foreground_palette
952 else:
953 palette = np.vstack([
954 reserved_background.reshape((1, 3)),
955 foreground_palette,
956 ])
958 unique_palette = []
959 seen = set()
960 for color in palette:
961 key = tuple(int(value) for value in color)
962 if key in seen: 962 ↛ 963line 962 didn't jump to line 963 because the condition on line 962 was never true
963 continue
964 seen.add(key)
965 unique_palette.append(color)
967 return np.asarray(
968 unique_palette[:color_count],
969 dtype=np.uint8,
970 )
973def apply_color_reduction_palette(
974 image,
975 palette,
976 alpha_threshold=128,
977 opaque_background=False,
978 background_rgb=None,
979 tone_curve_points=None,
980 extraction_mode="surface",
981):
982 """色相面を確定し、境界混合色を二分してからトーンを適用する。"""
983 if image is None or image.isNull():
984 return image
986 rgba = imaging.qimage_rgba_array(image)
987 if rgba.size == 0: 987 ↛ 988line 987 didn't jump to line 988 because the condition on line 987 was never true
988 return image.copy()
990 palette_array = np.asarray(
991 palette,
992 dtype=np.uint8,
993 ).reshape((-1, 3))
994 if palette_array.size == 0: 994 ↛ 995line 994 didn't jump to line 995 because the condition on line 994 was never true
995 return image.copy()
997 threshold = max(1, min(254, int(alpha_threshold)))
998 rgb = rgba[:, :, :3]
999 visible = (
1000 np.ones(
1001 rgba.shape[:2],
1002 dtype=bool,
1003 )
1004 if opaque_background
1005 else rgba[:, :, 3] >= threshold
1006 )
1008 if extraction_mode == "line": 1008 ↛ 1009line 1008 didn't jump to line 1009 because the condition on line 1008 was never true
1009 labels = _line_palette_labels(
1010 rgb, visible, palette_array
1011 )
1012 else:
1013 labels = _surface_guided_palette_labels(
1014 rgb,
1015 visible,
1016 palette_array,
1017 spatial_refinement=True,
1018 )
1020 result = np.zeros_like(rgba)
1021 valid = visible & (labels >= 0)
1022 result[:, :, :3][valid] = palette_array[
1023 labels[valid]
1024 ]
1026 if opaque_background: 1026 ↛ 1027line 1026 didn't jump to line 1027 because the condition on line 1026 was never true
1027 if background_rgb is not None:
1028 background = np.asarray(
1029 background_rgb,
1030 dtype=np.uint8,
1031 )
1032 result[:, :, :3][~valid] = background
1033 result[:, :, 3] = 255
1034 else:
1035 result[:, :, 3][valid] = 255
1037 quantized = imaging.rgba_array_to_qimage(result)
1038 return apply_tone_curve(
1039 quantized,
1040 tone_curve_points=tone_curve_points,
1041 background_rgb=background_rgb,
1042 )
1046#: パレットの手本にする代表フレームの枚数(1枚目を除く)。
1047REFERENCE_FRAME_COUNT = 4
1050def reference_frame_indices(total, count=REFERENCE_FRAME_COUNT):
1051 """``total`` 枚の連番から、代表フレームの位置を選ぶ(1枚目を除く)。
1053 末尾と、そこまでを等間隔に割った位置を取る。1枚目は呼び出し側が必ず
1054 手本にするので、ここには入れない(Issue #10)。
1055 """
1056 total = int(total)
1057 count = max(0, int(count))
1058 if total <= 1 or count == 0:
1059 return []
1060 picked = []
1061 for step in range(1, count + 1):
1062 index = round(step * (total - 1) / count)
1063 index = max(1, min(total - 1, int(index)))
1064 if index not in picked:
1065 picked.append(index)
1066 return picked