Article

Rendering a Live Rich Dashboard for Batch Raster Jobs

To show a live dashboard for a batch raster job, wrap a rich.live.Live around a Group that holds a Progress bar (tiles done, ETA) and a Table of per-worker status, then drive both from the parent process as ProcessPoolExecutor futures resolve through as_completed. Workers only reproject tiles and return counts; the parent owns the terminal and renders. This page is part of the Rich Console Output & Progress Bars for GIS CLIs guide inside the broader CLI Architecture & Design Patterns for Python GIS reference.

Prerequisites

  • Python 3.10 or later
  • pip install "rich>=13.0" rasterio pyproj
  • GDAL 3.4+ available to rasterio (via a wheel or conda/mamba)
  • concurrent.futures and multiprocessing are in the standard library

For the parallelism model underneath this dashboard, read Multiprocessing Geospatial Tasks. For the counting and ETA concepts the dashboard visualises, see Progress Tracking for Python GIS Batch Pipelines.

How the Dashboard Data Flows

The key architectural constraint is that rendering happens in exactly one process. Worker processes reproject tiles and return small result objects; the parent process consumes those results, mutates the Progress task and the per-worker Table, and asks Live to redraw. Nothing about Rich crosses the process boundary.

Live Rich dashboard data flow Three worker processes on the left reproject raster tiles and return result objects to a parent process on the right. The parent updates a Progress bar and a per-worker Table held inside one Live region, which redraws in place in the terminal. ProcessPoolExecutor Worker 1 reproject tile Worker 2 reproject tile Worker 3 reproject tile return TileResult(worker, ok, seconds) as_completed Parent process (renders) Live region (redraws in place) Progress: tiles done, bar, ETA Table: per-worker throughput and errors worker tiles/s done errors 1 2.4 18 0 2 2.1 16 1 live.update(dashboard) after each future

Complete Working Implementation

The script below reprojects every GeoTIFF tile in a directory to a target CRS using a ProcessPoolExecutor, while the parent process renders a single live dashboard. Copy it, adjust the paths and --crs, and run directly. Worker functions return a small TileResult and never touch Rich:

#!/usr/bin/env python3
"""
Live Rich dashboard over a multiprocessing raster reprojection batch.
Usage: python live_dashboard.py ./tiles ./out --crs EPSG:3857 --workers 4
"""
import os
import sys
import time
import argparse
from pathlib import Path
from dataclasses import dataclass
from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor, as_completed

import rasterio
from rasterio.warp import calculate_default_transform, reproject, Resampling

from rich.console import Group
from rich.live import Live
from rich.table import Table
from rich.progress import (
    Progress, BarColumn, TextColumn,
    TimeRemainingColumn, MofNCompleteColumn,
)


@dataclass
class TileResult:
    """Plain, picklable payload returned from a worker to the parent."""
    tile: str
    worker: int
    ok: bool
    seconds: float
    error: str | None = None


def reproject_tile(src_path: Path, dst_path: Path, dst_crs: str) -> TileResult:
    """Reproject one raster tile. Runs in a worker process — no Rich here."""
    worker = os.getpid()
    start = time.perf_counter()
    try:
        with rasterio.open(src_path) as src:
            transform, width, height = calculate_default_transform(
                src.crs, dst_crs, src.width, src.height, *src.bounds
            )
            profile = src.profile.copy()
            profile.update(
                crs=dst_crs, transform=transform,
                width=width, height=height,
                compress="lzw", tiled=True,
            )
            with rasterio.open(dst_path, "w", **profile) as dst:
                for band in range(1, src.count + 1):
                    reproject(
                        source=rasterio.band(src, band),
                        destination=rasterio.band(dst, band),
                        src_transform=src.transform, src_crs=src.crs,
                        dst_transform=transform, dst_crs=dst_crs,
                        resampling=Resampling.bilinear,
                    )
        elapsed = time.perf_counter() - start
        return TileResult(src_path.name, worker, True, elapsed)
    except Exception as exc:
        elapsed = time.perf_counter() - start
        return TileResult(src_path.name, worker, False, elapsed, str(exc))


