Coverage for src/dictk/plot.py: 98%

420 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-09 23:57 +0000

1"""Matplotlib-based figure generation for dictk's image and DIC data.""" 

2 

3from collections.abc import Sequence 

4from pathlib import Path 

5from typing import NamedTuple 

6 

7import numpy as np 

8from matplotlib import patches 

9from matplotlib import patheffects 

10from matplotlib import pyplot as plt 

11from matplotlib.colors import Colormap 

12from matplotlib.path import Path as MarkerPath 

13 

14from dictk.correlation import WindowingMethod, _kernel_pad, phase_correlation, window 

15from dictk.image import PixelCoordinate, SubpixelCoordinate, subimage 

16 

17 

18# Shared figure scale (pixels of image data per inch) for 

19# subimage_bounds_plot() and subimage_plot(), so each saved figure's size 

20# is proportional to its actual pixel content rather than a fixed default 

21# figure size -- letting the two be visually compared for relative size 

22# instead of both rendering at roughly the same size regardless of how 

23# many pixels each actually covers. 

24_FIGURE_PIXELS_PER_INCH = 100 

25 

26# The first 12 colors of matplotlib's "tab20" (Tableau 20) colormap: 6 hues 

27# (blue, orange, green, red, purple, brown), each as a dark/light pair. 

28# Deliberately stops short of tab20's gray pair (its indices 14-15) -- 

29# gray has no hue to contrast against a grayscale image with, so it all 

30# but disappears drawn on top of one. 

31_TABLEAU_PALETTE = ( 

32 "#1f77b4", 

33 "#aec7e8", # blue 

34 "#ff7f0e", 

35 "#ffbb78", # orange 

36 "#2ca02c", 

37 "#98df8a", # green 

38 "#d62728", 

39 "#ff9896", # red 

40 "#9467bd", 

41 "#c5b0d5", # purple 

42 "#8c564b", 

43 "#c49c94", # brown 

44) 

45 

46 

47def subimage_bounds_plot( 

48 *, 

49 image: np.ndarray, 

50 origin: PixelCoordinate, 

51 width: int, 

52 height: int, 

53 path: Path, 

54 dpi: int = 300, 

55) -> None: 

56 """Save a figure overlaying a source image's bounds and a subimage region. 

57 

58 Draws `image`'s own bounds in blue and the requested `origin`/`width`/ 

59 `height` region in red, on top of `image` itself — including cases 

60 where the red region extends partially or completely outside the blue 

61 one, to visualize what `subimage()` would crop from. An `'o'` marker 

62 is also drawn at each rectangle's own origin: blue at `(0, 0)` for 

63 `image`, red at `origin` for the subimage — both in `image`'s pixel 

64 reference frame. 

65 

66 Args: 

67 image: Source 2D grayscale image array. 

68 origin: Top-left corner of the region, in `image`'s pixel 

69 reference frame; see `subimage()`. 

70 width: Width of the region in pixels. Must be >= 1. 

71 height: Height of the region in pixels. Must be >= 1. 

72 path: Output file path for the figure; format is inferred from 

73 the extension by matplotlib's savefig (e.g. .png), not 

74 dictk's own write/write_svg. 

75 dpi: Resolution of the saved figure. 

76 

77 Raises: 

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

79 """ 

80 if width < 1: 

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

82 if height < 1: 

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

84 

85 image_height, image_width = image.shape 

86 

87 margin = max(width, height, image_width, image_height) * 0.05 

88 x_min = min(0, origin.x) - margin 

89 x_max = max(image_width, origin.x + width) + margin 

90 y_min = min(0, origin.y) - margin 

91 y_max = max(image_height, origin.y + height) + margin 

92 

93 figsize = ( 

94 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH, 

95 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH, 

96 ) 

97 fig, ax = plt.subplots(figsize=figsize) 

98 ax.imshow( 

99 image, cmap="gray", origin="upper", extent=(0, image_width, image_height, 0) 

100 ) 

101 

102 ax.add_patch( 

103 patches.Rectangle( 

104 (0, 0), 

105 image_width, 

106 image_height, 

107 edgecolor="blue", 

108 facecolor="none", 

109 linewidth=1.5, 

110 label="source image", 

111 ) 

112 ) 

113 ax.add_patch( 

114 patches.Rectangle( 

115 (origin.x, origin.y), 

116 width, 

117 height, 

118 edgecolor="red", 

119 facecolor="none", 

120 linewidth=1.5, 

121 label="subimage", 

122 ) 

123 ) 

124 ax.plot(0, 0, marker="o", color="blue", markersize=6) 

125 ax.plot(origin.x, origin.y, marker="o", color="red", markersize=6) 

126 

127 ax.set_xlim(x_min, x_max) 

128 ax.set_ylim(y_max, y_min) # inverted: image y increases downward 

129 

130 ax.set_xlabel("x (pixels)") 

131 ax.set_ylabel("y (pixels)") 

132 # Legend placed fully outside the axes (not just an "upper right"-style 

133 # corner) so it never overlaps the source or subimage rectangles, 

134 # regardless of how much of the frame they fill; bbox_inches="tight" 

135 # on save keeps it from being clipped off the saved figure. 

136 ax.legend(loc="center left", bbox_to_anchor=(1.02, 0.5), fontsize=8) 

137 ax.set_title(f"subimage bounds: origin=({origin.x}, {origin.y}), {width}x{height}") 

138 

139 plt.savefig(path, dpi=dpi, bbox_inches="tight") 

140 plt.close(fig) 

141 

142 

143def subimage_plot( 

144 *, 

145 image: np.ndarray, 

146 origin: PixelCoordinate, 

147 width: int, 

148 height: int, 

149 path: Path, 

150 dpi: int = 300, 

151) -> None: 

152 """Save a figure of the subimage itself, in its own local reference frame. 

153 

154 Extracts the `width` x `height` region at `origin` (via `subimage()`) 

155 and plots just that result, labeled with its own local pixel 

156 coordinates — `(0, 0)` at its own top-left corner — rather than 

157 `image`'s coordinates. An `'o'` marker is drawn at that local origin 

158 `(0, 0)`, matching the red origin marker `subimage_bounds_plot()` 

159 draws at the same subimage in `image`'s reference frame. Unlike 

160 `subimage_bounds_plot()`, which shows where the region falls relative 

161 to `image`, this shows what the extracted result actually looks like, 

162 including any zero (black) padding from parts of the region that fell 

163 outside `image`. 

164 

165 Args: 

166 image: Source 2D grayscale image array. 

167 origin: Top-left corner of the region, in `image`'s pixel 

168 reference frame; see `subimage()`. 

169 width: Width of the region in pixels. Must be >= 1. 

170 height: Height of the region in pixels. Must be >= 1. 

171 path: Output file path for the figure; format is inferred from 

172 the extension by matplotlib's savefig (e.g. .png), not 

173 dictk's own write/write_svg. 

174 dpi: Resolution of the saved figure. 

175 

176 Raises: 

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

178 """ 

179 region = subimage(image=image, origin=origin, width=width, height=height) 

180 

181 # Same margin approach as subimage_bounds_plot(), so the red border 

182 # gets the same small breathing room from the figure edge instead of 

183 # sitting flush against it. 

184 margin = max(width, height) * 0.05 

185 x_min, x_max = -margin, width + margin 

186 y_min, y_max = -margin, height + margin 

187 

188 figsize = ( 

189 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH, 

190 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH, 

191 ) 

192 fig, ax = plt.subplots(figsize=figsize) 

193 ax.imshow(region, cmap="gray", origin="upper", extent=(0, width, height, 0)) 

194 ax.add_patch( 

195 patches.Rectangle( 

196 (0, 0), 

197 width, 

198 height, 

199 edgecolor="red", 

200 facecolor="none", 

201 linewidth=1.5, 

202 ) 

203 ) 

204 ax.plot(0, 0, marker="o", color="red", markersize=6) 

205 

206 ax.set_xlim(x_min, x_max) 

207 ax.set_ylim(y_max, y_min) # inverted: image y increases downward 

208 

209 ax.set_xlabel("x (pixels)") 

210 ax.set_ylabel("y (pixels)") 

211 

212 plt.savefig(path, dpi=dpi, bbox_inches="tight") 

213 plt.close(fig) 

214 

215 

216def subimage_comparison_plot( 

217 *, 

218 image: np.ndarray, 

219 origin: PixelCoordinate, 

220 width: int, 

221 height: int, 

222 path: Path, 

223 point: PixelCoordinate | None = None, 

224 point_color: str = "gold", 

225 point_label: str | None = None, 

226 subimage_label: str | None = None, 

227 color: str = "red", 

228 origin_label: str | None = None, 

229 source_origin_label: str | None = None, 

230 figsize: tuple[float, float] | None = None, 

231 dpi: int = 300, 

232) -> None: 

233 """Save a side-by-side comparison of a subimage's placement and its extraction. 

234 

235 The left panel matches `subimage_bounds_plot()`: `image`'s bounds in 

236 blue, the requested region in `color` (red by default), with an `'o'` 

237 marker at each rectangle's own origin. The right panel shows the 

238 extracted subimage (via `subimage()`) with a `color` border and 

239 origin marker, in its own local reference frame — but drawn using the 

240 *same* axis limits as the left panel, rather than being cropped or 

241 zoomed to the subimage's own size the way `subimage_plot()` is. That 

242 shared scale is what makes the two boxes render at identical size by 

243 construction — same data units per pixel in both panels — rather than 

244 approximating it by sizing each panel's figure independently around 

245 its own content. 

246 

247 Args: 

248 image: Source 2D grayscale image array. 

249 origin: Top-left corner of the region, in `image`'s pixel 

250 reference frame; see `subimage()`. 

251 width: Width of the region in pixels. Must be >= 1. 

252 height: Height of the region in pixels. Must be >= 1. 

253 path: Output file path for the figure; format is inferred from 

254 the extension by matplotlib's savefig (e.g. .png), not 

255 dictk's own write/write_svg. 

256 point: An optional point of interest, in `image`'s pixel reference 

257 frame, marked with a `point_color` dot on both panels — at 

258 `point` itself on the left, and at `point` translated into 

259 the subimage's own local frame (`point` minus `origin`) on 

260 the right, since that's the same physical point expressed in 

261 each panel's own coordinates. 

262 point_color: Matplotlib color name for `point`'s marker. Ignored 

263 if `point` isn't given. 

264 point_label: An optional short text label (e.g. `"P"`) drawn next 

265 to `point`'s marker, on both panels. Ignored if `point` isn't 

266 given. 

267 subimage_label: An optional short label identifying what this 

268 subimage represents (e.g. `"kernel"`), appended to the right 

269 panel's "subimage" title as "subimage (label)". Left as-is 

270 ("subimage") when not given. 

271 color: Matplotlib color name for the subimage's bounding box and 

272 origin marker, on both panels. 

273 origin_label: An optional short text label (e.g. `"K"`) drawn 

274 next to the subimage's own origin marker, on both panels. 

275 source_origin_label: An optional short text label (e.g. `"O"`) 

276 drawn next to `image`'s own origin marker (the blue dot), 

277 on the left panel only -- the right panel has no equivalent 

278 marker for it. 

279 figsize: Optional (width, height) in inches for the saved figure 

280 (both panels combined). By default the canvas is sized from 

281 `image`/the subimage's own data extent (so a small subimage 

282 yields a small figure); pass this to override with a fixed 

283 size instead -- e.g. matplotlib's own default, `(6.4, 4.8)`, 

284 where its default font sizes and line widths look as intended. 

285 dpi: Resolution of the saved figure. 

286 

287 Raises: 

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

289 """ 

