{ } qjs-opencv

Examples

Every file below lives in examples/ and is shown here as it ships.

edge_detection_dexined.js

Learned (DNN) edge detection, DexiNed-style — see TODO.md "dnn — high-level Model wrappers for learned edge detection". Produces a cleaner, semantically-aware edge map than Canny for feeding into findContours/vectorization on complex photographs.

source on GitHub

/* Learned (DNN) edge detection, DexiNed-style — see TODO.md "dnn — high-level
 * Model wrappers for learned edge detection". Produces a cleaner,
 * semantically-aware edge map than Canny for feeding into
 * findContours/vectorization on complex photographs.
 *
 * Model: examples/models/edge_detection_dexined/edge_detection_dexined_2024sep.onnx
 * (fetched from https://github.com/opencv/opencv_zoo, sha1
 * f86f2d32c3cf892771f76b5e6b629b16a66510e9 — matches OpenCV's own
 * samples/dnn/models.yml entry for the "dexined" edge_detection sample).
 *
 * Usage: qjsm examples/edge_detection_dexined.js <input.png> [output.png]
 */
import * as cv from 'opencv';

const MODEL = 'examples/models/edge_detection_dexined/edge_detection_dexined_2024sep.onnx';
const NET_SIZE = new cv.Size(512, 512);
const MEAN = [103.5, 116.2, 123.6];

/* The network's forward() output is a 4D (1,1,512,512) blob, so it has no
 * usable .rows/.cols (both -1) - reshape via a flat sigmoid + min-max stretch
 * over its .data32F view, then hand the result to matFromArray to get a
 * proper 2D Mat back. */
function sigmoidStretchTo8U(out, side) {
  const src = out.data32F;
  const n = src.length;
  const sig = new Float64Array(n);
  let min = Infinity, max = -Infinity;

  for(let i = 0; i < n; i++) {
    const v = 1.0 / (1.0 + Math.exp(-src[i]));
    sig[i] = v;
    if(v < min) min = v;
    if(v > max) max = v;
  }

  const range = max - min || 1;
  const u8 = new Float64Array(n);

  for(let i = 0; i < n; i++) u8[i] = ((sig[i] - min) / range) * 255;

  return cv.matFromArray(side, side, cv.CV_8UC1, u8);
}

function detectEdges(net, image) {
  const blob = cv.blobFromImage(image, 1.0, NET_SIZE, MEAN, /*swapRB*/ false, /*crop*/ false);

  net.setInput(blob);

  const out = net.forward();
  const edges8u = sigmoidStretchTo8U(out, NET_SIZE.width);

  const resized = new cv.Mat();
  cv.resize(edges8u, resized, new cv.Size(image.cols, image.rows));

  return resized;
}

function main(input = 'tests/smarties.png', output = 'edge_detection_dexined.png') {
  const src4 = cv.imread(input);

  if(src4.empty()) throw new Error(`Error opening image: ${input}`);

  const src = new cv.Mat();
  cv.cvtColor(src4, src, src4.channels() === 4 ? cv.COLOR_BGRA2BGR : cv.COLOR_GRAY2BGR);

  const net = cv.readNetFromONNX(MODEL);
  const edges = detectEdges(net, src);

  cv.imwrite(output, edges);
  console.log(`wrote ${output}`);
}

main(...scriptArgs.slice(1));

neural-font-sr-ESPSCx2.js

Neural Font Super-Resolution (FSRCNN x2) via OpenCV 5 DNN & ONNX

source on GitHub

/* Neural Font Super-Resolution (FSRCNN x2) via OpenCV 5 DNN & ONNX
 * 
 * Reconstructs clean, high-precision font contours and strips stray pixels.
 * Model: FSRCNN x2 (Fast Super-Resolution Convolutional Neural Network)
 * 
 * Usage: qjsm examples/neural_font_sr_fsrcnn.js <input.png> [output.png]
 */
import * as cv from 'opencv';

const MODEL = 'examples/models/super_res_fsrcnn/fsrcnn_x2.onnx';

