#!/usr/bin/env python3 """Render a sensor dump as a heatmap. Usage: python heatmap.py [input.txt] [-o out.png] [--cmap inferno] Accepts either: - Raw device log lines as printed by the firmware, e.g.: 5.464019 [INFO ] row 0: [71.6, 75.2, ..., 82.1] (tts_test tts-test/src/main.rs:212) Only the "row N: [...]" part of each line is used; everything else (timestamps, log level, source location, other info! lines like the ambient temperature/heatmap range) is ignored. You can paste the whole console log as-is. - Plain rows of numbers, one row per line, brackets/commas optional: [55, 192, 131, ..., 138] """ import argparse import re import sys _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") _ROW_RE = re.compile(r"row\s*(\d+)\s*:\s*\[([^\]]*)\]", re.IGNORECASE) def parse_grid(text): """Parse a device log or a plain number dump into a 2D list of floats.""" text = _ANSI_RE.sub("", text) # Prefer explicit "row N: [...]" entries (as printed by the firmware's # info! logs) so that timestamps, log levels and source locations on the # same line don't get mistaken for pixel data. rows = {} for match in _ROW_RE.finditer(text): idx = int(match.group(1)) nums = re.findall(r"-?\d+(?:\.\d+)?", match.group(2)) if nums: rows[idx] = [float(n) for n in nums] if rows: grid = [rows[i] for i in sorted(rows)] else: # fall back: one row of numbers per line grid = [] for line in text.splitlines(): nums = re.findall(r"-?\d+(?:\.\d+)?", line) if nums: grid.append([float(n) for n in nums]) if not grid: raise ValueError("No numeric data found in input") width = len(grid[0]) if any(len(row) != width for row in grid): raise ValueError("Rows have inconsistent lengths") return grid def main(): parser = argparse.ArgumentParser(description="Render a number grid as a heatmap.") parser.add_argument("input", nargs="?", default="output.txt", help="input file (default: output.txt)") parser.add_argument("-o", "--output", help="save figure to this path instead of showing it") parser.add_argument("--cmap", default="inferno", help="matplotlib colormap") parser.add_argument("--no-annot", action="store_true", help="do not print the value inside each cell") args = parser.parse_args() import matplotlib.pyplot as plt try: with open(args.input) as f: grid = parse_grid(f.read()) except (OSError, ValueError) as e: sys.exit(f"error: {e}") rows, cols = len(grid), len(grid[0]) fig, ax = plt.subplots(figsize=(max(6, cols * 0.5), max(5, rows * 0.5))) im = ax.imshow(grid, cmap=args.cmap, interpolation="nearest") fig.colorbar(im, ax=ax, label="value") ax.set_title(f"{args.input} ({rows}x{cols})") ax.set_xticks(range(cols)) ax.set_yticks(range(rows)) if not args.no_annot: vmin, vmax = min(map(min, grid)), max(map(max, grid)) mid = (vmin + vmax) / 2 for r in range(rows): for c in range(cols): v = grid[r][c] ax.text(c, r, f"{v:g}", ha="center", va="center", fontsize=6, color="white" if v < mid else "black") fig.tight_layout() if args.output: fig.savefig(args.output, dpi=150) print(f"saved {args.output}") else: plt.show() if __name__ == "__main__": main()