Engineering a slider captcha automation: from reconstructing the original images to a non-Bézier trajectory

From image reconstruction and diff-based localisation to a minimum-jerk trajectory

Anonymisation note: this article calls the real business platform "business system A" and the captcha vendor "verification component B". Domains, endpoint paths, selectors, business fields and identifiers have all been replaced or omitted.example.test is a reserved example domain, and the code here is a simplified example rather than a production script you can run as-is.

This article covers page interaction and testing done with explicit authorisation; it does not involve faking business state, forging verification tokens or bypassing browser permissions. Whether automation is permitted is still governed by the target system's rules.


1. The hard part is not "how many pixels to drag right"

A jigsaw slider looks like three steps: find the gap, compute the distance, drag across.

In practice the problems go well beyond that:

  • Network images may be split into shuffled slices and cannot be matched directly;
  • the puzzle piece has transparent margins and a glowing outline, so its bounding box is not necessarily where the effective artwork is;
  • image coordinates and on-page CSS coordinates are not necessarily 1:1;
  • the success callback may trigger a business operation directly, with no second confirmation;
  • asynchronous page updates, duplicate callbacks, timeouts and cancellations can all change the final result.

So I split the whole process into layers:

读取当前页面状态
    ↓
获取同一组原始图片
    ↓
按渲染规则复原 → 与页面画布核对
    ↓
差分定位 + 拼图内部像素匹配
    ↓
图片坐标换算为页面位移
    ↓
平滑拖动 + 终点检查
    ↓
验证结果 → 独立测试回调 / 原业务回调

There is only one key principle here: every step has to produce something you can check; never continue on the strength of "it looks right".

2. From screenshot estimation to the original images

Screenshots are fine for a quick look, but they are not a good sole basis for precise computation. Page zoom, device pixel ratio, crop position and display interpolation all affect what a screenshot shows, and the challenge image itself may refresh mid-operation.

The more direct approach is to find the image resources the page actually loaded:

const imageUrls = performance.getEntriesByType('resource')
  .filter(entry => entry.initiatorType === 'img')
  .map(entry => entry.name)
  .filter(url => url.startsWith('https://assets.example.test/challenge/'));

One easy point of confusion: Resource Timing gives you resource URLs and timings, not the response bodies of past requests. Once you have the URL you still have to load the original image again. Reading pixels cross-origin requires the image service to allow it; if the canvas is tainted and cannot be read, stop — do not work around browser restrictions.

This sample involved three kinds of resource:

Resource Purpose
Complete background Supplies the artwork that was originally under the gap
Gapped background Diffed against the complete background
Transparent puzzle PNG Supplies shape, margins and inner texture

These resources have to come from the same challenge image set. Taking "the three most recently loaded images" is not reliable; associate them through their resource grouping, and re-confirm before dragging that the challenge image has not been swapped.

3. Image reconstruction: reproduce the rendering rules instead of guessing the arrangement

The rules at this stage come from the JavaScript the page actually loaded. I located two parts: the logic that generates the slice order, and the rendering logic that copies source slices onto the target canvas. I then rewrote them as readable code, without fully de-obfuscating the whole component.

This sample's layout is:

  • source image size:312 × 160;
  • 26 slice slots horizontally, two rows vertically, 52 pieces in total;
  • each slot is 12 pixels wide, of which the effective content is 10 pixels wide;
  • effective content size per piece:10 × 80;
  • after removing the gaps and re-arranging, the result is 260 × 160 's background.

Let the target i th piece correspond to source piece order[i] , and the core of the reconstruction is:

function restoreBackground(image, order) {
  const validOrder = order.length === 52
    && new Set(order).size === 52
    && order.every(n => Number.isInteger(n) && n >= 0 && n < 52);

  if (image.naturalWidth !== 312 || image.naturalHeight !== 160 || !validOrder) {
    throw new Error('不支持的图片布局或排列参数');
  }

  const source = document.createElement('canvas');
  source.width = 312;
  source.height = 160;
  const sourceContext = source.getContext('2d');
  sourceContext.drawImage(image, 0, 0);

  const output = document.createElement('canvas');
  output.width = 260;
  output.height = 160;
  const outputContext = output.getContext('2d');

  order.forEach((sourceIndex, targetIndex) => {
    const sx = (sourceIndex % 26) * 12 + 1;
    const sy = Math.floor(sourceIndex / 26) * 80;
    const dx = (targetIndex % 26) * 10;
    const dy = Math.floor(targetIndex / 26) * 80;

    const tile = sourceContext.getImageData(sx, sy, 10, 80);
    outputContext.putImageData(tile, dx, dy);
  });

  return output;
}

To avoid exposing a specific vendor's version parameters, the example does not include the actual fixed arrangement; instead it takes order as input.

This method is also not a universal rule: slice size, spacing and arrangement must come from the implementation of the matching version. After the component version changes, re-verify — do not keep reusing the old parameters.

A surprise: identical files, different pixels read back

Once reconstruction was done, I compared the result with the page's existing canvas pixel by pixel. At first the hashes did not match in some environments.

Closer comparison showed that the original file content was identical, but in this browser environment the default canvas drawing path and the path with willReadFrequently enabled read back subtly different pixels. Once both were unified to the drawing mode the original page used, both reconstructed backgrounds reached zero pixel difference.

This observation cannot be generalised into "every browser behaves this way", but it reminded me that:

  1. identical file bytes do not guarantee identical RGBA output across drawing paths;
  2. a different hash only proves the data differs — it does not by itself prove the slicing algorithm is wrong;
  3. exact comparison requires keeping decoding, drawing and pixel-reading conditions as consistent as possible.

Also note that the page canvas here is used only for after-the-fact verification; the input to the locating algorithm is still the original images, not a ready-made answer read off the canvas.

4. Gap detection: diff to narrow down, texture to confirm

With the background reconstructed, the first step is to compute the pixel difference between the complete background and the gapped background:

D(x, y) = max(
  |R完整 - R缺口|,
  |G完整 - G缺口|,
  |B完整 - B缺口|
)

Regions with a large difference help locate the gap, but you cannot simply take the leftmost edge of the diff as the answer — compression noise, shadows, outlines and transparent margins all shift it. So I used two stages.

Step one: narrow down the diff. Threshold the diff image and extract the main connected regions, to observe and cross-check the gap's extent; a candidate match position must also cover enough strongly-differing pixels. This sample used a diff threshold of 40and a candidate coverage ratio of 70%. These are sample-specific engineering parameters, not standards that apply to every image.

Step two: match the puzzle's inner texture. The puzzle edge usually contains transparent pixels and bright outlines, so comparing the whole rectangle directly is error-prone. Build a mask first:

  1. keep alpha > 250 pixels;
  2. erode the mask with a 7 × 7 square neighbourhood to strip more of the edge;
  3. for the remaining inner pixels only, compute the mean squared error against the candidate region of the complete background.
score(x, y) =
  掩码内所有 RGB 通道的平方误差之和
  ÷ (有效像素数量 × 3)

Pick the position that satisfies the diff-coverage condition and has the smallest error. If there are too few valid pixels, no candidate positions, or the result is not unique, stop — do not force out coordinates.

Image coordinates are not mouse coordinates

What you ultimately need is usually:

页面位移 = (目标图像起点 - 当前图像起点)
         × 页面显示宽度 / 图片实际宽度

Note that the "image origin" and "the leftmost visible pixel of the artwork" are not the same thing. You can compute the effective alpha bounds of the original puzzle and of the current puzzle canvas separately, and derive the current placement from the difference. That separates transparent margins, initial offset and page zoom, instead of nudging a few pixels by feel.

5. No Bézier: a quintic minimum-jerk trajectory