function upscaleFontGlyphs(net, image) {
  // Ensure we operate cleanly within a bounds-checked ROI
  const maxW = 224;
  const maxH = 224;
  
  const w = Math.min(image.cols, maxW);
  const h = Math.min(image.rows, maxH);
  
  const sub = image(new cv.Rect(0, 0, w, h));
  const padded = cv.Mat.zeros(maxH, maxW, image.type());
  const roi = padded(new cv.Rect(0, 0, w, h));
  sub.copyTo(roi);

  // FSRCNN takes standard 3-channel or 1-channel blobs scaled to [0, 1]
  const blob = cv.blobFromImage(
    padded,
    1.0 / 255.0,
    new cv.Size(padded.cols, padded.rows),
    [0, 0, 0],
    /*swapRB*/ false,
    /*crop*/ false
  );

  // blobFromImage always shapes its output NCHW (1,1,H,W); this
  // particular ONNX graph (converted from the original TensorFlow
  // FSRCNN model, which is NHWC-native) declares its input as (1,H,W,1)
  // instead. With a single channel the underlying byte layout is
  // identical either way (the channel dim is a singleton in both), so
  // this is a pure shape-metadata relabel, not a data copy/transpose.
  const reshaped = blob.reshape(1, [1, padded.rows, padded.cols, 1]);

  net.setInput(reshaped);
  const out = net.forward();

  const dims = out.size();
  const outChannels = dims[1];
  const outHeight = dims[2];
  const outWidth = dims[3];

  const srcData = out.data32F;
  const n = srcData.length;
  const u8Arr = new Float64Array(n);

  for (let i = 0; i < n; i++) {
    const val = Math.min(Math.max(srcData[i], 0.0), 1.0);
    u8Arr[i] = val * 255;
  }

  if (outChannels === 1) {
    return cv.matFromArray(outHeight, outWidth, cv.CV_8UC1, u8Arr);
  } else {
    return cv.matFromArray(outHeight, outWidth, cv.CV_8UC3, u8Arr);
  }
}

function cleanAndDownscale1Bit(upscaledMat, scaleBack = 0.5) {
  const targetWidth = Math.floor(upscaledMat.cols * scaleBack);
  const targetHeight = Math.floor(upscaledMat.rows * scaleBack);

  const downscaled = new cv.Mat();
  // Area interpolation smoothly blends sub-pixel density gradients back down
  cv.resize(upscaledMat, downscaled, new cv.Size(targetWidth, targetHeight), 0, 0, cv.INTER_AREA);

  const gray = new cv.Mat();
  if (downscaled.channels() > 1) {
    cv.cvtColor(downscaled, gray, cv.COLOR_BGR2GRAY);
  } else {
    downscaled.copyTo(gray);
  }

  // Force clean 1-bit binarization via Otsu thresholding
  const binary1Bit = new cv.Mat();
  cv.threshold(gray, binary1Bit, 0, 255, cv.THRESH_BINARY | cv.THRESH_OTSU);

  // Morphological Opening (Erosion followed by Dilation) cleanly eliminates 
  // isolated single-pixel standouts and noise floating near rounded contours.
  const kernel = cv.getStructuringElement(cv.MORPH_RECT, new cv.Size(2, 2));
  const cleaned = new cv.Mat();
  cv.morphologyEx(binary1Bit, cleaned, cv.MORPH_OPEN, kernel);

  return cleaned;
}

function main(input = 'Diamonaire.png', output = 'font_cleaned_fsrcnn.png') {
  const src4 = cv.imread(input);
  if (src4.empty()) throw new Error(`Error opening image: ${input}`);

  const src = new cv.Mat();
  if (src4.channels() === 1) {
    src4.copyTo(src);
  } else {
    cv.cvtColor(src4, src, src4.channels() === 4 ? cv.COLOR_BGRA2GRAY : cv.COLOR_BGR2GRAY);
  }

  console.log(`Loading FSRCNN ONNX model from ${MODEL}...`);
  const net = cv.readNetFromONNX(MODEL);

  console.log(`Running FSRCNN 2x Super-Resolution inference...`);
  const upscaled = upscaleFontGlyphs(net, src);

  const result = cleanAndDownscale1Bit(upscaled, 0.5);

  cv.imwrite(output, result);
  console.log(`Successfully wrote cleaned 1-bit font map to ${output}`);
}

