diff --git a/DESIGN.md b/DESIGN.md index 8360292..2f4825e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/src/fractal_mosaic/engine.py b/src/fractal_mosaic/engine.py index 617d072..aac88ac 100644 --- a/src/fractal_mosaic/engine.py +++ b/src/fractal_mosaic/engine.py @@ -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) diff --git a/src/fractal_mosaic/profiles.py b/src/fractal_mosaic/profiles.py index d157789..695bde9 100644 --- a/src/fractal_mosaic/profiles.py +++ b/src/fractal_mosaic/profiles.py @@ -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, ), } diff --git a/src/fractal_mosaic/retrieval.py b/src/fractal_mosaic/retrieval.py index 953c2b9..3304397 100644 --- a/src/fractal_mosaic/retrieval.py +++ b/src/fractal_mosaic/retrieval.py @@ -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]: