Coverage for paintmaskanimator/mask_transform.py: 93%

270 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""Binary-preserving mask transformation (inverse mapping + area coverage). 

2 

3The TP-quality transform used to project every color mask *forward* through the 

4deformation (``QPainter.setTransform`` / per-mask triangle rasterization) and 

5then rebuild a binary image from blurred grayscale masks. Forward mapping skips 

6or double-writes output pixels depending on the rotation angle, and the 

7blur-then-``> 0`` threshold inflated every shape by the blur radius, so rotated 

8edges came out as irregular noise instead of a clean staircase. 

9 

10This module replaces that with the standard approach for binary raster 

11resampling: 

12 

131. **Inverse mapping** - every *output* pixel is projected back into source 

14 space, so no output pixel is ever skipped or written twice. 

152. **Area coverage** - each output pixel is probed at ``n x n`` subsample 

16 positions and each color counts how many probes it won. 

173. **Majority resolution** - the color covering most of the pixel takes it 

18 whole. For a two-region boundary that is exactly a 50% area threshold, and 

19 the result is strictly binary: no intermediate colors, no partial alpha. 

20 

21Everything here is pure NumPy: no Qt, no Pillow. The functions operate on a 

22*label image* (``uint16``, 0 = outside/background, 1..N = color ids) instead of 

23one grayscale mask per color, which also makes the cost independent of the 

24palette size. 

25""" 

26from __future__ import annotations 

27 

28import math 

29 

30import numpy as np 

31 

32from . import native 

33 

34#: Subsample probes per axis. 4 -> 16 probes per output pixel, which resolves 

35#: coverage in 1/16 steps: enough to place a 50% boundary within a pixel. 

36DEFAULT_SUBSAMPLES = 4 

37 

38#: Output rows processed per band. Bounds peak memory: a band allocates 

39#: ``band_rows * width * subsamples^2`` entries rather than the whole image. 

40DEFAULT_BAND_ROWS = 64 

41 

42 

43def homography_matrix(source_points, target_points): 

44 """Return the 3x3 matrix mapping ``source_points`` onto ``target_points``. 

45 

46 Points are ``(x, y)`` pairs. Four correspondences are required. Raises 

47 ``numpy.linalg.LinAlgError`` for degenerate quads. 

48 """ 

49 rows = [] 

50 values = [] 

51 for (x, y), (u, v) in zip(source_points, target_points): 

52 x, y, u, v = float(x), float(y), float(u), float(v) 

53 rows.append([x, y, 1, 0, 0, 0, -u * x, -u * y]) 

54 values.append(u) 

55 rows.append([0, 0, 0, x, y, 1, -v * x, -v * y]) 

56 values.append(v) 

57 solved = np.linalg.solve( 

58 np.asarray(rows, dtype=np.float64), 

59 np.asarray(values, dtype=np.float64), 

60 ) 

61 matrix = np.empty((3, 3), dtype=np.float64) 

62 matrix.flat[:8] = solved 

63 matrix[2, 2] = 1.0 

64 return matrix 

65 

66 

67def _probe_coordinates(start, stop, subsamples): 

68 """Probe centers, in output-pixel units, for the range ``[start, stop)``.""" 

69 step = 1.0 / float(subsamples) 

70 offsets = (np.arange(subsamples, dtype=np.float64) + 0.5) * step 

71 return ( 

72 np.arange(start, stop, dtype=np.float64)[:, None] + offsets[None, :] 

73 ).reshape(-1) 

74 

75 

76class QuadMapper: 

77 """Inverse map for the free / scale / rotate transform (a planar quad). 

78 

79 ``target_quad`` are the four destination corners in output-image 

80 coordinates, in the same order as the source rectangle corners 

81 (top-left, top-right, bottom-right, bottom-left). 