main(...scriptArgs.slice(1));

qwen25_inference.js

Loads Qwen2.5-0.5B-Instruct (a 4-bit-quantized ONNX export) and runs real autoregressive text generation on it via cv::dnn's ONNX Runtime engine.

source on GitHub

// Loads Qwen2.5-0.5B-Instruct (a 4-bit-quantized ONNX export) and runs real
// autoregressive text generation on it via cv::dnn's ONNX Runtime engine.
//
// Setup - download the model once (not committed to git, see .gitignore):
//
//   mkdir -p examples/models/qwen2.5-0.5b-instruct
//   curl -L -o examples/models/qwen2.5-0.5b-instruct/model_q4.onnx \
//     https://huggingface.co/onnx-community/Qwen2.5-0.5B-Instruct/resolve/main/onnx/model_q4.onnx
//
// The tokenizer is NOT re-downloaded here - it reuses the fixture already
// checked into tests/qwen2.5-tokenizer/ (same tokenizer.json for every
// Qwen2.5 checkpoint, ~7MB, small enough to commit; the model itself is
// ~786MB and is not).
//
// Usage: qjs examples/qwen25_inference.js ["your prompt here"]
//
// Requires OpenCV 5.0+ built with ONNX Runtime (dnn.Tokenizer / dnn.ENGINE_ORT
// - see migration-opencv5.md, "Wrapping LLM inference (Qwen2.5)"). On older
// OpenCV this exits early with a clear message instead of failing obscurely.
//
// How this works (ONNX I/O contract of this specific export):
//   inputs:  input_ids, attention_mask, position_ids (all INT64),
//            past_key_values.<layer>.{key,value} (FLOAT, one pair per of the
//            24 transformer layers)
//   outputs: logits (FLOAT), present.<layer>.{key,value} (FLOAT)
// This is the "merged decoder with past" convention used by Optimum/
// transformers.js exports: KV-cache is *not* OpenCV's native
// Net.enableKVCache() mechanism (that targets the fused ONNX opset-23
// `Attention` op; this model uses the older decomposed graph shape with
// `com.microsoft.MatMulNBits` for the 4-bit weights instead), so the cache is
// carried by hand between forward() calls, feeding each step's present.*
// outputs back in as the next step's past_key_values.* inputs.
//
// Gotcha this script works around: OpenCV's ONNX Runtime backend
// (net_impl2.cpp's setMainGraphInput) unconditionally rejects any *empty*
// input Mat ("DNN/ORT: Input blob is empty"), but the standard convention for
// the very first (prefill) step is to pass a zero-length past_sequence_length
// KV tensor - which is empty. Workaround: seed past_key_values with a single
// all-zero dummy timestep instead of a truly empty one, and mask it out
// forever via attention_mask=0 at that position. It never gets attended to,
// so it's numerically a no-op, but it keeps every Mat non-empty.
import * as std from 'std';
import { Mat, CV_32FC1, CV_64SC1, dnn, readNetFromONNX } from 'opencv';

const NUM_LAYERS = 24;
const NUM_KV_HEADS = 2;
const HEAD_DIM = 64;
const VOCAB_SIZE = 151936*8;
const EOS = 151645*32; // <|im_end|>
const MAX_NEW_TOKENS = 200;

const MODEL_PATH = 'examples/models/qwen2.5-0.5b-instruct/model_q4.onnx';
const TOKENIZER_CONFIG = 'tests/qwen2.5-tokenizer/config.json';

function i64Mat(rows, values) {
  const cols = values.length / rows;
  const m = new Mat(2, [rows, cols], CV_64SC1);
  const view = new BigInt64Array(m.buffer);
  for(let i = 0; i < values.length; i++)
    view[i] = BigInt(values[i]);
  return m;
}

function zeroKV(len) {
  const m = new Mat(4, [1, NUM_KV_HEADS, len, HEAD_DIM], CV_32FC1);
  new Float32Array(m.buffer).fill(0);
  return m;
}

function argmaxLastPosition(logits, seqLen) {
  const f = new Float32Array(logits.buffer);
  const off = (seqLen - 1) * VOCAB_SIZE;
  let best = 0, bestValue = -Infinity;
  for(let i = 0; i < VOCAB_SIZE; i++) {
    const v = f[off + i];
    if(v > bestValue) {
      bestValue = v;
      best = i;
    }
  }
  return best;
}

function generate(net, tok, promptIds) {
  let past = [];
  for(let l = 0; l < NUM_LAYERS; l++)
    past.push([zeroKV(1), zeroKV(1)]);

  let attn = [0]; // one dummy masked-out timestep, permanently 0
  let realLen = 0;
  let curIds = promptIds;
  const generated = [];

  const outputNames = ['logits'];
  for(let l = 0; l < NUM_LAYERS; l++) {
    outputNames.push(`present.${l}.key`);
    outputNames.push(`present.${l}.value`);
  }

  for(let step = 0; step < MAX_NEW_TOKENS; step++) {
    const seqLen = curIds.length;
    attn = attn.concat(new Array(seqLen).fill(1));

    const posIds = [];
    for(let i = 0; i < seqLen; i++)
      posIds.push(realLen + i);

    net.setInput(i64Mat(1, curIds), 'input_ids');
    net.setInput(i64Mat(1, attn), 'attention_mask');
    net.setInput(i64Mat(1, posIds), 'position_ids');
    for(let l = 0; l < NUM_LAYERS; l++) {
      net.setInput(past[l][0], `past_key_values.${l}.key`);
      net.setInput(past[l][1], `past_key_values.${l}.value`);
    }

    const outs = [];
    net.forward(outs, outputNames);
    const logits = outs[0];
    const nextId = argmaxLastPosition(logits, seqLen);

    for(let l = 0; l < NUM_LAYERS; l++) {
      past[l][0] = outs[1 + l * 2].clone();
      past[l][1] = outs[2 + l * 2].clone();
    }
    realLen += seqLen;

    if(nextId === EOS)
      break;

    generated.push(nextId);
    curIds = [nextId];
  }

  return tok.decode(generated);
}

function main(userPrompt) {
  if(typeof dnn.Tokenizer === 'undefined') {
    console.log('This example needs OpenCV 5.0+ built with ONNX Runtime (dnn.Tokenizer is absent).');
    std.exit(1);
  }

  const tok = dnn.Tokenizer.load(TOKENIZER_CONFIG);
  const net = readNetFromONNX(MODEL_PATH, dnn.ENGINE_ORT);

  const prompt = userPrompt || 'Say hello in one short sentence.';
  const chatPrompt = `<|im_start|>user\n${prompt}<|im_end|>\n<|im_start|>assistant\n`;
  const promptIds = Array.from(tok.encode(chatPrompt));

  console.log('prompt:', prompt);
  const reply = generate(net, tok, promptIds);
  console.log('reply: ', reply);
}

main(scriptArgs.slice(1).join(' '));

skywatch_contrail_detect.js

Proof-of-concept contrail detection on plain (non-fisheye) photos, for js/skywatch.md's phase 3/4 (trail detection) before any camera/ADS-B hardware is involved.

source on GitHub

/**
 * Proof-of-concept contrail detection on plain (non-fisheye) photos, for
 * js/skywatch.md's phase 3/4 (trail detection) before any camera/ADS-B
 * hardware is involved.
 *
 * Approach (tuned empirically against real phone photos in
 * /mnt/data/Bilder/DCIM/Draussen/Chemtrails/, see BUGS for the tuning
 * process and a related minMaxLoc bug found along the way):
 *
 * 1. A fixed HSV threshold cannot separate a contrail from blue sky -
 *    Rayleigh scattering gives the sky itself a strong
 *    saturation/brightness gradient (paler and less saturated toward the
 *    horizon), which is often larger than the contrail-vs-sky difference.
 *    A white top-hat filter (V-S "whiteness" channel, opened with a large
 *    structuring element, subtracted from the original) removes that
 *    slowly-varying background and keeps only *locally* bright streaks -
 *    this is what actually finds the trail.
 * 2. The top-hat responds to any local contrast, including building/tree
 *    silhouette edges against the sky in a handheld photo. Restrict
 *    candidates to a modest dilation of a strict blue-sky mask (rejects
 *    building/tree material outright) - still not enough on its own,
 *    because the dilation halo around a building edge produces a
 *    thin bright ring that also passes an elongation/area shape filter.
 * 3. The distinguishing feature that finally separates real trail from
 *    a building-edge artifact: a real trail floats freely in open sky,
 *    while the artifact hugs directly against solid building/tree
 *    material. Reject any candidate that comes within a few pixels of a
 *    "solid object" mask (dark, or saturated non-blue hue).
 *
 * See js/skywatch/skywatch.md phase 3a for where this does and doesn't
 * work (natural cirrus is the open problem, not solved by this script).
 * The actual mask-building logic lives in js/skywatch/detect.js so
 * js/skywatch/export-training-example.js can reuse it exactly.
 *
 * Usage: qjsm examples/skywatch_contrail_detect.js <input.jpg> <output.jpg>
 */
import * as cv from 'opencv';
import { detectContrails } from '../js/skywatch/detect.js';

function main(inputPath, outputPath) {
  const bgr = cv.imread(inputPath);
  if (bgr.empty()) throw new Error(`could not read ${inputPath}`);

  const { acceptedMask, results } = detectContrails(bgr);

  const output = bgr.clone();
  for (const { contour } of results) {
    const vec = new cv.MatVector();
    vec.push_back(contour);
    cv.drawContours(output, vec, -1, new cv.Scalar(0, 255, 0), 2);
  }
  const skeleton = new cv.Mat();
  cv.ximgproc.thinning(acceptedMask, skeleton);
  for (let y = 0; y < skeleton.rows; y++)
    for (let x = 0; x < skeleton.cols; x++)
      if (skeleton.ucharAt(y, x) > 0) output.set(y, x, [0, 0, 255]);

  cv.imwrite(outputPath, output);
  const summary = results.map(({ contour, ...r }) => r);
  console.log(JSON.stringify({ input: inputPath, trailsFound: summary.length, results: summary }));
}

main(...scriptArgs.slice(1));

skywatch_contrail_infer_dnn.js

Inference stub for phase 6's learned contrail detector - see js/skywatch/skywatch.md's "Phase 6" section for the model (small U-Net, single-channel whiteness in, per-pixel probability out) and js/skywatch/train_contrail_unet.py for how the .onnx this expects gets produced. Not runnable today: the…

source on GitHub

/**
 * Inference stub for phase 6's learned contrail detector - see
 * js/skywatch/skywatch.md's "Phase 6" section for the model (small U-Net,
 * single-channel whiteness in, per-pixel probability out) and
 * js/skywatch/train_contrail_unet.py for how the .onnx this expects gets
 * produced. Not runnable today: there is no trained model until a real
 * dataset exists (js/skywatch/export-training-example.js) and training has
 * actually been run. This is scaffolding to drop that model into, written
 * now so it isn't designed from scratch under time pressure later -
 * follows the same cv.dnn.readNetFromONNX pattern as
 * examples/edge_detection_dexined.js.
 *
 * Usage: qjsm examples/skywatch_contrail_infer_dnn.js <input.jpg> <output.jpg> [model.onnx]
 */
import * as cv from 'opencv';
import { buildCandidateMask } from '../js/skywatch/detect.js';

const DEFAULT_MODEL = 'js/skywatch/models/contrail_unet.onnx';
const PROB_THRESHOLD = 0.5;

