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

1"""Color-reduction, tone-curve and alpha-analysis algorithms. 

2 

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 

14 

15 

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 

38 

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 

42 

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 

62 

63 

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 } 

87 

88 

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

96 

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

100 

101 threshold = max( 

102 1, 

103 min(254, int(alpha_threshold)), 

104 ) 

105 alpha = rgba[:, :, 3] 

106 keep = alpha >= threshold 

107 

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) 

114 

115 

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) 

133 

134 

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 

139 

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 

143 

144 alpha = rgba[:, :, 3] 

145 # 半透明・透明を含む画像は従来のアルファ二値化を使う。 

146 if np.any(alpha < 250): 

147 return None 

148 

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 

153 

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 

166 

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) 

173 

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 

179 

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 

184 

185 return tuple(int(value) for value in dominant) 

186 

187 

188def estimate_mixed_boundary_pixels( 

189 image, 

190 background_rgb=None, 

191): 

192 """白背景上のアンチエイリアス境界のおおよその画素数。""" 

193 if image is None or image.isNull(): 

194 return 0 

195 

196 rgba = imaging.qimage_rgba_array(image) 

197 if rgba.size == 0: 

198 return 0 

199 

200 rgb = rgba[:, :, :3] 

201 alpha = rgba[:, :, 3] 

202 variation = _colors.local_color_variation(rgb) 

203 candidate = (alpha >= 250) & (variation >= 10) 

204 

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) 

221 

222 return int(np.count_nonzero(candidate)) 

223 

224 

225def _surface_guided_palette_labels( 

226 rgb, 

227 visible, 

228 palette, 

229 spatial_refinement=True, 

230): 

231 """色面内部を種にし、混合境界を隣接する2色へ分割する。 

232 

233 画素ごとの繰り返しなので計算はRustで行う。 

234 :func:`_surface_guided_palette_labels_numpy` は突き合わせ用の参照実装。 

235 """ 

236 return native.surface_guided_palette_labels( 

237 rgb, visible, palette, spatial_refinement 

238 ) 

239 

240 

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 ) 

256 

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 ) 

269 

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 ] 

294 

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 

303 

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] 

317 

318 exactish = ( 

319 visible 

320 & (labels < 0) 

321 & (nearest_distance <= 0.015) 

322 ) 

323 labels[exactish] = nearest_labels[exactish] 

324 

325 pixel_features_all = _colors.rgb_hsv_features( 

326 rgb.reshape((-1, 3)) 

327 ).reshape((height, width, -1)) 

328 

329 # 隣接する色面候補だけを比較するため、 

330 # 2色の混合帯は中間位置で明確に二分される。 

331 for _iteration in range(12): 

332 unresolved = visible & (labels < 0) 

333 if not np.any(unresolved): 

334 break 

335 

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) 

349 

350 best_label = np.full_like(labels, -1) 

351 best_distance = np.full( 

352 (height, width), 

353 np.inf, 

354 dtype=np.float32, 

355 ) 

356 

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] 

374 

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] 

379 

380 unresolved = visible & (labels < 0) 

381 labels[unresolved] = nearest_labels[unresolved] 

382 return labels 

383 

384 

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) 

434 

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) 

447 

448 

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 ) 

471 

472 if len(xs) <= 2: 

473 return np.clip( 

474 np.interp(values, xs, ys), 

475 0.0, 

476 1.0, 

477 ) 

478 

479 intervals = np.diff(xs) 

480 slopes = np.diff(ys) / np.maximum( 

481 intervals, 

482 1e-12, 

483 ) 

484 tangents = np.zeros_like(xs) 

485 

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 ) 

511 

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 

534 

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 ) 

547 

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 

568 

569 y0 = ys[segment_indices] 

570 y1 = ys[segment_indices + 1] 

571 m0 = tangents[segment_indices] 

572 m1 = tangents[segment_indices + 1] 

573 

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) 

581 

582 

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) 

596 

597 

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 

606 

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

614 

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

618 

619 lut = tone_curve_lut(points) 

620 result = rgba.copy() 

621 result[:, :, :3] = lut[result[:, :, :3]] 

622 

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 ) 

642 

643 return imaging.rgba_array_to_qimage(result) 

644 

645 

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) 

658 

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) 

666 

667 features = _colors.rgb_hsv_features(colors) 

668 weights = counts.astype(np.float64) 

669 

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 ) 

676 

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 ) 

691 

692 centers = np.asarray(centers, dtype=np.float32) 

693 labels = np.zeros(len(colors), dtype=np.int32) 

694 

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 

707 

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 ) 

722 

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 ) 

759 

760 return np.asarray(palette, dtype=np.uint8) 

761 

762 

763def _palette_training_samples( 

764 image, 

765 alpha_threshold=128, 

766 opaque_background=False, 

767 background_rgb=None, 

768): 

769 """1枚から、パレットの手本にする画素 ``(N, 3)`` を取り出す。 

770 

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) 

778 

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) 

788 

789 variation = _colors.local_color_variation(rgb) 

790 stable = visible & (variation <= 12) 

791 

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 ) 

811 

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 ) 

827 

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 

832 

833 

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 """近い色相を同じ原色面としてまとめる共通パレット。 

846 

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 ) 

872 

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] 

881 

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

949 

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

957 

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) 

966 

967 return np.asarray( 

968 unique_palette[:color_count], 

969 dtype=np.uint8, 

970 ) 

971 

972 

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 

985 

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

989 

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

996 

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 ) 

1007 

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 ) 

1019 

1020 result = np.zeros_like(rgba) 

1021 valid = visible & (labels >= 0) 

1022 result[:, :, :3][valid] = palette_array[ 

1023 labels[valid] 

1024 ] 

1025 

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 

1036 

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 ) 

1043 

1044 

1045 

1046#: パレットの手本にする代表フレームの枚数(1枚目を除く)。 

1047REFERENCE_FRAME_COUNT = 4 

1048 

1049 

1050def reference_frame_indices(total, count=REFERENCE_FRAME_COUNT): 

1051 """``total`` 枚の連番から、代表フレームの位置を選ぶ(1枚目を除く)。 

1052 

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