82 """ 

83 

84 def __init__(self, target_quad, source_width, source_height): 

85 self.source_width = int(source_width) 

86 self.source_height = int(source_height) 

87 source_quad = [ 

88 (0.0, 0.0), 

89 (float(source_width), 0.0), 

90 (float(source_width), float(source_height)), 

91 (0.0, float(source_height)), 

92 ] 

93 target = [(float(p[0]), float(p[1])) for p in target_quad[:4]] 

94 # Solve destination -> source directly: the inverse map is what the 

95 # renderer needs, and solving it straight avoids inverting a matrix 

96 # that may be near-singular in one direction only. 

97 self.matrix = homography_matrix(target, source_quad) 

98 

99 def map_band(self, width, y0, y1, subsamples): 

100 # Each coordinate is separable - ``m0*x + m1*y + m2`` - so the per-axis 

101 # terms are computed once as vectors and only combined when the 2-D 

102 # probe grid is finally needed. That keeps one full-size temporary per 

103 # coordinate instead of one per term. 

104 probe_x = _probe_coordinates(0, width, subsamples) 

105 probe_y = _probe_coordinates(y0, y1, subsamples) 

106 matrix = self.matrix 

107 

108 def combine(row): 

109 # Kept in float64: this path is the reference the Rust renderer is 

110 # checked against, so probe positions must not round differently. 

111 along_x = matrix[row, 0] * probe_x 

112 along_y = matrix[row, 1] * probe_y + matrix[row, 2] 

113 return along_x[None, :] + along_y[:, None] 

114 

115 source_x = combine(0) 

116 source_y = combine(1) 

117 if abs(matrix[2, 0]) < 1e-12 and abs(matrix[2, 1]) < 1e-12: 

118 # Affine (scale / rotate / shear): the perspective divisor is 

119 # constant, so skip the division across the whole grid. 

120 scale = float(matrix[2, 2]) 

121 if abs(scale) < 1e-12: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true

122 return source_x, source_y, np.zeros(source_x.shape, dtype=bool) 

123 if abs(scale - 1.0) > 1e-12: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true

124 source_x /= scale 

125 source_y /= scale 

126 return source_x, source_y, True 

127 denominator = combine(2) 

128 # A zero denominator is the projective horizon; mark those probes 

129 # invalid rather than letting the division produce infinities. 

130 safe = np.abs(denominator) > 1e-12 

131 np.copyto(denominator, 1.0, where=~safe) 

132 source_x /= denominator 

133 source_y /= denominator 

134 return source_x, source_y, safe 

135 

136 def forward(self, source_x, source_y): 

137 """Map source positions to output positions (the inverse direction).""" 

138 try: 

139 matrix = np.linalg.inv(self.matrix) 

140 except np.linalg.LinAlgError: 

141 return None, None 

142 denominator = ( 

143 matrix[2, 0] * source_x + matrix[2, 1] * source_y + matrix[2, 2] 

144 ) 

145 denominator = np.where(np.abs(denominator) > 1e-12, denominator, np.nan) 

146 return ( 

147 (matrix[0, 0] * source_x + matrix[0, 1] * source_y + matrix[0, 2]) 

148 / denominator, 

149 (matrix[1, 0] * source_x + matrix[1, 1] * source_y + matrix[1, 2]) 

150 / denominator, 

151 ) 

152 

153 

154class MeshMapper: 

155 """Inverse map for the mesh transform, from the dense curved grid. 

156 

157 The dense grid is rasterized triangle by triangle in *output* space; the 

158 barycentric weights of each covered output probe give its source position. 

159 That is already a backward map, so unlike the quad case there is nothing to 

160 invert - but it must be rasterized rather than evaluated analytically. 

161 

162 ``dense_points`` are ``(x, y)`` pairs in output-image coordinates, laid out 

163 row-major over ``dense_cols x dense_rows``. 

