Coverage for src/dictk/imaging.py: 94%

153 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-24 22:28 +0000

1"""Image I/O, grayscale conversion, combination, and inspection utilities.""" 

2 

3import base64 

4import importlib.resources 

5from pathlib import Path 

6 

7import imageio.v3 as iio 

8import numpy as np 

9from matplotlib import pyplot as plt 

10from scipy.interpolate import RegularGridInterpolator 

11from scipy.ndimage import zoom 

12 

13 

14def checkerboard( 

15 width: int, height: int, count_x: int = 8, count_y: int = 8 

16) -> np.ndarray: 

17 """Generate a black-and-white checkerboard test image. 

18 

19 Same parameters and pixel values as the `dictk checkerboard` CLI 

20 command, minus the file write — see the `dictk` package docstring for 

21 why the API layer stops at the array. count_x and count_y are named for 

22 the rectangles they divide the image into, not "squares": unequal 

23 counts (or a width/height ratio that doesn't match count_x/count_y) 

24 produce rectangular cells, not square ones. 

25 

26 Args: 

27 width: Image width in pixels. 

28 height: Image height in pixels. 

29 count_x: Number of rectangles along the width. Must be >= 1. 

30 count_y: Number of rectangles along the height. Must be >= 1. 

31 

32 Returns: 

33 A 2D uint8 array of shape (height, width) with values 0 or 255. 

34 

35 Raises: 

36 ValueError: If count_x or count_y is less than 1. 

37 """ 

38 if count_x < 1: 

39 raise ValueError(f"count_x {count_x} must be >= 1") 

40 if count_y < 1: 

41 raise ValueError(f"count_y {count_y} must be >= 1") 

42 