The first version dragged in a fixed number of steps with evenly increasing horizontal displacement. It expresses "where to move", but the start and stop are abrupt, and timer delays change the overall rhythm.

The improved version uses a quintic minimum-jerk displacement curve:

u = clamp((当前时间 - 开始时间) / 总时长, 0, 1)
s(u) = 10u³ - 15u⁴ + 6u⁵
x(u) = x起点 + 总位移 × s(u)
y(u) = y起点

It is a quintic polynomial evaluated directly, with no Bézier control points. In theory the velocity and acceleration are zero at both ends, so the motion starts from rest smoothly and returns to rest.

What is being optimised here is the time distribution of horizontal motion, not drawing the spatial path as an arc.

function minimumJerk(progress) {
  if (!Number.isFinite(progress)) throw new Error('无效进度');
  if (progress <= 0) return 0;
  if (progress >= 1) return 1;

  // 减速段使用对称公式,减少接近终点时的浮点消减误差。
  const t = progress <= 0.5 ? progress : 1 - progress;
  const value = t * t * t * (10 + t * (-15 + 6 * t));
  return progress <= 0.5 ? value : 1 - value;
}

After adding boundary tests, I did find that evaluating the original polynomial directly can produce a floating-point result slightly greater than 1 very close to the end point. The symmetric formulation above handles that, rather than hiding it by loosening the "no overshoot" assertion.

Advance by real time, not by accumulating frames

"Wait 16 milliseconds" in a browser does not guarantee resumption after exactly 16 ms — background throttling, rendering and other tasks can all add delay. So every sample re-reads the monotonic clock and computes the position from the time that has actually elapsed:

async function move(distance, durationMs, emit, checkActive) {
  if (!Number.isFinite(distance) || !Number.isFinite(durationMs) || durationMs <= 0) {
    throw new Error('无效运动参数');
  }

  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
  checkActive();
  const start = performance.now();
  let previous = start;

  for (;;) {
    await sleep(16);
    checkActive();
    const now = performance.now();

    if (now - previous > 250) {
      throw new Error('页面停顿过久,取消拖动');
    }

    const progress = Math.min(1, (now - start) / durationMs);
    emit(progress === 1 ? distance : distance * minimumJerk(progress));
    previous = now;
    if (progress === 1) return;
  }
}

The full implementation also checks for clock anomalies and ends the current input state on cancellation. The parameters chosen here were:

  • the planned duration of the main drag varies with distance and is clamped to 650–1200 ms;
  • the sampling target is about 16 ms, but missed frames are not back-filled;
  • if a small correction is needed it uses the same curve and takes 140–240 ms;
  • no random jitter is added, and no deliberate overshoot or pull-back;
  • if the pause exceeds 250 ms it aborts, so execution cannot suddenly jump to the end point when it resumes.

Mouse and touch input share this displacement algorithm; only the event dispatch differs. A smoother trajectory does not mean the verification service will accept it, and it does not by itself imply a higher pass rate.

6. What really needs care: asynchronous callbacks

Getting the image algorithm right only solves part of the problem. The rest comes from the business lifecycle.

Do not treat verification success as business success

Some pages start a business write immediately inside the verification success callback. So testing cannot stop at whether the slider lines up; you also have to confirm what happens after the callback.

In the offline tests I replaced the SDK, business requests and state with mocks, using the real images only for algorithm and interaction verification; the standalone verification test used an isolated success callback, so the verification result never went straight into the business write path.

One success, handed over at most once

A success callback should not be assumed to fire exactly once. Before handing over to business logic, check that:

  • this run has not been cancelled;
  • no success result has been handed over yet;
  • the page and the selected business object have not changed;
  • the current state still satisfies the execution conditions.

Once handed over, duplicate callbacks should be ignored. Failure should not fall into a default degraded branch that might submit a business operation directly.

Three easy-to-miss edges

