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

399 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-25 21:06 +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.path import Path as MarkerPath 

12 

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

14from dictk.image import PixelCoordinate, SubpixelCoordinate, subimage 

15 

16 

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

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

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

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

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

22# many pixels each actually covers. 

23_FIGURE_PIXELS_PER_INCH = 100 

24 

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

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

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

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

29# but disappears drawn on top of one. 

30_TABLEAU_PALETTE = ( 

31 "#1f77b4", 

32 "#aec7e8", # blue 

33 "#ff7f0e", 

34 "#ffbb78", # orange 

35 "#2ca02c", 

36 "#98df8a", # green 

37 "#d62728", 

38 "#ff9896", # red 

39 "#9467bd", 

40 "#c5b0d5", # purple 

41 "#8c564b", 

42 "#c49c94", # brown 

43) 

44 

45 

46def subimage_bounds_plot( 

47 *, 

48 image: np.ndarray, 

49 origin: PixelCoordinate, 

50 width: int, 

51 height: int, 

52 path: Path, 

53 dpi: int = 300, 

54) -> None: 

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

56 

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

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

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

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

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

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

63 reference frame. 

64 

65 Args: 

66 image: Source 2D grayscale image array. 

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

68 reference frame; see `subimage()`. 

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

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

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

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

73 dictk's own write/write_svg. 

74 dpi: Resolution of the saved figure. 

75 

76 Raises: 

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

78 """ 

79 if width < 1: 

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

81 if height < 1: 

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

83 

84 image_height, image_width = image.shape 

85 

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

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

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

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

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

91 

92 figsize = ( 

93 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH, 

94 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH, 

95 ) 

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

97 ax.imshow( 

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

99 ) 

100 

101 ax.add_patch( 

102 patches.Rectangle( 

103 (0, 0), 

104 image_width, 

105 image_height, 

106 edgecolor="blue", 

107 facecolor="none", 

108 linewidth=1.5, 

109 label="source image", 

110 ) 

111 ) 

112 ax.add_patch( 

113 patches.Rectangle( 

114 (origin.x, origin.y), 

115 width, 

116 height, 

117 edgecolor="red", 

118 facecolor="none", 

119 linewidth=1.5, 

120 label="subimage", 

121 ) 

122 ) 

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

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

125 

126 ax.set_xlim(x_min, x_max) 

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

128 

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

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

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

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

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

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

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

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

137 

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

139 plt.close(fig) 

140 

141 

142def subimage_plot( 

143 *, 

144 image: np.ndarray, 

145 origin: PixelCoordinate, 

146 width: int, 

147 height: int, 

148 path: Path, 

149 dpi: int = 300, 

150) -> None: 

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

152 

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

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

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

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

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

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

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

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

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

162 outside `image`. 

163 

164 Args: 

165 image: Source 2D grayscale image array. 

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

167 reference frame; see `subimage()`. 

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

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

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

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

172 dictk's own write/write_svg. 

173 dpi: Resolution of the saved figure. 

174 

175 Raises: 

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

177 """ 

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

179 

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

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

182 # sitting flush against it. 

183 margin = max(width, height) * 0.05 

184 x_min, x_max = -margin, width + margin 

185 y_min, y_max = -margin, height + margin 

186 

187 figsize = ( 

188 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH, 

189 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH, 

190 ) 

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

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

193 ax.add_patch( 

194 patches.Rectangle( 

195 (0, 0), 

196 width, 

197 height, 

198 edgecolor="red", 

199 facecolor="none", 

200 linewidth=1.5, 

201 ) 

202 ) 

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

204 

205 ax.set_xlim(x_min, x_max) 

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

207 

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

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

210 

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

212 plt.close(fig) 

213 

214 

215def subimage_comparison_plot( 

216 *, 

217 image: np.ndarray, 

218 origin: PixelCoordinate, 

219 width: int, 

220 height: int, 

221 path: Path, 

222 point: PixelCoordinate | None = None, 

223 point_color: str = "gold", 

224 point_label: str | None = None, 

225 subimage_label: str | None = None, 

226 color: str = "red", 

227 origin_label: str | None = None, 

228 source_origin_label: str | None = None, 

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

230 dpi: int = 300, 

231) -> None: 

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

233 

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

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

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

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

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

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

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

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

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

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

244 its own content. 

245 

246 Args: 

247 image: Source 2D grayscale image array. 

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

249 reference frame; see `subimage()`. 

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

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

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

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

