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.futuresandmultiprocessingare 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.
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
-
TileResultis a plain dataclass — Anything a worker returns must be picklable to cross the process boundary. ALive,Progress, orConsoleobject cannot be pickled, so workers return only primitive fields (tile name, PID, success flag, elapsed seconds). The parent turns those numbers into rendered rows. -
Group(progress, table)—rich.console.Groupstacks theProgressbar above theTableas one renderable.Livethen 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. -
MofNCompleteColumnplusTimeRemainingColumn— TheM/Ncolumn shows tiles done out of total, and the time-remaining column derives the ETA from the recent completion rate. Becauseprogress.advance(task_id, 1)fires once per completed tile, the rate is measured in whole tiles and the ETA settles quickly. -
statskeyed by worker PID —ProcessPoolExecutorreuses a fixed set of worker processes, soos.getpid()insidereproject_tileyields a stable identifier per worker. Accumulatingdone,errors, andbusyseconds per PID gives a genuine tiles-per-second figure for each worker row. -
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. -
live.update(build_dashboard(...))— The table is immutable once built, so a freshTableis composed on every result and handed tolive.update. At a few tiles per second this rebuild is negligible, and it guarantees the rendered state always reflects the latest completed tile. -
Exit code
12— Following the site’s domain convention, a run with any failed tile exits12(partial batch failure) so a calling shell script or CI job can branch on it, while a clean run exits0.
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.
Related
- Rich Console Output & Progress Bars for GIS CLIs — parent guide covering progress bars, spinners, and structured console output for geospatial command-line tools
- Customizing Rich Tables for Coordinate System Outputs — styling and column formatting for the Rich Table used in this dashboard