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:
2026-04-26 20:44:56 -04:00
parent ae37c06f15
commit 314b68322c
4 changed files with 657 additions and 0 deletions
+77
View File
@@ -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 scans **`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 leftright** (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.*
+1
View File
@@ -4,4 +4,5 @@ lxml>=5.0.0
pyyaml>=6.0.1 pyyaml>=6.0.1
tqdm>=4.66.0 tqdm>=4.66.0
piexif>=1.1.3 piexif>=1.1.3
Pillow>=10.0.0
pytest>=8.0 pytest>=8.0
+347
View File
@@ -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 (0255 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()
+232
View File
@@ -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))