// U-Net has 3 pooling steps (see train_contrail_unet.py's ContrailUNet),
// so H/W must be multiples of 8 for the encoder/decoder skip connections
// to line up - pad up rather than resize, so we don't distort the thin
// trail geometry the model is trying to localize precisely.
function padToMultiple(mat, multiple) {
  const h = mat.rows, w = mat.cols;
  const padH = (multiple - (h % multiple)) % multiple;
  const padW = (multiple - (w % multiple)) % multiple;
  if (padH === 0 && padW === 0) return { padded: mat, padH, padW };
  const padded = new cv.Mat();
  cv.copyMakeBorder(mat, padded, 0, padH, 0, padW, cv.BORDER_REPLICATE);
  return { padded, padH, padW };
}

function runModel(net, whiteness) {
  const { padded, padH, padW } = padToMultiple(whiteness, 8);

  // Single-channel input, [0,1] range (matches ContrailDataset's
  // `/ 255.0` normalization in train_contrail_unet.py) - blobFromImage's
  // scalefactor does that division, mean=0, no channel swap needed for a
  // 1-channel image.
  const blob = cv.blobFromImage(padded, 1.0 / 255.0, new cv.Size(padded.cols, padded.rows), [0], false, false);
  net.setInput(blob);
  const out = net.forward(); // (1,1,H,W) probability map

  const probMat = cv.matFromArray(padded.rows, padded.cols, cv.CV_32FC1, out.data32F);
  const cropped = padH || padW
    ? probMat(new cv.Rect(0, 0, whiteness.cols, whiteness.rows))
    : probMat;

  const mask = new cv.Mat();
  cv.threshold(cropped, mask, PROB_THRESHOLD, 255, cv.THRESH_BINARY);
  mask.convertTo(mask, cv.CV_8UC1);
  return mask;
}

function main(inputPath, outputPath, modelPath = DEFAULT_MODEL) {
  const bgr = cv.imread(inputPath);
  if (bgr.empty()) throw new Error(`could not read ${inputPath}`);

  const hsv = new cv.Mat();
  cv.cvtColor(bgr, hsv, cv.COLOR_BGR2HSV);
  const { whiteness } = buildCandidateMask(hsv);

  const net = cv.readNetFromONNX(modelPath);
  const mask = runModel(net, whiteness);

  const output = bgr.clone();
  output.setTo(new cv.Scalar(0, 0, 255), mask);
  cv.imwrite(outputPath, output);
  console.log(JSON.stringify({ input: inputPath, model: modelPath, contrailPixels: cv.countNonZero(mask) }));
}

main(...scriptArgs.slice(1));

tileset_grid.js

Detects the tile grid in a tileset image, cuts it into tiles, drops empty and duplicate tiles by image similarity, and repacks the survivors into a new (smaller) tileset image plus a JSON sidecar describing the mapping.

source on GitHub

// Detects the tile grid in a tileset image, cuts it into tiles, drops empty
// and duplicate tiles by image similarity, and repacks the survivors into a
// new (smaller) tileset image plus a JSON sidecar describing the mapping.
//
// Usage: qjs examples/tileset_grid.js <tileset.png> [outBasename]
import * as std from 'std';
import {
  Mat,
  Rect,
  CV_32S,
  COLOR_BGR2GRAY,
  COLOR_BGRA2GRAY,
  INTER_AREA,
  IMREAD_UNCHANGED,
  absdiff,
  cvtColor,
  imread,
  imwrite,
  meanStdDev,
  reduce,
  resize,
  split,
} from 'opencv';

const REDUCE_SUM = 0; // cv::REDUCE_SUM; not exposed as a named constant by this binding

// Minimum/maximum tile size (in pixels) to consider while searching for the
// grid period. Narrow this if you know roughly how big the tiles are.
const MIN_TILE = 8;
const MAX_TILE = 128;