164 """ 

165 

166 def __init__( 

167 self, dense_points, dense_cols, dense_rows, source_width, source_height 

168 ): 

169 self.points = np.asarray(dense_points, dtype=np.float64).reshape(-1, 2) 

170 self.dense_cols = int(dense_cols) 

171 self.dense_rows = int(dense_rows) 

172 self.source_width = int(source_width) 

173 self.source_height = int(source_height) 

174 

175 def map_band(self, width, y0, y1, subsamples): 

176 out_width = int(width) * int(subsamples) 

177 out_height = (int(y1) - int(y0)) * int(subsamples) 

178 source_x = np.zeros((out_height, out_width), dtype=np.float64) 

179 source_y = np.zeros((out_height, out_width), dtype=np.float64) 

180 valid = np.zeros((out_height, out_width), dtype=bool) 

181 

182 scale = float(subsamples) 

183 # Output-space triangle vertices scaled into probe coordinates, with the 

184 # band origin removed so each band indexes from 0. 

185 points = self.points * scale 

186 origin_y = float(y0) * scale 

187 cols, rows = self.dense_cols, self.dense_rows 

188 span_x = max(1, cols - 1) 

189 span_y = max(1, rows - 1) 

190 last_x = float(self.source_width - 1) 

191 last_y = float(self.source_height - 1) 

192 

193 def raster(target_indices, source_triangle): 

194 triangle = points[list(target_indices)] 

195 target_x = triangle[:, 0] 

196 target_y = triangle[:, 1] - origin_y 

197 min_x = max(0, int(math.floor(float(target_x.min())))) 

198 max_x = min(out_width - 1, int(math.ceil(float(target_x.max())))) 

199 min_y = max(0, int(math.floor(float(target_y.min())))) 

200 max_y = min(out_height - 1, int(math.ceil(float(target_y.max())))) 

201 if max_x < min_x or max_y < min_y: 

202 return 

203 denominator = ( 

204 (target_y[1] - target_y[2]) * (target_x[0] - target_x[2]) 

205 + (target_x[2] - target_x[1]) * (target_y[0] - target_y[2]) 

206 ) 

207 if abs(denominator) < 1e-8: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true

208 return 

209 yy, xx = np.mgrid[min_y:max_y + 1, min_x:max_x + 1] 

210 # Probe centers sit half a subsample step inside the cell. 

211 xx = xx + 0.5 

212 yy = yy + 0.5 

213 weight0 = ( 

214 (target_y[1] - target_y[2]) * (xx - target_x[2]) 

215 + (target_x[2] - target_x[1]) * (yy - target_y[2]) 

216 ) / denominator 

217 weight1 = ( 

218 (target_y[2] - target_y[0]) * (xx - target_x[2]) 

219 + (target_x[0] - target_x[2]) * (yy - target_y[2]) 

220 ) / denominator 

221 weight2 = 1.0 - weight0 - weight1 

222 inside = ( 

223 (weight0 >= -1e-6) & (weight1 >= -1e-6) & (weight2 >= -1e-6) 

224 ) 

225 if not np.any(inside): 

226 return 

227 mapped_x = ( 

228 weight0 * source_triangle[0][0] 

229 + weight1 * source_triangle[1][0] 

230 + weight2 * source_triangle[2][0] 

231 ) 

232 mapped_y = ( 

233 weight0 * source_triangle[0][1] 

234 + weight1 * source_triangle[1][1] 

235 + weight2 * source_triangle[2][1] 

236 ) 

237 region_x = source_x[min_y:max_y + 1, min_x:max_x + 1] 

238 region_y = source_y[min_y:max_y + 1, min_x:max_x + 1] 

239 region_valid = valid[min_y:max_y + 1, min_x:max_x + 1] 

240 # The grid is anchored on source pixel *centers*; shift to the 

241 # area convention the sampler uses (pixel n spans [n, n+1)). 

242 region_x[inside] = mapped_x[inside] + 0.5 

243 region_y[inside] = mapped_y[inside] + 0.5 

244 region_valid[inside] = True 

245 

246 for grid_y in range(rows - 1): 

247 top = last_y * grid_y / span_y 

248 bottom = last_y * (grid_y + 1) / span_y 

249 row_base = grid_y * cols 

250 for grid_x in range(cols - 1): 

251 left = last_x * grid_x / span_x 

252 right = last_x * (grid_x + 1) / span_x 

253 index = row_base + grid_x 

254 raster( 

255 (index, index + 1, index + cols + 1), 

256 ((left, top), (right, top), (right, bottom)), 

257 ) 

258 raster( 

259 (index, index + cols + 1, index + cols), 

260 ((left, top), (right, bottom), (left, bottom)), 

261 ) 

262 return source_x, source_y, valid 

263 

264 def forward(self, source_x, source_y): 

265 """Map source positions to output positions. 