290 if width < 1: 

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

292 if height < 1: 

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

294 

295 region = subimage(image=image, origin=origin, width=width, height=height) 

296 image_height, image_width = image.shape 

297 

298 margin = max(width, height, image_width, image_height) * 0.05 

299 # Small up-right offset for marker labels, so label text doesn't sit 

300 # directly on top of its own marker. 

301 label_offset = max(image_width, image_height) * 0.03 

302 label_font_size = 12 

303 # source_origin_label/origin_label sit just up-right of a marker at 

304 # (0, 0) -- the top-left corner of the panel's own content -- so only 

305 # the top margin (where a label there would land, above the panel 

306 # title) needs widening to fit one; left/right/bottom stay at the 

307 # default margin instead of also carrying that extra, unused space. 

308 # Anchored so the label's own bottom edge sits `label_offset` above 

309 # the marker (matching the gap already below the marker, down to the 

310 # box edge at y=0) and its top edge sits that same `label_offset` 

311 # below the panel's own top border -- the same gap on both sides of 

312 # the label, rather than a fixed multiple of the font size that 

313 # doesn't relate to the marker's own gap below. 

314 top_margin = margin 

315 if source_origin_label is not None or origin_label is not None: 

316 label_text_height = label_font_size / 72 * _FIGURE_PIXELS_PER_INCH 

317 top_margin = max(margin, 2 * label_offset + label_text_height) 

318 x_min = min(0, origin.x) - margin 

319 x_max = max(image_width, origin.x + width) + margin 

320 y_min = min(0, origin.y) - top_margin 

321 y_max = max(image_height, origin.y + height) + margin 

322 

323 # A thin white outline keeps every marker label legible against 

324 # whatever's directly behind it in the image content. 

325 label_outline = [patheffects.withStroke(linewidth=1, foreground="white")] 

326 

327 if figsize is None: 

328 panel_width = (x_max - x_min) / _FIGURE_PIXELS_PER_INCH 

329 panel_height = (y_max - y_min) / _FIGURE_PIXELS_PER_INCH 

330 figsize = (panel_width * 2, panel_height) 

331 fig, (ax_left, ax_right) = plt.subplots( 

332 1, 2, figsize=figsize, constrained_layout=True 

333 ) 

334 

335 ax_left.imshow( 

336 image, cmap="gray", origin="upper", extent=(0, image_width, image_height, 0) 

337 ) 

338 ax_left.add_patch( 

339 patches.Rectangle( 

340 (0, 0), 

341 image_width, 

342 image_height, 

343 edgecolor="blue", 

344 facecolor="none", 

345 linewidth=1.5, 

346 ) 

347 ) 

348 ax_left.add_patch( 

349 patches.Rectangle( 

350 (origin.x, origin.y), 

351 width, 

352 height, 

353 edgecolor=color, 

354 facecolor="none", 

355 linewidth=1.5, 

356 ) 

357 ) 

358 ax_left.plot(0, 0, marker="o", color="blue", markersize=6) 

359 ax_left.plot(origin.x, origin.y, marker="o", color=color, markersize=6) 

360 if point is not None: 

361 ax_left.plot(point.x, point.y, marker="o", color=point_color, markersize=6) 

362 if source_origin_label is not None: 

363 ax_left.text( 

364 label_offset, 

365 -label_offset, 

366 source_origin_label, 

367 color="blue", 

368 fontsize=label_font_size, 

369 va="bottom", 

370 path_effects=label_outline, 

371 ) 

372 if origin_label is not None: 

373 ax_left.text( 

374 origin.x + label_offset, 

375 origin.y - label_offset, 

376 origin_label, 

377 color=color, 

378 fontsize=label_font_size, 

379 va="bottom", 

380 path_effects=label_outline, 

381 ) 

382 if point is not None and point_label is not None: 

383 ax_left.text( 

384 point.x + label_offset, 

385 point.y - label_offset, 

386 point_label, 

387 color=point_color, 

388 fontsize=12, 

389 path_effects=label_outline, 

390 ) 

391 ax_left.set_xlim(x_min, x_max) 

392 ax_left.set_ylim(y_max, y_min) # inverted: image y increases downward 

393 ax_left.set_xlabel("x (pixels)") 

394 ax_left.set_ylabel("y (pixels)") 

395 ax_left.set_title("source image + subimage") 

396 

397 ax_right.imshow(region, cmap="gray", origin="upper", extent=(0, width, height, 0)) 

398 ax_right.add_patch( 

399 patches.Rectangle( 

400 (0, 0), 

401 width, 

402 height, 

403 edgecolor=color, 

404 facecolor="none", 

405 linewidth=1.5, 

406 ) 

407 ) 

408 ax_right.plot(0, 0, marker="o", color=color, markersize=6) 

409 if point is not None: 

410 ax_right.plot( 

411 point.x - origin.x, 

412 point.y - origin.y, 

413 marker="o", 

414 color=point_color, 

415 markersize=6, 

416 ) 

417 if origin_label is not None: 

418 ax_right.text( 

419 label_offset, 

420 -label_offset, 

421 origin_label, 

422 color=color, 

423 fontsize=label_font_size, 

424 va="bottom", 

425 path_effects=label_outline, 

426 ) 

427 if point is not None and point_label is not None: 

428 ax_right.text( 

429 point.x - origin.x + label_offset, 

430 point.y - origin.y - label_offset, 

431 point_label, 

432 color=point_color, 

433 fontsize=12, 

434 path_effects=label_outline, 

435 ) 

436 # Same limits as ax_left, not the subimage's own tight size -- this is 

437 # the whole point: identical data-units-per-pixel in both panels. 

438 ax_right.set_xlim(x_min, x_max) 

439 ax_right.set_ylim(y_max, y_min) 

440 ax_right.set_xlabel("x (pixels)") 

441 ax_right.set_ylabel("y (pixels)") 

442 ax_right.set_title( 

443 f"subimage ({subimage_label})" if subimage_label is not None else "subimage" 

444 ) 

445 

446 plt.savefig(path, dpi=dpi, bbox_inches="tight") 

447 plt.close(fig) 

448 

449 

450class ArrowAnnotation(NamedTuple): 

451 """A labeled, colored arrow to overlay on an image, from `tail` to `head`. 

452 

453 Attributes: 

454 tail: Arrow's starting point, in the image's pixel reference frame. 

455 head: Arrow's ending point, in the image's pixel reference frame. 

456 color: Matplotlib color name for the arrow and its legend entry. 

457 label: Legend label for the arrow. 

458 """ 

459 

460 tail: PixelCoordinate 

461 head: PixelCoordinate 

462 color: str 

463 label: str 

464 

465 

466class BoxAnnotation(NamedTuple): 

467 """A labeled, colored rectangle (with an origin marker) to overlay on an image. 

468 

469 Attributes: 

470 origin: Top-left corner of the rectangle, in the image's pixel 

471 reference frame; see `subimage()`. 

472 width: Width of the rectangle in pixels. 

473 height: Height of the rectangle in pixels. 

474 color: Matplotlib color name for the rectangle, its origin 

475 marker, and its legend entry. 

476 label: Legend label for the rectangle. 

477 """ 

478 

479 origin: PixelCoordinate 

480 width: int 

481 height: int 

482 color: str 

483 label: str 

484 

485 

486class PointAnnotation(NamedTuple): 

487 """A short colored text label to draw at a point on an image, with no marker of its own. 

488 

489 Meant for labeling a point that already has its own marker drawn some 

490 other way (e.g. an `ArrowAnnotation`'s head/tail, or a `BoxAnnotation`'s 

491 origin) with a short symbol, like `"$P$"`. 

492 

493 Attributes: 

494 position: Location of the label, in the image's pixel reference frame. 

495 label: Short text to draw (e.g. `"$P$"`); LaTeX math (`$...$`) is 

496 rendered via matplotlib's built-in mathtext. 

497 color: Matplotlib color name for the label text. 

498 """ 

499 

500 position: PixelCoordinate 

501 label: str 

502 color: str 

503 

504 

505def point_plot( 

506 *, 

507 image: np.ndarray, 

508 arrows: Sequence[ArrowAnnotation], 

509 boxes: Sequence[BoxAnnotation] = (), 

510 points: Sequence[PointAnnotation] = (), 

511 legend: bool = True, 

512 figsize: tuple[float, float] | None = None, 

513 path: Path, 

514 dpi: int = 300, 

515) -> None: 

516 """Save a figure overlaying one or more labeled arrows (and boxes) on `image`. 

517 

518 Each `ArrowAnnotation` draws a straight arrow from `tail` to `head`, in 

519 `image`'s own pixel reference frame — e.g. from the origin `(0, 0)` to a 

520 point of interest, or between two points to show a displacement. Each 

521 `BoxAnnotation` draws a rectangle with an `'o'` marker at its own 

522 origin, layered behind the arrows but in front of `image` -- e.g. to 

523 show where a kernel or search area sits on a figure that's mainly 

524 about the arrows drawn on top of it. Each `PointAnnotation` draws a 

525 short text label (with a thin white outline, so it stays legible 

526 against the image) next to a point already marked some other way. 

527 

528 Args: 

529 image: Source 2D grayscale image array. 

530 arrows: One or more arrows to overlay, each with its own color and 

531 legend label. 

532 boxes: Zero or more rectangles to overlay underneath the arrows, 

533 each with its own color and legend label. 

534 points: Zero or more short text labels to draw, each next to a 

535 point already marked by an arrow or box elsewhere in the 

536 figure. 

537 legend: Whether to draw the arrow/box legend. Set to False when 

538 the arrow/box labels are already explained elsewhere (e.g. a 

539 figure caption) and would just clutter the figure. 

540 figsize: Optional (width, height) in inches for the saved figure. 

541 By default the canvas is sized from the annotations' own 

542 data extent (so a small image yields a small figure); pass 

543 this to override with a fixed size instead -- e.g. 

544 matplotlib's own default, `(6.4, 4.8)`, where its default 

545 font sizes and line widths look as intended. 

546 path: Output file path for the figure; format is inferred from the 

547 extension by matplotlib's savefig (e.g. .png), not dictk's own 

548 write/write_svg. 

549 dpi: Resolution of the saved figure. 

550 

551 Raises: 

552 ValueError: If `arrows` is empty. 

553 """ 

554 if not arrows: 

555 raise ValueError("arrows must not be empty") 

556 