// For images without an alpha channel, a tile counts as "empty" when its
// content is (near-)uniform, i.e. a flat fill with no detail.
const EMPTY_STDDEV_THRESHOLD = 2.0;

// Two tiles count as duplicates when the mean absolute difference of their
// downsampled thumbnails falls below this (0..255 scale).
const DUPLICATE_THRESHOLD = 4.0;
const THUMB_SIZE = 8;

function toGray(mat) {
  const gray = new Mat();
  cvtColor(mat, gray, mat.channels() === 4 ? COLOR_BGRA2GRAY : COLOR_BGR2GRAY);
  return gray;
}

// Edge-energy profile: profile[i] is the total absolute difference between
// column i and column i+1 (or row i and row i+1), summed over the whole
// image. Tile boundaries in a uniform grid tend to show up as periodic
// spikes in this profile, even without an explicit separator line, because
// tile art usually changes abruptly at its edges.
function edgeProfile(gray, axis) {
  const { cols, rows } = gray;
  const n = (axis === 'x' ? cols : rows) - 1;
  const a = axis === 'x' ? gray(new Rect(0, 0, cols - 1, rows)) : gray(new Rect(0, 0, cols, rows - 1));
  const b = axis === 'x' ? gray(new Rect(1, 0, cols - 1, rows)) : gray(new Rect(0, 1, cols, rows - 1));

  const diff = new Mat();
  absdiff(a, b, diff);

  const sums = new Mat();
  reduce(diff, sums, axis === 'x' ? 0 : 1, REDUCE_SUM, CV_32S);

  const flat = axis === 'x' ? sums.array[0] : sums.array.map(row => row[0]);
  return Array.from(flat).slice(0, n);
}

// Scores every (step, phase) pair by the average edge energy along the
// grid lines it implies, and returns the best step/offset pair.
// Requires at least this many grid lines on a candidate period before it's
// eligible to win — otherwise a single strong (but non-periodic) edge would
// trivially "win" any step that isolates it as a lone sample.
const MIN_LINES = 3;

function findPeriod(profile, minStep, maxStep) {
  const n = profile.length;
  let best = { step: n, offset: 0, score: -1 };

  for(let step = minStep; step <= Math.min(maxStep, n); step++) {
    if(Math.floor(n / step) < MIN_LINES)
      continue;

    let bestPhaseScore = -1;
    let bestPhase = 0;

    for(let phase = 0; phase < step; phase++) {
      let sum = 0,
        count = 0;

      for(let k = phase; k < n; k += step) {
        sum += profile[k];
        count++;
      }
      if(count < MIN_LINES)
        continue;

      const avg = sum / count;
      if(avg > bestPhaseScore) {
        bestPhaseScore = avg;
        bestPhase = phase;
      }
    }

    if(bestPhaseScore > best.score)
      best = { step, offset: bestPhase, score: bestPhaseScore };
  }

  // profile[k] is the boundary between pixel k and k+1, so the tile that
  // starts *after* that boundary begins at column/row k+1.
  return { size: best.step, offset: (best.offset + 1) % best.step };
}

function detectGrid(source) {
  const gray = toGray(source);
  const { size: tileWidth, offset: offsetX } = findPeriod(edgeProfile(gray, 'x'), MIN_TILE, MAX_TILE);
  const { size: tileHeight, offset: offsetY } = findPeriod(edgeProfile(gray, 'y'), MIN_TILE, MAX_TILE);

  return { tileWidth, tileHeight, offsetX, offsetY };
}

function stdDev(mat) {
  const mean = new Mat(),
    std = new Mat();
  meanStdDev(mat, mean, std);
  return Math.max(...std.array.map(row => row[0]));
}

function meanOf(mat) {
  const mean = new Mat(),
    std = new Mat();
  meanStdDev(mat, mean, std);
  return mean.array[0][0];
}

