feat: a traced mark is drawn as a curve, not as the dots it was sampled from

Asked how the overlay stays neat, the honest answer was that it did not. A
finger reports a position every few milliseconds, so a freehand mark is a string
of samples, and joining them with straight segments renders every sample as a
corner — visibly faceted on a traced anatomical edge, 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: somebody 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 rather than uniform
because uniform overshoots into cusps wherever the hand slowed down and the
samples bunched — which on a slow careful trace is everywhere.

Rendering only. What is stored stays the points that were recorded, so the marks
can be re-edited, re-smoothed, or read by something else later. The editor and
the reader's viewer share the one function, so what an educator draws is what a
learner sees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 21:11:17 +02:00
parent 500e8430a5
commit 409eb53c3d
4 changed files with 115 additions and 2 deletions

View file

@ -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 <path {...common} d={d} markerEnd="url(#imgov-head)" />
}

View file

@ -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 <polyline key={i} {...p} points={toPoints(shape.points)} />
// 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 <path key={i} {...p} d={smoothPath(shape.points)} />
case 'rect':
return <rect key={i} {...p} x={shape.x} y={shape.y} width={shape.w} height={shape.h} />
case 'ellipse':

View file

@ -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
}

View file

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