Coverage for paintmaskanimator/colors.py: 93%

175 statements  

« 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. 

2 

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 

9 

10import numpy as np 

11from PySide6.QtGui import QColor 

12 

13from . import constants # noqa: F401 

14 

15 

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 ) 

23 

24 

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) 

52 

53 if not normalized: 

54 return np.zeros((0, 3), dtype=np.uint8) 

55 return np.asarray( 

56 normalized, 

57 dtype=np.uint8, 

58 ) 

59 

60 

61def local_color_variation(rgb): 

62 """上下左右との差が大きい境界画素を抽出する。""" 

63 source = rgb.astype(np.int16) 

64 variation = np.zeros(source.shape[:2], dtype=np.int16) 

65 

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 ) 

77 

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 ) 

89 

90 return variation 

91 

92 

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) 

105 

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) 

113 

114 colors_i32 = colors.astype(np.int32) 

115 counts_i64 = counts.astype(np.int64) 

116 boxes = [np.arange(len(colors_i32), dtype=np.int32)] 

117 

118 while len(boxes) < color_count: 

119 best_box_index = -1 

120 best_score = -1 

121 best_axis = 0 

122 

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 

138 

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 

141 

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

155 

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 ) 

171 

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

173 

174 

175def normalize_tone_curve_points(points): 

176 normalized = [] 

177 try: 

178 iterable = list(points) 

179 except TypeError: 

180 iterable = [] 

181 

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 

191 

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 

203 

204 

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 

214 

215 saturation = np.divide( 

216 delta, 

217 np.maximum(maximum, 1e-6), 

218 ) 

219 hue = np.zeros_like(maximum) 

220 chromatic = delta > 1e-6 

221 

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 

231 

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 

245 

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) 

254 

255 

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) 

268 

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 ) 

282 

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 

307 

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 = [] 

330 

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 

348 

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

381 

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 ] 

389 

390 return np.asarray(selected, dtype=np.uint8).reshape((-1, 3))