First, React's DOM Fiber references may point at a stale tree. You cannot read one snapshot of Hooks state and assume it is current. This implementation starts from the root's current and walks the committed child/sibling tree, keeping the ancestor path itself so that stale alternate or expired return pointers cannot skew the decision. This relies on React internals; when a public state API exists, prefer it.

Second, a synchronous throw from the SDK is not necessarily caught by the outer button.click() caller. The SDK may store the callback first and throw afterwards. If you only wait for the error at the outer layer, a late success callback can still run. So the startup exception has to be caught inside the SDK wrapper function, which immediately closes the success hand-over channel for this run.

Third, a late error after success must not override a business result that is still pending. When success has been handed over and the business request is still in flight, a late verification error should not immediately tear down the response observer — keep waiting for the business response or time out explicitly, otherwise an operation that actually succeeded may be shown as a verification failure.

Also, destroying a component does not necessarily invoke the close callback. The cleanup phase should make sure the original close logic runs once, so the page's own refresh timer does not restart verification.

7. Stoppable and de-duplicated, but no promise to retract requests

Engineering also means limiting the blast radius of the automation:

  • read only the real business state the page loaded, and never fake execution conditions by editing front-end data;
  • use localStorage to store a one-shot attempt record, plus a same-origin Web Lock to prevent concurrent starts from multiple tabs;
  • no automatic retry after failure, timeout or refresh — the record is cleared only after a human confirms the result;
  • when the request outcome is unclear, show "unknown"; never read a timeout as a failure;
  • every temporarily wrapped SDK method, network observer and event listener must be restored.

One boundary has to be explicit here: stopping further automation is not the same as retracting requests the original page has already sent. Once a request has been handed to the business system, the outcome has to be confirmed from server state. Likewise, a local de-duplication record cannot sync across devices and cannot replace server-side idempotency control.

8. Test results and the limits of the conclusions

In the end I did not verify the script with real business writes; testing was layered:

Level Result in this round
Node.js unit and simulated-flow tests 32 passed
Both background reconstructions Pixel-identical to the page canvas under unified drawing conditions
Touch input offline integration test 53 pixels, 41 trajectory points, about 679 ms
Mouse input offline integration re-test 53 pixels, 41 trajectory points, about 680 ms
Trajectory properties Monotonic horizontally, no overshoot, non-uniform step size
Simulated business request per input mode 1
Real business writes in the final offline integration test 0

The tests also covered mid-flight cancellation, timer stalls, invalid parameters, duplicate callbacks, stale React state, late success after a startup exception, and late errors after success.

Mouse mode once ended early; an independent re-test passed. A single pass does not prove stability under every scheduling condition — keeping stall detection and failure-stop behaviour matters more than hiding failures.

Finally, several conclusions need to be kept apart:

  • correct image reconstruction does not mean the interaction events will be accepted;
  • standalone verification success does not mean the final business operation succeeded;
  • the latency ranking a component displays is not the automation success rate;
  • passing offline integration does not mean it has been verified in an extension-installed environment or in production.

The online acceptance rate of the new trajectory, real business submission, and the final extension-installed environment were all left unverified by this round of offline testing.

9. A pre-publication anonymisation checklist

Anonymising a technical post is not just replacing domains; you also have to check the context around them.

Information type How this article handles it
Company, platform, product, vendor names Generic labels such as system A and component B
Domains, endpoints, resource paths Reserved example domains and placeholder paths
Business objects, accounts, sessions, resource ids Removed; no linkable real identifiers kept
Cookies, headers, keys, verification tokens Not shown; no raw request logs attached
Actual version identifiers, fixed arrangement parameters, hashes Specific values omitted; verification method and conclusions kept
Page screenshots and local paths Not referenced, to avoid leaking avatars, org info and file locations
Generic technical names, formulas and test parameters Kept, to explain the implementation and allow re-checking

Summary

