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`.
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
# 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")
|
||||
# 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))
|
||||
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)))
|
||||
im = im.resize((target, target), Image.Resampling.LANCZOS)
|
||||
out = im.resize((target, target), Image.Resampling.BILINEAR)
|
||||
if abs(rotation_deg) > 0.5:
|
||||
im = im.rotate(rotation_deg, resample=Image.Resampling.BICUBIC, expand=True)
|
||||
return im
|
||||
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,
|
||||
best = _pick_best_rst(
|
||||
patch,
|
||||
library,
|
||||
idxs,
|
||||
tile_size,
|
||||
profile,
|
||||
reuse_counts,
|
||||
reuse_cap,
|
||||
prep_cache=prep_cache,
|
||||
)
|
||||
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,
|
||||
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,
|
||||
)
|
||||
err = _rms(patch, sample)
|
||||
if best is None or err < best[0]:
|
||||
best = (err, prepared, ti)
|
||||
|
||||
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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue