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('')
+ })
+})