266 

267 The dense grid samples the source on a regular lattice, so the forward 

268 direction is a bilinear lookup in that grid - no inversion needed. 

269 """ 

270 cols, rows = self.dense_cols, self.dense_rows 

271 span_x = max(1, cols - 1) 

272 span_y = max(1, rows - 1) 

273 last_x = max(1e-9, float(self.source_width - 1)) 

274 last_y = max(1e-9, float(self.source_height - 1)) 

275 grid_x = np.clip( 

276 np.asarray(source_x, dtype=np.float64) / last_x * span_x, 

277 0.0, span_x, 

278 ) 

279 grid_y = np.clip( 

280 np.asarray(source_y, dtype=np.float64) / last_y * span_y, 

281 0.0, span_y, 

282 ) 

283 x0 = np.minimum(grid_x.astype(np.int64), cols - 2) 

284 y0 = np.minimum(grid_y.astype(np.int64), rows - 2) 

285 fraction_x = (grid_x - x0)[..., None] 

286 fraction_y = (grid_y - y0)[..., None] 

287 points = self.points.reshape(rows, cols, 2) 

288 top = ( 

289 points[y0, x0] * (1.0 - fraction_x) + points[y0, x0 + 1] * fraction_x 

290 ) 

291 bottom = ( 

292 points[y0 + 1, x0] * (1.0 - fraction_x) 

293 + points[y0 + 1, x0 + 1] * fraction_x 

294 ) 

295 mapped = top * (1.0 - fraction_y) + bottom * fraction_y 

296 return mapped[..., 0], mapped[..., 1] 

297 

298 

299def _sample_labels(label_image, source_x, source_y, valid): 

300 """Nearest-neighbour label lookup; probes outside the source read 0. 

301 

302 ``valid`` may be a scalar ``True`` when the mapping cannot fail. 

303 """ 

304 height, width = label_image.shape 

305 x = np.floor(source_x, out=source_x).astype(np.int32) 

306 y = np.floor(source_y, out=source_y).astype(np.int32) 

307 inside = (x >= 0) & (x < width) & (y >= 0) & (y < height) 

308 if valid is not True: 

309 inside &= valid 

310 np.clip(x, 0, width - 1, out=x) 

311 np.clip(y, 0, height - 1, out=y) 

312 # One flat gather beats 2-D fancy indexing: the index arithmetic stays in 

313 # int32 and NumPy takes a single pass over the label image. 

314 y *= width 

315 y += x 

316 labels = np.take(label_image.reshape(-1), y) 

317 labels[~inside] = 0 

318 return labels 

319 

320 

321def _probe_axis(labels, subsamples): 

322 """Reshape ``(H*n, W*n)`` probes into ``(H, W, n*n)`` per-pixel probes.""" 

323 high_rows, high_cols = labels.shape 

324 rows = high_rows // subsamples 

325 cols = high_cols // subsamples 

326 return ( 

327 labels.reshape(rows, subsamples, cols, subsamples) 

328 .transpose(0, 2, 1, 3) 

329 .reshape(rows, cols, subsamples * subsamples) 

330 ) 

331 

332 

333def _majority(values): 

334 """Per-row winner of ``values`` (shape ``(M, S)``), lowest value wins ties. 

335 

336 Returns ``(winner, count)``. Implemented by sorting each row and measuring 

337 run lengths, which is O(S log S) with S = 16 and fully vectorized. 

338 """ 

339 count = values.shape[1] 

340 order = np.sort(values, axis=1) 

341 positions = np.arange(count) 

342 starts_run = np.empty(order.shape, dtype=bool) 

343 starts_run[:, 0] = True 

344 np.not_equal(order[:, 1:], order[:, :-1], out=starts_run[:, 1:]) 

345 ends_run = np.empty(order.shape, dtype=bool) 

346 ends_run[:, :-1] = starts_run[:, 1:] 

347 ends_run[:, -1] = True 

348 run_start = np.maximum.accumulate( 

349 np.where(starts_run, positions, 0), axis=1 

350 ) 

351 run_end = np.minimum.accumulate( 

352 np.where(ends_run, positions, count - 1)[:, ::-1], axis=1 

353 )[:, ::-1] 

354 run_length = run_end - run_start + 1 

355 # argmax returns the *first* maximum, and rows are sorted ascending, so ties 

356 # resolve to the smallest value - i.e. the highest-priority rank. 

357 best = np.argmax(run_length, axis=1) 

358 rows = np.arange(order.shape[0]) 

359 return order[rows, best], run_length[rows, best] 

360 

361 

362def _area_scale(mapper, source_width, source_height): 

363 """Rough output/source length ratio, measured at the middle of the source.""" 

364 center_x = source_width / 2.0 

365 center_y = source_height / 2.0 

366 span = max(1.0, min(source_width, source_height) / 8.0) 

367 xs = np.array([center_x, center_x + span, center_x]) 

368 ys = np.array([center_y, center_y, center_y + span]) 

369 mapped_x, mapped_y = mapper.forward(xs, ys) 

370 if mapped_x is None or not np.all(np.isfinite(mapped_x)): 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true

371 return 1.0 

372 edge_x = (mapped_x[1] - mapped_x[0], mapped_y[1] - mapped_y[0]) 

373 edge_y = (mapped_x[2] - mapped_x[0], mapped_y[2] - mapped_y[0]) 

374 area = abs(edge_x[0] * edge_y[1] - edge_x[1] * edge_y[0]) 

375 return math.sqrt(area) / span 

376 

377 

378def _splat_label(label_image, mapper, label, out_width, out_height): 

379 """Output pixels hit by forward-mapping every source pixel of ``label``. 

380 

381 Coverage alone cannot keep a line that shrinks below one pixel: its area 

382 falls under every threshold and the line dissolves into dashes. Mapping the 

383 source pixels *forward* fixes that, because a connected run of source pixels 

384 lands on output pixels at most one step apart, so the line stays connected 

385 however far it is shrunk. Forward mapping leaves holes when enlarging, 

386 which is exactly the case where coverage already does the right thing - so 

387 this is only used on a shrink, and only as a union with the coverage result. 

388 """ 

389 source_y, source_x = np.nonzero(label_image == label) 

390 if source_y.size == 0: 390 ↛ 391line 390 didn't jump to line 391 because the condition on line 390 was never true

391 return None 

392 mapped_x, mapped_y = mapper.forward( 

393 source_x.astype(np.float64) + 0.5, source_y.astype(np.float64) + 0.5 

394 ) 

395 if mapped_x is None: 395 ↛ 396line 395 didn't jump to line 396 because the condition on line 395 was never true

396 return None 

397 finite = np.isfinite(mapped_x) & np.isfinite(mapped_y) 

398 x = np.floor(mapped_x[finite]).astype(np.int64) 

399 y = np.floor(mapped_y[finite]).astype(np.int64) 

400 inside = (x >= 0) & (x < out_width) & (y >= 0) & (y < out_height) 

401 hit = np.zeros((out_height, out_width), dtype=bool) 

402 hit[y[inside], x[inside]] = True 

403 return hit 

404 

405 

406def render_labels_numpy( 

407 label_image, 

408 mapper, 

409 out_width, 

410 out_height, 

411 *, 

412 subsamples=DEFAULT_SUBSAMPLES, 

413 coverage_labels=(), 

414 protect_labels=(), 

415 band_rows=DEFAULT_BAND_ROWS, 

416 progress=None, 

417): 

418 """Transform ``label_image`` through ``mapper`` onto an output raster. 

