Resources Numerical accuracy Calculation policy

Burn levels of an engraved picture

Resamples a gray picture to the engraving grid and turns each cell into a burn level.

How the precision is kept

The resampled gray values are stored in a Float32Array before they are turned into burn levels. The picture's own pixels are 8-bit and the levels are 8-bit, but the averages between them pass through 32-bit, which can change the level of a cell whose average sits within 32-bit rounding of a threshold or a step.

Open item. Return the resampled gray values in a Float64Array, so burn levels are decided from 64-bit averages.

Calculations under this policy

2 calculations in 1 source file. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.

laser/laser-image.js CadCraft 0.8.2 32-bit in part 2 calculations Reviewed

Tests tools/laser/test.mjs

Gray value of each engraving cell

32-bit in part

The average gray of the picture's pixels under each cell of the engraving grid.

gray = Σ w × pixel ÷ Σ w, w = overlap area of pixel and cell

The weighted sum is formed in 64-bit and then stored in a Float32Array, which keeps about 7 digits. The stored value is what the burn level is decided from.

  • laser/laser-image.js
  • lines 18 to 39
  • CadCraft 0.8.2
export function resample(gray, pw, ph, cols, rows) {
  const out = new Float32Array(cols * rows);
  if (!(pw > 0 && ph > 0 && cols > 0 && rows > 0)) return out;
  const sx = pw / cols, sy = ph / rows;
  for (let r = 0; r < rows; r++) {
    const y0 = r * sy, y1 = (r + 1) * sy;
    for (let c = 0; c < cols; c++) {
      const x0 = c * sx, x1 = (c + 1) * sx;
      if (sx <= 1 && sy <= 1) { out[r * cols + c] = gray[Math.min(ph - 1, Math.floor((y0 + y1) / 2)) * pw + Math.min(pw - 1, Math.floor((x0 + x1) / 2))]; continue; }
      let sum = 0, wsum = 0;
      for (let j = Math.floor(y0); j < Math.min(ph, Math.ceil(y1)); j++) {
        const wy = Math.min(y1, j + 1) - Math.max(y0, j);
        for (let i = Math.floor(x0); i < Math.min(pw, Math.ceil(x1)); i++) {
          const w = wy * (Math.min(x1, i + 1) - Math.max(x0, i));
          sum += w * gray[j * pw + i]; wsum += w;
        }
      }
      out[r * cols + c] = wsum > 0 ? sum / wsum : 255;
    }
  }
  return out;
}

Burn level of each cell

32-bit in part

Turns the gray values into burn levels from 0 to 255 by the chosen method.

darkness = 1 − (gray ÷ 255)^gamma; threshold: darkness > t; gray steps: round(round(darkness × (n − 1)) ÷ (n − 1) × 255)

The darkness and the diffused error are 64-bit, but they start from the 32-bit gray values. The decision at a cell is a comparison with no tolerance, so a 32-bit rounding at exactly a threshold or half step can change that cell's level.

  • laser/laser-image.js
  • lines 43 to 78
  • CadCraft 0.8.2
export function dither(values, cols, rows, { method = 'floyd', threshold = 128, gamma = 1, invert = false, levels = 16 } = {}) {
  const out = new Uint8Array(cols * rows);
  const g = Number.isFinite(gamma) && gamma > 0 ? gamma : 1;
  // darkness 0 (white) … 1 (black), after gamma and invert
  const dark = new Float64Array(cols * rows);
  for (let i = 0; i < dark.length; i++) {
    let l = Math.max(0, Math.min(1, values[i] / 255));
    if (invert) l = 1 - l;
    dark[i] = 1 - Math.pow(l, g);
  }
  if (method === 'threshold') {
    const t = 1 - Math.max(0, Math.min(255, threshold)) / 255;        // burn when the darkness exceeds this
    for (let i = 0; i < dark.length; i++) out[i] = dark[i] > t ? 255 : 0;
  } else if (method === 'bayer') {
    for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) out[r * cols + c] = dark[r * cols + c] > (BAYER4[r % 4][c % 4] + 0.5) / 16 ? 255 : 0;
  } else if (method === 'gray') {
    const n = Math.max(2, Math.min(256, Math.floor(levels) || 16));
    for (let i = 0; i < dark.length; i++) out[i] = Math.round(Math.round(dark[i] * (n - 1)) / (n - 1) * 255);
  } else {                                                              // 'floyd' or 'atkinson'
    const atk = method === 'atkinson';
    const e = Float64Array.from(dark);
    for (let r = 0; r < rows; r++) {
      const fwd = r % 2 === 0, d = fwd ? 1 : -1;
      for (let k = 0; k < cols; k++) {
        const c = fwd ? k : cols - 1 - k, i = r * cols + c;
        const v = e[i], o = v >= 0.5 ? 1 : 0;
        out[i] = o ? 255 : 0;
        const err = v - o;
        const add = (dc, dr, w) => { const cc = c + dc, rr = r + dr; if (cc >= 0 && cc < cols && rr < rows) e[rr * cols + cc] += err * w; };
        if (atk) { for (const [dc, dr] of [[d, 0], [2 * d, 0], [-d, 1], [0, 1], [d, 1], [0, 2]]) add(dc, dr, 1 / 8); }
        else { add(d, 0, 7 / 16); add(-d, 1, 3 / 16); add(0, 1, 5 / 16); add(d, 1, 1 / 16); }
      }
    }
  }
  return out;
}

In detail

Method
Area average when shrinking, nearest pixel when growing; threshold, Floyd–Steinberg (weights 7, 3, 5 and 1 sixteenths), Atkinson (eighths), 4 × 4 ordered dither, or gray steps. The diffusion weights are exact binary fractions.
Number types
Gray averages in Float32Array; darkness and diffused error in Float64Array; levels in Uint8Array.
Limits
The picture's pixels and the burn levels are 8-bit, so a level has 256 steps at most.

Approach: 32-bit step, flagged

One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.

Every policy that takes this approach