From 409eb53c3de3b25fbc5e0a814e9db37272b754b6 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 21:11:17 +0200 Subject: [PATCH] feat: a traced mark is drawn as a curve, not as the dots it was sampled from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/ImageOverlay.jsx | 9 ++- frontend/src/components/OverlayEditor.jsx | 6 +- frontend/src/utils/smoothPath.js | 70 +++++++++++++++++++++++ frontend/src/utils/smoothPath.test.js | 32 +++++++++++ 4 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 frontend/src/utils/smoothPath.js create mode 100644 frontend/src/utils/smoothPath.test.js 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('') + }) +})