419 

420 ``label_image`` is ``uint16`` with 0 meaning "nothing here"; label ids must 

421 already be ordered by priority (a lower id wins a tie). Returns 

422 ``(labels, coverage)`` where ``labels`` is ``(out_height, out_width)`` 

423 ``uint16`` and ``coverage`` maps each id in ``coverage_labels`` to a 

424 ``uint8`` array holding that label's true area coverage (0-255) - not a 

425 blurred mask, so thresholding it stays meaningful. 

426 

427 Ids listed in ``protect_labels`` additionally keep their coverage ridge, so 

428 a line thinned below one pixel by a shrink stays connected instead of 

429 breaking into dashes. Protected ids must also appear in ``coverage_labels``. 

430 """ 

431 label_image = np.ascontiguousarray(label_image, dtype=np.uint16) 

432 out_width = max(1, int(out_width)) 

433 out_height = max(1, int(out_height)) 

434 subsamples = max(1, int(subsamples)) 

435 probes = subsamples * subsamples 

436 

437 result = np.zeros((out_height, out_width), dtype=np.uint16) 

438 coverage = { 

439 int(label): np.zeros((out_height, out_width), dtype=np.uint8) 

440 for label in coverage_labels 

441 } 

442 

443 band_rows = max(1, int(band_rows)) 

444 band_count = max(1, math.ceil(out_height / band_rows)) 

445 for band_index, y0 in enumerate(range(0, out_height, band_rows)): 

446 y1 = min(out_height, y0 + band_rows) 

447 if progress is not None: 447 ↛ 448line 447 didn't jump to line 448 because the condition on line 447 was never true

448 progress(band_index, band_count) 

449 source_x, source_y, valid = mapper.map_band( 

450 out_width, y0, y1, subsamples 

451 ) 

452 sampled = _sample_labels(label_image, source_x, source_y, valid) 

453 per_pixel = _probe_axis(sampled, subsamples) 

454 rows = y1 - y0 

455 flat = per_pixel.reshape(rows * out_width, probes) 

456 

457 # Only pixels straddling a boundary need their probes counted; every 

458 # interior pixel has all probes on one color. Boundaries are a small 

459 # fraction of any real drawing, so resolving just those is much cheaper 

460 # than sorting the whole band. 

461 low = flat.min(axis=1) 

462 mixed = low != flat.max(axis=1) 

463 winner = low 

464 if probes > 1 and np.any(mixed): 

465 winner = winner.copy() 

466 winner[mixed], _count = _majority(flat[mixed]) 

467 result[y0:y1] = winner.reshape(rows, out_width) 

468 

469 for label, buffer in coverage.items(): 

470 hits = np.where(low == label, probes, 0).astype(np.uint16) 

471 if np.any(mixed): 

472 hits[mixed] = np.count_nonzero( 

473 flat[mixed] == label, axis=1 

474 ).astype(np.uint16) 

475 buffer[y0:y1] = ((hits * 255) // probes).astype( 

476 np.uint8 

477 ).reshape(rows, out_width) 

478 if progress is not None: 478 ↛ 479line 478 didn't jump to line 479 because the condition on line 478 was never true

479 progress(band_count, band_count) 

480 

481 _protect_lines( 

482 result, coverage, label_image, mapper, protect_labels, 

483 out_width, out_height, 

484 ) 

485 return result, coverage 

486 

487 

488def _protect_lines( 

489 result, coverage, label_image, mapper, protect_labels, out_width, out_height 

490): 

491 """Union the forward-mapped source pixels of ``protect_labels`` into ``result``. 

