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:
parent
1d90b820ee
commit
564636a74e
4 changed files with 262 additions and 113 deletions
14
DESIGN.md
14
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`.
|
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)
|
## Non-goals (v1)
|
||||||
|
|
||||||
- Faithful bit-identical MATLAB port
|
- Faithful bit-identical MATLAB port
|
||||||
|
|
|
||||||
|
|
@ -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
|
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)
|
ys = np.linspace(-1.0, 1.0, h, dtype=np.float32)
|
||||||
xs = np.linspace(-1.0, 1.0, w, dtype=np.float32)
|
xs = np.linspace(-1.0, 1.0, w, dtype=np.float32)
|
||||||
xv, yv = np.meshgrid(xs, ys)
|
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)
|
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)
|
ay = np.clip((1.0 - np.abs(yv)) / blend, 0.0, 1.0)
|
||||||
return (ax * ay).astype(np.float32)
|
return (ax * ay).astype(np.float32)
|
||||||
|
|
@ -43,19 +42,33 @@ def _prepare_tile(
|
||||||
size: int,
|
size: int,
|
||||||
rotation_deg: float,
|
rotation_deg: float,
|
||||||
scale: float,
|
scale: float,
|
||||||
|
*,
|
||||||
|
cache: dict[tuple[int, int, int, int], Image.Image] | None = None,
|
||||||
) -> Image.Image:
|
) -> Image.Image:
|
||||||
|
# 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:
|
with Image.open(tile.path) as im:
|
||||||
im = im.convert("RGBA")
|
im = im.convert("RGBA")
|
||||||
# Center-crop to square then resize — crop only, no recolor
|
|
||||||
side = min(im.size)
|
side = min(im.size)
|
||||||
left = (im.size[0] - side) // 2
|
left = (im.size[0] - side) // 2
|
||||||
top = (im.size[1] - side) // 2
|
top = (im.size[1] - side) // 2
|
||||||
im = im.crop((left, top, left + side, top + side))
|
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)))
|
target = max(8, int(round(size * scale)))
|
||||||
im = im.resize((target, target), Image.Resampling.LANCZOS)
|
out = im.resize((target, target), Image.Resampling.BILINEAR)
|
||||||
if abs(rotation_deg) > 0.5:
|
if abs(rotation_deg) > 0.5:
|
||||||
im = im.rotate(rotation_deg, resample=Image.Resampling.BICUBIC, expand=True)
|
out = out.rotate(rotation_deg, resample=Image.Resampling.BILINEAR, expand=True)
|
||||||
return im
|
if cache is not None:
|
||||||
|
cache[key] = out
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _tile_geometry(
|
def _tile_geometry(
|
||||||
|
|
@ -109,7 +122,7 @@ def _paste_rgba(
|
||||||
edge_blend: float,
|
edge_blend: float,
|
||||||
tile_alpha: float,
|
tile_alpha: float,
|
||||||
) -> 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)
|
geo = _tile_geometry(tile_rgba, cx, cy, canvas.shape, edge_blend, tile_alpha)
|
||||||
if geo is None:
|
if geo is None:
|
||||||
return 1.0
|
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)))
|
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(
|
def generate_mosaic(
|
||||||
master_path: Path,
|
master_path: Path,
|
||||||
tiles_dir: Path,
|
tiles_dir: Path,
|
||||||
|
|
@ -161,21 +249,22 @@ def generate_mosaic(
|
||||||
rng = np.random.default_rng(42)
|
rng = np.random.default_rng(42)
|
||||||
placed = 0
|
placed = 0
|
||||||
reuse_counts: dict[int, int] = {}
|
reuse_counts: dict[int, int] = {}
|
||||||
|
prep_cache: dict[tuple[int, int, int, int], Image.Image] = {}
|
||||||
|
|
||||||
for tile_size in profile.tile_sizes:
|
for tile_size in profile.tile_sizes:
|
||||||
if placed >= profile.max_placements:
|
if placed >= profile.max_placements:
|
||||||
break
|
break
|
||||||
stride = max(8, int(tile_size * profile.stride_frac))
|
if float(coverage.mean()) >= profile.target_coverage:
|
||||||
# Prefer uncovered regions: sample grid, sort by low coverage
|
break
|
||||||
|
stride = max(6, int(tile_size * profile.stride_frac))
|
||||||
centers: list[tuple[float, int, int]] = []
|
centers: list[tuple[float, int, int]] = []
|
||||||
for y in range(tile_size // 2, H - 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, W - 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
|
y0, x0 = y - tile_size // 2, x - tile_size // 2
|
||||||
y1, x1 = y0 + tile_size, x0 + tile_size
|
y1, x1 = y0 + tile_size, x0 + tile_size
|
||||||
cov = float(coverage[y0:y1, x0:x1].mean())
|
cov = float(coverage[y0:y1, x0:x1].mean())
|
||||||
centers.append((cov, x, y))
|
centers.append((cov, x, y))
|
||||||
centers.sort(key=lambda t: t[0])
|
centers.sort(key=lambda t: t[0])
|
||||||
# shuffle lightly among similar coverage
|
|
||||||
if len(centers) > 4:
|
if len(centers) > 4:
|
||||||
head = centers[: max(1, len(centers) // 3)]
|
head = centers[: max(1, len(centers) // 3)]
|
||||||
rng.shuffle(head)
|
rng.shuffle(head)
|
||||||
|
|
@ -184,7 +273,7 @@ def generate_mosaic(
|
||||||
for cov0, cx, cy in centers:
|
for cov0, cx, cy in centers:
|
||||||
if placed >= profile.max_placements:
|
if placed >= profile.max_placements:
|
||||||
break
|
break
|
||||||
if cov0 > profile.max_overlap + 0.15:
|
if cov0 > profile.max_overlap + 0.12:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
y0, x0 = cy - tile_size // 2, cx - tile_size // 2
|
y0, x0 = cy - tile_size // 2, cx - tile_size // 2
|
||||||
|
|
@ -193,57 +282,34 @@ def generate_mosaic(
|
||||||
if patch.size == 0:
|
if patch.size == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
reuse_cap = _reuse_limit(profile, len(library), placed)
|
||||||
idxs = rank_tiles(patch, library, top_k=profile.candidates)
|
idxs = rank_tiles(patch, library, top_k=profile.candidates)
|
||||||
best: tuple[float, Image.Image, int] | None = None
|
best = _pick_best_rst(
|
||||||
|
patch,
|
||||||
for ti in idxs:
|
library,
|
||||||
# Soft reuse limit: denser profiles allow more repeats
|
idxs,
|
||||||
limit = 3 if profile.name == "wenig" else (6 if profile.name == "mittel" else 12)
|
tile_size,
|
||||||
if reuse_counts.get(ti, 0) >= limit:
|
profile,
|
||||||
continue
|
reuse_counts,
|
||||||
tile = library[ti]
|
reuse_cap,
|
||||||
# Log-polar hint for rot/scale, then snap to discrete search
|
prep_cache=prep_cache,
|
||||||
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:
|
if best is None:
|
||||||
rot_est, scale_est = 0.0, 1.0
|
# All candidates hit reuse cap — try full library once
|
||||||
|
best = _pick_best_rst(
|
||||||
rot_list = [0.0]
|
patch,
|
||||||
step = float(profile.rotation_step)
|
library,
|
||||||
for mul in (-2, -1, 1, 2):
|
list(range(len(library))),
|
||||||
rot_list.append(rot_est + mul * step)
|
tile_size,
|
||||||
rot_list.append(rot_est)
|
profile,
|
||||||
# Normalize to [-180,180) and unique
|
reuse_counts,
|
||||||
rot_list = sorted({((r + 180) % 360) - 180 for r in rot_list})
|
reuse_cap * 3,
|
||||||
|
prep_cache=prep_cache,
|
||||||
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)
|
|
||||||
|
|
||||||
if best is None:
|
if best is None:
|
||||||
continue
|
continue
|
||||||
err, prepared, ti = best
|
err, prepared, ti = best
|
||||||
# Accept threshold scaled by tile size (coarser allowed for large tiles)
|
if err > _accept_rms(tile_size, force=False):
|
||||||
thresh = 55.0 + (220 - min(tile_size, 220)) * 0.08
|
|
||||||
if err > thresh:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
geo = _tile_geometry(
|
geo = _tile_geometry(
|
||||||
|
|
@ -267,30 +333,75 @@ def generate_mosaic(
|
||||||
)
|
)
|
||||||
reuse_counts[ti] = reuse_counts.get(ti, 0) + 1
|
reuse_counts[ti] = reuse_counts.get(ti, 0) + 1
|
||||||
placed += 1
|
placed += 1
|
||||||
if progress and placed % 10 == 0:
|
if progress and placed % 25 == 0:
|
||||||
print(f"placed {placed}/{profile.max_placements} (tile≈{tile_size}px)", flush=True)
|
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
|
# Residual Fractal fill until target coverage (still RST pieces, forced place).
|
||||||
uncovered = coverage < 0.15
|
fill_pass = 0
|
||||||
if np.any(uncovered) and placed < profile.max_placements:
|
while (
|
||||||
ys, xs = np.where(uncovered[::8, ::8])
|
float(coverage.mean()) < profile.target_coverage
|
||||||
for yi, xi in zip(ys[:40], xs[:40], strict=False):
|
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:
|
if placed >= profile.max_placements:
|
||||||
break
|
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):
|
if not (0 <= cy < H and 0 <= cx < W):
|
||||||
continue
|
continue
|
||||||
|
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]
|
ts = profile.tile_sizes[-1]
|
||||||
y0, x0 = max(0, cy - ts // 2), max(0, cx - ts // 2)
|
y0, x0 = max(0, cy - ts // 2), max(0, cx - ts // 2)
|
||||||
y1, x1 = min(H, y0 + ts), min(W, x0 + ts)
|
y1, x1 = min(H, y0 + ts), min(W, x0 + ts)
|
||||||
patch = master[y0:y1, x0:x1]
|
patch = master[y0:y1, x0:x1]
|
||||||
if patch.size < 16:
|
if patch.size < 16:
|
||||||
continue
|
continue
|
||||||
idxs = rank_tiles(patch, library, top_k=3)
|
reuse_cap = _reuse_limit(profile, len(library), placed) * 4
|
||||||
if not idxs:
|
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
|
continue
|
||||||
ti = idxs[0]
|
|
||||||
prepared = _prepare_tile(library[ti], ts, float(rng.uniform(-20, 20)), 1.0)
|
|
||||||
_paste_rgba(
|
_paste_rgba(
|
||||||
canvas,
|
canvas,
|
||||||
coverage,
|
coverage,
|
||||||
|
|
@ -298,9 +409,18 @@ def generate_mosaic(
|
||||||
cx,
|
cx,
|
||||||
cy,
|
cy,
|
||||||
profile.edge_blend,
|
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
|
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)
|
out = (np.clip(canvas, 0.0, 1.0) * 255.0).astype(np.uint8)
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ class Profile:
|
||||||
name: str
|
name: str
|
||||||
# Master long-edge cap (pixels)
|
# Master long-edge cap (pixels)
|
||||||
master_max: int
|
master_max: int
|
||||||
# Tile base sizes tried (largest first)
|
# RST piece sizes tried (largest first) — pool images, not a grid
|
||||||
tile_sizes: tuple[int, ...]
|
tile_sizes: tuple[int, ...]
|
||||||
# How many placements to attempt (approx)
|
# Hard cap on placements (must be high enough for ~full coverage)
|
||||||
max_placements: int
|
max_placements: int
|
||||||
# CBIR: how many library candidates per site
|
# CBIR: how many library candidates per site
|
||||||
candidates: int
|
candidates: int
|
||||||
|
|
@ -25,61 +25,74 @@ class Profile:
|
||||||
edge_blend: float
|
edge_blend: float
|
||||||
# Global multiply on tile alpha (1.0 = full)
|
# Global multiply on tile alpha (1.0 = full)
|
||||||
tile_alpha: float
|
tile_alpha: float
|
||||||
# Minimum tiles required in pool
|
# Minimum pool images required
|
||||||
min_tiles: int
|
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
|
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
|
use_master_bg: bool
|
||||||
# Scale factors relative to tile_size tried per candidate
|
# Scale factors relative to tile_size tried per candidate
|
||||||
scales: tuple[float, ...]
|
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] = {
|
PROFILES: Mapping[str, Profile] = {
|
||||||
"wenig": Profile(
|
"wenig": Profile(
|
||||||
name="wenig",
|
name="wenig",
|
||||||
master_max=960,
|
master_max=3200,
|
||||||
tile_sizes=(220, 160, 110),
|
tile_sizes=(56, 40, 28, 20, 14),
|
||||||
max_placements=80,
|
max_placements=40000,
|
||||||
candidates=8,
|
candidates=8,
|
||||||
rotation_step=30,
|
rotation_step=25,
|
||||||
max_overlap=0.45,
|
max_overlap=0.74,
|
||||||
edge_blend=0.22,
|
edge_blend=0.18,
|
||||||
tile_alpha=0.92,
|
tile_alpha=0.98,
|
||||||
min_tiles=8,
|
min_tiles=8,
|
||||||
stride_frac=0.55,
|
stride_frac=0.34,
|
||||||
use_master_bg=True,
|
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(
|
"mittel": Profile(
|
||||||
name="mittel",
|
name="mittel",
|
||||||
master_max=1280,
|
master_max=4000,
|
||||||
tile_sizes=(200, 140, 100, 72),
|
tile_sizes=(52, 38, 26, 18, 13),
|
||||||
max_placements=220,
|
max_placements=90000,
|
||||||
candidates=12,
|
candidates=10,
|
||||||
rotation_step=20,
|
rotation_step=20,
|
||||||
max_overlap=0.35,
|
max_overlap=0.68,
|
||||||
edge_blend=0.18,
|
edge_blend=0.16,
|
||||||
tile_alpha=0.90,
|
tile_alpha=0.97,
|
||||||
min_tiles=20,
|
min_tiles=20,
|
||||||
stride_frac=0.45,
|
stride_frac=0.32,
|
||||||
use_master_bg=True,
|
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(
|
"dicht": Profile(
|
||||||
name="dicht",
|
name="dicht",
|
||||||
master_max=1600,
|
master_max=4800,
|
||||||
tile_sizes=(180, 130, 90, 64, 48),
|
tile_sizes=(48, 34, 24, 16, 12),
|
||||||
max_placements=480,
|
max_placements=150000,
|
||||||
candidates=16,
|
candidates=12,
|
||||||
rotation_step=15,
|
rotation_step=15,
|
||||||
max_overlap=0.28,
|
max_overlap=0.62,
|
||||||
edge_blend=0.14,
|
edge_blend=0.14,
|
||||||
tile_alpha=0.88,
|
tile_alpha=0.96,
|
||||||
min_tiles=40,
|
min_tiles=40,
|
||||||
stride_frac=0.35,
|
stride_frac=0.28,
|
||||||
use_master_bg=True,
|
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,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ class TileRecord:
|
||||||
hist: NDArray[np.float32]
|
hist: NDArray[np.float32]
|
||||||
# cached thumb for matching (RGB uint8)
|
# cached thumb for matching (RGB uint8)
|
||||||
thumb: NDArray[np.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]:
|
def _hist_hsv(rgb: NDArray[np.uint8], bins: int = 8) -> NDArray[np.float32]:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue