CoolFace
Datasetpublic

ppak10/Agentic-SLS-Telemetry

Inova-Mk1-Telemetry Time-aligned printer-state recordings from Inova Mk1 SLS 3D print runs. One row per 10 Hz tick — the recorder's /state/snapshot poll — with the full sensor state snapshot (~64 columns: temperatures, position, power, lights) on every row, the nearest camera frame embedded inline when one fell within the prior 100 ms window, and any 1 kHz position-stream samples from that window collected as a nested list. 25 parquet files across builds spanning 2026-05 through… See the full description on the dataset page: https://huggingface.co/datasets/ppak10/Agentic-SLS-Telemetry.

sourceHugging Facemitupdated 2mo agoView on Hugging Face
0likes410downloads
02_timelapse.py447 linesDownload Raw Back to previews
1#!/usr/bin/env python32"""Render per-layer timelapse GIFs for each build.3 4Reads data/ticks/{build_id:03d}.parquet and writes:5  previews/{build_id:03d}/timelapse_chamber.gif6  previews/{build_id:03d}/timelapse_thermal.gif7  previews/{build_id:03d}/timelapse_galvo.gif8  previews/{build_id:03d}/timelapse_composite.gif  (1×3 panel: chamber | thermal | galvo)9 10Layer detection: positions.position.z2 is quantized in 100 µm buckets.11Only z2 > 0 rows are used — z2 stays at 0 during pre-print heating so this12naturally excludes the heating phase without needing to inspect the phase column.13The *last* non-null frame within each z2 bucket is the representative — that's14the most-recent view of the layer just before recoating begins.15 16Null-frame levels are forward-filled from the most recent non-null level of the17same kind, so the GIF never shows a blank panel mid-timelapse.18 19The thermal panel is rendered from the raw `bedmatrix` IR grid (inferno colormap20over a fixed absolute °C range; see _thermal.py) whenever it's present — builds21013+. The three earliest builds (001/002/012) predate the bedmatrix stream and22fall back to the legacy pre-rendered `frame_thermal` GIF.23 24Subsampled to at most MAX_FRAMES (default 300). At GIF_FPS=25 that gives25a max 12-second GIF. Builds with fewer detected layers are not padded.26 27Canvas is scaled to GIF_PANEL_HEIGHT=240 px (half the MP4 panel height) to28keep file sizes web-friendly for README embedding.29 30Usage:31    uv run scripts/previews/02_timelapse.py            # all builds in data/ticks/32    uv run scripts/previews/02_timelapse.py 26 28      # specific build ids33    uv run scripts/previews/02_timelapse.py 26 --kinds chamber,composite34"""35import io36import sys37from pathlib import Path38 39import pyarrow.parquet as pq40from PIL import Image, ImageStat41 42sys.path.insert(0, str(Path(__file__).parent.parent))43sys.path.insert(0, str(Path(__file__).parent))44from _lib import DATA_DIR45from _thermal import bedmatrix_to_image46 47OUTPUT_DIR = DATA_DIR.parent / "previews"48TICKS_DIR  = DATA_DIR / "ticks"49 50FRAME_KINDS        = ("chamber", "thermal", "galvo")51KIND_ROTATION_CW   = {"chamber": 90}  # degrees; see _decode_raw52# The thermal panel renders from the raw `bedmatrix` IR grid when present53# (builds 013+), falling back to the legacy `frame_thermal` GIF for the three54# earliest builds (001/002/012) that predate the bedmatrix stream.55 56GIF_FPS            = 2557GIF_DURATION_MS    = int(1000 / GIF_FPS)   # 40 ms per frame58MAX_FRAMES         = 300                    # subsample cap → max 12 s GIF59LAYER_QUANTIZE_UM  = 100                    # z2 bucket size in microns60GIF_PANEL_HEIGHT   = 240                    # panel height in pixels for GIF canvas61BATCH_ROWS         = 1000                   # pyarrow streaming batch size62 63# Halogen brightness filter — applies to chamber frames only.64# The halogens pulse on/off throughout a build; dark frames (halogens off) are65# uninformative for viewing. A frame is kept if its mean grayscale brightness is66# at least this fraction of the brightest frame seen in the same build.67# For the individual chamber GIF dark frames are dropped entirely; for the68# composite they are forward-filled from the last bright frame so thermal/galvo69# stay in layer-sync.70CHAMBER_BRIGHTNESS_RATIO = 0.571 72 73# ---------------------------------------------------------------------------74# Image helpers (mirrors 01_render.py exactly)75# ---------------------------------------------------------------------------76 77def _decode_raw(b: bytes, kind: str) -> Image.Image | None:78    """Decode raw image bytes to PIL RGB with orientation correction."""79    if not b:80        return None81    try:82        img = Image.open(io.BytesIO(b)).convert("RGB")83    except Exception:84        return None85    rot = KIND_ROTATION_CW.get(kind, 0)86    if rot == 90:87        img = img.transpose(Image.Transpose.ROTATE_270)88    elif rot == 180:89        img = img.transpose(Image.Transpose.ROTATE_180)90    elif rot == 270:91        img = img.transpose(Image.Transpose.ROTATE_90)92    return img93 94 95def _decode_cell(cell, kind: str) -> Image.Image | None:96    """Decode one struct cell to a PIL RGB image, dispatching on struct shape:97    a `bedmatrix` struct (has 'values') renders as an inferno heatmap; a frame98    Image struct (has 'bytes') decodes + orientation-corrects. None when missing."""99    if cell is None:100        return None101    if "values" in cell:102        return bedmatrix_to_image(cell)103    return _decode_raw(cell.get("bytes"), kind)104 105 106def _thermal_column(parquet_path: Path) -> str:107    """Which column feeds the thermal panel for this build: 'bedmatrix' when the108    raw IR matrix has any non-null cell, else the legacy 'frame_thermal'. The109    bedmatrix stream started at build 013, so 001/002/012 fall back to the GIF."""110    pf = pq.ParquetFile(parquet_path)111    if "bedmatrix" not in {f.name for f in pf.schema_arrow}:112        return "frame_thermal"113    for batch in pf.iter_batches(columns=["bedmatrix"], batch_size=BATCH_ROWS):114        for cell in batch.column("bedmatrix").to_pylist():115            if cell is not None:116                return "bedmatrix"117    return "frame_thermal"118 119 120def _columns_for_build(parquet_path: Path) -> dict[str, str]:121    """Map each panel kind → the parquet column that feeds it for this build."""122    return {123        "chamber": "frame_chamber",124        "thermal": _thermal_column(parquet_path),125        "galvo":   "frame_galvo",126    }127 128 129def _canvas_width(cell, kind: str) -> int:130    """Decode one cell to determine the locked canvas width for this kind."""131    img = _decode_cell(cell, kind)132    if img is None:133        return GIF_PANEL_HEIGHT  # square fallback134    sw, sh = img.size135    return max(2, round(sw * GIF_PANEL_HEIGHT / sh))136 137 138def _fit_to_canvas(img: Image.Image, canvas_w: int) -> Image.Image:139    """Letterbox img into (canvas_w × GIF_PANEL_HEIGHT) with dark-gray fill."""140    sw, sh = img.size141    scale  = min(canvas_w / sw, GIF_PANEL_HEIGHT / sh)142    nw     = max(1, round(sw * scale))143    nh     = max(1, round(sh * scale))144    fitted = img.resize((nw, nh), Image.BILINEAR)145    canvas = Image.new("RGB", (canvas_w, GIF_PANEL_HEIGHT), (20, 20, 20))146    canvas.paste(fitted, ((canvas_w - nw) // 2, (GIF_PANEL_HEIGHT - nh) // 2))147    return canvas148 149 150def _placeholder(canvas_w: int) -> Image.Image:151    return Image.new("RGB", (canvas_w, GIF_PANEL_HEIGHT), (20, 20, 20))152 153 154def _mean_brightness(img: Image.Image) -> float:155    """Mean grayscale pixel value 0–255 (uses PIL ImageStat, no numpy)."""156    return ImageStat.Stat(img.convert("L")).mean[0]157 158 159def _chamber_threshold(decoded_frames: list[Image.Image | None]) -> float:160    """Return the brightness threshold for a build's chamber frames.161    25 % of the brightest frame seen; 0 if no frames (no filtering applied)."""162    brightnesses = [_mean_brightness(f) for f in decoded_frames if f is not None]163    return max(brightnesses) * CHAMBER_BRIGHTNESS_RATIO if brightnesses else 0.0164 165 166# ---------------------------------------------------------------------------167# Layer data collection168# ---------------------------------------------------------------------------169 170def collect_layer_cells(parquet_path: Path, kind: str, col: str) -> dict[int, dict]:171    """Single-pass stream → {z2_level: last_non_null_struct}.172 173    Only z2 > 0 rows are included. The dict is keyed by int(z2 / LAYER_QUANTIZE_UM);174    each entry holds the *last* non-null struct cell seen at that level (a frame175    Image struct, or a bedmatrix struct for the thermal panel). Reads only two176    parquet columns (z2 + the source column) for efficiency.177    """178    z2_col  = "positions.position.z2"179    pf      = pq.ParquetFile(parquet_path)180    present = {f.name for f in pf.schema_arrow}181    if col not in present or z2_col not in present:182        return {}183 184    layer_data: dict[int, dict] = {}185    for batch in pf.iter_batches(columns=[z2_col, col], batch_size=BATCH_ROWS):186        z2_list   = batch.column(z2_col).to_pylist()187        cell_list = batch.column(col).to_pylist()188        for z2, cell in zip(z2_list, cell_list):189            if z2 is None or z2 <= 0:190                continue191            if cell is not None:192                layer_data[int(z2 / LAYER_QUANTIZE_UM)] = cell193    return layer_data194 195 196def collect_all_kinds(parquet_path: Path, cols_map: dict[str, str]) -> dict[str, dict[int, dict]]:197    """Single streaming pass collecting all three kinds simultaneously.198 199    Used by render_timelapse_composite so we don't make three separate passes200    through (potentially 17+ GB) parquet files. Reads four columns: z2 + each201    kind's source column. Each kind gets its own {z2_level: struct} dict.202    """203    z2_col    = "positions.position.z2"204    pf        = pq.ParquetFile(parquet_path)205    present   = {f.name for f in pf.schema_arrow}206    read_cols = [c for c in ([z2_col] + [cols_map[k] for k in FRAME_KINDS]) if c in present]207    if z2_col not in read_cols:208        return {k: {} for k in FRAME_KINDS}209 210    layer_data: dict[str, dict[int, dict]] = {k: {} for k in FRAME_KINDS}211    for batch in pf.iter_batches(columns=read_cols, batch_size=BATCH_ROWS):212        z2_list = batch.column(z2_col).to_pylist()213        for kind in FRAME_KINDS:214            col = cols_map[kind]215            if col not in read_cols:216                continue217            cell_list = batch.column(col).to_pylist()218            for z2, cell in zip(z2_list, cell_list):219                if z2 is None or z2 <= 0:220                    continue221                if cell is not None:222                    layer_data[kind][int(z2 / LAYER_QUANTIZE_UM)] = cell223    return layer_data224 225 226# ---------------------------------------------------------------------------227# Subsampling and forward-fill228# ---------------------------------------------------------------------------229 230def _subsample(levels: list[int]) -> list[int]:231    """Evenly subsample sorted levels down to at most MAX_FRAMES."""232    if len(levels) <= MAX_FRAMES:233        return levels234    step = len(levels) / MAX_FRAMES235    return [levels[round(i * step)] for i in range(MAX_FRAMES)]236 237 238def _forward_fill(layer_bytes: dict[int, bytes],239                  target_levels: list[int]) -> list[bytes | None]:240    """For each target level, return the bytes at that level or the most241    recent non-null bytes seen so far (forward-fill across gaps)."""242    out: list[bytes | None] = []243    last: bytes | None = None244    for lvl in target_levels:245        b = layer_bytes.get(lvl)246        if b is not None:247            last = b248        out.append(last)249    return out250 251 252# ---------------------------------------------------------------------------253# GIF writer254# ---------------------------------------------------------------------------255 256def _write_gif(frames: list[Image.Image], out_path: Path) -> None:257    """Palette-quantize and save frames as an animated GIF."""258    out_path.parent.mkdir(parents=True, exist_ok=True)259    palette_frames = [260        f.quantize(colors=256, method=Image.Quantize.MEDIANCUT,261                   dither=Image.Dither.FLOYDSTEINBERG)262        for f in frames263    ]264    palette_frames[0].save(265        out_path,266        format="GIF",267        save_all=True,268        append_images=palette_frames[1:],269        loop=0,270        duration=GIF_DURATION_MS,271        optimize=False,272    )273 274 275# ---------------------------------------------------------------------------276# Per-build renderers277# ---------------------------------------------------------------------------278 279def render_timelapse_kind(parquet_path: Path, kind: str, out_path: Path, col: str) -> int:280    """Write timelapse_{kind}.gif. Returns number of GIF frames written.281 282    Chamber only: dark frames (halogens off) are dropped entirely so the GIF283    shows only moments where the part is visible. Thermal and galvo are284    unaffected — they don't depend on halogen lighting.285    """286    layer_cells = collect_layer_cells(parquet_path, kind, col)287    if not layer_cells:288        print(f"  {kind:8s}: no printing-phase frames (z2 > 0), skipping")289        return 0290 291    sorted_levels = sorted(layer_cells)292    target_levels = _subsample(sorted_levels)293    fill_cells    = _forward_fill(layer_cells, target_levels)294    canvas_w      = _canvas_width(next(c for c in fill_cells if c), kind)295 296    # Decode all selected frames up front (needed for brightness scan on chamber).297    decoded = [_decode_cell(c, kind) for c in fill_cells]298 299    if kind == "chamber":300        # Compute brightness once per decoded frame, then threshold and filter.301        brightnesses = [_mean_brightness(img) if img is not None else None302                        for img in decoded]303        threshold = _chamber_threshold(decoded)304        pil_frames = [305            _fit_to_canvas(img, canvas_w)306            for img, b in zip(decoded, brightnesses)307            if img is not None and b is not None and b >= threshold308        ]309        dark_dropped = sum(310            1 for img, b in zip(decoded, brightnesses)311            if img is not None and b is not None and b < threshold312        )313        if dark_dropped:314            print(f"  {kind:8s}: dropped {dark_dropped} dark frames "315                  f"(threshold {threshold:.1f}/255)")316    else:317        pil_frames = [318            _fit_to_canvas(img, canvas_w) if img else _placeholder(canvas_w)319            for img in decoded320        ]321 322    if not pil_frames:323        print(f"  {kind:8s}: no frames survived brightness filter, skipping")324        return 0325 326    _write_gif(pil_frames, out_path)327    return len(pil_frames)328 329 330def render_timelapse_composite(parquet_path: Path, out_path: Path,331                               cols_map: dict[str, str]) -> int:332    """Write timelapse_composite.gif (1×3 panel). Single parquet pass.333 334    Thermal and galvo show the actual frame for every layer (unaffected by335    halogens). The chamber panel forward-fills from the last *bright* frame336    when the current layer's chamber frame is dark — this keeps all three337    panels in layer-sync while never displaying a dark chamber view.338    """339    all_cells = collect_all_kinds(parquet_path, cols_map)340 341    all_levels = sorted(set().union(*(set(d) for d in all_cells.values())))342    if not all_levels:343        print("  composite: no printing-phase frames (z2 > 0), skipping")344        return 0345 346    target_levels = _subsample(all_levels)347    fill_per_kind = {k: _forward_fill(all_cells[k], target_levels) for k in FRAME_KINDS}348 349    canvas_widths: dict[str, int] = {}350    for kind in FRAME_KINDS:351        first_c = next((c for c in fill_per_kind[kind] if c), None)352        canvas_widths[kind] = (353            _canvas_width(first_c, kind) if first_c else GIF_PANEL_HEIGHT354        )355    total_w = sum(canvas_widths.values())356 357    # Pre-decode chamber frames once; compute adaptive brightness threshold.358    chamber_decoded = [359        _decode_cell(c, "chamber") for c in fill_per_kind["chamber"]360    ]361    chamber_threshold = _chamber_threshold(chamber_decoded)362 363    last_bright_chamber: Image.Image | None = None364    pil_frames: list[Image.Image] = []365    for i in range(len(target_levels)):366        composite = Image.new("RGB", (total_w, GIF_PANEL_HEIGHT), (20, 20, 20))367        x = 0368        for kind in FRAME_KINDS:369            if kind == "chamber":370                img = chamber_decoded[i]371                # Update the running bright-chamber reference when this frame is bright.372                if img is not None and _mean_brightness(img) >= chamber_threshold:373                    last_bright_chamber = img374                # Always use the last bright frame (forward-fill); placeholder until375                # the first bright frame arrives.376                panel_img = last_bright_chamber377            else:378                panel_img = _decode_cell(fill_per_kind[kind][i], kind)379 380            panel = (381                _fit_to_canvas(panel_img, canvas_widths[kind])382                if panel_img else _placeholder(canvas_widths[kind])383            )384            composite.paste(panel, (x, 0))385            x += canvas_widths[kind]386        pil_frames.append(composite)387 388    _write_gif(pil_frames, out_path)389    return len(pil_frames)390 391 392def process_build(build_id: int, kinds: set[str]) -> None:393    parquet_path = TICKS_DIR / f"{build_id:03d}.parquet"394    if not parquet_path.exists():395        print(f"build {build_id:03d}: no parquet, skipping")396        return397 398    build_dir = OUTPUT_DIR / f"{build_id:03d}"399    print(f"build {build_id:03d}: timelapse GIFs → {build_dir.relative_to(Path.cwd())}/")400    cols_map = _columns_for_build(parquet_path)401    if ("thermal" in kinds or "composite" in kinds):402        print(f"  thermal source: {cols_map['thermal']}")403 404    for kind in FRAME_KINDS:405        if kind not in kinds:406            continue407        out = build_dir / f"timelapse_{kind}.gif"408        n = render_timelapse_kind(parquet_path, kind, out, cols_map[kind])409        if out.exists():410            size = out.stat().st_size411            print(f"  {kind:8s}: {n:>4} frames → {out.name}  ({size:,} bytes)")412 413    if "composite" in kinds:414        out = build_dir / "timelapse_composite.gif"415        n = render_timelapse_composite(parquet_path, out, cols_map)416        if out.exists():417            size = out.stat().st_size418            print(f"  composite: {n:>4} frames → {out.name}  ({size:,} bytes)")419 420 421def main():422    import argparse423    parser = argparse.ArgumentParser(424        description=__doc__,425        formatter_class=argparse.RawDescriptionHelpFormatter,426    )427    parser.add_argument("build_ids", nargs="*", type=int,428                        help="Build IDs to render (default: all in data/ticks/)")429    parser.add_argument(430        "--kinds", default="chamber,thermal,galvo,composite",431        help="Comma-separated outputs to render. "432             "Valid: chamber, thermal, galvo, composite. Default: all four.",433    )434    args   = parser.parse_args()435    kinds  = set(args.kinds.split(","))436    unknown = kinds - (set(FRAME_KINDS) | {"composite"})437    if unknown:438        parser.error(f"unknown --kinds values: {sorted(unknown)}")439 440    targets = args.build_ids or sorted(int(p.stem) for p in TICKS_DIR.glob("*.parquet"))441    for bid in targets:442        process_build(bid, kinds)443 444 445if __name__ == "__main__":446    main()447