← back to Wallco Ai
lib/seamless-detector.js
107 lines
// Seamless-tile defect detector (pure-physics).
//
// Wraps scripts/seamless-check.py — the existing edge-pixel diff check that
// compares top↔bottom and left↔right rows after downscaling to 512px. A
// defective tile has high pixel diff at one or both edge pairs because motifs
// cut at the boundary don't wrap to the opposite edge.
//
// Why this lens (added 2026-05-24): the Gemini ghost-detector catches motif-
// opacity defects (intentional design rule violations) but misses tile-seam
// discontinuities (the seam line itself is visible when tiled). Both are
// defects but the physics check is cheaper ($0), faster (~50ms), and
// deterministic — runs before the Gemini call as a first-line filter.
//
// Returns:
// { ok: bool, top_bottom_diff: number, left_right_diff: number,
// tolerance: number, code: 'SEAMLESS_OK' | 'SEAMLESS_FAIL_TB' |
// 'SEAMLESS_FAIL_LR' | 'SEAMLESS_FAIL_BOTH' }
//
// Tolerance bands (from scripts/seamless-check.py header):
// < 4 — perceptually clean tile
// < 8 — soft honest repeat
// 12 — default tolerance (script default)
// 15+ — visible seam, defect
//
// verifySeamless(imagePath) — gate variant: throws SEAMLESS_DEFECT if NOT ok.
const { spawnSync } = require('child_process');
const path = require('path');
const SCRIPT = path.join(__dirname, '..', 'scripts', 'seamless-check.py');
// Scenic / mural categories — these are single-image compositions with
// intentional non-repeating content (sky stripes, atmospheric perspective,
// panoramic landscape). Pixel-edge tileability tests don't apply.
// Mirrors SCENIC_CATEGORIES in lib/ghost-detector.js — keep in sync.
const SCENIC_CATEGORIES = new Set([
'cactus-pine-scenic', 'cactus-11ft-mural', 'tree-mural',
'mural', 'scenic', 'panoramic', 'desert-flora', 'mural-animal',
'mural-scenic', 'designer-scenic',
]);
function isScenicCategory(category) {
if (!category) return false;
// category may be "designer-scenic · stone-pewter" — strip colorway suffix
const base = category.split(' · ')[0].toLowerCase();
return SCENIC_CATEGORIES.has(base);
}
function checkSeamless(imagePath, opts = {}) {
const tolerance = opts.tolerance ?? 12;
const r = spawnSync('python3', [SCRIPT, imagePath], {
env: { ...process.env, SEAMLESS_TOLERANCE: String(tolerance) },
encoding: 'utf8',
timeout: 15_000,
});
// The script exits 0 if ok, 1 if not — both write valid JSON to stdout.
// Exit 2 = usage error; anything else = unexpected.
if (r.status !== 0 && r.status !== 1) {
throw new Error(`seamless-check: status=${r.status} ${r.stderr || r.stdout}`.slice(0, 300));
}
let parsed;
try { parsed = JSON.parse(r.stdout.trim()); }
catch (e) { throw new Error(`seamless-check non-JSON: ${r.stdout.slice(0, 200)}`); }
// The script now returns its own `code` field — pass through. Fall back
// to deriving from diffs if an older script version is on PATH.
let code = parsed.code;
if (!code) {
const tbFail = parsed.top_bottom_diff > parsed.tolerance;
const lrFail = parsed.left_right_diff > parsed.tolerance;
if (parsed.ok) code = 'SEAMLESS';
else if (tbFail && lrFail) code = 'SEAMLESS_FAIL_BOTH';
else if (tbFail) code = 'SEAMLESS_FAIL_TB';
else if (lrFail) code = 'SEAMLESS_FAIL_LR';
else code = 'SEAMLESS_FAIL';
}
return {
ok: parsed.ok,
top_bottom_diff: parsed.top_bottom_diff,
left_right_diff: parsed.left_right_diff,
top_bottom_diff_halfdrop: parsed.top_bottom_diff_halfdrop,
left_right_diff_halfdrop: parsed.left_right_diff_halfdrop,
top_var: parsed.top_var,
bottom_var: parsed.bottom_var,
left_var: parsed.left_var,
right_var: parsed.right_var,
tolerance: parsed.tolerance,
code,
};
}
async function verifySeamless(imagePath, opts = {}) {
// Scenic murals are not tiles — gate trivially passes them.
if (isScenicCategory(opts.category)) {
return { ok: true, code: 'SKIPPED_SCENIC', scenic: true };
}
const r = checkSeamless(imagePath, opts);
if (!r.ok) {
const e = new Error(`tile not seamless: ${r.code} tb=${r.top_bottom_diff} lr=${r.left_right_diff} tol=${r.tolerance}`);
e.code = 'SEAMLESS_DEFECT';
e.detector_result = r;
throw e;
}
return r;
}
module.exports = { checkSeamless, verifySeamless, isScenicCategory };