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
« 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."""
3from collections.abc import Sequence
4from pathlib import Path
5from typing import NamedTuple
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
13from dictk.correlation import WindowingMethod, _kernel_pad, phase_correlation, window
14from dictk.image import PixelCoordinate, SubpixelCoordinate, subimage
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
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)
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.
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.
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.
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")
84 image_height, image_width = image.shape
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
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 )
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)
126 ax.set_xlim(x_min, x_max)
127 ax.set_ylim(y_max, y_min) # inverted: image y increases downward
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}")
138 plt.savefig(path, dpi=dpi, bbox_inches="tight")
139 plt.close(fig)
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.
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`.
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.
175 Raises:
176 ValueError: If width or height is less than 1.
177 """
178 region = subimage(image=image, origin=origin, width=width, height=height)
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
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)
205 ax.set_xlim(x_min, x_max)
206 ax.set_ylim(y_max, y_min) # inverted: image y increases downward
208 ax.set_xlabel("x (pixels)")
209 ax.set_ylabel("y (pixels)")
211 plt.savefig(path, dpi=dpi, bbox_inches="tight")
212 plt.close(fig)
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.
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.
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.
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")
294 region = subimage(image=image, origin=origin, width=width, height=height)
295 image_height, image_width = image.shape
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
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")]
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 )
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")
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 )
445 plt.savefig(path, dpi=dpi, bbox_inches="tight")
446 plt.close(fig)
449class ArrowAnnotation(NamedTuple):
450 """A labeled, colored arrow to overlay on an image, from `tail` to `head`.
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 """
459 tail: PixelCoordinate
460 head: PixelCoordinate
461 color: str
462 label: str
465class BoxAnnotation(NamedTuple):
466 """A labeled, colored rectangle (with an origin marker) to overlay on an image.
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 """
478 origin: PixelCoordinate
479 width: int
480 height: int
481 color: str
482 label: str
485class PointAnnotation(NamedTuple):
486 """A short colored text label to draw at a point on an image, with no marker of its own.
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$"`.
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 """
499 position: PixelCoordinate
500 label: str
501 color: str
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`.
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.
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.
550 Raises:
551 ValueError: If `arrows` is empty.
552 """
553 if not arrows:
554 raise ValueError("arrows must not be empty")
556 image_height, image_width = image.shape
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")]
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 )
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)
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 )
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 ]
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 )
659 plt.savefig(path, dpi=dpi, bbox_inches="tight")
660 plt.close(fig)
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.
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.
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
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)
703 x_min, x_max = -margin, image_width + margin
704 y_min, y_max = -margin, image_height + margin
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 )
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
722 ax_left.set_title("image")
723 ax_left.axis("off")
725 ax_right.set_xlabel("x (pixels)")
726 ax_right.set_ylabel("y (pixels)")
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)
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 )
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 )
786 ax_right.set_title("reference frame")
788 plt.savefig(path, dpi=dpi, bbox_inches="tight")
789 plt.close(fig)
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.
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()
811def _correlation_surface_ticks(size: int) -> list[int]:
812 """Fixed Correlation Surface tick marks for an axis of length `size`.
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.
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.
833 Args:
834 size: The axis's length in pixels (a Correlation Surface panel's
835 own height or width).
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))
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.
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.
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.
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 )
908 search_height, search_width = search.shape
909 kernel_height, kernel_width = kernel.shape
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)
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
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
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)")
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)")
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))
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)")
1069 fig.savefig(path, dpi=dpi)
1070 plt.close(fig)
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.
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.
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.
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.
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.
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 )
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.
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.
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.
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.
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.
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)
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 )
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`.
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.
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.
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
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
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 )
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 )
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)
1383 plt.savefig(path, dpi=dpi, bbox_inches="tight")
1384 plt.close(fig)
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.
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.
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.
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)
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 ]
1432 return MarkerPath.make_compound_path(circle_path, *tick_paths)
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.
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.
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.
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
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")]
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 )
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 )
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)")
1648 plt.savefig(path, dpi=dpi, bbox_inches="tight")
1649 plt.close(fig)
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.
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.
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.
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]
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)
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()
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)
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 )
1773 scatter = ax.scatter(gauss_xs, gauss_ys, c=values, cmap=cmap, s=150)
1774 fig.colorbar(scatter, ax=ax, label=label)
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)")
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)