The point of this exercise was not a trajectory that "looks more human", but an execution chain you can explain, check and stop:

  1. use the original resources to reduce the uncertainty of screenshot measurement;
  2. reconstruct the images from the actual rendering rules and prove correctness pixel by pixel;
  3. derive an explainable displacement from diffs, masks and coordinate conversion;
  4. use a non-Bézier minimum-jerk curve to improve start/stop and timing behaviour;
  5. control side effects with one-shot callbacks, explicit unknown states and layered testing.

Dragging to the right position is only the starting point. Knowing why it is right, when you must stop, and which conclusions remain unverified is what makes the implementation complete.

Appendix: the complete code

This script is the minimal implementation behind this article. It only does offline image restoration and localisation — no verification submission, no mouse or touch automation. Run it in a page in the same directory as the assets; the page needs the elements #fullbg,#bg,#difference,#alignment,#distance,#position,#verification,#gap,#permutation,#hashes,#status,#results, and#download to exist, plus the following files:

File Purpose
raw-fullbg.webp Complete background source image (312×160)
raw-bg.webp Gapped background source image
slice.png Transparent puzzle PNG
evidence.json The pixel hashes recorded on the live page, used for pixel-by-pixel verification

The code maps onto the sections above one by one:permutation() reproduces the slice arrangement,restore() reconstructs the image from the rendering rules,difference() and largestRegion() find the differing region,matchPiece() matches using the eroded inner pixels,hashPixels() and then evidence.json is used for the comparison; the result is also written to window.analysisResult so it can be checked afterwards.

/* Offline image restoration only. No challenge submission or mouse/touch automation. */
'use strict';

const WIDTH = 260;
const HEIGHT = 160;
const SEED = [6, 11, 7, 10, 4, 12, 3, 1, 0, 5, 2, 9, 8];

function permutation() {
  const order = Array.from({ length: 52 }, (_, r) => {
    let t = 2 * SEED[Math.floor((r % 26) / 2)] + r % 2;
    if (Math.floor(r / 2) % 2 === 0) t += r % 2 ? -1 : 1;
    return t + (r < 26 ? 26 : 0);
  });
  if (new Set(order).size !== 52 || order.some(x => x < 0 || x > 51)) {
    throw new Error('切片排列不是 0–51 的完整置换');
  }
  return order;
}

function loadImage(src) {
  return new Promise((resolve, reject) => {
    const image = new Image();
    image.onload = () => resolve(image);
    image.onerror = () => reject(new Error(`无法加载图片:${src}`));
    image.src = src;
  });
}

function imagePixels(image) {
  const canvas = document.createElement('canvas');
  canvas.width = image.naturalWidth;
  canvas.height = image.naturalHeight;
  // Match the source page's default canvas path; willReadFrequently changes pixel rounding here.
  const ctx = canvas.getContext('2d');
  ctx.drawImage(image, 0, 0);
  return ctx.getImageData(0, 0, canvas.width, canvas.height);
}

function restore(image, canvas) {
  if (image.naturalWidth !== 312 || image.naturalHeight !== HEIGHT) {
    throw new Error('此切片规则只适用于本样本的 312×160 原图');
  }
  const source = imagePixels(image);
  const output = new ImageData(WIDTH, HEIGHT);
  for (const [i, j] of permutation().entries()) {
    const sx = (j % 26) * 12 + 1;
    const sy = Math.floor(j / 26) * 80;
    const dx = (i % 26) * 10;
    const dy = Math.floor(i / 26) * 80;
    for (let y = 0; y < 80; y++) {
      const start = ((sy + y) * 312 + sx) * 4;
      output.data.set(source.data.subarray(start, start + 10 * 4), ((dy + y) * WIDTH + dx) * 4);
    }
  }
  canvas.width = WIDTH;
  canvas.height = HEIGHT;
  canvas.getContext('2d').putImageData(output, 0, 0);
  return output;
}

function difference(a, b) {
  const values = new Uint8Array(WIDTH * HEIGHT);
  for (let p = 0; p < values.length; p++) {
    const i = p * 4;
    values[p] = Math.max(...[0, 1, 2].map(c => Math.abs(a.data[i + c] - b.data[i + c])));
  }
  return values;
}

