Barcode on a bottle — realistic test photos from one clean code

A clean DataMatrix (a medicine pack code) is wrapped around a bottle and lit like a phone photo: the modules narrow towards the edges, the rows bend into arcs when the camera looks down, light falls off to the sides, plus glare, sensor noise and blur. The cylinder is a stampGrid mesh computed in the script — no hand-drawn grid. MLC Barcode builds the test set for its reader exactly like this: every image comes from a code with known content, so a test can check what was read. The same idea yields training data for vision models — any number of variants, labels for free.

INPUT
INPUT — Barcode on a bottle — realistic test photos from one clean code
1 clean · 2 bottle 60° · 3 90° from above · 4 turned 30°, from above
1 clean · 2 bottle 60° · 3 90° from above · 4 turned 30°, from above — Barcode on a bottle — realistic test photos from one clean code
JavaScript
// Barcode on a bottle — realistic test photos from one clean code.
// demo_barcode_bottle.js
//
// A clean DataMatrix (a medicine pack code) is wrapped around a bottle and
// lit like a phone photo: the code narrows towards the edges, its rows
// bend into arcs when the camera looks down, the light falls off to the
// sides, a glare stripe, sensor noise and blur. Four
// variants in a 2×2 grid — from flat to "hard to read".
//
// This is how MLC Barcode builds the test set for its reader
// (github.com/hmsoft0815/mlc_barcode, tests/testdata/cylinder.js):
// every image is generated from a code with known content, so a test can
// check what was read. The same idea gives training data for vision
// models — any number of variants, labels for free.
//
// The cylinder is a stampGrid mesh computed in the script (no hand-drawn
// grid): x = cx + R·sin θ, and seen from above a point drops by
// R·(cos θ − cos θ₀)·sin(tilt).
//!INPUT: INPUT
//!OUTPUT: OUTPUT
//!PARAM: NOISE:number=0.03,min=0,max=0.3
//!PARAM: GLARE:number=0.25,min=0,max=1
//!PARAM: SEED:integer=1,min=0,max=1000000
// SEED: gleiche Zahl → gleiches Rauschen (reproduzierbar); 0 → jedes Mal neu.

// 2×2 tiles of 640×400 (photo + caption band): 1280×800, the 16:10 of
// the gallery cards, so the preview shows all four steps.
const TW = 640, TH = 356, LABEL = 44;
const rad = (d) => (d * Math.PI) / 180;

// One photo: SPAN degrees of the circumference covered by the code, the
// bottle turned by TURN, the camera looking down by TILT. SPAN 0 = flat.
function bottlePhoto(span, turn, tilt) {
  const img = Engine.createColoredImage(TW, TH, "#8a8478");
  const code = Engine.loadImage(INPUT);
  const codeW = 180;
  if (span === 0) {
    img.fillRectangle(0, 0, TW, TH, "#f6f5f0");
    const x0 = (TW - codeW) / 2, y0 = (TH - codeW) / 2;
    img.stampAt(code, [px(x0, y0), px(x0 + codeW, y0), px(x0 + codeW, y0 + codeW), px(x0, y0 + codeW)]);
    code.free();
    return img;
  }
  const R = codeW / rad(span);
  const cx = TW / 2 - R * Math.sin(rad(turn)); // the code stays centred
  const left = Math.max(0, Math.round(cx - R)), right = Math.min(TW, Math.round(cx + R));
  img.fillRectangle(left, 0, right - left, TH, "#f6f5f0");

  const cols = 33, rows = 3;
  const height = codeW * Math.cos(rad(tilt));
  const top = (TH - height) / 2;
  const drop = (t) => R * (Math.cos(t) - Math.cos(rad(turn))) * Math.sin(rad(tilt));
  const nodes = [];
  for (let r = 0; r < rows; r++) {
    const y = top + (height * r) / (rows - 1);
    const row = [];
    for (let c = 0; c < cols; c++) {
      const t = rad(turn) + (c / (cols - 1) - 0.5) * rad(span);
      row.push((cx + R * Math.sin(t)) / TW, (y + drop(t)) / TH);
    }
    nodes.push(row);
  }
  img.stampGrid(code, { rows, cols, nodes }, Interp.Bicubic);
  code.free();

  // Light falls off with the surface angle: multiply a gradient over the body.
  const bodyW = right - left;
  const cv = Engine.createCanvas(bodyW, TH);
  const stops = [], colors = [];
  for (let i = 0; i <= 16; i++) {
    const xr = (left + (i / 16) * bodyW - cx) / R;
    const v = Math.round(255 * (1 - 0.55 * (1 - Math.cos(Math.asin(Math.max(-1, Math.min(1, xr)))))));
    const h = v.toString(16).padStart(2, "0");
    stops.push(i / 16);
    colors.push(`#${h}${h}${h}`);
  }
  cv.linearGradient(px(0, 0), px(bodyW, 0), colors, stops);
  cv.drawPathStr(`M 0,0 L ${bodyW},0 L ${bodyW},${TH} L 0,${TH} Z`, false);
  const shade = cv.toImage();
  img.blendAt(shade, px(left, 0), 1.0, Blend.Multiply);
  shade.free();
  cv.free();

  // A soft glare stripe just beside the code.
  if (GLARE > 0) {
    const gx = cx + R * Math.sin(rad(turn - span / 2 - 12));
    const gw = Math.max(8, R * 0.18);
    const gcv = Engine.createCanvas(Math.round(2 * gw), TH);
    gcv.linearGradient(px(0, 0), px(2 * gw, 0), ["#00000000", "#ffffffff", "#00000000"], [0, 0.5, 1]);
    gcv.drawPathStr(`M 0,0 L ${2 * gw},0 L ${2 * gw},${TH} L 0,${TH} Z`, false);
    const glare = gcv.toImage();
    img.blendAt(glare, px(Math.round(gx - gw), 0), GLARE, Blend.Screen);
    glare.free();
    gcv.free();
  }
  return img;
}

const variants = [
  { span: 0, turn: 0, tilt: 0, label: "clean code" },
  { span: 60, turn: 0, tilt: 0, label: "on a bottle, 60°" },
  { span: 90, turn: 0, tilt: 20, label: "90°, camera from above" },
  { span: 90, turn: 30, tilt: 20, label: "90°, turned 30°, from above" },
];

const grid = Engine.createColoredImage(TW * 2, (TH + LABEL) * 2, "#1b1b1d");
variants.forEach((v, i) => {
  const x = (i % 2) * TW, y = Math.floor(i / 2) * (TH + LABEL);
  const photo = bottlePhoto(v.span, v.turn, v.tilt);
  if (v.span > 0) {
    photo.gaussianBlur(0.8);
    if (NOISE > 0) photo.addNoise({ type: "gaussian", sigma: NOISE, color: false, seed: SEED || undefined });
  }
  grid.blendAt(photo, px(x, y), 1.0, Blend.Over);
  photo.free();
  grid.drawText(`${i + 1}  ${v.label}`, x + TW / 2, y + TH + 30, { font: "Inter", size: 22, color: "#e8e6e1", anchor: "middle" });
});
// thin separators between the tiles
grid.fillRectangle(TW - 1, 0, 2, grid.height, "#1b1b1d");
grid.save(OUTPUT);

// © 2026 Michael Lechner · mlc OpticScript · https://mlcgo.eu · Elastic License 2.0