254 dictk's own write/write_svg. 

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

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

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

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

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

260 each panel's own coordinates. 

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

262 if `point` isn't given. 

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

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

265 given. 

266 subimage_label: An optional short label identifying what this 

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

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

269 ("subimage") when not given. 

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

271 origin marker, on both panels. 

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

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

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

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

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

277 marker for it. 

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

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

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

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

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

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

284 dpi: Resolution of the saved figure. 

285 

286 Raises: 

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

288 """ 

289 if width < 1: 

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

291 if height < 1: 

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

293 

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

295 image_height, image_width = image.shape 

296 

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

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

299 # directly on top of its own marker. 

300 label_offset = max(image_width, image_height) * 0.03 

301 label_font_size = 12 

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

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

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

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

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

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

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

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

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

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

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

313 top_margin = margin 

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

315 label_text_height = label_font_size / 72 * _FIGURE_PIXELS_PER_INCH 

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

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

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

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

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

321 

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

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

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

325 

326 if figsize is None: 

327 panel_width = (x_max - x_min) / _FIGURE_PIXELS_PER_INCH 

328 panel_height = (y_max - y_min) / _FIGURE_PIXELS_PER_INCH 

329 figsize = (panel_width * 2, panel_height) 

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

331 1, 2, figsize=figsize, constrained_layout=True 

332 ) 

333 

334 ax_left.imshow( 

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

336 ) 

337 ax_left.add_patch( 

338 patches.Rectangle( 

339 (0, 0), 

340 image_width, 

341 image_height, 

342 edgecolor="blue", 

343 facecolor="none", 

344 linewidth=1.5, 

345 ) 

346 ) 

347 ax_left.add_patch( 

348 patches.Rectangle( 

349 (origin.x, origin.y), 

350 width, 

351 height, 

352 edgecolor=color, 

353 facecolor="none", 

354 linewidth=1.5, 

355 ) 

356 ) 

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

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

359 if point is not None: 

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

361 if source_origin_label is not None: 

362 ax_left.text( 

363 label_offset, 

364 -label_offset, 

365 source_origin_label, 

366 color="blue", 

367 fontsize=label_font_size, 

368 va="bottom", 

369 path_effects=label_outline, 

370 ) 

371 if origin_label is not None: 

372 ax_left.text( 

373 origin.x + label_offset, 

374 origin.y - label_offset, 

375 origin_label, 

376 color=color, 

377 fontsize=label_font_size, 

378 va="bottom", 

379 path_effects=label_outline, 

380 ) 

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

382 ax_left.text( 

383 point.x + label_offset, 

384 point.y - label_offset, 

385 point_label, 

386 color=point_color, 

387 fontsize=12, 

388 path_effects=label_outline, 

389 ) 

390 ax_left.set_xlim(x_min, x_max) 

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

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

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

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

395 

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

397 ax_right.add_patch( 

398 patches.Rectangle( 

399 (0, 0), 

400 width, 

401 height, 

402 edgecolor=color, 

403 facecolor="none", 

404 linewidth=1.5, 

405 ) 

406 ) 

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

408 if point is not None: 

409 ax_right.plot( 

410 point.x - origin.x, 

411 point.y - origin.y, 

412 marker="o", 

413 color=point_color, 

414 markersize=6, 

415 ) 

416 if origin_label is not None: 

417 ax_right.text( 

418 label_offset, 

419 -label_offset, 

420 origin_label, 

421 color=color, 

422 fontsize=label_font_size, 

423 va="bottom", 

424 path_effects=label_outline, 

425 ) 

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

427 ax_right.text( 

428 point.x - origin.x + label_offset, 

429 point.y - origin.y - label_offset, 

430 point_label, 

431 color=point_color, 

432 fontsize=12, 

433 path_effects=label_outline, 

434 ) 

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

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

437 ax_right.set_xlim(x_min, x_max) 

438 ax_right.set_ylim(y_max, y_min) 

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

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

441 ax_right.set_title( 

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

443 ) 

444 

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

446 plt.close(fig) 

447 

448 

449class ArrowAnnotation(NamedTuple): 

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

451 

452 Attributes: 

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

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

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

456 label: Legend label for the arrow. 

457 """ 

458 

459 tail: PixelCoordinate 

460 head: PixelCoordinate 

461 color: str 

462 label: str 

463 

464 

465class BoxAnnotation(NamedTuple): 

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

467 

468 Attributes: 

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

470 reference frame; see `subimage()`. 

471 width: Width of the rectangle in pixels. 

472 height: Height of the rectangle in pixels. 

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

474 marker, and its legend entry. 

475 label: Legend label for the rectangle. 

476 """ 

477 

478 origin: PixelCoordinate 

479 width: int 

480 height: int 

481 color: str 

482 label: str 

483 

484 

485class PointAnnotation(NamedTuple): 

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

487 

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

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

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

491 

492 Attributes: 

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

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

495 rendered via matplotlib's built-in mathtext. 

496 color: Matplotlib color name for the label text. 

497 """ 

498 

499 position: PixelCoordinate 

500 label: str 

501 color: str 

502 

503 

504def point_plot( 

505 *, 

506 image: np.ndarray, 

507 arrows: Sequence[ArrowAnnotation], 

508 boxes: Sequence[BoxAnnotation] = (), 

509 points: Sequence[PointAnnotation] = (), 

510 legend: bool = True, 

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

512 path: Path, 

513 dpi: int = 300, 

514) -> None: 

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

516 

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

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

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

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

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

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

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

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

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

526 

527 Args: 

528 image: Source 2D grayscale image array. 

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

530 legend label. 

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

532 each with its own color and legend label. 

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

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

535 figure. 

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

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

538 figure caption) and would just clutter the figure. 

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

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

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

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

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

544 font sizes and line widths look as intended. 

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

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

547 write/write_svg. 

548 dpi: Resolution of the saved figure. 

549 

550 Raises: 

551 ValueError: If `arrows` is empty. 

552 """ 

553 if not arrows: 

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

555 

556 image_height, image_width = image.shape 

557 

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

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

560 for box in boxes: 

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

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

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

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

565 margin = max(image_width, image_height) * 0.05 

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

567 # directly on top of its own marker. 

568 label_offset = max(image_width, image_height) * 0.03 

569 label_font_size = 12 

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

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

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

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

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

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

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

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

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

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

580 top_margin = margin 

581 if points: 

582 label_text_height = label_font_size / 72 * _FIGURE_PIXELS_PER_INCH 

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

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

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

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

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

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

589 

590 if figsize is None: 

591 figsize = ( 

592 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH, 

593 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH, 

594 ) 

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

596 ax.imshow( 

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

598 ) 

599 

600 for box in boxes: 

601 ax.add_patch( 

602 patches.Rectangle( 

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

604 box.width, 

605 box.height, 

606 edgecolor=box.color, 

607 facecolor="none", 

608 linewidth=1.5, 

609 ) 

610 ) 

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

612 

613 for arrow in arrows: 

614 ax.annotate( 

615 "", 

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

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

618 arrowprops={ 

619 "color": arrow.color, 

620 "width": 1.5, 

621 "headwidth": 8, 

622 "shrink": 0, 

623 }, 

624 ) 

625 

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

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

628 for point in points: 

629 ax.text( 

630 point.position.x + label_offset, 

631 point.position.y - label_offset, 

632 point.label, 

633 color=point.color, 

634 fontsize=label_font_size, 

635 va="bottom", 

636 path_effects=label_outline, 

637 ) 

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

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

640 handles = [ 

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

642 for arrow in arrows 

643 ] + [ 

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

645 for box in boxes 

646 ] 

647 

648 ax.set_xlim(x_min, x_max) 

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

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

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

652 if legend: 

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

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

655 ax.legend( 

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

657 ) 

658 

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

660 plt.close(fig) 

661 

662 

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

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

665 

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

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

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

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

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

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

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

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

674 pixel convention used throughout this codebase explicit, labeled with 

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

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

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

678 border. 

679 

680 Args: 

681 image: Source 2D grayscale image array. 

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

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

684 dictk's own write/write_svg. 

685 dpi: Resolution of the saved figure. 

686 """ 

687 image_height, image_width = image.shape 

688 axis_length = min(image_width, image_height) * 0.2 

689 

690 margin = max(image_width, image_height) * 0.05 

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

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

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

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

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

696 label_font_size = 12 

697 frame_label_font_size = 14 

698 min_margin_for_labels = ( 

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

700 ) 

701 margin = max(margin, min_margin_for_labels) 

702 

703 x_min, x_max = -margin, image_width + margin 

704 y_min, y_max = -margin, image_height + margin 

705 

706 panel_width = (x_max - x_min) / _FIGURE_PIXELS_PER_INCH 

