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
This commit is contained in:
@@ -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=<scan_id>`
|
||||
- **Pre-stitched mosaic JPEG:**
|
||||
`http://205.149.147.131:8011/RootView_Database/<scan_id>/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=<scan_id>&s=1&x=<start_x>&y=<start_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://<image-host>:8011/RootView_Database/<scan_id>/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://<image-host>:8011/RootView_Database/<scan_id>/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=<scan_id>&s=<scale>&x=<x_mm>&y=<y_mm>` 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.*
|
||||
@@ -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
|
||||
|
||||
@@ -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: <scan_dir>/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()
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user