186 lines
6.8 KiB
Python
186 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Live 16x16 thermal heatmap viewer.
|
|
|
|
Reads the firmware's RTT/defmt log stream and updates a heatmap window in
|
|
real time, one frame at a time. The firmware (see `src/main.rs`) prints one
|
|
line per frame that looks like:
|
|
|
|
12.345 [INFO ] frame ambient_c=23.5 min_c=10.2 max_c=45.6 pixels=[23.1, ...] (tts_test ...)
|
|
|
|
Everything else on the line (timestamp, log level, source location) and any
|
|
other log lines (warnings, startup messages, ...) are ignored.
|
|
|
|
Usage:
|
|
# live, straight from the device (uses the runner in .cargo/config.toml,
|
|
# i.e. `probe-rs run --chip STM32U585CI`):
|
|
cargo run --release | python3 live_heatmap.py
|
|
|
|
# replay a previously captured log file instead of a live device:
|
|
python3 live_heatmap.py --replay output.txt
|
|
"""
|
|
|
|
import argparse
|
|
import re
|
|
import select
|
|
import sys
|
|
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
|
|
GRID = 16
|
|
|
|
# Fixed color-scale range (\u00b0C). Kept constant across frames (rather than
|
|
# autoscaling to each frame's min/max) so colors stay comparable over time;
|
|
# override with --vmin/--vmax if your scene runs outside this range.
|
|
DEFAULT_VMIN_C = 0.0
|
|
DEFAULT_VMAX_C = 100.0
|
|
|
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
_FRAME_RE = re.compile(
|
|
r"ambient_c=(?P<ambient>-?\d+(?:\.\d+)?).*?"
|
|
r"min_c=(?P<min>-?\d+(?:\.\d+)?).*?"
|
|
r"max_c=(?P<max>-?\d+(?:\.\d+)?).*?"
|
|
r"pixels=\[(?P<pixels>[^\]]*)\]"
|
|
)
|
|
|
|
|
|
def parse_frame(line):
|
|
"""Return (ambient, min, max, 16x16 array), or None if `line` isn't a frame line."""
|
|
line = _ANSI_RE.sub("", line)
|
|
match = _FRAME_RE.search(line)
|
|
if not match:
|
|
return None
|
|
|
|
pixels = [float(v) for v in match.group("pixels").split(",") if v.strip()]
|
|
if len(pixels) != GRID * GRID:
|
|
return None
|
|
|
|
grid = np.array(pixels, dtype=float).reshape(GRID, GRID)
|
|
ambient = float(match.group("ambient"))
|
|
vmin = float(match.group("min"))
|
|
vmax = float(match.group("max"))
|
|
return ambient, vmin, vmax, grid
|
|
|
|
|
|
def read_latest_frame(stream):
|
|
"""Block until at least one frame line is available on `stream`, then
|
|
drain any further lines that are *already* buffered and return only the
|
|
most recently parsed frame.
|
|
|
|
This is what keeps the viewer live: if frames arrive faster than they
|
|
can be drawn, we skip straight to the newest one each time instead of
|
|
rendering every buffered frame in order and steadily falling behind.
|
|
|
|
Returns `None` once `stream` reaches EOF.
|
|
"""
|
|
frame = None
|
|
while True:
|
|
line = stream.readline()
|
|
if line == "":
|
|
return frame # EOF
|
|
parsed = parse_frame(line)
|
|
if parsed is not None:
|
|
frame = parsed
|
|
# non-blocking peek: is there already more data waiting?
|
|
more_data_ready, _, _ = select.select([stream], [], [], 0)
|
|
if not more_data_ready and frame is not None:
|
|
return frame
|
|
# otherwise keep draining (or, if nothing parsed yet, block again
|
|
# on the next readline() until a new line arrives)
|
|
|
|
|
|
class LiveHeatmap:
|
|
"""A matplotlib window that redraws itself each time `update` is called.
|
|
|
|
The color scale (`vmin`/`vmax`) is fixed for the lifetime of the window,
|
|
so colors stay comparable from frame to frame; only the pixel data and
|
|
the title (which still reports each frame's actual min/max) change.
|
|
"""
|
|
|
|
def __init__(self, cmap, vmin, vmax, annotate=True):
|
|
plt.ion()
|
|
self.fig, ax = plt.subplots(figsize=(8, 7))
|
|
self.image = ax.imshow(
|
|
np.zeros((GRID, GRID)), cmap=cmap, interpolation="nearest", vmin=vmin, vmax=vmax
|
|
)
|
|
self.colorbar = self.fig.colorbar(self.image, ax=ax, label="\u00b0C")
|
|
self.title = ax.set_title("waiting for first frame...")
|
|
ax.set_xticks(range(GRID))
|
|
ax.set_yticks(range(GRID))
|
|
|
|
# midpoint of the fixed color scale, used to pick readable text color
|
|
self.mid = (vmin + vmax) / 2
|
|
self.annotations = None
|
|
if annotate:
|
|
self.annotations = [
|
|
[
|
|
ax.text(col, row, "", ha="center", va="center", fontsize=6, color="white")
|
|
for col in range(GRID)
|
|
]
|
|
for row in range(GRID)
|
|
]
|
|
|
|
self.fig.tight_layout()
|
|
self.fig.canvas.draw()
|
|
self.fig.canvas.flush_events()
|
|
self.frame_count = 0
|
|
|
|
def update(self, ambient, frame_min, frame_max, grid):
|
|
self.frame_count += 1
|
|
self.image.set_data(grid)
|
|
self.title.set_text(
|
|
f"frame {self.frame_count} ambient {ambient:.1f}\u00b0C "
|
|
f"range {frame_min:.1f}..{frame_max:.1f}\u00b0C"
|
|
)
|
|
if self.annotations is not None:
|
|
for row in range(GRID):
|
|
for col in range(GRID):
|
|
value = grid[row, col]
|
|
text = self.annotations[row][col]
|
|
text.set_text(f"{value:.0f}")
|
|
text.set_color("white" if value < self.mid else "black")
|
|
self.fig.canvas.draw_idle()
|
|
self.fig.canvas.flush_events()
|
|
|
|
def is_open(self):
|
|
return plt.fignum_exists(self.fig.number)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--replay", help="replay a captured log file instead of reading stdin")
|
|
parser.add_argument("--replay-fps", type=float, default=5.0, help="frame rate when replaying a file")
|
|
parser.add_argument("--cmap", default="inferno", help="matplotlib colormap")
|
|
parser.add_argument("--vmin", type=float, default=DEFAULT_VMIN_C, help="fixed color-scale minimum (\u00b0C)")
|
|
parser.add_argument("--vmax", type=float, default=DEFAULT_VMAX_C, help="fixed color-scale maximum (\u00b0C)")
|
|
parser.add_argument("--no-annot", action="store_true", help="do not print the value inside each cell")
|
|
args = parser.parse_args()
|
|
|
|
heatmap = LiveHeatmap(args.cmap, args.vmin, args.vmax, annotate=not args.no_annot)
|
|
try:
|
|
if args.replay:
|
|
# deliberately paced, frame by frame, at --replay-fps
|
|
with open(args.replay) as source:
|
|
for line in source:
|
|
if not heatmap.is_open():
|
|
break
|
|
frame = parse_frame(line)
|
|
if frame is None:
|
|
continue
|
|
heatmap.update(*frame)
|
|
plt.pause(1.0 / args.replay_fps)
|
|
else:
|
|
# live: always show the newest frame, never a backlog
|
|
while heatmap.is_open():
|
|
frame = read_latest_frame(sys.stdin)
|
|
if frame is None:
|
|
break # EOF: the device stream ended
|
|
heatmap.update(*frame)
|
|
plt.pause(0.001) # let the GUI event loop breathe
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|