mirror of
https://github.com/vondas-network/videobeaux.git
synced 2025-12-05 15:30:02 +01:00
623 lines
24 KiB
Python
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()
|