707 panel_height = (y_max - y_min) / _FIGURE_PIXELS_PER_INCH 

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

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

710 ) 

711 

712 for ax in (ax_left, ax_right): 

713 ax.imshow( 

714 image, 

715 cmap="gray", 

716 origin="upper", 

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

718 ) 

719 ax.set_xlim(x_min, x_max) 

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

721 

722 ax_left.set_title("image") 

723 ax_left.axis("off") 

724 

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

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

727 

728 ax_right.add_patch( 

729 patches.Rectangle( 

730 (0, 0), 

731 image_width, 

732 image_height, 

733 edgecolor="blue", 

734 facecolor="none", 

735 linewidth=1.5, 

736 ) 

737 ) 

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

739 

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

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

742 frame_label_offset = margin * 0.35 

743 ax_right.text( 

744 -frame_label_offset, 

745 -frame_label_offset, 

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

747 color="blue", 

748 fontsize=frame_label_font_size, 

749 ha="right", 

750 va="bottom", 

751 ) 

752 

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

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

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

756 # frame's border). 

757 label_offset = margin / 2 

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

759 ( 

760 (axis_length, 0), 

761 (axis_length, -label_offset), 

762 "red", 

763 "x", 

764 "center", 

765 "bottom", 

766 ), 

767 ( 

768 (0, axis_length), 

769 (-label_offset, axis_length), 

770 "green", 

771 "y", 

772 "right", 

773 "center", 

774 ), 

775 ): 

776 ax_right.annotate( 

777 "", 

778 xy=axis_head, 

779 xytext=(0, 0), 

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

781 ) 

782 ax_right.text( 

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

784 ) 

785 

786 ax_right.set_title("reference frame") 

787 

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

789 plt.close(fig) 

790 

791 

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

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

794 

795 Args: 

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

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

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

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

800 dpi: Resolution of the saved figure. 

801 """ 

802 plt.figure() 

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

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

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

806 plt.ylabel("frequency") 

807 plt.savefig(path, dpi=dpi) 

808 plt.close() 

809 

810 

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

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

813 

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

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

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

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

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

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

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

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

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

823 

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

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

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

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

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

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

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

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

832 

833 Args: 

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

835 own height or width). 

836 

837 Returns: 

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

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

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

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

842 """ 

843 if size == 51: 

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

845 if size == 100: 

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

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

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

849 

850 

851def _correlation_quadrant_plot( 

852 *, 

853 kernel: np.ndarray, 

854 search: np.ndarray, 

855 correlation_surface: np.ndarray, 

856 title: str, 

857 path: Path, 

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

859 dpi: int = 300, 

860 vicinity_margin: int = 4, 

861 reported_position: PixelCoordinate | None = None, 

862 reported_position_label: str = "reported", 

863 centered: bool = False, 

864) -> None: 

865 """Shared renderer behind spatial_correlation_quadrant_plot() and 

866 phase_correlation_quadrant_plot() -- private (never published by 

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

868 docstring rather than pointing here. 

869 

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

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

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

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

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

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

876 

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

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

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

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

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

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

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

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

885 

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

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

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

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

890 [Recoverable Displacement 

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

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

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

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

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

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

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

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

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

900 parameter existed. 

901 """ 

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

903 raise ValueError( 

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

905 "in both dimensions" 

906 ) 

907 

908 search_height, search_width = search.shape 

909 kernel_height, kernel_width = kernel.shape 

910 

911 peak_y, peak_x = np.unravel_index( 

912 np.argmax(correlation_surface), correlation_surface.shape 

913 ) 

914 peak_y, peak_x = int(peak_y), int(peak_x) 

915 

916 if centered: 

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

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

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

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

921 kernel_padded, pad_before_height, pad_before_width = _kernel_pad( 

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

923 ) 

924 box_x = (peak_x + pad_before_width) % search_width 

925 box_y = (peak_y + pad_before_height) % search_height 

926 else: 

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

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

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

930 kernel_padded = np.pad( 

931 kernel, 

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

933 ) 

934 box_x, box_y = peak_x, peak_y 

935 

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

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

938 fig.suptitle(title) 

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

940 

941 im1 = ax1.imshow( 

942 search, 

943 cmap="gray", 

944 vmin=0, 

945 vmax=255, 

946 origin="upper", 

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

948 ) 

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

