Tune RST profiles for high-res dense mosaics with full coverage.

Raise master_max and piece counts, cache tile prep, and scale mittel/dicht
from the validated wenig density ladder so each profile can fill the canvas.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
copilot 2026-09-29 00:38:02 +02:00
parent 1d90b820ee
commit 564636a74e
4 changed files with 262 additions and 113 deletions

View file

@ -18,6 +18,20 @@ Headless RST collage generator for the Tower mosaik inbox, adapted from Seth Ben
See `src/fractal_mosaic/profiles.py`: `wenig`, `mittel`, `dicht`.
## Coverage
Profiles target ~99% canvas coverage via denser RST passes + residual fill, while
keeping Fractal RST (rotation / scale / translate) — not a classic grid mosaic.
Pool images may be reused heavily when the library is small.
## Tile size (readability)
Piece sizes stay in a recognizable absolute range while `master_max` is high so
the canvas holds many pieces. Relative density ladder (largest tile / master):
wenig ≈1.75%, mittel ≈1.3%, dicht ≈1.0%. `max_placements` scales with
`(master_max / mean_tile)^2` so each profile can still hit ~99% coverage.
Scales never enlarge above 1.0.
## Non-goals (v1)
- Faithful bit-identical MATLAB port

View file

@ -1,4 +1,4 @@
"""Large-first collage placement with edge alpha — no tile recoloring."""
"""Large-first Fractal RST collage — rebuild master from pool images, ~full coverage."""
from __future__ import annotations
@ -32,7 +32,6 @@ def _edge_alpha_mask(w: int, h: int, blend: float) -> NDArray[np.float32]:
ys = np.linspace(-1.0, 1.0, h, dtype=np.float32)
xs = np.linspace(-1.0, 1.0, w, dtype=np.float32)
xv, yv = np.meshgrid(xs, ys)
# Distance from edge in each axis
ax = np.clip((1.0 - np.abs(xv)) / blend, 0.0, 1.0)
ay = np.clip((1.0 - np.abs(yv)) / blend, 0.0, 1.0)
return (ax * ay).astype(np.float32)
@ -43,19 +42,33 @@ def _prepare_tile(
size: int,
rotation_deg: float,
scale: float,
*,
cache: dict[tuple[int, int, int, int], Image.Image] | None = None,
) -> Image.Image:
with Image.open(tile.path) as im:
im = im.convert("RGBA")
# Center-crop to square then resize — crop only, no recolor
side = min(im.size)
left = (im.size[0] - side) // 2
top = (im.size[1] - side) // 2
im = im.crop((left, top, left + side, top + side))
target = max(8, int(round(size * scale)))
im = im.resize((target, target), Image.Resampling.LANCZOS)
if abs(rotation_deg) > 0.5:
im = im.rotate(rotation_deg, resample=Image.Resampling.BICUBIC, expand=True)
return im
# Quantize rot for cache keys (profile steps are coarse anyway).
rot_q = int(round(rotation_deg))
sc_q = int(round(scale * 100))
key = (id(tile), size, rot_q, sc_q)
if cache is not None and key in cache:
return cache[key]
if tile.square_rgba is None:
with Image.open(tile.path) as im:
im = im.convert("RGBA")
side = min(im.size)
left = (im.size[0] - side) // 2
top = (im.size[1] - side) // 2
tile.square_rgba = im.crop((left, top, left + side, top + side))
im = tile.square_rgba
# Center-crop to square then resize — crop only, no recolor
target = max(8, int(round(size * scale)))
out = im.resize((target, target), Image.Resampling.BILINEAR)
if abs(rotation_deg) > 0.5:
out = out.rotate(rotation_deg, resample=Image.Resampling.BILINEAR, expand=True)
if cache is not None:
cache[key] = out
return out
def _tile_geometry(
@ -109,7 +122,7 @@ def _paste_rgba(
edge_blend: float,
tile_alpha: float,
) -> float:
"""Paste tile centered at (cx,cy). Returns overlap fraction before paste."""
"""Paste piece centered at (cx,cy). Returns overlap fraction before paste."""
geo = _tile_geometry(tile_rgba, cx, cy, canvas.shape, edge_blend, tile_alpha)
if geo is None:
return 1.0
@ -130,6 +143,81 @@ def _rms(patch: NDArray[np.uint8], tile_rgb: NDArray[np.uint8]) -> float:
return float(np.sqrt(np.mean((a - b) ** 2)))
def _reuse_limit(profile: Profile, library_n: int, placed: int) -> int:
"""Allow enough repeats that a small pool can still cover the canvas."""
# Rough budget: each piece covers ~tile²; need many repeats for full fill.
base = max(profile.reuse_soft_cap, (profile.max_placements // max(library_n, 1)) + 4)
# Late in the run, loosen further so residual fill isn't starved.
if placed > profile.max_placements // 2:
return base * 2
return base
def _accept_rms(tile_size: int, *, force: bool) -> float:
"""RMS gate vs master patch. `force` = residual fill must place something."""
if force:
return 1e9
# Looser than v1 so colour-diverse pools still rebuild structure.
return 95.0 + (220 - min(tile_size, 220)) * 0.12
def _pick_best_rst(
patch: NDArray[np.uint8],
library: list[TileRecord],
idxs: list[int],
tile_size: int,
profile: Profile,
reuse_counts: dict[int, int],
reuse_cap: int,
*,
prep_cache: dict[tuple[int, int, int, int], Image.Image] | None = None,
) -> tuple[float, Image.Image, int] | None:
# Small pieces: skip log-polar FFT (too costly at dense stride) and use a
# thinner discrete rot/scale grid so many tiny placements stay practical.
small = tile_size <= 120
best: tuple[float, Image.Image, int] | None = None
for ti in idxs:
if reuse_counts.get(ti, 0) >= reuse_cap:
continue
tile = library[ti]
if small:
rot_est, scale_est = 0.0, 1.0
else:
try:
rot_est, scale_est = estimate_rotation_scale(
to_gray_f32(patch),
to_gray_f32(tile.thumb),
n_rho=48,
n_theta=96,
)
except Exception:
rot_est, scale_est = 0.0, 1.0
step = float(profile.rotation_step)
rot_list = [0.0, rot_est]
muls = (-1, 1) if small else (-2, -1, 1, 2)
for mul in muls:
rot_list.append(rot_est + mul * step)
rot_list = sorted({((r + 180) % 360) - 180 for r in rot_list})
scales = list(profile.scales)
if not small and 0.5 <= scale_est <= 2.0:
scales = sorted(set(scales + [float(np.clip(scale_est, 0.7, 1.0))]))
for rot in rot_list:
for sc in scales:
prepared = _prepare_tile(tile, tile_size, rot, sc, cache=prep_cache)
rgb = prepared.convert("RGB")
sample = np.asarray(
rgb.resize((patch.shape[1], patch.shape[0]), Image.Resampling.BILINEAR),
dtype=np.uint8,
)
err = _rms(patch, sample)
if best is None or err < best[0]:
best = (err, prepared, ti)
return best
def generate_mosaic(
master_path: Path,
tiles_dir: Path,
@ -161,21 +249,22 @@ def generate_mosaic(
rng = np.random.default_rng(42)
placed = 0
reuse_counts: dict[int, int] = {}
prep_cache: dict[tuple[int, int, int, int], Image.Image] = {}
for tile_size in profile.tile_sizes:
if placed >= profile.max_placements:
break
stride = max(8, int(tile_size * profile.stride_frac))
# Prefer uncovered regions: sample grid, sort by low coverage
if float(coverage.mean()) >= profile.target_coverage:
break
stride = max(6, int(tile_size * profile.stride_frac))
centers: list[tuple[float, int, int]] = []
for y in range(tile_size // 2, H - tile_size // 2, stride):
for x in range(tile_size // 2, W - tile_size // 2, stride):
for y in range(tile_size // 2, max(tile_size // 2 + 1, H - tile_size // 2), stride):
for x in range(tile_size // 2, max(tile_size // 2 + 1, W - tile_size // 2), stride):
y0, x0 = y - tile_size // 2, x - tile_size // 2
y1, x1 = y0 + tile_size, x0 + tile_size
cov = float(coverage[y0:y1, x0:x1].mean())
centers.append((cov, x, y))
centers.sort(key=lambda t: t[0])
# shuffle lightly among similar coverage
if len(centers) > 4:
head = centers[: max(1, len(centers) // 3)]
rng.shuffle(head)
@ -184,7 +273,7 @@ def generate_mosaic(
for cov0, cx, cy in centers:
if placed >= profile.max_placements:
break
if cov0 > profile.max_overlap + 0.15:
if cov0 > profile.max_overlap + 0.12:
continue
y0, x0 = cy - tile_size // 2, cx - tile_size // 2
@ -193,57 +282,34 @@ def generate_mosaic(
if patch.size == 0:
continue
reuse_cap = _reuse_limit(profile, len(library), placed)
idxs = rank_tiles(patch, library, top_k=profile.candidates)
best: tuple[float, Image.Image, int] | None = None
for ti in idxs:
# Soft reuse limit: denser profiles allow more repeats
limit = 3 if profile.name == "wenig" else (6 if profile.name == "mittel" else 12)
if reuse_counts.get(ti, 0) >= limit:
continue
tile = library[ti]
# Log-polar hint for rot/scale, then snap to discrete search
try:
rot_est, scale_est = estimate_rotation_scale(
to_gray_f32(patch),
to_gray_f32(tile.thumb),
n_rho=48,
n_theta=96,
)
except Exception:
rot_est, scale_est = 0.0, 1.0
rot_list = [0.0]
step = float(profile.rotation_step)
for mul in (-2, -1, 1, 2):
rot_list.append(rot_est + mul * step)
rot_list.append(rot_est)
# Normalize to [-180,180) and unique
rot_list = sorted({((r + 180) % 360) - 180 for r in rot_list})
scales = list(profile.scales)
if 0.5 <= scale_est <= 2.0:
scales = sorted(set(scales + [float(np.clip(scale_est, 0.7, 1.3))]))
for rot in rot_list:
for sc in scales:
prepared = _prepare_tile(tile, tile_size, rot, sc)
# Compare center crop of prepared RGB to patch (no recolor)
rgb = prepared.convert("RGB")
sample = np.asarray(
rgb.resize((patch.shape[1], patch.shape[0]), Image.Resampling.BILINEAR),
dtype=np.uint8,
)
err = _rms(patch, sample)
if best is None or err < best[0]:
best = (err, prepared, ti)
best = _pick_best_rst(
patch,
library,
idxs,
tile_size,
profile,
reuse_counts,
reuse_cap,
prep_cache=prep_cache,
)
if best is None:
# All candidates hit reuse cap — try full library once
best = _pick_best_rst(
patch,
library,
list(range(len(library))),
tile_size,
profile,
reuse_counts,
reuse_cap * 3,
prep_cache=prep_cache,
)
if best is None:
continue
err, prepared, ti = best
# Accept threshold scaled by tile size (coarser allowed for large tiles)
thresh = 55.0 + (220 - min(tile_size, 220)) * 0.08
if err > thresh:
if err > _accept_rms(tile_size, force=False):
continue
geo = _tile_geometry(
@ -267,30 +333,75 @@ def generate_mosaic(
)
reuse_counts[ti] = reuse_counts.get(ti, 0) + 1
placed += 1
if progress and placed % 10 == 0:
print(f"placed {placed}/{profile.max_placements} (tile≈{tile_size}px)", flush=True)
if progress and placed % 25 == 0:
print(
f"placed {placed}/{profile.max_placements} "
f"(size≈{tile_size}px, coverage≈{float(coverage.mean()):.0%})",
flush=True,
)
# Mild residual fill: boost uncovered with smaller opaque tiles
uncovered = coverage < 0.15
if np.any(uncovered) and placed < profile.max_placements:
ys, xs = np.where(uncovered[::8, ::8])
for yi, xi in zip(ys[:40], xs[:40], strict=False):
# Residual Fractal fill until target coverage (still RST pieces, forced place).
fill_pass = 0
while (
float(coverage.mean()) < profile.target_coverage
and placed < profile.max_placements
and fill_pass < 400
):
fill_pass += 1
uncovered = coverage < 0.35
if not np.any(uncovered):
break
step = max(4, min(profile.tile_sizes) // 3)
ys_u, xs_u = np.where(uncovered[::step, ::step])
if len(ys_u) == 0:
break
order = rng.permutation(len(ys_u))
progressed = False
for oi in order[: max(40, len(order) // 4)]:
if placed >= profile.max_placements:
break
cy, cx = int(yi * 8), int(xi * 8)
if float(coverage.mean()) >= profile.target_coverage:
break
cy, cx = int(ys_u[oi] * step), int(xs_u[oi] * step)
if not (0 <= cy < H and 0 <= cx < W):
continue
ts = profile.tile_sizes[-1]
if coverage[cy, cx] >= 0.55:
continue
ts = profile.tile_sizes[min(fill_pass % len(profile.tile_sizes), len(profile.tile_sizes) - 1)]
# Prefer smaller pieces late
if fill_pass > 3:
ts = profile.tile_sizes[-1]
y0, x0 = max(0, cy - ts // 2), max(0, cx - ts // 2)
y1, x1 = min(H, y0 + ts), min(W, x0 + ts)
patch = master[y0:y1, x0:x1]
if patch.size < 16:
continue
idxs = rank_tiles(patch, library, top_k=3)
if not idxs:
reuse_cap = _reuse_limit(profile, len(library), placed) * 4
idxs = rank_tiles(patch, library, top_k=min(profile.candidates, len(library)))
best = _pick_best_rst(
patch,
library,
idxs,
ts,
profile,
reuse_counts,
reuse_cap,
prep_cache=prep_cache,
)
if best is None:
ti = int(idxs[0]) if idxs else int(rng.integers(0, len(library)))
prepared = _prepare_tile(
library[ti],
ts,
float(rng.uniform(-25, 25)),
float(rng.choice(profile.scales)),
cache=prep_cache,
)
err = 0.0
else:
err, prepared, ti = best
if err > _accept_rms(ts, force=True):
continue
ti = idxs[0]
prepared = _prepare_tile(library[ti], ts, float(rng.uniform(-20, 20)), 1.0)
_paste_rgba(
canvas,
coverage,
@ -298,9 +409,18 @@ def generate_mosaic(
cx,
cy,
profile.edge_blend,
min(1.0, profile.tile_alpha + 0.05),
min(1.0, profile.tile_alpha + 0.02),
)
reuse_counts[ti] = reuse_counts.get(ti, 0) + 1
placed += 1
progressed = True
if progress and fill_pass % 5 == 0:
print(
f"fill pass {fill_pass}: placed={placed}, coverage≈{float(coverage.mean()):.0%}",
flush=True,
)
if not progressed:
break
out = (np.clip(canvas, 0.0, 1.0) * 255.0).astype(np.uint8)
output_path.parent.mkdir(parents=True, exist_ok=True)

View file

@ -11,9 +11,9 @@ class Profile:
name: str
# Master long-edge cap (pixels)
master_max: int
# Tile base sizes tried (largest first)
# RST piece sizes tried (largest first) — pool images, not a grid
tile_sizes: tuple[int, ...]
# How many placements to attempt (approx)
# Hard cap on placements (must be high enough for ~full coverage)
max_placements: int
# CBIR: how many library candidates per site
candidates: int
@ -25,61 +25,74 @@ class Profile:
edge_blend: float
# Global multiply on tile alpha (1.0 = full)
tile_alpha: float
# Minimum tiles required in pool
# Minimum pool images required
min_tiles: int
# Grid stride for candidate centers as fraction of tile size
# Grid stride for candidate centers as fraction of piece size
stride_frac: float
# Whether to composite over master (True) or black (False)
# Start from master (True) then bury it under RST pieces; False = black
use_master_bg: bool
# Scale factors relative to tile_size tried per candidate
scales: tuple[float, ...]
# Target mean coverage before stopping residual fill (0–1)
target_coverage: float
# Soft reuse cap per pool image (scaled up automatically if pool is small)
reuse_soft_cap: int
# Density ladder (relative to wenig baseline that looked good):
# - master_max ↑, largest tile / master ≈ 1.75% → 1.3% → 1.0%
# - max_placements ≈ k * (master_max / mean_tile)^2 so coverage can hit ~99%
PROFILES: Mapping[str, Profile] = {
"wenig": Profile(
name="wenig",
master_max=960,
tile_sizes=(220, 160, 110),
max_placements=80,
master_max=3200,
tile_sizes=(56, 40, 28, 20, 14),
max_placements=40000,
candidates=8,
rotation_step=30,
max_overlap=0.45,
edge_blend=0.22,
tile_alpha=0.92,
rotation_step=25,
max_overlap=0.74,
edge_blend=0.18,
tile_alpha=0.98,
min_tiles=8,
stride_frac=0.55,
stride_frac=0.34,
use_master_bg=True,
scales=(1.0, 0.85),
scales=(1.0, 0.75, 0.55),
target_coverage=0.99,
reuse_soft_cap=200,
),
"mittel": Profile(
name="mittel",
master_max=1280,
tile_sizes=(200, 140, 100, 72),
max_placements=220,
candidates=12,
master_max=4000,
tile_sizes=(52, 38, 26, 18, 13),
max_placements=90000,
candidates=10,
rotation_step=20,
max_overlap=0.35,
edge_blend=0.18,
tile_alpha=0.90,
max_overlap=0.68,
edge_blend=0.16,
tile_alpha=0.97,
min_tiles=20,
stride_frac=0.45,
stride_frac=0.32,
use_master_bg=True,
scales=(1.1, 1.0, 0.85),
scales=(1.0, 0.75, 0.55),
target_coverage=0.99,
reuse_soft_cap=280,
),
"dicht": Profile(
name="dicht",
master_max=1600,
tile_sizes=(180, 130, 90, 64, 48),
max_placements=480,
candidates=16,
master_max=4800,
tile_sizes=(48, 34, 24, 16, 12),
max_placements=150000,
candidates=12,
rotation_step=15,
max_overlap=0.28,
max_overlap=0.62,
edge_blend=0.14,
tile_alpha=0.88,
tile_alpha=0.96,
min_tiles=40,
stride_frac=0.35,
stride_frac=0.28,
use_master_bg=True,
scales=(1.15, 1.0, 0.85, 0.7),
scales=(1.0, 0.75, 0.55),
target_coverage=0.99,
reuse_soft_cap=360,
),
}

View file

@ -20,6 +20,8 @@ class TileRecord:
hist: NDArray[np.float32]
# cached thumb for matching (RGB uint8)
thumb: NDArray[np.uint8]
# center-cropped square RGBA — loaded lazily for placement
square_rgba: Image.Image | None = None
def _hist_hsv(rgb: NDArray[np.uint8], bins: int = 8) -> NDArray[np.float32]: