From 314b68322cfa82a7f4a45ea67a270ff2c7f47437 Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Sun, 26 Apr 2026 20:44:56 -0400 Subject: [PATCH] Add script to stitch tiles into a mosaic, with gutter/padding support - scripts/stitch_mosaic_from_tiles.py: grid layout from metadata.json, flip-x/y, tile gap (gutters), compare to server mosaic.jpg - tests/test_stitch_mosaic.py, Pillow in requirements, docs/mosaic_reconstruction_report.md --- docs/mosaic_reconstruction_report.md | 77 ++++++ requirements.txt | 1 + scripts/stitch_mosaic_from_tiles.py | 347 +++++++++++++++++++++++++++ tests/test_stitch_mosaic.py | 232 ++++++++++++++++++ 4 files changed, 657 insertions(+) create mode 100644 docs/mosaic_reconstruction_report.md create mode 100644 scripts/stitch_mosaic_from_tiles.py create mode 100644 tests/test_stitch_mosaic.py diff --git a/docs/mosaic_reconstruction_report.md b/docs/mosaic_reconstruction_report.md new file mode 100644 index 0000000..aca85e9 --- /dev/null +++ b/docs/mosaic_reconstruction_report.md @@ -0,0 +1,77 @@ +# RootView SPRUCE server: pre-stitched mosaic vs tile imagery + +Observations from the **RootView** deployment used by the **SPRUCE** minirhizotron experiment (web UI and image service). They concern what the server actually delivers for scan previews versus per-tile imagery—not any particular client or archive layout. + +## Summary + +### How the RootView web app presents the data + +**Tiles** — A *scan* is a rectangular grid of fixed positions along a buried minirhizotron tube. Each cell is a **JPEG image** fetched by coordinates: position along the tube circumference (**X**, millimetres) and along the tube length / depth (**Y**, millimetres). The camera steps a regular grid from a **start** to an **end** position in each axis, with spacing **dx** and **dy**. A scale parameter (**s**) on the tile request selects rendering resolution. In the UI, users open individual tiles (for example from a popup or drill-down) to inspect roots at full zoom; tiles are the **authoritative** image product for a single field of view on the tube. + +**Mosaic** — The **scan view** page shows one large **pre-composited** image, **`mosaic.jpg`**, that depicts the **entire grid** in a single picture: the whole scanned region at a glance. It is used for **orientation and navigation**—seeing how the tube was covered, spotting patterns, and correlating with the grid—rather than as the per-tile download path. The page embeds that image from the separate **image service** (static path per scan ID). + +**Metadata** — The same **scan view** page carries **structured scan metadata**: identifiers (e.g. scan ID, name), **when** the scan ran (date/time, duration), **who** operated it, **how** it was acquired (scan mode, line orientation), and the **imaging grid**: **nx** and **ny** (counts), **start_x** / **start_y** and **end_x** / **end_y** (mm), **dx** / **dy** (step in mm), and derived **total_tiles**. Together, those fields define which **(x, y)** pairs correspond to each tile and how the mosaic is aligned to the physical tube. + +### Sample data + +Hostnames and ports match the **SPRUCE** RootView deployment documented for this service: web app **`http://205.149.147.131:8010`**, image service **`http://205.149.147.131:8011`**. + +**Note:** `index.php` URLs usually require an **authenticated browser session** (login on port 8010). The `mosaic.jpg` URL is served from port **8011**; whether it is reachable without cookies depends on server configuration. + +For each scan below: + +- **Scan view (HTML metadata / grid parameters):** + `http://205.149.147.131:8010/index.php?cmd=scan&mode=view&id=` +- **Pre-stitched mosaic JPEG:** + `http://205.149.147.131:8011/RootView_Database//mosaic.jpg` +- **Example tile (`s=1`, grid corner `row=0`, `col=0` → `x=start_x`, `y=start_y` mm):** + `http://205.149.147.131:8010/index.php?cmd=image&mode=image_scan&id=&s=1&x=&y=` + +| Scan ID | Name (from scan record) | View scan | `mosaic.jpg` | Example tile (`x`, `y` mm) | +|--------:|-------------------------|-----------|--------------|---------------------------| +| 156875 | Plot 6 AMR19 13458 | [view](http://205.149.147.131:8010/index.php?cmd=scan&mode=view&id=156875) | [mosaic.jpg](http://205.149.147.131:8011/RootView_Database/156875/mosaic.jpg) | [tile s=1](http://205.149.147.131:8010/index.php?cmd=image&mode=image_scan&id=156875&s=1&x=42.14&y=273.46) | +| 146368 | Plot 10 AMR22 14350 | [view](http://205.149.147.131:8010/index.php?cmd=scan&mode=view&id=146368) | [mosaic.jpg](http://205.149.147.131:8011/RootView_Database/146368/mosaic.jpg) | [tile s=1](http://205.149.147.131:8010/index.php?cmd=image&mode=image_scan&id=146368&s=1&x=150.5&y=442.96) | +| 160022 | Plot 11 AMR23 14309 | [view](http://205.149.147.131:8010/index.php?cmd=scan&mode=view&id=160022) | [mosaic.jpg](http://205.149.147.131:8011/RootView_Database/160022/mosaic.jpg) | [tile s=1](http://205.149.147.131:8010/index.php?cmd=image&mode=image_scan&id=160022&s=1&x=27.09&y=131.08) | +| 156957 | Plot 13 AMR 24 17454 | [view](http://205.149.147.131:8010/index.php?cmd=scan&mode=view&id=156957) | [mosaic.jpg](http://205.149.147.131:8011/RootView_Database/156957/mosaic.jpg) | [tile s=1](http://205.149.147.131:8010/index.php?cmd=image&mode=image_scan&id=156957&s=1&x=276.92&y=2.26) | + +The tile coordinates are the scan’s **`start_x`** and **`start_y`** (first grid position). Other tiles use the same `id` and `s`, with `x` and `y` stepped by **`dx`** and **`dy`** from the scan view metadata. + +### Findings + +| Topic | Finding | +|--------|---------| +| **`mosaic.jpg` resolution** | The image at `http://:8011/RootView_Database//mosaic.jpg` is a **JPEG whose pixel dimensions are far smaller** than assembling the same scan from full-resolution tile requests (e.g. typical tile sizes on the order of 640×480 at scale 1). It behaves like a **downsampled preview**, not a pixel-for-pixel merge of those tiles. | +| **“Full resolution”** | The file still represents the **full spatial extent** of the scan (whole tube region as one picture). **Full extent** should not be confused with **full sensor / tile pixel resolution**. | +| **Other mosaic URLs** | The scan **view** page embeds this **single** `mosaic.jpg` URL (no query string in the `src` observed in page markup). There is **no** documented or obvious alternate HTTP path in that UI for a higher-resolution pre-stitched mosaic. A different filename or PHP endpoint could exist server-side but would require checking the live service (network log, server code). | +| **Circumference (X) direction** | Compared side-by-side with a mosaic built by placing tiles in **grid column order** matching the tile API (`x` / column index increasing in one direction), the server `mosaic.jpg` matches **after mirroring left–right** (column order reversed in the image). | +| **Depth (Y) direction** | For the scans checked, **vertical** orientation did **not** require a global flip to align with that tile ordering; a vertical mirror **worsened** agreement. | +| **Between-tile appearance** | Server mosaics show **narrow light (typically white) separation** between tile cells—visible borders or gutters—not a seamless paste of tile pixels edge-to-edge. | + +## Endpoints (as exposed by this RootView instance) + +- **Web application** (login, scan list, scan detail): served from the main host (e.g. port **8010** in the SPRUCE deployment). +- **Pre-stitched mosaic:** `GET http://:8011/RootView_Database//mosaic.jpg` — static path under the image service; **not** the same request pattern as tiles. +- **Individual tiles:** `GET .../index.php?cmd=image&mode=image_scan&id=&s=&x=&y=` on the web host, with millimetre coordinates from the scan grid. + +Tile **`s`** (scale) controls tile resolution; **`mosaic.jpg`** exposes **no** analogous parameter in the URL seen in the UI. + +## Empirical resolution comparison + +When the same scan is available both as: + +- the downloaded **`mosaic.jpg`**, and +- the full set of **tile JPEGs** at a fixed scale, + +the **intrinsic size** of `mosaic.jpg` is on the order of **hundreds** of pixels per side, while an assembly of those tiles spans **thousands** of pixels per side (depending on `nx`, `ny`, and tile size). So the server preview is **not** bit-equivalent to “all tiles merged at native tile resolution.” + +Any numeric comparison after **resizing** one image to match the other is only useful for **relative** checks (e.g. mirroring, gutter width), not for claiming identity, because of scaling, JPEG compression, and residual layout differences. + +## Implications + +1. **`mosaic.jpg`** is appropriate for **quick visual review** and light workflows; **quantitative or publication-grade** work that needs tile-level pixels should use the **tile API** (or derivatives built from those tiles). +2. Claims that the server mosaic is “full resolution of the available tile data” should be read carefully: **full field of view** is plausible; **full pixel resolution matching tiles** is **not** supported by measurements on this service. +3. To confirm whether **any** higher-resolution pre-stitched asset exists, inspect **browser network requests** on a scan page or **server-side** RootView sources—this cannot be settled from the public HTML alone. + +--- + +*Observations reflect the RootView SPRUCE image service behavior as exercised through its web UI and HTTP endpoints; server software may change in future deployments.* diff --git a/requirements.txt b/requirements.txt index 26e62e8..4cf59c3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ lxml>=5.0.0 pyyaml>=6.0.1 tqdm>=4.66.0 piexif>=1.1.3 +Pillow>=10.0.0 pytest>=8.0 diff --git a/scripts/stitch_mosaic_from_tiles.py b/scripts/stitch_mosaic_from_tiles.py new file mode 100644 index 0000000..fcc2578 --- /dev/null +++ b/scripts/stitch_mosaic_from_tiles.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +""" +Reconstruct a scan mosaic from archived tiles (grid layout matches the scraper). + +Usage: + python scripts/stitch_mosaic_from_tiles.py /path/to/scan_dir + python scripts/stitch_mosaic_from_tiles.py /path/to/scan_dir --no-flip-x # raw col index = left-to-right + python scripts/stitch_mosaic_from_tiles.py /path/to/scan_dir --flip-y --compare-mosaic + +By default, columns are mirrored horizontally so the stitched image matches RootView's +downloaded mosaic.jpg (low ``col_index`` tiles sit on the right in the server preview). +Default ``--tile-gap`` is 1 (white gutters like the server); use ``--tile-gap 0`` for flush tiles. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from PIL import Image, ImageChops + +TILE_FILENAME_RE = re.compile(r"tile_r(\d+)_c(\d+)\.jpg$", re.IGNORECASE) + + +def _load_metadata(scan_dir: Path) -> dict: + path = scan_dir / "metadata.json" + if not path.is_file(): + raise SystemExit(f"Missing metadata.json: {path}") + with path.open(encoding="utf-8") as f: + return json.load(f) + + +def _index_tiles(tiles_dir: Path) -> dict[tuple[int, int], Path]: + if not tiles_dir.is_dir(): + raise SystemExit(f"Missing tiles directory: {tiles_dir}") + by_rc: dict[tuple[int, int], Path] = {} + for p in tiles_dir.iterdir(): + if not p.is_file(): + continue + m = TILE_FILENAME_RE.match(p.name) + if not m: + continue + key = (int(m.group(1)), int(m.group(2))) + if key in by_rc: + raise SystemExit(f"Duplicate tile index {key}: {by_rc[key]} and {p}") + by_rc[key] = p + return by_rc + + +def _open_tile_rgb(path: Path) -> Image.Image: + try: + im = Image.open(path) + im.load() + except OSError as exc: + raise SystemExit(f"Cannot read tile image {path}: {exc}") from exc + if im.mode in ("RGBA", "LA"): + bg = Image.new("RGB", im.size, (255, 255, 255)) + bg.paste(im, mask=im.split()[-1]) + return bg + return im.convert("RGB") + + +def _paste_tile( + canvas: Image.Image, + tile: Image.Image, + row: int, + col: int, + nx: int, + ny: int, + tw: int, + th: int, + tile_gap: int, + flip_y: bool, + flip_x: bool, +) -> None: + if flip_x: + col_x = nx - 1 - col + else: + col_x = col + if flip_y: + row_y = ny - 1 - row + else: + row_y = row + stride_w = tw + tile_gap + stride_h = th + tile_gap + x = col_x * stride_w + y = row_y * stride_h + canvas.paste(tile, (x, y)) + + +def stitch_scan( + scan_dir: Path, + *, + flip_y: bool, + flip_x: bool, + allow_missing: bool, + tile_gap: int = 1, + gap_fill: tuple[int, int, int] = (255, 255, 255), +) -> tuple[Image.Image, int, int, int, int]: + meta = _load_metadata(scan_dir) + nx = int(meta.get("nx") or 0) + ny = int(meta.get("ny") or 0) + if nx < 1 or ny < 1: + raise SystemExit(f"Invalid nx/ny in metadata: nx={nx} ny={ny}") + if tile_gap < 0: + raise SystemExit(f"tile_gap must be >= 0, got {tile_gap}") + + tiles_dir = scan_dir / "tiles" + by_rc = _index_tiles(tiles_dir) + expected = nx * ny + if not by_rc: + raise SystemExit(f"No tile files matching tile_r*_c*.jpg in {tiles_dir}") + if len(by_rc) > expected: + raise SystemExit( + f"Too many tile files: {len(by_rc)} (expected at most {expected} for nx={nx} ny={ny})" + ) + missing: list[tuple[int, int]] = [] + for r in range(ny): + for c in range(nx): + if (r, c) not in by_rc: + missing.append((r, c)) + if missing and not allow_missing: + sample = ", ".join(f"r{a}_c{b}" for a, b in missing[:5]) + more = f" (+{len(missing) - 5} more)" if len(missing) > 5 else "" + raise SystemExit( + f"Missing {len(missing)} tile(s): {sample}{more}. " + f"Use --allow-missing to leave black holes." + ) + + tw = th = None + for (r, c), tpath in by_rc.items(): + if not (0 <= r < ny and 0 <= c < nx): + raise SystemExit(f"Tile index out of range r={r} c={c} (nx={nx} ny={ny}): {tpath}") + im = _open_tile_rgb(tpath) + w, h = im.size + if tw is None: + tw, th = w, h + elif (w, h) != (tw, th): + raise SystemExit( + f"Non-uniform tile size: {tpath} is {w}x{h}, expected {tw}x{th}" + ) + + assert tw is not None and th is not None + bg = gap_fill if tile_gap > 0 else (0, 0, 0) + cw = nx * tw + (nx - 1) * tile_gap + ch = ny * th + (ny - 1) * tile_gap + canvas = Image.new("RGB", (cw, ch), bg) + + for r in range(ny): + for c in range(nx): + tpath = by_rc.get((r, c)) + if tpath is None: + continue + tile = _open_tile_rgb(tpath) + _paste_tile( + canvas, tile, r, c, nx, ny, tw, th, tile_gap, flip_y, flip_x + ) + + return canvas, nx, ny, tw, th + + +def _histogram_mae_max(diff: Image.Image) -> tuple[float, int]: + """Mean absolute error (0–255 per channel) and max channel diff.""" + bands = diff.split() + total_px = diff.width * diff.height + mae_sum = 0.0 + ch_max = 0 + for band in bands: + h = band.histogram() + mae_sum += sum(i * cnt for i, cnt in enumerate(h)) + _mn, mx = band.getextrema() + ch_max = max(ch_max, mx) + mae = mae_sum / (total_px * len(bands)) + return mae, ch_max + + +def _exact_pixel_fraction(diff: Image.Image) -> float: + """Fraction of pixels where all channels of `diff` are zero.""" + bands = diff.getbands() + bpp = len(bands) + raw = diff.tobytes() + if bpp == 0 or len(raw) == 0: + return 1.0 + n = len(raw) // bpp + exact = sum( + 1 + for i in range(0, len(raw), bpp) + if all(raw[i + j] == 0 for j in range(bpp)) + ) + return exact / n + + +def compare_mosaics( + reconstructed: Image.Image, + reference_path: Path, + *, + fit: bool, +) -> None: + if not reference_path.is_file(): + print(f"No reference mosaic at {reference_path}; skipping comparison.", file=sys.stderr) + return + ref = _open_tile_rgb(reference_path) + a = reconstructed + b = ref + if a.size != b.size: + print( + f"Dimension mismatch: reconstructed {a.size[0]}x{a.size[1]} vs " + f"reference {b.size[0]}x{b.size[1]}", + file=sys.stderr, + ) + if not fit: + print("Re-run with --fit to resize reference to reconstructed size.", file=sys.stderr) + return + b = b.resize(a.size, Image.Resampling.LANCZOS) + + diff = ImageChops.difference(a, b) + mae, dmax = _histogram_mae_max(diff) + frac = _exact_pixel_fraction(diff) + print( + f"Compare vs {reference_path.name}: MAE={mae:.4f} max_diff={dmax} " + f"exact_pixels={frac*100:.4f}%" + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Stitch mosaic from archived tiles using metadata.json grid (nx, ny)." + ) + parser.add_argument( + "scan_dir", + type=Path, + help="Directory containing metadata.json, tiles/, and optionally mosaic.jpg", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + default=None, + help="Output JPEG path (default: /mosaic_reconstructed.jpg)", + ) + parser.add_argument( + "--flip-y", + action="store_true", + help="Flip row order vertically to match server mosaic orientation.", + ) + parser.add_argument( + "--no-flip-x", + dest="flip_x", + action="store_false", + help=( + "Do not mirror columns: tile column 0 is on the left (matches URL/grid order). " + "Default flips X so the layout matches mosaic.jpg from the server." + ), + ) + parser.set_defaults(flip_x=True) + parser.add_argument( + "--allow-missing", + action="store_true", + help="Leave black pixels for missing tiles instead of failing.", + ) + parser.add_argument( + "--compare-mosaic", + action="store_true", + help="Compare reconstructed image to mosaic.jpg in scan_dir (decoded RGB).", + ) + parser.add_argument( + "--fit", + action="store_true", + help="When comparing, resize reference mosaic.jpg to reconstructed size.", + ) + parser.add_argument( + "--jpeg-quality", + type=int, + default=95, + help="JPEG quality for output (default: 95).", + ) + parser.add_argument( + "--tile-gap", + type=int, + default=1, + metavar="PX", + help=( + "Insert PX pixels of spacing between adjacent tiles (horizontal and vertical), " + "like RootView server mosaics. Gap is filled with --gap-color (default: white). " + "Use 0 for flush tiles. Default: 1." + ), + ) + parser.add_argument( + "--gap-color", + default="255,255,255", + metavar="R,G,B", + help="RGB for tile gutters and canvas background when --tile-gap > 0 (default: 255,255,255).", + ) + args = parser.parse_args() + scan_dir = args.scan_dir.expanduser().resolve() + if not scan_dir.is_dir(): + raise SystemExit(f"Not a directory: {scan_dir}") + + out = args.output + if out is None: + out = scan_dir / "mosaic_reconstructed.jpg" + else: + out = out.expanduser().resolve() + + parts = [p.strip() for p in args.gap_color.split(",")] + if len(parts) != 3: + raise SystemExit("--gap-color must be R,G,B with three integers.") + try: + gf = tuple(max(0, min(255, int(x))) for x in parts) + except ValueError as exc: + raise SystemExit("--gap-color must be three integers.") from exc + gap_fill: tuple[int, int, int] = (gf[0], gf[1], gf[2]) + + canvas, nx, ny, tw, th = stitch_scan( + scan_dir, + flip_y=args.flip_y, + flip_x=args.flip_x, + allow_missing=args.allow_missing, + tile_gap=args.tile_gap, + gap_fill=gf, + ) + est_bytes = canvas.width * canvas.height * 3 + if est_bytes > 512 * 1024 * 1024: + print( + f"Warning: decoded canvas ~{est_bytes / (1024**3):.2f} GiB in memory.", + file=sys.stderr, + ) + + canvas.save(out, format="JPEG", quality=args.jpeg_quality, subsampling=0) + print( + f"Wrote {out} ({canvas.width}x{canvas.height}, tile {tw}x{th}, " + f"grid {nx}x{ny}, tile_gap={args.tile_gap})" + ) + + if args.compare_mosaic: + compare_mosaics(canvas, scan_dir / "mosaic.jpg", fit=args.fit) + + +if __name__ == "__main__": + main() diff --git a/tests/test_stitch_mosaic.py b/tests/test_stitch_mosaic.py new file mode 100644 index 0000000..a891015 --- /dev/null +++ b/tests/test_stitch_mosaic.py @@ -0,0 +1,232 @@ +"""Tests for scripts/stitch_mosaic_from_tiles.py — grid stitch + CLI.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +from PIL import Image + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "stitch_mosaic_from_tiles.py" + + +def _rgb_near( + got: tuple[int, ...], expected: tuple[int, int, int], tol: int = 8 +) -> bool: + return all(abs(a - b) <= tol for a, b in zip(got, expected)) + + +def _write_scan( + scan: Path, + *, + nx: int, + ny: int, + tile_size: int, + colors: dict[tuple[int, int], tuple[int, int, int]], +) -> None: + tiles = scan / "tiles" + tiles.mkdir(parents=True) + meta = { + "nx": nx, + "ny": ny, + "scan_id": 999, + "scan_time": "2024-01-01 00:00:00", + } + (scan / "metadata.json").write_text(json.dumps(meta), encoding="utf-8") + for r in range(ny): + for c in range(nx): + rgb = colors[(r, c)] + im = Image.new("RGB", (tile_size, tile_size), rgb) + im.save(tiles / f"tile_r{r}_c{c}.jpg", quality=95) + + +def test_stitch_cli_2x2_default_layout(tmp_path: Path) -> None: + scan = tmp_path / "scan" + colors = { + (0, 0): (255, 0, 0), + (0, 1): (0, 255, 0), + (1, 0): (0, 0, 255), + (1, 1): (255, 255, 0), + } + _write_scan(scan, nx=2, ny=2, tile_size=8, colors=colors) + out = scan / "out.jpg" + subprocess.run( + [ + sys.executable, + str(SCRIPT), + str(scan), + "-o", + str(out), + "--no-flip-x", + "--tile-gap", + "0", + ], + check=True, + cwd=str(ROOT), + ) + im = Image.open(out).convert("RGB") + assert im.size == (16, 16) + assert _rgb_near(im.getpixel((4, 4)), (255, 0, 0)) + assert _rgb_near(im.getpixel((12, 4)), (0, 255, 0)) + assert _rgb_near(im.getpixel((4, 12)), (0, 0, 255)) + assert _rgb_near(im.getpixel((12, 12)), (255, 255, 0)) + + +def test_stitch_cli_flip_y_moves_row0_to_bottom(tmp_path: Path) -> None: + scan = tmp_path / "scan" + colors = { + (0, 0): (255, 0, 0), + (0, 1): (255, 0, 0), + (1, 0): (0, 0, 255), + (1, 1): (0, 0, 255), + } + _write_scan(scan, nx=2, ny=2, tile_size=4, colors=colors) + out = scan / "out.jpg" + subprocess.run( + [ + sys.executable, + str(SCRIPT), + str(scan), + "-o", + str(out), + "--no-flip-x", + "--flip-y", + "--tile-gap", + "0", + ], + check=True, + cwd=str(ROOT), + ) + im = Image.open(out).convert("RGB") + # Without flip, top row is red; with flip-y row 0 is pasted at y=4 + assert _rgb_near(im.getpixel((2, 2)), (0, 0, 255)) + assert _rgb_near(im.getpixel((2, 6)), (255, 0, 0)) + + +def test_stitch_missing_tile_fails(tmp_path: Path) -> None: + scan = tmp_path / "scan" + tiles = scan / "tiles" + tiles.mkdir(parents=True) + (scan / "metadata.json").write_text( + json.dumps({"nx": 2, "ny": 2, "scan_time": "2024-01-01"}), + encoding="utf-8", + ) + im = Image.new("RGB", (4, 4), (128, 128, 128)) + im.save(tiles / "tile_r0_c0.jpg", quality=95) + r = subprocess.run( + [sys.executable, str(SCRIPT), str(scan)], + cwd=str(ROOT), + capture_output=True, + text=True, + ) + assert r.returncode != 0 + assert "Missing" in r.stderr or "Missing" in r.stdout + + +def test_stitch_allow_missing_black_hole(tmp_path: Path) -> None: + scan = tmp_path / "scan" + tiles = scan / "tiles" + tiles.mkdir(parents=True) + (scan / "metadata.json").write_text( + json.dumps({"nx": 2, "ny": 2, "scan_time": "2024-01-01"}), + encoding="utf-8", + ) + im = Image.new("RGB", (4, 4), (255, 0, 0)) + im.save(tiles / "tile_r0_c0.jpg", quality=95) + out = scan / "out.jpg" + subprocess.run( + [ + sys.executable, + str(SCRIPT), + str(scan), + "-o", + str(out), + "--no-flip-x", + "--allow-missing", + "--tile-gap", + "0", + ], + check=True, + cwd=str(ROOT), + ) + im_out = Image.open(out).convert("RGB") + assert _rgb_near(im_out.getpixel((2, 2)), (255, 0, 0)) + # JPEG output may not keep (0,0,0) exact on chroma-subsampled black regions + hole = im_out.getpixel((6, 6)) + assert max(hole) <= 20, hole + + +def test_compare_mosaic_fit(tmp_path: Path) -> None: + scan = tmp_path / "scan" + colors = {(0, 0): (100, 150, 200)} + _write_scan(scan, nx=1, ny=1, tile_size=20, colors=colors) + ref = Image.new("RGB", (10, 10), (100, 150, 200)) + ref.save(scan / "mosaic.jpg", quality=95) + out = scan / "out.jpg" + r = subprocess.run( + [ + sys.executable, + str(SCRIPT), + str(scan), + "-o", + str(out), + "--compare-mosaic", + "--fit", + ], + check=True, + cwd=str(ROOT), + capture_output=True, + text=True, + ) + assert "MAE=" in r.stdout + assert "exact_pixels=" in r.stdout + + +def test_stitch_tile_gap_inserts_gutter(tmp_path: Path) -> None: + scan = tmp_path / "scan" + colors = {(0, 0): (255, 0, 0), (0, 1): (0, 255, 0), (1, 0): (0, 0, 255), (1, 1): (128, 128, 128)} + _write_scan(scan, nx=2, ny=2, tile_size=8, colors=colors) + out = scan / "out.jpg" + subprocess.run( + [ + sys.executable, + str(SCRIPT), + str(scan), + "-o", + str(out), + "--no-flip-x", + "--tile-gap", + "2", + ], + check=True, + cwd=str(ROOT), + ) + im = Image.open(out).convert("RGB") + assert im.size == (18, 18) + gutter = im.getpixel((8, 4)) + assert min(gutter) >= 250, gutter + assert _rgb_near(im.getpixel((4, 4)), (255, 0, 0)) + + +def test_stitch_default_flip_x_swaps_left_right_columns(tmp_path: Path) -> None: + """Default matches RootView mosaic: col 0 is placed on the right.""" + scan = tmp_path / "scan" + colors = { + (0, 0): (255, 0, 0), + (0, 1): (0, 255, 0), + (1, 0): (0, 0, 255), + (1, 1): (255, 255, 0), + } + _write_scan(scan, nx=2, ny=2, tile_size=8, colors=colors) + out = scan / "out.jpg" + subprocess.run( + [sys.executable, str(SCRIPT), str(scan), "-o", str(out)], + check=True, + cwd=str(ROOT), + ) + im = Image.open(out).convert("RGB") + assert _rgb_near(im.getpixel((4, 4)), (0, 255, 0)) + assert _rgb_near(im.getpixel((12, 4)), (255, 0, 0))