← 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 };