557 image_height, image_width = image.shape 

558 

559 endpoints_x = [pt.x for arrow in arrows for pt in (arrow.tail, arrow.head)] 

560 endpoints_y = [pt.y for arrow in arrows for pt in (arrow.tail, arrow.head)] 

561 for box in boxes: 

562 endpoints_x += [box.origin.x, box.origin.x + box.width] 

563 endpoints_y += [box.origin.y, box.origin.y + box.height] 

564 endpoints_x += [point.position.x for point in points] 

565 endpoints_y += [point.position.y for point in points] 

566 margin = max(image_width, image_height) * 0.05 

567 # Small up-right offset for point labels, so label text doesn't sit 

568 # directly on top of its own marker. 

569 label_offset = max(image_width, image_height) * 0.03 

570 label_font_size = 12 

571 # Point labels sit just up-right of their marker, so only the top 

572 # margin (where a label on a point near y=0 would land, above the 

573 # title) needs widening to fit one; left/right/bottom stay at the 

574 # default margin instead of also carrying that extra, unused space. 

575 # Anchored so the label's own bottom edge sits `label_offset` above 

576 # its marker (matching the gap already below the marker, e.g. down 

577 # to a box edge at y=0) and its top edge sits that same 

578 # `label_offset` below the figure's own top border -- the same gap 

579 # on both sides of the label, rather than a fixed multiple of the 

580 # font size that doesn't relate to the marker's own gap below. 

581 top_margin = margin 

582 if points: 

583 label_text_height = label_font_size / 72 * _FIGURE_PIXELS_PER_INCH 

584 top_margin = max(margin, 2 * label_offset + label_text_height) 

585 x_min = min(0, *endpoints_x) - margin 

586 x_max = max(image_width, *endpoints_x) + margin 

587 y_min = min(0, *endpoints_y) - top_margin 

588 y_max = max(image_height, *endpoints_y) + margin 

589 label_outline = [patheffects.withStroke(linewidth=1, foreground="white")] 

590 

591 if figsize is None: 

592 figsize = ( 

593 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH, 

594 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH, 

595 ) 

596 fig, ax = plt.subplots(figsize=figsize) 

597 ax.imshow( 

598 image, cmap="gray", origin="upper", extent=(0, image_width, image_height, 0) 

599 ) 

600 

601 for box in boxes: 

602 ax.add_patch( 

603 patches.Rectangle( 

604 (box.origin.x, box.origin.y), 

605 box.width, 

606 box.height, 

607 edgecolor=box.color, 

608 facecolor="none", 

609 linewidth=1.5, 

610 ) 

611 ) 

612 ax.plot(box.origin.x, box.origin.y, marker="o", color=box.color, markersize=6) 

613 

614 for arrow in arrows: 

615 ax.annotate( 

616 "", 

617 xy=(arrow.head.x, arrow.head.y), 

618 xytext=(arrow.tail.x, arrow.tail.y), 

619 arrowprops={ 

620 "color": arrow.color, 

621 "width": 1.5, 

622 "headwidth": 8, 

623 "shrink": 0, 

624 }, 

625 ) 

626 

627 # Drawn last (on top of the arrows), so a label sitting where an arrow 

628 # starts or ends doesn't get partly covered by the arrow itself. 

629 for point in points: 

630 ax.text( 

631 point.position.x + label_offset, 

632 point.position.y - label_offset, 

633 point.label, 

634 color=point.color, 

635 fontsize=label_font_size, 

636 va="bottom", 

637 path_effects=label_outline, 

638 ) 

639 # ax.annotate's arrows don't register with the legend on their own, so 

640 # proxy line handles stand in for each arrow's (and box's) color/label. 

641 handles = [ 

642 plt.Line2D([0], [0], color=arrow.color, lw=1.5, label=arrow.label) 

643 for arrow in arrows 

644 ] + [ 

645 patches.Patch(edgecolor=box.color, facecolor="none", label=box.label) 

646 for box in boxes 

647 ] 

648 

649 ax.set_xlim(x_min, x_max) 

650 ax.set_ylim(y_max, y_min) # inverted: image y increases downward 

651 ax.set_xlabel("x (pixels)") 

652 ax.set_ylabel("y (pixels)") 

653 if legend: 

654 # Same fully-outside legend placement as subimage_bounds_plot(), so 

655 # it never overlaps the image regardless of arrow placement. 

656 ax.legend( 

657 handles=handles, loc="center left", bbox_to_anchor=(1.02, 0.5), fontsize=8 

658 ) 

659 

660 plt.savefig(path, dpi=dpi, bbox_inches="tight") 

661 plt.close(fig) 

662 

663 

664def reference_frame_plot(*, image: np.ndarray, path: Path, dpi: int = 300) -> None: 

665 """Save a side-by-side figure contrasting `image` with its reference frame made explicit. 

666 

667 Left panel: `image` alone, with no annotation — no axes box, ticks, or 

668 labels either — a reminder that a pixel reference frame is always 

669 implicitly present, even when nothing is drawn to show it. Right 

670 panel: the same `image`, with its bounds outlined in blue and a blue 

671 'o' marker at its origin `(0, 0)` (top-left corner) — the same style 

672 `subimage_bounds_plot()` uses for a source image — plus a red arrow 

673 along the x-axis and a green arrow along the y-axis, both starting 

674 from that origin, making the top-left-origin, y-increases-downward 

675 pixel convention used throughout this codebase explicit, labeled with 

676 the calligraphic frame symbol "F" just above and to the left of the 

677 origin marker. The "x"/"y" axis labels sit in the margin outside the 

678 blue frame, rather than on top of it, so they don't overlap its 

679 border. 

680 

681 Args: 

682 image: Source 2D grayscale image array. 

683 path: Output file path for the figure; format is inferred from 

684 the extension by matplotlib's savefig (e.g. .png), not 

685 dictk's own write/write_svg. 

686 dpi: Resolution of the saved figure. 

687 """ 

688 image_height, image_width = image.shape 

689 axis_length = min(image_width, image_height) * 0.2 

690 

691 margin = max(image_width, image_height) * 0.05 

692 # The "x"/"y" labels need enough margin to sit clear of both the blue 

693 # frame and the axes spine; the label's rendered size only depends on 

694 # its font size and the data-units-per-inch scale figsize is built 

695 # from, not on image size, so a small image's default margin (5% of 

696 # its own dimensions) can otherwise be too little to fit it. 

697 label_font_size = 12 

698 frame_label_font_size = 14 

699 min_margin_for_labels = ( 

700 max(label_font_size, frame_label_font_size) * 2.2 / 72 * _FIGURE_PIXELS_PER_INCH 

701 ) 

702 margin = max(margin, min_margin_for_labels) 

703 

704 x_min, x_max = -margin, image_width + margin 

705 y_min, y_max = -margin, image_height + margin 

706 

707 panel_width = (x_max - x_min) / _FIGURE_PIXELS_PER_INCH 

708 panel_height = (y_max - y_min) / _FIGURE_PIXELS_PER_INCH 

709 fig, (ax_left, ax_right) = plt.subplots( 

710 1, 2, figsize=(panel_width * 2, panel_height), constrained_layout=True 

711 ) 

712 

713 for ax in (ax_left, ax_right): 

714 ax.imshow( 

715 image, 

716 cmap="gray", 

717 origin="upper", 

718 extent=(0, image_width, image_height, 0), 

719 ) 

720 ax.set_xlim(x_min, x_max) 

721 ax.set_ylim(y_max, y_min) # inverted: image y increases downward 

722 

723 ax_left.set_title("image") 

724 ax_left.axis("off") 

725 

726 ax_right.set_xlabel("x (pixels)") 

727 ax_right.set_ylabel("y (pixels)") 

728 

729 ax_right.add_patch( 

730 patches.Rectangle( 

731 (0, 0), 

732 image_width, 

733 image_height, 

734 edgecolor="blue", 

735 facecolor="none", 

736 linewidth=1.5, 

737 ) 

738 ) 

739 ax_right.plot(0, 0, marker="o", color="blue", markersize=8) 

740 

741 # Frame label sits just above and to the left of the origin marker, 

742 # in the empty diagonal corner the x-/y-axis arrows leave untouched. 

743 frame_label_offset = margin * 0.35 

744 ax_right.text( 

745 -frame_label_offset, 

746 -frame_label_offset, 

747 r"$\mathcal{F}$", 

748 color="blue", 

749 fontsize=frame_label_font_size, 

750 ha="right", 

751 va="bottom", 

752 ) 

753 

754 # Labels sit centered in the margin strip between the blue frame and 

755 # the axes spine, rather than at the arrowhead itself, so they don't 

756 # touch either (the arrows run flush along the top/left edges of the 

757 # frame's border). 

758 label_offset = margin / 2 

759 for axis_head, label_pos, color, label, ha, va in ( 

760 ( 

761 (axis_length, 0), 

762 (axis_length, -label_offset), 

763 "red", 

764 "x", 

765 "center", 

766 "bottom", 

767 ), 

768 ( 

769 (0, axis_length), 

770 (-label_offset, axis_length), 

771 "green", 

772 "y", 

773 "right", 

774 "center", 

775 ), 

776 ): 

777 ax_right.annotate( 

778 "", 

779 xy=axis_head, 

780 xytext=(0, 0), 

781 arrowprops={"color": color, "width": 1.5, "headwidth": 8, "shrink": 0}, 

782 ) 

783 ax_right.text( 

784 *label_pos, label, color=color, fontsize=label_font_size, ha=ha, va=va 

785 ) 

786 

787 ax_right.set_title("reference frame") 

788 

789 plt.savefig(path, dpi=dpi, bbox_inches="tight") 

790 plt.close(fig) 

791 

792 

793def histogram_save(*, arr: np.ndarray, path: Path, dpi: int = 300) -> None: 

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

795 

796 Args: 

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

798 path: The output file path for the histogram image; format is 

799 inferred from the extension by matplotlib's savefig (e.g. 

800 .png), not dictk's own write/write_svg. 

801 dpi: Resolution of the saved figure. 

802 """ 

803 plt.figure() 

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

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

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

807 plt.ylabel("frequency") 

808 plt.savefig(path, dpi=dpi) 

809 plt.close() 

810 

811 

812def _correlation_surface_ticks(size: int) -> list[int]: 

813 """Fixed Correlation Surface tick marks for an axis of length `size`. 

814 

815 Correlation Visualization shows this panel seven times: CC/NCC/ZCC/ 

816 ZNCC's 51x51 "valid" surfaces, and phase correlation's three 

817 same-shape-as-`search` 100x100 ones (no windowing/Hann/Hamming). 

818 Rather than matplotlib's own per-panel default (which lands on 

819 different steps for the two sizes, and doesn't always reach the 

820 axis's own upper bound), each of those two known sizes gets one 

821 fixed, round-number tick list spanning its own full extent -- so 

822 every 51x51 panel matches every other 51x51 panel, and every 100x100 

823 one matches every other 100x100 one, when read side by side. 

824 

825 Any other `size` (e.g. Recoverable Displacement Range's 300x300 

826 quadrant figures, `search_margin=150` against `astronaut0`'s own 

827 300px canvas) falls back to a generated five-interval, round-number 

828 step instead of matplotlib's own default -- the original problem 

829 this function exists to fix in the first place. Without this 

830 fallback, every `size` other than the two known ones used to 

831 silently reuse the 100-wide list regardless of its own actual 

832 extent, cramming every tick into the axis's first 100 pixels. 

833 

834 Args: 

835 size: The axis's length in pixels (a Correlation Surface panel's 

836 own height or width). 

837 

838 Returns: 

839 `[0, 10, 20, 30, 40, 50]` for the 51-wide "valid" surfaces, 

840 `[0, 20, 40, 60, 80, 100]` for the 100-wide full-search ones, 

841 otherwise five evenly-spaced ticks from 0 up to (as close as a 

842 multiple-of-10 step allows) `size` itself. 

843 """ 

844 if size == 51: 

845 return [0, 10, 20, 30, 40, 50] 

846 if size == 100: 

847 return [0, 20, 40, 60, 80, 100] 

848 step = max(10, round(size / 5 / 10) * 10) 

849 return list(range(0, size + 1, step)) 

850 

851 

852def _correlation_quadrant_plot( 

853 *, 

854 kernel: np.ndarray, 

855 search: np.ndarray, 

856 correlation_surface: np.ndarray, 

857 title: str, 

858 path: Path, 

859 figsize: tuple[float, float] = (8.0, 8.0), 

860 dpi: int = 300, 

861 vicinity_margin: int = 4, 

862 reported_position: PixelCoordinate | None = None, 

863 reported_position_label: str = "reported", 

864 centered: bool = False, 

865) -> None: 

866 """Shared renderer behind spatial_correlation_quadrant_plot() and 

867 phase_correlation_quadrant_plot() -- private (never published by 

868 pdoc), so those two public functions each carry their own complete 

869 docstring rather than pointing here. 

870 

871 Draws the Fixed Image / Moving Image / correlation surface / Solution 

872 Vicinity 2x2 layout for whatever `correlation_surface` array it's 

873 given. Shape-agnostic on purpose: works identically whether the 

874 surface came from a spatial-domain "valid" computation (smaller than 

875 `search`) or a same-shape-as-`search` Fourier-domain one -- it only 

876 ever takes that array's own argmax and plots whatever shape comes in. 

877 

878 `reported_position`, if given, is a second position in `search`'s own 

879 local frame (same top-left-corner convention as the surface's own 

880 peak) -- some other, external computation's *claimed* answer, which 

881 may differ from where `correlation_surface` itself actually peaks. 

882 Drawn as a second, dotted magenta box on the Fixed Image panel, 

883 distinct from the surface's own yellow dashed one, with a legend 

884 naming both. `None` (default) omits it entirely, leaving every 

885 existing figure byte-identical to before this parameter existed. 

886 

887 `centered`, if `True`, pads the Moving Image panel's `kernel` display 

888 the same way `dictk.translation.locate` does internally (via 

889 `_kernel_pad(..., centered=True)`) instead of the permanent 

890 bottom-right-only padding `phase_correlation` itself always uses (see 

891 [Recoverable Displacement 

892 Range](../../getting_started/recoverable_displacement_range.html) for 

893 why those two conventions differ). `correlation_surface`'s own raw 

894 `argmax` is always relative to *that* padding, so with centered 

895 padding the Fixed Image panel's box needs `kernel_padded`'s own 

896 content offset added back in to land on the true match position -- 

897 `correlation_surface`'s peak itself (and the Correlation Surface/ 

898 Solution Vicinity panels showing it) is unaffected, still the raw 

899 array position. Default `False` reproduces the original bottom-right 

900 padding exactly, byte-identical to every figure from before this 

901 parameter existed. 

902 """ 

903 if search.shape[0] < kernel.shape[0] or search.shape[1] < kernel.shape[1]: 

904 raise ValueError( 

905 f"search shape {search.shape} must be >= kernel shape {kernel.shape} " 

906 "in both dimensions" 

907 ) 

908 

909 search_height, search_width = search.shape 

910 kernel_height, kernel_width = kernel.shape 

911 

912 peak_y, peak_x = np.unravel_index( 

913 np.argmax(correlation_surface), correlation_surface.shape 

914 ) 

915 peak_y, peak_x = int(peak_y), int(peak_x) 

916 

917 if centered: 

918 # Same padding dictk.translation.locate() applies internally -- 

919 # kernel_padded's own content no longer starts at (0, 0), so the 

920 # Fixed Image panel's box needs that offset added back in to the 

921 # surface's own raw peak to land on the true match position. 

922 kernel_padded, pad_before_height, pad_before_width = _kernel_pad( 

923 kernel=kernel, shape=search.shape, centered=True 

924 ) 

925 box_x = (peak_x + pad_before_width) % search_width 

926 box_y = (peak_y + pad_before_height) % search_height 

927 else: 

928 # Same bottom/right zero-padding dictk.translation.locate() applied 

929 # before its own fix, here purely for display so the kernel's 

930 # content sits at the correct corner of a search-shaped canvas. 

931 kernel_padded = np.pad( 

932 kernel, 

933 ((0, search_height - kernel_height), (0, search_width - kernel_width)), 

934 ) 

935 box_x, box_y = peak_x, peak_y 

936 

937 with plt.rc_context({"font.family": "serif", "mathtext.fontset": "cm"}): 

938 fig, axes = plt.subplots(2, 2, figsize=figsize, constrained_layout=True) 

939 fig.suptitle(title) 

940 ax1, ax2, ax3, ax4 = axes.flat 

941 

942 im1 = ax1.imshow( 

943 search, 

944 cmap="gray", 

945 vmin=0, 

946 vmax=255, 

947 origin="upper", 

948 extent=(0, search_width, search_height, 0), 

949 ) 

950 plt.colorbar(im1, ax=ax1, shrink=0.8) 

951 ax1.add_patch( 

952 patches.Rectangle( 

953 (box_x, box_y), 

954 kernel_width, 

955 kernel_height, 

956 edgecolor="yellow", 

957 facecolor="none", 

958 linestyle="--", 

959 linewidth=1, 

960 alpha=0.8, 

961 label="correlation surface peak", 

962 ) 

963 ) 

964 ax1.axvline(x=box_x, color="red", linestyle="--", linewidth=1, alpha=0.8) 

965 ax1.axhline(y=box_y, color="green", linestyle="--", linewidth=1, alpha=0.8) 

966 xticks = {0, search_width, box_x} 

967 yticks = {0, search_height, box_y} 

968 if reported_position is not None: 

969 ax1.add_patch( 

970 patches.Rectangle( 

971 (reported_position.x, reported_position.y), 

972 kernel_width, 

973 kernel_height, 

974 edgecolor="magenta", 

975 facecolor="none", 

976 linestyle=":", 

977 linewidth=1.5, 

978 alpha=0.5, 

979 label=reported_position_label, 

980 ) 

981 ) 

982 xticks.add(reported_position.x) 

983 yticks.add(reported_position.y) 

984 ax1.legend(loc="upper right", fontsize=7, framealpha=0.9) 

985 ax1.set_xticks(sorted(xticks)) 

986 ax1.set_yticks(sorted(yticks)) 

987 for label in ax1.get_xticklabels(): 

988 if label.get_text() == str(box_x): 

989 label.set_color("red") 

990 for label in ax1.get_yticklabels(): 

991 if label.get_text() == str(box_y): 

992 label.set_color("green") 

993 ax1.set_title(r"Fixed Image with frame $\mathcal{S}$") 

994 ax1.set_xlabel("x (pixels)") 

995 ax1.set_ylabel("y (pixels)") 

996 

997 im2 = ax2.imshow( 

998 kernel_padded, 

999 cmap="gray", 

1000 vmin=0, 

1001 vmax=255, 

1002 origin="upper", 

1003 extent=(0, search_width, search_height, 0), 

1004 ) 

1005 plt.colorbar(im2, ax=ax2, shrink=0.8) 

1006 if centered: 

1007 kernel_xticks = { 

1008 0, 

1009 search_width, 

1010 pad_before_width, 

1011 pad_before_width + kernel_width, 

1012 } 

1013 kernel_yticks = { 

1014 0, 

1015 search_height, 

1016 pad_before_height, 

1017 pad_before_height + kernel_height, 

1018 } 

1019 ax2.set_title(r"Moving Image with frame $\mathcal{K}$ (centered)") 

1020 else: 

1021 kernel_xticks = {0, search_width, kernel_width} 

1022 kernel_yticks = {0, search_height, kernel_height} 

1023 ax2.set_title(r"Moving Image with frame $\mathcal{K}$") 

1024 ax2.set_xticks(sorted(kernel_xticks)) 

1025 ax2.set_yticks(sorted(kernel_yticks)) 

1026 ax2.set_xlabel("x (pixels)") 

1027 ax2.set_ylabel("y (pixels)") 

1028 

1029 im3 = ax3.imshow(correlation_surface, cmap="viridis", origin="upper") 

1030 plt.colorbar(im3, ax=ax3, shrink=0.8) 

1031 ax3.add_patch( 

1032 patches.Circle( 

1033 (peak_x, peak_y), 

1034 radius=vicinity_margin, 

1035 edgecolor="red", 

1036 facecolor="none", 

1037 linewidth=1.5, 

1038 ) 

1039 ) 

1040 ax3.set_title("Correlation Surface") 

1041 ax3.set_xlabel(r"$\Delta x$ offset (pixels)") 

1042 ax3.set_ylabel(r"$\Delta y$ offset (pixels)") 

1043 # Fixed ticks, not matplotlib's own auto-choice -- see 

1044 # _correlation_surface_ticks() for why. 

1045 surface_height, surface_width = correlation_surface.shape 

1046 ax3.set_xticks(_correlation_surface_ticks(surface_width)) 

1047 ax3.set_yticks(_correlation_surface_ticks(surface_height)) 

1048 

1049 im4 = ax4.imshow(correlation_surface, cmap="viridis", origin="upper") 

1050 plt.colorbar(im4, ax=ax4, shrink=0.8) 

1051 ax4.set_xlim(peak_x - vicinity_margin, peak_x + vicinity_margin) 

1052 ax4.set_ylim(peak_y + vicinity_margin, peak_y - vicinity_margin) 

1053 # Same circle as ax3, same radius, in the same data coordinates -- 

1054 # since this panel is zoomed to exactly peak +/- vicinity_margin, 

1055 # the circle exactly reaches this panel's own edges, appearing 

1056 # clipped by them rather than fully visible as it was in ax3. 

1057 ax4.add_patch( 

1058 patches.Circle( 

1059 (peak_x, peak_y), 

1060 radius=vicinity_margin, 

1061 edgecolor="red", 

1062 facecolor="none", 

1063 linewidth=1.5, 

1064 ) 

1065 ) 

1066 ax4.set_title("Solution Vicinity") 

1067 ax4.set_xlabel(r"$\Delta x$ offset (pixels)") 

1068 ax4.set_ylabel(r"$\Delta y$ offset (pixels)") 

1069 

1070 fig.savefig(path, dpi=dpi) 

1071 plt.close(fig) 

1072 

1073 

1074def spatial_correlation_quadrant_plot( 

1075 *, 

1076 kernel: np.ndarray, 

1077 search: np.ndarray, 

1078 correlation_surface: np.ndarray, 

1079 title: str, 

1080 path: Path, 

1081 figsize: tuple[float, float] = (8.0, 8.0), 

1082 dpi: int = 300, 

1083 vicinity_margin: int = 4, 

1084) -> None: 

1085 r"""Save a 2x2 composite figure illustrating one spatial-domain correlation criterion end to end. 

