diff --git a/research/delighting/render050/.gitignore b/research/delighting/render050/.gitignore new file mode 100644 index 0000000..f66c702 --- /dev/null +++ b/research/delighting/render050/.gitignore @@ -0,0 +1,2 @@ +.relief_vlm_cache.json +__pycache__/ diff --git a/research/delighting/render050/board_real.py b/research/delighting/render050/board_real.py new file mode 100644 index 0000000..5171604 --- /dev/null +++ b/research/delighting/render050/board_real.py @@ -0,0 +1,101 @@ +"""050 REAL qualitative board: run relief auto-detection on a spread of real +corpus sheet photos + the CTO's difficult sheet, and show for each: + photo | detected category + knob settings | resulting procedural normal | shade + +Raw corpus images are LOCAL-ONLY; this writes only a downscaled JPEG board. +""" +import os, json, sys, glob +import numpy as np +from PIL import Image, ImageDraw, ImageFont +import detect_relief as D +import relief_presets as RP +from preview_bank import shade + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.abspath(os.path.join(HERE, "..", "results", "050")) +ASSETS029 = os.path.abspath(os.path.join(HERE, "..", "reports", "assets_029")) +CTO = os.path.expanduser("~/Downloads/PXL_20260508_165112222 (1).jpg") + + +def gather(): + imgs = [] + for pat in ("corpus_*.jpg", "wild_sheets_*.jpg", "wild_panel_*.jpg"): + imgs += sorted(glob.glob(os.path.join(ASSETS029, pat))) + if os.path.exists(CTO): + imgs.append(CTO) + return imgs + + +def center_crop_square(im, s=256): + w, h = im.size + m = min(w, h) + im = im.crop(((w - m) // 2, (h - m) // 2, (w + m) // 2, (h + m) // 2)) + return im.resize((s, s), Image.LANCZOS) + + +def main(): + model = sys.argv[1] if len(sys.argv) > 1 else "haiku" + imgs = gather() + results = [] + for p in imgs: + try: + r = D.detect(p, use_vlm_knobs=False, model=model) + except Exception as e: + print("FAIL", os.path.basename(p), e); continue + results.append((p, r)) + print(f"{os.path.basename(p)[:34]:34s} -> {r['category']:12s} " + f"amp={r['amplitude']:6s} scale={r['feature_scale']:6s}") + + # board + cell = 220; pad = 10; cols = 4 + labels = ["real photo", "detected", "procedural normal", "relief shade"] + rowh = cell + pad + top = 26 + W = pad + cols * (cell + pad) + Hh = top + len(results) * rowh + pad + sheet = Image.new("RGB", (W, Hh), (18, 20, 24)) + d = ImageDraw.Draw(sheet) + try: + font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 12) + fsm = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 11) + except Exception: + font = fsm = ImageFont.load_default() + for j, lab in enumerate(labels): + d.text((pad + j * (cell + pad) + 4, 6), lab, fill=(180, 188, 200), font=fsm) + for i, (p, r) in enumerate(results): + y = top + i * rowh + pad + photo = center_crop_square(Image.open(p).convert("RGB"), cell) + sheet.paste(photo, (pad, y)) + cat = r["category"] + ang = r.get("angle_deg") + nrm, meta = RP.make_normal(cat, size=cell, seed=3, + amplitude=r["amplitude"], + feature_scale=r["feature_scale"], + angle_deg=ang) + h, amp = RP.make_height(cat, size=cell, seed=3, amplitude=r["amplitude"], + feature_scale=r["feature_scale"], angle_deg=ang) + nimg = Image.fromarray((nrm * 255).astype(np.uint8)) + simg = Image.fromarray((np.clip(shade(h, nrm), 0, 1) * 255).astype(np.uint8)) + # detected text panel + panel = Image.new("RGB", (cell, cell), (26, 30, 36)) + pd = ImageDraw.Draw(panel) + name = os.path.basename(p) + pd.text((8, 10), name[:26], fill=(150, 160, 172), font=fsm) + pd.text((8, 44), cat, fill=(140, 200, 255), font=font) + pd.text((8, 66), f"amplitude: {r['amplitude']}", fill=(220, 225, 232), font=fsm) + pd.text((8, 84), f"feat scale: {r['feature_scale']}", fill=(220, 225, 232), font=fsm) + if ang is not None: + pd.text((8, 102), f"angle: {ang:.0f}deg", fill=(220, 225, 232), font=fsm) + sheet.paste(panel, (pad + (cell + pad), y)) + sheet.paste(nimg, (pad + 2 * (cell + pad), y)) + sheet.paste(simg.convert("RGB"), (pad + 3 * (cell + pad), y)) + path = os.path.join(OUT, "board_real_detection.jpg") + sheet.save(path, quality=88) + print("wrote", path, sheet.size) + json.dump([{"file": os.path.basename(p), **{k: v for k, v in r.items() if k != "features"}} + for p, r in results], + open(os.path.join(OUT, "real_detection.json"), "w"), indent=2) + + +if __name__ == "__main__": + main() diff --git a/research/delighting/render050/detect_relief.py b/research/delighting/render050/detect_relief.py new file mode 100644 index 0000000..7f31d7a --- /dev/null +++ b/research/delighting/render050/detect_relief.py @@ -0,0 +1,246 @@ +"""050 auto-detection of relief category + knob settings from ONE user photo. + +The CTO requirement (verbatim): the category + settings (hammered, small bumps, +...) must be AUTO-DETECTED from the user photo, not something the user tunes by +trying 8 presets. This module maps: raw photo -> (category, amplitude_bin, +feature_scale_bin, [angle]) -> a procedural preset from relief_presets. + +Two detection channels, per the validated lab pattern (VLMs classify glass well, +regress badly -- reports 019-024, vlm_classify.py): + * CATEGORY: VLM multiple-choice (claude CLI subprocess), 6 explicit options. + * KNOBS : (a) classical image statistics on the photo, and (b) VLM + multiple-choice bins -- both computed so validation can ship the + more reliable one. + +Signals used (mission: for clear glass relief is visible only as glints/shading +in the photo; for streaky/wispy the structure lives in the tint T -> use both): + * luminance high-frequency energy + dominant bandpass scale + specular glint + density/contrast (the relief-glint signal, dominant for clear glass) + * chroma high-frequency energy + gradient-orientation anisotropy (the T-borne + structure, dominant for streaky/wispy) +A derived-from-T pseudo-height normal is also produced as a blend component +where texture is visible in T. +""" +from __future__ import annotations +import json, os, subprocess, hashlib +import numpy as np +from PIL import Image + +HERE = os.path.dirname(os.path.abspath(__file__)) +CACHE = os.path.join(HERE, ".relief_vlm_cache.json") +import relief_presets as RP + +# ----------------------------------------------------------------- VLM channel +CAT_LETTERS = {"A": "smooth", "B": "hammered", "C": "granite", + "D": "seedy", "E": "ripple", "F": "rolling_wave"} +# direction-first prompt: on the synthetic holdout this beat the flat 6-option +# prompt on haiku (fine 0.50->0.58, coarse ->0.67) and, notably, beat SONNET with +# the same prompt (0.33/0.42) -- category detection here is NOT model-capability +# limited, it is genuine single-photo relief ambiguity, so we keep the cheap model. +CAT_PROMPT = """Read the image file at {path} and look at it closely. It is a \ +photo of one sheet of art glass. Judge ONLY the SURFACE TEXTURE relief, not the \ +colour. Decide FIRST whether the texture is DIRECTIONAL (features clearly line up \ +and run one way -- parallel streaks/reeds/pulls) or ISOTROPIC (features look the \ +same in all directions -- an all-over field of bumps/cells/seeds with no single \ +direction). Only choose ripple if the direction is unmistakable. Then pick \ +exactly one: +A) smooth - flat/float/cast, clean surface, essentially no relief +B) hammered - ISOTROPIC all-over field of small rounded dimples/cells (classic cathedral) +C) granite - ISOTROPIC dense fine sandy/stippled tooth, busier and finer than hammered +D) seedy - ISOTROPIC scattered discrete round bumps/seeds/bubbles on a soft surface +E) ripple - DIRECTIONAL parallel streaks/reeds/pulled lines running one way +F) rolling_wave - large coarse smooth waves/folds, centimetre-scale undulation +Reply with ONLY the single letter (A-F).""" + +AMP_LETTERS = {"A": "subtle", "B": "medium", "C": "strong"} +AMP_PROMPT = """Read the image file at {path} and look at it. It is a photo of a \ +sheet of art glass. How PRONOUNCED / DEEP is its surface texture relief -- how \ +strongly do the bumps/waves/streaks catch the light and distort what is seen \ +through the glass? Answer with ONLY one letter: +A) subtle - very shallow, the surface is nearly flat, only faint texture +B) medium - a clearly visible but moderate texture +C) strong - deep, bold texture with strong glints and heavy distortion""" + +SCALE_LETTERS = {"A": "fine", "B": "medium", "C": "coarse"} +SCALE_PROMPT = """Read the image file at {path} and look at it. It is a photo of \ +a sheet of art glass. How LARGE are the individual surface-texture features (the \ +bumps, cells, streak spacing or waves)? Answer with ONLY one letter: +A) fine - tiny, dense features (many per hand-width) +B) medium - moderate features +C) coarse - large features, only a few span the sheet""" + + +def _cache(): + if os.path.exists(CACHE): + try: + return json.load(open(CACHE)) + except Exception: + return {} + return {} + + +def _save_cache(c): + json.dump(c, open(CACHE, "w"), indent=1) + + +def _vlm(image_path, prompt, mapping, kind, model="haiku", timeout=120): + image_path = os.path.abspath(image_path) + c = _cache() + key = f"{kind}:{image_path}:{os.path.getmtime(image_path):.0f}" + if key in c: + return c[key] + out = subprocess.run( + ["claude", "-p", prompt.format(path=image_path), + "--allowedTools", "Read", "--model", model], + capture_output=True, text=True, timeout=timeout) + ans = out.stdout.strip().rstrip(".").upper()[-1:] + val = mapping.get(ans) + if val is None: + raise RuntimeError(f"VLM unparseable {out.stdout!r} for {image_path}") + c[key] = val + _save_cache(c) + return val + + +def vlm_category(path, model="haiku"): + return _vlm(path, CAT_PROMPT, CAT_LETTERS, "cat2", model) + + +def vlm_amplitude(path, model="haiku"): + return _vlm(path, AMP_PROMPT, AMP_LETTERS, "amp", model) + + +def vlm_scale(path, model="haiku"): + return _vlm(path, SCALE_PROMPT, SCALE_LETTERS, "scale", model) + + +# ------------------------------------------------------------- classical stats +def _load_gray_lab(path, n=384): + im = Image.open(path).convert("RGB").resize((n, n), Image.LANCZOS) + rgb = np.asarray(im, np.float32) / 255.0 + # luminance + lum = 0.299 * rgb[..., 0] + 0.587 * rgb[..., 1] + 0.114 * rgb[..., 2] + # simple opponent chroma (a,b-ish) without full Lab dependency + ca = rgb[..., 0] - rgb[..., 1] + cb = 0.5 * (rgb[..., 0] + rgb[..., 1]) - rgb[..., 2] + return lum, ca, cb + + +def _radial_psd(img): + f = np.fft.fftshift(np.fft.fft2(img - img.mean())) + p = np.abs(f) ** 2 + n = img.shape[0] + cy = cx = n // 2 + yy, xx = np.mgrid[0:n, 0:n] + r = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2).astype(int) + tbin = np.bincount(r.ravel(), p.ravel()) + nbin = np.bincount(r.ravel()) + prof = tbin / np.maximum(nbin, 1) + return prof # index = radial freq in cycles/image + + +def _structure_tensor_aniso(lum): + gy, gx = np.gradient(lum) + Jxx = (gx * gx).mean(); Jyy = (gy * gy).mean(); Jxy = (gx * gy).mean() + tr = Jxx + Jyy + det = Jxx * Jyy - Jxy * Jxy + l1 = tr / 2 + np.sqrt(max(tr * tr / 4 - det, 0)) + l2 = tr / 2 - np.sqrt(max(tr * tr / 4 - det, 0)) + aniso = (l1 - l2) / (l1 + l2 + 1e-9) + angle = 0.5 * np.degrees(np.arctan2(2 * Jxy, Jxx - Jyy)) + return float(aniso), float(angle) + + +def classical_features(path, n=384): + lum, ca, cb = _load_gray_lab(path, n) + # high-frequency luminance energy fraction (glints) + prof = _radial_psd(lum) + freqs = np.arange(len(prof)) + lowcut = int(0.03 * n); midcut = int(0.10 * n) + total = prof[1:].sum() + 1e-9 + hf_lum = prof[midcut:].sum() / total + # dominant bandpass scale: peak of freq-weighted psd above the DC/low blob + band = prof.copy(); band[:max(2, lowcut // 3)] = 0 + peak_freq = int(np.argmax(band[: n // 2])) + peak_scale_frac = (1.0 / peak_freq) if peak_freq > 0 else 0.5 # fraction of image + # specular glint density + contrast + thr = np.percentile(lum, 99) + glints = lum > thr + spec_contrast = float(np.percentile(lum, 99) - np.percentile(lum, 50)) + # chroma HF (T-borne structure) + anisotropy + chf = _radial_psd(ca) + _radial_psd(cb) + chroma_hf = chf[midcut:].sum() / (chf[1:].sum() + 1e-9) + aniso, angle = _structure_tensor_aniso(lum) + return { + "hf_lum": float(hf_lum), "peak_scale_frac": float(peak_scale_frac), + "spec_contrast": spec_contrast, "chroma_hf": float(chroma_hf), + "aniso": aniso, "angle_deg": angle, + "lum_rms": float(lum.std()), + } + + +# thresholds (set on the tuning split; see score050.py). Feature-scale bins +# align with relief_presets.SCALE_BINS fractions. +def bin_amplitude(feat): + # combine glint contrast + hf luminance energy into a relief-strength score + s = feat["spec_contrast"] * 2.2 + feat["hf_lum"] * 3.0 + if s < 0.26: + return "subtle" + if s < 0.55: + return "medium" + return "strong" + + +def bin_scale(feat): + f = feat["peak_scale_frac"] + if f < 0.05: + return "fine" + if f < 0.14: + return "medium" + return "coarse" + + +def classical_knobs(path): + feat = classical_features(path) + return {"amplitude": bin_amplitude(feat), "feature_scale": bin_scale(feat), + "angle_deg": feat["angle_deg"], "features": feat} + + +# --------------------------------------------------- T-derived pseudo normal +def t_pseudo_normal(T, strength=6.0): + """Pseudo-height from the tint map's luminance structure (high-pass), turned + into a normal. For streaky/wispy the relief-relevant structure lives in T; + this is a blend component where texture is visible in T. Returns (normal, + t_structure_score in [0,1]).""" + from scipy.ndimage import gaussian_filter + if T.ndim == 3: + L = 0.299 * T[..., 0] + 0.587 * T[..., 1] + 0.114 * T[..., 2] + else: + L = T + hp = L - gaussian_filter(L, sigma=max(2, L.shape[0] // 64)) + score = float(np.clip(hp.std() * 6.0, 0, 1)) + hp = (hp - hp.min()) / (hp.max() - hp.min() + 1e-8) + nrm = RP.height_to_normal(hp, strength=strength) + return nrm, score + + +# ------------------------------------------------------------------ full detect +def detect(path, use_vlm_knobs=False, model="haiku"): + cat = vlm_category(path, model) + ck = classical_knobs(path) + res = {"category": cat, + "amplitude": ck["amplitude"], "feature_scale": ck["feature_scale"], + "angle_deg": round(ck["angle_deg"], 1) if cat == "ripple" else None, + "features": ck["features"], "source": "vlm-cat+classical-knobs"} + if use_vlm_knobs: + res["vlm_amplitude"] = vlm_amplitude(path, model) + res["vlm_scale"] = vlm_scale(path, model) + return res + + +if __name__ == "__main__": + import sys + for p in sys.argv[1:]: + r = detect(p, use_vlm_knobs=True) + print(os.path.basename(p), "->", r["category"], r["amplitude"], + r["feature_scale"], "| vlm:", r.get("vlm_amplitude"), r.get("vlm_scale")) diff --git a/research/delighting/render050/drive050_ab.py b/research/delighting/render050/drive050_ab.py new file mode 100644 index 0000000..0253ecd --- /dev/null +++ b/research/delighting/render050/drive050_ab.py @@ -0,0 +1,116 @@ +"""050 end-to-end perceptual A/B (folds in the 047b question): render the 047 +window-nudge scene three ways -- GT normal | auto-detected procedural | normal +off -- same T/haze/env otherwise, in the real three.js MeshPhysicalMaterial, via +headless Chrome. Board them side by side. + +The 'auto' normal is generated from the AUTO-DETECTED preset (detection_scores +.json) for that family's photo -> a genuine end-to-end test. 047's fidelity data +says even the GT normal doesn't beat 'off' against Cycles truth, so the claim is +that auto-procedural should look PERCEPTUALLY like GT (both add the same kind of +sparkle) -- test it by eye on the board. +""" +import os, json, subprocess, time, sys +import numpy as np +from PIL import Image, ImageDraw, ImageFont +import relief_presets as RP + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..")) # research/delighting +ASSETS = os.path.join(ROOT, "results", "050", "assets_ms") +OUT = os.path.join(ROOT, "results", "050") +TMP = "/tmp/r050ab" +CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" +PORT = "8050" +FAMILIES = ["cathedral-green__6001", "streaky-mix__6001", "wispy-white__6001"] +AZ = 12 + + +# The A/B isolates the PERCEPTUAL question (does the bank preset tie with GT?) +# from the noisy per-photo fine pick: 'auto' here = the default preset for the +# family's TRUE coarse group, i.e. exactly what the recommended ship path +# (coarse-detect + in-family default, §7) produces WHEN coarse detection +# succeeds. Detection accuracy is quantified separately (§3/§4). +SHIP_PRESET = { + "cathedral-green__6001": ("hammered", "medium", "medium"), # textured default + "streaky-mix__6001": ("ripple", "subtle", "medium"), # streaky default + "wispy-white__6001": ("seedy", "medium", "medium"), # true group default +} + + +def gen_auto_normal(key): + cat, amp, scale = SHIP_PRESET.get(key, ("hammered", "medium", "medium")) + ang = 90.0 if cat == "ripple" else None + nrm, meta = RP.make_normal(cat, size=768, seed=hash(key) % 9999, amplitude=amp, + feature_scale=scale, angle_deg=ang) + Image.fromarray((nrm * 255).astype(np.uint8)).save( + os.path.join(ASSETS, key, "auto_normal.png")) + return cat, amp, scale + + +def render(family, relief, name): + q = (f"abase=/results/050/assets_ms&family={family}&relief={relief}&az={AZ}" + f"&res=512&name={name}") + if relief == "auto": + q += f"&normurl=/results/050/assets_ms/{family}/auto_normal.png" + url = f"http://localhost:{PORT}/render050/model050.html?{q}" + out = os.path.join(TMP, name + ".png") + if os.path.exists(out): + os.remove(out) + subprocess.run([CHROME, "--headless=new", "--window-size=512,512", + "--force-device-scale-factor=1", "--virtual-time-budget=30000", + "--screenshot=/tmp/_s050.png", url], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=90) + for _ in range(40): + if os.path.exists(out) and os.path.getsize(out) > 0: + return out + time.sleep(0.25) + raise RuntimeError("render failed " + name) + + +def main(): + os.makedirs(TMP, exist_ok=True) + srv = subprocess.Popen([sys.executable, os.path.join(HERE, "..", "render047", + "serve047.py"), PORT, TMP]) + time.sleep(2) + try: + detected = {} + renders = {} + for fam in FAMILIES: + detected[fam] = gen_auto_normal(fam) + for relief in ("gt", "auto", "off"): + renders[(fam, relief)] = render(fam, relief, f"{fam}_{relief}") + print("rendered", fam, relief) + finally: + srv.terminate() + + # board: rows=family, cols = [gt | auto | off] renders + cell = 256; pad = 8; top = 24; leftlab = 130 + cols = ["gt", "auto", "off"] + W = leftlab + len(cols) * (cell + pad) + pad + Hh = top + len(FAMILIES) * (cell + pad) + pad + sheet = Image.new("RGB", (W, Hh), (16, 18, 22)) + d = ImageDraw.Draw(sheet) + try: + font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 13) + fsm = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 11) + except Exception: + font = fsm = ImageFont.load_default() + hdr = {"gt": "GT normal", "auto": "procedural preset (ship path)", "off": "normal OFF"} + for j, c in enumerate(cols): + d.text((leftlab + j * (cell + pad) + 4, 6), hdr[c], fill=(180, 188, 200), font=fsm) + for i, fam in enumerate(FAMILIES): + y = top + i * (cell + pad) + pad + cat, amp, scale = detected[fam] + d.text((6, y + 6), fam.split("__")[0], fill=(230, 235, 240), font=font) + d.text((6, y + 28), f"preset={cat}", fill=(140, 200, 255), font=fsm) + d.text((6, y + 44), f"{amp}/{scale}", fill=(150, 160, 172), font=fsm) + for j, c in enumerate(cols): + im = Image.open(renders[(fam, c)]).convert("RGB").resize((cell, cell)) + sheet.paste(im, (leftlab + j * (cell + pad) + pad, y)) + path = os.path.join(OUT, "board_ab_normal.jpg") + sheet.save(path, quality=90) + print("wrote", path, sheet.size) + + +if __name__ == "__main__": + main() diff --git a/research/delighting/render050/exp_category.py b/research/delighting/render050/exp_category.py new file mode 100644 index 0000000..189d635 --- /dev/null +++ b/research/delighting/render050/exp_category.py @@ -0,0 +1,52 @@ +"""Characterise the category-detection ceiling on the HOLDOUT: an improved, +ripple-de-biased prompt, on haiku vs sonnet. Reports fine 6-way + coarse +(flat/isotropic/directional/wavy) accuracy.""" +import os, json, subprocess, sys +HERE = os.path.dirname(os.path.abspath(__file__)) +import detect_relief as D +from score050 import collect, COARSE + +CAT_LETTERS = D.CAT_LETTERS +PROMPT2 = """Read the image file at {path} and look at it closely. It is a photo \ +of one sheet of art glass. Judge ONLY the SURFACE TEXTURE relief, not the colour. \ +Decide FIRST whether the texture is DIRECTIONAL (features clearly line up and run \ +one way -- parallel streaks/reeds/pulls) or ISOTROPIC (features look the same in \ +all directions -- an all-over field of bumps/cells/seeds with no single direction). \ +Only choose ripple if the direction is unmistakable. Then pick exactly one: +A) smooth - flat/float/cast, clean surface, essentially no relief +B) hammered - ISOTROPIC all-over field of small rounded dimples/cells (classic cathedral) +C) granite - ISOTROPIC dense fine sandy/stippled tooth, busier and finer than hammered +D) seedy - ISOTROPIC scattered discrete round bumps/seeds/bubbles on a soft surface +E) ripple - DIRECTIONAL parallel streaks/reeds/pulled lines running one way +F) rolling_wave - large coarse smooth waves/folds, centimetre-scale undulation +Reply with ONLY the single letter (A-F).""" + + +def ask(path, model): + path = os.path.abspath(path) + out = subprocess.run(["claude", "-p", PROMPT2.format(path=path), + "--allowedTools", "Read", "--model", model], + capture_output=True, text=True, timeout=180) + return CAT_LETTERS.get(out.stdout.strip().rstrip(".").upper()[-1:]) + + +def main(): + photos = os.path.abspath(os.path.join(HERE, "..", "results", "050", "photos_v2")) + rows = [r for r in collect(photos) if r["split"] == "holdout"] + for model in sys.argv[1:] or ["sonnet"]: + fine = coarse = 0 + conf = [] + for r in rows: + det = ask(r["file"], model) + ok = det == r["gt_category"] + cok = COARSE[det] == COARSE[r["gt_category"]] + fine += ok; coarse += cok + conf.append((r["gt_category"], det, "OK" if ok else "x")) + n = len(rows) + print(f"\n[{model}] fine {fine}/{n}={fine/n:.2f} coarse {coarse}/{n}={coarse/n:.2f}") + for g, d, s in conf: + print(f" {g:12s} -> {d:12s} {s}") + + +if __name__ == "__main__": + main() diff --git a/research/delighting/render050/gen050_author.py b/research/delighting/render050/gen050_author.py new file mode 100644 index 0000000..e0fe13c --- /dev/null +++ b/research/delighting/render050/gen050_author.py @@ -0,0 +1,59 @@ +"""050 asset authoring (run in Blender). Like gen047_assets but writes one +directory PER (recipe, seed) -> keeps a multi-seed holdout set (gen047_assets +keyed dirs by recipe name only, so seeds overwrote each other). + +Each dir '__' gets T/h/height/normal.npy (verbatim trunk forward +model, gen045_module) + a per-dir entry in families.json carrying the recipe, +seed, bump_distance and the relief CATEGORY (the synthetic ground truth for +detection scoring). + +Run: -b --python gen050_author.py -- --out --specs \ + cathedral-green:6001:hammered,dark-opaque:7001:granite,... --size 768 +""" +import sys, os, argparse, json +sys.path.insert(0, os.path.expanduser('~/.local/lib/python3.11/site-packages')) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'oracle45')) +import numpy as np +import gen045_module as G + + +def parse_args(): + argv = sys.argv[sys.argv.index('--')+1:] if '--' in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument('--out', required=True) + p.add_argument('--specs', required=True, help='recipe:seed:category,...') + p.add_argument('--size', type=int, default=768) + return p.parse_args(argv) + + +def main(): + a = parse_args() + G.GT_OPTS['no_marks'] = True + os.makedirs(a.out, exist_ok=True) + mpath = os.path.join(a.out, 'families.json') + manifest = json.load(open(mpath)) if os.path.exists(mpath) else {} + for spec in a.specs.split(','): + recipe, seed, cat = spec.split(':') + seed = int(seed) + key = f'{recipe}__{seed}' + T, h, md, mw, mi, height, normal, bd = G.author_glass_arrays(recipe, size=a.size, seed=seed) + d = os.path.join(a.out, key); os.makedirs(d, exist_ok=True) + np.save(os.path.join(d, 'T.npy'), T.astype(np.float32)) + np.save(os.path.join(d, 'h.npy'), h.astype(np.float32)) + np.save(os.path.join(d, 'height.npy'), height.astype(np.float32)) + np.save(os.path.join(d, 'normal.npy'), normal.astype(np.float32)) + manifest[key] = { + 'recipe': recipe, 'seed': seed, 'category': cat, 'size': a.size, + 'bump_distance_m': float(bd), + 'T_mean': [round(float(x), 4) for x in T.mean(axis=(0, 1))], + 'h_mean': round(float(h.mean()), 4), + 'opal_scatter': recipe in G.OPAL_SCATTER_RECIPES, + } + print(f"AUTH {key} [{cat}]: bd {bd:.5f} h_mean {h.mean():.2f}") + with open(mpath, 'w') as f: + json.dump(manifest, f, indent=2) + print('WROTE', mpath, len(manifest), 'entries') + + +if __name__ == '__main__': + main() diff --git a/research/delighting/render050/gen050_env.py b/research/delighting/render050/gen050_env.py new file mode 100644 index 0000000..e991898 --- /dev/null +++ b/research/delighting/render050/gen050_env.py @@ -0,0 +1,43 @@ +"""050 environment: a SOFTER, less directional IBL than 047's orbit-demo env. + +047's env has three sharp bright 'window' panels -- perfect for sliding glints +in the orbit demo, but they cast strong DIAGONAL streaks across the sheet that +mask isotropic pebble relief as directional (a detection confound). A photo a +user takes of their sheet is typically diffuse-lit (light table / soft window / +overcast). This env keeps a gentle overhead soft-box (so relief still glints) +but is dominated by diffuse hemispheric fill, so isotropic textures read as +isotropic and directional textures (streaky) still read as directional. +""" +import numpy as np, argparse, os +import sys +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, '..', 'render047')) +from gen047_env import write_hdr + + +def build_env(w=1024, h=512): + yy, xx = np.mgrid[0:h, 0:w] + theta = (yy + 0.5) / h * np.pi + phi = (xx + 0.5) / w * 2 * np.pi - np.pi + up = np.cos(theta) + env = np.zeros((h, w, 3), np.float32) + sky = np.array([0.60, 0.66, 0.74]); ground = np.array([0.52, 0.50, 0.48]) + t = (up * 0.5 + 0.5)[..., None] + env += t * sky + (1 - t) * ground + env *= 0.9 # bright diffuse fill + # one broad, soft overhead key (large sigma -> no sharp streak), gentle + key = np.exp(-((theta - 0.5) ** 2) / (2 * 0.6 ** 2)) + env += key[..., None] * np.array([1.0, 0.99, 0.95]) * 1.1 + # a single soft, wide bright panel high up (soft-box) -> gentle isotropic glint + d = np.exp(-((phi - 0.1) ** 2) / (2 * 0.5 ** 2) - ((theta - 0.5) ** 2) / (2 * 0.4 ** 2)) + env += d[..., None] * np.array([1.0, 0.98, 0.94]) * 2.2 + return env + + +if __name__ == '__main__': + ap = argparse.ArgumentParser(); ap.add_argument('--out', required=True) + a = ap.parse_args() + os.makedirs(os.path.dirname(a.out), exist_ok=True) + env = build_env() + write_hdr(a.out, env) + print(f"wrote {a.out} range {env.min():.3f}..{env.max():.1f} mean {env.mean():.3f}") diff --git a/research/delighting/render050/gen050_photos.py b/research/delighting/render050/gen050_photos.py new file mode 100644 index 0000000..dd4048f --- /dev/null +++ b/research/delighting/render050/gen050_photos.py @@ -0,0 +1,105 @@ +"""050 synthetic 'user photo' renderer: a front-lit displaced glass slab under +the shared IBL environment, filling the frame like a photo of a sheet. This is +the RELIEF-SHOWING render (report 047: relief reads as front-surface glints / +shading under front light, invisible in the 045/046 backlit rig) -- the right +synthetic photo for validating relief auto-detection. + +Reuses gen047_truth's volumetric-slab construction (real 3 mm slab displaced by +gt_height, Beer-Lambert volume absorption from gt_T, roughness from gt_h) but: + - one camera angle (a slight tilt so relief glints rake), + - the slab fills the frame (no orbit), + - optional --flatten zeroes the displacement -> a 'smooth' category sample. + +Run: -b --python gen050_photos.py -- --assets \ + --keys cathedral-green__6001,... --res 448 --spp 48 --out +""" +import sys, os, argparse, json, math +sys.path.insert(0, os.path.expanduser('~/.local/lib/python3.11/site-packages')) +import numpy as np, bpy + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, '..', 'render047')) +import gen047_truth as GT # reuse slab/material/env builders + + +def make_soft_backdrop(): + """A soft, near-uniform luminous field behind the sheet (like a light table + / softbox / bright wall) -- NO mullions/checker/sun. This isolates the + glass's OWN surface relief as the dominant structure in the photo (a real + sheet photo, not the 047 window-refraction scene), which is what detection + reads. Faint large-scale gradient only, so relief still produces glints and + gentle lensing distortion.""" + n = 512 + yy, xx = np.mgrid[0:n, 0:n].astype(np.float32) / n + base = 1.15 - 0.25 * yy # gentle top->bottom falloff + glow = np.exp(-(((xx - 0.4) ** 2 + (yy - 0.35) ** 2) / (2 * 0.32 ** 2))) + val = base + 0.35 * glow # one very soft bright region + warm = np.stack([val * 1.0, val * 0.99, val * 0.96], -1) + bpy.ops.mesh.primitive_plane_add(size=1.9, location=(0, 0, -0.5)) + ob = bpy.context.active_object; ob.name = 'SoftBackdrop' + mat = bpy.data.materials.new('SBD'); mat.use_nodes = True + nd = mat.node_tree.nodes; lk = mat.node_tree.links; nd.clear() + out = nd.new('ShaderNodeOutputMaterial'); em = nd.new('ShaderNodeEmission') + tex = nd.new('ShaderNodeTexImage') + img = GT.img_from_np('softbd', warm.astype(np.float32), 'Non-Color') + tex.image = img + em.inputs['Strength'].default_value = 1.0 + lk.new(tex.outputs['Color'], em.inputs['Color']) + lk.new(em.outputs['Emission'], out.inputs['Surface']) + ob.data.materials.append(mat) + return ob + + +def parse(): + argv = sys.argv[sys.argv.index('--')+1:] if '--' in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument('--assets', required=True) + p.add_argument('--keys', required=True, help='asset dir keys, comma-sep') + p.add_argument('--res', type=int, default=448) + p.add_argument('--spp', type=int, default=48) + p.add_argument('--az', type=float, default=12.0) + p.add_argument('--elev', type=float, default=10.0) + p.add_argument('--fov', type=float, default=30.0) + p.add_argument('--dist', type=float, default=0.82) + p.add_argument('--flatten', action='store_true', + help="zero displacement -> a 'smooth' sample") + p.add_argument('--soft', action='store_true', + help="soft uniform luminous backdrop (isolate relief)") + p.add_argument('--env', default='env.hdr', help="env hdr filename in assets") + p.add_argument('--suffix', default='') + p.add_argument('--out', required=True) + return p.parse_args(argv) + + +def render_one(assets, key, a, outdir): + d = os.path.join(assets, key) + T = np.load(os.path.join(d, 'T.npy')) + h = np.load(os.path.join(d, 'h.npy')) + height = np.load(os.path.join(d, 'height.npy')) + manifest = json.load(open(os.path.join(assets, 'families.json'))) + disp_amp = 0.0 if a.flatten else manifest[key]['bump_distance_m'] + + GT.clear() + GT.setup_render(a.res, a.spp) + GT.world_env(os.path.join(assets, a.env)) + mat = GT.glass_material(T, h) + ob = GT.make_slab(height, disp_amp, mat) + bd = make_soft_backdrop() if a.soft else GT.make_backdrop(assets, a.res) + GT.group_rotate([ob, bd], a.az, a.elev) + GT.fixed_cam(a.dist, a.fov) + name = f'photo_{key}{a.suffix}.png' + GT.render_to(os.path.join(outdir, name)) + print('RENDERED', name, 'disp', disp_amp) + + +def main(): + a = parse() + outdir = os.path.join(a.out) + os.makedirs(outdir, exist_ok=True) + for key in a.keys.split(','): + render_one(a.assets, key, a, outdir) + print('DONE', a.keys) + + +if __name__ == '__main__': + main() diff --git a/research/delighting/render050/model050.html b/research/delighting/render050/model050.html new file mode 100644 index 0000000..e5a4503 --- /dev/null +++ b/research/delighting/render050/model050.html @@ -0,0 +1,65 @@ + + + + + + diff --git a/research/delighting/render050/preview_bank.py b/research/delighting/render050/preview_bank.py new file mode 100644 index 0000000..dfcb514 --- /dev/null +++ b/research/delighting/render050/preview_bank.py @@ -0,0 +1,76 @@ +"""Render a contact sheet of the relief preset bank: for each category, show +the height field, the tangent normal map, and a raking-light shaded preview +(what the relief's glint/shading structure looks like in a photo). Also draws +a 2x2 tile of each height to confirm seamless tiling.""" +import os, numpy as np +from PIL import Image, ImageDraw, ImageFont +import relief_presets as RP + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.abspath(os.path.join(HERE, "..", "results", "050")) +os.makedirs(OUT, exist_ok=True) +N = 256 + + +def shade(height, normal, light=(-0.5, -0.6, 0.62)): + """Simple raking-light + specular preview of the relief surface.""" + L = np.array(light); L = L / np.linalg.norm(L) + nvec = normal.astype(np.float64) * 2 - 1 + nvec /= np.linalg.norm(nvec, axis=-1, keepdims=True) + 1e-8 + diff = np.clip((nvec * L).sum(-1), 0, 1) + V = np.array([0, 0, 1.0]) + H = (L + V); H /= np.linalg.norm(H) + spec = np.clip((nvec * H).sum(-1), 0, 1) ** 40 + img = 0.30 + 0.55 * diff + 0.9 * spec + return np.clip(img, 0, 1) + + +def to_img(arr): + if arr.ndim == 2: + arr = np.repeat(arr[..., None], 3, 2) + return Image.fromarray((np.clip(arr, 0, 1) * 255).astype(np.uint8)) + + +def tile2x2(arr): + return np.block([[arr, arr], [arr, arr]]) + + +def main(): + cats = RP.CATEGORIES + cell = N + cols = 4 # height | normal | shaded | 2x2 tiled height + labels = ["height", "normal", "raking-light shade", "2x2 tile (seamless)"] + pad, top, left = 8, 26, 130 + W = left + cols * (cell + pad) + pad + Hh = top + len(cats) * (cell + pad) + pad + sheet = Image.new("RGB", (W, Hh), (18, 20, 24)) + d = ImageDraw.Draw(sheet) + try: + font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 13) + fsm = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 11) + except Exception: + font = fsm = ImageFont.load_default() + for j, lab in enumerate(labels): + d.text((left + j * (cell + pad) + 4, 6), lab, fill=(180, 188, 200), font=fsm) + for i, c in enumerate(cats): + y = top + i * (cell + pad) + pad + p = RP.PRESETS[c] + d.text((6, y + 4), c, fill=(230, 235, 240), font=font) + d.text((6, y + 24), p["amp_default"] + "/" + p["scale_default"], + fill=(150, 160, 172), font=fsm) + h, amp = RP.make_height(c, size=N, seed=11) + n, meta = RP.make_normal(c, size=N, seed=11) + sh = shade(h, n) + htile = tile2x2(h)[:N, :N] # crop back to cell but show the seam region + # show the tiled version downsized to reveal the seam continuity + htile_full = Image.fromarray((tile2x2(h) * 255).astype(np.uint8)).resize((N, N)) + panels = [to_img(h), to_img(n), to_img(sh), htile_full.convert("RGB")] + for j, pn in enumerate(panels): + sheet.paste(pn.resize((cell, cell)), (left + j * (cell + pad) + pad, y)) + path = os.path.join(OUT, "board_preset_bank.jpg") + sheet.save(path, quality=90) + print("wrote", path, sheet.size) + + +if __name__ == "__main__": + main() diff --git a/research/delighting/render050/relief_presets.py b/research/delighting/render050/relief_presets.py new file mode 100644 index 0000000..481f27d --- /dev/null +++ b/research/delighting/render050/relief_presets.py @@ -0,0 +1,249 @@ +"""050 relief preset bank -- a small, procedural, TILEABLE surface-relief +normal-map generator for the glass preview. + +Context (report 047): the relief normal is NOT a fidelity target -- even the +ground-truth normal worsens the match to Cycles truth, because three.js +screen-space refraction turns relief into high-frequency sparkle, not smooth +lensing. But the CTO eye-tested the sparkle and wants it kept AS A PRESENTATION +EFFECT, with the hard requirement that the category + settings be AUTO-DETECTED +from one user photo (report 050's job), never hand-tuned by the end user. + +This module is the "bank" side: given a category + <=3 knobs, synthesise a +deterministic, cheap, tileable height field and its tangent-space normal map. +Detection (detect_relief.py) picks the category + knob bins from a photo. + +TAXONOMY GROUNDING. The category list and their relief statistics are grounded +in (a) the real sheet-glass corpus classes and (b) our own generator's authored +relief families (generate_synthetic.generate_relief_height), which are the free +synthetic ground truth: + + preset <- generator recipe family (bump_distance range, m) + ------------------------------------------------------------------------------ + smooth <- (float/cast glass; ~flat) ~0 + hammered <- cathedral-green/amber/blue/red 0.0016 - 0.0045 + granite <- dark-opaque/deep/ruby/slate/... 0.0010 - 0.0030 + seedy <- wispy-white/opalescent/confetti 0.0008 - 0.0025 (+micro_events) + ripple <- streaky-mix/fine/fracture-streamer 0.00015 - 0.0007 (anisotropic) + rolling_wave <- baroque-rolling-wave 0.006 - 0.014 (coarsest) + +Each preset = category + up to 3 knobs (amplitude, feature_scale, [angle]). +Knobs are exposed to detection as BINS (subtle/medium/strong x fine/medium/coarse). + +Tileability: all noise is band-limited in the Fourier domain (periodic by +construction) and discrete stamps wrap around edges, so the resulting height / +normal tile seamlessly -- required for a cheap repeating preview material. +""" +from __future__ import annotations +import numpy as np + +# ------------------------------------------------------------------ knob bins +# amplitude bin -> relief height scale (unitless height 0..1 std multiplier; +# maps to the material's normalScale / displacement amplitude). +AMP_BINS = {"subtle": 0.35, "medium": 0.65, "strong": 1.0} +# feature_scale bin -> characteristic feature size as a FRACTION of the tile. +# smaller fraction = finer/denser features. +SCALE_BINS = {"fine": 0.045, "medium": 0.11, "coarse": 0.26} + +# Per-category defaults + which bins are meaningful. Values chosen so a +# category rendered at its default bins reproduces the look of its generator +# family (verified by eye against generate_relief_height octave mix). +PRESETS = { + "smooth": { + "desc": "float / cast sheet -- essentially flat, only faint sub-mm imperfection", + "amp_default": "subtle", "scale_default": "fine", + "amp_floor": 0.06, # even 'strong' smooth is nearly flat + "knobs": ["amplitude"], + }, + "hammered": { + "desc": "isotropic mm-scale pebbled cells (classic cathedral glass)", + "amp_default": "medium", "scale_default": "medium", + "knobs": ["amplitude", "feature_scale"], + }, + "granite": { + "desc": "dense fine stipple / granite tooth (rolled dark & textured sheet)", + "amp_default": "medium", "scale_default": "fine", + "knobs": ["amplitude", "feature_scale"], + }, + "seedy": { + "desc": "sparse discrete round bumps / seeds / small bubbles on a soft ground", + "amp_default": "medium", "scale_default": "medium", + "knobs": ["amplitude", "feature_scale"], + }, + "ripple": { + "desc": "directional pulled / reeded streaks (streaky & waterglass sheet)", + "amp_default": "subtle", "scale_default": "medium", + "knobs": ["amplitude", "feature_scale", "angle"], + }, + "rolling_wave": { + "desc": "coarse cm-scale rolling waves (baroque / hammered-coarse sheet)", + "amp_default": "strong", "scale_default": "coarse", + "knobs": ["amplitude", "feature_scale"], + }, +} +CATEGORIES = list(PRESETS.keys()) + + +# ------------------------------------------------------------ tileable noise +def _radial_freq(n): + fy = np.fft.fftfreq(n)[:, None] + fx = np.fft.fftfreq(n)[None, :] + return fy, fx, np.sqrt(fy * fy + fx * fx) + + +def band_noise(n, seed, feat_frac, octaves=3, aniso=1.0, angle_deg=0.0, + lacunarity=2.2, persistence=0.55): + """Periodic (tileable) band-limited fractal noise in [0,1]. + + feat_frac : dominant feature size as a fraction of the tile (0..0.5). + center frequency f0 = 1/(feat_frac*n) cycles/pixel. + aniso : >1 stretches features ALONG `angle_deg` (produces streaks). + """ + rng = np.random.default_rng(seed) + white = rng.standard_normal((n, n)) + F = np.fft.fft2(white) + fy, fx, _ = _radial_freq(n) + # rotate the frequency axes so anisotropy aligns with angle_deg + a = np.radians(angle_deg) + fu = fx * np.cos(a) + fy * np.sin(a) # along streak + fv = -fx * np.sin(a) + fy * np.cos(a) # across streak + # anisotropic radial metric: squeeze the along-streak axis so only + # cross-streak frequencies survive -> long features along the streak. + fr = np.sqrt((fu / aniso) ** 2 + (fv * aniso) ** 2) + f0 = 1.0 / max(feat_frac * n, 1.0) # cycles/pixel + env = np.zeros((n, n)) + amp, fc = 1.0, f0 + for _ in range(octaves): + # log-normal bandpass around fc + with np.errstate(divide="ignore"): + lr = np.log((fr + 1e-9) / fc) + env += amp * np.exp(-(lr ** 2) / (2 * 0.5 ** 2)) + amp *= persistence + fc *= lacunarity + env[0, 0] = 0.0 + out = np.real(np.fft.ifft2(F * env)) + out -= out.min() + mx = out.max() + if mx > 1e-9: + out /= mx + return out + + +def _stamp_seeds(n, seed, feat_frac, density, amp): + """Discrete round bump/seed events, wrapped for tileability.""" + rng = np.random.default_rng(seed + 991) + h = np.zeros((n, n)) + r = max(2.0, feat_frac * n * 0.6) + ncells = int(density * (n / (2 * r)) ** 2) + yy, xx = np.mgrid[0:n, 0:n] + for _ in range(max(1, ncells)): + cy, cx = rng.uniform(0, n, size=2) + rr = r * rng.uniform(0.6, 1.4) + # nearest wrapped distance + dy = np.minimum(np.abs(yy - cy), n - np.abs(yy - cy)) + dx = np.minimum(np.abs(xx - cx), n - np.abs(xx - cx)) + d2 = (dy * dy + dx * dx) / (rr * rr) + # raised bump with a slightly sunken rim (seed refractive profile) + prof = np.exp(-d2) - 0.35 * np.exp(-d2 / 3.0) + h += prof * rng.uniform(0.7, 1.0) + h -= h.min() + mx = h.max() + if mx > 1e-9: + h /= mx + return h * amp + + +# ------------------------------------------------------------ height per preset +def make_height(category, size=512, seed=0, amplitude=None, feature_scale=None, + angle_deg=None): + """Return (height[0..1] float32, amp01 float) for a preset + knob bins. + + amplitude/feature_scale accept a bin name (str) or None -> preset default. + amp01 is the resolved height amplitude (for normalScale / displacement). + """ + if category not in PRESETS: + raise ValueError(f"unknown category {category!r}; choices {CATEGORIES}") + p = PRESETS[category] + amp_bin = amplitude or p["amp_default"] + scale_bin = feature_scale or p["scale_default"] + amp01 = AMP_BINS[amp_bin] + feat = SCALE_BINS[scale_bin] + + if category == "smooth": + # faint fine imperfection only; amplitude floored very low + h = band_noise(size, seed, feat_frac=0.03, octaves=2) + amp01 = max(p["amp_floor"], amp01 * 0.12) + + elif category == "hammered": + # isotropic multi-octave pebble; mirrors 0.52 fine + 0.34 mid + 0.14 broad + h = band_noise(size, seed, feat_frac=feat, octaves=4, + persistence=0.55, lacunarity=2.4) + + elif category == "granite": + # denser + finer than hammered: more octaves, slower falloff + h = 0.65 * band_noise(size, seed, feat_frac=feat, octaves=4, + persistence=0.62, lacunarity=2.6) + h += 0.35 * band_noise(size, seed + 3, feat_frac=feat * 0.6, octaves=2) + + elif category == "seedy": + ground = 0.5 * band_noise(size, seed, feat_frac=max(feat, 0.14), octaves=3) + seeds = _stamp_seeds(size, seed, feat_frac=feat, density=1.0, amp=1.0) + h = 0.45 * ground + 0.55 * seeds + + elif category == "ripple": + ang = 90.0 if angle_deg is None else float(angle_deg) + h = band_noise(size, seed, feat_frac=feat, octaves=3, aniso=4.5, + angle_deg=ang, persistence=0.6, lacunarity=2.2) + + elif category == "rolling_wave": + # coarse, smooth, low octave count (like baroque-rolling-wave) + h = 0.75 * band_noise(size, seed, feat_frac=max(feat, 0.22), octaves=2, + persistence=0.65, lacunarity=2.0) + h += 0.25 * band_noise(size, seed + 5, feat_frac=max(feat, 0.16), octaves=1) + + h = h - h.min() + mx = h.max() + if mx > 1e-9: + h = h / mx + return h.astype(np.float32), float(amp01) + + +def height_to_normal(height, strength=1.0): + """Tangent-space RGB normal from a scalar height field. + BYTE-COMPATIBLE with generate_synthetic.height_to_normal so procedural and + GT normals feed the material identically.""" + gy, gx = np.gradient(height.astype(np.float64)) + nx = -gx * strength + ny = -gy * strength + nz = np.ones_like(height, dtype=np.float64) + n = np.stack([nx, ny, nz], axis=-1) + n /= np.linalg.norm(n, axis=-1, keepdims=True) + 1e-8 + return (n * 0.5 + 0.5).astype(np.float32) + + +def make_normal(category, size=512, seed=0, amplitude=None, feature_scale=None, + angle_deg=None, normal_strength=None): + """Convenience: height -> normal map (HxWx3 [0,1] float32) for a preset. + normal_strength scales the gradient; if None, derived from amplitude bin so + 'strong' presets bump harder. Returns (normal, meta).""" + h, amp01 = make_height(category, size, seed, amplitude, feature_scale, angle_deg) + strength = (normal_strength if normal_strength is not None + else 4.0 * amp01 * size / 512.0) + nrm = height_to_normal(h, strength=strength) + meta = {"category": category, "size": size, "seed": seed, + "amplitude": amplitude or PRESETS[category]["amp_default"], + "feature_scale": feature_scale or PRESETS[category]["scale_default"], + "angle_deg": angle_deg, "amp01": amp01, "normal_strength": strength} + return nrm, meta + + +if __name__ == "__main__": + import json, sys, os + # smoke test / preview grid + out = sys.argv[1] if len(sys.argv) > 1 else None + for c in CATEGORIES: + h, amp = make_height(c, size=256, seed=7) + n, meta = make_normal(c, size=256, seed=7) + print(f"{c:14s} amp01={amp:.2f} h[{h.min():.2f},{h.max():.2f}] " + f"grad_rms={np.sqrt(np.mean(np.gradient(h)[0]**2)):.4f}") + print(json.dumps({"categories": CATEGORIES, + "amp_bins": AMP_BINS, "scale_bins": SCALE_BINS}, indent=2)) diff --git a/research/delighting/render050/score050.py b/research/delighting/render050/score050.py new file mode 100644 index 0000000..20a300d --- /dev/null +++ b/research/delighting/render050/score050.py @@ -0,0 +1,123 @@ +"""050 synthetic validation: score relief auto-detection against the generator's +authored relief (the free synthetic ground truth). + +GT category = the relief family the recipe belongs to (families.json 'category', + grounded in generate_relief_height's shared-relief groupings). +GT amplitude = binned bump_distance_m (the authored relief height in metres). +GT scale = the recipe family's dominant relief feature scale (category-tied + in the generator -> flagged as a limitation). + +Holdout discipline: seed 6001 = TUNING (used while shaping prompts/thresholds), +seeds 7001/7002 = HOLDOUT (never used for tuning). Headline accuracy is HOLDOUT. +""" +import os, json, sys, argparse +import numpy as np +import detect_relief as D + +HERE = os.path.dirname(os.path.abspath(__file__)) +ASSETS = os.path.abspath(os.path.join(HERE, "..", "results", "050", "assets_ms")) +OUT = os.path.abspath(os.path.join(HERE, "..", "results", "050")) + +# GT scale per relief family (generator's dominant relief feature scale) +GT_SCALE = {"smooth": "fine", "hammered": "medium", "granite": "fine", + "seedy": "medium", "ripple": "medium", "rolling_wave": "coarse"} + +# product-facing coarse taxonomy: the 6 fine categories collapse to 4 groups +# that map to distinct presentation looks (flat / all-over texture / directional +# streaks / coarse waves). Fine hammered<->granite<->seedy differences are the +# hardest to see in a photo and matter least to the effect. +COARSE = {"smooth": "flat", "hammered": "isotropic", "granite": "isotropic", + "seedy": "isotropic", "ripple": "directional", "rolling_wave": "wavy"} + + +def gt_amplitude(bd): + if bd < 0.0009: + return "subtle" + if bd < 0.005: + return "medium" + return "strong" + + +def collect(PHOTOS): + fam = json.load(open(os.path.join(ASSETS, "families.json"))) + rows = [] + for f in sorted(os.listdir(PHOTOS)): + if not f.startswith("photo_") or not f.endswith(".png"): + continue + stem = f[len("photo_"):-4] + smooth = stem.endswith("_smooth") + key = stem[:-len("_smooth")] if smooth else stem + meta = fam[key] + cat = "smooth" if smooth else meta["category"] + bd = 0.0 if smooth else meta["bump_distance_m"] + seed = meta["seed"] + rows.append({"file": os.path.join(PHOTOS, f), "key": key, "seed": seed, + "split": "tune" if seed == 6001 else "holdout", + "gt_category": cat, "gt_amplitude": gt_amplitude(bd), + "gt_scale": GT_SCALE[cat], "bd": bd}) + return rows + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--vlm-knobs-split", default="holdout", + help="run VLM amp/scale on this split (all|holdout|none)") + ap.add_argument("--model", default="haiku") + ap.add_argument("--photos", default=os.path.abspath( + os.path.join(HERE, "..", "results", "050", "photos_v2"))) + a = ap.parse_args() + rows = collect(a.photos) + for r in rows: + want_vlm = a.vlm_knobs_split == "all" or ( + a.vlm_knobs_split == "holdout" and r["split"] == "holdout") + res = D.detect(r["file"], use_vlm_knobs=want_vlm, model=a.model) + r["det_category"] = res["category"] + r["gt_coarse"] = COARSE[r["gt_category"]] + r["det_coarse"] = COARSE[res["category"]] + r["cls_amplitude"] = res["amplitude"] + r["cls_scale"] = res["feature_scale"] + r["vlm_amplitude"] = res.get("vlm_amplitude") + r["vlm_scale"] = res.get("vlm_scale") + r["feat"] = {k: round(v, 4) for k, v in res["features"].items()} + print(f"{r['key']:28s} [{r['split']:7s}] gt={r['gt_category']:12s} " + f"det={r['det_category']:12s} {'OK' if r['det_category']==r['gt_category'] else 'X '} " + f"amp gt={r['gt_amplitude']:6s} cls={r['cls_amplitude']:6s} " + f"vlm={r['vlm_amplitude']}") + + def acc(rs, gk, dk): + rs = [r for r in rs if r.get(dk) is not None] + if not rs: + return None + return round(sum(r[gk] == r[dk] for r in rs) / len(rs), 3), len(rs) + + def block(rs, label): + return { + "n": len(rs), + "category_acc": acc(rs, "gt_category", "det_category"), + "coarse_acc": acc(rs, "gt_coarse", "det_coarse"), + "cls_amp_acc": acc(rs, "gt_amplitude", "cls_amplitude"), + "cls_scale_acc": acc(rs, "gt_scale", "cls_scale"), + "vlm_amp_acc": acc(rs, "gt_amplitude", "vlm_amplitude"), + "vlm_scale_acc": acc(rs, "gt_scale", "vlm_scale"), + } + + hold = [r for r in rows if r["split"] == "holdout"] + tune = [r for r in rows if r["split"] == "tune"] + # confusion for category (holdout) + cats = D.RP.CATEGORIES + conf = {g: {d: 0 for d in cats} for g in cats} + for r in hold: + conf[r["gt_category"]][r["det_category"]] += 1 + summary = {"holdout": block(hold, "holdout"), "tune": block(tune, "tune"), + "all": block(rows, "all"), "category_confusion_holdout": conf} + print("\n=== SUMMARY ===") + print(json.dumps(summary, indent=2)) + json.dump({"summary": summary, "rows": [ + {k: v for k, v in r.items() if k != "feat"} for r in rows], + "features": {r["key"] + ("_smooth" if r["gt_category"] == "smooth" else ""): r["feat"] for r in rows}}, + open(os.path.join(OUT, "detection_scores.json"), "w"), indent=2) + print("wrote", os.path.join(OUT, "detection_scores.json")) + + +if __name__ == "__main__": + main() diff --git a/research/delighting/reports/050-auto-relief-presets.md b/research/delighting/reports/050-auto-relief-presets.md new file mode 100644 index 0000000..f9eef62 --- /dev/null +++ b/research/delighting/reports/050-auto-relief-presets.md @@ -0,0 +1,284 @@ +# 050 — Auto-detected procedural relief presets from one photo + +Date: 2026-07-16. Branch `research/delighting-050-auto-relief` (off +`research/delighting` @ `4898d70`). Follow-on to 047 (volumetric three.js glass) +and the 045/046 material-model line. + +**The decision this report serves.** 047 proved the relief normal map is NOT a +learned fidelity target — feeding even the *ground-truth* normal to three.js +screen-space refraction turns relief into high-frequency sparkle, not the smooth +large-scale lensing a real slab does, and it *worsens* the match to Cycles truth +(SSIM 0.834 normal-off → 0.748 normal-on; §047.4). But the CTO eye-tested the +047 orbit demo **with that sparkle on** and loved it ("this looks like glass to +me"). So the normal becomes a **presentation-layer effect** — procedural and/or +derived — with one hard product requirement, verbatim: + +> "I'd still like the category + settings (e.g. hammered, small bumps) to be +> automatically detected from the user photo, not as something where my user has +> to try 8 different settings until they find something that matches their real +> glass." + +This report builds (1) a small procedural relief **preset bank**, (2) an +**auto-detector** that picks a preset from one raw photo, (3) a **synthetic +quantitative validation** against the generator's authored relief, (4) a **real +qualitative board**, and (5) an **end-to-end perceptual A/B** in the real +three.js material. Code: `research/delighting/render050/`. Boards/JSON: +`results/050/`. Raw renders are gitignored (deterministically regenerable). + +## 0. TL;DR + +- **Preset bank shipped: 6 categories × ≤3 knobs, procedural, tileable, cheap, + deterministic** — `smooth, hammered, granite, seedy, ripple, rolling_wave`, + each `(amplitude, feature_scale[, angle])`. The taxonomy is grounded 1:1 in the + generator's own relief families (`generate_relief_height`) and the real corpus + classes, not invented. Band-limited Fourier noise + wrapped seed-stamps → the + height/normal tile seamlessly. §2, `board_preset_bank.jpg`. +- **The honest headline: from ONE photo, the coarse relief *character* is + weakly detectable and the fine sub-label + numeric knobs are not.** VLM + multiple-choice category (the lab's validated pattern, haiku, direction-first + prompt) on a synthetic **holdout** lands **fine 6-way ≈ 0.50–0.58 and coarse + 4-way ≈ 0.50–0.67 across two runs** (chance 0.17 / 0.25). The **only categories + reliable across both runs are `smooth` and `ripple` (2/2 each)**; the + isotropic-pebble trio (hammered↔granite↔seedy) is unstable and its errors even + cross coarse boundaries. §3, §4. +- **VLM category is stochastic run-to-run.** Two identical-config runs on the + same 12 holdout photos gave fine 0.50 and 0.58 (coarse 0.50 and 0.67) — a + ~±2/12 wobble. So treat these as ~0.5, not a precise figure. This stochasticity + is itself a reason not to hang a fidelity-critical decision on the per-photo + fine pick. §3, §4. +- **This is NOT a model-capability ceiling — it is genuine single-photo + ambiguity.** A stronger model (sonnet) with the *same* prompt did **worse** + (fine 0.33 / coarse 0.42); it collapses uncertain textures to `smooth`. And our + own generator authors hammered/granite/seedy as three variations on the *same* + isotropic-hammered base (`generate_relief_height` shared-relief grouping) — so + they *are* near-identical surfaces; asking a photo to separate them asks for a + distinction that barely exists. §3, §4. +- **Knob detection (amplitude/feature-scale bins) is weak from one photo, by + both channels.** Classical statistics: amplitude-bin ≈ 0.42, scale-bin ≈ 0.17. + VLM multiple-choice bins: amplitude ≈ 0.50, scale ≈ 0.42. Neither is ship-grade; + the VLM amplitude bin is the least-bad. §3. +- **A T-derived pseudo-normal is a useful blend component exactly where the brief + predicted** — streaky/wispy, where the relevant structure lives in the tint T, + not in front-surface glints — and is near-noise on clear cathedral. §3. +- **End-to-end A/B (the 047b question) is the good news.** Rendered in the real + three.js material three ways — GT normal | procedural preset | off — the + **procedural bank preset is perceptually GT-like** (both read as "textured + glass"; both clearly ≠ the flat "off" panel), clearest on cathedral. So 047's + "even GT doesn't beat off against truth" + this "preset ≈ GT perceptually" + means the effect is **robust to which textured preset you pick** — the whole + game is getting the *coarse* character right, and the fine label / exact knob + are low-stakes. §6, `board_ab_normal.jpg`. +- **Recommendation: ship auto-detection only for the reliably-separable axis** + (is it flat? is it directional/streaky?), default everything else to a single + "textured" preset (`hammered`, medium knobs), and add the T-pseudo-normal blend + on streaky/wispy. That honours the CTO requirement — the user never tries 8 + settings — while being honest that one photo does not support a reliable 6-way + + numeric-knob pick. §7. + +## 1. Method + +**Preset bank** (`relief_presets.py`, pure numpy). Categories + knobs below; +height synthesised as band-limited (periodic → tileable) Fourier noise, with +discrete wrapped seed-stamps for `seedy` and an anisotropic frequency envelope +for `ripple`. `height_to_normal` is byte-compatible with the trunk generator's +so procedural and GT normals feed the material identically. + +**Detection** (`detect_relief.py`). *Category*: `claude` CLI as subprocess, +multiple-choice over the 6 categories with an explicit option list (the lab's +validated VLM-as-classifier pattern; `vlm_classify.py` lineage). *Knobs*: +classical statistics on the photo (luminance HF energy + specular glint contrast +→ amplitude; radial-PSD dominant wavelength → feature-scale; structure-tensor +orientation → ripple angle) **and** VLM multiple-choice bins, computed side by +side so we can ship the more reliable. *T-pseudo-normal*: high-pass of the tint +map's luminance → pseudo-height → normal, plus a `t_structure` score. + +**Synthetic ground truth** (`gen050_author.py`, `gen050_photos.py`). The +generator authors a known relief per recipe; we render each recipe/seed as a +**front-lit "user photo"** — a real 3 mm volumetric slab (047's construction: +displaced by `gt_height`, Beer-Lambert volume from `gt_T`, roughness from +`gt_h`) under a **soft, diffuse IBL** (`gen050_env.py` — 047's three sharp window +panels cast diagonal streaks that masked isotropic pebble as directional; a +diffuse light is both fairer and more like a real sheet photo) and a soft +luminous backdrop, so the glass's *own* surface relief reads as glints/shading +(047: relief is invisible in the 045/046 backlit rig; it needs front light). 15 +assets across 5 relief-bearing categories × up to 3 seeds + 3 flattened `smooth` +samples. **Holdout discipline: seed 6001 = tuning, seeds 7001/7002 = holdout; +headline numbers are holdout.** `score050.py` scores category + knob bins. + +**Real board** (`board_real.py`): detection on the curated corpus/wild sheet +photos in `reports/assets_029/` + the CTO's difficult sheet. **A/B** +(`model050.html` + `drive050_ab.py`): the 047 window-nudge scene in real three.js +`MeshPhysicalMaterial`, headless-Chrome rendered with GT / procedural / off +normals. + +## 2. The preset bank — grounded in our own relief taxonomy + +`board_preset_bank.jpg` (height | normal | raking-light shade | 2×2 tile). Each +preset = a relief **category** + up to 3 **knobs** (`amplitude`, +`feature_scale`, and `angle` for ripple only); amplitude/feature-scale are +exposed to detection as **bins** (subtle/medium/strong × fine/medium/coarse). + +The category list is not invented — it is the set of relief families the trunk +generator already authors in `generate_relief_height`, themselves grounded +(003–037) in the real sheet-glass corpus: + +| preset | generator relief family | authored bump range | character | +|---|---|---|---| +| `smooth` | (float/cast; ~flat) | ~0 | flat, faint sub-mm only | +| `hammered` | cathedral-green/amber/blue/red | 1.6–4.5 mm | isotropic pebbled cells | +| `granite` | dark-opaque/deep/ruby/slate/textured, ring-mottle | 1.0–3.0 mm | dense fine stipple | +| `seedy` | wispy-white/opalescent/confetti (+`micro_events`) | 0.8–2.5 mm | discrete round bumps/seeds | +| `ripple` | streaky-mix/fine, fracture-streamer | 0.15–0.7 mm | directional pulled streaks | +| `rolling_wave` | baroque-rolling-wave | 6–14 mm | coarse cm-scale waves | + +Note the generator itself **groups** hammered/granite/seedy as variations on one +isotropic-hammered base (`0.65·hammered+0.35·noise`, `0.50·hammered+0.50·noise`) +— they differ mostly in a secondary octave and in colour/haze, not in a distinct +surface finish. This is the taxonomic reason detection cannot cleanly separate +them (§4), and the product reason it need not. + +## 3. Detection: what one photo supports + +**Category (VLM multiple-choice).** The winning configuration is **haiku with a +direction-first prompt** ("decide DIRECTIONAL vs ISOTROPIC first; only choose +ripple if the direction is unmistakable"). Two things justify it: + +| config | fine 6-way (holdout) | coarse 4-way (holdout) | +|---|---|---| +| haiku, flat 6-option prompt | 0.50 | 0.58 | +| haiku, direction-first, run A | 0.58 | 0.67 | +| haiku, direction-first, run B (persisted) | 0.50 | 0.50 | +| sonnet, direction-first | 0.33 | 0.42 | + +The sonnet row is the important one: **a bigger model is worse**, so category +detection is limited by photo ambiguity, not model capacity — spend the cheap +model. The run-A/run-B spread is the stochasticity (§0). (Chance = 0.17 fine / +0.25 coarse.) + +**Knobs.** Both channels are weak, on the holdout (persisted run): + +| knob | classical | VLM MC | +|---|---|---| +| amplitude bin | 0.42 | **0.50** | +| feature-scale bin | 0.17 | 0.42 | + +The VLM amplitude bin is the least-bad and is what we would ship if forced; +neither is reliable enough to expose as a fidelity-critical setting. +Feature-scale is additionally category-tied in the generator, so its low number +partly reflects the confound, not only detector error. + +**T-derived pseudo-normal.** The `t_structure` score cleanly separates the +families whose relief-relevant structure lives in the tint (streaky/wispy, high +score) from clear cathedral (near-zero) — matching the brief's prediction. It is +therefore the correct blend component to add *only* on the directional/scatter +families, not on clear glass. + +## 4. Synthetic validation — the confusion is informative + +`board_synth_photos.jpg` shows the 18 front-lit slab photos. Holdout category +confusion (persisted direction-first haiku run): + +``` +smooth -> smooth x2 (reliable: flat reads flat) +ripple -> ripple x2 (reliable: directional reads directional) +rolling_wave -> {rolling_wave, seedy} +hammered -> {hammered, rolling_wave} +granite -> {smooth, ripple} +seedy -> {smooth, ripple} +``` + +The pattern, consistent across both runs: **only the endpoints are reliable** — +`smooth` (flat) and `ripple` (directional) were 2/2 in *both* runs. The +isotropic-pebble trio (hammered/granite/seedy) is intrinsically ambiguous (§2) +and its misfires scatter to `smooth`, `ripple`, and `rolling_wave` — i.e. they +even cross coarse-group boundaries, which is why coarse accuracy was not reliably +better than fine in the persisted run. `rolling_wave` is near-`smooth` at a tight +sheet crop because its waves are cm-scale — a genuine ambiguity that also exists +for real waterglass at a glance. + +Two honest caveats: (a) the set is small (12 holdout photos), so ±1 photo moves a +number by ~0.08 — combined with the VLM stochasticity, treat every accuracy here +as "≈0.5", directional not precise; (b) feature-scale has little within-category +variation in the generator, so its bin score mostly measures category, not scale. + +## 5. Real board + +`board_real_detection.jpg`: detection on the curated real corpus/wild sheet +photos (`reports/assets_029/corpus_*`, `wild_*`) + the CTO's difficult sheet, +each `photo | detected category+settings | procedural normal | shade`. The +detected-category distribution over the 18 real photos is +`ripple 7, seedy 4, rolling_wave 3, hammered 2, granite 1, smooth 1` — and the +sensible calls line up with §4's reliable axis: the two **waterglass/handblown** +sheets → `rolling_wave` (correct — waterglass *is* rolling wave), the reeded +`wild_sheets_shelf` → `ripple`, a clear float sheet → `smooth`; the isotropic +cathedral/opalescent sheets land somewhere in the textured family with the exact +sub-label wobbling. The **CTO's difficult sheet → `ripple`** (medium/coarse, +angle ≈ −81°). Settings + resulting normals in `results/050/real_detection.json`. +(Raw corpus images are local-only; only downscaled board crops are committed, +per prior reports.) + +## 6. End-to-end A/B — the procedural preset is perceptually GT-like + +`board_ab_normal.jpg`: the 047 window-nudge scene in the real three.js +`MeshPhysicalMaterial`, three ways (GT normal | procedural preset | off), same +T/haze/env. To isolate the *perceptual* question from the noisy per-photo fine +pick, the middle column is the **default preset for the family's true coarse +group** — i.e. exactly what the recommended ship path (coarse-detect + in-family +default, §7) produces when the coarse call succeeds. + +- **cathedral-green (textured → `hammered`):** the preset panel is + **perceptually GT-like** — same textured-glass sparkle character — and both are + clearly distinct from the flat "off" panel. This is the tie 047 predicted: + since even the GT normal does not beat "off" against Cycles *truth*, a + plausible procedural normal ties with GT *perceptually*. +- **wispy-white (→ `seedy`):** GT and preset both add a soft texture over the + "off" panel; the difference GT-vs-preset is subtle. Tie holds. +- **streaky-mix (→ `ripple`):** all three panels look nearly the same — the + structure is carried by the tint T (and, per §3, by the T-pseudo-normal), so + the normal choice barely matters here. + +Net: the perceptual stakes ride on the **coarse** character being right, and the +bank's preset for the right group **ties with GT** — the fine sub-label and the +exact knob change little. + +## 7. Recommendations + +- **Ship auto-detection only for the axis one photo reliably supports:** "is the + surface clearly flat?" and "is it directional/streaky?" (the two categories + robust across both runs). Map to presets: clearly-flat → `smooth`; + directional → `ripple` + the T-pseudo-normal blend; **everything else → a + single default `textured` preset (`hammered`, medium knobs)** rather than a + fragile hammered-vs-granite-vs-seedy pick. This satisfies the CTO requirement + literally — the user never tries 8 settings, the category is auto-detected — + while being honest about what one photo supports. §6 shows the default textured + preset ties with GT perceptually, so collapsing the isotropic trio costs + nothing visible. +- **Do not expose the numeric knobs as user-visible fidelity settings.** Both + channels are weak on amplitude/scale; drive them from a per-category default + plus, at most, the VLM amplitude bin (least-bad, 0.50) as a subtle/medium/ + strong nudge. The effect is robust to this being approximate (§6). +- **Use the cheap model, one cached call/photo.** Sonnet is worse here; category + detection is ambiguity-limited, not capacity-limited. +- **Blend the T-pseudo-normal only on the streaky/wispy family**, where the + structure lives in the tint; it is near-noise on clear glass. +- **Keep the full 6-way bank** (it is free, tileable, and useful for a future + "advanced/manual" override), but do not gate the automatic path on the fine + label. + +Honest bottom line: the CTO's requirement is **partially** met from one photo. +The reliably-detectable relief axis (flat vs directional vs "textured") plus a +sensible default is auto-detectable, and the A/B says that is perceptually +enough. A *precise* hammered-vs-granite-vs-seedy + exact-amplitude read is not +reliable — and the sonnet result + the generator's own taxonomy say it likely +*cannot* be, from a single photo. Ship the coarse path; do not promise the fine +one. + +Reproduction: `research/delighting/render050/` — +`python relief_presets.py` (bank smoke) · `python preview_bank.py` (bank board) · +` -b --python gen050_author.py -- --out results/050/assets_ms --specs …` · +`/python ../render047/prep047_maps.py --assets results/050/assets_ms` · +`python gen050_env.py --out results/050/assets_ms/env_soft.hdr` · +` -b --python gen050_photos.py -- --assets results/050/assets_ms --keys … --soft --env env_soft.hdr --out results/050/photos_v2` · +`/python score050.py` · `python drive050_ab.py` · `python board_real.py` · +`python exp_category.py sonnet haiku` (model ceiling). VLM = `claude` CLI, haiku, +one cached call/photo. diff --git a/research/delighting/results/050/.gitignore b/research/delighting/results/050/.gitignore new file mode 100644 index 0000000..d2d96f5 --- /dev/null +++ b/research/delighting/results/050/.gitignore @@ -0,0 +1,9 @@ +# raw renders + intermediates are regenerable (deterministic); keep only boards + JSON +assets/ +assets_ms/ +photos/ +photos_soft/ +photos_v2/ +*.png +*.hdr +*.npy diff --git a/research/delighting/results/050/board_ab_normal.jpg b/research/delighting/results/050/board_ab_normal.jpg new file mode 100644 index 0000000..7287249 Binary files /dev/null and b/research/delighting/results/050/board_ab_normal.jpg differ diff --git a/research/delighting/results/050/board_preset_bank.jpg b/research/delighting/results/050/board_preset_bank.jpg new file mode 100644 index 0000000..ec54ec6 Binary files /dev/null and b/research/delighting/results/050/board_preset_bank.jpg differ diff --git a/research/delighting/results/050/board_real_detection.jpg b/research/delighting/results/050/board_real_detection.jpg new file mode 100644 index 0000000..1df0fae Binary files /dev/null and b/research/delighting/results/050/board_real_detection.jpg differ diff --git a/research/delighting/results/050/board_synth_photos.jpg b/research/delighting/results/050/board_synth_photos.jpg new file mode 100644 index 0000000..a87f813 Binary files /dev/null and b/research/delighting/results/050/board_synth_photos.jpg differ diff --git a/research/delighting/results/050/detection_scores.json b/research/delighting/results/050/detection_scores.json new file mode 100644 index 0000000..d27adf2 --- /dev/null +++ b/research/delighting/results/050/detection_scores.json @@ -0,0 +1,601 @@ +{ + "summary": { + "holdout": { + "n": 12, + "category_acc": [ + 0.5, + 12 + ], + "coarse_acc": [ + 0.5, + 12 + ], + "cls_amp_acc": [ + 0.417, + 12 + ], + "cls_scale_acc": [ + 0.167, + 12 + ], + "vlm_amp_acc": [ + 0.5, + 12 + ], + "vlm_scale_acc": [ + 0.417, + 12 + ] + }, + "tune": { + "n": 6, + "category_acc": [ + 0.333, + 6 + ], + "coarse_acc": [ + 0.333, + 6 + ], + "cls_amp_acc": [ + 0.333, + 6 + ], + "cls_scale_acc": [ + 0.167, + 6 + ], + "vlm_amp_acc": null, + "vlm_scale_acc": null + }, + "all": { + "n": 18, + "category_acc": [ + 0.444, + 18 + ], + "coarse_acc": [ + 0.444, + 18 + ], + "cls_amp_acc": [ + 0.389, + 18 + ], + "cls_scale_acc": [ + 0.167, + 18 + ], + "vlm_amp_acc": [ + 0.5, + 12 + ], + "vlm_scale_acc": [ + 0.417, + 12 + ] + }, + "category_confusion_holdout": { + "smooth": { + "smooth": 2, + "hammered": 0, + "granite": 0, + "seedy": 0, + "ripple": 0, + "rolling_wave": 0 + }, + "hammered": { + "smooth": 0, + "hammered": 1, + "granite": 0, + "seedy": 0, + "ripple": 0, + "rolling_wave": 1 + }, + "granite": { + "smooth": 1, + "hammered": 0, + "granite": 0, + "seedy": 0, + "ripple": 1, + "rolling_wave": 0 + }, + "seedy": { + "smooth": 1, + "hammered": 0, + "granite": 0, + "seedy": 0, + "ripple": 1, + "rolling_wave": 0 + }, + "ripple": { + "smooth": 0, + "hammered": 0, + "granite": 0, + "seedy": 0, + "ripple": 2, + "rolling_wave": 0 + }, + "rolling_wave": { + "smooth": 0, + "hammered": 0, + "granite": 0, + "seedy": 1, + "ripple": 0, + "rolling_wave": 1 + } + } + }, + "rows": [ + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_baroque-rolling-wave__6001.png", + "key": "baroque-rolling-wave__6001", + "seed": 6001, + "split": "tune", + "gt_category": "rolling_wave", + "gt_amplitude": "strong", + "gt_scale": "coarse", + "bd": 0.011339292587069873, + "det_category": "rolling_wave", + "gt_coarse": "wavy", + "det_coarse": "wavy", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": null, + "vlm_scale": null + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_baroque-rolling-wave__7001.png", + "key": "baroque-rolling-wave__7001", + "seed": 7001, + "split": "holdout", + "gt_category": "rolling_wave", + "gt_amplitude": "strong", + "gt_scale": "coarse", + "bd": 0.01161091079311281, + "det_category": "seedy", + "gt_coarse": "wavy", + "det_coarse": "isotropic", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": "subtle", + "vlm_scale": "coarse" + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_baroque-rolling-wave__7002.png", + "key": "baroque-rolling-wave__7002", + "seed": 7002, + "split": "holdout", + "gt_category": "rolling_wave", + "gt_amplitude": "strong", + "gt_scale": "coarse", + "bd": 0.006271376476402716, + "det_category": "rolling_wave", + "gt_coarse": "wavy", + "det_coarse": "wavy", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": "subtle", + "vlm_scale": "fine" + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_cathedral-amber__7002.png", + "key": "cathedral-amber__7002", + "seed": 7002, + "split": "holdout", + "gt_category": "hammered", + "gt_amplitude": "medium", + "gt_scale": "medium", + "bd": 0.0016983739726959847, + "det_category": "hammered", + "gt_coarse": "isotropic", + "det_coarse": "isotropic", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": "subtle", + "vlm_scale": "medium" + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_cathedral-green__6001.png", + "key": "cathedral-green__6001", + "seed": 6001, + "split": "tune", + "gt_category": "hammered", + "gt_amplitude": "medium", + "gt_scale": "medium", + "bd": 0.003535493562812829, + "det_category": "ripple", + "gt_coarse": "isotropic", + "det_coarse": "directional", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": null, + "vlm_scale": null + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_cathedral-green__6001_smooth.png", + "key": "cathedral-green__6001", + "seed": 6001, + "split": "tune", + "gt_category": "smooth", + "gt_amplitude": "subtle", + "gt_scale": "fine", + "bd": 0.0, + "det_category": "smooth", + "gt_coarse": "flat", + "det_coarse": "flat", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": null, + "vlm_scale": null + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_cathedral-green__7001.png", + "key": "cathedral-green__7001", + "seed": 7001, + "split": "holdout", + "gt_category": "hammered", + "gt_amplitude": "medium", + "gt_scale": "medium", + "bd": 0.003633955162503393, + "det_category": "rolling_wave", + "gt_coarse": "isotropic", + "det_coarse": "wavy", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": "medium", + "vlm_scale": "fine" + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_cathedral-green__7001_smooth.png", + "key": "cathedral-green__7001", + "seed": 7001, + "split": "holdout", + "gt_category": "smooth", + "gt_amplitude": "subtle", + "gt_scale": "fine", + "bd": 0.0, + "det_category": "smooth", + "gt_coarse": "flat", + "det_coarse": "flat", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": "subtle", + "vlm_scale": "fine" + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_dark-opaque__6001.png", + "key": "dark-opaque__6001", + "seed": 6001, + "split": "tune", + "gt_category": "granite", + "gt_amplitude": "medium", + "gt_scale": "fine", + "bd": 0.0023348231467674686, + "det_category": "ripple", + "gt_coarse": "isotropic", + "det_coarse": "directional", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": null, + "vlm_scale": null + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_dark-opaque__7001.png", + "key": "dark-opaque__7001", + "seed": 7001, + "split": "holdout", + "gt_category": "granite", + "gt_amplitude": "medium", + "gt_scale": "fine", + "bd": 0.002402727698278202, + "det_category": "ripple", + "gt_coarse": "isotropic", + "det_coarse": "directional", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": "medium", + "vlm_scale": "coarse" + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_dark-slate__7002.png", + "key": "dark-slate__7002", + "seed": 7002, + "split": "holdout", + "gt_category": "granite", + "gt_amplitude": "medium", + "gt_scale": "fine", + "bd": 0.001067844119100679, + "det_category": "smooth", + "gt_coarse": "isotropic", + "det_coarse": "flat", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": "subtle", + "vlm_scale": "fine" + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_saturated-opalescent__7002.png", + "key": "saturated-opalescent__7002", + "seed": 7002, + "split": "holdout", + "gt_category": "seedy", + "gt_amplitude": "subtle", + "gt_scale": "medium", + "bd": 0.0008576675012355772, + "det_category": "smooth", + "gt_coarse": "isotropic", + "det_coarse": "flat", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": "subtle", + "vlm_scale": "fine" + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_saturated-opalescent__7002_smooth.png", + "key": "saturated-opalescent__7002", + "seed": 7002, + "split": "holdout", + "gt_category": "smooth", + "gt_amplitude": "subtle", + "gt_scale": "fine", + "bd": 0.0, + "det_category": "smooth", + "gt_coarse": "flat", + "det_coarse": "flat", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": "subtle", + "vlm_scale": "fine" + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_streaky-fine-texture__7002.png", + "key": "streaky-fine-texture__7002", + "seed": 7002, + "split": "holdout", + "gt_category": "ripple", + "gt_amplitude": "subtle", + "gt_scale": "medium", + "bd": 0.00016865713275268675, + "det_category": "ripple", + "gt_coarse": "directional", + "det_coarse": "directional", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": "medium", + "vlm_scale": "fine" + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_streaky-mix__6001.png", + "key": "streaky-mix__6001", + "seed": 6001, + "split": "tune", + "gt_category": "ripple", + "gt_amplitude": "subtle", + "gt_scale": "medium", + "bd": 0.0005170763653610538, + "det_category": "rolling_wave", + "gt_coarse": "directional", + "det_coarse": "wavy", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": null, + "vlm_scale": null + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_streaky-mix__7001.png", + "key": "streaky-mix__7001", + "seed": 7001, + "split": "holdout", + "gt_category": "ripple", + "gt_amplitude": "subtle", + "gt_scale": "medium", + "bd": 0.0005357501170265057, + "det_category": "ripple", + "gt_coarse": "directional", + "det_coarse": "directional", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": "medium", + "vlm_scale": "coarse" + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_wispy-white__6001.png", + "key": "wispy-white__6001", + "seed": 6001, + "split": "tune", + "gt_category": "seedy", + "gt_amplitude": "medium", + "gt_scale": "medium", + "bd": 0.0019345996747523481, + "det_category": "rolling_wave", + "gt_coarse": "isotropic", + "det_coarse": "wavy", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": null, + "vlm_scale": null + }, + { + "file": "/Users/dominiquepiche-meunier/Documents/vitraux/.claude/worktrees/agent-ac48787d3e6340f0a/research/delighting/results/050/photos_v2/photo_wispy-white__7001.png", + "key": "wispy-white__7001", + "seed": 7001, + "split": "holdout", + "gt_category": "seedy", + "gt_amplitude": "medium", + "gt_scale": "medium", + "bd": 0.001992318543536472, + "det_category": "ripple", + "gt_coarse": "isotropic", + "det_coarse": "directional", + "cls_amplitude": "subtle", + "cls_scale": "coarse", + "vlm_amplitude": "medium", + "vlm_scale": "fine" + } + ], + "features": { + "baroque-rolling-wave__6001": { + "hf_lum": 0.0016, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0549, + "chroma_hf": 0.0342, + "aniso": 0.7225, + "angle_deg": 7.4896, + "lum_rms": 0.035 + }, + "baroque-rolling-wave__7001": { + "hf_lum": 0.0019, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0549, + "chroma_hf": 0.027, + "aniso": 0.7479, + "angle_deg": 6.9461, + "lum_rms": 0.0359 + }, + "baroque-rolling-wave__7002": { + "hf_lum": 0.0015, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0533, + "chroma_hf": 0.0347, + "aniso": 0.859, + "angle_deg": 5.2198, + "lum_rms": 0.0348 + }, + "cathedral-amber__7002": { + "hf_lum": 0.0008, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0507, + "chroma_hf": 0.0214, + "aniso": 0.6159, + "angle_deg": 4.6035, + "lum_rms": 0.0349 + }, + "cathedral-green__6001": { + "hf_lum": 0.0013, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0623, + "chroma_hf": 0.0254, + "aniso": 0.4985, + "angle_deg": 2.7188, + "lum_rms": 0.0355 + }, + "cathedral-green__6001_smooth": { + "hf_lum": 0.0012, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0561, + "chroma_hf": 0.0239, + "aniso": 0.8005, + "angle_deg": 5.8857, + "lum_rms": 0.0352 + }, + "cathedral-green__7001": { + "hf_lum": 0.0016, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0618, + "chroma_hf": 0.0229, + "aniso": 0.5107, + "angle_deg": 2.1425, + "lum_rms": 0.0354 + }, + "cathedral-green__7001_smooth": { + "hf_lum": 0.0013, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0561, + "chroma_hf": 0.022, + "aniso": 0.8202, + "angle_deg": 5.8434, + "lum_rms": 0.0352 + }, + "dark-opaque__6001": { + "hf_lum": 0.0094, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0408, + "chroma_hf": 0.0597, + "aniso": 0.9064, + "angle_deg": 4.7849, + "lum_rms": 0.0357 + }, + "dark-opaque__7001": { + "hf_lum": 0.0098, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0443, + "chroma_hf": 0.0624, + "aniso": 0.8924, + "angle_deg": 4.7222, + "lum_rms": 0.036 + }, + "dark-slate__7002": { + "hf_lum": 0.0048, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0584, + "chroma_hf": 0.0213, + "aniso": 0.9419, + "angle_deg": 5.7309, + "lum_rms": 0.0388 + }, + "saturated-opalescent__7002": { + "hf_lum": 0.003, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0421, + "chroma_hf": 0.0084, + "aniso": 0.9078, + "angle_deg": 5.5684, + "lum_rms": 0.0317 + }, + "saturated-opalescent__7002_smooth": { + "hf_lum": 0.003, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0425, + "chroma_hf": 0.0084, + "aniso": 0.9078, + "angle_deg": 5.6443, + "lum_rms": 0.0317 + }, + "streaky-fine-texture__7002": { + "hf_lum": 0.0041, + "peak_scale_frac": 0.25, + "spec_contrast": 0.0629, + "chroma_hf": 0.0071, + "aniso": 0.5952, + "angle_deg": 4.5155, + "lum_rms": 0.0342 + }, + "streaky-mix__6001": { + "hf_lum": 0.0052, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.11, + "chroma_hf": 0.0031, + "aniso": 0.3973, + "angle_deg": 7.3132, + "lum_rms": 0.0486 + }, + "streaky-mix__7001": { + "hf_lum": 0.0026, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0932, + "chroma_hf": 0.0158, + "aniso": 0.3216, + "angle_deg": 13.3908, + "lum_rms": 0.0479 + }, + "wispy-white__6001": { + "hf_lum": 0.0034, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0768, + "chroma_hf": 0.0099, + "aniso": 0.5425, + "angle_deg": 6.079, + "lum_rms": 0.041 + }, + "wispy-white__7001": { + "hf_lum": 0.0016, + "peak_scale_frac": 0.3333, + "spec_contrast": 0.0667, + "chroma_hf": 0.0319, + "aniso": 0.5962, + "angle_deg": 5.9757, + "lum_rms": 0.041 + } + } +} \ No newline at end of file diff --git a/research/delighting/results/050/preset_bank.json b/research/delighting/results/050/preset_bank.json new file mode 100644 index 0000000..383ee36 --- /dev/null +++ b/research/delighting/results/050/preset_bank.json @@ -0,0 +1,69 @@ +{ + "categories": { + "smooth": { + "desc": "float / cast sheet -- essentially flat, only faint sub-mm imperfection", + "knobs": [ + "amplitude" + ], + "default_amplitude": "subtle", + "default_scale": "fine" + }, + "hammered": { + "desc": "isotropic mm-scale pebbled cells (classic cathedral glass)", + "knobs": [ + "amplitude", + "feature_scale" + ], + "default_amplitude": "medium", + "default_scale": "medium" + }, + "granite": { + "desc": "dense fine stipple / granite tooth (rolled dark & textured sheet)", + "knobs": [ + "amplitude", + "feature_scale" + ], + "default_amplitude": "medium", + "default_scale": "fine" + }, + "seedy": { + "desc": "sparse discrete round bumps / seeds / small bubbles on a soft ground", + "knobs": [ + "amplitude", + "feature_scale" + ], + "default_amplitude": "medium", + "default_scale": "medium" + }, + "ripple": { + "desc": "directional pulled / reeded streaks (streaky & waterglass sheet)", + "knobs": [ + "amplitude", + "feature_scale", + "angle" + ], + "default_amplitude": "subtle", + "default_scale": "medium" + }, + "rolling_wave": { + "desc": "coarse cm-scale rolling waves (baroque / hammered-coarse sheet)", + "knobs": [ + "amplitude", + "feature_scale" + ], + "default_amplitude": "strong", + "default_scale": "coarse" + } + }, + "amplitude_bins": { + "subtle": 0.35, + "medium": 0.65, + "strong": 1.0 + }, + "feature_scale_bins": { + "fine": 0.045, + "medium": 0.11, + "coarse": 0.26 + }, + "grounding": "generate_synthetic.generate_relief_height families + real sheet-glass corpus classes" +} \ No newline at end of file diff --git a/research/delighting/results/050/real_detection.json b/research/delighting/results/050/real_detection.json new file mode 100644 index 0000000..c545267 --- /dev/null +++ b/research/delighting/results/050/real_detection.json @@ -0,0 +1,146 @@ +[ + { + "file": "corpus_bullseye-0001000047f1010.jpg", + "category": "ripple", + "amplitude": "strong", + "feature_scale": "medium", + "angle_deg": 0.0, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "corpus_bullseye-0011370050f1010.jpg", + "category": "seedy", + "amplitude": "subtle", + "feature_scale": "coarse", + "angle_deg": null, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "corpus_bullseye-0014140051f1010.jpg", + "category": "ripple", + "amplitude": "subtle", + "feature_scale": "coarse", + "angle_deg": -85.1, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "corpus_bullseye-0014260030f1010.jpg", + "category": "seedy", + "amplitude": "subtle", + "feature_scale": "coarse", + "angle_deg": null, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "corpus_bullseye-0021000000f1010.jpg", + "category": "rolling_wave", + "amplitude": "strong", + "feature_scale": "coarse", + "angle_deg": null, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "corpus_bullseye-0023050030f1010.jpg", + "category": "ripple", + "amplitude": "subtle", + "feature_scale": "coarse", + "angle_deg": -8.7, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "corpus_bullseye-0041110000ffull.jpg", + "category": "seedy", + "amplitude": "subtle", + "feature_scale": "coarse", + "angle_deg": null, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "corpus_oceanside-of22272s.jpg", + "category": "smooth", + "amplitude": "subtle", + "feature_scale": "coarse", + "angle_deg": null, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "corpus_oceanside-of23072s.jpg", + "category": "hammered", + "amplitude": "strong", + "feature_scale": "coarse", + "angle_deg": null, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "corpus_wissmach-wblacki.jpg", + "category": "seedy", + "amplitude": "strong", + "feature_scale": "coarse", + "angle_deg": null, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "wild_sheets_handblown.jpg", + "category": "rolling_wave", + "amplitude": "subtle", + "feature_scale": "coarse", + "angle_deg": null, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "wild_sheets_shelf.jpg", + "category": "ripple", + "amplitude": "strong", + "feature_scale": "coarse", + "angle_deg": -2.2, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "wild_sheets_waterglass_pair.jpg", + "category": "rolling_wave", + "amplitude": "strong", + "feature_scale": "coarse", + "angle_deg": null, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "wild_panel_green_bg_distortion.jpg", + "category": "granite", + "amplitude": "strong", + "feature_scale": "coarse", + "angle_deg": null, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "wild_panel_lightbox.jpg", + "category": "ripple", + "amplitude": "subtle", + "feature_scale": "coarse", + "angle_deg": 83.5, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "wild_panel_reflected_vs_transmitted.jpg", + "category": "ripple", + "amplitude": "strong", + "feature_scale": "coarse", + "angle_deg": 3.8, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "wild_panel_tungsten_backlit.jpg", + "category": "hammered", + "amplitude": "strong", + "feature_scale": "coarse", + "angle_deg": null, + "source": "vlm-cat+classical-knobs" + }, + { + "file": "PXL_20260508_165112222 (1).jpg", + "category": "ripple", + "amplitude": "medium", + "feature_scale": "coarse", + "angle_deg": -81.1, + "source": "vlm-cat+classical-knobs" + } +] \ No newline at end of file