950 ax1.add_patch( 

951 patches.Rectangle( 

952 (box_x, box_y), 

953 kernel_width, 

954 kernel_height, 

955 edgecolor="yellow", 

956 facecolor="none", 

957 linestyle="--", 

958 linewidth=1, 

959 alpha=0.8, 

960 label="correlation surface peak", 

961 ) 

962 ) 

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

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

965 xticks = {0, search_width, box_x} 

966 yticks = {0, search_height, box_y} 

967 if reported_position is not None: 

968 ax1.add_patch( 

969 patches.Rectangle( 

970 (reported_position.x, reported_position.y), 

971 kernel_width, 

972 kernel_height, 

973 edgecolor="magenta", 

974 facecolor="none", 

975 linestyle=":", 

976 linewidth=1.5, 

977 alpha=0.5, 

978 label=reported_position_label, 

979 ) 

980 ) 

981 xticks.add(reported_position.x) 

982 yticks.add(reported_position.y) 

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

984 ax1.set_xticks(sorted(xticks)) 

985 ax1.set_yticks(sorted(yticks)) 

986 for label in ax1.get_xticklabels(): 

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

988 label.set_color("red") 

989 for label in ax1.get_yticklabels(): 

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

991 label.set_color("green") 

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

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

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

995 

996 im2 = ax2.imshow( 

997 kernel_padded, 

998 cmap="gray", 

999 vmin=0, 

1000 vmax=255, 

1001 origin="upper", 

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

1003 ) 

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

1005 if centered: 

1006 kernel_xticks = { 

1007 0, 

1008 search_width, 

1009 pad_before_width, 

1010 pad_before_width + kernel_width, 

1011 } 

1012 kernel_yticks = { 

1013 0, 

1014 search_height, 

1015 pad_before_height, 

1016 pad_before_height + kernel_height, 

1017 } 

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

1019 else: 

1020 kernel_xticks = {0, search_width, kernel_width} 

1021 kernel_yticks = {0, search_height, kernel_height} 

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

1023 ax2.set_xticks(sorted(kernel_xticks)) 

1024 ax2.set_yticks(sorted(kernel_yticks)) 

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

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

1027 

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

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

1030 ax3.add_patch( 

1031 patches.Circle( 

1032 (peak_x, peak_y), 

1033 radius=vicinity_margin, 

1034 edgecolor="red", 

1035 facecolor="none", 

1036 linewidth=1.5, 

1037 ) 

1038 ) 

1039 ax3.set_title("Correlation Surface") 

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

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

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

1043 # _correlation_surface_ticks() for why. 

1044 surface_height, surface_width = correlation_surface.shape 

1045 ax3.set_xticks(_correlation_surface_ticks(surface_width)) 

1046 ax3.set_yticks(_correlation_surface_ticks(surface_height)) 

1047 

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

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

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

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

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

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

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

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

1056 ax4.add_patch( 

1057 patches.Circle( 

1058 (peak_x, peak_y), 

1059 radius=vicinity_margin, 

1060 edgecolor="red", 

1061 facecolor="none", 

1062 linewidth=1.5, 

1063 ) 

1064 ) 

1065 ax4.set_title("Solution Vicinity") 

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

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

1068 

1069 fig.savefig(path, dpi=dpi) 

1070 plt.close(fig) 

1071 

1072 

1073def spatial_correlation_quadrant_plot( 

1074 *, 

1075 kernel: np.ndarray, 

1076 search: np.ndarray, 

1077 correlation_surface: np.ndarray, 

1078 title: str, 

1079 path: Path, 

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

1081 dpi: int = 300, 

1082 vicinity_margin: int = 4, 

1083) -> None: 

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

1085 

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

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

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

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

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

1091 approach. See 

1092 [`phase_correlation_quadrant_plot`](#phase_correlation_quadrant_plot) 

1093 for the Fourier-domain sibling of this function. 

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

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

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

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

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

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

1100 

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

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

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

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

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

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

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

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

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

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

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

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

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

1114 exactly the circle's own bounding box. 

1115 

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

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

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

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

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

1121 

1122 Args: 

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

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

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

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

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

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

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

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

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

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

1133 titles are too narrow to reliably fit the longer criterion 

1134 names without truncating or overlapping their colorbar. 

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

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

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

1138 dpi: Resolution of the saved figure. 

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

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

1141 

1142 Raises: 

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

1144 dimension. 

1145 """ 

1146 _correlation_quadrant_plot( 

1147 kernel=kernel, 

1148 search=search, 

1149 correlation_surface=correlation_surface, 

1150 title=title, 

1151 path=path, 

1152 figsize=figsize, 

1153 dpi=dpi, 

1154 vicinity_margin=vicinity_margin, 

1155 ) 

1156 

1157 

1158def phase_correlation_quadrant_plot( 

1159 *, 

1160 kernel: np.ndarray, 

1161 search: np.ndarray, 

1162 windowing: WindowingMethod | None = None, 

1163 title: str = "Phase Correlation", 

1164 path: Path, 

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

1166 dpi: int = 300, 

1167 vicinity_margin: int = 4, 

1168 reported_position: PixelCoordinate | None = None, 

1169 reported_position_label: str = "reported", 

1170 centered: bool = False, 

1171) -> None: 

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

1173 

1174 The Fourier-domain sibling of 

1175 [`spatial_correlation_quadrant_plot`](#spatial_correlation_quadrant_plot), 

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

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

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

1179 surface: spatial-domain correlation has four interchangeable criteria 

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

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

1182 computes it internally via 

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

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

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

1186 `zncc`, by a completely different mechanism. 

1187 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1202 

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

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

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

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

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

1208 

1209 Args: 

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

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

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

1213 windowing: If given, passed straight through to 

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

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

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

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

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

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

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

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

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

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

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

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

1226 is wanted. 

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

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

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

1230 dpi: Resolution of the saved figure. 

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

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

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

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

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

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

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

1238 when that answer might disagree with where this surface 

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

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

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

1242 though the underlying surface it was computed from peaks at 

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

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

1245 this parameter existed. 

1246 reported_position_label: Legend label for `reported_position`'s 

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

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

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

1250 centered: Passed straight through to 

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

1252 own `centered` parameter -- the same convention 

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

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

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

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

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

1258 byte-identical to every figure from before this parameter 

1259 existed. 

1260 

1261 Raises: 

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

1263 dimension. 

1264 """ 

1265 correlation_surface = phase_correlation( 

1266 kernel=kernel, search=search, windowing=windowing, centered=centered 

1267 ) 

1268 display_kernel, display_search = kernel, search 

1269 if windowing is not None: 

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

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

1272 

1273 _correlation_quadrant_plot( 

1274 kernel=display_kernel, 

1275 search=display_search, 

1276 correlation_surface=correlation_surface, 

1277 title=title, 

1278 path=path, 

1279 figsize=figsize, 

1280 dpi=dpi, 

1281 vicinity_margin=vicinity_margin, 

1282 reported_position=reported_position, 

1283 reported_position_label=reported_position_label, 

1284 centered=centered, 

1285 ) 

1286 

1287 

1288def point_grid_boxes_plot( 

1289 *, 

1290 image: np.ndarray, 

1291 points: Sequence[PixelCoordinate], 

1292 margin_width: int, 

1293 margin_height: int, 

1294 label_prefix: str, 

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

1296 path: Path, 

1297 dpi: int = 300, 

1298) -> None: 

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

1300 

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

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

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

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

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

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

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

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

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

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

1311 

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

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

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

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

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

1317 distinguishable and individually identifiable, not just grouped by box 

1318 type. 

1319 

1320 Args: 

1321 image: Source 2D grayscale image array. 

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

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

1324 saved). 

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

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

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

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

1329 appended to it. 

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

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

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

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

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

1335 write/write_svg. 

1336 dpi: Resolution of the saved figure. 

1337 """ 

1338 image_height, image_width = image.shape 

1339 

1340 endpoints_x = [ 

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

1342 ] 

1343 endpoints_y = [ 

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

1345 ] 

1346 margin = max(image_width, image_height) * 0.05 

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

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

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

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

1351 

1352 if figsize is None: 

1353 figsize = ( 

1354 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH, 

1355 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH, 

1356 ) 

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

1358 ax.imshow( 

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

1360 ) 

1361 

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

1363 for i, point in enumerate(points): 

1364 ax.add_patch( 

1365 patches.Rectangle( 

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

1367 2 * margin_width, 

1368 2 * margin_height, 

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

1370 facecolor="none", 

1371 linewidth=1.0, 

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

1373 ) 

1374 ) 

1375 

1376 ax.set_xlim(x_min, x_max) 

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

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

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

1380 if points: 

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

1382 

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

1384 plt.close(fig) 

1385 

1386 

1387def _reticle_marker_path( 

1388 *, 

1389 ring_radius: float = 0.85, 

1390 tick_outer: float = 1.20, 

1391 num_circle_points: int = 64, 

1392) -> MarkerPath: 

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

1394 

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

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

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

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

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

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

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

1402 before anti-aliasing makes it look patchy. 

1403 

1404 Args: 

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

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

1407 num_circle_points: Number of vertices approximating the circle. 

1408 

1409 Returns: 

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

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

1412 """ 

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

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

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

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

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

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

