← back to Wallco Ai
scripts/controlnet-anchored-redo.py
506 lines
#!/usr/bin/env python3
"""controlnet-anchored-redo.py — REDO of the BOUNDED 5-root sanity sample using
an SDXL CANNY ControlNet to anchor the subject while hitting an OPEN composition.
WHY THIS SUPERSEDES native-seamless-redo.py (2026-06-11):
The img2img redo anchored the subject by starting from the root latent, but the
root tiles are EDGE-TO-EDGE BUSY toiles (sunflowers everywhere / barcode vertical
stripes / an abstract red-cyan blob). img2img inherits that busyness, fighting the
graphic-designer open-composition brief. The ControlNet model
`controlnet-canny-sdxl-1.0.safetensors` was installed on Mac1 ComfyUI after Steve's
approval, which unlocks the right architecture.
DTD VERDICT (3/3, 2026-06-11): feed the ControlNet a CROPPED single focal motif of
the root (the one clean animal head) centred on an otherwise-BLANK canvas, at
LOW-MODERATE strength (~0.5), NOT the full busy root. A canny map of the full root
is a dense spatial prior that re-imposes the edge-to-edge clutter and makes an open
layout structurally impossible. Cropping decouples WHICH-animal (canny anchor) from
LAYOUT (blank surround + open prompt + circular-pad), so all four goals co-exist.
THE FOUR GOALS, each by mechanism:
1. ZERO smear — native circular-pad seamless: SeamlessTile + MakeCircularVAE,
txt2img at denoise 1.0. The tile wraps by construction. We NEVER touch
make_seamless.py / force-edge-seamless.py (retired smear sources).
2. SUBJECT anchored — the root's single focal crop -> Canny edge map, fed through
ControlNetApplyAdvanced (canny SDXL CN) at strength ~0.5 over a partial step
window. The motif identity (giraffe head, frog, etc.) survives; the model is
free to rebuild open surround.
3. REAL fibre ground — the clean two-tone open motif tile is composited onto a
seamless procedural natural-fibre swatch (make-fibre-ground.py) via the
deterministic seamless-bg-swap path. Both layers wrap; motif kept byte-for-byte.
4. OPEN / less busy — blank canny surround + open/sparse prompt + a focal-scale
canny crop sized so the primary motif lands ~150-200px tall in a 512 render.
SACRED ROOTS: never overwrites a root. Each candidate -> NEW file + NEW id with
is_published=FALSE, user_removed=FALSE, parent_design_id=<root>. NEVER published live.
The composite's edges are verified with verify-edge-seamless.py on the WRITTEN file;
the real verdict is recorded (a WARN/FAIL is recorded as such, never a fake PASS).
NOT a bulk run. Caps at --ids. DATABASE_URL self-resolves (never echoed).
Usage:
python3 scripts/controlnet-anchored-redo.py --ids 2662,54266,54076,54610,53896
python3 scripts/controlnet-anchored-redo.py --ids 2662 --cn-strength 0.55
"""
import argparse, datetime, json, os, re, subprocess, sys, time
from pathlib import Path
from urllib.parse import quote
import numpy as np
from PIL import Image, ImageFilter
from scipy.ndimage import gaussian_filter
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GENDIR = os.path.join(ROOT, 'data', 'generated')
FIBREDIR = os.path.join(ROOT, 'data', 'fibre-grounds')
QUEUE = os.path.join(ROOT, 'data', 'seam-fix-queue.jsonl')
sys.path.insert(0, os.path.join(ROOT, 'scripts'))
from importlib import import_module
sfv = import_module('seam-fix-variants') # psql / root_meta
edgeverify = import_module('verify-edge-seamless') # true-toroidal edge gate
mkfibre = import_module('make-fibre-ground') # procedural fibre swatch
COMFY = os.environ.get('COMFY_URL', 'http://192.168.1.133:8188')
MODEL = os.environ.get('COMFY_MODEL', 'sd_xl_base_1.0.safetensors')
CN_MODEL = os.environ.get('COMFY_CN_MODEL', 'controlnet-canny-sdxl-1.0.safetensors')
SAMPLER = os.environ.get('COMFY_SAMPLER', 'dpmpp_2m_sde')
SCHEDULER = os.environ.get('COMFY_SCHEDULER', 'karras')
POLL_TIMEOUT = int(os.environ.get('COMFY_POLL_TIMEOUT_SEC', '900'))
RENDER = 1024 # SDXL native; verify+brief are framed at 512 — we downscale notes
# Rotate the natural ground across the sample so Steve sees range, not one note.
GROUNDS = ['grasscloth', 'raffia', 'natural linen', 'sisal', 'paperweave']
# ---------- subject extraction ----------
def extract_subject(prompt: str) -> str:
if not prompt:
return 'a single tipsy animal holding one cocktail'
parts = [p.strip() for p in prompt.split(',')]
subject = ', '.join(parts[:2]) if len(parts) >= 2 else parts[0]
subject = re.split(r'\.\s', subject)[0]
return subject.strip().rstrip('.')
def short_animal(prompt: str) -> str:
"""Just the animal noun (for a tighter focal prompt)."""
p = (prompt or '').lower()
for animal in ['giraffe', 'tree frog', 'frog', 'orangutan', 'elephant',
'red panda', 'panda', 'monkey', 'lemur', 'sloth', 'fox',
'otter', 'raccoon', 'koala', 'parrot', 'flamingo']:
if animal in p:
return animal
return 'animal'
# ---------- focal crop ----------
def _detail_density(gray):
"""Local edge-energy map: where the subject's fine detail concentrates."""
gx = np.abs(np.diff(gray, axis=1, append=gray[:, -1:]))
gy = np.abs(np.diff(gray, axis=0, append=gray[-1:, :]))
energy = gx + gy
return gaussian_filter(energy, sigma=24)
def find_focal_crop(root_img, crop_frac=0.42):
"""Find the densest-detail square region away from the tile edges — that is
where the animal subject (eyes/face/contours) concentrates vs. flat ground or
repetitive filler. Returns a centred square crop of the root.
crop_frac sets crop side as a fraction of the root's short edge; smaller =
tighter focal motif = more open surround once placed on the blank canvas."""
g = np.asarray(root_img.convert('L'), np.float64)
H, W = g.shape
side = int(min(H, W) * crop_frac)
side = max(64, min(side, min(H, W)))
dens = _detail_density(g)
# integral image for fast windowed sum of density
ii = dens.cumsum(0).cumsum(1)
ii = np.pad(ii, ((1, 0), (1, 0)))
def win_sum(y, x):
return (ii[y + side, x + side] - ii[y, x + side]
- ii[y + side, x] + ii[y, x])
# bias slightly toward centre (avoid grabbing a cropped half-subject at edge)
best, by, bx = -1.0, 0, 0
step = max(8, side // 16)
cy, cx = (H - side) / 2.0, (W - side) / 2.0
for y in range(0, H - side + 1, step):
for x in range(0, W - side + 1, step):
centre_bias = 1.0 - 0.35 * (abs(y - cy) / max(cy, 1) + abs(x - cx) / max(cx, 1)) / 2.0
s = win_sum(y, x) * centre_bias
if s > best:
best, by, bx = s, y, x
return root_img.crop((bx, by, bx + side, by + side)), (bx, by, side)
def build_canny_hint(focal_crop, motif_px=180):
"""Place the focal crop, scaled so the subject reads ~motif_px tall in a 512
frame (scaled to RENDER), centred on a BLACK canvas. Black surround => Canny
yields no edges there => CN exerts no constraint => the model leaves it open.
CRITICAL: feather the crop's outer ring to black with a radial alpha so the
crop's square boundary does NOT survive as a hard edge through Canny — a hard
square produced the faint frame-overlay ghost-box in the first smoke test.
The feather kills the box; only the subject's interior contours remain as
canny edges."""
target = int(round(motif_px * (RENDER / 512.0)))
target = max(96, min(target, RENDER - 32))
crop = focal_crop.convert('RGB').resize((target, target), Image.LANCZOS)
# Feather to black over a LONG, VERY gradual radial ramp so NO detectable
# edge forms at any radius — the first feather (sharp ring at r=0.82) created
# a circular gradient that Canny picked up as a ring outline ('framed cameo'
# defect). Here alpha falls linearly from 1.0 at the centre to 0 at r=1.0,
# and we blur it heavily, so the luminance gradient everywhere is below
# Canny's gradient threshold — only the SUBJECT's own internal contours
# survive as edges, no box and no ring.
yy, xx = np.mgrid[0:target, 0:target].astype(np.float64)
cx = cy = (target - 1) / 2.0
r = np.sqrt(((xx - cx) / cx) ** 2 + ((yy - cy) / cy) ** 2) # 0 centre .. ~1.41 corners
alpha = np.clip(1.0 - r, 0.0, 1.0) # gentle whole-radius ramp
alpha = gaussian_filter(alpha, sigma=target * 0.06) # heavy blur => no hard ring
crop_arr = np.asarray(crop, np.float64) * alpha[..., None] # fade toward black
canvas = np.zeros((RENDER, RENDER, 3), np.float64)
off = (RENDER - target) // 2
canvas[off:off + target, off:off + target] = crop_arr
return Image.fromarray(np.clip(canvas, 0, 255).astype(np.uint8), 'RGB')
# ---------- comfy plumbing ----------
def comfy_post(path, data=None, timeout=30):
url = f'{COMFY}{path}'
if data is not None:
r = subprocess.run(
['curl', '-sf', '-m', str(timeout), '-H', 'Content-Type: application/json',
'-X', 'POST', url, '-d', '@-'],
input=json.dumps(data), capture_output=True, text=True)
else:
r = subprocess.run(['curl', '-sf', '-m', str(timeout), url],
capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(f'comfy {path} failed rc={r.returncode}: {r.stderr[:200]}')
return r.stdout
def comfy_upload_image(local_path, name_hint):
r = subprocess.run(
['curl', '-sf', '-m', '60', '-X', 'POST', f'{COMFY}/upload/image',
'-F', f'image=@{local_path}', '-F', 'overwrite=true'],
capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(f'comfy upload failed: {r.stderr[:200]}')
name = json.loads(r.stdout).get('name')
if not name:
raise RuntimeError(f'comfy upload returned no name: {r.stdout[:200]}')
return name
def build_prompt(subject, animal):
positive = (
f"A single {subject}. ONE clean bold flat two-tone {animal} as the SOLE "
"focal motif, large and isolated and centred, with generous EMPTY "
"negative space of bare flat ground all around it. Open airy wallcovering "
"repeat, sparse and minimal, calm and uncluttered, NOT busy, NOT "
"edge-to-edge, NOT a dense all-over print, plenty of breathing room. The "
"figure is a SOLID flat dark-ink silhouette in one deep archival ink "
"colour on a clean light flat ground, STRONG figure-ground contrast, "
"crisp graphic two-tone block-print, hard clean confident edges, no "
"shading, no gradient, no depth, no relief, NOT pale, NOT washed out, NOT "
"faded. Elegant heritage toile, refined block-print designer wallcovering."
)
negative = (
"busy, cluttered, dense, edge-to-edge, all-over print, tessellated "
"densely, packed motifs, many animals, crowded figures, repeated heads, "
"rows of faces, sunflowers, foliage filling the background, leaves filling "
"the background, vines everywhere, jungle scene, vertical stripes, barcode "
"stripes, columns of shapes, abstract shapes, abstract waves, abstract "
"blobs, leaf blades, unrecognizable subject, embossed, relief, raised "
"texture, 3D, dimensional, depth, drop shadow, ambient occlusion, ghost "
"layer, ghosted, faded background copies, gradient fill, halftone, "
"ben-day dots, outline only, hollow shape, pale, washed out, faded, "
"pastel, low contrast, ghostly, translucent, neon, fluorescent, "
"saturated, rainbow, more than 4 colours, seam visible, hard seam, frame, "
"border, picture frame, pasted-on rectangle, plate behind motif, "
"signature, watermark, text, blurry, low quality"
)
return positive, negative
def gen_controlnet_seamless(positive, negative, seed, hint_local, cn_strength,
cn_end, out_path):
"""txt2img native-seamless with a canny ControlNet anchoring the focal motif.
Graph: CheckpointLoader -> SeamlessTile(model) + MakeCircularVAE(vae);
LoadImage(hint) -> Canny -> ControlNetApplyAdvanced(positive,negative) at
strength<1 over [0, cn_end]; EmptyLatentImage(seamless via circular VAE on
decode); KSampler(denoise 1.0) -> VAEDecode(circular vae) -> SaveImage."""
hint_name = comfy_upload_image(hint_local, 'cn_hint')
workflow = {
'4': {'class_type': 'CheckpointLoaderSimple', 'inputs': {'ckpt_name': MODEL}},
'1000': {'class_type': 'SeamlessTile', 'inputs': {'model': ['4', 0], 'tiling': 'enable', 'copy_model': 'Make a copy'}},
'2000': {'class_type': 'MakeCircularVAE', 'inputs': {'vae': ['4', 2], 'tiling': 'enable', 'copy_vae': 'Make a copy'}},
'6': {'class_type': 'CLIPTextEncode', 'inputs': {'text': positive, 'clip': ['4', 1]}},
'7': {'class_type': 'CLIPTextEncode', 'inputs': {'text': negative, 'clip': ['4', 1]}},
'20': {'class_type': 'ControlNetLoader', 'inputs': {'control_net_name': CN_MODEL}},
'10': {'class_type': 'LoadImage', 'inputs': {'image': hint_name}},
'21': {'class_type': 'Canny', 'inputs': {'image': ['10', 0], 'low_threshold': 0.45, 'high_threshold': 0.85}},
'22': {'class_type': 'ControlNetApplyAdvanced', 'inputs': {
'positive': ['6', 0], 'negative': ['7', 0],
'control_net': ['20', 0], 'image': ['21', 0],
'strength': cn_strength, 'start_percent': 0.0, 'end_percent': cn_end}},
'5': {'class_type': 'EmptyLatentImage', 'inputs': {'width': RENDER, 'height': RENDER, 'batch_size': 1}},
'3': {'class_type': 'KSampler', 'inputs': {
'seed': seed, 'steps': 30, 'cfg': 7.0, 'sampler_name': SAMPLER,
'scheduler': SCHEDULER, 'denoise': 1.0,
'model': ['1000', 0], 'positive': ['22', 0], 'negative': ['22', 1],
'latent_image': ['5', 0]}},
'8': {'class_type': 'VAEDecode', 'inputs': {'samples': ['3', 0], 'vae': ['2000', 0]}},
'9': {'class_type': 'SaveImage', 'inputs': {'filename_prefix': f'wallco_cnar_{seed}', 'images': ['8', 0]}},
}
return _submit_and_pull(workflow, out_path)
def _submit_and_pull(workflow, out_path):
"""Submit a ComfyUI workflow, poll /history until node '9' SaveImage yields,
pull the bytes to out_path. Shared by the canny + text-led generators."""
submit = comfy_post('/prompt', {'prompt': workflow, 'client_id': 'wallco-cnar'})
pid = json.loads(submit).get('prompt_id')
if not pid:
raise RuntimeError('ComfyUI did not return prompt_id')
start = time.time()
images = None
last_err = None
while time.time() - start < POLL_TIMEOUT:
time.sleep(2)
try:
hist = json.loads(comfy_post(f'/history/{pid}', timeout=5))
except Exception:
continue
entry = hist.get(pid)
if not entry:
continue
status = entry.get('status', {})
if status.get('status_str') == 'error':
msgs = status.get('messages', [])
last_err = json.dumps(msgs)[:400]
raise RuntimeError(f'ComfyUI execution error: {last_err}')
if entry.get('outputs', {}).get('9', {}).get('images'):
images = entry['outputs']['9']['images']
break
if not images:
raise RuntimeError(f'ComfyUI timed out after {POLL_TIMEOUT}s (pid={pid}) {last_err or ""}')
im = images[0]
view = (f"/view?filename={quote(im['filename'])}"
f"&subfolder={quote(im.get('subfolder',''))}"
f"&type={quote(im.get('type','output'))}")
raw = subprocess.run(['curl', '-sf', '-m', '60', f'{COMFY}{view}'], capture_output=True)
if raw.returncode != 0 or len(raw.stdout) < 1000:
raise RuntimeError(f'ComfyUI image pull failed ({view})')
with open(out_path, 'wb') as f:
f.write(raw.stdout)
return out_path
def gen_textled_seamless(positive, negative, seed, out_path):
"""txt2img native-seamless with NO ControlNet — pure text-led. Used for roots
whose source structure (glassware) entangles the animal so badly that a canny
edge-map re-imports the wrong geometry no matter how it is cropped (DTD verdict
A, 2026-06-11). Same zero-smear circular-pad construction (SeamlessTile +
MakeCircularVAE, denoise 1.0) so the tile wraps by construction; the open
sole-focal-animal prompt drives a clean single subject with no glassware prior.
Graph: CheckpointLoader -> SeamlessTile(model) + MakeCircularVAE(vae);
CLIPTextEncode(pos/neg); EmptyLatentImage; KSampler(denoise 1.0) ->
VAEDecode(circular vae) -> SaveImage. Identical to the canny path minus the
LoadImage/Canny/ControlNet nodes."""
workflow = {
'4': {'class_type': 'CheckpointLoaderSimple', 'inputs': {'ckpt_name': MODEL}},
'1000': {'class_type': 'SeamlessTile', 'inputs': {'model': ['4', 0], 'tiling': 'enable', 'copy_model': 'Make a copy'}},
'2000': {'class_type': 'MakeCircularVAE', 'inputs': {'vae': ['4', 2], 'tiling': 'enable', 'copy_vae': 'Make a copy'}},
'6': {'class_type': 'CLIPTextEncode', 'inputs': {'text': positive, 'clip': ['4', 1]}},
'7': {'class_type': 'CLIPTextEncode', 'inputs': {'text': negative, 'clip': ['4', 1]}},
'5': {'class_type': 'EmptyLatentImage', 'inputs': {'width': RENDER, 'height': RENDER, 'batch_size': 1}},
'3': {'class_type': 'KSampler', 'inputs': {
'seed': seed, 'steps': 30, 'cfg': 7.0, 'sampler_name': SAMPLER,
'scheduler': SCHEDULER, 'denoise': 1.0,
'model': ['1000', 0], 'positive': ['6', 0], 'negative': ['7', 0],
'latent_image': ['5', 0]}},
'8': {'class_type': 'VAEDecode', 'inputs': {'samples': ['3', 0], 'vae': ['2000', 0]}},
'9': {'class_type': 'SaveImage', 'inputs': {'filename_prefix': f'wallco_cnar_txt_{seed}', 'images': ['8', 0]}},
}
return _submit_and_pull(workflow, out_path)
# ---------- composite ----------
def dominant_hex(png_path):
im = Image.open(png_path).convert('RGB').resize((128, 128))
arr = np.asarray(im).reshape(-1, 3)
q = (arr // 16 * 16).astype(np.uint8)
vals, counts = np.unique(q, axis=0, return_counts=True)
r, g, b = vals[counts.argmax()]
return f'#{r:02x}{g:02x}{b:02x}'
def coverage_pct(png_path, base_hex, tol=26):
"""Rough motif-coverage estimate: fraction of pixels NOT near the ground hex."""
arr = np.asarray(Image.open(png_path).convert('RGB').resize((256, 256)), np.float64)
base = np.array([int(base_hex[i:i + 2], 16) for i in (1, 3, 5)], np.float64)
d = np.sqrt(((arr - base) ** 2).sum(2))
return float((d > tol).mean()) * 100.0
def composite_real_fibre(motif_png, ground, base_hex, out_path):
slug = ground.replace(' ', '-')
fibre_png = os.path.join(FIBREDIR, f'{slug}.png')
if not os.path.exists(fibre_png):
os.makedirs(FIBREDIR, exist_ok=True)
mkfibre.make_fibre(ground, 1024, 11).save(fibre_png, 'PNG')
cmd = ['python3', os.path.join(ROOT, 'scripts', 'seamless-bg-swap-file.py'),
'--src', motif_png,
'--texture-image', fibre_png, '--texture-name', f'real-{slug}',
'--base-hex', base_hex,
'--strength', '0.6', '--feather', '40',
'--mask-tolerance', '20', '--mask-feather', '1.2',
'--no-sanitize', '--out', out_path]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
if r.returncode != 0 or not os.path.exists(out_path):
raise RuntimeError(f'bg-swap composite failed rc={r.returncode}: {r.stderr[-300:]}')
return r.stdout
def insert_child(root, out_path, positive, negative, ground, cn_strength):
"""Insert the child row. dw_admin has INSERT on all_designs but NO USAGE on
spoon_all_designs_id_seq, so we supply an EXPLICIT id = max(id)+1 (never touch
the sequence). Safe for this bounded, single-threaded 5-row sanity sample —
no concurrent inserts. is_published / user_removed FALSE; parent = root."""
pos = positive.replace("'", "''")
neg = negative.replace("'", "''")
# image_url is built IN the INSERT (not a later UPDATE): all_designs is in a
# logical-replication PUBLICATION with no replica identity, so UPDATE without
# a PK identity errors. The new id is the MAX(id)+1 computed once in a CTE so
# image_url can reference the same value the row is inserted with.
new_id = int(sfv.psql(
"WITH nid AS (SELECT COALESCE(MAX(id),0)+1 AS id FROM all_designs) "
"INSERT INTO all_designs "
"(id, category, kind, prompt, negative_prompt, local_path, image_url, dominant_hex, "
" width_in, height_in, seed, tags, is_published, user_removed, generator, "
" parent_design_id, source_dw_sku) "
"SELECT nid.id, "
f" s.category, s.kind, '{pos}', '{neg}', '{out_path}', "
" '/designs/img/by-id/' || nid.id, s.dominant_hex, "
" s.width_in, s.height_in, s.seed, "
f" (COALESCE(s.tags, ARRAY[]::text[])) || "
f" ARRAY['controlnet-anchored-redo','canny-sdxl-cn','real-fibre-{ground.replace(' ','-')}','open-composition','focal-crop','redo-sample-from-{root}']::text[], "
f" FALSE, FALSE, 'controlnet-anchored-redo', {root}, s.source_dw_sku "
f"FROM (SELECT * FROM all_designs WHERE id={root} LIMIT 1) s, nid RETURNING id;"))
return new_id
def root_meta_one(root):
"""Local meta fetch with LIMIT 1 — some root ids are duplicated in all_designs
(snapshot import dupes), and sfv.root_meta json.loads-es ALL matching rows and
chokes on 'Extra data'. We take exactly one."""
row = sfv.psql(
"SELECT row_to_json(t) FROM (SELECT id, category, kind, prompt, local_path "
f"FROM all_designs WHERE id={root} LIMIT 1) t;")
if not row:
raise RuntimeError(f'no design {root}')
return json.loads(row.splitlines()[0])
def process(root, ground, cn_strength, cn_end, crop_frac, motif_px):
os.makedirs(GENDIR, exist_ok=True)
meta = root_meta_one(root)
src = meta.get('local_path')
if not src or not os.path.exists(src):
return {'root_id': root, 'ok': False, 'error': 'no source file'}
prompt = meta.get('prompt')
if not prompt:
pr = sfv.psql(f"SELECT COALESCE(prompt,'') FROM all_designs WHERE id={root} LIMIT 1;").splitlines()
prompt = pr[0] if pr else ''
subject = extract_subject(prompt)
animal = short_animal(prompt)
positive, negative = build_prompt(subject, animal)
root_img = Image.open(src).convert('RGB')
focal, crop_box = find_focal_crop(root_img, crop_frac)
hint = build_canny_hint(focal, motif_px)
seed = int.from_bytes(os.urandom(4), 'big') % (2**31 - 1)
ts = int(time.time() * 1000)
hint_png = os.path.join(GENDIR, f'{ts}_{seed}_cnar_hint.png')
motif_png = os.path.join(GENDIR, f'{ts}_{seed}_cnar_motif.png')
final_png = os.path.join(GENDIR, f'{ts}_{seed}_cnar.png')
hint.save(hint_png, 'PNG')
# 1+2. controlnet-anchored native-seamless txt2img (zero smear, subject anchored)
gen_controlnet_seamless(positive, negative, seed, hint_png, cn_strength, cn_end, motif_png)
# composition stats on the raw motif tile (before fibre)
base_hex = dominant_hex(motif_png)
cov = coverage_pct(motif_png, base_hex)
# 3. real-fibre composite (deterministic, both layers wrap)
composite_real_fibre(motif_png, ground, base_hex, final_png)
# 4. true-toroidal edge gate on the FINAL composite
ev = edgeverify.verify_path(Path(final_png))
new_id = insert_child(root, final_png, positive, negative, ground, cn_strength)
return {
'ts': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z'),
'root_id': root, 'variant': 'controlnet-anchored-redo',
'generator': 'controlnet-anchored-redo',
'category': meta.get('category'), 'subject': subject, 'animal': animal,
'ground': ground, 'seed': seed, 'base_hex': base_hex,
'cn_strength': cn_strength, 'cn_end': cn_end, 'crop_box': crop_box,
'coverage_pct': round(cov, 1),
'technique': 'canny-SDXL-CN-anchored native-seamless txt2img (SeamlessTile+MakeCircularVAE, denoise 1.0, cropped focal canny hint) + deterministic real-fibre composite — NO smear',
'edge_verdict': ev.get('verdict'), 'edge_seamless': bool(ev.get('ok')),
'edge_axes': {'h': ev.get('axes', {}).get('horizontal', {}),
'v': ev.get('axes', {}).get('vertical', {})},
'new_id': new_id, 'hint': hint_png, 'motif': motif_png, 'out': final_png, 'ok': True,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--ids', required=True)
ap.add_argument('--ground', help='force one ground for all (else rotates)')
ap.add_argument('--cn-strength', type=float, default=0.5, help='canny CN strength (DTD: ~0.5)')
ap.add_argument('--cn-end', type=float, default=0.55, help='CN end_percent (release control late so layout opens)')
ap.add_argument('--crop-frac', type=float, default=0.32, help='focal crop side / root short edge (tighter = one head, less filler)')
ap.add_argument('--motif-px', type=int, default=175, help='target motif height in a 512 frame')
args = ap.parse_args()
ids = [int(x) for x in args.ids.split(',') if x.strip()]
results = []
for i, rid in enumerate(ids):
ground = args.ground or GROUNDS[i % len(GROUNDS)]
try:
r = process(rid, ground, args.cn_strength, args.cn_end, args.crop_frac, args.motif_px)
except Exception as e:
r = {'root_id': rid, 'ok': False, 'error': str(e), 'ground': ground}
results.append(r)
if r.get('ok'):
with open(QUEUE, 'a') as q:
q.write(json.dumps(r) + '\n')
v = r.get('edge_verdict', r.get('error', '?'))
ax = r.get('edge_axes', {})
h = (ax.get('h') or {}).get('verdict'); vv = (ax.get('v') or {}).get('verdict')
print(f"root {rid} ground={r.get('ground')}: edge={v} h={h} v={vv} "
f"cov={r.get('coverage_pct')}% new_id={r.get('new_id')}", file=sys.stderr)
print(json.dumps(results, indent=2))
sys.exit(0 if all(r.get('ok') for r in results) else 1)
if __name__ == '__main__':
main()