diff --git a/frontend/src/components/ImageOverlay.jsx b/frontend/src/components/ImageOverlay.jsx index c873c80..4498401 100644 --- a/frontend/src/components/ImageOverlay.jsx +++ b/frontend/src/components/ImageOverlay.jsx @@ -1,3 +1,5 @@ +import smoothPath from '../utils/smoothPath' + /** * The regions an educator has marked on an image. * @@ -42,7 +44,12 @@ export default function ImageOverlay({ overlay }) { } const list = points(shape) if (list.length < 2) return null - const d = list.map(([x, y], i) => `${i ? 'L' : 'M'} ${x} ${y}`).join(' ') + // Curved, not joined dot to dot: a freehand trace is a string of + // samples, and straight segments between them render every sample as + // a corner. An arrow is two points and stays straight. + const d = shape.kind === 'arrow' + ? list.map(([x, y], i) => `${i ? 'L' : 'M'} ${x} ${y}`).join(' ') + : smoothPath(list) if (shape.kind === 'arrow') { return } diff --git a/frontend/src/components/OverlayEditor.jsx b/frontend/src/components/OverlayEditor.jsx index 1765d8e..b486c42 100644 --- a/frontend/src/components/OverlayEditor.jsx +++ b/frontend/src/components/OverlayEditor.jsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { uploadUrl } from '../utils/uploads' +import smoothPath from '../utils/smoothPath' import './OverlayEditor.css' // The marks are stored as vectors, not burnt into a second copy of the image, @@ -137,7 +138,10 @@ function Shape({ shape, box, halo }) { const p = { fill: 'none', strokeLinecap: 'round', strokeLinejoin: 'round', vectorEffect: 'non-scaling-stroke', ...pass } switch (shape.kind) { case 'path': - return + // Drawn as a curve through the recorded points, not as the polyline + // they were sampled as — same rendering as the reader's viewer, so + // what is drawn here is what a learner sees. See utils/smoothPath. + return case 'rect': return case 'ellipse': diff --git a/frontend/src/utils/smoothPath.js b/frontend/src/utils/smoothPath.js new file mode 100644 index 0000000..c638b8e --- /dev/null +++ b/frontend/src/utils/smoothPath.js @@ -0,0 +1,70 @@ +/** + * A traced line, drawn as a curve rather than as the dots it was sampled from. + * + * A finger or a mouse reports positions every few milliseconds, so a freehand + * mark is a string of points; joining them with straight segments renders every + * one of those samples as a corner, and a traced anatomical edge comes out + * visibly faceted — worse on a phone, where the hand is less steady and the + * samples are further apart. + * + * Centripetal Catmull-Rom, converted to cubic béziers. Catmull-Rom because the + * curve passes *through* every recorded point rather than near it: an educator + * tracing the edge of a lesion has said where the edge is, and a spline that + * smooths their marks away from it is drawing something they did not mean. + * Centripetal (alpha 0.5) rather than uniform because uniform overshoots into + * cusps and self-intersections wherever the hand slowed down and the samples + * bunched up, which on a slow careful trace is everywhere. + * + * Rendering only. What is stored stays the points that were recorded — the + * curve is how they are drawn, not what they are, so the same marks can be + * re-edited, re-smoothed or read by something else later. + */ +const ALPHA = 0.5 + +const knots = (points) => { + const out = [0] + for (let i = 1; i < points.length; i += 1) { + const dx = points[i][0] - points[i - 1][0] + const dy = points[i][1] - points[i - 1][1] + // A repeated point would give a zero interval and divide by it further + // down; nudging it is cheaper than filtering the list twice. + out.push(out[i - 1] + (Math.hypot(dx, dy) ** ALPHA || 1e-6)) + } + return out +} + +export default function smoothPath(points) { + const pts = (points || []).filter(p => Array.isArray(p) && p.length === 2) + if (pts.length < 2) return '' + // Two points are a straight line, and a curve through two points is a + // straight line with extra arithmetic. + if (pts.length === 2) return `M ${pts[0][0]} ${pts[0][1]} L ${pts[1][0]} ${pts[1][1]}` + + // The ends are duplicated so the first and last segments have the neighbours + // the formula needs; without them a trace starts and finishes with a kink. + const p = [pts[0], ...pts, pts[pts.length - 1]] + const t = knots(p) + let d = `M ${pts[0][0]} ${pts[0][1]}` + + for (let i = 1; i < p.length - 2; i += 1) { + const [p0, p1, p2, p3] = [p[i - 1], p[i], p[i + 1], p[i + 2]] + const [t0, t1, t2, t3] = [t[i - 1], t[i], t[i + 1], t[i + 2]] + const control = (axis) => { + // The two tangents at the ends of this segment, scaled to thirds — which + // is what turns a Catmull-Rom segment into the cubic bézier an SVG path + // can actually express. + const m1 = ((p2[axis] - p1[axis]) / (t2 - t1) + - (p2[axis] - p0[axis]) / (t2 - t0) + + (p1[axis] - p0[axis]) / (t1 - t0)) * (t2 - t1) + const m2 = ((p3[axis] - p2[axis]) / (t3 - t2) + - (p3[axis] - p1[axis]) / (t3 - t1) + + (p2[axis] - p1[axis]) / (t2 - t1)) * (t2 - t1) + return [p1[axis] + m1 / 3, p2[axis] - m2 / 3] + } + const [c1x, c2x] = control(0) + const [c1y, c2y] = control(1) + const r = (n) => Math.round(n * 100000) / 100000 + d += ` C ${r(c1x)} ${r(c1y)}, ${r(c2x)} ${r(c2y)}, ${r(p2[0])} ${r(p2[1])}` + } + return d +} diff --git a/frontend/src/utils/smoothPath.test.js b/frontend/src/utils/smoothPath.test.js new file mode 100644 index 0000000..112e537 --- /dev/null +++ b/frontend/src/utils/smoothPath.test.js @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import smoothPath from './smoothPath' + +describe('smoothing a traced line', () => { + it('passes through every point the educator recorded', () => { + // Catmull-Rom rather than a smoothing spline for exactly this reason: + // somebody tracing the edge of a lesion has said where the edge is, and a + // curve that drifts off their marks is drawing something else. + const d = smoothPath([[0, 0], [0.5, 0.2], [1, 0]]) + expect(d.startsWith('M 0 0')).toBe(true) + // Each recorded point is the end of a cubic segment. + expect(d).toContain('0.5 0.2') + expect(d.trimEnd().endsWith('1 0')).toBe(true) + expect(d.match(/C /g)).toHaveLength(2) + }) + + it('leaves two points as a straight line', () => { + expect(smoothPath([[0, 0], [1, 1]])).toBe('M 0 0 L 1 1') + }) + + it('survives a repeated point without dividing by zero', () => { + const d = smoothPath([[0.2, 0.2], [0.2, 0.2], [0.6, 0.4], [0.9, 0.9]]) + expect(d).not.toContain('NaN') + expect(d).not.toContain('Infinity') + }) + + it('is nothing at all when there is nothing to draw', () => { + expect(smoothPath([])).toBe('') + expect(smoothPath(null)).toBe('') + expect(smoothPath([[0.5, 0.5]])).toBe('') + }) +})