function largestRegion(diff, threshold) {
  const visited = new Uint8Array(diff.length);
  let largest = [];
  for (let start = 0; start < diff.length; start++) {
    if (visited[start] || diff[start] <= threshold) continue;
    const queue = [start];
    visited[start] = 1;
    for (let head = 0; head < queue.length; head++) {
      const p = queue[head], x = p % WIDTH, y = Math.floor(p / WIDTH);
      const next = [];
      if (x > 0) next.push(p - 1);
      if (x < WIDTH - 1) next.push(p + 1);
      if (y > 0) next.push(p - WIDTH);
      if (y < HEIGHT - 1) next.push(p + WIDTH);
      for (const q of next) {
        if (!visited[q] && diff[q] > threshold) {
          visited[q] = 1;
          queue.push(q);
        }
      }
    }
    if (queue.length > largest.length) largest = queue;
  }
  if (!largest.length) throw new Error('没有找到明显差异区域');
  const xs = largest.map(p => p % WIDTH);
  const ys = largest.map(p => Math.floor(p / WIDTH));
  return { pixels: largest, bbox: [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] };
}

function matchPiece(full, piece, diff) {
  // Ignore transparency, antialiased edges and the bright outline:
  // keep alpha > 250, then erode the mask with a 7×7 square.
  const core = [];
  const { width: w, height: h, data } = piece;
  for (let y = 3; y < h - 3; y++) {
    for (let x = 3; x < w - 3; x++) {
      let keep = true;
      for (let yy = y - 3; yy <= y + 3 && keep; yy++) {
        for (let xx = x - 3; xx <= x + 3; xx++) {
          if (data[(yy * w + xx) * 4 + 3] <= 250) { keep = false; break; }
        }
      }
      if (keep) core.push({ x, y, index: (y * w + x) * 4 });
    }
  }
  if (!core.length) throw new Error('拼图内部像素不足,无法匹配');
  const candidates = [];
  for (let y = 0; y <= HEIGHT - h; y++) {
    for (let x = 0; x <= WIDTH - w; x++) {
      let changed = 0;
      for (const p of core) if (diff[(y + p.y) * WIDTH + x + p.x] > 40) changed++;
      if (changed / core.length < 0.7) continue;
      let squaredError = 0;
      for (const p of core) {
        const index = ((y + p.y) * WIDTH + x + p.x) * 4;
        for (let c = 0; c < 3; c++) squaredError += (full.data[index + c] - data[p.index + c]) ** 2;
      }
      candidates.push({ x, y, mse: squaredError / (core.length * 3) });
    }
  }
  candidates.sort((a, b) => a.mse - b.mse);
  if (!candidates.length) throw new Error('没有足够覆盖缺口的拼图匹配位置');
  return { best: candidates[0], topMatches: candidates.slice(0, 5), corePixels: core.length };
}

async function hashPixels(data) {
  return [...new Uint8Array(await crypto.subtle.digest('SHA-256', data))]
    .map(value => value.toString(16).padStart(2, '0')).join('');
}

function showDifference(diff, region) {
  const canvas = document.getElementById('difference');
  canvas.width = WIDTH;
  canvas.height = HEIGHT;
  const ctx = canvas.getContext('2d');
  const pixels = new ImageData(WIDTH, HEIGHT);
  for (let p = 0; p < diff.length; p++) {
    const value = Math.min(255, diff[p] * 2);
    pixels.data.set([value, Math.round(value * 0.25), 30, 255], p * 4);
  }
  ctx.putImageData(pixels, 0, 0);
  const [x0, y0, x1, y1] = region.bbox;
  ctx.strokeStyle = '#fff';
  ctx.strokeRect(x0 - 1, y0 - 1, x1 - x0 + 3, y1 - y0 + 3);
}

