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>