1419 

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

1421 tick_paths = [ 

1422 MarkerPath( 

1423 [ 

1424 (direction_x * ring_radius, direction_y * ring_radius), 

1425 (direction_x * tick_outer, direction_y * tick_outer), 

1426 ], 

1427 codes=tick_codes, 

1428 ) 

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

1430 ] 

1431 

1432 return MarkerPath.make_compound_path(circle_path, *tick_paths) 

1433 

1434 

1435def point_grid_plot( 

1436 *, 

1437 image: np.ndarray, 

1438 points: Sequence[PixelCoordinate], 

1439 color: str = "red", 

1440 show_node_numbers: bool = True, 

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

1442 dot_size: float | None = None, 

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

1444 circle_center: PixelCoordinate | None = None, 

1445 circle_radius: float | None = None, 

1446 circle_linewidth: float = 1.5, 

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

1448 path: Path, 

1449 dpi: int = 300, 

1450) -> None: 

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

1452 

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

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

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

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

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

1458 row-major index, e.g. from 

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

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

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

1462 

1463 Args: 

1464 image: Source 2D grayscale image array. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1484 `False`. 

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

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

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

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

1489 center point has always used, appropriate when the reticle 

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

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

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

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

1494 or `3`) to make it visible. 

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

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

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

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

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

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

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

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

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

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

1505 or in a figure of the uncropped image. 

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

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

1508 -- same style as 

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

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

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

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

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

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

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

1516 it appears as a normal circle marking exactly the region 

1517 the narrower one shows. Must be given together with 

1518 `circle_radius`. 

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

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

1521 together with `circle_center`. 

1522 circle_linewidth: Matplotlib `linewidth` for the circle outline 

1523 above. Default `1.5` matches 

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

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

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

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

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

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

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

1531 write/write_svg. 

1532 dpi: Resolution of the saved figure. 

1533 

1534 Raises: 

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

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

1537 is given without the other. 