1086 

1087 Reproduces a reference composite-figure layout used in prior DIC 

1088 tooling -- Fixed Image, Moving Image, the correlation surface, and a 

1089 zoomed Solution Vicinity -- using dictk's own spatial-domain 

1090 correlation surfaces ([`dictk.correlation`](../correlation.html)'s 

1091 `cc`/`ncc`/`zcc`/`zncc`) rather than a zero-padded whole-image FFT 

1092 approach. See 

1093 [`phase_correlation_quadrant_plot`](#phase_correlation_quadrant_plot) 

1094 for the Fourier-domain sibling of this function. 

1095 `correlation_surface`'s own argmax directly gives the kernel's found 

1096 offset within `search`'s own frame $\mathcal{S}$ -- the same 

1097 $\boldsymbol{r}_{SK/\mathcal{S}}$ quantity [Cross Correlation 

1098 (CC)](../../getting_started/cross_correlation.html) walks through by 

1099 hand -- so no separate found-position argument is needed the way that 

1100 prior tooling's own composite-figure function takes one. 

1101 

1102 Top-left panel: `search`, in its own local frame $\mathcal{S}$, with a 

1103 yellow dashed box marking where `kernel` was found, plus red/green 

1104 dashed guide lines through that box's origin (and matching red/green 

1105 tick labels at that position). Top-right panel: `kernel` zero-padded 

1106 (bottom and right) up to `search`'s own shape -- the same padding 