def build_dashboard(progress: Progress, table: Table) -> Group:
    """Compose the two renderables into one block Live can refresh."""
    return Group(progress, table)


def render_table(stats: dict[int, dict], errors: int) -> Table:
    """Rebuild the per-worker table from accumulated stats."""
    table = Table(title=f"Per-worker throughput  (errors: {errors})",
                  expand=True, title_justify="left")
    table.add_column("Worker (PID)", justify="right", no_wrap=True)
    table.add_column("Tiles done", justify="right")
    table.add_column("Tiles/sec", justify="right")
    table.add_column("Errors", justify="right", style="red")
    for pid, s in sorted(stats.items()):
        rate = s["done"] / s["busy"] if s["busy"] > 0 else 0.0
        table.add_row(str(pid), str(s["done"]), f"{rate:0.2f}", str(s["errors"]))
    return table


def main() -> None:
    parser = argparse.ArgumentParser(description="Live dashboard raster batch")
    parser.add_argument("input_dir", type=Path)
    parser.add_argument("output_dir", type=Path)
    parser.add_argument("--crs", default="EPSG:3857", help="Target CRS")
    parser.add_argument("--workers", type=int, default=4)
    args = parser.parse_args()
    args.output_dir.mkdir(parents=True, exist_ok=True)

    tiles = sorted(args.input_dir.glob("*.tif"))
    if not tiles:
        print(f"No .tif tiles in {args.input_dir}", file=sys.stderr)
        sys.exit(2)

    progress = Progress(
        TextColumn("[bold]Reprojecting tiles"),
        BarColumn(),
        MofNCompleteColumn(),
        TextColumn("ETA"),
        TimeRemainingColumn(),
        refresh_per_second=4,
    )
    task_id = progress.add_task("tiles", total=len(tiles))
    stats: dict[int, dict] = defaultdict(
        lambda: {"done": 0, "errors": 0, "busy": 0.0}
    )
    errors = 0

    # Live owns the terminal. Only this (parent) process renders.
    with Live(build_dashboard(progress, render_table(stats, errors)),
              refresh_per_second=4) as live:
        with ProcessPoolExecutor(max_workers=args.workers) as pool:
            futures = {
                pool.submit(reproject_tile, t,
                            args.output_dir / f"{t.stem}_3857.tif", args.crs): t
                for t in tiles
            }
            for future in as_completed(futures):
                result: TileResult = future.result()
                s = stats[result.worker]
                s["busy"] += result.seconds
                if result.ok:
                    s["done"] += 1
                else:
                    s["errors"] += 1
                    errors += 1
                    live.console.print(
                        f"[red]FAIL[/red] {result.tile}: {result.error}"
                    )
                progress.advance(task_id, 1)
                live.update(build_dashboard(progress, render_table(stats, errors)))

    total_ok = sum(s["done"] for s in stats.values())
    print(f"Completed {total_ok}/{len(tiles)} tiles, {errors} errors.")
    sys.exit(0 if errors == 0 else 12)   # 12 = partial batch failure


if __name__ == "__main__":
    main()

Step Annotations

  1. TileResult is a plain dataclass — Anything a worker returns must be picklable to cross the process boundary. A Live, Progress, or Console object cannot be pickled, so workers return only primitive fields (tile name, PID, success flag, elapsed seconds). The parent turns those numbers into rendered rows.

  2. Group(progress, table)rich.console.Group stacks the Progress bar above the Table as one renderable. Live then treats the pair as a single region and rewrites exactly those lines on each update, which is what keeps the dashboard pinned instead of scrolling.

  3. MofNCompleteColumn plus TimeRemainingColumn — The M/N column shows tiles done out of total, and the time-remaining column derives the ETA from the recent completion rate. Because progress.advance(task_id, 1) fires once per completed tile, the rate is measured in whole tiles and the ETA settles quickly.

  4. stats keyed by worker PIDProcessPoolExecutor reuses a fixed set of worker processes, so os.getpid() inside reproject_tile yields a stable identifier per worker. Accumulating done, errors, and busy seconds per PID gives a genuine tiles-per-second figure for each worker row.

  5. live.console.print(...) for failures — Routing per-tile log lines through the Live console prints them above the pinned dashboard rather than tearing it. See Progress Tracking for Python GIS Batch Pipelines for the same log-above-progress technique applied to plain counters.

  6. live.update(build_dashboard(...)) — The table is immutable once built, so a fresh Table is composed on every result and handed to live.update. At a few tiles per second this rebuild is negligible, and it guarantees the rendered state always reflects the latest completed tile.

  7. Exit code 12 — Following the site’s domain convention, a run with any failed tile exits 12 (partial batch failure) so a calling shell script or CI job can branch on it, while a clean run exits 0.

Named Gotcha: Updating Rich From Worker Processes

The single most common way this pattern breaks is trying to pass the Live, Progress, or a shared Console into the worker so each process can update its own row. It fails immediately: ProcessPoolExecutor pickles the arguments to every task, and these objects hold a reference to an open terminal file handle, raising TypeError: cannot pickle '_io.TextIOWrapper' object (or a lock-pickling error). Even if you force it through with a manager proxy, multiple processes writing ANSI escape sequences to the same terminal interleave into corrupted, unreadable output.

The fix is the architecture shown above: workers are pure functions that reproject a tile and return a TileResult. Only the parent process holds the Live instance, and it mutates the Progress task and rebuilds the Table inside the single-threaded as_completed loop. All rendering is serialised through one process, so the terminal never sees interleaved writes. This mirrors the render-in-the-parent rule from Multiprocessing Geospatial Tasks.

Verification

Run the script against a directory of tiles, then confirm the tally and CRS of the outputs:

# Run the batch; capture the exit code
python live_dashboard.py ./tiles ./out --crs EPSG:3857 --workers 4
echo "exit code: $?"          # 0 = all tiles OK, 12 = partial failure

# Count outputs against inputs
echo "inputs:  $(ls ./tiles/*.tif | wc -l)"
echo "outputs: $(ls ./out/*_3857.tif | wc -l)"

# Confirm the target CRS landed on the first output
python3 - <<'EOF'
import rasterio
with rasterio.open(sorted(__import__("pathlib").Path("./out").glob("*_3857.tif"))[0]) as ds:
    print("CRS:", ds.crs)       # should print EPSG:3857
EOF

A matching input/output count, an exit code of 0, and EPSG:3857 on the outputs confirm the dashboard tracked every tile and no failure was silently swallowed. If the counts differ, the printed FAIL lines above the dashboard name the exact tiles and errors.

Troubleshooting

Symptom Root Cause Fix
TypeError: cannot pickle ... on submit Live/Console passed into a worker Return TileResult; render only in the parent
Dashboard scrolls instead of redrawing Progress and Table printed separately Wrap both in one Group inside a single Live
ETA erratic for the first few tiles Rate estimate is noisy while the sample is small Advance one unit per tile; it stabilises after ~12
Log lines shred the table Using bare print() during Live Use live.console.print(...) so lines land above the region
Worker rows never appear Table not rebuilt after results Call live.update with a fresh table each iteration

FAQ

Can I update a Rich Live display from inside worker processes?

No. A Rich Live instance owns a single terminal and cannot be pickled or shared across processes. Only the parent process may render. Workers return plain data such as tile counts and error flags, and the parent updates the Progress bar and Table from the as_completed loop.

How do I stop the dashboard from flooding the scrollback?

Render the Progress bar and Table inside a single Live region so Rich rewrites the same block of lines instead of appending. Route any per-tile log lines through live.console.print or a logging RichHandler bound to the same console so they scroll above the pinned dashboard.

Why is my ETA jumping around during the raster batch?

TimeRemainingColumn estimates from the recent completion rate. When tiles vary widely in size the rate is noisy early on. Advance the Progress task once per completed tile rather than per byte, and the estimate stabilises after the first dozen tiles.

Should I use refresh_per_second or manual refresh with Live?

For a batch driven by as_completed, set a modest refresh_per_second such as 4 and call live.update after each result. A high refresh rate wastes CPU redrawing an unchanged table, and manual updates guarantee the view reflects the latest completed tile.