Coverage for src/dictk/plot.py: 98%
420 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-09 23:57 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-09 23:57 +0000
1"""Matplotlib-based figure generation for dictk's image and DIC data."""
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.colors import Colormap
12from matplotlib.path import Path as MarkerPath
14from dictk.correlation import WindowingMethod, _kernel_pad, phase_correlation, window
15from dictk.image import PixelCoordinate, SubpixelCoordinate, subimage
18# Shared figure scale (pixels of image data per inch) for
19# subimage_bounds_plot() and subimage_plot(), so each saved figure's size
20# is proportional to its actual pixel content rather than a fixed default
21# figure size -- letting the two be visually compared for relative size
22# instead of both rendering at roughly the same size regardless of how
23# many pixels each actually covers.
24_FIGURE_PIXELS_PER_INCH = 100
26# The first 12 colors of matplotlib's "tab20" (Tableau 20) colormap: 6 hues
27# (blue, orange, green, red, purple, brown), each as a dark/light pair.
28# Deliberately stops short of tab20's gray pair (its indices 14-15) --
29# gray has no hue to contrast against a grayscale image with, so it all
30# but disappears drawn on top of one.
31_TABLEAU_PALETTE = (
32 "#1f77b4",
33 "#aec7e8", # blue
34 "#ff7f0e",
35 "#ffbb78", # orange
36 "#2ca02c",
37 "#98df8a", # green
38 "#d62728",
39 "#ff9896", # red
40 "#9467bd",
41 "#c5b0d5", # purple
42 "#8c564b",
43 "#c49c94", # brown
44)
47def subimage_bounds_plot(
48 *,
49 image: np.ndarray,
50 origin: PixelCoordinate,
51 width: int,
52 height: int,
53 path: Path,
54 dpi: int = 300,
55) -> None:
56 """Save a figure overlaying a source image's bounds and a subimage region.
58 Draws `image`'s own bounds in blue and the requested `origin`/`width`/
59 `height` region in red, on top of `image` itself — including cases
60 where the red region extends partially or completely outside the blue
61 one, to visualize what `subimage()` would crop from. An `'o'` marker
62 is also drawn at each rectangle's own origin: blue at `(0, 0)` for
63 `image`, red at `origin` for the subimage — both in `image`'s pixel
64 reference frame.
66 Args:
67 image: Source 2D grayscale image array.
68 origin: Top-left corner of the region, in `image`'s pixel
69 reference frame; see `subimage()`.
70 width: Width of the region in pixels. Must be >= 1.
71 height: Height of the region in pixels. Must be >= 1.
72 path: Output file path for the figure; format is inferred from
73 the extension by matplotlib's savefig (e.g. .png), not
74 dictk's own write/write_svg.
75 dpi: Resolution of the saved figure.
77 Raises:
78 ValueError: If width or height is less than 1.
79 """
80 if width < 1:
81 raise ValueError(f"width {width} must be >= 1")
82 if height < 1:
83 raise ValueError(f"height {height} must be >= 1")
85 image_height, image_width = image.shape
87 margin = max(width, height, image_width, image_height) * 0.05
88 x_min = min(0, origin.x) - margin
89 x_max = max(image_width, origin.x + width) + margin
90 y_min = min(0, origin.y) - margin
91 y_max = max(image_height, origin.y + height) + margin
93 figsize = (
94 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH,
95 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH,
96 )
97 fig, ax = plt.subplots(figsize=figsize)
98 ax.imshow(
99 image, cmap="gray", origin="upper", extent=(0, image_width, image_height, 0)
100 )
102 ax.add_patch(
103 patches.Rectangle(
104 (0, 0),
105 image_width,
106 image_height,
107 edgecolor="blue",
108 facecolor="none",
109 linewidth=1.5,
110 label="source image",
111 )
112 )
113 ax.add_patch(
114 patches.Rectangle(
115 (origin.x, origin.y),
116 width,
117 height,
118 edgecolor="red",
119 facecolor="none",
120 linewidth=1.5,
121 label="subimage",
122 )
123 )
124 ax.plot(0, 0, marker="o", color="blue", markersize=6)
125 ax.plot(origin.x, origin.y, marker="o", color="red", markersize=6)
127 ax.set_xlim(x_min, x_max)
128 ax.set_ylim(y_max, y_min) # inverted: image y increases downward
130 ax.set_xlabel("x (pixels)")
131 ax.set_ylabel("y (pixels)")
132 # Legend placed fully outside the axes (not just an "upper right"-style
133 # corner) so it never overlaps the source or subimage rectangles,
134 # regardless of how much of the frame they fill; bbox_inches="tight"
135 # on save keeps it from being clipped off the saved figure.
136 ax.legend(loc="center left", bbox_to_anchor=(1.02, 0.5), fontsize=8)
137 ax.set_title(f"subimage bounds: origin=({origin.x}, {origin.y}), {width}x{height}")
139 plt.savefig(path, dpi=dpi, bbox_inches="tight")
140 plt.close(fig)
143def subimage_plot(
144 *,
145 image: np.ndarray,
146 origin: PixelCoordinate,
147 width: int,
148 height: int,
149 path: Path,
150 dpi: int = 300,
151) -> None:
152 """Save a figure of the subimage itself, in its own local reference frame.
154 Extracts the `width` x `height` region at `origin` (via `subimage()`)
155 and plots just that result, labeled with its own local pixel
156 coordinates — `(0, 0)` at its own top-left corner — rather than
157 `image`'s coordinates. An `'o'` marker is drawn at that local origin
158 `(0, 0)`, matching the red origin marker `subimage_bounds_plot()`
159 draws at the same subimage in `image`'s reference frame. Unlike
160 `subimage_bounds_plot()`, which shows where the region falls relative
161 to `image`, this shows what the extracted result actually looks like,
162 including any zero (black) padding from parts of the region that fell
163 outside `image`.
165 Args:
166 image: Source 2D grayscale image array.
167 origin: Top-left corner of the region, in `image`'s pixel
168 reference frame; see `subimage()`.
169 width: Width of the region in pixels. Must be >= 1.
170 height: Height of the region in pixels. Must be >= 1.
171 path: Output file path for the figure; format is inferred from
172 the extension by matplotlib's savefig (e.g. .png), not
173 dictk's own write/write_svg.
174 dpi: Resolution of the saved figure.
176 Raises:
177 ValueError: If width or height is less than 1.
178 """
179 region = subimage(image=image, origin=origin, width=width, height=height)
181 # Same margin approach as subimage_bounds_plot(), so the red border
182 # gets the same small breathing room from the figure edge instead of
183 # sitting flush against it.
184 margin = max(width, height) * 0.05
185 x_min, x_max = -margin, width + margin
186 y_min, y_max = -margin, height + margin
188 figsize = (
189 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH,
190 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH,
191 )
192 fig, ax = plt.subplots(figsize=figsize)
193 ax.imshow(region, cmap="gray", origin="upper", extent=(0, width, height, 0))
194 ax.add_patch(
195 patches.Rectangle(
196 (0, 0),
197 width,
198 height,
199 edgecolor="red",
200 facecolor="none",
201 linewidth=1.5,
202 )
203 )
204 ax.plot(0, 0, marker="o", color="red", markersize=6)
206 ax.set_xlim(x_min, x_max)
207 ax.set_ylim(y_max, y_min) # inverted: image y increases downward
209 ax.set_xlabel("x (pixels)")
210 ax.set_ylabel("y (pixels)")
212 plt.savefig(path, dpi=dpi, bbox_inches="tight")
213 plt.close(fig)
216def subimage_comparison_plot(
217 *,
218 image: np.ndarray,
219 origin: PixelCoordinate,
220 width: int,
221 height: int,
222 path: Path,
223 point: PixelCoordinate | None = None,
224 point_color: str = "gold",
225 point_label: str | None = None,
226 subimage_label: str | None = None,
227 color: str = "red",
228 origin_label: str | None = None,
229 source_origin_label: str | None = None,
230 figsize: tuple[float, float] | None = None,
231 dpi: int = 300,
232) -> None:
233 """Save a side-by-side comparison of a subimage's placement and its extraction.
235 The left panel matches `subimage_bounds_plot()`: `image`'s bounds in
236 blue, the requested region in `color` (red by default), with an `'o'`
237 marker at each rectangle's own origin. The right panel shows the
238 extracted subimage (via `subimage()`) with a `color` border and
239 origin marker, in its own local reference frame — but drawn using the
240 *same* axis limits as the left panel, rather than being cropped or
241 zoomed to the subimage's own size the way `subimage_plot()` is. That
242 shared scale is what makes the two boxes render at identical size by
243 construction — same data units per pixel in both panels — rather than
244 approximating it by sizing each panel's figure independently around
245 its own content.
247 Args:
248 image: Source 2D grayscale image array.
249 origin: Top-left corner of the region, in `image`'s pixel
250 reference frame; see `subimage()`.
251 width: Width of the region in pixels. Must be >= 1.
252 height: Height of the region in pixels. Must be >= 1.
253 path: Output file path for the figure; format is inferred from
254 the extension by matplotlib's savefig (e.g. .png), not
255 dictk's own write/write_svg.
256 point: An optional point of interest, in `image`'s pixel reference
257 frame, marked with a `point_color` dot on both panels — at
258 `point` itself on the left, and at `point` translated into
259 the subimage's own local frame (`point` minus `origin`) on
260 the right, since that's the same physical point expressed in
261 each panel's own coordinates.
262 point_color: Matplotlib color name for `point`'s marker. Ignored
263 if `point` isn't given.
264 point_label: An optional short text label (e.g. `"P"`) drawn next
265 to `point`'s marker, on both panels. Ignored if `point` isn't
266 given.
267 subimage_label: An optional short label identifying what this
268 subimage represents (e.g. `"kernel"`), appended to the right
269 panel's "subimage" title as "subimage (label)". Left as-is
270 ("subimage") when not given.
271 color: Matplotlib color name for the subimage's bounding box and
272 origin marker, on both panels.
273 origin_label: An optional short text label (e.g. `"K"`) drawn
274 next to the subimage's own origin marker, on both panels.
275 source_origin_label: An optional short text label (e.g. `"O"`)
276 drawn next to `image`'s own origin marker (the blue dot),
277 on the left panel only -- the right panel has no equivalent
278 marker for it.
279 figsize: Optional (width, height) in inches for the saved figure
280 (both panels combined). By default the canvas is sized from
281 `image`/the subimage's own data extent (so a small subimage
282 yields a small figure); pass this to override with a fixed
283 size instead -- e.g. matplotlib's own default, `(6.4, 4.8)`,
284 where its default font sizes and line widths look as intended.
285 dpi: Resolution of the saved figure.
287 Raises:
288 ValueError: If width or height is less than 1.
289 """
290 if width < 1:
291 raise ValueError(f"width {width} must be >= 1")
292 if height < 1:
293 raise ValueError(f"height {height} must be >= 1")
295 region = subimage(image=image, origin=origin, width=width, height=height)
296 image_height, image_width = image.shape
298 margin = max(width, height, image_width, image_height) * 0.05
299 # Small up-right offset for marker labels, so label text doesn't sit
300 # directly on top of its own marker.
301 label_offset = max(image_width, image_height) * 0.03
302 label_font_size = 12
303 # source_origin_label/origin_label sit just up-right of a marker at
304 # (0, 0) -- the top-left corner of the panel's own content -- so only
305 # the top margin (where a label there would land, above the panel
306 # title) needs widening to fit one; left/right/bottom stay at the
307 # default margin instead of also carrying that extra, unused space.
308 # Anchored so the label's own bottom edge sits `label_offset` above
309 # the marker (matching the gap already below the marker, down to the
310 # box edge at y=0) and its top edge sits that same `label_offset`
311 # below the panel's own top border -- the same gap on both sides of
312 # the label, rather than a fixed multiple of the font size that
313 # doesn't relate to the marker's own gap below.
314 top_margin = margin
315 if source_origin_label is not None or origin_label is not None:
316 label_text_height = label_font_size / 72 * _FIGURE_PIXELS_PER_INCH
317 top_margin = max(margin, 2 * label_offset + label_text_height)
318 x_min = min(0, origin.x) - margin
319 x_max = max(image_width, origin.x + width) + margin
320 y_min = min(0, origin.y) - top_margin
321 y_max = max(image_height, origin.y + height) + margin
323 # A thin white outline keeps every marker label legible against
324 # whatever's directly behind it in the image content.
325 label_outline = [patheffects.withStroke(linewidth=1, foreground="white")]
327 if figsize is None:
328 panel_width = (x_max - x_min) / _FIGURE_PIXELS_PER_INCH
329 panel_height = (y_max - y_min) / _FIGURE_PIXELS_PER_INCH
330 figsize = (panel_width * 2, panel_height)
331 fig, (ax_left, ax_right) = plt.subplots(
332 1, 2, figsize=figsize, constrained_layout=True
333 )
335 ax_left.imshow(
336 image, cmap="gray", origin="upper", extent=(0, image_width, image_height, 0)
337 )
338 ax_left.add_patch(
339 patches.Rectangle(
340 (0, 0),
341 image_width,
342 image_height,
343 edgecolor="blue",
344 facecolor="none",
345 linewidth=1.5,
346 )
347 )
348 ax_left.add_patch(
349 patches.Rectangle(
350 (origin.x, origin.y),
351 width,
352 height,
353 edgecolor=color,
354 facecolor="none",
355 linewidth=1.5,
356 )
357 )
358 ax_left.plot(0, 0, marker="o", color="blue", markersize=6)
359 ax_left.plot(origin.x, origin.y, marker="o", color=color, markersize=6)
360 if point is not None:
361 ax_left.plot(point.x, point.y, marker="o", color=point_color, markersize=6)
362 if source_origin_label is not None:
363 ax_left.text(
364 label_offset,
365 -label_offset,
366 source_origin_label,
367 color="blue",
368 fontsize=label_font_size,
369 va="bottom",
370 path_effects=label_outline,
371 )
372 if origin_label is not None:
373 ax_left.text(
374 origin.x + label_offset,
375 origin.y - label_offset,
376 origin_label,
377 color=color,
378 fontsize=label_font_size,
379 va="bottom",
380 path_effects=label_outline,
381 )
382 if point is not None and point_label is not None:
383 ax_left.text(
384 point.x + label_offset,
385 point.y - label_offset,
386 point_label,
387 color=point_color,
388 fontsize=12,
389 path_effects=label_outline,
390 )
391 ax_left.set_xlim(x_min, x_max)
392 ax_left.set_ylim(y_max, y_min) # inverted: image y increases downward
393 ax_left.set_xlabel("x (pixels)")
394 ax_left.set_ylabel("y (pixels)")
395 ax_left.set_title("source image + subimage")
397 ax_right.imshow(region, cmap="gray", origin="upper", extent=(0, width, height, 0))
398 ax_right.add_patch(
399 patches.Rectangle(
400 (0, 0),
401 width,
402 height,
403 edgecolor=color,
404 facecolor="none",
405 linewidth=1.5,
406 )
407 )
408 ax_right.plot(0, 0, marker="o", color=color, markersize=6)
409 if point is not None:
410 ax_right.plot(
411 point.x - origin.x,
412 point.y - origin.y,
413 marker="o",
414 color=point_color,
415 markersize=6,
416 )
417 if origin_label is not None:
418 ax_right.text(
419 label_offset,
420 -label_offset,
421 origin_label,
422 color=color,
423 fontsize=label_font_size,
424 va="bottom",
425 path_effects=label_outline,
426 )
427 if point is not None and point_label is not None:
428 ax_right.text(
429 point.x - origin.x + label_offset,
430 point.y - origin.y - label_offset,
431 point_label,
432 color=point_color,
433 fontsize=12,
434 path_effects=label_outline,
435 )
436 # Same limits as ax_left, not the subimage's own tight size -- this is
437 # the whole point: identical data-units-per-pixel in both panels.
438 ax_right.set_xlim(x_min, x_max)
439 ax_right.set_ylim(y_max, y_min)
440 ax_right.set_xlabel("x (pixels)")
441 ax_right.set_ylabel("y (pixels)")
442 ax_right.set_title(
443 f"subimage ({subimage_label})" if subimage_label is not None else "subimage"
444 )
446 plt.savefig(path, dpi=dpi, bbox_inches="tight")
447 plt.close(fig)
450class ArrowAnnotation(NamedTuple):
451 """A labeled, colored arrow to overlay on an image, from `tail` to `head`.
453 Attributes:
454 tail: Arrow's starting point, in the image's pixel reference frame.
455 head: Arrow's ending point, in the image's pixel reference frame.
456 color: Matplotlib color name for the arrow and its legend entry.
457 label: Legend label for the arrow.
458 """
460 tail: PixelCoordinate
461 head: PixelCoordinate
462 color: str
463 label: str
466class BoxAnnotation(NamedTuple):
467 """A labeled, colored rectangle (with an origin marker) to overlay on an image.
469 Attributes:
470 origin: Top-left corner of the rectangle, in the image's pixel
471 reference frame; see `subimage()`.
472 width: Width of the rectangle in pixels.
473 height: Height of the rectangle in pixels.
474 color: Matplotlib color name for the rectangle, its origin
475 marker, and its legend entry.
476 label: Legend label for the rectangle.
477 """
479 origin: PixelCoordinate
480 width: int
481 height: int
482 color: str
483 label: str
486class PointAnnotation(NamedTuple):
487 """A short colored text label to draw at a point on an image, with no marker of its own.
489 Meant for labeling a point that already has its own marker drawn some
490 other way (e.g. an `ArrowAnnotation`'s head/tail, or a `BoxAnnotation`'s
491 origin) with a short symbol, like `"$P$"`.
493 Attributes:
494 position: Location of the label, in the image's pixel reference frame.
495 label: Short text to draw (e.g. `"$P$"`); LaTeX math (`$...$`) is
496 rendered via matplotlib's built-in mathtext.
497 color: Matplotlib color name for the label text.
498 """
500 position: PixelCoordinate
501 label: str
502 color: str
505def point_plot(
506 *,
507 image: np.ndarray,
508 arrows: Sequence[ArrowAnnotation],
509 boxes: Sequence[BoxAnnotation] = (),
510 points: Sequence[PointAnnotation] = (),
511 legend: bool = True,
512 figsize: tuple[float, float] | None = None,
513 path: Path,
514 dpi: int = 300,
515) -> None:
516 """Save a figure overlaying one or more labeled arrows (and boxes) on `image`.
518 Each `ArrowAnnotation` draws a straight arrow from `tail` to `head`, in
519 `image`'s own pixel reference frame — e.g. from the origin `(0, 0)` to a
520 point of interest, or between two points to show a displacement. Each
521 `BoxAnnotation` draws a rectangle with an `'o'` marker at its own
522 origin, layered behind the arrows but in front of `image` -- e.g. to
523 show where a kernel or search area sits on a figure that's mainly
524 about the arrows drawn on top of it. Each `PointAnnotation` draws a
525 short text label (with a thin white outline, so it stays legible
526 against the image) next to a point already marked some other way.
528 Args:
529 image: Source 2D grayscale image array.
530 arrows: One or more arrows to overlay, each with its own color and
531 legend label.
532 boxes: Zero or more rectangles to overlay underneath the arrows,
533 each with its own color and legend label.
534 points: Zero or more short text labels to draw, each next to a
535 point already marked by an arrow or box elsewhere in the
536 figure.
537 legend: Whether to draw the arrow/box legend. Set to False when
538 the arrow/box labels are already explained elsewhere (e.g. a
539 figure caption) and would just clutter the figure.
540 figsize: Optional (width, height) in inches for the saved figure.
541 By default the canvas is sized from the annotations' own
542 data extent (so a small image yields a small figure); pass
543 this to override with a fixed size instead -- e.g.
544 matplotlib's own default, `(6.4, 4.8)`, where its default
545 font sizes and line widths look as intended.
546 path: Output file path for the figure; format is inferred from the
547 extension by matplotlib's savefig (e.g. .png), not dictk's own
548 write/write_svg.
549 dpi: Resolution of the saved figure.
551 Raises:
552 ValueError: If `arrows` is empty.
553 """
554 if not arrows:
555 raise ValueError("arrows must not be empty")
557 image_height, image_width = image.shape
559 endpoints_x = [pt.x for arrow in arrows for pt in (arrow.tail, arrow.head)]
560 endpoints_y = [pt.y for arrow in arrows for pt in (arrow.tail, arrow.head)]
561 for box in boxes:
562 endpoints_x += [box.origin.x, box.origin.x + box.width]
563 endpoints_y += [box.origin.y, box.origin.y + box.height]
564 endpoints_x += [point.position.x for point in points]
565 endpoints_y += [point.position.y for point in points]
566 margin = max(image_width, image_height) * 0.05
567 # Small up-right offset for point labels, so label text doesn't sit
568 # directly on top of its own marker.
569 label_offset = max(image_width, image_height) * 0.03
570 label_font_size = 12
571 # Point labels sit just up-right of their marker, so only the top
572 # margin (where a label on a point near y=0 would land, above the
573 # title) needs widening to fit one; left/right/bottom stay at the
574 # default margin instead of also carrying that extra, unused space.
575 # Anchored so the label's own bottom edge sits `label_offset` above
576 # its marker (matching the gap already below the marker, e.g. down
577 # to a box edge at y=0) and its top edge sits that same
578 # `label_offset` below the figure's own top border -- the same gap
579 # on both sides of the label, rather than a fixed multiple of the
580 # font size that doesn't relate to the marker's own gap below.
581 top_margin = margin
582 if points:
583 label_text_height = label_font_size / 72 * _FIGURE_PIXELS_PER_INCH
584 top_margin = max(margin, 2 * label_offset + label_text_height)
585 x_min = min(0, *endpoints_x) - margin
586 x_max = max(image_width, *endpoints_x) + margin
587 y_min = min(0, *endpoints_y) - top_margin
588 y_max = max(image_height, *endpoints_y) + margin
589 label_outline = [patheffects.withStroke(linewidth=1, foreground="white")]
591 if figsize is None:
592 figsize = (
593 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH,
594 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH,
595 )
596 fig, ax = plt.subplots(figsize=figsize)
597 ax.imshow(
598 image, cmap="gray", origin="upper", extent=(0, image_width, image_height, 0)
599 )
601 for box in boxes:
602 ax.add_patch(
603 patches.Rectangle(
604 (box.origin.x, box.origin.y),
605 box.width,
606 box.height,
607 edgecolor=box.color,
608 facecolor="none",
609 linewidth=1.5,
610 )
611 )
612 ax.plot(box.origin.x, box.origin.y, marker="o", color=box.color, markersize=6)
614 for arrow in arrows:
615 ax.annotate(
616 "",
617 xy=(arrow.head.x, arrow.head.y),
618 xytext=(arrow.tail.x, arrow.tail.y),
619 arrowprops={
620 "color": arrow.color,
621 "width": 1.5,
622 "headwidth": 8,
623 "shrink": 0,
624 },
625 )
627 # Drawn last (on top of the arrows), so a label sitting where an arrow
628 # starts or ends doesn't get partly covered by the arrow itself.
629 for point in points:
630 ax.text(
631 point.position.x + label_offset,
632 point.position.y - label_offset,
633 point.label,
634 color=point.color,
635 fontsize=label_font_size,
636 va="bottom",
637 path_effects=label_outline,
638 )
639 # ax.annotate's arrows don't register with the legend on their own, so
640 # proxy line handles stand in for each arrow's (and box's) color/label.
641 handles = [
642 plt.Line2D([0], [0], color=arrow.color, lw=1.5, label=arrow.label)
643 for arrow in arrows
644 ] + [
645 patches.Patch(edgecolor=box.color, facecolor="none", label=box.label)
646 for box in boxes
647 ]
649 ax.set_xlim(x_min, x_max)
650 ax.set_ylim(y_max, y_min) # inverted: image y increases downward
651 ax.set_xlabel("x (pixels)")
652 ax.set_ylabel("y (pixels)")
653 if legend:
654 # Same fully-outside legend placement as subimage_bounds_plot(), so
655 # it never overlaps the image regardless of arrow placement.
656 ax.legend(
657 handles=handles, loc="center left", bbox_to_anchor=(1.02, 0.5), fontsize=8
658 )
660 plt.savefig(path, dpi=dpi, bbox_inches="tight")
661 plt.close(fig)
664def reference_frame_plot(*, image: np.ndarray, path: Path, dpi: int = 300) -> None:
665 """Save a side-by-side figure contrasting `image` with its reference frame made explicit.
667 Left panel: `image` alone, with no annotation — no axes box, ticks, or
668 labels either — a reminder that a pixel reference frame is always
669 implicitly present, even when nothing is drawn to show it. Right
670 panel: the same `image`, with its bounds outlined in blue and a blue
671 'o' marker at its origin `(0, 0)` (top-left corner) — the same style
672 `subimage_bounds_plot()` uses for a source image — plus a red arrow
673 along the x-axis and a green arrow along the y-axis, both starting
674 from that origin, making the top-left-origin, y-increases-downward
675 pixel convention used throughout this codebase explicit, labeled with
676 the calligraphic frame symbol "F" just above and to the left of the
677 origin marker. The "x"/"y" axis labels sit in the margin outside the
678 blue frame, rather than on top of it, so they don't overlap its
679 border.
681 Args:
682 image: Source 2D grayscale image array.
683 path: Output file path for the figure; format is inferred from
684 the extension by matplotlib's savefig (e.g. .png), not
685 dictk's own write/write_svg.
686 dpi: Resolution of the saved figure.
687 """
688 image_height, image_width = image.shape
689 axis_length = min(image_width, image_height) * 0.2
691 margin = max(image_width, image_height) * 0.05
692 # The "x"/"y" labels need enough margin to sit clear of both the blue
693 # frame and the axes spine; the label's rendered size only depends on
694 # its font size and the data-units-per-inch scale figsize is built
695 # from, not on image size, so a small image's default margin (5% of
696 # its own dimensions) can otherwise be too little to fit it.
697 label_font_size = 12
698 frame_label_font_size = 14
699 min_margin_for_labels = (
700 max(label_font_size, frame_label_font_size) * 2.2 / 72 * _FIGURE_PIXELS_PER_INCH
701 )
702 margin = max(margin, min_margin_for_labels)
704 x_min, x_max = -margin, image_width + margin
705 y_min, y_max = -margin, image_height + margin
707 panel_width = (x_max - x_min) / _FIGURE_PIXELS_PER_INCH
708 panel_height = (y_max - y_min) / _FIGURE_PIXELS_PER_INCH
709 fig, (ax_left, ax_right) = plt.subplots(
710 1, 2, figsize=(panel_width * 2, panel_height), constrained_layout=True
711 )
713 for ax in (ax_left, ax_right):
714 ax.imshow(
715 image,
716 cmap="gray",
717 origin="upper",
718 extent=(0, image_width, image_height, 0),
719 )
720 ax.set_xlim(x_min, x_max)
721 ax.set_ylim(y_max, y_min) # inverted: image y increases downward
723 ax_left.set_title("image")
724 ax_left.axis("off")
726 ax_right.set_xlabel("x (pixels)")
727 ax_right.set_ylabel("y (pixels)")
729 ax_right.add_patch(
730 patches.Rectangle(
731 (0, 0),
732 image_width,
733 image_height,
734 edgecolor="blue",
735 facecolor="none",
736 linewidth=1.5,
737 )
738 )
739 ax_right.plot(0, 0, marker="o", color="blue", markersize=8)
741 # Frame label sits just above and to the left of the origin marker,
742 # in the empty diagonal corner the x-/y-axis arrows leave untouched.
743 frame_label_offset = margin * 0.35
744 ax_right.text(
745 -frame_label_offset,
746 -frame_label_offset,
747 r"$\mathcal{F}$",
748 color="blue",
749 fontsize=frame_label_font_size,
750 ha="right",
751 va="bottom",
752 )
754 # Labels sit centered in the margin strip between the blue frame and
755 # the axes spine, rather than at the arrowhead itself, so they don't
756 # touch either (the arrows run flush along the top/left edges of the
757 # frame's border).
758 label_offset = margin / 2
759 for axis_head, label_pos, color, label, ha, va in (
760 (
761 (axis_length, 0),
762 (axis_length, -label_offset),
763 "red",
764 "x",
765 "center",
766 "bottom",
767 ),
768 (
769 (0, axis_length),
770 (-label_offset, axis_length),
771 "green",
772 "y",
773 "right",
774 "center",
775 ),
776 ):
777 ax_right.annotate(
778 "",
779 xy=axis_head,
780 xytext=(0, 0),
781 arrowprops={"color": color, "width": 1.5, "headwidth": 8, "shrink": 0},
782 )
783 ax_right.text(
784 *label_pos, label, color=color, fontsize=label_font_size, ha=ha, va=va
785 )
787 ax_right.set_title("reference frame")
789 plt.savefig(path, dpi=dpi, bbox_inches="tight")
790 plt.close(fig)
793def histogram_save(*, arr: np.ndarray, path: Path, dpi: int = 300) -> None:
794 """Save a histogram of pixel intensities [0, 255] for a grayscale image.
796 Args:
797 arr: The 2D grayscale image array, expected type uint8, range [0, 255].
798 path: The output file path for the histogram image; format is
799 inferred from the extension by matplotlib's savefig (e.g.
800 .png), not dictk's own write/write_svg.
801 dpi: Resolution of the saved figure.
802 """
803 plt.figure()
804 plt.hist(arr.ravel(), bins=256, range=(0, 255), color="black", alpha=0.7)
805 plt.title(f"pixel histogram intensity\n{path}", fontsize=8)
806 plt.xlabel("pixel intensity (0-255)")
807 plt.ylabel("frequency")
808 plt.savefig(path, dpi=dpi)
809 plt.close()
812def _correlation_surface_ticks(size: int) -> list[int]:
813 """Fixed Correlation Surface tick marks for an axis of length `size`.
815 Correlation Visualization shows this panel seven times: CC/NCC/ZCC/
816 ZNCC's 51x51 "valid" surfaces, and phase correlation's three
817 same-shape-as-`search` 100x100 ones (no windowing/Hann/Hamming).
818 Rather than matplotlib's own per-panel default (which lands on
819 different steps for the two sizes, and doesn't always reach the
820 axis's own upper bound), each of those two known sizes gets one
821 fixed, round-number tick list spanning its own full extent -- so
822 every 51x51 panel matches every other 51x51 panel, and every 100x100
823 one matches every other 100x100 one, when read side by side.
825 Any other `size` (e.g. Recoverable Displacement Range's 300x300
826 quadrant figures, `search_margin=150` against `astronaut0`'s own
827 300px canvas) falls back to a generated five-interval, round-number
828 step instead of matplotlib's own default -- the original problem
829 this function exists to fix in the first place. Without this
830 fallback, every `size` other than the two known ones used to
831 silently reuse the 100-wide list regardless of its own actual
832 extent, cramming every tick into the axis's first 100 pixels.
834 Args:
835 size: The axis's length in pixels (a Correlation Surface panel's
836 own height or width).
838 Returns:
839 `[0, 10, 20, 30, 40, 50]` for the 51-wide "valid" surfaces,
840 `[0, 20, 40, 60, 80, 100]` for the 100-wide full-search ones,
841 otherwise five evenly-spaced ticks from 0 up to (as close as a
842 multiple-of-10 step allows) `size` itself.
843 """
844 if size == 51:
845 return [0, 10, 20, 30, 40, 50]
846 if size == 100:
847 return [0, 20, 40, 60, 80, 100]
848 step = max(10, round(size / 5 / 10) * 10)
849 return list(range(0, size + 1, step))
852def _correlation_quadrant_plot(
853 *,
854 kernel: np.ndarray,
855 search: np.ndarray,
856 correlation_surface: np.ndarray,
857 title: str,
858 path: Path,
859 figsize: tuple[float, float] = (8.0, 8.0),
860 dpi: int = 300,
861 vicinity_margin: int = 4,
862 reported_position: PixelCoordinate | None = None,
863 reported_position_label: str = "reported",
864 centered: bool = False,
865) -> None:
866 """Shared renderer behind spatial_correlation_quadrant_plot() and
867 phase_correlation_quadrant_plot() -- private (never published by
868 pdoc), so those two public functions each carry their own complete
869 docstring rather than pointing here.
871 Draws the Fixed Image / Moving Image / correlation surface / Solution
872 Vicinity 2x2 layout for whatever `correlation_surface` array it's
873 given. Shape-agnostic on purpose: works identically whether the
874 surface came from a spatial-domain "valid" computation (smaller than
875 `search`) or a same-shape-as-`search` Fourier-domain one -- it only
876 ever takes that array's own argmax and plots whatever shape comes in.
878 `reported_position`, if given, is a second position in `search`'s own
879 local frame (same top-left-corner convention as the surface's own
880 peak) -- some other, external computation's *claimed* answer, which
881 may differ from where `correlation_surface` itself actually peaks.
882 Drawn as a second, dotted magenta box on the Fixed Image panel,
883 distinct from the surface's own yellow dashed one, with a legend
884 naming both. `None` (default) omits it entirely, leaving every
885 existing figure byte-identical to before this parameter existed.
887 `centered`, if `True`, pads the Moving Image panel's `kernel` display
888 the same way `dictk.translation.locate` does internally (via
889 `_kernel_pad(..., centered=True)`) instead of the permanent
890 bottom-right-only padding `phase_correlation` itself always uses (see
891 [Recoverable Displacement
892 Range](../../getting_started/recoverable_displacement_range.html) for
893 why those two conventions differ). `correlation_surface`'s own raw
894 `argmax` is always relative to *that* padding, so with centered
895 padding the Fixed Image panel's box needs `kernel_padded`'s own
896 content offset added back in to land on the true match position --
897 `correlation_surface`'s peak itself (and the Correlation Surface/
898 Solution Vicinity panels showing it) is unaffected, still the raw
899 array position. Default `False` reproduces the original bottom-right
900 padding exactly, byte-identical to every figure from before this
901 parameter existed.
902 """
903 if search.shape[0] < kernel.shape[0] or search.shape[1] < kernel.shape[1]:
904 raise ValueError(
905 f"search shape {search.shape} must be >= kernel shape {kernel.shape} "
906 "in both dimensions"
907 )
909 search_height, search_width = search.shape
910 kernel_height, kernel_width = kernel.shape
912 peak_y, peak_x = np.unravel_index(
913 np.argmax(correlation_surface), correlation_surface.shape
914 )
915 peak_y, peak_x = int(peak_y), int(peak_x)
917 if centered:
918 # Same padding dictk.translation.locate() applies internally --
919 # kernel_padded's own content no longer starts at (0, 0), so the
920 # Fixed Image panel's box needs that offset added back in to the
921 # surface's own raw peak to land on the true match position.
922 kernel_padded, pad_before_height, pad_before_width = _kernel_pad(
923 kernel=kernel, shape=search.shape, centered=True
924 )
925 box_x = (peak_x + pad_before_width) % search_width
926 box_y = (peak_y + pad_before_height) % search_height
927 else:
928 # Same bottom/right zero-padding dictk.translation.locate() applied
929 # before its own fix, here purely for display so the kernel's
930 # content sits at the correct corner of a search-shaped canvas.
931 kernel_padded = np.pad(
932 kernel,
933 ((0, search_height - kernel_height), (0, search_width - kernel_width)),
934 )
935 box_x, box_y = peak_x, peak_y
937 with plt.rc_context({"font.family": "serif", "mathtext.fontset": "cm"}):
938 fig, axes = plt.subplots(2, 2, figsize=figsize, constrained_layout=True)
939 fig.suptitle(title)
940 ax1, ax2, ax3, ax4 = axes.flat
942 im1 = ax1.imshow(
943 search,
944 cmap="gray",
945 vmin=0,
946 vmax=255,
947 origin="upper",
948 extent=(0, search_width, search_height, 0),
949 )
950 plt.colorbar(im1, ax=ax1, shrink=0.8)
951 ax1.add_patch(
952 patches.Rectangle(
953 (box_x, box_y),
954 kernel_width,
955 kernel_height,
956 edgecolor="yellow",
957 facecolor="none",
958 linestyle="--",
959 linewidth=1,
960 alpha=0.8,
961 label="correlation surface peak",
962 )
963 )
964 ax1.axvline(x=box_x, color="red", linestyle="--", linewidth=1, alpha=0.8)
965 ax1.axhline(y=box_y, color="green", linestyle="--", linewidth=1, alpha=0.8)
966 xticks = {0, search_width, box_x}
967 yticks = {0, search_height, box_y}
968 if reported_position is not None:
969 ax1.add_patch(
970 patches.Rectangle(
971 (reported_position.x, reported_position.y),
972 kernel_width,
973 kernel_height,
974 edgecolor="magenta",
975 facecolor="none",
976 linestyle=":",
977 linewidth=1.5,
978 alpha=0.5,
979 label=reported_position_label,
980 )
981 )
982 xticks.add(reported_position.x)
983 yticks.add(reported_position.y)
984 ax1.legend(loc="upper right", fontsize=7, framealpha=0.9)
985 ax1.set_xticks(sorted(xticks))
986 ax1.set_yticks(sorted(yticks))
987 for label in ax1.get_xticklabels():
988 if label.get_text() == str(box_x):
989 label.set_color("red")
990 for label in ax1.get_yticklabels():
991 if label.get_text() == str(box_y):
992 label.set_color("green")
993 ax1.set_title(r"Fixed Image with frame $\mathcal{S}$")
994 ax1.set_xlabel("x (pixels)")
995 ax1.set_ylabel("y (pixels)")
997 im2 = ax2.imshow(
998 kernel_padded,
999 cmap="gray",
1000 vmin=0,
1001 vmax=255,
1002 origin="upper",
1003 extent=(0, search_width, search_height, 0),
1004 )
1005 plt.colorbar(im2, ax=ax2, shrink=0.8)
1006 if centered:
1007 kernel_xticks = {
1008 0,
1009 search_width,
1010 pad_before_width,
1011 pad_before_width + kernel_width,
1012 }
1013 kernel_yticks = {
1014 0,
1015 search_height,
1016 pad_before_height,
1017 pad_before_height + kernel_height,
1018 }
1019 ax2.set_title(r"Moving Image with frame $\mathcal{K}$ (centered)")
1020 else:
1021 kernel_xticks = {0, search_width, kernel_width}
1022 kernel_yticks = {0, search_height, kernel_height}
1023 ax2.set_title(r"Moving Image with frame $\mathcal{K}$")
1024 ax2.set_xticks(sorted(kernel_xticks))
1025 ax2.set_yticks(sorted(kernel_yticks))
1026 ax2.set_xlabel("x (pixels)")
1027 ax2.set_ylabel("y (pixels)")
1029 im3 = ax3.imshow(correlation_surface, cmap="viridis", origin="upper")
1030 plt.colorbar(im3, ax=ax3, shrink=0.8)
1031 ax3.add_patch(
1032 patches.Circle(
1033 (peak_x, peak_y),
1034 radius=vicinity_margin,
1035 edgecolor="red",
1036 facecolor="none",
1037 linewidth=1.5,
1038 )
1039 )
1040 ax3.set_title("Correlation Surface")
1041 ax3.set_xlabel(r"$\Delta x$ offset (pixels)")
1042 ax3.set_ylabel(r"$\Delta y$ offset (pixels)")
1043 # Fixed ticks, not matplotlib's own auto-choice -- see
1044 # _correlation_surface_ticks() for why.
1045 surface_height, surface_width = correlation_surface.shape
1046 ax3.set_xticks(_correlation_surface_ticks(surface_width))
1047 ax3.set_yticks(_correlation_surface_ticks(surface_height))
1049 im4 = ax4.imshow(correlation_surface, cmap="viridis", origin="upper")
1050 plt.colorbar(im4, ax=ax4, shrink=0.8)
1051 ax4.set_xlim(peak_x - vicinity_margin, peak_x + vicinity_margin)
1052 ax4.set_ylim(peak_y + vicinity_margin, peak_y - vicinity_margin)
1053 # Same circle as ax3, same radius, in the same data coordinates --
1054 # since this panel is zoomed to exactly peak +/- vicinity_margin,
1055 # the circle exactly reaches this panel's own edges, appearing
1056 # clipped by them rather than fully visible as it was in ax3.
1057 ax4.add_patch(
1058 patches.Circle(
1059 (peak_x, peak_y),
1060 radius=vicinity_margin,
1061 edgecolor="red",
1062 facecolor="none",
1063 linewidth=1.5,
1064 )
1065 )
1066 ax4.set_title("Solution Vicinity")
1067 ax4.set_xlabel(r"$\Delta x$ offset (pixels)")
1068 ax4.set_ylabel(r"$\Delta y$ offset (pixels)")
1070 fig.savefig(path, dpi=dpi)
1071 plt.close(fig)
1074def spatial_correlation_quadrant_plot(
1075 *,
1076 kernel: np.ndarray,
1077 search: np.ndarray,
1078 correlation_surface: np.ndarray,
1079 title: str,
1080 path: Path,
1081 figsize: tuple[float, float] = (8.0, 8.0),
1082 dpi: int = 300,
1083 vicinity_margin: int = 4,
1084) -> None:
1085 r"""Save a 2x2 composite figure illustrating one spatial-domain correlation criterion end to end.
1087 Reproduces a reference composite-figure layout used in prior DIC
1088 tooling -- Fixed Image, Moving Image, the correlation surface, and a
1089 zoomed Solution Vicinity -- using dictk's own spatial-domain
1090 correlation surfaces ([`dictk.correlation`](../correlation.html)'s
1091 `cc`/`ncc`/`zcc`/`zncc`) rather than a zero-padded whole-image FFT
1092 approach. See
1093 [`phase_correlation_quadrant_plot`](#phase_correlation_quadrant_plot)
1094 for the Fourier-domain sibling of this function.
1095 `correlation_surface`'s own argmax directly gives the kernel's found
1096 offset within `search`'s own frame $\mathcal{S}$ -- the same
1097 $\boldsymbol{r}_{SK/\mathcal{S}}$ quantity [Cross Correlation
1098 (CC)](../../getting_started/cross_correlation.html) walks through by
1099 hand -- so no separate found-position argument is needed the way that
1100 prior tooling's own composite-figure function takes one.
1102 Top-left panel: `search`, in its own local frame $\mathcal{S}$, with a
1103 yellow dashed box marking where `kernel` was found, plus red/green
1104 dashed guide lines through that box's origin (and matching red/green
1105 tick labels at that position). Top-right panel: `kernel` zero-padded
1106 (bottom and right) up to `search`'s own shape -- the same padding
1107 [`dictk.translation.locate`](../translation.html#locate) does
1108 internally -- so its content occupies only the top-left corner of an
1109 otherwise-black canvas the size of `search`, labeled frame
1110 $\mathcal{K}$. Bottom-left: `correlation_surface` as a heatmap, peak
1111 marked with a red circle of radius `vicinity_margin`. Bottom-right:
1112 the same surface, zoomed to exactly that same `vicinity_margin`
1113 pixels around its own peak -- the same circle reappears there too,
1114 now clipped by the panel's own edges, since the zoom window is
1115 exactly the circle's own bounding box.
1117 Text renders via matplotlib's built-in mathtext with a Computer-Modern
1118 -style serif font (`mathtext.fontset="cm"`), not real LaTeX
1119 (`text.usetex`) -- visually close to a real-LaTeX-rendered figure
1120 without a system TeX install, scoped to this function alone via
1121 `rc_context` so it can't leak into any other figure.
1123 Args:
1124 kernel: The extracted kernel subimage (2D grayscale array).
1125 search: The extracted search-area subimage (2D grayscale array);
1126 must be at least as large as `kernel` in both dimensions.
1127 correlation_surface: One of `dictk.correlation`'s `cc`/`ncc`/`zcc`/
1128 `zncc` surfaces, computed from this same `kernel`/`search`
1129 pair. Its own argmax is taken as the found position.
1130 title: Figure-level title naming the correlation criterion shown,
1131 e.g. `"Zero-mean Normalized Cross-Correlation (ZNCC)"` --
1132 rendered as a `suptitle` spanning the full figure width rather
1133 than the correlation-surface panel's own title, since panel
1134 titles are too narrow to reliably fit the longer criterion
1135 names without truncating or overlapping their colorbar.
1136 path: Output file path for the figure; format is inferred from the
1137 extension by matplotlib's savefig (e.g. .png).
1138 figsize: (width, height) in inches for the saved figure.
1139 dpi: Resolution of the saved figure.
1140 vicinity_margin: Half-width/height, in pixels, of the Solution
1141 Vicinity zoom window around the correlation surface's peak.
1143 Raises:
1144 ValueError: If `search` is smaller than `kernel` in either
1145 dimension.
1146 """
1147 _correlation_quadrant_plot(
1148 kernel=kernel,
1149 search=search,
1150 correlation_surface=correlation_surface,
1151 title=title,
1152 path=path,
1153 figsize=figsize,
1154 dpi=dpi,
1155 vicinity_margin=vicinity_margin,
1156 )
1159def phase_correlation_quadrant_plot(
1160 *,
1161 kernel: np.ndarray,
1162 search: np.ndarray,
1163 windowing: WindowingMethod | None = None,
1164 title: str = "Phase Correlation",
1165 path: Path,
1166 figsize: tuple[float, float] = (8.0, 8.0),
1167 dpi: int = 300,
1168 vicinity_margin: int = 4,
1169 reported_position: PixelCoordinate | None = None,
1170 reported_position_label: str = "reported",
1171 centered: bool = False,
1172) -> None:
1173 r"""Save a 2x2 composite figure illustrating Fourier-domain phase correlation end to end.
1175 The Fourier-domain sibling of
1176 [`spatial_correlation_quadrant_plot`](#spatial_correlation_quadrant_plot),
1177 sharing that function's exact panel layout (Fixed Image, Moving Image,
1178 correlation surface, zoomed Solution Vicinity). Unlike its sibling,
1179 this function takes raw `kernel`/`search` rather than a pre-computed
1180 surface: spatial-domain correlation has four interchangeable criteria
1181 (`cc`/`ncc`/`zcc`/`zncc`) a caller must choose between and compute
1182 themselves, but there is only one Fourier-domain flavor here, so this
1183 computes it internally via
1184 [`dictk.correlation.phase_correlation`](../correlation.html#phase_correlation)
1185 -- see that function's own docstring for the algorithm itself and why
1186 it lands in the same "robust to both brightness and contrast" tier as
1187 `zncc`, by a completely different mechanism.
1189 Top-left panel: `search`, in its own local frame $\mathcal{S}$, with a
1190 yellow dashed box marking where `kernel` was found, plus red/green
1191 dashed guide lines through that box's origin (and matching red/green
1192 tick labels at that position). Top-right panel: `kernel` zero-padded
1193 (bottom and right) up to `search`'s own shape -- the same padding
1194 [`dictk.translation.locate`](../translation.html#locate) does
1195 internally -- so its content occupies only the top-left corner of an
1196 otherwise-black canvas the size of `search`, labeled frame
1197 $\mathcal{K}$. Bottom-left: the phase correlation surface as a
1198 heatmap, peak marked with a red circle of radius `vicinity_margin`.
1199 Bottom-right: the same surface, zoomed to exactly that same
1200 `vicinity_margin` pixels around its own peak -- the same circle
1201 reappears there too, now clipped by the panel's own edges, since the
1202 zoom window is exactly the circle's own bounding box.
1204 Text renders via matplotlib's built-in mathtext with a Computer-Modern
1205 -style serif font (`mathtext.fontset="cm"`), not real LaTeX
1206 (`text.usetex`) -- visually close to a real-LaTeX-rendered figure
1207 without a system TeX install, scoped to this function alone via
1208 `rc_context` so it can't leak into any other figure.
1210 Args:
1211 kernel: The extracted kernel subimage (2D grayscale array).
1212 search: The extracted search-area subimage (2D grayscale array);
1213 must be at least as large as `kernel` in both dimensions.
1214 windowing: If given, passed straight through to
1215 [`phase_correlation`](../correlation.html#phase_correlation) --
1216 tapers `kernel`/`search` before computing the surface. The
1217 same tapered `kernel`/`search` are what the Fixed Image and
1218 Moving Image panels display too (windowed, *then* zero-padded
1219 for the Moving Image panel, same order the surface itself is
1220 computed in), so those panels always show what was actually
1221 correlated -- not a stale, untapered view next to a surface
1222 that no longer matches it. Default `None` applies no
1223 windowing, and the panels look exactly as they always have.
1224 title: Figure-level title, rendered as a `suptitle` spanning the
1225 full figure width. Defaults to `"Phase Correlation"` since
1226 there's only one flavor here -- override if different phrasing
1227 is wanted.
1228 path: Output file path for the figure; format is inferred from the
1229 extension by matplotlib's savefig (e.g. .png).
1230 figsize: (width, height) in inches for the saved figure.
1231 dpi: Resolution of the saved figure.
1232 vicinity_margin: Half-width/height, in pixels, of the Solution
1233 Vicinity zoom window around the correlation surface's peak.
1234 reported_position: A second position, in `search`'s own local
1235 frame (same top-left-corner convention as the surface's own
1236 peak), to mark on the Fixed Image panel as a dotted magenta
1237 box distinct from the surface's own yellow dashed one --
1238 some other, external computation's *claimed* answer, useful
1239 when that answer might disagree with where this surface
1240 itself actually peaks (e.g. [Recoverable Displacement
1241 Range](../../getting_started/recoverable_displacement_range.html)'s
1242 pre-fix `locate` reporting a wrapped, wrong position even
1243 though the underlying surface it was computed from peaks at
1244 the correct one). Default `None` omits it entirely, leaving
1245 every figure that doesn't pass it byte-identical to before
1246 this parameter existed.
1247 reported_position_label: Legend label for `reported_position`'s
1248 box, shown alongside "correlation surface peak" for the
1249 existing yellow one. Only rendered (and only then does a
1250 legend appear at all) when `reported_position` is given.
1251 centered: Passed straight through to
1252 [`phase_correlation`](../correlation.html#phase_correlation)'s
1253 own `centered` parameter -- the same convention
1254 `dictk.translation.locate` uses internally. The Moving Image
1255 panel's padding, and the Fixed Image panel's box position,
1256 follow suit (see `_correlation_quadrant_plot`'s own note on
1257 why the box needs the padding offset added back in). Default
1258 `False` matches `phase_correlation`'s own default exactly,
1259 byte-identical to every figure from before this parameter
1260 existed.
1262 Raises:
1263 ValueError: If `search` is smaller than `kernel` in either
1264 dimension.
1265 """
1266 correlation_surface = phase_correlation(
1267 kernel=kernel, search=search, windowing=windowing, centered=centered
1268 )
1269 display_kernel, display_search = kernel, search
1270 if windowing is not None:
1271 display_kernel = window(arr=kernel, method=windowing)
1272 display_search = window(arr=search, method=windowing)
1274 _correlation_quadrant_plot(
1275 kernel=display_kernel,
1276 search=display_search,
1277 correlation_surface=correlation_surface,
1278 title=title,
1279 path=path,
1280 figsize=figsize,
1281 dpi=dpi,
1282 vicinity_margin=vicinity_margin,
1283 reported_position=reported_position,
1284 reported_position_label=reported_position_label,
1285 centered=centered,
1286 )
1289def point_grid_boxes_plot(
1290 *,
1291 image: np.ndarray,
1292 points: Sequence[PixelCoordinate],
1293 margin_width: int,
1294 margin_height: int,
1295 label_prefix: str,
1296 figsize: tuple[float, float] | None = None,
1297 path: Path,
1298 dpi: int = 300,
1299) -> None:
1300 """Save a figure overlaying one uniquely colored, labeled box per point on `image`.
1302 For each of `points`, draws an unfilled rectangle centered on it, sized
1303 `2 * margin_width` by `2 * margin_height` -- e.g. a kernel (the patch
1304 [`dictk.translation.locate`](../translation.html#locate) would extract
1305 from the reference image) or a search area (its default search
1306 region), one call per box type. Agnostic to which: call it once with a
1307 kernel's margins and once with a search area's margins (on separate
1308 figures, or via multiple calls onto the same `ax` for a combined one)
1309 to compare either against point spacing at a glance -- e.g. whether
1310 neighboring kernels overlap, or whether search areas run off the image
1311 -- across the whole grid at once, not just one point.
1313 Each point's box gets its own color, cycling through a 12-color
1314 Tableau palette (`dictk.image._TABLEAU_PALETTE`; if there are more
1315 than 12 points, colors repeat), and its own legend entry -- `points[0]`
1316 labeled `"{label_prefix} 00"`, `points[19]` labeled `"{label_prefix}
1317 19"`, for a 20-point collection -- so overlapping boxes stay visually
1318 distinguishable and individually identifiable, not just grouped by box
1319 type.
1321 Args:
1322 image: Source 2D grayscale image array.
1323 points: The points to draw boxes around, in the image's own pixel
1324 reference frame. May be empty (an unmarked copy of `image` is
1325 saved).
1326 margin_width: Half each box's width, in pixels.
1327 margin_height: Half each box's height, in pixels.
1328 label_prefix: Legend label prefix for the boxes, e.g. `"kernel"`
1329 or `"search area"` -- each point's own zero-padded index is
1330 appended to it.
1331 figsize: Optional (width, height) in inches for the saved figure.
1332 By default the canvas is sized from `image`/the boxes' own
1333 data extent; pass this to override with a fixed size instead.
1334 path: Output file path for the figure; format is inferred from the
1335 extension by matplotlib's savefig (e.g. .png), not dictk's own
1336 write/write_svg.
1337 dpi: Resolution of the saved figure.
1338 """
1339 image_height, image_width = image.shape
1341 endpoints_x = [
1342 point.x + sign * margin_width for point in points for sign in (-1, 1)
1343 ]
1344 endpoints_y = [
1345 point.y + sign * margin_height for point in points for sign in (-1, 1)
1346 ]
1347 margin = max(image_width, image_height) * 0.05
1348 x_min = min(0, *endpoints_x, 0) - margin
1349 x_max = max(image_width, *endpoints_x, image_width) + margin
1350 y_min = min(0, *endpoints_y, 0) - margin
1351 y_max = max(image_height, *endpoints_y, image_height) + margin
1353 if figsize is None:
1354 figsize = (
1355 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH,
1356 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH,
1357 )
1358 fig, ax = plt.subplots(figsize=figsize)
1359 ax.imshow(
1360 image, cmap="gray", origin="upper", extent=(0, image_width, image_height, 0)
1361 )
1363 index_width = len(str(len(points) - 1)) if len(points) > 1 else 2
1364 for i, point in enumerate(points):
1365 ax.add_patch(
1366 patches.Rectangle(
1367 (point.x - margin_width, point.y - margin_height),
1368 2 * margin_width,
1369 2 * margin_height,
1370 edgecolor=_TABLEAU_PALETTE[i % len(_TABLEAU_PALETTE)],
1371 facecolor="none",
1372 linewidth=1.0,
1373 label=f"{label_prefix} {i:0{index_width}d}",
1374 )
1375 )
1377 ax.set_xlim(x_min, x_max)
1378 ax.set_ylim(y_max, y_min) # inverted: image y increases downward
1379 ax.set_xlabel("x (pixels)")
1380 ax.set_ylabel("y (pixels)")
1381 if points:
1382 ax.legend(loc="center left", bbox_to_anchor=(1.02, 0.5), fontsize=7)
1384 plt.savefig(path, dpi=dpi, bbox_inches="tight")
1385 plt.close(fig)
1388def _reticle_marker_path(
1389 *,
1390 ring_radius: float = 0.85,
1391 tick_outer: float = 1.20,
1392 num_circle_points: int = 64,
1393) -> MarkerPath:
1394 """A target-reticle glyph: a ring with four tick marks poking through it.
1396 Built as a matplotlib marker path (unit-scaled to roughly [-1, 1]) meant
1397 to be *stroked*, not filled -- a circle outline, plus four short line
1398 segments along +/-x and +/-y running from the circle's own radius out
1399 past it, so the center stays fully open. Unlike a filled ring (an
1400 annulus), a stroked outline's thickness is set directly via
1401 `markeredgewidth` (in points), independent of this path's own geometry
1402 -- letting the line be pushed far thinner than a filled band can go
1403 before anti-aliasing makes it look patchy.
1405 Args:
1406 ring_radius: The circle's radius; also each tick's start radius.
1407 tick_outer: Each tick's end radius (past `ring_radius`).
1408 num_circle_points: Number of vertices approximating the circle.
1410 Returns:
1411 A compound `matplotlib.path.Path` usable as a `marker=` argument,
1412 with `markerfacecolor="none"` so only its stroke renders.
1413 """
1414 theta = np.linspace(0, 2 * np.pi, num_circle_points, endpoint=False)
1415 circle = np.column_stack((ring_radius * np.cos(theta), ring_radius * np.sin(theta)))
1416 # Path(..., closed=True) treats the *last* vertex as the ignored
1417 # CLOSEPOLY placeholder rather than drawing through it, so it must be a
1418 # repeat of the first vertex -- otherwise the final edge is dropped.
1419 circle_path = MarkerPath(np.vstack([circle, circle[:1]]), closed=True)
1421 tick_codes = [MarkerPath.MOVETO, MarkerPath.LINETO]
1422 tick_paths = [
1423 MarkerPath(
1424 [
1425 (direction_x * ring_radius, direction_y * ring_radius),
1426 (direction_x * tick_outer, direction_y * tick_outer),
1427 ],
1428 codes=tick_codes,
1429 )
1430 for direction_x, direction_y in [(1, 0), (-1, 0), (0, 1), (0, -1)]
1431 ]
1433 return MarkerPath.make_compound_path(circle_path, *tick_paths)
1436def point_grid_plot(
1437 *,
1438 image: np.ndarray,
1439 points: Sequence[PixelCoordinate],
1440 color: str = "red",
1441 show_node_numbers: bool = True,
1442 labels: Sequence[str] | None = None,
1443 dot_size: float | None = None,
1444 origin: PixelCoordinate = PixelCoordinate(x=0, y=0),
1445 circle_center: PixelCoordinate | None = None,
1446 circle_radius: float | None = None,
1447 circle_linewidth: float = 1.5,
1448 figsize: tuple[float, float] | None = None,
1449 path: Path,
1450 dpi: int = 300,
1451) -> None:
1452 """Save a figure marking each of `points` on `image`, labeled by its own index.
1454 Each point is drawn as a target-reticle glyph in `color` -- a ring with
1455 four tick marks poking through it, open at the center so the
1456 underlying image stays visible, plus a single-pixel dot at the point's
1457 exact location (where the N/S or E/W ticks would cross, if extended
1458 across the open center) -- with its position in `points` (its
1459 row-major index, e.g. from
1460 [`dictk.grid.generate`](../grid.html#generate)) as a short zero-padded
1461 label just up-right of the marker -- e.g. `points[0]` is labeled `"00"`,
1462 `points[19]` is labeled `"19"`, for a 20-point collection.
1464 Args:
1465 image: Source 2D grayscale image array.
1466 points: The points to mark, in the same reference frame as
1467 `origin` (by default, `image`'s own frame -- see `origin`
1468 below). May be empty (an unmarked copy of `image` is saved).
1469 color: Matplotlib color name for every marker and label.
1470 show_node_numbers: Whether to draw the reticle glyph and zero-padded
1471 index label described above. Default `True` matches every
1472 existing call site's own output exactly. `False` draws only
1473 the single-pixel center dot at each point -- a bare position
1474 marker with none of the reticle/label clutter, meant for a
1475 point count dense enough that a reticle-and-label per point
1476 would be unreadable (a few thousand points, not a dozen).
1477 labels: Optional label text per point, same length and order as
1478 `points`, overriding the default zero-padded `points`-local
1479 index. For a `points` list that's already a subset of some
1480 larger collection (e.g. every 4th point of a denser grid,
1481 picked to space labels legibly), this shows each point's
1482 *true* index in that larger collection instead of a
1483 re-enumerated `0, 1, 2, ...` that would otherwise misrepresent
1484 which points were skipped. Ignored when `show_node_numbers` is
1485 `False`.
1486 dot_size: Optional matplotlib `markersize` (in points) for the
1487 single-pixel center dot at each point's exact location.
1488 Default `None` keeps that dot exactly 1 raster pixel wide at
1489 `dpi` -- the same on-canvas size the reticle glyph's own
1490 center point has always used, appropriate when the reticle
1491 (or a label) is doing the actual work of marking a point.
1492 With `show_node_numbers=False`, the center dot is the *only*
1493 marker drawn, and 1 raster pixel is too faint to see clearly
1494 against real image content -- pass a larger value (e.g. `2`
1495 or `3`) to make it visible.
1496 origin: Where `image`'s own top-left corner sits in `points`'
1497 reference frame. Default `PixelCoordinate(x=0, y=0)` means
1498 `image` and `points` already share one frame -- every
1499 existing call site's own behavior, unchanged. Pass `image`'s
1500 true position (e.g. [`subimage`](./image.html#subimage)'s own
1501 `origin` argument) when `image` is a crop of some larger
1502 image and `points` are still expressed in that larger
1503 image's coordinates: the axes then read in the *larger*
1504 image's own numbers, not `image`'s local `0`-based ones, so
1505 the same point reads identically whether it's plotted here
1506 or in a figure of the uncropped image.
1507 circle_center: Optional center, in the same reference frame as
1508 `points`, of a red circle outline drawn on top of the image
1509 -- same style as
1510 [`spatial_correlation_quadrant_plot`](#spatial_correlation_quadrant_plot)'s
1511 own Solution Vicinity marker. Meant to visually tie a figure
1512 of one region back to a figure of a wider region it was
1513 cropped from: draw the same `circle_center`/`circle_radius`
1514 in both, and pick the radius to match the *narrower*
1515 figure's own extent (e.g. half its width) -- there, the
1516 circle exactly touches all four edges; in the wider figure,
1517 it appears as a normal circle marking exactly the region
1518 the narrower one shows. Must be given together with
1519 `circle_radius`.
1520 circle_radius: Radius, in the same units as `points`' own
1521 coordinates, of the circle described above. Must be given
1522 together with `circle_center`.
1523 circle_linewidth: Matplotlib `linewidth` for the circle outline
1524 above. Default `1.5` matches
1525 [`spatial_correlation_quadrant_plot`](#spatial_correlation_quadrant_plot)'s
1526 own circle exactly. Ignored when `circle_center` is `None`.
1527 figsize: Optional (width, height) in inches for the saved figure.
1528 By default the canvas is sized from `image`/`points`' own data
1529 extent; pass this to override with a fixed size instead.
1530 path: Output file path for the figure; format is inferred from the
1531 extension by matplotlib's savefig (e.g. .png), not dictk's own
1532 write/write_svg.
1533 dpi: Resolution of the saved figure.
1535 Raises:
1536 ValueError: If `labels` is given and its length doesn't match
1537 `points`, or if exactly one of `circle_center`/`circle_radius`
1538 is given without the other.
1539 """
1540 if (circle_center is None) != (circle_radius is None):
1541 raise ValueError("circle_center and circle_radius must be given together")
1542 if labels is not None and len(labels) != len(points):
1543 raise ValueError(
1544 f"labels has {len(labels)} entries, but points has {len(points)}"
1545 )
1546 image_height, image_width = image.shape
1547 image_left, image_top = origin.x, origin.y
1548 image_right, image_bottom = origin.x + image_width, origin.y + image_height
1550 endpoints_x = [point.x for point in points]
1551 endpoints_y = [point.y for point in points]
1552 margin = max(image_width, image_height) * 0.05
1553 # Small up-right offset for point labels, so label text doesn't sit
1554 # directly on top of its own marker -- same convention as point_plot().
1555 label_offset = max(image_width, image_height) * 0.03
1556 label_font_size = 12
1557 if points and show_node_numbers:
1558 label_text_height = label_font_size / 72 * _FIGURE_PIXELS_PER_INCH
1559 # Grow the margin uniformly (not just at the top) so a label above
1560 # the topmost point still has room, while all four margins match.
1561 # Capped at half the image's own size: label_text_height is a
1562 # fixed absolute constant (a fixed font size, not scaled to image
1563 # size), so on a small image/crop it would otherwise dominate the
1564 # whole canvas -- e.g. a 25x25px crop would demand a margin nearly
1565 # 3x wider than the image itself, mostly blank. The cap trades a
1566 # little label headroom on a very small image for a canvas that
1567 # still reads as "the image," not "mostly margin." No labels are
1568 # drawn at all when show_node_numbers is False, so this margin
1569 # isn't needed then either.
1570 label_margin = 2 * label_offset + label_text_height
1571 margin = max(margin, min(label_margin, max(image_width, image_height) * 0.5))
1572 x_min = min(image_left, *endpoints_x, image_left) - margin
1573 x_max = max(image_right, *endpoints_x, image_right) + margin
1574 y_min = min(image_top, *endpoints_y, image_top) - margin
1575 y_max = max(image_bottom, *endpoints_y, image_bottom) + margin
1576 label_outline = [patheffects.withStroke(linewidth=1, foreground="white")]
1578 if figsize is None:
1579 figsize = (
1580 (x_max - x_min) / _FIGURE_PIXELS_PER_INCH,
1581 (y_max - y_min) / _FIGURE_PIXELS_PER_INCH,
1582 )
1583 fig, ax = plt.subplots(figsize=figsize)
1584 ax.imshow(
1585 image,
1586 cmap="gray",
1587 origin="upper",
1588 extent=(image_left, image_right, image_bottom, image_top),
1589 )
1590 if circle_center is not None:
1591 ax.add_patch(
1592 patches.Circle(
1593 (circle_center.x, circle_center.y),
1594 radius=circle_radius,
1595 edgecolor="red",
1596 facecolor="none",
1597 linewidth=circle_linewidth,
1598 )
1599 )
1601 reticle = _reticle_marker_path()
1602 # A single-raster-pixel dot at each point's exact location: where the
1603 # reticle's N/S ticks (or E/W ticks), if extended across the open
1604 # center, would cross. markeredgewidth=0 is required -- otherwise the
1605 # default 1pt stroke dominates a marker this small and floors its
1606 # rendered size at several pixels regardless of markersize.
1607 center_dot_size = dot_size if dot_size is not None else 72 / dpi
1608 index_width = len(str(len(points) - 1)) if len(points) > 1 else 2
1609 point_labels = (
1610 labels
1611 if labels is not None
1612 else [f"{i:0{index_width}d}" for i in range(len(points))]
1613 )
1614 for i, point in enumerate(points):
1615 if show_node_numbers:
1616 ax.plot(
1617 point.x,
1618 point.y,
1619 marker=reticle,
1620 markerfacecolor="none",
1621 markeredgecolor=color,
1622 markeredgewidth=0.5,
1623 markersize=20,
1624 )
1625 ax.plot(
1626 point.x,
1627 point.y,
1628 marker="s",
1629 color=color,
1630 markersize=center_dot_size,
1631 markeredgewidth=0,
1632 )
1633 if show_node_numbers:
1634 ax.text(
1635 point.x + label_offset,
1636 point.y - label_offset,
1637 point_labels[i],
1638 color=color,
1639 fontsize=label_font_size,
1640 va="bottom",
1641 path_effects=label_outline,
1642 )
1644 ax.set_xlim(x_min, x_max)
1645 ax.set_ylim(y_max, y_min) # inverted: image y increases downward
1646 ax.set_xlabel("x (pixels)")
1647 ax.set_ylabel("y (pixels)")
1649 plt.savefig(path, dpi=dpi, bbox_inches="tight")
1650 plt.close(fig)
1653def point_displacement_plot(
1654 *,
1655 points: Sequence[PixelCoordinate | SubpixelCoordinate],
1656 values: Sequence[float],
1657 label: str,
1658 image: np.ndarray | None = None,
1659 cmap: str | Colormap = "viridis",
1660 dot_size: float = 150,
1661 marker: str = "o",
1662 vmin: float | None = None,
1663 vmax: float | None = None,
1664 figsize: tuple[float, float] = (10.0, 5.0),
1665 path: Path,
1666 dpi: int = 300,
1667) -> None:
1668 """Save a figure of a scalar value at each of a set of points.
1670 Scatters `points` colored by `values`, with a colorbar labeled
1671 `label`. If `image` is given, it's drawn as the background (e.g.
1672 pass the current/deformed image, with `points` in that same current
1673 configuration, to show where each point ended up); otherwise the
1674 axes alone are y-inverted to match image/pixel convention (y
1675 increasing downward), so the two modes render in the same visual
1676 orientation. The axes are always aspect-locked 1:1 (a pixel spans
1677 the same rendered length along x and y), regardless of `figsize` or
1678 `image`'s own shape.
1680 This is [`element_strain_plot`](#element_strain_plot)'s same
1681 rendering -- background image, scatter, colorbar, 1:1 aspect -- with
1682 its Q4 mesh/Gauss-point machinery dropped: there's no element
1683 outline to draw and no interpolated Gauss-point location to plot,
1684 just each point's own raw value. Useful for e.g. a per-point
1685 displacement field, where `values` is each point's own `dy` (or
1686 `dx`) and no mesh connectivity is involved at all.
1688 Args:
1689 points: One point per value, in the same configuration `values`
1690 was measured in (e.g. `found`, not the original reference
1691 points, to plot where each point ended up).
1692 values: One scalar per point, same order and length as `points`.
1693 This function doesn't compute anything itself, so the
1694 caller picks what `values` means (e.g. `dy = found.y -
1695 reference.y`) and sets `label` to match.
1696 label: Colorbar label, e.g. `r"Displacement, $\\delta y$ (pixels)"`.
1697 image: Optional background image (2D grayscale array). Default
1698 `None` draws the points alone, on a plain y-inverted axes.
1699 cmap: Matplotlib colormap for the scatter -- either a name
1700 (e.g. `"viridis"`) or a `Colormap` instance (e.g.
1701 `matplotlib.colors.ListedColormap`, for a custom or
1702 externally-matched palette).
1703 dot_size: Marker size (matplotlib `scatter`'s own `s`) for each
1704 point. Default `150` suits sparse grids; a dense grid with
1705 points only a few pixels apart needs a smaller value, or
1706 neighboring markers overlap into a solid mass instead of a
1707 legible field.
1708 marker: Matplotlib marker style for each point. Default `"o"`
1709 (circle). On a regular grid dense enough that neighboring
1710 markers touch, circles leave small diamond-shaped gaps at
1711 their corners (tangent circles never fully tile a plane) --
1712 `"s"` (square), sized and axis-aligned with the grid, tiles
1713 edge to edge with no gaps, reading as a genuinely continuous
1714 field rather than a field of dots.
1715 vmin: Optional fixed lower bound for the color scale. Default
1716 `None` auto-scales from `values`' own min. Set alongside
1717 `vmax` to pin the colorbar to a specific range. Values
1718 outside `[vmin, vmax]` still plot, just clipped to the
1719 scale's own end colors, the same way matplotlib always
1720 handles an explicit `vmin`/`vmax`.
1721 vmax: Optional fixed upper bound for the color scale; see `vmin`.
1722 figsize: `(width, height)` in inches for the saved figure, used
1723 whether or not `image` is given.
1724 path: Output file path for the figure; format is inferred from
1725 the extension by matplotlib's savefig (e.g. `.png`).
1726 dpi: Resolution of the saved figure.
1728 Raises:
1729 ValueError: If `values` and `points` have different lengths.
1730 """
1731 if len(values) != len(points):
1732 raise ValueError(
1733 f"values has {len(values)} entries, but points has {len(points)}"
1734 )
1736 xs = [point.x for point in points]
1737 ys = [point.y for point in points]
1739 fig, ax = plt.subplots(figsize=figsize)
1741 if image is not None:
1742 image_height, image_width = image.shape
1743 ax.imshow(
1744 image,
1745 cmap="gray",
1746 origin="upper",
1747 extent=(0, image_width, image_height, 0),
1748 )
1749 elif not ax.yaxis_inverted():
1750 ax.invert_yaxis()
1752 scatter = ax.scatter(
1753 xs, ys, c=values, cmap=cmap, s=dot_size, marker=marker, vmin=vmin, vmax=vmax
1754 )
1755 fig.colorbar(scatter, ax=ax, label=label)
1757 ax.axis("image") # aspect-locked 1:1, autoscaled tight to the data
1758 ax.set_xlabel("x (pixels)")
1759 ax.set_ylabel("y (pixels)")
1761 plt.tight_layout()
1762 # No bbox_inches="tight" here, matching element_strain_plot's own
1763 # fixed-canvas save.
1764 plt.savefig(path, dpi=dpi)
1765 plt.close(fig)
1768def element_strain_plot(
1769 *,
1770 points: Sequence[PixelCoordinate | SubpixelCoordinate],
1771 elements: Sequence[tuple[int, int, int, int]],
1772 coordinates: Sequence[tuple[float, float]],
1773 values: Sequence[float],
1774 label: str,
1775 image: np.ndarray | None = None,
1776 show_node_numbers: bool = False,
1777 show_mesh_lines: bool = True,
1778 cmap: str | Colormap = "viridis",
1779 dot_size: float = 150,
1780 marker: str = "o",
1781 vmin: float | None = None,
1782 vmax: float | None = None,
1783 figsize: tuple[float, float] = (10.0, 5.0),
1784 path: Path,
1785 dpi: int = 300,
1786) -> None:
1787 r"""Save a figure of a Q4 mesh with its Gauss points colored by a scalar value.
1789 Two rendering modes -- with or without a background image -- share
1790 the same node-number/Gauss-point-scatter/colorbar layout, built from
1791 dictk's own point/element representation
1792 ([`dictk.grid.elements`](../grid.html#elements),
1793 [`dictk.element.gauss_point_coordinates`](../element.html#gauss_point_coordinates)).
1794 Deliberately agnostic to which strain measure (or any other
1795 per-Gauss-point scalar) produced `values` -- it only draws what it's
1796 given, matching
1797 [`point_grid_plot`](#point_grid_plot)/[`point_grid_boxes_plot`](#point_grid_boxes_plot)'s
1798 own "just draws what it's given" design, rather than computing strain
1799 itself.
1801 Draws each of `elements`' 4-corner polygon outline from `points`
1802 (optionally, see `show_mesh_lines`), optionally labels each of
1803 `points` with its own zero-padded index
1804 (matching `point_grid_plot`'s exact `"00"`, `"01"`, ... convention),
1805 and scatters `coordinates` colored by `values` with a colorbar
1806 labeled `label`. If `image` is given, it's drawn as the background
1807 (e.g. pass the current/deformed image, with `points`/`coordinates` in
1808 that same current configuration, to show the mesh atop what it was
1809 measured from); otherwise the axes alone are y-inverted to match
1810 image/pixel convention (y increasing downward), so the two modes
1811 render in the same visual orientation. The axes are always
1812 aspect-locked 1:1 (a pixel spans the same rendered length along
1813 x and y), regardless of `figsize` or `image`'s own shape -- so the
1814 mesh's true proportions are never visually distorted.
1816 Args:
1817 points: The mesh's corner node positions, in the same
1818 configuration `coordinates` uses (e.g. `found`, not the
1819 original reference `points`, to draw the deformed shape).
1820 elements: Q4 connectivity, e.g. from
1821 [`dictk.grid.elements`](../grid.html#elements) -- each
1822 4-tuple indexes into `points`.
1823 coordinates: One `(X, Y)` position per Gauss point, e.g. from
1824 [`dictk.element.gauss_point_coordinates`](../element.html#gauss_point_coordinates)
1825 called once per element and concatenated, in the same order
1826 as `values`.
1827 values: One scalar per Gauss point, same order and length as
1828 `coordinates` -- e.g. a strain tensor's own `[0, 0]`
1829 component at each point. This function doesn't compute
1830 strain itself, so the caller picks the strain measure by
1831 choosing which function computed `values` --
1832 [`dictk.element.gauss_point_log_strains`](../element.html#gauss_point_log_strains)
1833 vs.
1834 [`dictk.element.gauss_point_green_lagrange_strains`](../element.html#gauss_point_green_lagrange_strains),
1835 for instance -- and should set `label` to match.
1836 label: Colorbar label, e.g. `r"Log Strain, $E_{11}$"`.
1837 image: Optional background image (2D grayscale array). Default
1838 `None` draws the mesh alone, on a plain y-inverted axes.
1839 show_node_numbers: Whether to label each of `points` with its own
1840 zero-padded index.
1841 show_mesh_lines: Whether to draw each element's own 4-corner
1842 outline. Default `True`. At high point density the mesh
1843 lines add visual clutter without much information -- a
1844 dense enough scatter (see `dot_size`) already reads as a
1845 field on its own; `False` drops the outlines so the
1846 colored points aren't fighting a grid of black lines for
1847 attention.
1848 cmap: Matplotlib colormap for the Gauss-point scatter -- either
1849 a name (e.g. `"viridis"`) or a `Colormap` instance (e.g.
1850 `matplotlib.colors.ListedColormap`, for a custom or
1851 externally-matched palette).
1852 dot_size: Marker size (matplotlib `scatter`'s own `s`) for each
1853 Gauss point. Default `150` suits sparse meshes; a dense mesh
1854 with points only a few pixels apart needs a smaller value, or
1855 neighboring markers overlap into a solid mass instead of a
1856 legible field.
1857 marker: Matplotlib marker style for each Gauss point. Default
1858 `"o"` (circle). On a regular grid dense enough that
1859 neighboring markers touch, circles leave small diamond-
1860 shaped gaps at their corners (tangent circles never fully
1861 tile a plane) -- `"s"` (square), sized and axis-aligned with
1862 the grid, tiles edge to edge with no gaps, reading as a
1863 genuinely continuous field rather than a field of dots.
1864 vmin: Optional fixed lower bound for the color scale. Default
1865 `None` auto-scales from `values`' own min, matching every
1866 existing call. Set alongside `vmax` to pin the colorbar to a
1867 specific range -- e.g. matching an external tool's own
1868 colorbar exactly, for a direct visual comparison between two
1869 figures that wouldn't otherwise share a color scale. Values
1870 outside `[vmin, vmax]` still plot, just clipped to the
1871 scale's own end colors, the same way matplotlib always
1872 handles an explicit `vmin`/`vmax`.
1873 vmax: Optional fixed upper bound for the color scale; see `vmin`.
1874 figsize: `(width, height)` in inches for the saved figure, used
1875 whether or not `image` is given -- unlike `point_grid_plot`,
1876 this isn't sized from `image`'s own shape (see `image`
1877 above).
1878 path: Output file path for the figure; format is inferred from
1879 the extension by matplotlib's savefig (e.g. `.png`).
1880 dpi: Resolution of the saved figure.
1881 """
1882 gauss_xs = [c[0] for c in coordinates]
1883 gauss_ys = [c[1] for c in coordinates]
1885 # Same figsize whether or not image is given -- not sized from
1886 # image.shape -- one fixed canvas for both calls.
1887 fig, ax = plt.subplots(figsize=figsize)
1889 if image is not None:
1890 image_height, image_width = image.shape
1891 ax.imshow(
1892 image,
1893 cmap="gray",
1894 origin="upper",
1895 extent=(0, image_width, image_height, 0),
1896 )
1897 elif not ax.yaxis_inverted():
1898 # No image to establish the y-down orientation via its own
1899 # extent -- invert explicitly.
1900 ax.invert_yaxis()
1902 if show_mesh_lines:
1903 for element in elements:
1904 corners = [points[i] for i in element]
1905 corners.append(corners[0]) # close the quadrilateral
1906 ax.plot([c.x for c in corners], [c.y for c in corners], "k-", alpha=0.3)
1908 if show_node_numbers:
1909 label_outline = [patheffects.withStroke(linewidth=1, foreground="white")]
1910 index_width = len(str(len(points) - 1)) if len(points) > 1 else 2
1911 for i, point in enumerate(points):
1912 ax.text(
1913 point.x,
1914 point.y,
1915 f"{i:0{index_width}d}",
1916 color="red",
1917 fontsize=10,
1918 fontweight="bold",
1919 ha="center",
1920 va="center",
1921 path_effects=label_outline,
1922 )
1924 scatter = ax.scatter(
1925 gauss_xs,
1926 gauss_ys,
1927 c=values,
1928 cmap=cmap,
1929 s=dot_size,
1930 marker=marker,
1931 vmin=vmin,
1932 vmax=vmax,
1933 )
1934 fig.colorbar(scatter, ax=ax, label=label)
1936 ax.axis("image") # aspect-locked 1:1, autoscaled tight to the data --
1937 # not a manually recreated aspect+anchor+xlim/ylim equivalent.
1938 ax.set_xlabel("x (pixels)")
1939 ax.set_ylabel("y (pixels)")
1941 plt.tight_layout()
1942 # No bbox_inches="tight" here, unlike this module's other plot
1943 # functions -- the fixed-size canvas is saved as is (plt.savefig(...,
1944 # dpi=dpi), no bbox_inches) rather than cropped to the mesh's own,
1945 # generally smaller, content bounding box.
1946 plt.savefig(path, dpi=dpi)
1947 plt.close(fig)