1107 [`dictk.translation.locate`](../translation.html#locate) does 

1108 internally -- so its content occupies only the top-left corner of an 

1109 otherwise-black canvas the size of `search`, labeled frame 

1110 $\mathcal{K}$. Bottom-left: `correlation_surface` as a heatmap, peak 

1111 marked with a red circle of radius `vicinity_margin`. Bottom-right: 

1112 the same surface, zoomed to exactly that same `vicinity_margin` 

1113 pixels around its own peak -- the same circle reappears there too, 

1114 now clipped by the panel's own edges, since the zoom window is 

1115 exactly the circle's own bounding box. 

1116 

1117 Text renders via matplotlib's built-in mathtext with a Computer-Modern 

1118 -style serif font (`mathtext.fontset="cm"`), not real LaTeX 

1119 (`text.usetex`) -- visually close to a real-LaTeX-rendered figure 

1120 without a system TeX install, scoped to this function alone via 

1121 `rc_context` so it can't leak into any other figure. 

1122 

1123 Args: 

1124 kernel: The extracted kernel subimage (2D grayscale array). 

1125 search: The extracted search-area subimage (2D grayscale array); 

1126 must be at least as large as `kernel` in both dimensions. 

1127 correlation_surface: One of `dictk.correlation`'s `cc`/`ncc`/`zcc`/ 

1128 `zncc` surfaces, computed from this same `kernel`/`search` 

1129 pair. Its own argmax is taken as the found position. 

1130 title: Figure-level title naming the correlation criterion shown, 

1131 e.g. `"Zero-mean Normalized Cross-Correlation (ZNCC)"` -- 

1132 rendered as a `suptitle` spanning the full figure width rather 

1133 than the correlation-surface panel's own title, since panel 

1134 titles are too narrow to reliably fit the longer criterion 

1135 names without truncating or overlapping their colorbar. 

1136 path: Output file path for the figure; format is inferred from the 

1137 extension by matplotlib's savefig (e.g. .png). 

1138 figsize: (width, height) in inches for the saved figure. 

1139 dpi: Resolution of the saved figure. 

1140 vicinity_margin: Half-width/height, in pixels, of the Solution 

1141 Vicinity zoom window around the correlation surface's peak. 

1142 

1143 Raises: 

1144 ValueError: If `search` is smaller than `kernel` in either 

1145 dimension. 

1146 """ 

1147 _correlation_quadrant_plot( 

1148 kernel=kernel, 

1149 search=search, 

1150 correlation_surface=correlation_surface, 

1151 title=title, 

1152 path=path, 

1153 figsize=figsize, 

1154 dpi=dpi, 

1155 vicinity_margin=vicinity_margin, 

1156 ) 

1157 

1158 

1159def phase_correlation_quadrant_plot( 

1160 *, 

1161 kernel: np.ndarray, 

1162 search: np.ndarray, 

1163 windowing: WindowingMethod | None = None, 

1164 title: str = "Phase Correlation", 

1165 path: Path, 

1166 figsize: tuple[float, float] = (8.0, 8.0), 

1167 dpi: int = 300, 

1168 vicinity_margin: int = 4, 

1169 reported_position: PixelCoordinate | None = None, 

1170 reported_position_label: str = "reported", 

1171 centered: bool = False, 

1172) -> None: 

1173 r"""Save a 2x2 composite figure illustrating Fourier-domain phase correlation end to end. 

1174 

1175 The Fourier-domain sibling of 

1176 [`spatial_correlation_quadrant_plot`](#spatial_correlation_quadrant_plot), 

1177 sharing that function's exact panel layout (Fixed Image, Moving Image, 

1178 correlation surface, zoomed Solution Vicinity). Unlike its sibling, 

1179 this function takes raw `kernel`/`search` rather than a pre-computed 

1180 surface: spatial-domain correlation has four interchangeable criteria 

1181 (`cc`/`ncc`/`zcc`/`zncc`) a caller must choose between and compute 

1182 themselves, but there is only one Fourier-domain flavor here, so this 

1183 computes it internally via 

1184 [`dictk.correlation.phase_correlation`](../correlation.html#phase_correlation) 

1185 -- see that function's own docstring for the algorithm itself and why 

1186 it lands in the same "robust to both brightness and contrast" tier as 

1187 `zncc`, by a completely different mechanism. 

1188 

1189 Top-left panel: `search`, in its own local frame $\mathcal{S}$, with a 

1190 yellow dashed box marking where `kernel` was found, plus red/green 

1191 dashed guide lines through that box's origin (and matching red/green 

1192 tick labels at that position). Top-right panel: `kernel` zero-padded 

1193 (bottom and right) up to `search`'s own shape -- the same padding 

1194 [`dictk.translation.locate`](../translation.html#locate) does 

1195 internally -- so its content occupies only the top-left corner of an 

1196 otherwise-black canvas the size of `search`, labeled frame 

1197 $\mathcal{K}$. Bottom-left: the phase correlation surface as a 

1198 heatmap, peak marked with a red circle of radius `vicinity_margin`. 

1199 Bottom-right: the same surface, zoomed to exactly that same 

1200 `vicinity_margin` pixels around its own peak -- the same circle 

1201 reappears there too, now clipped by the panel's own edges, since the 

1202 zoom window is exactly the circle's own bounding box. 

1203 

1204 Text renders via matplotlib's built-in mathtext with a Computer-Modern 

1205 -style serif font (`mathtext.fontset="cm"`), not real LaTeX 

1206 (`text.usetex`) -- visually close to a real-LaTeX-rendered figure 

1207 without a system TeX install, scoped to this function alone via 

1208 `rc_context` so it can't leak into any other figure. 

1209 

1210 Args: 

1211 kernel: The extracted kernel subimage (2D grayscale array). 

1212 search: The extracted search-area subimage (2D grayscale array); 

1213 must be at least as large as `kernel` in both dimensions. 

1214 windowing: If given, passed straight through to 

1215 [`phase_correlation`](../correlation.html#phase_correlation) -- 

1216 tapers `kernel`/`search` before computing the surface. The 

1217 same tapered `kernel`/`search` are what the Fixed Image and 

1218 Moving Image panels display too (windowed, *then* zero-padded 

1219 for the Moving Image panel, same order the surface itself is 

1220 computed in), so those panels always show what was actually 

1221 correlated -- not a stale, untapered view next to a surface 

1222 that no longer matches it. Default `None` applies no 

1223 windowing, and the panels look exactly as they always have. 

1224 title: Figure-level title, rendered as a `suptitle` spanning the 

1225 full figure width. Defaults to `"Phase Correlation"` since 

1226 there's only one flavor here -- override if different phrasing 

1227 is wanted. 

1228 path: Output file path for the figure; format is inferred from the 

1229 extension by matplotlib's savefig (e.g. .png). 

1230 figsize: (width, height) in inches for the saved figure. 

1231 dpi: Resolution of the saved figure. 

1232 vicinity_margin: Half-width/height, in pixels, of the Solution 

1233 Vicinity zoom window around the correlation surface's peak. 

1234 reported_position: A second position, in `search`'s own local 

1235 frame (same top-left-corner convention as the surface's own 

1236 peak), to mark on the Fixed Image panel as a dotted magenta 

1237 box distinct from the surface's own yellow dashed one -- 

1238 some other, external computation's *claimed* answer, useful 

1239 when that answer might disagree with where this surface 

1240 itself actually peaks (e.g. [Recoverable Displacement 

1241 Range](../../getting_started/recoverable_displacement_range.html)'s 

1242 pre-fix `locate` reporting a wrapped, wrong position even 

1243 though the underlying surface it was computed from peaks at 

1244 the correct one). Default `None` omits it entirely, leaving 

1245 every figure that doesn't pass it byte-identical to before 

1246 this parameter existed. 

1247 reported_position_label: Legend label for `reported_position`'s 

1248 box, shown alongside "correlation surface peak" for the 

1249 existing yellow one. Only rendered (and only then does a 

1250 legend appear at all) when `reported_position` is given. 

1251 centered: Passed straight through to 

1252 [`phase_correlation`](../correlation.html#phase_correlation)'s 

1253 own `centered` parameter -- the same convention 

1254 `dictk.translation.locate` uses internally. The Moving Image 

1255 panel's padding, and the Fixed Image panel's box position, 

1256 follow suit (see `_correlation_quadrant_plot`'s own note on 

1257 why the box needs the padding offset added back in). Default 

1258 `False` matches `phase_correlation`'s own default exactly, 

1259 byte-identical to every figure from before this parameter 

1260 existed. 

1261 

1262 Raises: 

1263 ValueError: If `search` is smaller than `kernel` in either 

1264 dimension. 

1265 """ 

1266 correlation_surface = phase_correlation( 

1267 kernel=kernel, search=search, windowing=windowing, centered=centered 

1268 ) 

1269 display_kernel, display_search = kernel, search 

1270 if windowing is not None: 

1271 display_kernel = window(arr=kernel, method=windowing) 

1272 display_search = window(arr=search, method=windowing) 

1273 

1274 _correlation_quadrant_plot( 

1275 kernel=display_kernel, 

1276 search=display_search, 

1277 correlation_surface=correlation_surface, 

1278 title=title, 

1279 path=path, 

1280 figsize=figsize, 

1281 dpi=dpi, 

1282 vicinity_margin=vicinity_margin, 

1283 reported_position=reported_position, 

1284 reported_position_label=reported_position_label, 

1285 centered=centered, 

1286 ) 

1287 

1288 

1289def point_grid_boxes_plot( 

1290 *, 

1291 image: np.ndarray, 

1292 points: Sequence[PixelCoordinate], 

1293 margin_width: int, 

1294 margin_height: int, 

1295 label_prefix: str, 

1296 figsize: tuple[float, float] | None = None, 

1297 path: Path, 

1298 dpi: int = 300, 

1299) -> None: 

1300 """Save a figure overlaying one uniquely colored, labeled box per point on `image`. 

1301 

1302 For each of `points`, draws an unfilled rectangle centered on it, sized 

1303 `2 * margin_width` by `2 * margin_height` -- e.g. a kernel (the patch 

1304 [`dictk.translation.locate`](../translation.html#locate) would extract 

1305 from the reference image) or a search area (its default search 

1306 region), one call per box type. Agnostic to which: call it once with a 

1307 kernel's margins and once with a search area's margins (on separate 

1308 figures, or via multiple calls onto the same `ax` for a combined one) 

1309 to compare either against point spacing at a glance -- e.g. whether 

1310 neighboring kernels overlap, or whether search areas run off the image 

1311 -- across the whole grid at once, not just one point. 

1312 

1313 Each point's box gets its own color, cycling through a 12-color 

1314 Tableau palette (`dictk.image._TABLEAU_PALETTE`; if there are more 

1315 than 12 points, colors repeat), and its own legend entry -- `points[0]` 

1316 labeled `"{label_prefix} 00"`, `points[19]` labeled `"{label_prefix} 

1317 19"`, for a 20-point collection -- so overlapping boxes stay visually 

1318 distinguishable and individually identifiable, not just grouped by box 

1319 type. 

1320 

1321 Args: 

1322 image: Source 2D grayscale image array. 

1323 points: The points to draw boxes around, in the image's own pixel 

1324 reference frame. May be empty (an unmarked copy of `image` is 

1325 saved). 

1326 margin_width: Half each box's width, in pixels. 

1327 margin_height: Half each box's height, in pixels. 

1328 label_prefix: Legend label prefix for the boxes, e.g. `"kernel"` 

1329 or `"search area"` -- each point's own zero-padded index is 

1330 appended to it. 

1331 figsize: Optional (width, height) in inches for the saved figure. 

1332 By default the canvas is sized from `image`/the boxes' own 

1333 data extent; pass this to override with a fixed size instead. 

1334 path: Output file path for the figure; format is inferred from the 

1335 extension by matplotlib's savefig (e.g. .png), not dictk's own 

1336 write/write_svg. 

1337 dpi: Resolution of the saved figure. 

1338 """ 

1339 image_height, image_width = image.shape 

1340 

1341 endpoints_x = [ 

1342 point.x + sign * margin_width for point in points for sign in (-1, 1) 

1343 ] 

1344 endpoints_y = [ 

1345 point.y + sign * margin_height for point in points for sign in (-1, 1) 

1346 ] 

1347 margin = max(image_width, image_height) * 0.05 

1348 x_min = min(0, *endpoints_x, 0) - margin 

1349 x_max = max(image_width, *endpoints_x, image_width) + margin 

1350 y_min = min(0, *endpoints_y, 0) - margin 

1351 y_max = max(image_height, *endpoints_y, image_height) + margin 

1352 

1353 if figsize is None: 

1354 figsize = ( 

1355 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH, 

1356 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH, 

1357 ) 

1358 fig, ax = plt.subplots(figsize=figsize) 

1359 ax.imshow( 

1360 image, cmap="gray", origin="upper", extent=(0, image_width, image_height, 0) 

1361 ) 

1362 

1363 index_width = len(str(len(points) - 1)) if len(points) > 1 else 2 

1364 for i, point in enumerate(points): 

1365 ax.add_patch( 

1366 patches.Rectangle( 

1367 (point.x - margin_width, point.y - margin_height), 

1368 2 * margin_width, 

1369 2 * margin_height, 

1370 edgecolor=_TABLEAU_PALETTE[i % len(_TABLEAU_PALETTE)], 

1371 facecolor="none", 

1372 linewidth=1.0, 

1373 label=f"{label_prefix} {i:0{index_width}d}", 

1374 ) 

1375 ) 

1376 

1377 ax.set_xlim(x_min, x_max) 

1378 ax.set_ylim(y_max, y_min) # inverted: image y increases downward 

1379 ax.set_xlabel("x (pixels)") 

1380 ax.set_ylabel("y (pixels)") 

1381 if points: 

1382 ax.legend(loc="center left", bbox_to_anchor=(1.02, 0.5), fontsize=7) 

1383 

1384 plt.savefig(path, dpi=dpi, bbox_inches="tight") 

1385 plt.close(fig) 

1386 

1387 

1388def _reticle_marker_path( 

1389 *, 

1390 ring_radius: float = 0.85, 

1391 tick_outer: float = 1.20, 

1392 num_circle_points: int = 64, 

1393) -> MarkerPath: 

1394 """A target-reticle glyph: a ring with four tick marks poking through it. 

1395 

1396 Built as a matplotlib marker path (unit-scaled to roughly [-1, 1]) meant 

1397 to be *stroked*, not filled -- a circle outline, plus four short line 

1398 segments along +/-x and +/-y running from the circle's own radius out 

1399 past it, so the center stays fully open. Unlike a filled ring (an 

1400 annulus), a stroked outline's thickness is set directly via 

1401 `markeredgewidth` (in points), independent of this path's own geometry 

1402 -- letting the line be pushed far thinner than a filled band can go 

1403 before anti-aliasing makes it look patchy. 

1404 

1405 Args: 

1406 ring_radius: The circle's radius; also each tick's start radius. 

1407 tick_outer: Each tick's end radius (past `ring_radius`). 

1408 num_circle_points: Number of vertices approximating the circle. 

1409 

1410 Returns: 

1411 A compound `matplotlib.path.Path` usable as a `marker=` argument, 

1412 with `markerfacecolor="none"` so only its stroke renders. 

1413 """ 

1414 theta = np.linspace(0, 2 * np.pi, num_circle_points, endpoint=False) 

1415 circle = np.column_stack((ring_radius * np.cos(theta), ring_radius * np.sin(theta))) 

1416 # Path(..., closed=True) treats the *last* vertex as the ignored 

1417 # CLOSEPOLY placeholder rather than drawing through it, so it must be a 

1418 # repeat of the first vertex -- otherwise the final edge is dropped. 

1419 circle_path = MarkerPath(np.vstack([circle, circle[:1]]), closed=True) 

1420 

1421 tick_codes = [MarkerPath.MOVETO, MarkerPath.LINETO] 

1422 tick_paths = [ 

1423 MarkerPath( 

1424 [ 

1425 (direction_x * ring_radius, direction_y * ring_radius), 

1426 (direction_x * tick_outer, direction_y * tick_outer), 

1427 ], 

1428 codes=tick_codes, 

1429 ) 

1430 for direction_x, direction_y in [(1, 0), (-1, 0), (0, 1), (0, -1)] 

1431 ] 

1432 

1433 return MarkerPath.make_compound_path(circle_path, *tick_paths) 

1434 

1435 

1436def point_grid_plot( 

1437 *, 

1438 image: np.ndarray, 

1439 points: Sequence[PixelCoordinate], 

1440 color: str = "red", 

1441 show_node_numbers: bool = True, 

1442 labels: Sequence[str] | None = None, 

1443 dot_size: float | None = None, 

1444 origin: PixelCoordinate = PixelCoordinate(x=0, y=0), 

1445 circle_center: PixelCoordinate | None = None, 

1446 circle_radius: float | None = None, 

1447 circle_linewidth: float = 1.5, 

1448 figsize: tuple[float, float] | None = None, 

1449 path: Path, 

1450 dpi: int = 300, 

1451) -> None: 

1452 """Save a figure marking each of `points` on `image`, labeled by its own index. 

1453 

1454 Each point is drawn as a target-reticle glyph in `color` -- a ring with 

1455 four tick marks poking through it, open at the center so the 

1456 underlying image stays visible, plus a single-pixel dot at the point's 

1457 exact location (where the N/S or E/W ticks would cross, if extended 

1458 across the open center) -- with its position in `points` (its 

1459 row-major index, e.g. from 

1460 [`dictk.grid.generate`](../grid.html#generate)) as a short zero-padded 

1461 label just up-right of the marker -- e.g. `points[0]` is labeled `"00"`, 

1462 `points[19]` is labeled `"19"`, for a 20-point collection. 

1463 

1464 Args: 

1465 image: Source 2D grayscale image array. 

1466 points: The points to mark, in the same reference frame as 

1467 `origin` (by default, `image`'s own frame -- see `origin` 

1468 below). May be empty (an unmarked copy of `image` is saved). 

1469 color: Matplotlib color name for every marker and label. 

1470 show_node_numbers: Whether to draw the reticle glyph and zero-padded 

1471 index label described above. Default `True` matches every 

1472 existing call site's own output exactly. `False` draws only 

1473 the single-pixel center dot at each point -- a bare position 

1474 marker with none of the reticle/label clutter, meant for a 

1475 point count dense enough that a reticle-and-label per point 

1476 would be unreadable (a few thousand points, not a dozen). 

1477 labels: Optional label text per point, same length and order as 

1478 `points`, overriding the default zero-padded `points`-local 

1479 index. For a `points` list that's already a subset of some 

1480 larger collection (e.g. every 4th point of a denser grid, 

1481 picked to space labels legibly), this shows each point's 

1482 *true* index in that larger collection instead of a 

1483 re-enumerated `0, 1, 2, ...` that would otherwise misrepresent 

1484 which points were skipped. Ignored when `show_node_numbers` is 

1485 `False`. 

1486 dot_size: Optional matplotlib `markersize` (in points) for the 

1487 single-pixel center dot at each point's exact location. 

1488 Default `None` keeps that dot exactly 1 raster pixel wide at 

1489 `dpi` -- the same on-canvas size the reticle glyph's own 

1490 center point has always used, appropriate when the reticle 

1491 (or a label) is doing the actual work of marking a point. 

1492 With `show_node_numbers=False`, the center dot is the *only* 

1493 marker drawn, and 1 raster pixel is too faint to see clearly 

1494 against real image content -- pass a larger value (e.g. `2` 

1495 or `3`) to make it visible. 

1496 origin: Where `image`'s own top-left corner sits in `points`' 

1497 reference frame. Default `PixelCoordinate(x=0, y=0)` means 

1498 `image` and `points` already share one frame -- every 

1499 existing call site's own behavior, unchanged. Pass `image`'s 

1500 true position (e.g. [`subimage`](./image.html#subimage)'s own 

1501 `origin` argument) when `image` is a crop of some larger 

1502 image and `points` are still expressed in that larger 

1503 image's coordinates: the axes then read in the *larger* 

1504 image's own numbers, not `image`'s local `0`-based ones, so 

1505 the same point reads identically whether it's plotted here 

1506 or in a figure of the uncropped image. 

1507 circle_center: Optional center, in the same reference frame as 

1508 `points`, of a red circle outline drawn on top of the image 

1509 -- same style as 

1510 [`spatial_correlation_quadrant_plot`](#spatial_correlation_quadrant_plot)'s 

1511 own Solution Vicinity marker. Meant to visually tie a figure 

1512 of one region back to a figure of a wider region it was 

1513 cropped from: draw the same `circle_center`/`circle_radius` 

1514 in both, and pick the radius to match the *narrower* 

1515 figure's own extent (e.g. half its width) -- there, the 

1516 circle exactly touches all four edges; in the wider figure, 

1517 it appears as a normal circle marking exactly the region 

1518 the narrower one shows. Must be given together with 

1519 `circle_radius`. 

1520 circle_radius: Radius, in the same units as `points`' own 

1521 coordinates, of the circle described above. Must be given 

1522 together with `circle_center`. 

1523 circle_linewidth: Matplotlib `linewidth` for the circle outline 

1524 above. Default `1.5` matches 

1525 [`spatial_correlation_quadrant_plot`](#spatial_correlation_quadrant_plot)'s 

1526 own circle exactly. Ignored when `circle_center` is `None`. 

1527 figsize: Optional (width, height) in inches for the saved figure. 

1528 By default the canvas is sized from `image`/`points`' own data 

1529 extent; pass this to override with a fixed size instead. 

1530 path: Output file path for the figure; format is inferred from the 

1531 extension by matplotlib's savefig (e.g. .png), not dictk's own 

1532 write/write_svg. 

1533 dpi: Resolution of the saved figure. 

1534 

1535 Raises: 

1536 ValueError: If `labels` is given and its length doesn't match 

1537 `points`, or if exactly one of `circle_center`/`circle_radius` 

1538 is given without the other. 

1539 """ 

1540 if (circle_center is None) != (circle_radius is None): 

1541 raise ValueError("circle_center and circle_radius must be given together") 

1542 if labels is not None and len(labels) != len(points): 

1543 raise ValueError( 

1544 f"labels has {len(labels)} entries, but points has {len(points)}" 

1545 ) 

1546 image_height, image_width = image.shape 

1547 image_left, image_top = origin.x, origin.y 

1548 image_right, image_bottom = origin.x + image_width, origin.y + image_height 

1549 

1550 endpoints_x = [point.x for point in points] 

1551 endpoints_y = [point.y for point in points] 

1552 margin = max(image_width, image_height) * 0.05 

1553 # Small up-right offset for point labels, so label text doesn't sit 

1554 # directly on top of its own marker -- same convention as point_plot(). 

1555 label_offset = max(image_width, image_height) * 0.03 

1556 label_font_size = 12 

1557 if points and show_node_numbers: 

1558 label_text_height = label_font_size / 72 * _FIGURE_PIXELS_PER_INCH 

1559 # Grow the margin uniformly (not just at the top) so a label above 

1560 # the topmost point still has room, while all four margins match. 

1561 # Capped at half the image's own size: label_text_height is a 

1562 # fixed absolute constant (a fixed font size, not scaled to image 

1563 # size), so on a small image/crop it would otherwise dominate the 

1564 # whole canvas -- e.g. a 25x25px crop would demand a margin nearly 

1565 # 3x wider than the image itself, mostly blank. The cap trades a 

1566 # little label headroom on a very small image for a canvas that 

1567 # still reads as "the image," not "mostly margin." No labels are 

1568 # drawn at all when show_node_numbers is False, so this margin 

1569 # isn't needed then either. 

1570 label_margin = 2 * label_offset + label_text_height 

1571 margin = max(margin, min(label_margin, max(image_width, image_height) * 0.5)) 

1572 x_min = min(image_left, *endpoints_x, image_left) - margin 

1573 x_max = max(image_right, *endpoints_x, image_right) + margin 

1574 y_min = min(image_top, *endpoints_y, image_top) - margin 

1575 y_max = max(image_bottom, *endpoints_y, image_bottom) + margin 

1576 label_outline = [patheffects.withStroke(linewidth=1, foreground="white")] 

1577 

1578 if figsize is None: 

1579 figsize = ( 

1580 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH, 

1581 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH, 

1582 ) 

1583 fig, ax = plt.subplots(figsize=figsize) 

1584 ax.imshow( 

1585 image, 

1586 cmap="gray", 

1587 origin="upper", 

1588 extent=(image_left, image_right, image_bottom, image_top), 

1589 ) 

1590 if circle_center is not None: 

1591 ax.add_patch( 

1592 patches.Circle( 

1593 (circle_center.x, circle_center.y), 

1594 radius=circle_radius, 

1595 edgecolor="red", 

1596 facecolor="none", 

1597 linewidth=circle_linewidth, 

1598 ) 

1599 ) 

1600 

1601 reticle = _reticle_marker_path() 

1602 # A single-raster-pixel dot at each point's exact location: where the 

1603 # reticle's N/S ticks (or E/W ticks), if extended across the open 

1604 # center, would cross. markeredgewidth=0 is required -- otherwise the 

1605 # default 1pt stroke dominates a marker this small and floors its 

1606 # rendered size at several pixels regardless of markersize. 

1607 center_dot_size = dot_size if dot_size is not None else 72 / dpi 

1608 index_width = len(str(len(points) - 1)) if len(points) > 1 else 2 

1609 point_labels = ( 

1610 labels 

1611 if labels is not None 

1612 else [f"{i:0{index_width}d}" for i in range(len(points))] 

1613 ) 

1614 for i, point in enumerate(points): 

1615 if show_node_numbers: 

1616 ax.plot( 

1617 point.x, 

1618 point.y, 

1619 marker=reticle, 

1620 markerfacecolor="none", 

1621 markeredgecolor=color, 

1622 markeredgewidth=0.5, 

1623 markersize=20, 

1624 ) 

1625 ax.plot( 

1626 point.x, 

1627 point.y, 

1628 marker="s", 

1629 color=color, 

1630 markersize=center_dot_size, 

1631 markeredgewidth=0, 

1632 ) 

1633 if show_node_numbers: 

1634 ax.text( 

1635 point.x + label_offset, 

1636 point.y - label_offset, 

1637 point_labels[i], 

1638 color=color, 

1639 fontsize=label_font_size, 

1640 va="bottom", 

1641 path_effects=label_outline, 

1642 ) 

1643 

1644 ax.set_xlim(x_min, x_max) 

1645 ax.set_ylim(y_max, y_min) # inverted: image y increases downward 

1646 ax.set_xlabel("x (pixels)") 

1647 ax.set_ylabel("y (pixels)") 

1648 

1649 plt.savefig(path, dpi=dpi, bbox_inches="tight") 

1650 plt.close(fig) 

1651 

1652 

1653def point_displacement_plot( 

1654 *, 

1655 points: Sequence[PixelCoordinate | SubpixelCoordinate], 

1656 values: Sequence[float], 

1657 label: str, 

1658 image: np.ndarray | None = None, 

1659 cmap: str | Colormap = "viridis", 

1660 dot_size: float = 150, 

1661 marker: str = "o", 

1662 vmin: float | None = None, 

1663 vmax: float | None = None, 

1664 figsize: tuple[float, float] = (10.0, 5.0), 

1665 path: Path, 

1666 dpi: int = 300, 

1667) -> None: 

1668 """Save a figure of a scalar value at each of a set of points. 

1669 

1670 Scatters `points` colored by `values`, with a colorbar labeled 

1671 `label`. If `image` is given, it's drawn as the background (e.g. 

1672 pass the current/deformed image, with `points` in that same current 

1673 configuration, to show where each point ended up); otherwise the 

1674 axes alone are y-inverted to match image/pixel convention (y 

1675 increasing downward), so the two modes render in the same visual 

1676 orientation. The axes are always aspect-locked 1:1 (a pixel spans 

1677 the same rendered length along x and y), regardless of `figsize` or 

1678 `image`'s own shape. 

1679 

1680 This is [`element_strain_plot`](#element_strain_plot)'s same 

1681 rendering -- background image, scatter, colorbar, 1:1 aspect -- with 

1682 its Q4 mesh/Gauss-point machinery dropped: there's no element 

1683 outline to draw and no interpolated Gauss-point location to plot, 

1684 just each point's own raw value. Useful for e.g. a per-point 

1685 displacement field, where `values` is each point's own `dy` (or 

1686 `dx`) and no mesh connectivity is involved at all. 

1687 

1688 Args: 

1689 points: One point per value, in the same configuration `values` 

1690 was measured in (e.g. `found`, not the original reference 

1691 points, to plot where each point ended up). 

1692 values: One scalar per point, same order and length as `points`. 

1693 This function doesn't compute anything itself, so the 

1694 caller picks what `values` means (e.g. `dy = found.y - 

1695 reference.y`) and sets `label` to match. 

1696 label: Colorbar label, e.g. `r"Displacement, $\\delta y$ (pixels)"`. 

1697 image: Optional background image (2D grayscale array). Default 

1698 `None` draws the points alone, on a plain y-inverted axes. 

1699 cmap: Matplotlib colormap for the scatter -- either a name 

1700 (e.g. `"viridis"`) or a `Colormap` instance (e.g. 

1701 `matplotlib.colors.ListedColormap`, for a custom or 

1702 externally-matched palette). 

1703 dot_size: Marker size (matplotlib `scatter`'s own `s`) for each 

1704 point. Default `150` suits sparse grids; a dense grid with 

1705 points only a few pixels apart needs a smaller value, or 

1706 neighboring markers overlap into a solid mass instead of a 

1707 legible field. 

1708 marker: Matplotlib marker style for each point. Default `"o"` 

1709 (circle). On a regular grid dense enough that neighboring 

1710 markers touch, circles leave small diamond-shaped gaps at 

1711 their corners (tangent circles never fully tile a plane) -- 

1712 `"s"` (square), sized and axis-aligned with the grid, tiles 

1713 edge to edge with no gaps, reading as a genuinely continuous 

1714 field rather than a field of dots. 

1715 vmin: Optional fixed lower bound for the color scale. Default 

1716 `None` auto-scales from `values`' own min. Set alongside 

1717 `vmax` to pin the colorbar to a specific range. Values 

1718 outside `[vmin, vmax]` still plot, just clipped to the 

1719 scale's own end colors, the same way matplotlib always 

1720 handles an explicit `vmin`/`vmax`. 

1721 vmax: Optional fixed upper bound for the color scale; see `vmin`. 

1722 figsize: `(width, height)` in inches for the saved figure, used 

1723 whether or not `image` is given. 

1724 path: Output file path for the figure; format is inferred from 

1725 the extension by matplotlib's savefig (e.g. `.png`). 

1726 dpi: Resolution of the saved figure. 

1727 

1728 Raises: 

1729 ValueError: If `values` and `points` have different lengths. 

1730 """ 

1731 if len(values) != len(points): 

1732 raise ValueError( 

1733 f"values has {len(values)} entries, but points has {len(points)}" 

1734 ) 

1735 

1736 xs = [point.x for point in points] 

1737 ys = [point.y for point in points] 

1738 

1739 fig, ax = plt.subplots(figsize=figsize) 

1740 

1741 if image is not None: 

1742 image_height, image_width = image.shape 

1743 ax.imshow( 

1744 image, 

1745 cmap="gray", 

1746 origin="upper", 

1747 extent=(0, image_width, image_height, 0), 

1748 ) 

1749 elif not ax.yaxis_inverted(): 

1750 ax.invert_yaxis() 

1751 

1752 scatter = ax.scatter( 

1753 xs, ys, c=values, cmap=cmap, s=dot_size, marker=marker, vmin=vmin, vmax=vmax 

1754 ) 

1755 fig.colorbar(scatter, ax=ax, label=label) 

1756 

1757 ax.axis("image") # aspect-locked 1:1, autoscaled tight to the data 

1758 ax.set_xlabel("x (pixels)") 

1759 ax.set_ylabel("y (pixels)") 

1760 

1761 plt.tight_layout() 

1762 # No bbox_inches="tight" here, matching element_strain_plot's own 

1763 # fixed-canvas save. 

1764 plt.savefig(path, dpi=dpi) 

1765 plt.close(fig) 

1766 

1767 

1768def element_strain_plot( 

1769 *, 

1770 points: Sequence[PixelCoordinate | SubpixelCoordinate], 

1771 elements: Sequence[tuple[int, int, int, int]], 

1772 coordinates: Sequence[tuple[float, float]], 

1773 values: Sequence[float], 

1774 label: str, 

1775 image: np.ndarray | None = None, 

1776 show_node_numbers: bool = False, 

1777 show_mesh_lines: bool = True, 

1778 cmap: str | Colormap = "viridis", 

1779 dot_size: float = 150, 

1780 marker: str = "o", 

1781 vmin: float | None = None, 

1782 vmax: float | None = None, 

1783 figsize: tuple[float, float] = (10.0, 5.0), 

1784 path: Path, 

1785 dpi: int = 300, 

1786) -> None: 

1787 r"""Save a figure of a Q4 mesh with its Gauss points colored by a scalar value. 

1788 

1789 Two rendering modes -- with or without a background image -- share 

1790 the same node-number/Gauss-point-scatter/colorbar layout, built from 

1791 dictk's own point/element representation 

1792 ([`dictk.grid.elements`](../grid.html#elements), 

1793 [`dictk.element.gauss_point_coordinates`](../element.html#gauss_point_coordinates)). 

1794 Deliberately agnostic to which strain measure (or any other 

1795 per-Gauss-point scalar) produced `values` -- it only draws what it's 

1796 given, matching 

1797 [`point_grid_plot`](#point_grid_plot)/[`point_grid_boxes_plot`](#point_grid_boxes_plot)'s 

1798 own "just draws what it's given" design, rather than computing strain 

1799 itself. 

1800 

1801 Draws each of `elements`' 4-corner polygon outline from `points` 

1802 (optionally, see `show_mesh_lines`), optionally labels each of 

1803 `points` with its own zero-padded index 

1804 (matching `point_grid_plot`'s exact `"00"`, `"01"`, ... convention), 

1805 and scatters `coordinates` colored by `values` with a colorbar 

1806 labeled `label`. If `image` is given, it's drawn as the background 

1807 (e.g. pass the current/deformed image, with `points`/`coordinates` in 

1808 that same current configuration, to show the mesh atop what it was 

1809 measured from); otherwise the axes alone are y-inverted to match 

1810 image/pixel convention (y increasing downward), so the two modes 

1811 render in the same visual orientation. The axes are always 

1812 aspect-locked 1:1 (a pixel spans the same rendered length along 

1813 x and y), regardless of `figsize` or `image`'s own shape -- so the 

1814 mesh's true proportions are never visually distorted. 

1815 

1816 Args: 

1817 points: The mesh's corner node positions, in the same 

1818 configuration `coordinates` uses (e.g. `found`, not the 

1819 original reference `points`, to draw the deformed shape). 

1820 elements: Q4 connectivity, e.g. from 

1821 [`dictk.grid.elements`](../grid.html#elements) -- each 

1822 4-tuple indexes into `points`. 

1823 coordinates: One `(X, Y)` position per Gauss point, e.g. from 

1824 [`dictk.element.gauss_point_coordinates`](../element.html#gauss_point_coordinates) 

1825 called once per element and concatenated, in the same order 

1826 as `values`. 

1827 values: One scalar per Gauss point, same order and length as 

1828 `coordinates` -- e.g. a strain tensor's own `[0, 0]` 

1829 component at each point. This function doesn't compute 

1830 strain itself, so the caller picks the strain measure by 

1831 choosing which function computed `values` -- 

1832 [`dictk.element.gauss_point_log_strains`](../element.html#gauss_point_log_strains) 

1833 vs. 

1834 [`dictk.element.gauss_point_green_lagrange_strains`](../element.html#gauss_point_green_lagrange_strains), 

1835 for instance -- and should set `label` to match. 

1836 label: Colorbar label, e.g. `r"Log Strain, $E_{11}$"`. 

1837 image: Optional background image (2D grayscale array). Default 

1838 `None` draws the mesh alone, on a plain y-inverted axes. 

1839 show_node_numbers: Whether to label each of `points` with its own 

1840 zero-padded index. 

1841 show_mesh_lines: Whether to draw each element's own 4-corner 

1842 outline. Default `True`. At high point density the mesh 

1843 lines add visual clutter without much information -- a 

1844 dense enough scatter (see `dot_size`) already reads as a 

1845 field on its own; `False` drops the outlines so the 

1846 colored points aren't fighting a grid of black lines for 

1847 attention. 

1848 cmap: Matplotlib colormap for the Gauss-point scatter -- either 

1849 a name (e.g. `"viridis"`) or a `Colormap` instance (e.g. 

1850 `matplotlib.colors.ListedColormap`, for a custom or 

1851 externally-matched palette). 

1852 dot_size: Marker size (matplotlib `scatter`'s own `s`) for each 

1853 Gauss point. Default `150` suits sparse meshes; a dense mesh 

1854 with points only a few pixels apart needs a smaller value, or 

1855 neighboring markers overlap into a solid mass instead of a 

1856 legible field. 

1857 marker: Matplotlib marker style for each Gauss point. Default 

1858 `"o"` (circle). On a regular grid dense enough that 

1859 neighboring markers touch, circles leave small diamond- 

1860 shaped gaps at their corners (tangent circles never fully 

1861 tile a plane) -- `"s"` (square), sized and axis-aligned with 

1862 the grid, tiles edge to edge with no gaps, reading as a 

1863 genuinely continuous field rather than a field of dots. 

1864 vmin: Optional fixed lower bound for the color scale. Default 

1865 `None` auto-scales from `values`' own min, matching every 

1866 existing call. Set alongside `vmax` to pin the colorbar to a 

1867 specific range -- e.g. matching an external tool's own 

1868 colorbar exactly, for a direct visual comparison between two 

1869 figures that wouldn't otherwise share a color scale. Values 

1870 outside `[vmin, vmax]` still plot, just clipped to the 

1871 scale's own end colors, the same way matplotlib always 

1872 handles an explicit `vmin`/`vmax`. 

1873 vmax: Optional fixed upper bound for the color scale; see `vmin`. 

1874 figsize: `(width, height)` in inches for the saved figure, used 

1875 whether or not `image` is given -- unlike `point_grid_plot`, 

1876 this isn't sized from `image`'s own shape (see `image` 

1877 above). 

1878 path: Output file path for the figure; format is inferred from 

1879 the extension by matplotlib's savefig (e.g. `.png`). 

1880 dpi: Resolution of the saved figure. 

1881 """ 

1882 gauss_xs = [c[0] for c in coordinates] 

1883 gauss_ys = [c[1] for c in coordinates] 

1884 

1885 # Same figsize whether or not image is given -- not sized from 

1886 # image.shape -- one fixed canvas for both calls. 

1887 fig, ax = plt.subplots(figsize=figsize) 

1888 

1889 if image is not None: 

1890 image_height, image_width = image.shape 

1891 ax.imshow( 

1892 image, 

1893 cmap="gray", 

1894 origin="upper", 

1895 extent=(0, image_width, image_height, 0), 

1896 ) 

1897 elif not ax.yaxis_inverted(): 

1898 # No image to establish the y-down orientation via its own 

1899 # extent -- invert explicitly. 

1900 ax.invert_yaxis() 

1901 

1902 if show_mesh_lines: 

1903 for element in elements: 

1904 corners = [points[i] for i in element] 

1905 corners.append(corners[0]) # close the quadrilateral 

1906 ax.plot([c.x for c in corners], [c.y for c in corners], "k-", alpha=0.3) 

1907 

1908 if show_node_numbers: 

1909 label_outline = [patheffects.withStroke(linewidth=1, foreground="white")] 

1910 index_width = len(str(len(points) - 1)) if len(points) > 1 else 2 

1911 for i, point in enumerate(points): 

1912 ax.text( 

1913 point.x, 

1914 point.y, 

1915 f"{i:0{index_width}d}", 

1916 color="red", 

1917 fontsize=10, 

1918 fontweight="bold", 

1919 ha="center", 

1920 va="center", 

1921 path_effects=label_outline, 

1922 ) 

1923 

1924 scatter = ax.scatter( 

1925 gauss_xs, 

1926 gauss_ys, 

1927 c=values, 

1928 cmap=cmap, 

1929 s=dot_size, 

1930 marker=marker, 

1931 vmin=vmin, 

1932 vmax=vmax, 

1933 ) 

1934 fig.colorbar(scatter, ax=ax, label=label) 

1935 

1936 ax.axis("image") # aspect-locked 1:1, autoscaled tight to the data -- 

1937 # not a manually recreated aspect+anchor+xlim/ylim equivalent. 

1938 ax.set_xlabel("x (pixels)") 

1939 ax.set_ylabel("y (pixels)") 

1940 

1941 plt.tight_layout() 

1942 # No bbox_inches="tight" here, unlike this module's other plot 

1943 # functions -- the fixed-size canvas is saved as is (plt.savefig(..., 

1944 # dpi=dpi), no bbox_inches) rather than cropped to the mesh's own, 

1945 # generally smaller, content bounding box. 

1946 plt.savefig(path, dpi=dpi) 

1947 plt.close(fig)