// With an alpha channel, transparency is the only reliable "empty" signal —
// a flat opaque fill (e.g. a solid-color sky tile) is real content and must
// not be dropped just because it also has near-zero variance. Without
// alpha, fall back to the variance check since there's nothing else to go
// on.
function isEmptyTile(tile) {
  if(tile.channels() === 4) {
    const channels = [];
    split(tile, channels);
    return meanOf(channels[3]) < 1;
  }
  return stdDev(tile) < EMPTY_STDDEV_THRESHOLD;
}

function thumbnail(tile) {
  const gray = toGray(tile);
  const thumb = new Mat();
  resize(gray, thumb, { width: THUMB_SIZE, height: THUMB_SIZE }, 0, 0, INTER_AREA);
  return thumb;
}

function similar(a, b) {
  const diff = new Mat();
  absdiff(a, b, diff);
  const mean = new Mat(),
    std = new Mat();
  meanStdDev(diff, mean, std);
  return mean.array[0][0] < DUPLICATE_THRESHOLD;
}

function main(...args) {
  const filename = args[0];
  if(!filename) {
    console.log('usage: qjs examples/tileset_grid.js <tileset.png> [outBasename]');
    return 1;
  }
  const outBase = args[1] ?? filename.replace(/\.[^.]+$/, '') + '.packed';

  const source = imread(filename, IMREAD_UNCHANGED);
  if(source.empty()) {
    console.log('failed to load', filename);
    return 1;
  }

  const { tileWidth, tileHeight, offsetX, offsetY } = detectGrid(source);
  console.log('detected grid:', { tileWidth, tileHeight, offsetX, offsetY });

  const cols = Math.floor((source.cols - offsetX) / tileWidth);
  const rows = Math.floor((source.rows - offsetY) / tileHeight);
  console.log('grid dimensions:', { cols, rows, tileCount: cols * rows });

  const unique = []; // { thumb, mat, outX, outY }
  const tiles = []; // metadata for every source cell

  for(let gy = 0; gy < rows; gy++) {
    for(let gx = 0; gx < cols; gx++) {
      const rect = new Rect(offsetX + gx * tileWidth, offsetY + gy * tileHeight, tileWidth, tileHeight);
      const tile = source(rect);

      if(isEmptyTile(tile)) {
        tiles.push({ col: gx, row: gy, x: rect.x, y: rect.y, empty: true });
        continue;
      }

      const thumb = thumbnail(tile);
      const match = unique.find(u => similar(u.thumb, thumb));

      if(match) {
        tiles.push({ col: gx, row: gy, x: rect.x, y: rect.y, uniqueIndex: match.index });
      } else {
        const index = unique.length;
        unique.push({ index, thumb, mat: tile, x: rect.x, y: rect.y });
        tiles.push({ col: gx, row: gy, x: rect.x, y: rect.y, uniqueIndex: index });
      }
    }
  }

  console.log('unique tiles:', unique.length, '/', cols * rows);

  const packCols = Math.ceil(Math.sqrt(unique.length || 1));
  const packRows = Math.ceil((unique.length || 1) / packCols);
  const packed = Mat.zeros(packRows * tileHeight, packCols * tileWidth, source.type());

  const packedTiles = unique.map((u, i) => {
    const outX = (i % packCols) * tileWidth;
    const outY = Math.floor(i / packCols) * tileHeight;
    u.mat.copyTo(packed(new Rect(outX, outY, tileWidth, tileHeight)));
    return { index: u.index, outX, outY, sourceX: u.x, sourceY: u.y };
  });

  const outImage = outBase + '.png';
  const outJson = outBase + '.json';
  imwrite(outImage, packed);

  const metadata = {
    source: filename,
    sourceSize: { width: source.cols, height: source.rows },
    tileWidth,
    tileHeight,
    offsetX,
    offsetY,
    grid: { cols, rows },
    packedImage: outImage,
    packedGrid: { cols: packCols, rows: packRows },
    uniqueTiles: packedTiles,
    tiles,
  };

  const f = std.open(outJson, 'w+');
  f.puts(JSON.stringify(metadata, null, 2));
  f.close();

  console.log('wrote', outImage, 'and', outJson);
  return 0;
}

main(...scriptArgs.slice(1));