492 

493 Only a shrink can thin a line below one pixel. At 1:1 or larger the 

494 coverage result is already complete, and splatting there would only pad the 

495 edge of lines that are perfectly fine. 

496 """ 

497 shrinking = protect_labels and _area_scale( 

498 mapper, label_image.shape[1], label_image.shape[0] 

499 ) < 0.999 

500 for label in protect_labels if shrinking else (): 

501 label = int(label) 

502 hit = _splat_label(label_image, mapper, label, out_width, out_height) 

503 if hit is None: 503 ↛ 504line 503 didn't jump to line 504 because the condition on line 503 was never true

504 continue 

505 result[hit] = np.uint16(label) 

506 buffer = coverage.get(label) 

507 if buffer is not None: 507 ↛ 500line 507 didn't jump to line 500 because the condition on line 507 was always true

508 # Report full coverage there so the caller's line-width threshold 

509 # cannot drop the pixels that keep the line connected. 

510 buffer[hit] = 255 

511 

512 

513def render_labels( 

514 label_image, 

515 mapper, 

516 out_width, 

517 out_height, 

518 *, 

519 subsamples=DEFAULT_SUBSAMPLES, 

520 coverage_labels=(), 

521 protect_labels=(), 

522 band_rows=DEFAULT_BAND_ROWS, 

523 progress=None, 

524): 

525 """Transform ``label_image`` through ``mapper``; see :func:`render_labels_numpy`. 

526 

527 Both mappers are resolved by the Rust renderer. The planar quad (scale / 

528 rotate / free) maps probes straight from the 3x3 inverse matrix, so no band 

529 of probe coordinates is ever held in memory; the mesh is rasterized 

530 triangle by triangle into one band of probe *labels*, which is a quarter of 

531 what the coordinates alone would take. :func:`render_labels_numpy` stays as 

532 the reference the two are checked against. 

533 """ 

534 label_image = np.ascontiguousarray(label_image, dtype=np.uint16) 

535 out_width = max(1, int(out_width)) 

536 out_height = max(1, int(out_height)) 

537 subsamples = max(1, int(subsamples)) 

538 # The renderer resolves the whole raster in one call, so progress can only 

539 # report the step as a whole. 

540 if progress is not None: 

541 progress(0, 1) 

542 if isinstance(mapper, QuadMapper): 

543 result, coverage = native.render_quad_labels( 

544 label_image, mapper.matrix, out_width, out_height, 

545 subsamples, coverage_labels, 

546 ) 

547 else: 

548 result, coverage = native.render_mesh_labels( 

549 label_image, mapper.points, mapper.dense_cols, mapper.dense_rows, 

550 out_width, out_height, subsamples, max(1, int(band_rows)), 

551 coverage_labels, 

552 ) 

553 if progress is not None: 

554 progress(1, 1) 

555 _protect_lines( 

556 result, coverage, label_image, mapper, protect_labels, 

557 out_width, out_height, 

558 ) 

559 return result, coverage 

560 

561 

562def labels_to_rgba(labels, colors): 

563 """Expand a label image into binary RGBA using ``colors`` indexed by label. 

564 

565 ``colors`` is an ``(N, 4) uint8`` array whose row 0 is the background. 

566 Output alpha is only ever 0 or 255. 

567 """ 

568 palette = np.asarray(colors, dtype=np.uint8) 

569 labels = np.asarray(labels) 

570 clipped = np.clip(labels, 0, len(palette) - 1) 

571 rgba = palette[clipped] 

572 return np.ascontiguousarray(rgba)