← back to Wallco Ai
fix(edges-scan): move scanner from ~/.claude/skills into project so it deploys
5d5b7c03008e4f35031aff96ca622ae8742ff291 · 2026-05-25 01:01:14 -0700 · Steve Abrams
Prod 500'd with 'No such file: /root/.claude/skills/edges-agent/scripts/scan.py'
because the skill folder is Mac2-only. Scanner now lives at
scripts/edges-scan.py (deploys via rsync), with a fallback to the skill
path for dev environments. Endpoint picks the project copy first when it
exists.
Verified: scanner gives identical FAIL verdict from the new path
(top/bottom WARN 10.11/10.11, left/right WARN 9.36/9.36, h_mid/v_mid
FAIL 18.79/17.64) on design 41509.
Files touched
A scripts/edges-scan.pyM server.js
Diff
commit 5d5b7c03008e4f35031aff96ca622ae8742ff291
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon May 25 01:01:14 2026 -0700
fix(edges-scan): move scanner from ~/.claude/skills into project so it deploys
Prod 500'd with 'No such file: /root/.claude/skills/edges-agent/scripts/scan.py'
because the skill folder is Mac2-only. Scanner now lives at
scripts/edges-scan.py (deploys via rsync), with a fallback to the skill
path for dev environments. Endpoint picks the project copy first when it
exists.
Verified: scanner gives identical FAIL verdict from the new path
(top/bottom WARN 10.11/10.11, left/right WARN 9.36/9.36, h_mid/v_mid
FAIL 18.79/17.64) on design 41509.
---
scripts/edges-scan.py | 219 ++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 7 +-
2 files changed, 225 insertions(+), 1 deletion(-)
diff --git a/scripts/edges-scan.py b/scripts/edges-scan.py
new file mode 100755
index 0000000..a70f32e
--- /dev/null
+++ b/scripts/edges-scan.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+"""edges-agent — 6-lens seamless-tile defect scanner.
+
+Samples 2-pixel-thick strips at four wrap edges (top/bottom/left/right) and the
+two internal midlines (h_mid, v_mid). Computes mean LAB ΔE between the two
+sides of each lens — low score = seamless at that lens, high score = visible
+seam.
+
+Outputs JSON to stdout (default) or a human-readable scorecard (--text).
+"""
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+# Tunable thresholds (1024x1024 SDXL output baseline).
+PASS_MAX = 5.0
+WARN_MAX = 12.0
+
+# Strip thickness on each side of the lens line.
+STRIP_PX = 2
+
+LENSES = ['top', 'bottom', 'left', 'right', 'h_mid', 'v_mid']
+
+
+def verdict_for(score: float) -> str:
+ if score <= PASS_MAX:
+ return 'PASS'
+ if score <= WARN_MAX:
+ return 'WARN'
+ return 'FAIL'
+
+
+def rgb_to_lab_strip(arr):
+ """Convert a numpy RGB strip (H, W, 3) uint8 → LAB float64. Pure-numpy
+ sRGB→LAB so we don't pull in skimage."""
+ import numpy as np
+ rgb = arr.astype(np.float64) / 255.0
+ # sRGB → linear RGB
+ a = 0.055
+ linear = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + a) / (1 + a)) ** 2.4)
+ # linear RGB → XYZ (D65)
+ M = np.array([
+ [0.4124564, 0.3575761, 0.1804375],
+ [0.2126729, 0.7151522, 0.0721750],
+ [0.0193339, 0.1191920, 0.9503041],
+ ])
+ xyz = linear @ M.T
+ # XYZ → LAB (D65 white)
+ white = np.array([0.95047, 1.0, 1.08883])
+ xyz_n = xyz / white
+ eps = 216 / 24389
+ kappa = 24389 / 27
+ f = np.where(xyz_n > eps, np.cbrt(xyz_n), (kappa * xyz_n + 16) / 116)
+ L = 116 * f[..., 1] - 16
+ a_ch = 500 * (f[..., 0] - f[..., 1])
+ b_ch = 200 * (f[..., 1] - f[..., 2])
+ return np.stack([L, a_ch, b_ch], axis=-1)
+
+
+def lens_score(img_np, lens: str) -> float:
+ """Mean ΔE76 across the lens strip."""
+ import numpy as np
+ H, W = img_np.shape[:2]
+
+ if lens == 'top':
+ a = img_np[0:STRIP_PX, :, :]
+ b = img_np[H - STRIP_PX:H, :, :]
+ elif lens == 'bottom':
+ a = img_np[H - STRIP_PX:H, :, :]
+ b = img_np[0:STRIP_PX, :, :]
+ elif lens == 'left':
+ a = img_np[:, 0:STRIP_PX, :]
+ b = img_np[:, W - STRIP_PX:W, :]
+ elif lens == 'right':
+ a = img_np[:, W - STRIP_PX:W, :]
+ b = img_np[:, 0:STRIP_PX, :]
+ elif lens == 'h_mid':
+ m = H // 2
+ a = img_np[m - STRIP_PX:m, :, :]
+ b = img_np[m:m + STRIP_PX, :, :]
+ elif lens == 'v_mid':
+ m = W // 2
+ a = img_np[:, m - STRIP_PX:m, :]
+ b = img_np[:, m:m + STRIP_PX, :]
+ else:
+ raise ValueError(f'unknown lens {lens}')
+
+ # Resize sides to identical shape for safety (top/bottom both have STRIP_PX
+ # rows × W cols, left/right both have H rows × STRIP_PX cols — already
+ # matched; this is just defensive).
+ if a.shape != b.shape:
+ raise ValueError(f'shape mismatch in lens {lens}: {a.shape} vs {b.shape}')
+
+ lab_a = rgb_to_lab_strip(a)
+ lab_b = rgb_to_lab_strip(b)
+ delta = lab_a - lab_b
+ de76 = (delta ** 2).sum(axis=-1) ** 0.5
+ return float(de76.mean())
+
+
+def scan_path(png_path: Path) -> dict:
+ from PIL import Image
+ import numpy as np
+
+ if not png_path.exists():
+ return {'ok': False, 'error': f'file not found: {png_path}'}
+ img = Image.open(png_path).convert('RGB')
+ arr = np.asarray(img)
+ if arr.ndim != 3 or arr.shape[2] != 3:
+ return {'ok': False, 'error': f'unexpected image shape: {arr.shape}'}
+ H, W = arr.shape[:2]
+ if min(H, W) < 2 * STRIP_PX + 4:
+ return {'ok': False, 'error': f'image too small ({W}x{H})'}
+
+ lenses = {}
+ for lens in LENSES:
+ score = lens_score(arr, lens)
+ lenses[lens] = {'score': round(score, 2), 'verdict': verdict_for(score)}
+
+ overall_max = max(l['score'] for l in lenses.values())
+ overall_verdict = verdict_for(overall_max)
+
+ edges_max = max(lenses[k]['score'] for k in ('top', 'bottom', 'left', 'right'))
+ mids_max = max(lenses[k]['score'] for k in ('h_mid', 'v_mid'))
+
+ if overall_verdict == 'PASS':
+ summary = 'Clean seamless tile — every lens under the PASS threshold.'
+ elif mids_max > WARN_MAX and edges_max <= PASS_MAX:
+ summary = (f'Internal-grid defect — midline lens(es) > {WARN_MAX:.0f} '
+ f'while edges are clean. Classic SDXL latent-grid leak.')
+ elif edges_max > WARN_MAX and mids_max <= PASS_MAX:
+ summary = (f'Edge-wrap defect — edge lens(es) > {WARN_MAX:.0f} while '
+ f'midlines clean. Tile-prep regression (e.g. center-crop on '
+ f'a seamless source).')
+ elif overall_verdict == 'FAIL':
+ summary = f'Multiple lens failures — both edges and midlines flag.'
+ else:
+ summary = 'Borderline — some lenses in WARN range.'
+
+ return {
+ 'ok': True,
+ 'path': str(png_path),
+ 'width': W, 'height': H,
+ 'verdict': overall_verdict,
+ 'lenses': lenses,
+ 'edges_max': round(edges_max, 2),
+ 'mids_max': round(mids_max, 2),
+ 'summary': summary,
+ }
+
+
+def scan_id(design_id: int) -> dict:
+ sql = f"SELECT local_path FROM spoon_all_designs WHERE id={design_id};"
+ try:
+ r = subprocess.run(
+ ['psql', 'dw_unified', '-At', '-c', sql],
+ check=True, capture_output=True, text=True, timeout=10,
+ )
+ except subprocess.CalledProcessError as e:
+ # Try sudo-postgres path (Linux prod)
+ try:
+ r = subprocess.run(
+ ['sudo', '-n', '-u', 'postgres', 'psql', 'dw_unified', '-At', '-c', sql],
+ check=True, capture_output=True, text=True, timeout=10,
+ )
+ except Exception:
+ return {'ok': False, 'error': f'psql failed: {e.stderr.strip()}'}
+ path_str = (r.stdout or '').strip()
+ if not path_str:
+ return {'ok': False, 'error': f'design {design_id} not found in PG'}
+ out = scan_path(Path(path_str))
+ out['id'] = design_id
+ return out
+
+
+def render_text(result: dict) -> str:
+ if not result.get('ok'):
+ return f"ERROR: {result.get('error', 'unknown')}"
+ lines = []
+ lines.append('')
+ lines.append(f" edges-agent · {result.get('id', '?')} · {result['width']}x{result['height']}")
+ lines.append(f" ────────────────────────────────────")
+ for lens, v in result['lenses'].items():
+ bar = '█' * min(60, int(v['score'] * 2))
+ lines.append(f" {lens:<7} ΔE={v['score']:>6.2f} {v['verdict']:<4} {bar}")
+ lines.append(f" ────────────────────────────────────")
+ lines.append(f" VERDICT: {result['verdict']}")
+ lines.append(f" {result['summary']}")
+ lines.append('')
+ return '\n'.join(lines)
+
+
+def main():
+ p = argparse.ArgumentParser(description='6-lens seamless-tile defect scanner.')
+ p.add_argument('--id', type=int, help='Design id to look up in PG')
+ p.add_argument('--path', type=str, help='Direct path to a PNG')
+ p.add_argument('--json', action='store_true', help='JSON only (default)')
+ p.add_argument('--text', action='store_true', help='Human-readable scorecard')
+ args = p.parse_args()
+
+ if args.id:
+ result = scan_id(args.id)
+ elif args.path:
+ result = scan_path(Path(args.path))
+ else:
+ p.error('Pass --id or --path')
+
+ if args.text:
+ print(render_text(result))
+ else:
+ print(json.dumps(result, indent=2 if args.text else None))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/server.js b/server.js
index b97657d..e447090 100644
--- a/server.js
+++ b/server.js
@@ -6910,7 +6910,12 @@ app.get('/api/design/:id/edges-scan', (req, res) => {
if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
try {
const { spawnSync } = require('child_process');
- const scanPath = require('os').homedir() + '/.claude/skills/edges-agent/scripts/scan.py';
+ // Canonical location is the in-project scripts/edges-scan.py (deploys via
+ // rsync). Falls back to the dev-time skill path if the project copy is
+ // missing (only happens when running outside a freshly-deployed checkout).
+ const projectScan = require('path').join(__dirname, 'scripts', 'edges-scan.py');
+ const skillScan = require('os').homedir() + '/.claude/skills/edges-agent/scripts/scan.py';
+ const scanPath = require('fs').existsSync(projectScan) ? projectScan : skillScan;
const r = spawnSync('python3', [scanPath, '--id', String(id)], {
encoding: 'utf8', timeout: 20_000,
});
← 77c507c gen-luxe v2: 5 heritage-house variants + DW-texture ground a
·
back to Wallco Ai
·
gen-luxe: add --variant=<A|B|C|D|E> flag to lock heritage ae a9b73c7 →