Created
August 28, 2026 05:58
-
-
Save mithro/7a4520b69d42311e525a5d1e6e24af29 to your computer and use it in GitHub Desktop.
pyuscope scan viewers: per-directory HTML grid, overlap view from uscan.json, and mosaic images
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Write tiles.html into every directory that holds cNNN_rNNN.jpg tiles, laying them out | |
| in a table (rows = r, cols = c). Also writes ROOT/tiles.html linking to each page. | |
| The page is deliberately NOT called index.html. These trees are published by | |
| nginx with `autoindex on` (data.wafer.space), where a directory request goes | |
| `try_files $uri/` -> index module -> index.html if it exists, and only falls | |
| through to the generated file listing if it does not. An index.html here would | |
| therefore replace the browsable listing of the scan tiles. Keep any name | |
| outside the `index` set. overlap_html.py writes overlap.html for the same | |
| reason. | |
| Usage: grid_html.py [ROOT] [--width PX] | |
| """ | |
| import argparse, os, re | |
| from html import escape | |
| NAME_RE = re.compile(r"c(\d+)_r(\d+)\.jpe?g$", re.I) | |
| def write_page(dirpath, files, width): | |
| tiles = {} | |
| for f in files: | |
| m = NAME_RE.search(f) | |
| if m: | |
| tiles[(int(m.group(2)), int(m.group(1)))] = f # (row, col) -> filename | |
| if not tiles: | |
| return False | |
| rows = sorted({r for r, _ in tiles}, reverse=True) | |
| cols = sorted({c for _, c in tiles}, reverse=True) | |
| html = [f"<!doctype html><title>{escape(os.path.basename(dirpath))}</title>", | |
| f"<style>img{{width:{width}px;display:block}} td{{padding:0}} table{{border-spacing:4px}}</style>", | |
| f"<h1>{escape(dirpath)}</h1><p>{len(tiles)} tiles, {len(rows)} rows x {len(cols)} cols</p>", | |
| "<table><tr><th></th>" + "".join(f"<th>c{c}</th>" for c in cols) + "</tr>"] | |
| for r in rows: | |
| cells = [] | |
| for c in cols: | |
| f = tiles.get((r, c)) | |
| cells.append(f'<td><a href="{f}"><img loading="lazy" src="{f}" title="c{c} r{r}"></a></td>' if f else "<td></td>") | |
| html.append(f"<tr><th>r{r}</th>{''.join(cells)}</tr>") | |
| html.append("</table>") | |
| with open(os.path.join(dirpath, "tiles.html"), "w") as fh: | |
| fh.write("\n".join(html)) | |
| return True | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("root", nargs="?", default=".") | |
| ap.add_argument("--width", type=int, default=200, help="thumbnail width in px") | |
| a = ap.parse_args() | |
| pages = [] | |
| for dirpath, _, files in os.walk(a.root): | |
| if write_page(dirpath, files, a.width): | |
| pages.append(os.path.relpath(os.path.join(dirpath, "tiles.html"), a.root)) | |
| print("wrote", os.path.join(dirpath, "tiles.html")) | |
| with open(os.path.join(a.root, "tiles.html"), "w") as fh: | |
| fh.write("<!doctype html><title>scans</title><ul>" + | |
| "".join(f'<li><a href="{p}">{escape(os.path.dirname(p))}</a></li>' for p in sorted(pages)) + "</ul>") | |
| if __name__ == "__main__": | |
| main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Compose one mosaic image per directory from cNNN_rNNN.jpg tiles (same layout as grid_html.py). | |
| Usage: grid_png.py ROOT --out OUTDIR [--tile PX] | |
| """ | |
| import argparse, os, re | |
| from multiprocessing import Pool | |
| from PIL import Image | |
| NAME_RE = re.compile(r"c(\d+)_r(\d+)\.jpe?g$", re.I) | |
| def load(args): | |
| path, tile = args | |
| im = Image.open(path) | |
| im.draft("RGB", (tile, tile)) # fast JPEG decode at reduced scale | |
| return im.convert("RGB").resize((tile, tile), Image.BILINEAR) | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("root") | |
| ap.add_argument("--out", required=True) | |
| ap.add_argument("--tile", type=int, default=64, help="pixels per tile in the mosaic") | |
| a = ap.parse_args() | |
| os.makedirs(a.out, exist_ok=True) | |
| with Pool() as pool: | |
| for dirpath, _, files in os.walk(a.root): | |
| tiles = {} | |
| for f in files: | |
| m = NAME_RE.search(f) | |
| if m: | |
| tiles[(int(m.group(2)), int(m.group(1)))] = os.path.join(dirpath, f) | |
| if not tiles: | |
| continue | |
| rows = sorted({r for r, _ in tiles}, reverse=True); cols = sorted({c for _, c in tiles}, reverse=True) | |
| keys = sorted(tiles) | |
| mosaic = Image.new("RGB", (len(cols) * a.tile, len(rows) * a.tile), "black") | |
| for (r, c), im in zip(keys, pool.imap(load, [(tiles[k], a.tile) for k in keys], chunksize=32)): | |
| mosaic.paste(im, (cols.index(c) * a.tile, rows.index(r) * a.tile)) | |
| out = os.path.join(a.out, os.path.basename(dirpath) + ".jpg") | |
| mosaic.save(out, quality=90) | |
| print(f"wrote {out} {mosaic.size[0]}x{mosaic.size[1]} ({len(tiles)} tiles)", flush=True) | |
| if __name__ == "__main__": | |
| main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Write overlap.html into every directory with a uscan.json, placing each tile at its | |
| stage position so neighbouring images overlap as they were captured. | |
| Usage: overlap_html.py [ROOT] [--scale F] | |
| """ | |
| import argparse, json, os | |
| from html import escape | |
| def write_page(dirpath, scale): | |
| try: | |
| with open(os.path.join(dirpath, "uscan.json")) as fh: | |
| js = json.load(fh) | |
| except FileNotFoundError: | |
| return False | |
| ax = js["points-xy3p"]["axes"]["x"] | |
| px_per_mm = ax["pixels_per_mm"] * scale | |
| size = ax["view_pixels"] * scale | |
| files = {f: v["position"] for f, v in js["files"].items() if os.path.exists(os.path.join(dirpath, f))} | |
| xs = [p["x"] for p in files.values()]; ys = [p["y"] for p in files.values()] | |
| # same orientation as the table view: highest col at left, highest row at top | |
| w = (max(xs) - min(xs)) * px_per_mm + size | |
| h = (max(ys) - min(ys)) * px_per_mm + size | |
| html = [f"<!doctype html><title>{escape(os.path.basename(dirpath))} (overlap)</title>", | |
| "<style>#c{position:relative;background:#222} #c img{position:absolute}</style>", | |
| f"<h1>{escape(dirpath)}</h1><p>{len(files)} tiles at scale {scale}, {round(w)} x {round(h)} px</p>", | |
| f'<div id="c" style="width:{w:.0f}px;height:{h:.0f}px">'] | |
| for f, p in sorted(files.items()): | |
| left = (max(xs) - p["x"]) * px_per_mm | |
| top = (p["y"] - min(ys)) * px_per_mm | |
| html.append(f'<img loading="lazy" src="{f}" title="{f} x={p["x"]:.3f} y={p["y"]:.3f}" ' | |
| f'style="left:{left:.1f}px;top:{top:.1f}px;width:{size:.1f}px;height:{size:.1f}px">') | |
| html.append("</div>") | |
| with open(os.path.join(dirpath, "overlap.html"), "w") as fh: | |
| fh.write("\n".join(html)) | |
| return True | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("root", nargs="?", default=".") | |
| ap.add_argument("--scale", type=float, default=0.25, help="tile scale factor (1.0 = full size)") | |
| a = ap.parse_args() | |
| pages = [] | |
| for dirpath, _, files in os.walk(a.root): | |
| if any(f.lower().endswith(".jpg") for f in files): | |
| if write_page(dirpath, a.scale): | |
| pages.append(os.path.relpath(os.path.join(dirpath, "overlap.html"), a.root)) | |
| print("wrote", os.path.join(dirpath, "overlap.html")) | |
| else: | |
| print("skipped (no uscan.json)", dirpath) | |
| with open(os.path.join(a.root, "overlap.html"), "w") as fh: | |
| fh.write("<!doctype html><title>scans (overlap)</title><ul>" + | |
| "".join(f'<li><a href="{p}">{escape(os.path.dirname(p))}</a></li>' for p in sorted(pages)) + "</ul>") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment