Skip to content

Commit 3d28ae4

Browse files
matmenclaude
andcommitted
renderText: smooth outlines along the antialiased glyph edge
The outline was the distance to the nearest pixel at least half covered, so its edge followed that pixel staircase (bumpy at larger widths). Near the outline's edge, the distance now goes to the edge points of the pixels around the nearest covered one (3x3): from the pixel center along the coverage gradient to where a straight edge would cover it as much, like Gustavson's antialiased distance transform. The distance transform also returns the nearest sample for that. Against text rendered at 8x and scaled down, the mean error of the outline's coverage drops from about 0.3 to 0.07-0.1 (Source Sans, Open Sans, stroke widths 1-24). Distances beyond the outline are left out of the row pass, so it costs about as much as before (64px text, 3px outline: 6.6 -> 7.9 ms). effects target regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
1 parent 7b94bf8 commit 3d28ae4

3 files changed

Lines changed: 74 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939

4040
- The outline of `Image.renderText` (`strokeWidth`) showed through translucent text, it now only shows around the
4141
glyphs (opaque text renders as before).
42+
- The outline of `Image.renderText` follows the glyphs' antialiased edges instead of their pixels, so it is smooth
43+
rather than stepped, and it reaches exactly `strokeWidth` beyond the edge.
4244

4345
## 3.2.1
4446

‎src/image.ts‎

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2212,44 +2212,100 @@ const BLEND_MODES: Record<BlendMode, (backdrop: number, source: number) => numbe
22122212