43 rows = (np.arange(height) * count_y // height) % 2 

44 cols = (np.arange(width) * count_x // width) % 2 

45 pattern = np.logical_xor(rows[:, None], cols[None, :]) 

46 return (pattern * 255).astype(np.uint8) 

47 

48 

49def astronaut(width: int = 512, height: int = 512) -> np.ndarray: 

50 """Load a bundled real-world grayscale reference image. 

51 

52 Same parameters and pixel values as the `dictk astronaut` CLI 

53 command, minus the file write — see the `dictk` package docstring for 

54 why the API layer stops at the array. 

55 

56 The source is a NASA portrait of astronaut Eileen Collins, from the 

57 NASA Great Images database ("No known copyright restrictions, released 

58 into the public domain."). It's bundled as a color image and converted 

59 with `rgba_to_gray`; unlike `rosta` and `checkerboard`, it isn't 

60 procedurally generated, so `width`/`height` other than the native 

61 512x512 resize the source image (via `scipy.ndimage.zoom`) rather than 

62 computing a fresh pattern at that size. 

63 

64 Args: 

65 width: Image width in pixels. Must be >= 1. 

66 height: Image height in pixels. Must be >= 1. 

67 

68 Returns: 

69 A 2D uint8 array of shape (height, width). 

70 

71 Raises: 

72 ValueError: If width or height is less than 1. 

73 """ 

74 if width < 1: 

75 raise ValueError(f"width {width} must be >= 1") 

76 if height < 1: 

77 raise ValueError(f"height {height} must be >= 1") 

78 

79 asset_path = importlib.resources.files("dictk") / "data" / "astronaut.png" 

80 with importlib.resources.as_file(asset_path) as path: 

81 color = read_image(path) 

82 gray = rgba_to_gray(color) 

83 

84 native_height, native_width = gray.shape 

85 if (width, height) == (native_width, native_height): 

86 return gray 

87 

88 zoom_factors = (height / native_height, width / native_width) 

89 resized = zoom(gray.astype(np.float64), zoom_factors, order=3) 

90 return np.clip(resized, 0, 255).astype(np.uint8) 

91 

92 

93def is_rgba(arr: np.ndarray) -> bool: 

94 """Check whether an image array is in RGB or RGBA format. 

95 

96 Args: 

97 arr: Input image array. 

98 

99 Returns: 

100 True if the array is 3D with 3 or 4 channels, False otherwise. 

101 """ 

102 return arr.ndim == 3 and arr.shape[2] in (3, 4) 

103 

104 

105def rgba_to_gray(arr: np.ndarray) -> np.ndarray: 

106 """Convert an RGB(A) image to grayscale by averaging the RGB channels. 

107 

108 Args: 

109 arr: Input image array, either 2D (grayscale) or 3D (color). 

110 

111 Returns: 

112 A 2D grayscale image array. If the input is already 2D, it is 

113 returned unchanged. 

114 

115 Raises: 

116 ValueError: If the array is neither 2D nor a 3-or-4-channel 3D array. 

117 """ 

118 if arr.ndim == 2: 

119 return arr 

120 

121 if is_rgba(arr): 

122 return np.mean(arr[:, :, :3], axis=2).astype(arr.dtype) 

123 

124 raise ValueError( 

125 "Input array must be either 2D (grayscale) or 3D with 3 or 4 channels (color)." 

126 ) 

127 

128 

129def combine_images(a: np.ndarray, b: np.ndarray) -> np.ndarray: 

130 """Combine two images by averaging their pixel values. 

131 

132 Args: 

133 a: First image, 2D grayscale or 3D color. 

134 b: Second image, same shape as `a` once converted to grayscale. 

135 

136 Returns: 

137 A 2D uint8 array, normalized to the range [0, 255]. 

138 

139 Raises: 

140 ValueError: If the grayscale-converted images don't share a shape. 

141 """ 

142 gray_a = rgba_to_gray(a) 

143 gray_b = rgba_to_gray(b) 

144 

145 if gray_a.shape != gray_b.shape: 

146 raise ValueError( 

147 f"shape mismatch: a.shape={gray_a.shape}, b.shape={gray_b.shape}" 

148 ) 

149 

150 combined = gray_a.astype(np.float64) + gray_b.astype(np.float64) 

151 return (combined / combined.max() * 255).astype(np.uint8) 

152 

153 

154def brightness(arr: np.ndarray, factor: float) -> np.ndarray: 

155 """Adjust image brightness by an additive shift, clipped to [0, 255]. 

156 

157 Brightness *translates* the pixel-intensity histogram: every pixel is 

158 shifted by the same amount, so dark areas lighten right along with 

159 bright ones (unlike a multiplicative scale, where a black pixel would 

160 stay exactly black). factor=1.0 leaves the image unchanged; factor=1.5 

161 shifts every pixel by +127.5*0.5 = +63.75, factor=2.0 by +127.5. 

162 

163 Args: 

164 arr: A 2D grayscale image array. 

165 factor: Brightness factor. 1.0 is unchanged; > 1.0 brightens 

166 (shifts toward white); < 1.0 darkens (shifts toward black). 

167 

168 Returns: 

169 A 2D uint8 array, same shape as `arr`. 

170 """ 

171 max_pixel_value = 255.0 

172 offset = (factor - 1.0) * (max_pixel_value / 2.0) 

173 shifted = arr.astype(np.float64) + offset 

174 return np.clip(shifted, 0, max_pixel_value).astype(np.uint8) 

175 

176 

177def contrast(arr: np.ndarray, factor: float) -> np.ndarray: 

178 """Adjust image contrast by scaling around the mean, clipped to [0, 255]. 

179 

180 Contrast *stretches* the pixel-intensity histogram outward from its own 

181 mean, rather than shifting it: the mean stays roughly the same, while 

182 values spread further from it. factor=1.0 leaves the image unchanged; 

183 factor=0.0 collapses every pixel to the mean (a flat gray image); 

184 factor > 1.0 pushes values further toward 0 and 255. 

185 

186 Args: 

187 arr: A 2D grayscale image array. 

188 factor: Contrast factor. 1.0 is unchanged; > 1.0 increases 

189 contrast; between 0.0 and 1.0 decreases it. 

190 

191 Returns: 

192 A 2D uint8 array, same shape as `arr`. 

193 """ 

194 mean = arr.astype(np.float64).mean() 

195 stretched = (arr.astype(np.float64) - mean) * factor + mean 

196 return np.clip(stretched, 0, 255).astype(np.uint8) 

197 

198 

199def _backward_map( 

200 arr: np.ndarray, xs_source: np.ndarray, ys_source: np.ndarray 

201) -> np.ndarray: 

202 """Sample `arr` via bilinear interpolation at (xs_source, ys_source). 

203 

204 Shared by geometric-transform functions (`stretch`, `translate`, ...): 

205 each computes where every output pixel's source coordinate falls under 

206 its own inverse transform, then hands the resulting coordinate grids 

207 here to do the actual sampling. Coordinates outside `arr`'s bounds are 

208 filled with black (0). 

209 

210 Args: 

211 arr: A 2D grayscale image array. 

212 xs_source: Source x-coordinate for each output pixel, shape (height, width). 

213 ys_source: Source y-coordinate for each output pixel, shape (height, width). 

214 

215 Returns: 

216 A 2D uint8 array, same shape as `arr`. 

217 """ 

218 height, width = arr.shape 

219 interpolator = RegularGridInterpolator( 

220 points=(np.arange(height), np.arange(width)), 

221 values=arr.astype(np.float64), 

222 method="linear", 

223 bounds_error=False, 

224 fill_value=0.0, 

225 ) 

226 points = np.stack((ys_source.ravel(), xs_source.ravel()), axis=1) 

227 deformed = interpolator(points).reshape(arr.shape) 

228 return np.clip(deformed, 0, 255).astype(np.uint8) 

229 

230 

231def stretch( 

232 arr: np.ndarray, factor_x: float = 1.0, factor_y: float = 1.0 

233) -> np.ndarray: 

234 """Apply a uniaxial or biaxial stretch, pivoting on the image origin. 

235 

236 Mimics a continuum-mechanics stretch deformation gradient 

237 diag(factor_x, factor_y), anchored at the image's top-left corner 

238 (x=0, y=0): that corner stays fixed, and content grows (factor > 1.0) 

239 or shrinks (factor < 1.0) away from it along each axis. Uses backward 

240 mapping — for each output pixel, the inverse of the stretch locates 

241 its source coordinate in `arr`, with bilinear interpolation for 

242 non-integer source coordinates — so the result has no gaps, unlike 

243 naively moving each source pixel forward. A factor < 1.0 shrinks 

244 content toward the origin, leaving black (fill value 0) margins along 

245 the far (bottom/right) edges. 

246 

247 Args: 

248 arr: A 2D grayscale image array. 

249 factor_x: Stretch factor along the x-axis. Must be > 0. 

250 factor_y: Stretch factor along the y-axis. Must be > 0. 

251 

252 Returns: 

253 A 2D uint8 array, same shape as `arr`. 

254 

255 Raises: 

256 ValueError: If factor_x or factor_y is <= 0. 

257 """ 

258 if factor_x <= 0: 

259 raise ValueError(f"factor_x {factor_x} must be > 0") 

260 if factor_y <= 0: 

261 raise ValueError(f"factor_y {factor_y} must be > 0") 

262 

263 height, width = arr.shape 

264 xs, ys = np.meshgrid(np.arange(width), np.arange(height)) 

265 

266 # Backward mapping, pivoting on the origin (0, 0): for each output 

267 # pixel, the inverse of the stretch gives the coordinate to sample 

268 # from in the original image. 

269 xs_source = xs / factor_x 

270 ys_source = ys / factor_y 

271 

272 return _backward_map(arr, xs_source, ys_source) 

273 

274 

275def translate(arr: np.ndarray, dx: float = 0.0, dy: float = 0.0) -> np.ndarray: 

276 """Apply a rigid-body translation: every pixel shifts by (dx, dy). 

277 

278 A pure displacement, with no change in shape or size — the simplest 

279 transformation category. Uses the same backward-mapping approach as 

280 `stretch`, so non-integer displacements are handled with bilinear 

281 interpolation rather than rounding. Content shifted in from outside 

282 the original bounds is filled with black (fill value 0). 

283 

284 Args: 

285 arr: A 2D grayscale image array. 

286 dx: Displacement in pixels along the x-axis; positive moves 

287 content right. 

288 dy: Displacement in pixels along the y-axis; positive moves 

289 content down. 

290 

291 Returns: 

292 A 2D uint8 array, same shape as `arr`. 

293 """ 

294 height, width = arr.shape 

295 xs, ys = np.meshgrid(np.arange(width), np.arange(height)) 

296 

297 # Backward mapping: the source of output pixel (x, y) is (x - dx, y - dy). 

298 xs_source = xs - dx 

299 ys_source = ys - dy 

300 

301 return _backward_map(arr, xs_source, ys_source) 

302 

303 

304def rotate(arr: np.ndarray, angle: float) -> np.ndarray: 

305 """Apply a rigid-body rotation, pivoting on the image origin. 

306 

307 Rotates content by `angle` degrees, positive counterclockwise, 

308 pivoting on the image's top-left corner (0, 0) rather than its 

309 center — consistent with `stretch` and `translate`'s pivot choice in 

310 this codebase, but unlike a typical "object spins in place" rotation 

311 example: most content swings away from that fixed corner, similar to 

312 a door on a hinge. Uses the same backward-mapping approach as 

313 `stretch` and `translate`, so non-integer source coordinates are 

314 bilinearly interpolated, and any pixel with no corresponding source 

315 coordinate within `arr`'s bounds is filled with black (fill value 0). 

316 

317 Args: 

318 arr: A 2D grayscale image array. 

319 angle: Rotation angle in degrees; positive is counterclockwise. 

320 

321 Returns: 

322 A 2D uint8 array, same shape as `arr`. 

323 """ 

324 height, width = arr.shape 

325 xs, ys = np.meshgrid(np.arange(width), np.arange(height)) 

326 

327 theta = np.deg2rad(angle) 

328 cos_theta = np.cos(theta) 

329 sin_theta = np.sin(theta) 

330 

331 # Backward mapping: applying the inverse (-angle) rotation to each 

332 # output coordinate gives the coordinate to sample from. 

333 xs_source = cos_theta * xs + sin_theta * ys 

334 ys_source = -sin_theta * xs + cos_theta * ys 

335 

336 return _backward_map(arr, xs_source, ys_source) 

337 

338 

339def shear(arr: np.ndarray, shear_x: float = 0.0, shear_y: float = 0.0) -> np.ndarray: 

340 """Apply a simple shear, pivoting on the image origin. 

341 

342 Mimics a continuum-mechanics shear deformation gradient 

343 [[1, shear_x], [shear_y, 1]], anchored at the image's top-left corner 

344 (x=0, y=0), consistent with `stretch`, `translate`, and `rotate`'s 

345 pivot choice in this codebase: horizontal planes slide relative to 

346 each other by an amount proportional to their y-coordinate 

347 (shear_x), and/or vertical planes slide by an amount proportional to 

348 their x-coordinate (shear_y). Uses the same backward-mapping approach 

349 as the other transform functions, so non-integer source coordinates 

350 are bilinearly interpolated, and any pixel with no corresponding 

351 source coordinate within `arr`'s bounds is filled with black (fill 

352 value 0). 

353 

354 Args: 

355 arr: A 2D grayscale image array. 

356 shear_x: Horizontal shear factor. 

357 shear_y: Vertical shear factor. 

358 

359 Returns: 

360 A 2D uint8 array, same shape as `arr`. 

361 

362 Raises: 

363 ValueError: If shear_x * shear_y == 1, which makes the 

364 deformation gradient singular (non-invertible). 

365 """ 

366 determinant = 1.0 - shear_x * shear_y 

367 if determinant == 0: 

368 raise ValueError( 

369 f"shear_x {shear_x} and shear_y {shear_y} produce a singular " 

370 "deformation gradient (shear_x * shear_y == 1)" 

371 ) 

372 

373 height, width = arr.shape 

374 xs, ys = np.meshgrid(np.arange(width), np.arange(height)) 

375 

376 # Backward mapping: invert the shear deformation gradient 

377 # [[1, shear_x], [shear_y, 1]] to find each output pixel's source. 

378 xs_source = (xs - shear_x * ys) / determinant 

379 ys_source = (-shear_y * xs + ys) / determinant 

380 

381 return _backward_map(arr, xs_source, ys_source) 

382 

383 

384def complex_deform( 

385 arr: np.ndarray, factor_x: float = 1.0, factor_y: float = 1.0, angle: float = 0.0 

386) -> np.ndarray: 

387 """Apply an anisotropic stretch composed with a rotation, in one pass. 

388 

389 Composes a stretch (deformation gradient diag(factor_x, factor_y)) 

390 with a rotation (`angle` degrees, counterclockwise): the combined 

391 deformation gradient is F = R(angle) @ diag(factor_x, factor_y), i.e. 

392 the stretch is applied first and the rotation second. Applying both 

393 in a single backward-mapping pass (rather than calling `stretch` and 

394 then `rotate` separately) avoids the extra blur of interpolating 

395 twice. Represents realistic loading scenarios where materials 

396 experience multiple simultaneous deformation modes — typically the 

397 hardest case for correlation algorithms. Pivots on the image's 

398 top-left corner (0, 0), consistent with `stretch`, `translate`, 

399 `rotate`, and `shear`'s pivot choice in this codebase. 

400 

401 Args: 

402 arr: A 2D grayscale image array. 

403 factor_x: Stretch factor along the x-axis, applied before the 

404 rotation. Must be > 0. 

405 factor_y: Stretch factor along the y-axis, applied before the 

406 rotation. Must be > 0. 

407 angle: Rotation angle in degrees, applied after the stretch; 

408 positive is counterclockwise. 

409 

410 Returns: 

411 A 2D uint8 array, same shape as `arr`. 

412 

413 Raises: 

414 ValueError: If factor_x or factor_y is <= 0. 

415 """ 

416 if factor_x <= 0: 

417 raise ValueError(f"factor_x {factor_x} must be > 0") 

418 if factor_y <= 0: 

419 raise ValueError(f"factor_y {factor_y} must be > 0") 

420 

421 height, width = arr.shape 

422 xs, ys = np.meshgrid(np.arange(width), np.arange(height)) 

423 

424 theta = np.deg2rad(angle) 

425 cos_theta = np.cos(theta) 

426 sin_theta = np.sin(theta) 

427 

428 # Backward mapping for F = R(angle) @ diag(factor_x, factor_y): 

429 # F_inv = diag(1/factor_x, 1/factor_y) @ R(-angle), applied to each 

430 # output coordinate to find its source. Un-rotate first, then unscale. 

431 xs_rotated = cos_theta * xs + sin_theta * ys 

432 ys_rotated = -sin_theta * xs + cos_theta * ys 

433 xs_source = xs_rotated / factor_x 

434 ys_source = ys_rotated / factor_y 

435 

436 return _backward_map(arr, xs_source, ys_source) 

437 

438 

439def crack_dislocation(arr: np.ndarray, offset: float = 8.0) -> np.ndarray: 

440 """Apply a discontinuous vertical-crack displacement field. 

441 

442 Splits the image with a vertical crack at x = width / 2: the left 

443 half shifts down by `offset` pixels and the right half shifts up by 

444 `offset` pixels, producing a displacement field that jumps 

445 discontinuously across the crack line — unlike every other transform 

446 in this module, which varies smoothly. Standard DIC assumes smooth 

447 displacements and cannot capture this jump; cases like this motivate 

448 the Heaviside finite-element formulation. Uses the same backward 

449 mapping as the other transform functions, just with a piecewise 

450 (rather than single-matrix) displacement field, so non-integer 

451 source coordinates are bilinearly interpolated, and any pixel with 

452 no corresponding source coordinate within `arr`'s bounds is filled 

453 with black (fill value 0). 

454 

455 Args: 

456 arr: A 2D grayscale image array. 

457 offset: Displacement in pixels; the left half (x < width / 2) 

458 shifts down by this amount, the right half shifts up by it. 

459 

460 Returns: 

461 A 2D uint8 array, same shape as `arr`. 

462 """ 

463 height, width = arr.shape 

464 xs, ys = np.meshgrid(np.arange(width), np.arange(height)) 

465 

466 xs_source = xs.astype(np.float64) 

467 ys_source = ys.astype(np.float64) 

468 

469 left_half = xs < (width / 2) 

470 ys_source[left_half] -= offset 

471 ys_source[~left_half] += offset 

472 

473 return _backward_map(arr, xs_source, ys_source) 

474 

475 

476def read_image(path: Path) -> np.ndarray: 

477 """Read an image file into a NumPy array. 

478 

479 Args: 

480 path: Path to the image file. 

481 

482 Returns: 

483 The image as a NumPy array. 

484 """ 

485 return iio.imread(path) 

486 

487 

488def write_svg(arr: np.ndarray, path: Path) -> None: 

489 """Write a NumPy array to an SVG file. 

490 

491 SVG is a vector format with no native pixel-grid concept, so the array 

492 is PNG-encoded and embedded as a base64 data URI inside a minimal SVG 

493 wrapper (the standard way to carry raster data in SVG) rather than 

494 traced into vector shapes. 

495 

496 Args: 

497 arr: The image array to save. 

498 path: The output file path. 

499 """ 

500 height, width = arr.shape[:2] 

501 png_bytes = iio.imwrite("<bytes>", arr, extension=".png") 

502 encoded = base64.b64encode(png_bytes).decode("ascii") 

503 

504 svg = ( 

505 '<?xml version="1.0" encoding="UTF-8"?>\n' 

506 f'<svg xmlns="http://www.w3.org/2000/svg" ' 

507 f'xmlns:xlink="http://www.w3.org/1999/xlink" ' 

508 f'width="{width}" height="{height}" viewBox="0 0 {width} {height}">\n' 

509 f' <image width="{width}" height="{height}" ' 

510 f'xlink:href="data:image/png;base64,{encoded}"/>\n' 

511 "</svg>\n" 

512 ) 

513 Path(path).write_text(svg, encoding="ascii") 

514 

515 

516def write_image(arr: np.ndarray, path: Path) -> None: 

517 """Write a NumPy array to an image file. 

518 

519 Dispatches on the file extension: `.svg` is handled by `write_svg` 

520 (embedding a raster PNG in an SVG wrapper); every other extension 

521 (`.tiff`, `.png`, `.jpg`, ...) is handled by imageio directly. 

522 

523 Args: 

524 arr: The image array to save. 

525 path: The output file path. 

526 """ 

527 if Path(path).suffix.lower() == ".svg": 

528 write_svg(arr, path) 

529 return 

530 

531 iio.imwrite(path, arr) 

532 

533 

534def describe_image(arr: np.ndarray) -> str: 

535 """Format a description of an image array's type, shape, and color format. 

536 

537 Args: 

538 arr: The image array to describe. 

539 

540 Returns: 

541 A multi-line description string. 

542 """ 

543 lines = [ 

544 f"Type: {type(arr)}", 

545 f"Shape: {arr.shape}", 

546 f"Dtype: {arr.dtype}", 

547 ] 

548 

549 match arr.shape: 

550 case (_height, _width): 

551 lines.append("The image is grayscale.") 

552 case (_height, _width, 3): 

553 lines.append("The image is color (RGB).") 

554 case (_height, _width, 4): 

555 lines.append("The image is color (RGBA, includes alpha channel).") 

556 case _: 

557 lines.append("The image has an unsupported format.") 

558 

559 return "\n".join(lines) 

560 

561 

562def save_histogram(arr: np.ndarray, path: Path, dpi: int = 300) -> None: 

563 """Save a histogram of pixel intensities [0, 255] for a grayscale image. 

564 

565 Args: 

566 arr: The 2D grayscale image array, expected type uint8, range [0, 255]. 

567 path: The output file path for the histogram image. 

568 dpi: Resolution of the saved figure. 

569 """ 

570 plt.figure() 

571 plt.hist(arr.ravel(), bins=256, range=(0, 255), color="black", alpha=0.7) 

572 plt.title(f"pixel histogram intensity\n{path}", fontsize=8) 

573 plt.xlabel("pixel intensity (0-255)") 

574 plt.ylabel("frequency") 

575 plt.savefig(path, dpi=dpi) 

576 plt.close()