Files
videobeaux/experimental/bianlian.py
2025-09-01 19:35:24 -04:00

623 lines
24 KiB
Python

#!/usr/bin/env python3
"""
Lumask — PNG-only generative mask/stencil tool
Symmetry:
--mirror none|horizontal|vertical|both
--diag-mirror none|diag|antidiag|both
--radial-sym N
--dihedral N
--grid CxR (or a single integer -> NxN), --grid-spacing S (fraction of min(W,H))
--spiral K, --spiral-rot-deg D, --spiral-scale S
Other:
Transparent mask (--mode mask) or color preview (--mode color, --bg)
Noise placement (perlin/value)
Frame-safe sizing (no giant whiteouts), keep-in-frame clamping
Batch generation → timestamped folder + filenames + PNG metadata
"""
import argparse
import json
import math
import os
import random
from dataclasses import dataclass
from datetime import datetime
from typing import List, Tuple, Optional
from PIL import Image, ImageDraw, ImageColor, PngImagePlugin
# -----------------------------
# Constants / Presets
# -----------------------------
TWO_PI = math.pi * 2
SHAPES = [
"circle", "square", "triangle", "vertical", "horizontal",
"diagonal", "diagonal-flipped", "polygon", "rhombus",
"octagon", "pentagon", "nonagon"
]
PRESETS = {
"sd": (320, 240),
"720hd": (640, 360),
"1080hd": (960, 540),
"widescreen": (320, 180),
"portrait1080": (1080, 1620),
}
# -----------------------------
# Data classes
# -----------------------------
@dataclass
class PlaneParams:
shape: str
size: float
x: float
y: float
rotation: float
stretchX: float
stretchY: float
alpha: int = 255
@dataclass
class GenConfig:
W: int
H: int
mode: str
bg_hex: str
planes: int
no_rand: bool
seed: int
noise_mode: str
noise_scale: float
noise_strength: float
# symmetry
mirror: str # none|horizontal|vertical|both
diag_mirror: str # none|diag|antidiag|both
radial_sym: int
dihedral: int # >=2 enables D_n
grid_cols: int
grid_rows: int
grid_spacing: float # fraction of min(W,H)
spiral_k: int
spiral_rot_deg: float
spiral_scale: float
# sizing / safety
size_min: float
size_max: float
margin: float
fit_enabled: bool
keep_in_frame: bool
# -----------------------------
# Utilities
# -----------------------------
def now_stamp() -> str:
return datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
def rng_pick(rng: random.Random, items: List[str]) -> str:
return items[rng.randrange(0, len(items))]
def hex_to_rgba(hex_str: str, alpha: int = 255) -> Tuple[int, int, int, int]:
c = ImageColor.getrgb(hex_str)
return (c[0], c[1], c[2], alpha)
def polygon_points(cx: float, cy: float, radius: float, n: int) -> List[Tuple[float, float]]:
return [(cx + math.cos(i * (TWO_PI / n)) * radius,
cy + math.sin(i * (TWO_PI / n)) * radius) for i in range(n)]
def paste_centered(base: Image.Image, layer: Image.Image, cx: float, cy: float) -> None:
x = int(round(cx - layer.width / 2))
y = int(round(cy - layer.height / 2))
base.alpha_composite(layer, (x, y))
def format_outname(base_path: str, preset: str, idx: Optional[int], digits: int, ts: Optional[str] = None) -> str:
root, ext = os.path.splitext(base_path)
parts = [root]
if preset:
parts.append(preset)
if idx is not None:
parts.append(str(idx).zfill(digits))
if ts:
parts.append(ts)
return os.path.abspath(f"{'_'.join(parts)}{ext}")
# -----------------------------
# Noise (value + Perlin)
# -----------------------------
def hash2d(xi: int, yi: int, seed: int) -> float:
n = (xi * 374761393 + yi * 668265263) ^ (seed * 1274126177)
n = (n ^ (n >> 13)) * 1274126177
n = (n ^ (n >> 16)) & 0xFFFFFFFF
return n / 0x100000000
def lerp(a: float, b: float, t: float) -> float:
return a + (b - a) * t
def smoothstep(t: float) -> float:
return t * t * (3 - 2 * t)
def value_noise(x: float, y: float, seed: int) -> float:
xi, yi = math.floor(x), math.floor(y)
xf, yf = x - xi, y - yi
v00 = hash2d(xi, yi, seed)
v10 = hash2d(xi + 1, yi, seed)
v01 = hash2d(xi, yi + 1, seed)
v11 = hash2d(xi + 1, yi + 1, seed)
u = smoothstep(xf)
v = smoothstep(yf)
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v)
def grad(hashv: int, x: float, y: float) -> float:
h = hashv & 7
u = x if h < 4 else y
v = y if h < 4 else x
return ((u if (h & 1) == 0 else -u) + (v if (h & 2) == 0 else -v))
def perlin(x: float, y: float, seed: int) -> float:
xi, yi = math.floor(x), math.floor(y)
xf, yf = x - xi, y - yi
h00 = int(hash2d(xi, yi, seed) * 256)
h10 = int(hash2d(xi + 1, yi, seed) * 256)
h01 = int(hash2d(xi, yi + 1, seed) * 256)
h11 = int(hash2d(xi + 1, yi + 1, seed) * 256)
u = smoothstep(xf)
v = smoothstep(yf)
n00 = grad(h00, xf, yf)
n10 = grad(h10, xf - 1, yf)
n01 = grad(h01, xf, yf - 1)
n11 = grad(h11, xf - 1, yf - 1)
nx0 = lerp(n00, n10, u)
nx1 = lerp(n01, n11, u)
return (lerp(nx0, nx1, v) + 1) * 0.5
def sample_noise(nx: float, ny: float, seed: int, mode: str) -> float:
if mode == "perlin":
return perlin(nx, ny, seed)
elif mode == "value":
return value_noise(nx, ny, seed)
return 0.5
# -----------------------------
# Frame-safe helpers
# -----------------------------
def rotated_bbox(w: float, h: float, theta: float) -> Tuple[float, float]:
ct = abs(math.cos(theta))
st = abs(math.sin(theta))
return (w * ct + h * st, w * st + h * ct)
def fit_size_to_canvas(size: float, stretchX: float, stretchY: float, rotation: float,
W: int, H: int, margin: float) -> float:
w = size * stretchX
h = size * stretchY
bw, bh = rotated_bbox(w, h, rotation)
maxW = W * margin
maxH = H * margin
scale = min(1.0, maxW / bw if bw > 0 else 1.0, maxH / bh if bh > 0 else 1.0)
return size * scale
def clamp_position_to_canvas(x: float, y: float, size: float, stretchX: float, stretchY: float,
rotation: float, W: int, H: int, margin: float) -> Tuple[float, float]:
w = size * stretchX
h = size * stretchY
bw, bh = rotated_bbox(w, h, rotation)
halfW = (W * margin - bw) / 2.0
halfH = (H * margin - bh) / 2.0
if halfW < 0: halfW = 0
if halfH < 0: halfH = 0
return (max(-halfW, min(halfW, x)), max(-halfH, min(halfH, y)))
# -----------------------------
# Param generation & noise
# -----------------------------
def generate_params(rng: random.Random, width: int, height: int, planes: int, do_randomize: bool,
size_min: float, size_max: float) -> List[PlaneParams]:
out: List[PlaneParams] = []
base = min(width, height)
min_px = max(2.0, base * size_min)
max_px = max(min_px + 1.0, base * size_max)
for i in range(planes):
shape = rng_pick(rng, SHAPES) if do_randomize else SHAPES[i % len(SHAPES)]
size = rng.uniform(min_px, max_px) if do_randomize else (0.5 * (min_px + max_px))
x = rng.uniform(-width / 2, width / 2) if do_randomize else 0.0
y = rng.uniform(-height / 2, height / 2) if do_randomize else 0.0
rot = rng.uniform(0, TWO_PI) if do_randomize else 0.0
sx = rng.uniform(0.5, 2.0) if do_randomize else 1.0
sy = rng.uniform(0.5, 2.0) if do_randomize else 1.0
out.append(PlaneParams(shape, size, x, y, rot, sx, sy, 255))
return out
def apply_noise_to_params(params: List[PlaneParams], cfg: GenConfig) -> None:
if cfg.noise_mode == "none" or cfg.noise_strength <= 0 or cfg.noise_scale <= 0:
return
W, H = cfg.W, cfg.H
for idx, p in enumerate(params):
nx = (p.x + W * 0.5) * cfg.noise_scale
ny = (p.y + H * 0.5) * cfg.noise_scale
n1 = sample_noise(nx + 10.123 * idx, ny + 20.456 * idx, cfg.seed, cfg.noise_mode)
n2 = sample_noise(nx + 33.789 * idx, ny + 44.012 * idx, cfg.seed ^ 0xABCDEF, cfg.noise_mode)
p.x += (n1 - 0.5) * (W * cfg.noise_strength)
p.y += (n2 - 0.5) * (H * cfg.noise_strength)
n3 = sample_noise(nx - 55.5, ny + 66.6, cfg.seed ^ 0x123456, cfg.noise_mode)
p.rotation += (n3 - 0.5) * 0.25
def enforce_frame_safety(params: List[PlaneParams], cfg: GenConfig) -> None:
for p in params:
if cfg.fit_enabled:
p.size = fit_size_to_canvas(p.size, p.stretchX, p.stretchY, p.rotation, cfg.W, cfg.H, cfg.margin)
if cfg.keep_in_frame:
p.x, p.y = clamp_position_to_canvas(p.x, p.y, p.size, p.stretchX, p.stretchY, p.rotation,
cfg.W, cfg.H, cfg.margin)
# -----------------------------
# Symmetry helpers
# -----------------------------
def rotate_vec(x: float, y: float, ang: float) -> Tuple[float, float]:
ca, sa = math.cos(ang), math.sin(ang)
return (x * ca - y * sa, x * sa + y * ca)
def add_mirror_variants(x: float, y: float, rot: float, mirror: str) -> List[Tuple[float, float, float]]:
out = [(x, y, rot)]
if mirror in ("horizontal", "both"):
out.append((-x, y, -rot))
if mirror in ("vertical", "both"):
out.append((x, -y, -rot))
if mirror == "both":
out.append((-x, -y, rot))
return out
def add_diag_mirror_variants(x: float, y: float, rot: float, diag_mirror: str) -> List[Tuple[float, float, float]]:
out = [(x, y, rot)]
if diag_mirror in ("diag", "both"):
out.append(( y, x, (math.pi/2) - rot))
if diag_mirror in ("antidiag", "both"):
out.append((-y, -x, -(math.pi/2) - rot))
return out
def add_dihedral_variants(x: float, y: float, rot: float, n: int) -> List[Tuple[float, float, float]]:
"""
Dihedral D_n: for each sector k, add a rotated copy and its mirror.
Construct by taking base {(x,y,rot), (x,-y,-rot)} and rotating by 2πk/n.
"""
if n is None or n < 2:
return [(x, y, rot)]
base = [(x, y, rot), (x, -y, -rot)]
out: List[Tuple[float, float, float]] = []
for k in range(n):
a = (k / n) * TWO_PI
for px, py, pr in base:
rx, ry = rotate_vec(px, py, a)
out.append((rx, ry, pr + a))
return out
def parse_grid_spec(spec: Optional[str]) -> Tuple[int, int]:
if not spec:
return (0, 0)
s = spec.lower().replace(" ", "")
if "x" in s:
c, r = s.split("x", 1)
try:
return (max(1, int(c)), max(1, int(r)))
except:
return (0, 0)
try:
n = int(s)
side = max(1, int(round(math.sqrt(n))))
return (side, side)
except:
return (0, 0)
def add_grid_variants(x: float, y: float, rot: float, cols: int, rows: int,
spacing: float, W: int, H: int) -> List[Tuple[float, float, float]]:
"""Place copies on a centered CxR grid. spacing is fraction of min(W,H) between cell centers."""
if cols < 1 or rows < 1:
return [(x, y, rot)]
out: List[Tuple[float, float, float]] = []
base_step = max(1.0, min(W, H) * spacing)
x0 = - (cols - 1) * 0.5 * base_step
y0 = - (rows - 1) * 0.5 * base_step
for ci in range(cols):
for ri in range(rows):
gx = x + x0 + ci * base_step
gy = y + y0 + ri * base_step
out.append((gx, gy, rot))
return out
def add_spiral_variants(x: float, y: float, rot: float, k: int, rot_deg: float, scale: float) -> List[Tuple[float, float, float]]:
"""Create k-1 additional copies along a spiral (position rotated and radius multiplied)."""
if k is None or k < 2:
return [(x, y, rot)]
out = [(x, y, rot)]
step = math.radians(rot_deg)
r0 = math.hypot(x, y)
theta0 = math.atan2(y, x)
for i in range(1, k):
r = r0 * (scale ** i)
theta = theta0 + step * i
sx = r * math.cos(theta)
sy = r * math.sin(theta)
srot = rot + step * i
out.append((sx, sy, srot))
return out
# -----------------------------
# Drawing
# -----------------------------
def draw_base_shape(shape: str, size: int, fill: Tuple[int, int, int, int]) -> Image.Image:
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
s = size
if shape == "circle":
d.ellipse([0, 0, s, s], fill=fill)
elif shape == "square":
d.rectangle([0, 0, s, s], fill=fill)
elif shape == "triangle":
h = s * (math.sqrt(3) / 2)
pts = [(s * 0.0, s * 0.5 + h / 6),
(s * 1.0, s * 0.5 + h / 6),
(s * 0.5, s * 0.5 - 2 * h / 6)]
d.polygon(pts, fill=fill)
elif shape == "vertical":
d.rectangle([s * 0.375, 0, s * 0.625, s], fill=fill)
elif shape == "horizontal":
d.rectangle([0, s * 0.375, s, s * 0.625], fill=fill)
elif shape in ("diagonal", "diagonal-flipped"):
d.rectangle([0, s * 0.375, s, s * 0.625], fill=fill)
img = img.rotate(45 if shape == "diagonal" else -45, resample=Image.BICUBIC, expand=True)
elif shape == "polygon":
d.polygon(polygon_points(s / 2, s / 2, s / 2, 6), fill=fill)
elif shape == "rhombus":
d.polygon([(s * 0.5, 0), (s, s * 0.5), (s * 0.5, s), (0, s * 0.5)], fill=fill)
elif shape == "octagon":
d.polygon(polygon_points(s / 2, s / 2, s / 2, 8), fill=fill)
elif shape == "pentagon":
d.polygon(polygon_points(s / 2, s / 2, s / 2, 5), fill=fill)
elif shape == "nonagon":
d.polygon(polygon_points(s / 2, s / 2, s / 2, 9), fill=fill)
else:
d.ellipse([0, 0, s, s], fill=fill)
return img
def draw_planes_to_png(params: List[PlaneParams], cfg: GenConfig, out_path: str, timestamp: Optional[str]) -> None:
if cfg.mode == "mask":
base = Image.new("RGBA", (cfg.W, cfg.H), (0, 0, 0, 0))
shape_fill = (255, 255, 255, 255)
else:
base = Image.new("RGBA", (cfg.W, cfg.H), hex_to_rgba(cfg.bg_hex, 255))
shape_fill = (190, 190, 190, 255)
def draw_one_at(xc: float, yc: float, rot: float, sx: float, sy: float, shape: str, size: float):
s = max(2, int(round(size)))
shape_img = draw_base_shape(shape, s, shape_fill)
sw = max(1, int(round(shape_img.width * sx)))
sh = max(1, int(round(shape_img.height * sy)))
if (sw, sh) != shape_img.size:
shape_img = shape_img.resize((sw, sh), resample=Image.LANCZOS)
deg = math.degrees(rot)
if abs(deg) > 0.0001:
shape_img = shape_img.rotate(deg, resample=Image.BICUBIC, expand=True)
paste_centered(base, shape_img, xc, yc)
cx, cy = cfg.W / 2, cfg.H / 2
for p in params:
# Compose symmetries in stages:
# 1) Axis mirrors
axis_set = add_mirror_variants(p.x, p.y, p.rotation, cfg.mirror)
# 2) Diagonal mirrors
diag_set: List[Tuple[float, float, float]] = []
for axx, axy, arot in axis_set:
diag_set.extend(add_diag_mirror_variants(axx, axy, arot, cfg.diag_mirror))
# 3) Dihedral (kaleidoscope) around center
dih_set: List[Tuple[float, float, float]] = []
for dx, dy, drot in diag_set:
dih_set.extend(add_dihedral_variants(dx, dy, drot, cfg.dihedral))
# 4) Grid (translate to lattice positions)
grid_set: List[Tuple[float, float, float]] = []
for gx, gy, grot in dih_set:
grid_set.extend(add_grid_variants(gx, gy, grot, cfg.grid_cols, cfg.grid_rows, cfg.grid_spacing, cfg.W, cfg.H))
# 5) Spiral (successive rotate & radius scale)
spiral_set: List[Tuple[float, float, float]] = []
for qx, qy, qrot in grid_set:
spiral_set.extend(add_spiral_variants(qx, qy, qrot, cfg.spiral_k, cfg.spiral_rot_deg, cfg.spiral_scale))
# 6) Base + radial symmetry copies
for bx, by, brot in spiral_set:
# base
draw_one_at(cx + bx, cy + by, brot, p.stretchX, p.stretchY, p.shape, p.size)
# radial copies
if cfg.radial_sym and cfg.radial_sym > 1:
for k in range(cfg.radial_sym):
ang = (k / cfg.radial_sym) * TWO_PI
rx, ry = rotate_vec(bx, by, ang)
draw_one_at(cx + rx, cy + ry, brot + ang, p.stretchX, p.stretchY, p.shape, p.size)
pnginfo = PngImagePlugin.PngInfo()
if timestamp:
pnginfo.add_text("lumask_timestamp", timestamp)
pnginfo.add_text("lumask_seed", str(cfg.seed))
pnginfo.add_text("lumask_mode", cfg.mode)
base.save(out_path, "PNG", pnginfo=pnginfo)
# -----------------------------
# Orchestration (one / batch)
# -----------------------------
def generate_one(seed_val: int, W: int, H: int, args: argparse.Namespace,
preset: str, idx: Optional[int], digits: int,
out_dir: Optional[str] = None, ts: Optional[str] = None) -> None:
rng = random.Random(seed_val)
params = generate_params(
rng, W, H, args.planes, not args.no_rand,
size_min=args.size_min, size_max=args.size_max
)
grid_cols, grid_rows = parse_grid_spec(args.grid)
cfg = GenConfig(
W=W, H=H, mode=args.mode, bg_hex=args.bg, planes=args.planes,
no_rand=args.no_rand, seed=seed_val,
noise_mode=args.noise, noise_scale=args.noise_scale, noise_strength=args.noise_strength,
mirror=args.mirror, diag_mirror=args.diag_mirror, radial_sym=args.radial_sym or 0,
dihedral=args.dihedral or 0,
grid_cols=grid_cols, grid_rows=grid_rows, grid_spacing=args.grid_spacing,
spiral_k=args.spiral or 0, spiral_rot_deg=args.spiral_rot_deg, spiral_scale=args.spiral_scale,
size_min=args.size_min, size_max=args.size_max,
margin=args.margin, fit_enabled=(not args.no_fit), keep_in_frame=not args.no_keep_in_frame
)
# Noise + safety
apply_noise_to_params(params, cfg)
enforce_frame_safety(params, cfg)
# Output names
digits = max(digits, 2)
ts_str = ts or now_stamp()
def in_dir(path: str) -> str:
return os.path.join(out_dir, os.path.basename(path)) if out_dir else path
out_png = in_dir(format_outname(args.out, preset, idx, digits, ts_str))
draw_planes_to_png(params, cfg, out_png, ts_str)
print(f"[✓] Wrote {out_png}")
if args.config:
conf_path = in_dir(format_outname(args.config, preset, idx, digits, ts_str))
conf = {
"timestamp": ts_str,
"seed": seed_val,
"mode": args.mode,
"bg": args.bg,
"width": W,
"height": H,
"preset": preset,
"planes": args.planes,
"randomized": not args.no_rand,
"noise": {
"mode": args.noise,
"scale": args.noise_scale,
"strength": args.noise_strength
},
"symmetry": {
"mirror": args.mirror,
"diag_mirror": args.diag_mirror,
"radial_sym": args.radial_sym or 0,
"dihedral": args.dihedral or 0,
"grid": {"spec": args.grid, "cols": grid_cols, "rows": grid_rows, "spacing_frac": args.grid_spacing},
"spiral": {"k": args.spiral or 0, "rot_deg": args.spiral_rot_deg, "scale": args.spiral_scale}
},
"size": {
"min_frac": args.size_min,
"max_frac": args.size_max
},
"fit": {
"enabled": not args.no_fit,
"margin": args.margin,
"keep_in_frame": not args.no_keep_in_frame
},
"params": [p.__dict__ for p in params],
}
with open(conf_path, "w", encoding="utf-8") as f:
json.dump(conf, f, indent=2)
print(f"[✓] Wrote {conf_path}")
print(f" seed={seed_val} mode={args.mode} size={W}x{H} planes={args.planes} preset={preset} noise={args.noise}")
def generate_batch(args: argparse.Namespace, W: int, H: int, preset: str) -> None:
batch_ts = now_stamp()
root, _ext = os.path.splitext(os.path.basename(args.out))
batch_dir = f"{root}_{preset}_{batch_ts}_batch"
os.makedirs(batch_dir, exist_ok=True)
total = int(args.batch)
digits = len(str(total))
print(f"==> Batch: writing {total} file(s) to ./{batch_dir}")
for i in range(1, total + 1):
seed_val = random.randrange(1, 10**9) if (args.auto_seed or args.seed is None) else (args.seed + i - 1)
print(f"[{i}/{total}] Generating (seed={seed_val}) …")
generate_one(seed_val, W, H, args, preset, idx=i, digits=digits, out_dir=batch_dir, ts=batch_ts)
# -----------------------------
# CLI
# -----------------------------
def main() -> None:
ap = argparse.ArgumentParser(description="Lumask PNG generator (noise, rich symmetry, frame-safe sizing, batch folders).")
ap.add_argument("--preset", default="portrait1080",
help="sd, 720hd, 1080hd, widescreen, portrait1080 (default)")
ap.add_argument("--w", type=int, help="Custom width (overrides preset)")
ap.add_argument("--h", type=int, help="Custom height (overrides preset)")
ap.add_argument("--out", default="lumask.png",
help="Output base path (used to build batch folder + filenames)")
ap.add_argument("--planes", type=int, default=3, help="Number of planes")
ap.add_argument("--seed", type=int, help="Deterministic seed")
ap.add_argument("--auto-seed", action="store_true", help="Ignore --seed and pick a fresh one each run")
ap.add_argument("--batch", type=int, help="Generate N masks; creates a timestamped folder")
ap.add_argument("--mode", choices=["mask", "color"], default="mask",
help="mask=transparent bg + white; color=solid bg + gray")
ap.add_argument("--config", help="Write JSON config (suffix matches batch/preset/timestamp)")
ap.add_argument("--no-rand", action="store_true", help="Disable randomization")
ap.add_argument("--bg", default="#FFFFFF", help="Background color for color mode (hex)")
# Noise
ap.add_argument("--noise", choices=["none", "perlin", "value"], default="none", help="Noise source")
ap.add_argument("--noise-scale", type=float, default=0.003, help="Noise frequency scale (smaller = smoother)")
ap.add_argument("--noise-strength", type=float, default=0.0, help="Displacement strength as fraction of canvas (0..1)")
# Symmetry (axis + diagonal + radial)
ap.add_argument("--mirror", choices=["none", "horizontal", "vertical", "both"], default="none",
help="Axis mirroring")
ap.add_argument("--diag-mirror", choices=["none", "diag", "antidiag", "both"], default="none",
help="Diagonal mirroring (diag=y=x, antidiag=y=-x)")
ap.add_argument("--radial-sym", type=int, help="Number of radial symmetry copies (>=2)")
# New symmetry systems
ap.add_argument("--dihedral", type=int, help="Dihedral kaleidoscope sectors (>=2)")
ap.add_argument("--grid", type=str, help="Grid copies as CxR (e.g., 3x2 or 4x4); integer -> NxN")
ap.add_argument("--grid-spacing", type=float, default=0.6, help="Grid spacing as fraction of min(W,H) between cells")
ap.add_argument("--spiral", type=int, help="Spiral copies (>=2)")
ap.add_argument("--spiral-rot-deg", type=float, default=20.0, help="Spiral rotation per copy (degrees)")
ap.add_argument("--spiral-scale", type=float, default=1.08, help="Spiral radius multiplier per copy")
# Sizing & fitting controls
ap.add_argument("--size-min", type=float, default=0.20, help="Min size as fraction of min(W,H) [0..1]")
ap.add_argument("--size-max", type=float, default=0.65, help="Max size as fraction of min(W,H) [0..1]")
ap.add_argument("--margin", type=float, default=0.95, help="Fit margin (1.0 = tight fit, 0.95 = 5% inset)")
ap.add_argument("--no-fit", action="store_true", help="Disable fit-to-frame (allow overflow)")
ap.add_argument("--no-keep-in-frame", action="store_true", help="Do not clamp positions to keep bbox inside")
args = ap.parse_args()
# Validate sizes
if args.size_min < 0 or args.size_max <= 0 or args.size_min >= args.size_max:
raise SystemExit("--size-min must be >=0 and < --size-max; --size-max must be >0")
if args.grid_spacing <= 0:
raise SystemExit("--grid-spacing must be > 0")
if args.spiral_scale <= 0:
raise SystemExit("--spiral-scale must be > 0")
# Resolve size/preset
if args.w and args.h:
W, H = args.w, args.h
preset = "custom"
else:
preset = (args.preset or "portrait1080").lower()
W, H = PRESETS.get(preset, PRESETS["portrait1080"])
# Batch vs single
if args.batch and args.batch > 0:
generate_batch(args, W, H, preset)
else:
if args.auto_seed or args.seed is None:
seed_val = random.randrange(1, 10**9)
else:
seed_val = args.seed
print("[1/1] Generating single output …")
generate_one(seed_val, W, H, args, preset, idx=None, digits=2)
if __name__ == "__main__":
main()