feat(proxy): add image tracing endpoint
Add GET /trace which fetches an image, traces it into a vector shape, and returns the result BSON-encoded. Tracing is configurable via mode (alpha, bw, color) and format (shape, svg). The shape format parses the vtracer SVG into an outline/holes polygon matching @tts/mesh's Shape interface.
This commit is contained in:
@@ -6,6 +6,7 @@ import asset from './routes/asset.js';
|
||||
import health from './routes/health.js';
|
||||
import items from './routes/items.js';
|
||||
import search from './routes/search.js';
|
||||
import trace from './routes/trace.js';
|
||||
|
||||
const env = loadEnv();
|
||||
|
||||
@@ -16,6 +17,7 @@ app.route('/health', health);
|
||||
app.route('/search', search);
|
||||
app.route('/items', items);
|
||||
app.route('/asset', asset);
|
||||
app.route('/trace', trace);
|
||||
|
||||
serve(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSvgShape } from './svgShape.js';
|
||||
|
||||
describe('parseSvgShape', () => {
|
||||
it('parses a simple closed outline', () => {
|
||||
const svg =
|
||||
'<svg><path d="M0,0L10,0L10,10L0,10Z" fill="#000"/></svg>';
|
||||
const shape = parseSvgShape(svg);
|
||||
expect(shape.outline.length).toBeGreaterThanOrEqual(4);
|
||||
expect(shape.holes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('assigns a clockwise subpath as a hole', () => {
|
||||
// Outer ring CCW, inner ring CW (hole).
|
||||
const svg =
|
||||
'<svg><path d="M0,0L20,0L20,20L0,20ZM5,5L5,15L15,15L15,5Z" fill="#000"/></svg>';
|
||||
const shape = parseSvgShape(svg);
|
||||
expect(shape.outline.length).toBeGreaterThanOrEqual(4);
|
||||
expect(shape.holes).toBeDefined();
|
||||
expect(shape.holes!.length).toBe(1);
|
||||
});
|
||||
|
||||
it('flattens cubic curves into multiple points', () => {
|
||||
const svg =
|
||||
'<svg><path d="M0,0C10,0 10,10 20,10Z" fill="#000"/></svg>';
|
||||
const shape = parseSvgShape(svg);
|
||||
// Start point + 12 curve samples + closing point, minus duplicate.
|
||||
expect(shape.outline.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it('returns an empty outline for no paths', () => {
|
||||
const shape = parseSvgShape('<svg></svg>');
|
||||
expect(shape.outline).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import svgpath from 'svgpath';
|
||||
import type { TracedShape } from '@tts/shared';
|
||||
|
||||
/** Number of samples per cubic bezier when flattening curves to polylines. */
|
||||
const CURVE_STEPS = 12;
|
||||
|
||||
/**
|
||||
* Parse an SVG produced by vtracer into a `TracedShape` (outline + holes),
|
||||
* matching the `Shape` interface in `@tts/mesh` so the result can be extruded
|
||||
* directly. vtracer emits one `<path>` per traced region; a region with holes
|
||||
* is a single path containing multiple subpaths (one per `M` command).
|
||||
*
|
||||
* Subpaths are classified by winding: counter-clockwise rings become the
|
||||
* outline, clockwise rings become holes. Holes are assigned to the smallest
|
||||
* outline that contains them (point-in-polygon on the first vertex).
|
||||
*/
|
||||
export function parseSvgShape(svg: string): TracedShape {
|
||||
const paths = [...svg.matchAll(/<path\s+d="([^"]*)"/g)].map((m) => m[1]!);
|
||||
|
||||
const rings = paths.flatMap(parseRings);
|
||||
if (rings.length === 0) {
|
||||
return { outline: [] };
|
||||
}
|
||||
|
||||
const outlines = rings.filter((r) => signedArea(r) > 0);
|
||||
const holes = rings.filter((r) => signedArea(r) <= 0);
|
||||
|
||||
// Assign each hole to the smallest outline that contains its first vertex.
|
||||
const assigned = new Map<number, number[][][]>();
|
||||
for (const hole of holes) {
|
||||
const p = hole[0]! as [number, number];
|
||||
let best: number | null = null;
|
||||
let bestArea = Infinity;
|
||||
for (let i = 0; i < outlines.length; i++) {
|
||||
const area = Math.abs(signedArea(outlines[i]!));
|
||||
if (area < bestArea && pointInPolygon(p, outlines[i]!)) {
|
||||
best = i;
|
||||
bestArea = area;
|
||||
}
|
||||
}
|
||||
if (best !== null) {
|
||||
const list = assigned.get(best) ?? [];
|
||||
list.push(hole);
|
||||
assigned.set(best, list);
|
||||
}
|
||||
}
|
||||
|
||||
// The largest outline is the outer boundary; any remaining outlines are
|
||||
// treated as additional (disjoint) regions, which vtracer emits as separate
|
||||
// paths anyway. Keep the first/largest as the primary outline.
|
||||
const primary = outlines.reduce((a, b) =>
|
||||
Math.abs(signedArea(a)) >= Math.abs(signedArea(b)) ? a : b,
|
||||
);
|
||||
|
||||
return {
|
||||
outline: primary,
|
||||
holes: assigned.get(outlines.indexOf(primary)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Split a path's `d` attribute into rings, one per `M` subpath. */
|
||||
function parseRings(d: string): number[][][] {
|
||||
const rings: number[][][] = [];
|
||||
let cur: number[][] | null = null;
|
||||
let pen: [number, number] = [0, 0];
|
||||
let ctrl: [number, number] | null = null;
|
||||
|
||||
svgpath(d)
|
||||
.abs()
|
||||
.iterate((seg) => {
|
||||
const cmd = seg[0];
|
||||
switch (cmd) {
|
||||
case 'M':
|
||||
if (cur && cur.length) rings.push(cur);
|
||||
cur = [[seg[1]!, seg[2]!]];
|
||||
pen = [seg[1]!, seg[2]!];
|
||||
ctrl = null;
|
||||
break;
|
||||
case 'L':
|
||||
cur?.push([seg[1]!, seg[2]!]);
|
||||
pen = [seg[1]!, seg[2]!];
|
||||
ctrl = null;
|
||||
break;
|
||||
case 'C': {
|
||||
const p1: [number, number] = [seg[1]!, seg[2]!];
|
||||
const p2: [number, number] = [seg[3]!, seg[4]!];
|
||||
const p3: [number, number] = [seg[5]!, seg[6]!];
|
||||
flattenCubic(pen, p1, p2, p3, cur!);
|
||||
pen = p3;
|
||||
ctrl = p2;
|
||||
break;
|
||||
}
|
||||
case 'Q': {
|
||||
// Approximate a quadratic bezier with a cubic.
|
||||
const q1: [number, number] = [seg[1]!, seg[2]!];
|
||||
const q2: [number, number] = [seg[3]!, seg[4]!];
|
||||
const p1: [number, number] = [
|
||||
pen[0] + (2 / 3) * (q1[0] - pen[0]),
|
||||
pen[1] + (2 / 3) * (q1[1] - pen[1]),
|
||||
];
|
||||
const p2: [number, number] = [
|
||||
q2[0] + (2 / 3) * (q1[0] - q2[0]),
|
||||
q2[1] + (2 / 3) * (q1[1] - q2[1]),
|
||||
];
|
||||
flattenCubic(pen, p1, p2, q2, cur!);
|
||||
pen = q2;
|
||||
ctrl = q1;
|
||||
break;
|
||||
}
|
||||
case 'Z':
|
||||
case 'z':
|
||||
cur?.push([pen[0], pen[1]]);
|
||||
ctrl = null;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
if (cur !== null) rings.push(cur);
|
||||
|
||||
// Drop the duplicate closing vertex (first == last).
|
||||
for (const ring of rings) {
|
||||
const first = ring[0]!;
|
||||
const last = ring[ring.length - 1]!;
|
||||
if (
|
||||
Math.abs(first[0]! - last[0]!) < 1e-6 &&
|
||||
Math.abs(first[1]! - last[1]!) < 1e-6
|
||||
) {
|
||||
ring.pop();
|
||||
}
|
||||
}
|
||||
|
||||
return rings;
|
||||
}
|
||||
|
||||
/** Sample a cubic bezier into `CURVE_STEPS` points appended to `out`. */
|
||||
function flattenCubic(
|
||||
p0: [number, number],
|
||||
p1: [number, number],
|
||||
p2: [number, number],
|
||||
p3: [number, number],
|
||||
out: number[][],
|
||||
): void {
|
||||
for (let i = 1; i <= CURVE_STEPS; i++) {
|
||||
const t = i / CURVE_STEPS;
|
||||
const mt = 1 - t;
|
||||
const x =
|
||||
mt * mt * mt * p0[0] +
|
||||
3 * mt * mt * t * p1[0] +
|
||||
3 * mt * t * t * p2[0] +
|
||||
t * t * t * p3[0];
|
||||
const y =
|
||||
mt * mt * mt * p0[1] +
|
||||
3 * mt * mt * t * p1[1] +
|
||||
3 * mt * t * t * p2[1] +
|
||||
t * t * t * p3[1];
|
||||
out.push([x, y]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Signed area of a polygon; positive means counter-clockwise. */
|
||||
function signedArea(points: number[][]): number {
|
||||
let area = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const [x1, y1] = points[i]!;
|
||||
const [x2, y2] = points[(i + 1) % points.length]!;
|
||||
area += x1! * y2! - x2! * y1!;
|
||||
}
|
||||
return area / 2;
|
||||
}
|
||||
|
||||
/** Ray-casting point-in-polygon test. */
|
||||
function pointInPolygon(p: [number, number], polygon: number[][]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const [xi, yi] = polygon[i]!;
|
||||
const [xj, yj] = polygon[j]!;
|
||||
const intersects =
|
||||
yi! > p[1] !== yj! > p[1] &&
|
||||
p[0] < ((xj! - xi!) * (p[1] - yi!)) / (yj! - yi!) + xi!;
|
||||
if (intersects) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { deserialize } from 'bson';
|
||||
import sharp from 'sharp';
|
||||
import trace from './trace.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** Build a small PNG with an opaque circle in the center on a transparent bg. */
|
||||
async function makePng(size = 20): Promise<Buffer> {
|
||||
const rgba = Buffer.alloc(size * size * 4);
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = size / 3;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
if (Math.hypot(x - cx, y - cy) <= r) {
|
||||
const i = (y * size + x) * 4;
|
||||
rgba[i] = 255;
|
||||
rgba[i + 1] = 255;
|
||||
rgba[i + 2] = 255;
|
||||
rgba[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
return sharp(rgba, { raw: { width: size, height: size, channels: 4 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
function stubFetch(body: Buffer) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(new Uint8Array(body), {
|
||||
headers: { 'content-type': 'image/png' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('trace route', () => {
|
||||
it('rejects a missing url', async () => {
|
||||
const res = await trace.request('/');
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toEqual({ error: 'Required' });
|
||||
});
|
||||
|
||||
it('rejects a non-http url', async () => {
|
||||
const res = await trace.request('/?url=file%3A%2F%2F%2Fetc%2Fpasswd');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 502 when the upstream fetch fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(new Response('error', { status: 500 })),
|
||||
);
|
||||
const res = await trace.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
|
||||
it('returns a BSON shape by default', async () => {
|
||||
stubFetch(await makePng());
|
||||
const res = await trace.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe('application/octet-stream');
|
||||
|
||||
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
||||
expect(result.format).toBe('shape');
|
||||
expect(result.mode).toBe('alpha');
|
||||
expect(result.width).toBe(20);
|
||||
expect(result.height).toBe(20);
|
||||
expect(typeof result.svg).toBe('string');
|
||||
expect(result.svg).toContain('<svg');
|
||||
expect(result.shape).toBeDefined();
|
||||
expect(result.shape.outline.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it('returns a raw SVG when format=svg', async () => {
|
||||
stubFetch(await makePng());
|
||||
const res = await trace.request(
|
||||
'/?url=https%3A%2F%2Fexample.com%2Fa.png&format=svg',
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
||||
expect(result.format).toBe('svg');
|
||||
expect(result.shape).toBeUndefined();
|
||||
expect(typeof result.svg).toBe('string');
|
||||
});
|
||||
|
||||
it('supports bw mode', async () => {
|
||||
stubFetch(await makePng());
|
||||
const res = await trace.request(
|
||||
'/?url=https%3A%2F%2Fexample.com%2Fa.png&mode=bw',
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
||||
expect(result.mode).toBe('bw');
|
||||
expect(result.shape.outline.length).toBeGreaterThan(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Hono } from 'hono';
|
||||
import { serialize } from 'bson';
|
||||
import sharp from 'sharp';
|
||||
import { createRequire } from 'module';
|
||||
import { traceRequestSchema, type TraceResult } from '@tts/shared';
|
||||
import { parseSvgShape } from './svgShape.js';
|
||||
|
||||
// vtracer is a CommonJS package; load it via require so the wasm initializes
|
||||
// with the correct `__dirname`.
|
||||
const require = createRequire(import.meta.url);
|
||||
const vtracer = require('@visioncortex/vtracer') as {
|
||||
convertPixels(
|
||||
rgba: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
options: Record<string, unknown>,
|
||||
): string;
|
||||
};
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
/**
|
||||
* Trace an image into a vector shape and return it BSON-encoded.
|
||||
*
|
||||
* `mode` controls how the traced region is derived:
|
||||
* - `alpha` (default): the alpha channel (opaque -> shape)
|
||||
* - `bw`: luminance thresholding
|
||||
* - `color`: vtracer's native color clustering
|
||||
*
|
||||
* `format` selects the response shape: `shape` (default) returns a parsed
|
||||
* `{ outline, holes }` polygon ready for `@tts/mesh`; `svg` returns the raw
|
||||
* SVG string.
|
||||
*/
|
||||
app.get('/', async (c) => {
|
||||
const parsed = traceRequestSchema.safeParse(c.req.query());
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: parsed.error.issues[0]?.message }, 400);
|
||||
}
|
||||
const { url, mode, threshold, format, simplify, maxColors } = parsed.data;
|
||||
|
||||
// Only allow http(s) to avoid SSRF via file://, etc.
|
||||
const parsedUrl = new URL(url);
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
return c.json({ error: 'Only http(s) urls are allowed' }, 400);
|
||||
}
|
||||
|
||||
let image: Buffer;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
return c.json({ error: `Image responded ${res.status}` }, 502);
|
||||
}
|
||||
image = Buffer.from(await res.arrayBuffer());
|
||||
} catch (err) {
|
||||
return c.json({ error: `Failed to fetch image: ${String(err)}` }, 502);
|
||||
}
|
||||
|
||||
let svg: string;
|
||||
let width: number;
|
||||
let height: number;
|
||||
try {
|
||||
const { data, info } = await sharp(image)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
width = info.width;
|
||||
height = info.height;
|
||||
|
||||
const pixels = buildPixels(data, width, height, mode, threshold);
|
||||
const options: Record<string, unknown> = {};
|
||||
if (mode === 'color') {
|
||||
options.clustering = 'color-cluster';
|
||||
if (maxColors !== undefined) options.maxColors = maxColors;
|
||||
} else {
|
||||
options.clustering = 'bw';
|
||||
options.binaryThreshold = threshold;
|
||||
}
|
||||
if (simplify !== undefined) options.simplify = simplify;
|
||||
|
||||
svg = vtracer.convertPixels(
|
||||
new Uint8Array(pixels),
|
||||
width,
|
||||
height,
|
||||
options,
|
||||
);
|
||||
} catch (err) {
|
||||
return c.json({ error: `Failed to trace image: ${String(err)}` }, 500);
|
||||
}
|
||||
|
||||
const result: TraceResult = {
|
||||
width,
|
||||
height,
|
||||
mode,
|
||||
format,
|
||||
svg,
|
||||
};
|
||||
if (format === 'shape') {
|
||||
result.shape = parseSvgShape(svg);
|
||||
}
|
||||
|
||||
return new Response(Buffer.from(serialize(result)), {
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Build the RGBA buffer fed to vtracer for the given mode.
|
||||
* `alpha` and `bw` produce a binary mask (black foreground, white background)
|
||||
* so vtracer's `bw` clustering traces the desired region.
|
||||
*/
|
||||
function buildPixels(
|
||||
rgba: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
mode: 'alpha' | 'bw' | 'color',
|
||||
threshold: number,
|
||||
): Buffer {
|
||||
if (mode === 'color') return rgba;
|
||||
|
||||
const out = Buffer.alloc(width * height * 4);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const r = rgba[i * 4]!;
|
||||
const g = rgba[i * 4 + 1]!;
|
||||
const b = rgba[i * 4 + 2]!;
|
||||
const a = rgba[i * 4 + 3]!;
|
||||
|
||||
const isForeground =
|
||||
mode === 'alpha'
|
||||
? a > threshold
|
||||
: 0.299 * r + 0.587 * g + 0.114 * b < threshold;
|
||||
|
||||
// vtracer treats intensity below `binaryThreshold` as foreground, so the
|
||||
// foreground region is black and the background is white.
|
||||
const v = isForeground ? 0 : 255;
|
||||
out[i * 4] = v;
|
||||
out[i * 4 + 1] = v;
|
||||
out[i * 4 + 2] = v;
|
||||
out[i * 4 + 3] = 255;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default app;
|
||||
Reference in New Issue
Block a user