v2.6.0

<bmx-annotation-studio>

Image labelling for machine-learning training data: boxes, rotated boxes, polygons, lines, points, keypoint skeletons and painted masks, each with a class and attributes, on a queue of images with review states, imported from and exported to COCO, YOLO and Pascal VOC.

14 properties · 3 events · 21 methods · 4 parts

Example

B draws a box, P a polygon, K a skeleton, M paints a mask; 1 to 6 pick the class. With the image focused, the arrow keys move a cross and Space places it. Export gives a COCO, YOLO or Pascal VOC ZIP.

Show markup
<div style="display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; margin: 0 0 12px">
  <label>Mode <select id="ex-as-mode"><option value="annotate">Annotate</option><option value="review">Review</option></select></label>
  <button type="button" id="ex-as-todo">Next image to do</button>
</div>
<bmx-annotation-studio id="ex-as" label="Street scenes" allow-add></bmx-annotation-studio>
<p class="note" id="ex-as-out" role="status" style="margin: 12px 0 0">B draws a box, P a polygon, K a skeleton, M paints a mask; 1 to 6 pick the class. With the image focused, the arrow keys move a cross and Space places it. Export gives a COCO, YOLO or Pascal VOC ZIP.</p>

<script type="module">
  await customElements.whenDefined('bmx-annotation-studio');
  const studio = document.getElementById('ex-as');
  const out = document.getElementById('ex-as-out');
  const W = 640;
  const H = 400;
  const HORIZON = 170;

  // A small seeded random generator, so every visit draws the same scenes.
  const random = seed => () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296);

  // The top of the road in each column: a straight road running to the horizon.
  const roadTop = x => (x < 260 ? HORIZON + ((260 - x) / 260) * (H - HORIZON) : x > 380 ? HORIZON + ((x - 380) / 260) * (H - HORIZON) : HORIZON);

  // The road as a mask: run lengths down each column in turn, starting with background.
  const roadMask = () => {
    const counts = [];
    for (let x = 0; x < W; x++) {
      const top = Math.min(H, Math.round(roadTop(x)));
      counts.push(top, H - top);
    }
    return { counts, size: [H, W] };
  };

  // The person's joints, in the order of the class's skeleton.
  const SKELETON = ['head', 'neck', 'left hand', 'right hand', 'hip', 'left foot', 'right foot'];
  const personPoints = (x, y, s) => [
    [x, y - 62 * s],
    [x, y - 50 * s],
    [x - 16 * s, y - 30 * s],
    [x + 16 * s, y - 30 * s],
    [x, y - 24 * s],
    [x - 10 * s, y],
    [x + 10 * s, y],
  ];

  /** Draws one street scene and returns the picture with the shapes in it. */
  const scene = n => {
    const rnd = random(n * 7919 + 13);
    const dusk = n % 5 === 3;
    const canvas = document.createElement('canvas');
    canvas.width = W;
    canvas.height = H;
    const g = canvas.getContext('2d');
    const sky = g.createLinearGradient(0, 0, 0, HORIZON);
    sky.addColorStop(0, dusk ? '#2c2f5a' : '#7fb4e6');
    sky.addColorStop(1, dusk ? '#e08a5a' : '#d9ecfa');
    g.fillStyle = sky;
    g.fillRect(0, 0, W, HORIZON);
    g.fillStyle = dusk ? '#3f5a35' : '#7cab5a';
    g.fillRect(0, HORIZON, W, H - HORIZON);
    g.fillStyle = dusk ? '#3b3d44' : '#5f6168';
    g.beginPath();
    g.moveTo(260, HORIZON);
    g.lineTo(380, HORIZON);
    g.lineTo(W, H);
    g.lineTo(0, H);
    g.closePath();
    g.fill();
    // The centre line, as dashes getting longer towards the viewer.
    const lane = [];
    g.strokeStyle = '#f2f2f2';
    for (let t = 0.05; t < 1; t += 0.12) {
      const y0 = HORIZON + t * (H - HORIZON);
      const y1 = HORIZON + (t + 0.06) * (H - HORIZON);
      g.lineWidth = 1 + t * 5;
      g.beginPath();
      g.moveTo(320, y0);
      g.lineTo(320, y1);
      g.stroke();
    }
    lane.push([320, HORIZON + 4], [320, H]);
    const shapes = { trees: [], cars: [], parked: null, people: [], signs: [], lane };
    // Trees either side.
    for (let k = 0; k < 2 + Math.floor(rnd() * 3); k++) {
      const left = k % 2 === 0;
      const x = left ? 30 + rnd() * 170 : 450 + rnd() * 160;
      const y = HORIZON + 10 + rnd() * 40;
      const s = 0.8 + rnd() * 0.6;
      g.fillStyle = '#6b4a2b';
      g.fillRect(x - 4 * s, y - 10 * s, 8 * s, 22 * s);
      g.fillStyle = dusk ? '#24452a' : '#2f7d3a';
      const poly = [
        [x, y - 70 * s],
        [x + 30 * s, y - 8 * s],
        [x - 30 * s, y - 8 * s],
      ];
      g.beginPath();
      poly.forEach(([px, py], i) => (i ? g.lineTo(px, py) : g.moveTo(px, py)));
      g.closePath();
      g.fill();
      shapes.trees.push(poly.map(([px, py]) => [Math.round(px), Math.round(py)]));
    }
    // A road sign on a post.
    if (n % 2 === 0) {
      const x = 410 + rnd() * 30;
      const y = HORIZON + 30;
      g.fillStyle = '#9aa0a6';
      g.fillRect(x - 1.5, y - 40, 3, 40);
      g.fillStyle = '#d93025';
      g.beginPath();
      g.arc(x, y - 46, 9, 0, Math.PI * 2);
      g.fill();
      shapes.signs.push([Math.round(x), Math.round(y - 46)]);
    }
    // Cars on the road, bigger as they come closer.
    const colours = ['#c62828', '#1565c0', '#f9a825', '#eeeeee', '#2e7d32', '#212121'];
    const names = ['red', 'blue', 'yellow', 'white', 'green', 'black'];
    for (let k = 0; k < 1 + Math.floor(rnd() * 3); k++) {
      const t = 0.25 + rnd() * 0.65;
      const y = HORIZON + t * (H - HORIZON);
      const half = 60 + 260 * t;
      const s = 0.4 + t * 1.3;
      const w = 70 * s;
      const h = 34 * s;
      const x = 320 + [-0.75, 0.1, -0.3][k] * half;
      const c = Math.floor(rnd() * colours.length);
      g.fillStyle = colours[c];
      g.fillRect(x, y - h, w, h * 0.7);
      g.fillRect(x + w * 0.18, y - h * 1.35, w * 0.6, h * 0.45);
      g.fillStyle = '#9fd3f5';
      g.fillRect(x + w * 0.24, y - h * 1.28, w * 0.48, h * 0.32);
      g.fillStyle = '#111';
      g.beginPath();
      g.arc(x + w * 0.22, y - h * 0.28, h * 0.22, 0, Math.PI * 2);
      g.arc(x + w * 0.78, y - h * 0.28, h * 0.22, 0, Math.PI * 2);
      g.fill();
      shapes.cars.push({ box: [Math.round(x), Math.round(y - h * 1.35), Math.round(w), Math.round(h * 1.35 - h * 0.28 + h * 0.22)], colour: names[c], occluded: k > 0 && rnd() < 0.3 });
    }
    // A car parked at an angle on the verge.
    if (n % 3 === 1) {
      const cx = 540;
      const cy = 330;
      const angle = -24;
      g.save();
      g.translate(cx, cy);
      g.rotate((angle * Math.PI) / 180);
      g.fillStyle = '#6a1b9a';
      g.fillRect(-55, -24, 110, 48);
      g.fillStyle = '#9fd3f5';
      g.fillRect(-20, -20, 34, 40);
      g.restore();
      shapes.parked = { box: [cx - 55, cy - 24, 110, 48], angle };
    }
    // People on the grass.
    for (let k = 0; k < (n % 4 === 0 ? 2 : 1); k++) {
      const x = k ? 600 - rnd() * 40 : 60 + rnd() * 60;
      const y = 300 + rnd() * 70;
      const s = 0.9 + rnd() * 0.5;
      const p = personPoints(x, y, s);
      g.strokeStyle = dusk ? '#f5d0a9' : '#1d1d1d';
      g.lineWidth = 4 * s;
      g.lineCap = 'round';
      const seg = (a, b) => {
        g.beginPath();
        g.moveTo(...p[a]);
        g.lineTo(...p[b]);
        g.stroke();
      };
      [[1, 4], [1, 2], [1, 3], [4, 5], [4, 6]].forEach(([a, b]) => seg(a, b));
      g.fillStyle = g.strokeStyle;
      g.beginPath();
      g.arc(p[0][0], p[0][1], 9 * s, 0, Math.PI * 2);
      g.fill();
      shapes.people.push(p.map(([px, py]) => [Math.round(px), Math.round(py), 2]));
    }
    return { src: canvas.toDataURL('image/jpeg', 0.86), dusk, shapes };
  };

  /** The annotations of a scene: exact for labelled images, a little off and scored for the model's guesses. */
  const annotationsOf = (n, shapes, predicted) => {
    const rnd = random(n * 31 + 7);
    const jitter = v => (predicted ? Math.round(v + (rnd() - 0.5) * 8) : v);
    const score = () => (predicted ? { confidence: Math.round((0.35 + rnd() * 0.63) * 100) / 100 } : {});
    const list = [];
    let id = 0;
    const next = () => `${n}-${++id}`;
    if (!predicted) list.push({ id: next(), classId: 'road', type: 'mask', mask: roadMask() });
    for (const car of shapes.cars) list.push({ id: next(), classId: 'car', type: 'box', box: car.box.map(jitter), ...score(), attributes: { colour: car.colour, occluded: car.occluded } });
    if (shapes.parked) list.push({ id: next(), classId: 'car', type: 'rotated', box: shapes.parked.box, angle: shapes.parked.angle, ...score(), attributes: { colour: 'purple', parked: true } });
    for (const p of shapes.people) list.push({ id: next(), classId: 'person', type: 'keypoints', keypoints: p.map(([x, y, v]) => [jitter(x), jitter(y), v]), ...score() });
    for (const t of shapes.trees) list.push({ id: next(), classId: 'tree', type: 'polygon', points: t.map(([x, y]) => [jitter(x), jitter(y)]), ...score() });
    for (const s of shapes.signs) list.push({ id: next(), classId: 'sign', type: 'point', points: [s], ...score(), attributes: { kind: 'No entry' } });
    if (!predicted) list.push({ id: next(), classId: 'lane', type: 'polyline', points: shapes.lane });
    return list;
  };

  // Twenty-four frames: approved, labelled, part done, model guesses to check, and still to do.
  const images = Array.from({ length: 24 }, (_, n) => {
    const { src, dusk, shapes } = scene(n);
    const status = n < 5 ? 'approved' : n === 5 ? 'rejected' : n < 10 ? 'labelled' : n < 13 ? 'in-progress' : undefined;
    let annotations = [];
    if (n < 10) annotations = annotationsOf(n, shapes, false);
    else if (n < 13) annotations = annotationsOf(n, shapes, false).slice(0, 2);
    else if (n < 18) annotations = annotationsOf(n, shapes, true);
    return {
      id: `frame-${String(n + 1).padStart(4, '0')}`,
      name: `frame-${String(n + 1).padStart(4, '0')}.jpg`,
      src,
      width: W,
      height: H,
      ...(status ? { status } : {}),
      ...(n === 5 ? { reviewNote: 'The parked car is missing its colour.' } : {}),
      attributes: { weather: n % 4 === 2 ? 'Overcast' : 'Clear', light: dusk ? 'Dusk' : 'Day' },
      annotations,
    };
  });

  studio.project = {
    name: 'Street scenes',
    classes: [
      { id: 'car', name: 'Car', color: '#e6194b', types: ['box', 'rotated', 'polygon'], attributes: [
        { id: 'colour', label: 'Colour', type: 'select', options: ['red', 'blue', 'yellow', 'white', 'green', 'black', 'purple'], required: true },
        { id: 'occluded', label: 'Partly hidden', type: 'boolean' },
        { id: 'parked', label: 'Parked', type: 'boolean' },
      ] },
      { id: 'person', name: 'Person', color: '#f58231', types: ['keypoints', 'box'], skeleton: { points: SKELETON, edges: [[0, 1], [1, 2], [1, 3], [1, 4], [4, 5], [4, 6]] } },
      { id: 'tree', name: 'Tree', color: '#3cb44b', types: ['polygon', 'mask'] },
      { id: 'road', name: 'Road', color: '#4363d8', types: ['mask', 'polygon'] },
      { id: 'sign', name: 'Road sign', color: '#f032e6', types: ['point', 'box'], attributes: [{ id: 'kind', label: 'Kind', type: 'select', options: ['No entry', 'Stop', 'Give way', 'Speed limit'] }] },
      { id: 'lane', name: 'Lane line', color: '#ffe119', types: ['polyline'] },
    ],
    imageAttributes: [
      { id: 'weather', label: 'Weather', type: 'select', options: ['Clear', 'Overcast', 'Rain'] },
      { id: 'light', label: 'Light', type: 'select', options: ['Day', 'Dusk', 'Night'] },
    ],
    images,
  };
  studio.image = 'frame-0011';

  document.getElementById('ex-as-mode').addEventListener('change', e => (studio.mode = e.target.value));
  document.getElementById('ex-as-todo').addEventListener('click', async () => {
    const project = await studio.getProject();
    const todo = project.images.find(im => !im.status || im.status === 'todo' || im.status === 'in-progress');
    if (todo) await studio.selectImage(todo.id);
  });
  studio.addEventListener('bmxChange', e => {
    const { reason, project } = e.detail;
    const done = project.images.filter(im => im.status === 'labelled' || im.status === 'approved').length;
    out.textContent = `${reason}: ${done} of ${project.images.length} images labelled or approved.`;
  });
</script>
<bmx-annotation-studio id="studio" user="Ada"></bmx-annotation-studio>
<script>
  studio.project = {
    classes: [{ id: 'car', color: '#e6194b' }, { id: 'person', skeleton: { points: ['head', 'hand_l', 'hand_r'] } }],
    images: [{ id: 'img1', src: '/frames/0001.jpg' }],
  };
  studio.addEventListener('bmxChange', e => save(e.detail.project));
  const { zip } = await studio.exportZip('yolo');
</script>

Draw with the mouse, a pen or touch, or entirely from the keyboard (a cross moved with the arrow keys places corners and points). Shapes can be moved, resized, rotated and reshaped by their handles or with the keyboard, and their positions typed in the panel. Model predictions (annotations with a confidence) are shown dashed, hidden below a threshold, and accepted with one click. Images move from to do to labelled; reviewers approve or reject them with a note. Images are shown from the addresses given - nothing is uploaded, and the project is plain JSON the page keeps.

Properties

PropertyAttributeTypeDefaultDescription
activeClass active-class string — The class new shapes get. Default: the first. Two-way.
allowAdd allow-add boolean false Lets the reader add image files (shown with blob: addresses; bmxAddImages hands the files to the page).
fillOpacity fill-opacity number 0.22 How strongly shapes are filled, 0 to 1.
image image string — The id of the image shown. Default: the first. Two-way.
label label string — The studio's accessible name.
minConfidence min-confidence number 0 Predictions with a confidence below this (0 to 1) are not shown. Two-way.
mode mode 'annotate' | 'review' 'annotate' Labelling, or reviewing (shapes read-only, with approve and reject).
project project BmxAnnotationProject | string { classes: [], images: [] } The project: classes, image attributes and images with their annotations (object or JSON). Two-way.
queue queue boolean true Show the list of images.
readonly readonly boolean false Shows everything without letting it change.
showLabels show-labels boolean true Show class names on the shapes. Two-way.
strings strings Partial<BmxAnnotationStudioStrings> | string — Words to show instead of the English ones, as an object or JSON.
tool tool BmxAnnotationTool 'select' The tool in use. Two-way.
user user string — The name new annotations and review notes are signed with.

