When an AI feature returns coordinates on top of a user's photo, such as detected objects, document fields, or the lines on a palm, the drawing layer is where a lot of bugs hide. The model is right, but the box is in the wrong place on a phone, or the labels overlap, or the overlay drifts when the page resizes.
This post walks through a small, framework-free way to draw a labelled overlay with SVG, using the example of a palm photo where the result is three named curves. I work on PalmReading.pro, a palm reading web app that shows a labelled map of the palm, so the examples come from that kind of output. (Palmistry itself is folklore; the app is for entertainment. The rendering problem is the same for any image-to-annotation tool.)
1. Keep the model's coordinates normalized
Ask the backend to return points in the range 0 to 1, relative to the image it actually analyzed:
{
"width": 1024,
"height": 1365,
"lines": [
{"name": "Heart line", "points": [[0.18,0.36],[0.42,0.33],[0.71,0.30]]},
{"name": "Head line", "points": [[0.20,0.45],[0.47,0.47],[0.69,0.52]]},
{"name": "Life line", "points": [[0.24,0.44],[0.30,0.62],[0.38,0.82]]}
]
}
Normalized points survive resizing on the server, retina screens, and CSS changes. Pixel coordinates tie you to one size, and one forgotten downscale step breaks everything.
2. Use an SVG with a viewBox that matches the image
Put the image and the SVG in the same positioned container, and give the SVG a viewBox equal to the analyzed image size:
<figure class="overlay">
<img src="palm.jpg" alt="Uploaded palm photo">
<svg viewBox="0 0 1024 1365" preserveAspectRatio="xMidYMid meet"></svg>
</figure>
.overlay { position: relative; display: inline-block; }
.overlay img, .overlay svg { display: block; width: 100%; height: auto; }
.overlay svg { position: absolute; inset: 0; height: 100%; pointer-events: none; }
Because the viewBox and the image share the same aspect ratio and meet scaling, the browser does all the resizing math. No resize listeners are needed.
3. Draw smooth curves, not polylines
A few points joined by straight segments look robotic. A Catmull-Rom spline converted to cubic Bezier commands gives a natural curve through every point:
function smoothPath(pts) {
if (pts.length < 2) return "";
let d = `M ${pts[0][0]} ${pts[0][1]}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] || pts[i];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[i + 2] || p2;
const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
d += ` C ${c1} ${c2} ${p2[0]} ${p2[1]}`;
}
return d;
}
function render(svg, result) {
const { width: w, height: h } = result;
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
svg.innerHTML = "";
for (const line of result.lines) {
const pts = line.points.map(([x, y]) => [x * w, y * h]);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", smoothPath(pts));
path.setAttribute("class", "trace");
svg.append(path);
}
}
4. Make strokes readable on any photo
User photos have unpredictable colors. A stroke that is visible on pale skin vanishes on a dark background. Two stacked strokes fix this: a wide dark halo and a narrow bright line. Use vector-effect so the width stays constant at any zoom:
.trace { fill: none; stroke-linecap: round; vector-effect: non-scaling-stroke; }
.trace.halo { stroke: rgba(0,0,0,.55); stroke-width: 6px; }
.trace.main { stroke: #ffd166; stroke-width: 2.5px; }
5. Place labels where they do not collide
Labels at the first point of each curve often overlap, because the lines start close together. A simple rule works well: anchor each label at the point of the curve farthest from the others, then nudge vertically if two labels' boxes intersect. Measure with getBBox() after inserting the <text>, and move the later label down by the overlap plus a small gap. For three to five labels this greedy pass is enough.
6. Handle EXIF orientation once
Phone photos often carry an orientation flag. Modern browsers rotate the <img> automatically (image-orientation: from-image is the default), but your server or model may not. Normalize orientation before analysis, or the curves will appear rotated by 90 degrees on exactly the devices you did not test. The simplest fix is to draw the file into a canvas with createImageBitmap(file, { imageOrientation: "from-image" }) and upload that.
7. Accessibility
An overlay is visual only. Repeat the result in text next to it, give the SVG role="img" with an aria-label summarizing what is drawn, and keep the labels as real <text> elements rather than baked into an image.
Wrap-up
Normalized coordinates, a matching viewBox, smoothed paths, halo strokes, and a collision pass cover most of what an annotation overlay needs, with no canvas redraw loop and no library. The same approach works for bounding boxes, keypoints, or any curve your model returns.
Top comments (0)