function showAlignment(background, slice, best) {
  const canvas = document.getElementById('alignment');
  canvas.width = WIDTH;
  canvas.height = HEIGHT;
  const ctx = canvas.getContext('2d');
  ctx.putImageData(background, 0, 0);
  ctx.drawImage(slice, best.x, best.y);
  ctx.strokeStyle = '#ffda72';
  ctx.lineWidth = 1;
  ctx.strokeRect(best.x, best.y, slice.naturalWidth, slice.naturalHeight);
  ctx.setLineDash([3, 3]);
  ctx.strokeStyle = '#fff';
  ctx.strokeRect(0.5, best.y, slice.naturalWidth, slice.naturalHeight);
  ctx.setLineDash([]);
  ctx.fillStyle = '#111827';
  ctx.fillRect(10, 126, 145, 24);
  ctx.fillStyle = '#fff';
  ctx.font = '13px sans-serif';
  ctx.fillText(`x: 0 → ${best.x} px`, 20, 143);
}

async function main() {
  const [rawFull, rawBg, slice, evidence] = await Promise.all([
    loadImage('raw-fullbg.webp'), loadImage('raw-bg.webp'), loadImage('slice.png'),
    fetch('evidence.json').then(r => { if (!r.ok) throw new Error('无法读取校验证据'); return r.json(); })
  ]);
  const full = restore(rawFull, document.getElementById('fullbg'));
  const bg = restore(rawBg, document.getElementById('bg'));
  const diff = difference(full, bg);
  const region = largestRegion(diff, 40);
  const match = matchPiece(full, imagePixels(slice), diff);
  const hashes = { fullbg: await hashPixels(full.data), bg: await hashPixels(bg.data) };
  const hashMatches = Object.fromEntries(Object.entries(hashes).map(([kind, hash]) =>
    [kind, hash === evidence.livePageVerification[kind].rgbaSha256]));
  showDifference(diff, region);
  showAlignment(bg, slice, match.best);
  const allPass = Object.values(hashMatches).every(Boolean);
  document.getElementById('distance').textContent = `${match.best.x} px`;
  document.getElementById('position').textContent = `最佳贴图位置 (${match.best.x}, ${match.best.y})`;
  document.getElementById('verification').textContent = allPass ? '2 / 2 哈希一致' : '哈希不一致:请检查画布绘制与读取差异';
  document.getElementById('gap').textContent = `差异区域:(${region.bbox.join(', ')});${region.pixels.length} 个连通像素。`;
  document.getElementById('permutation').textContent = JSON.stringify(permutation());
  document.getElementById('hashes').textContent = JSON.stringify({ hashes, hashMatches }, null, 2);
  document.getElementById('status').textContent = allPass ? '完成:复原、定位与哈希校验均通过' : '定位完成,但本环境的像素哈希与现场记录不一致';
  document.getElementById('status').classList.add(allPass ? 'success' : 'warning');
  const result = {
    sample: evidence.sample, rawSize: [312, 160], restoredSize: [WIDTH, HEIGHT],
    permutation: permutation(), target: match.best, displacementFromImageOrigin: match.best.x,
    corePixels: match.corePixels, topMatches: match.topMatches,
    gapBoundingBoxInclusive: region.bbox, hashes, hashMatches,
    submittedNewVerification: false
  };
  window.analysisResult = result;
  document.getElementById('results').textContent = JSON.stringify(result, null, 2);
  const download = document.getElementById('download');
  download.href = URL.createObjectURL(new Blob([JSON.stringify(result, null, 2)], { type: 'application/json' }));
  download.hidden = false;
  document.body.dataset.state = allPass ? 'complete' : 'warning';
}

main().catch(error => {
  document.getElementById('status').textContent = `分析失败:${error.message}`;
  document.body.dataset.state = 'error';
  console.error(error);
});

Note:SEED, slice size, erosion radius and diff threshold apply only to this sample — after a component version change you must re-verify rather than reusing them.