1538 """ 

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

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

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

1542 raise ValueError( 

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

1544 ) 

1545 image_height, image_width = image.shape 

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

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

1548 

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

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

1551 margin = max(image_width, image_height) * 0.05 

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

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

1554 label_offset = max(image_width, image_height) * 0.03 

1555 label_font_size = 12 

1556 if points and show_node_numbers: 

1557 label_text_height = label_font_size / 72 * _FIGURE_PIXELS_PER_INCH 

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

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

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

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

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

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

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

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

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

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

1568 # isn't needed then either. 

1569 label_margin = 2 * label_offset + label_text_height 

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

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

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

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

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

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

1576 

1577 if figsize is None: 

1578 figsize = ( 

1579 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH, 

1580 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH, 

1581 ) 

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

1583 ax.imshow( 

1584 image, 

1585 cmap="gray", 

1586 origin="upper", 

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

1588 ) 

1589 if circle_center is not None: 

1590 ax.add_patch( 

1591 patches.Circle( 

1592 (circle_center.x, circle_center.y), 

1593 radius=circle_radius, 

1594 edgecolor="red", 

1595 facecolor="none", 

1596 linewidth=circle_linewidth, 

1597 ) 

1598 ) 

1599 

1600 reticle = _reticle_marker_path() 

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

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

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

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

1605 # rendered size at several pixels regardless of markersize. 

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

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

1608 point_labels = ( 

1609 labels 

1610 if labels is not None 

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

1612 ) 

1613 for i, point in enumerate(points): 

1614 if show_node_numbers: 

1615 ax.plot( 

1616 point.x, 

1617 point.y, 

1618 marker=reticle, 

1619 markerfacecolor="none", 

1620 markeredgecolor=color, 

1621 markeredgewidth=0.5, 

1622 markersize=20, 

1623 ) 

1624 ax.plot( 

1625 point.x, 

1626 point.y, 

1627 marker="s", 

1628 color=color, 

1629 markersize=center_dot_size, 

1630 markeredgewidth=0, 

1631 ) 

1632 if show_node_numbers: 

1633 ax.text( 

1634 point.x + label_offset, 

1635 point.y - label_offset, 

1636 point_labels[i], 

1637 color=color, 

1638 fontsize=label_font_size, 

1639 va="bottom", 

1640 path_effects=label_outline, 

1641 ) 

1642 

1643 ax.set_xlim(x_min, x_max) 

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

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

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

1647 

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

1649 plt.close(fig) 

1650 

1651 

1652def element_strain_plot( 

1653 *, 

1654 points: Sequence[PixelCoordinate | SubpixelCoordinate], 

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

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

1657 values: Sequence[float], 

1658 label: str, 

1659 image: np.ndarray | None = None, 

1660 show_node_numbers: bool = False, 

1661 cmap: str = "viridis", 

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

1663 path: Path, 

1664 dpi: int = 300, 

1665) -> None: 

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

1667 

1668 Modeled on the reference `hdic` codebase's 

1669 `~/hdic/src/hdic/types/fea_vis.py`'s `plot_strain_at_gauss_points` -- 

1670 same two-mode design (with or without a background image), same 

1671 node-number/Gauss-point-scatter/colorbar layout -- adapted to dictk's 

1672 own point/element representation 

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

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

1675 Deliberately agnostic to which strain measure (or any other 

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

1677 given, matching 

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

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

1680 itself. 

1681 

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

1683 optionally labels each of `points` with its own zero-padded index 

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

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

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

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

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

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

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

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

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

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

1694 mesh's true proportions are never visually distorted. 

1695 

1696 Args: 

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

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

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

1700 elements: Q4 connectivity, e.g. from 

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

1702 4-tuple indexes into `points`. 

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

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

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

1706 as `values`. 

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

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

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

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

1711 choosing which function computed `values` -- 

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

1713 vs. 

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

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

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

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

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

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

1720 zero-padded index. 

1721 cmap: Matplotlib colormap for the Gauss-point scatter. 

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

1723 whether or not `image` is given -- unlike `point_grid_plot`, 

1724 this isn't sized from `image`'s own shape (see `image` 

1725 above). 

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

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

1728 dpi: Resolution of the saved figure. 

1729 """ 

1730 gauss_xs = [c[0] for c in coordinates] 

1731 gauss_ys = [c[1] for c in coordinates] 

1732 

1733 # Same figsize whether or not image is given -- not sized from 

1734 # image.shape -- matching hdic's own plot_strain_at_gauss_points, 

1735 # which uses one fixed canvas for both calls. 

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

1737 

1738 if image is not None: 

1739 image_height, image_width = image.shape 

1740 ax.imshow( 

1741 image, 

1742 cmap="gray", 

1743 origin="upper", 

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

1745 ) 

1746 elif not ax.yaxis_inverted(): 

1747 # No image to establish the y-down orientation via its own 

1748 # extent -- invert explicitly, matching hdic's own conditional 

1749 # invert_yaxis() call. 

1750 ax.invert_yaxis() 

1751 

1752 for element in elements: 

1753 corners = [points[i] for i in element] 

1754 corners.append(corners[0]) # close the quadrilateral 

1755 ax.plot([c.x for c in corners], [c.y for c in corners], "k-", alpha=0.3) 

1756 

1757 if show_node_numbers: 

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

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

1760 for i, point in enumerate(points): 

1761 ax.text( 

1762 point.x, 

1763 point.y, 

1764 f"{i:0{index_width}d}", 

1765 color="red", 

1766 fontsize=10, 

1767 fontweight="bold", 

1768 ha="center", 

1769 va="center", 

1770 path_effects=label_outline, 

1771 ) 

1772 

1773 scatter = ax.scatter(gauss_xs, gauss_ys, c=values, cmap=cmap, s=150) 

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

1775 

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

1777 # matching hdic's own plt.axis("image") exactly, not a manually 

1778 # recreated aspect+anchor+xlim/ylim equivalent. 

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

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

1781 

1782 plt.tight_layout() 

1783 # No bbox_inches="tight" here, unlike this module's other plot 

1784 # functions -- matching hdic's own plot_strain_at_gauss_points, which 

1785 # keeps its fixed-size canvas as saved (plt.savefig(..., dpi=300), 

1786 # no bbox_inches) rather than cropping to the mesh's own, generally 

1787 # smaller, content bounding box. 

1788 plt.savefig(path, dpi=dpi) 

1789 plt.close(fig)