Events

EventDetailDescription
bmxAddImages BmxAnnotationAddImagesDetail Fired when the reader adds image files: keep the files, and replace their blob: addresses with your own.
bmxChange BmxAnnotationChangeDetail Fired after every change, with the whole project.
bmxImageChange BmxAnnotationImageDetail Fired when another image is shown.

Methods

MethodSignatureDescription
addAnnotation addAnnotation(annotation: Partial<BmxAnnotation>, image?: string) => Promise<BmxAnnotation | null> Adds an annotation (as JSON) to an image (default: the one shown). Resolves with it, or null when it cannot be read.
addImages addImages(images: Partial<BmxAnnotationImage>[]) => Promise<BmxAnnotationImage[]> Adds images to the end of the queue.
check check(image?: string) => Promise<BmxAnnotationProblem[]> What needs fixing in the project (or one image).
deleteAnnotation deleteAnnotation(id: string) => Promise<boolean> Deletes an annotation.
exportAs exportAs(format: BmxAnnotationFormat, options?: BmxAnnotationExportOptions) => Promise<BmxAnnotationExport> The project in a format, as files by path, with what had to be left out.
exportZip exportZip(format: BmxAnnotationFormat, options?: BmxAnnotationExportOptions) => Promise<{ zip: Uint8Array; warnings: string[]; }> The project in a format, as one ZIP file.
fit fit() => Promise<void> Fits the image to the view.
getProject getProject() => Promise<BmxAnnotationProject> The project as it stands.
importCoco importCoco(json: unknown) => Promise<string[]> Reads COCO JSON: its classes and images join the project's (images matched by file name), its annotations replace theirs.
importVoc importVoc(files: Record<string, string>) => Promise<string[]> Reads Pascal VOC XML files (by path, as text).
importYolo importYolo(files: Record<string, string>, options?: { task?: BmxYoloTask; }) => Promise<string[]> Reads YOLO label files (by path, as text) onto the project's images, matched by name.
next next() => Promise<void> Shows the next image (in the current status filter).
previous previous() => Promise<void> Shows the previous image.
redo redo() => Promise<boolean> Redoes the last undone change.
selectAnnotation selectAnnotation(id: string | null) => Promise<void> Selects an annotation on the image shown, or clears the selection with null.
selectImage selectImage(image: string | number) => Promise<boolean> Shows an image by id or position.
setProject setProject(project: BmxAnnotationProject | string, options?: { keepHistory?: boolean; }) => Promise<void> Replaces the project. keepHistory keeps undo.
setStatus setStatus(status: BmxAnnotationStatus, options?: { image?: string; note?: string; }) => Promise<void> Sets an image's status (default: the one shown), with an optional review note.
undo undo() => Promise<boolean> Undoes the last change.
updateAnnotation updateAnnotation(id: string, patch: Partial<BmxAnnotation>) => Promise<boolean> Changes an annotation (on any image).
zoomTo zoomTo(level: number) => Promise<void> Zooms to a level (1 = one image pixel per screen pixel), around the view's centre.

CSS shadow parts

PartDescription
panel The classes, the selected annotation and the image's details.
queue The list of images.
stage The image and its shapes.
toolbar The tools, zoom, image and export controls.

CSS custom properties

PropertyDescription
--bmx-annotation-studio-handle The fill of the handles of the selected shape.
--bmx-annotation-studio-height The height of the queue, image and panel.
--bmx-annotation-studio-panel-width The width of the panel of classes and details.
--bmx-annotation-studio-queue-width The width of the list of images.
--bmx-annotation-studio-stage-background What shows around and behind the image.
--bmx-annotation-studio-stroke-width The width of shape outlines on screen, at any zoom.