22132213
/**
22142214
* Grows a coverage mask by a radius, using the distance of every pixel to the glyphs (an exact euclidean distance
2215-
* transform, so the cost doesn't depend on the radius)
2215+
* transform, so the cost doesn't depend on the radius). Near the outline's edge, the distance goes to the glyph edge
2216+
* within the pixels around the nearest covered one (estimated from their coverage and its gradient, like Gustavson's
2217+
* antialiased distance transform), so the outline follows the antialiased edge instead of the pixels
22162218
* @return the outline's coverage (0..1) per pixel, including the area under the glyphs
22172219
*/
2220+
// covered pixels around the nearest one (on either side) whose edge points the outline's edge is measured to
2221+
const OUTLINE_WINDOW = 1;
2222+
22182223
function outline(coverage: Uint8Array, width: number, height: number, radius: number): Float32Array {
22192224
const count = width * height;
2220-
// squared distance to the nearest pixel that is at least half covered
2225+
// squared distance to the nearest pixel that is at least half covered, and that pixel's coordinates
22212226
const distance = new Float64Array(count);
22222227
for (let i = 0; i < count; i++) distance[i] = coverage[i] >= 128 ? 0 : Infinity;
2228+
const nearestY = new Int32Array(count), nearestX = new Int32Array(count);
22232229

22242230
const size = Math.max(width, height);
2225-
const line = new Float64Array(size), result = new Float64Array(size);
2231+
const line = new Float64Array(size), result = new Float64Array(size), nearest = new Int32Array(size);
22262232
const parabolas = new Int32Array(size), bounds = new Float64Array(size + 1);
22272233

22282234
for (let x = 0; x < width; x++) {
22292235
for (let y = 0; y < height; y++) line[y] = distance[y * width + x];
2230-
distanceTransform(line, height, result, parabolas, bounds);
2231-
for (let y = 0; y < height; y++) distance[y * width + x] = result[y];
2236+
distanceTransform(line, height, result, parabolas, bounds, nearest);
2237+
for (let y = 0; y < height; y++) {
2238+
distance[y * width + x] = result[y];
2239+
nearestY[y * width + x] = nearest[y];
2240+
}
22322241
}
22332242

2243+
// distances beyond the outline don't matter: leaving them out of the rows saves most of their work
2244+
const far = (radius + 2) ** 2;
22342245
for (let y = 0; y < height; y++) {
22352246
const row = y * width;
2236-
for (let x = 0; x < width; x++) line[x] = distance[row + x];
2237-
distanceTransform(line, width, result, parabolas, bounds);
2238-
for (let x = 0; x < width; x++) distance[row + x] = result[x];
2247+
for (let x = 0; x < width; x++) line[x] = distance[row + x] < far ? distance[row + x] : Infinity;
2248+
distanceTransform(line, width, result, parabolas, bounds, nearest);
2249+
for (let x = 0; x < width; x++) {
2250+
distance[row + x] = result[x];
2251+
nearestX[row + x] = nearest[x];
2252+
}
22392253
}
22402254

2255+
// where the glyph edge crosses an edge pixel (partly covered, or covered next to an uncovered one): from its
2256+
// center along the outward normal (the coverage gradient) to where a straight edge would cover it as much. The
2257+
// coverage has an empty border (the outline's room), so covered pixels have all their neighbors
2258+
const pointX = new Float32Array(count), pointY = new Float32Array(count), isEdge = new Uint8Array(count);
2259+
for (let y = 1; y < height - 1; y++)
2260+
for (let x = 1, e = y * width + 1; x < width - 1; x++, e++) {
2261+
const c = coverage[e];
2262+
if (!c || c === 255 && coverage[e - 1] >= 128 && coverage[e + 1] >= 128 && coverage[e - width] >= 128 && coverage[e + width] >= 128) continue;
2263+
// Sobel gradient, pointing inwards
2264+
const above = e - width, below = e + width;
2265+
const gx = coverage[above + 1] + 2 * coverage[e + 1] + coverage[below + 1] - coverage[above - 1] - 2 * coverage[e - 1] - coverage[below - 1];
2266+
const gy = coverage[below - 1] + 2 * coverage[below] + coverage[below + 1] - coverage[above - 1] - 2 * coverage[above] - coverage[above + 1];
2267+
const length = Math.sqrt(gx * gx + gy * gy);
2268+
if (!length) continue;
2269+
// the center lies 1/2 - c outside the edge
2270+
const outside = 0.5 - c / 255;
2271+
pointX[e] = x + outside * gx / length;
2272+
pointY[e] = y + outside * gy / length;
2273+
isEdge[e] = 1;
2274+
}
2275+
2276+
// the distance to the nearest covered pixel's center is within a pixel of the distance to the edge: outside of that
2277+
// band around the outline's edge, a pixel is fully covered or not at all
2278+
// (pixels next to the glyph edge are covered by the glyph's own antialiasing)
22412279
const out = new Float32Array(count);
2242-
for (let i = 0; i < count; i++) {
2243-
// pixels next to the glyph edge are covered by the glyph's own antialiasing
2244-
const d = Math.sqrt(distance[i]);
2245-
out[i] = Math.max(coverage[i] / 255, Math.min(1, Math.max(0, radius + 0.5 - d)));
2246-
}
2280+
const inner = radius - 1.5, near = inner > 0 ? inner * inner : -1;
2281+
for (let y = 0, i = 0; y < height; y++)
2282+
for (let x = 0; x < width; x++, i++) {
2283+
const d2 = distance[i];
2284+
if (d2 <= near) out[i] = 1;
2285+
if (d2 <= near || d2 >= far) {
2286+
if (d2 >= far) out[i] = coverage[i] / 255;
2287+
continue;
2288+
}
2289+
2290+
// the closest edge point around the nearest covered pixel
2291+
const qx = nearestX[i], qy = nearestY[y * width + qx];
2292+
let closest = Infinity;
2293+
for (let wy = Math.max(0, qy - OUTLINE_WINDOW); wy <= Math.min(height - 1, qy + OUTLINE_WINDOW); wy++)
2294+
for (let wx = Math.max(0, qx - OUTLINE_WINDOW), e = wy * width + wx; wx <= Math.min(width - 1, qx + OUTLINE_WINDOW); wx++, e++) {
2295+
if (!isEdge[e]) continue;
2296+
const ex = x - pointX[e], ey = y - pointY[e];
2297+
closest = Math.min(closest, ex * ex + ey * ey);
2298+
}
2299+
closest = closest === Infinity ? Math.sqrt(d2) - 0.5 : Math.sqrt(closest);
2300+
out[i] = Math.max(coverage[i] / 255, Math.min(1, Math.max(0, radius + 0.5 - closest)));
2301+
}
22472302

22482303
return out;
22492304
}
22502305

2251-
// 1D squared euclidean distance transform (Felzenszwalb & Huttenlocher): lower envelope of parabolas
2252-
function distanceTransform(f: Float64Array, n: number, d: Float64Array, v: Int32Array, z: Float64Array): void {
2306+
// 1D squared euclidean distance transform (Felzenszwalb & Huttenlocher): lower envelope of parabolas, also the index
2307+
// of the nearest sample
2308+
function distanceTransform(f: Float64Array, n: number, d: Float64Array, v: Int32Array, z: Float64Array, nearest: Int32Array): void {
22532309
let k = -1;
22542310
for (let q = 0; q < n; q++) {
22552311
if (f[q] === Infinity) continue;
@@ -2273,5 +2329,6 @@ function distanceTransform(f: Float64Array, n: number, d: Float64Array, v: Int32
22732329
for (let q = 0, j = 0; q < n; q++) {
22742330
while (z[j + 1] < q) j++;
22752331
d[q] = (q - v[j]) ** 2 + f[v[j]];
2332+
nearest[q] = v[j];
22762333
}
22772334
}

‎tests/targets/effects.png‎

1.76 KB
Loading

0 commit comments

Comments
 (0)