Initial public release of PSX Adventure Engine
Browser reference stack for PSX-era third-person adventure: fixed cameras, inventory puzzles, Box3D physics, host-authoritative P2P co-op, and a modular character harness. Ships the Ashgrove Precinct Level 1 investigation demo with a full cast and nine linked rooms.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { extname } from "node:path";
|
||||
import jpeg from "jpeg-js";
|
||||
import { PNG } from "pngjs";
|
||||
|
||||
/** Decoded RGBA image, row-major, 4 bytes per pixel. */
|
||||
export interface Raster {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface Rgb {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export async function loadRaster(path: string): Promise<Raster> {
|
||||
const bytes = await readFile(path);
|
||||
const extension = extname(path).toLowerCase();
|
||||
|
||||
if (extension === ".png") {
|
||||
const png = PNG.sync.read(bytes);
|
||||
return { width: png.width, height: png.height, data: new Uint8Array(png.data) };
|
||||
}
|
||||
|
||||
if (extension === ".jpg" || extension === ".jpeg") {
|
||||
// `useTArray` keeps the result a Uint8Array rather than a Node Buffer.
|
||||
const decoded = jpeg.decode(bytes, { useTArray: true });
|
||||
return { width: decoded.width, height: decoded.height, data: new Uint8Array(decoded.data) };
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported image format: ${extension || path}. Use PNG or JPEG.`);
|
||||
}
|
||||
|
||||
export const pixelIndex = (raster: Raster, x: number, y: number): number =>
|
||||
(y * raster.width + x) * 4;
|
||||
|
||||
export function pixelAt(raster: Raster, x: number, y: number): Rgb {
|
||||
const i = pixelIndex(raster, x, y);
|
||||
return { r: raster.data[i]!, g: raster.data[i + 1]!, b: raster.data[i + 2]! };
|
||||
}
|
||||
|
||||
/** Perceptual-ish distance. Cheap, and good enough to separate a flat backdrop. */
|
||||
export function colorDistance(a: Rgb, b: Rgb): number {
|
||||
const dr = a.r - b.r;
|
||||
const dg = a.g - b.g;
|
||||
const db = a.b - b.b;
|
||||
// Green weighted highest, matching luminance sensitivity.
|
||||
return Math.sqrt(dr * dr * 0.3 + dg * dg * 0.59 + db * db * 0.11);
|
||||
}
|
||||
|
||||
export const luminance = (color: Rgb): number =>
|
||||
(color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722) / 255;
|
||||
|
||||
/** Debug output: write a mask or raster to PNG so a human can eyeball it. */
|
||||
export function rasterToPng(raster: Raster): Buffer {
|
||||
const png = new PNG({ width: raster.width, height: raster.height });
|
||||
png.data = Buffer.from(raster.data);
|
||||
return PNG.sync.write(png);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { analyzeReference } from "./analyze.js";
|
||||
import { colorDistance } from "./Raster.js";
|
||||
import { denoise, segmentForeground } from "./segment.js";
|
||||
import { DEFAULT_FIGURE, PACKED_FIGURE, renderFigure } from "./testFixtures.js";
|
||||
|
||||
/**
|
||||
* Ground truth is known here, so tolerances are tight where the analyzer
|
||||
* genuinely measures and loose where it admits to deriving.
|
||||
*/
|
||||
|
||||
function analyze(spec = DEFAULT_FIGURE) {
|
||||
const raster = renderFigure(spec);
|
||||
const mask = denoise(segmentForeground(raster));
|
||||
return { ...analyzeReference(raster, mask), mask };
|
||||
}
|
||||
|
||||
/** Landmarks are normalised to the *body*, the fixture to the *image*. */
|
||||
function toBodySpace(spec: typeof DEFAULT_FIGURE, imageFraction: number): number {
|
||||
return (imageFraction - spec.headTop) / (spec.soleY - spec.headTop);
|
||||
}
|
||||
|
||||
describe("segmentation", () => {
|
||||
it("separates the figure from a flat backdrop", () => {
|
||||
const { mask } = analyze();
|
||||
expect(mask.coverage).toBeGreaterThan(0.05);
|
||||
expect(mask.coverage).toBeLessThan(0.6);
|
||||
});
|
||||
|
||||
it("keeps a backdrop-coloured patch enclosed by the body", () => {
|
||||
// A grey badge in the middle of the torso, the exact colour of the
|
||||
// backdrop. A global colour threshold would punch a hole through the
|
||||
// character; a flood fill from the border cannot reach it.
|
||||
const spec = DEFAULT_FIGURE;
|
||||
const raster = renderFigure(spec);
|
||||
|
||||
const midX = Math.round(spec.width / 2);
|
||||
const torsoY = Math.round(((spec.shoulderY + spec.crotchY) / 2) * spec.height);
|
||||
for (let y = torsoY - 12; y <= torsoY + 12; y++) {
|
||||
for (let x = midX - 12; x <= midX + 12; x++) {
|
||||
const i = (y * spec.width + x) * 4;
|
||||
raster.data[i] = spec.backdrop.r;
|
||||
raster.data[i + 1] = spec.backdrop.g;
|
||||
raster.data[i + 2] = spec.backdrop.b;
|
||||
}
|
||||
}
|
||||
|
||||
const mask = denoise(segmentForeground(raster));
|
||||
expect(mask.data[torsoY * mask.width + midX]).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyzeReference landmarks", () => {
|
||||
it("measures the neck near the head/body junction", () => {
|
||||
const { measurements } = analyze();
|
||||
expect(measurements.landmarks.neck.y).toBeCloseTo(
|
||||
toBodySpace(DEFAULT_FIGURE, DEFAULT_FIGURE.neckY),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("measures the crotch where the legs separate", () => {
|
||||
const { measurements } = analyze();
|
||||
expect(measurements.landmarks.crotch.source).toBe("measured");
|
||||
expect(measurements.landmarks.crotch.y).toBeCloseTo(
|
||||
toBodySpace(DEFAULT_FIGURE, DEFAULT_FIGURE.crotchY),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("measures the ankle above the boot flare", () => {
|
||||
const { measurements } = analyze();
|
||||
expect(measurements.landmarks.ankle.y).toBeGreaterThan(0.85);
|
||||
expect(measurements.landmarks.ankle.y).toBeLessThan(0.98);
|
||||
});
|
||||
|
||||
it("orders every landmark head to toe", () => {
|
||||
const { landmarks } = analyze().measurements;
|
||||
const order = [
|
||||
landmarks.neck.y,
|
||||
landmarks.shoulder.y,
|
||||
landmarks.chest.y,
|
||||
landmarks.waist.y,
|
||||
landmarks.hip.y,
|
||||
landmarks.crotch.y,
|
||||
landmarks.knee.y,
|
||||
landmarks.ankle.y,
|
||||
];
|
||||
for (let i = 1; i < order.length; i++) {
|
||||
expect(order[i]).toBeGreaterThanOrEqual(order[i - 1]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyzeReference robustness", () => {
|
||||
it("rejects an implausible shoulder caused by worn gear", () => {
|
||||
// The pack merges with the collar, so the width test fires under the jaw.
|
||||
// The analyzer must notice and fall back rather than rig arms to the chin.
|
||||
const { measurements, notes } = analyze(PACKED_FIGURE);
|
||||
|
||||
expect(measurements.landmarks.shoulder.source).toBe("derived");
|
||||
expect(notes.join(" ")).toContain("plausible band");
|
||||
// Still lands somewhere a shoulder could actually be.
|
||||
expect(measurements.landmarks.shoulder.y).toBeGreaterThan(0.12);
|
||||
expect(measurements.landmarks.shoulder.y).toBeLessThan(0.3);
|
||||
});
|
||||
|
||||
it("keeps the torso width free of the arms", () => {
|
||||
const { measurements } = analyze();
|
||||
// Torso is 0.26 of image height; arms add ~0.15 per side if wrongly merged.
|
||||
const expected = DEFAULT_FIGURE.torsoWidth / (DEFAULT_FIGURE.soleY - DEFAULT_FIGURE.headTop);
|
||||
expect(measurements.widths.chest).toBeLessThan(expected * 1.4);
|
||||
});
|
||||
|
||||
it("reports a thigh wider than a calf", () => {
|
||||
const { measurements } = analyze();
|
||||
expect(measurements.widths.thigh).toBeGreaterThan(0);
|
||||
expect(measurements.widths.calf).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("survives a figure whose legs never separate", () => {
|
||||
const { measurements, notes } = analyze({ ...DEFAULT_FIGURE, legGap: 0 });
|
||||
expect(measurements.landmarks.crotch.y).toBeGreaterThan(0.3);
|
||||
expect(measurements.landmarks.crotch.y).toBeLessThan(0.75);
|
||||
if (measurements.landmarks.crotch.source === "derived") {
|
||||
expect(notes.join(" ")).toContain("crotch");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("palette sampling", () => {
|
||||
it("recovers the colour of each body region", () => {
|
||||
const { palette } = analyze();
|
||||
const { colors } = DEFAULT_FIGURE;
|
||||
|
||||
// Generous tolerance: the sampler insets from edges and takes a median.
|
||||
expect(colorDistance(palette.hair, colors.hair)).toBeLessThan(40);
|
||||
expect(colorDistance(palette.skin, colors.skin)).toBeLessThan(40);
|
||||
expect(colorDistance(palette.torsoUpper, colors.torso)).toBeLessThan(40);
|
||||
expect(colorDistance(palette.leg, colors.leg)).toBeLessThan(40);
|
||||
expect(colorDistance(palette.foot, colors.foot)).toBeLessThan(40);
|
||||
});
|
||||
|
||||
it("distinguishes the arms from the torso", () => {
|
||||
const { palette } = analyze();
|
||||
expect(colorDistance(palette.arm, DEFAULT_FIGURE.colors.arm)).toBeLessThan(45);
|
||||
});
|
||||
|
||||
it("never returns the backdrop colour for a body region", () => {
|
||||
const { palette } = analyze();
|
||||
for (const [region, color] of Object.entries(palette)) {
|
||||
expect(
|
||||
colorDistance(color, DEFAULT_FIGURE.backdrop),
|
||||
`${region} sampled the backdrop`,
|
||||
).toBeGreaterThan(8);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyzeReference on a real generated reference", () => {
|
||||
// The image Grok's image_gen actually produced: a survivor in a bulky jacket
|
||||
// wearing a high pack. Synthetic fixtures cannot reproduce JPEG ringing,
|
||||
// painted shading, or gear that genuinely merges with the collar.
|
||||
const fixture = new URL("../../fixtures/survivor-reference.jpg", import.meta.url).pathname;
|
||||
|
||||
it("produces an anatomically ordered figure", async () => {
|
||||
const { loadRaster } = await import("./Raster.js");
|
||||
const raster = await loadRaster(fixture);
|
||||
const mask = denoise(segmentForeground(raster));
|
||||
const { measurements, palette } = analyzeReference(raster, mask);
|
||||
const { landmarks, widths } = measurements;
|
||||
|
||||
expect(landmarks.neck.y).toBeGreaterThan(0.08);
|
||||
expect(landmarks.neck.y).toBeLessThan(0.2);
|
||||
// Must not be dragged up to the arm-separation line by the loose jacket.
|
||||
expect(landmarks.crotch.y).toBeGreaterThan(0.5);
|
||||
expect(landmarks.crotch.y).toBeLessThan(0.62);
|
||||
expect(landmarks.ankle.y).toBeGreaterThan(0.85);
|
||||
|
||||
// Torso tapers downward, and never includes the arms.
|
||||
expect(widths.chest).toBeGreaterThan(widths.hip);
|
||||
expect(widths.chest).toBeLessThan(0.35);
|
||||
expect(widths.thigh).toBeGreaterThan(widths.calf);
|
||||
|
||||
// The jacket is olive and the trousers are dark; they must not be confused.
|
||||
expect(palette.torsoUpper.g).toBeGreaterThan(palette.leg.g);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,538 @@
|
||||
import { luminance, pixelAt, type Raster, type Rgb } from "./Raster.js";
|
||||
import { isForeground, type Mask } from "./segment.js";
|
||||
|
||||
/**
|
||||
* Turns a reference image into measurements the mesh builder can draw from.
|
||||
*
|
||||
* The core trick is **run structure per scanline** rather than raw width. For a
|
||||
* front-facing figure with arms held clear of the body, a horizontal scan
|
||||
* yields a predictable pattern:
|
||||
*
|
||||
* head/neck 1 run
|
||||
* shoulders+arms 3 runs [left arm | torso | right arm]
|
||||
* below the arms 1 run (torso only)
|
||||
* below the crotch 2 runs [left leg | right leg]
|
||||
*
|
||||
* The transitions between those counts are the landmarks, and they are far more
|
||||
* robust than thresholding widths: they survive baggy clothing, backpack
|
||||
* straps, and the figure being off-centre.
|
||||
*
|
||||
* Anything that cannot be measured falls back to a documented anthropometric
|
||||
* ratio, and `MeasurementSource` records which is which so the caller can tell
|
||||
* a measurement from an assumption.
|
||||
*/
|
||||
|
||||
export type MeasurementSource = "measured" | "derived";
|
||||
|
||||
export interface Landmark {
|
||||
/** Normalised 0 (top of head) to 1 (soles). */
|
||||
y: number;
|
||||
source: MeasurementSource;
|
||||
}
|
||||
|
||||
export interface Measurements {
|
||||
/** Pixel bounds used, for debugging overlays. */
|
||||
pixelHeight: number;
|
||||
pixelWidth: number;
|
||||
|
||||
landmarks: {
|
||||
neck: Landmark;
|
||||
shoulder: Landmark;
|
||||
chest: Landmark;
|
||||
waist: Landmark;
|
||||
hip: Landmark;
|
||||
crotch: Landmark;
|
||||
knee: Landmark;
|
||||
ankle: Landmark;
|
||||
};
|
||||
|
||||
/** All widths normalised against total body height. */
|
||||
widths: {
|
||||
head: number;
|
||||
neck: number;
|
||||
shoulder: number;
|
||||
chest: number;
|
||||
waist: number;
|
||||
hip: number;
|
||||
thigh: number;
|
||||
calf: number;
|
||||
foot: number;
|
||||
upperArm: number;
|
||||
forearm: number;
|
||||
};
|
||||
|
||||
/** Half the full arm span, normalised to body height. */
|
||||
armReach: number;
|
||||
/** Horizontal offset of each foot centre from the body midline. */
|
||||
stance: number;
|
||||
/** Body height in pixels over width, for sanity checks. */
|
||||
aspect: number;
|
||||
}
|
||||
|
||||
export interface Palette {
|
||||
hair: Rgb;
|
||||
skin: Rgb;
|
||||
torsoUpper: Rgb;
|
||||
torsoLower: Rgb;
|
||||
arm: Rgb;
|
||||
hand: Rgb;
|
||||
leg: Rgb;
|
||||
foot: Rgb;
|
||||
}
|
||||
|
||||
export interface ReferenceAnalysis {
|
||||
measurements: Measurements;
|
||||
palette: Palette;
|
||||
/** Warnings where a landmark could not be measured and was derived. */
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
interface Run {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Runs shorter than this fraction of body width are speckle, not anatomy. */
|
||||
const MIN_RUN_FRACTION = 0.012;
|
||||
|
||||
function runsForRow(mask: Mask, y: number, minRunWidth: number): Run[] {
|
||||
const runs: Run[] = [];
|
||||
let start = -1;
|
||||
|
||||
for (let x = 0; x <= mask.width; x++) {
|
||||
const solid = x < mask.width && isForeground(mask, x, y);
|
||||
if (solid && start === -1) start = x;
|
||||
else if (!solid && start !== -1) {
|
||||
if (x - start >= minRunWidth) runs.push({ start, end: x - 1 });
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
const runWidth = (run: Run): number => run.end - run.start + 1;
|
||||
|
||||
/** The run containing the body midline, or the widest as a fallback. */
|
||||
function centralRun(runs: Run[], midX: number): Run | null {
|
||||
if (runs.length === 0) return null;
|
||||
const containing = runs.find((run) => midX >= run.start && midX <= run.end);
|
||||
if (containing) return containing;
|
||||
return runs.reduce((widest, run) => (runWidth(run) > runWidth(widest) ? run : widest));
|
||||
}
|
||||
|
||||
export function analyzeReference(raster: Raster, mask: Mask): ReferenceAnalysis {
|
||||
const notes: string[] = [];
|
||||
const { minY, maxY, minX, maxX } = mask.bounds;
|
||||
|
||||
const pixelHeight = maxY - minY + 1;
|
||||
const pixelWidth = maxX - minX + 1;
|
||||
const minRunWidth = Math.max(2, Math.floor(pixelWidth * MIN_RUN_FRACTION));
|
||||
|
||||
// Midline from the centre of mass, not the bounding box: an asymmetric pose
|
||||
// (a backpack, one arm lower) would otherwise skew the box.
|
||||
const midX = centreOfMassX(mask);
|
||||
|
||||
const rows: Array<{ y: number; runs: Run[]; central: Run | null }> = [];
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
const runs = runsForRow(mask, y, minRunWidth);
|
||||
rows.push({ y, runs, central: centralRun(runs, midX) });
|
||||
}
|
||||
|
||||
const norm = (y: number): number => (y - minY) / Math.max(1, pixelHeight - 1);
|
||||
const at = (t: number) => rows[Math.min(rows.length - 1, Math.max(0, Math.round(t * (rows.length - 1))))]!;
|
||||
|
||||
// --- neck: narrowest central run in the upper third --------------------
|
||||
let neckIndex = -1;
|
||||
let neckWidth = Number.POSITIVE_INFINITY;
|
||||
for (let i = Math.floor(rows.length * 0.06); i < Math.floor(rows.length * 0.3); i++) {
|
||||
const central = rows[i]?.central;
|
||||
if (!central) continue;
|
||||
if (runWidth(central) < neckWidth) {
|
||||
neckWidth = runWidth(central);
|
||||
neckIndex = i;
|
||||
}
|
||||
}
|
||||
if (neckIndex === -1) {
|
||||
neckIndex = Math.floor(rows.length * 0.13);
|
||||
neckWidth = runWidth(at(0.13).central ?? { start: 0, end: 10 });
|
||||
notes.push("neck not measurable; assumed at 13% of height");
|
||||
}
|
||||
|
||||
// --- shoulder: where the torso itself widens past the neck -------------
|
||||
//
|
||||
// Deliberately driven by the *central* run rather than by a 3-run split.
|
||||
// Gear that sticks out sideways — a backpack, a rifle, a raised collar —
|
||||
// produces side runs above the true shoulder line and fools a run-count test
|
||||
// into placing the shoulder on top of the neck.
|
||||
// Shoulder breadth runs roughly 2.2-2.7x neck breadth on an adult, so the
|
||||
// yoke announces itself as an abrupt multiple of the neck — not as a gentle
|
||||
// widening, which a collar or scarf also produces.
|
||||
//
|
||||
// The run-count guard matters as much as the width test: gear that projects
|
||||
// sideways (a backpack, a raised hood) fragments the scanline into four or
|
||||
// more runs while the central one is still narrow. Those rows are rejected
|
||||
// outright rather than mistaken for the shoulder line.
|
||||
const torsoSearchEnd = Math.floor(rows.length * 0.45);
|
||||
|
||||
const shoulderIndex = firstSustained(
|
||||
rows,
|
||||
neckIndex + 1,
|
||||
torsoSearchEnd,
|
||||
(row) =>
|
||||
row.runs.length <= 3 && row.central !== null && runWidth(row.central) >= neckWidth * 2,
|
||||
Math.max(2, Math.floor(rows.length * 0.02)),
|
||||
);
|
||||
// Anthropometric plausibility gate.
|
||||
//
|
||||
// Head height (crown to chin) is the one vertical measurement that is
|
||||
// reliable here, and adult shoulder height sits about 1.35-2.1 head heights
|
||||
// below the crown. A "shoulder" outside that band is not a shoulder — on a
|
||||
// figure wearing a high pack the gear merges with the collar and the width
|
||||
// test fires just under the jaw. Rather than trust it, fall back to 1.6 head
|
||||
// heights and say so.
|
||||
const headHeightRows = Math.max(1, neckIndex);
|
||||
const shoulderMin = Math.round(headHeightRows * 1.35);
|
||||
const shoulderMax = Math.round(headHeightRows * 2.1);
|
||||
|
||||
let shoulderSource: MeasurementSource = "measured";
|
||||
let resolvedShoulder = shoulderIndex;
|
||||
|
||||
if (resolvedShoulder === -1) {
|
||||
resolvedShoulder = Math.round(headHeightRows * 1.6);
|
||||
shoulderSource = "derived";
|
||||
notes.push("shoulder not measurable; derived as 1.6 head heights below the crown");
|
||||
} else if (resolvedShoulder < shoulderMin || resolvedShoulder > shoulderMax) {
|
||||
notes.push(
|
||||
`shoulder measured at ${(norm(rows[resolvedShoulder]!.y)).toFixed(3)} of height, outside the ` +
|
||||
`plausible band — worn gear likely merged with the collar; derived from head height instead`,
|
||||
);
|
||||
resolvedShoulder = Math.round(headHeightRows * 1.6);
|
||||
shoulderSource = "derived";
|
||||
}
|
||||
resolvedShoulder = Math.min(rows.length - 1, Math.max(0, resolvedShoulder));
|
||||
|
||||
// --- crotch: where one run becomes two, and *stays* two ----------------
|
||||
//
|
||||
// A long coat hem, a belt, or a dangling strap all notch the silhouette
|
||||
// briefly. Real legs stay separated all the way to the floor, so the split
|
||||
// must persist across most of the remaining height to count.
|
||||
// Exactly two runs, not "two or more": with the arms still clear of the body
|
||||
// a scanline reads [arm | torso | arm] — three runs — and a `>= 2` test would
|
||||
// call the point where the *arms* separate the crotch. Two runs and only two
|
||||
// means the arms have ended and what remains is a pair of legs.
|
||||
const legSearchStart = Math.floor(rows.length * 0.4);
|
||||
let crotchSource: MeasurementSource = "measured";
|
||||
let crotchIndex = firstSustained(
|
||||
rows,
|
||||
legSearchStart,
|
||||
Math.floor(rows.length * 0.75),
|
||||
(row) => row.runs.length === 2,
|
||||
Math.max(4, Math.floor(rows.length * 0.12)),
|
||||
);
|
||||
if (crotchIndex === -1) {
|
||||
crotchIndex = Math.floor(rows.length * 0.53);
|
||||
crotchSource = "derived";
|
||||
notes.push("legs never cleanly separate; crotch assumed at 53% of height");
|
||||
}
|
||||
|
||||
// --- ankle: narrowest leg span between crotch and the boot flare -------
|
||||
const footSearchStart = crotchIndex + Math.floor((rows.length - crotchIndex) * 0.55);
|
||||
let ankleIndex = -1;
|
||||
let ankleWidth = Number.POSITIVE_INFINITY;
|
||||
for (let i = footSearchStart; i < rows.length - Math.floor(rows.length * 0.02); i++) {
|
||||
const total = rows[i]!.runs.reduce((sum, run) => sum + runWidth(run), 0);
|
||||
if (total > 0 && total < ankleWidth) {
|
||||
ankleWidth = total;
|
||||
ankleIndex = i;
|
||||
}
|
||||
}
|
||||
if (ankleIndex === -1) {
|
||||
ankleIndex = Math.floor(rows.length * 0.94);
|
||||
notes.push("ankle not measurable; assumed at 94% of height");
|
||||
}
|
||||
|
||||
// Knee sits midway between crotch and ankle on a standing figure. There is no
|
||||
// reliable silhouette cue for it through trousers, so this one is always
|
||||
// derived rather than pretending to measure it.
|
||||
const kneeIndex = Math.round((crotchIndex + ankleIndex) / 2);
|
||||
|
||||
const chestIndex = Math.round(resolvedShoulder + (crotchIndex - resolvedShoulder) * 0.25);
|
||||
const waistIndex = Math.round(resolvedShoulder + (crotchIndex - resolvedShoulder) * 0.68);
|
||||
const hipIndex = Math.round(resolvedShoulder + (crotchIndex - resolvedShoulder) * 0.88);
|
||||
|
||||
// --- widths ------------------------------------------------------------
|
||||
const centralWidthAt = (index: number): number => {
|
||||
const central = rows[Math.min(rows.length - 1, Math.max(0, index))]?.central;
|
||||
return central ? runWidth(central) : 0;
|
||||
};
|
||||
|
||||
const headWidth = maxRunWidthBetween(rows, 0, neckIndex);
|
||||
|
||||
/**
|
||||
* Torso width is only observable where the arms are clear of the body. On
|
||||
* rows where they overlap, the central run is torso *plus* both arms and
|
||||
* over-reports by 50% or more, so those rows are not used at all — the
|
||||
* reference width comes from the band where the silhouette actually
|
||||
* separates, and the unmeasurable rows are scaled from it.
|
||||
*/
|
||||
const separatedWidths: number[] = [];
|
||||
for (let i = resolvedShoulder; i < crotchIndex; i++) {
|
||||
const row = rows[i]!;
|
||||
if (row.runs.length >= 3 && row.central) separatedWidths.push(runWidth(row.central));
|
||||
}
|
||||
const torsoReference =
|
||||
separatedWidths.length > 0 ? median(separatedWidths) : centralWidthAt(waistIndex);
|
||||
|
||||
const torsoWidthAt = (index: number, fallbackRatio: number): number => {
|
||||
const row = rows[Math.min(rows.length - 1, Math.max(0, index))];
|
||||
return row && row.runs.length >= 3 && row.central
|
||||
? runWidth(row.central)
|
||||
: torsoReference * fallbackRatio;
|
||||
};
|
||||
|
||||
// Shoulder breadth tracks chest breadth closely on a clothed figure.
|
||||
const shoulderWidth = torsoWidthAt(resolvedShoulder, 1);
|
||||
|
||||
// Arm thickness from the side runs where they are cleanly separated.
|
||||
const armRuns = rows
|
||||
.slice(resolvedShoulder, crotchIndex)
|
||||
.filter((row) => row.runs.length >= 3)
|
||||
.flatMap((row) => [row.runs[0]!, row.runs[row.runs.length - 1]!]);
|
||||
const upperArmWidth =
|
||||
armRuns.length > 0
|
||||
? median(armRuns.slice(0, Math.max(1, armRuns.length >> 1)).map(runWidth))
|
||||
: headWidth * 0.42;
|
||||
const forearmWidth =
|
||||
armRuns.length > 0
|
||||
? median(armRuns.slice(Math.max(1, armRuns.length >> 1)).map(runWidth))
|
||||
: upperArmWidth * 0.8;
|
||||
|
||||
const legRunsAt = (index: number): Run[] => rows[index]?.runs ?? [];
|
||||
|
||||
/**
|
||||
* Width of a *single* leg near `index`. Only rows where the legs are actually
|
||||
* split are usable — a merged row measures both legs plus the gap, and a row
|
||||
* still under a coat hem measures the coat.
|
||||
*/
|
||||
const singleLegWidth = (index: number, searchSpan: number): number => {
|
||||
const widths: number[] = [];
|
||||
for (let i = index; i < Math.min(rows.length, index + searchSpan); i++) {
|
||||
const runs = legRunsAt(i);
|
||||
if (runs.length >= 2) widths.push(median(runs.map(runWidth)));
|
||||
}
|
||||
return widths.length > 0 ? median(widths) : 0;
|
||||
};
|
||||
|
||||
const thighSpan = Math.max(3, Math.floor((kneeIndex - crotchIndex) * 0.5));
|
||||
const thighWidth =
|
||||
singleLegWidth(Math.round(crotchIndex + (kneeIndex - crotchIndex) * 0.3), thighSpan) ||
|
||||
headWidth * 0.55;
|
||||
const calfWidth = singleLegWidth(kneeIndex, thighSpan) || thighWidth * 0.78;
|
||||
|
||||
// The boot is widest at its sole, so take the maximum across the bottom band
|
||||
// rather than a sample a few rows from the very bottom, which catches only
|
||||
// the toe tip and reports a foot narrower than the ankle.
|
||||
let footWidth = 0;
|
||||
for (let i = Math.max(ankleIndex, rows.length - Math.floor(rows.length * 0.06)); i < rows.length; i++) {
|
||||
const runs = legRunsAt(i);
|
||||
if (runs.length === 0) continue;
|
||||
footWidth = Math.max(footWidth, Math.max(...runs.map(runWidth)));
|
||||
}
|
||||
if (footWidth === 0) footWidth = calfWidth * 1.3;
|
||||
|
||||
// Arm reach: the widest point anywhere above the crotch.
|
||||
let reachPixels = 0;
|
||||
for (let i = 0; i < crotchIndex; i++) {
|
||||
const row = rows[i]!;
|
||||
if (row.runs.length === 0) continue;
|
||||
const span = row.runs[row.runs.length - 1]!.end - row.runs[0]!.start + 1;
|
||||
if (span > reachPixels) reachPixels = span;
|
||||
}
|
||||
|
||||
// Stance: horizontal offset of the feet from the midline.
|
||||
const footRuns = legRunsAt(rows.length - 3);
|
||||
const stancePixels =
|
||||
footRuns.length >= 2
|
||||
? Math.abs((footRuns[0]!.start + runWidth(footRuns[0]!) / 2) - midX)
|
||||
: pixelWidth * 0.08;
|
||||
|
||||
const measurements: Measurements = {
|
||||
pixelHeight,
|
||||
pixelWidth,
|
||||
landmarks: {
|
||||
neck: { y: norm(rows[neckIndex]!.y), source: "measured" },
|
||||
shoulder: { y: norm(rows[resolvedShoulder]!.y), source: shoulderSource },
|
||||
chest: { y: norm(rows[chestIndex]!.y), source: "derived" },
|
||||
waist: { y: norm(rows[waistIndex]!.y), source: "derived" },
|
||||
hip: { y: norm(rows[hipIndex]!.y), source: "derived" },
|
||||
crotch: { y: norm(rows[crotchIndex]!.y), source: crotchSource },
|
||||
knee: { y: norm(rows[kneeIndex]!.y), source: "derived" },
|
||||
ankle: { y: norm(rows[ankleIndex]!.y), source: "measured" },
|
||||
},
|
||||
widths: {
|
||||
head: headWidth / pixelHeight,
|
||||
neck: neckWidth / pixelHeight,
|
||||
shoulder: shoulderWidth / pixelHeight,
|
||||
chest: torsoWidthAt(chestIndex, 1) / pixelHeight,
|
||||
waist: torsoWidthAt(waistIndex, 0.92) / pixelHeight,
|
||||
hip: torsoWidthAt(hipIndex, 0.95) / pixelHeight,
|
||||
thigh: thighWidth / pixelHeight,
|
||||
calf: calfWidth / pixelHeight,
|
||||
foot: footWidth / pixelHeight,
|
||||
upperArm: upperArmWidth / pixelHeight,
|
||||
forearm: forearmWidth / pixelHeight,
|
||||
},
|
||||
armReach: reachPixels / 2 / pixelHeight,
|
||||
stance: stancePixels / pixelHeight,
|
||||
aspect: pixelHeight / pixelWidth,
|
||||
};
|
||||
|
||||
const palette = samplePalette(raster, mask, rows, midX, {
|
||||
neckIndex,
|
||||
resolvedShoulder,
|
||||
chestIndex,
|
||||
waistIndex,
|
||||
crotchIndex,
|
||||
kneeIndex,
|
||||
ankleIndex,
|
||||
});
|
||||
|
||||
return { measurements, palette, notes };
|
||||
}
|
||||
|
||||
interface PaletteIndices {
|
||||
neckIndex: number;
|
||||
resolvedShoulder: number;
|
||||
chestIndex: number;
|
||||
waistIndex: number;
|
||||
crotchIndex: number;
|
||||
kneeIndex: number;
|
||||
ankleIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Median colour per body region.
|
||||
*
|
||||
* Median rather than mean: a mean blends a dark jacket and a light shirt into a
|
||||
* muddy average that appears nowhere in the reference, whereas the median lands
|
||||
* on whichever actually dominates the region.
|
||||
*/
|
||||
function samplePalette(
|
||||
raster: Raster,
|
||||
mask: Mask,
|
||||
rows: Array<{ y: number; runs: Run[]; central: Run | null }>,
|
||||
midX: number,
|
||||
indices: PaletteIndices,
|
||||
): Palette {
|
||||
const sampleCentral = (fromIndex: number, toIndex: number): Rgb => {
|
||||
const samples: Rgb[] = [];
|
||||
const step = Math.max(1, Math.floor((toIndex - fromIndex) / 24));
|
||||
|
||||
for (let i = fromIndex; i < toIndex; i += step) {
|
||||
const row = rows[i];
|
||||
if (!row?.central) continue;
|
||||
// Inset from the silhouette edge to dodge outlines and JPEG ringing.
|
||||
const inset = Math.max(1, Math.floor(runWidth(row.central) * 0.25));
|
||||
for (let x = row.central.start + inset; x <= row.central.end - inset; x += 3) {
|
||||
if (isForeground(mask, x, row.y)) samples.push(pixelAt(raster, x, row.y));
|
||||
}
|
||||
}
|
||||
return medianColor(samples);
|
||||
};
|
||||
|
||||
const sampleSideRuns = (fromIndex: number, toIndex: number): Rgb => {
|
||||
const samples: Rgb[] = [];
|
||||
for (let i = fromIndex; i < toIndex; i++) {
|
||||
const row = rows[i];
|
||||
if (!row || row.runs.length < 3) continue;
|
||||
for (const run of [row.runs[0]!, row.runs[row.runs.length - 1]!]) {
|
||||
const centre = Math.round((run.start + run.end) / 2);
|
||||
if (isForeground(mask, centre, row.y)) samples.push(pixelAt(raster, centre, row.y));
|
||||
}
|
||||
}
|
||||
return samples.length > 0 ? medianColor(samples) : sampleCentral(fromIndex, toIndex);
|
||||
};
|
||||
|
||||
const headTop = 0;
|
||||
const headEnd = indices.neckIndex;
|
||||
const hairEnd = headTop + Math.round((headEnd - headTop) * 0.34);
|
||||
|
||||
// The face is the lighter half of the head region; hair the darker cap.
|
||||
const hair = sampleCentral(headTop, Math.max(headTop + 1, hairEnd));
|
||||
const skin = sampleCentral(hairEnd, Math.max(hairEnd + 1, headEnd));
|
||||
|
||||
const armStart = indices.resolvedShoulder;
|
||||
const armSplit = Math.round(armStart + (indices.crotchIndex - armStart) * 0.55);
|
||||
|
||||
return {
|
||||
hair,
|
||||
skin,
|
||||
torsoUpper: sampleCentral(indices.resolvedShoulder, indices.waistIndex),
|
||||
torsoLower: sampleCentral(indices.waistIndex, indices.crotchIndex),
|
||||
arm: sampleSideRuns(armStart, armSplit),
|
||||
hand: sampleSideRuns(armSplit, indices.crotchIndex),
|
||||
leg: sampleCentral(indices.crotchIndex, indices.ankleIndex),
|
||||
foot: sampleCentral(indices.ankleIndex, rows.length - 1),
|
||||
};
|
||||
}
|
||||
|
||||
function centreOfMassX(mask: Mask): number {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let y = mask.bounds.minY; y <= mask.bounds.maxY; y++) {
|
||||
for (let x = mask.bounds.minX; x <= mask.bounds.maxX; x++) {
|
||||
if (mask.data[y * mask.width + x] !== 1) continue;
|
||||
sum += x;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count > 0 ? sum / count : mask.width / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* First index in `[from, to)` where `predicate` holds and keeps holding for
|
||||
* `sustain` consecutive rows. Transient silhouette features — a strap, a hem,
|
||||
* a stray highlight — satisfy a predicate for a row or two; anatomy does not.
|
||||
*/
|
||||
function firstSustained(
|
||||
rows: Array<{ runs: Run[]; central: Run | null }>,
|
||||
from: number,
|
||||
to: number,
|
||||
predicate: (row: { runs: Run[]; central: Run | null }) => boolean,
|
||||
sustain: number,
|
||||
): number {
|
||||
for (let i = Math.max(0, from); i < Math.min(rows.length, to); i++) {
|
||||
if (!predicate(rows[i]!)) continue;
|
||||
|
||||
let held = 0;
|
||||
while (held < sustain && i + held < rows.length && predicate(rows[i + held]!)) held++;
|
||||
if (held >= sustain) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function maxRunWidthBetween(
|
||||
rows: Array<{ central: Run | null }>,
|
||||
fromIndex: number,
|
||||
toIndex: number,
|
||||
): number {
|
||||
let widest = 0;
|
||||
for (let i = fromIndex; i < toIndex; i++) {
|
||||
const central = rows[i]?.central;
|
||||
if (central) widest = Math.max(widest, runWidth(central));
|
||||
}
|
||||
return widest;
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[sorted.length >> 1]!;
|
||||
}
|
||||
|
||||
function medianColor(samples: Rgb[]): Rgb {
|
||||
if (samples.length === 0) return { r: 128, g: 128, b: 128 };
|
||||
// Median by luminance keeps a real pixel rather than inventing a channel mix.
|
||||
const sorted = [...samples].sort((a, b) => luminance(a) - luminance(b));
|
||||
return sorted[sorted.length >> 1]!;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { colorDistance, pixelAt, type Raster, type Rgb } from "./Raster.js";
|
||||
|
||||
/**
|
||||
* Foreground extraction.
|
||||
*
|
||||
* The reference prompt asks for a plain uniform backdrop, which makes this
|
||||
* tractable without a segmentation model: sample the corners for the backdrop
|
||||
* colour, then flood-fill inward from the border. Flood-fill rather than a
|
||||
* global colour threshold matters — a global test would also erase any part of
|
||||
* the *character* that happens to match the backdrop (grey clothing against
|
||||
* grey), whereas a fill only removes background actually connected to the edge.
|
||||
*/
|
||||
|
||||
export interface Mask {
|
||||
width: number;
|
||||
height: number;
|
||||
/** 1 = foreground, 0 = background. */
|
||||
data: Uint8Array;
|
||||
/** Tight bounds of the foreground. */
|
||||
bounds: { minX: number; minY: number; maxX: number; maxY: number };
|
||||
coverage: number;
|
||||
}
|
||||
|
||||
export interface SegmentOptions {
|
||||
/** Colour distance under which a pixel counts as backdrop. */
|
||||
tolerance?: number;
|
||||
/** Fraction of the shorter side sampled at each corner. */
|
||||
cornerSampleRatio?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TOLERANCE = 42;
|
||||
|
||||
/** Median of the four corner patches, so one odd corner cannot skew it. */
|
||||
export function estimateBackdrop(raster: Raster, sampleRatio = 0.06): Rgb {
|
||||
const size = Math.max(2, Math.floor(Math.min(raster.width, raster.height) * sampleRatio));
|
||||
const reds: number[] = [];
|
||||
const greens: number[] = [];
|
||||
const blues: number[] = [];
|
||||
|
||||
const corners: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[raster.width - size, 0],
|
||||
[0, raster.height - size],
|
||||
[raster.width - size, raster.height - size],
|
||||
];
|
||||
|
||||
for (const [originX, originY] of corners) {
|
||||
for (let y = originY; y < originY + size; y++) {
|
||||
for (let x = originX; x < originX + size; x++) {
|
||||
const pixel = pixelAt(raster, x, y);
|
||||
reds.push(pixel.r);
|
||||
greens.push(pixel.g);
|
||||
blues.push(pixel.b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { r: median(reds), g: median(greens), b: median(blues) };
|
||||
}
|
||||
|
||||
export function segmentForeground(raster: Raster, options: SegmentOptions = {}): Mask {
|
||||
const tolerance = options.tolerance ?? DEFAULT_TOLERANCE;
|
||||
const backdrop = estimateBackdrop(raster, options.cornerSampleRatio);
|
||||
|
||||
const { width, height } = raster;
|
||||
const total = width * height;
|
||||
|
||||
// Start as all-foreground; the fill carves the background away.
|
||||
const data = new Uint8Array(total).fill(1);
|
||||
|
||||
// Iterative stack rather than recursion — a 1024x1365 image overflows the
|
||||
// call stack immediately with a recursive fill.
|
||||
const stack: number[] = [];
|
||||
const pushIfBackdrop = (x: number, y: number) => {
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) return;
|
||||
const index = y * width + x;
|
||||
if (data[index] === 0) return;
|
||||
if (colorDistance(pixelAt(raster, x, y), backdrop) > tolerance) return;
|
||||
data[index] = 0;
|
||||
stack.push(index);
|
||||
};
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
pushIfBackdrop(x, 0);
|
||||
pushIfBackdrop(x, height - 1);
|
||||
}
|
||||
for (let y = 0; y < height; y++) {
|
||||
pushIfBackdrop(0, y);
|
||||
pushIfBackdrop(width - 1, y);
|
||||
}
|
||||
|
||||
while (stack.length > 0) {
|
||||
const index = stack.pop()!;
|
||||
const x = index % width;
|
||||
const y = (index - x) / width;
|
||||
pushIfBackdrop(x + 1, y);
|
||||
pushIfBackdrop(x - 1, y);
|
||||
pushIfBackdrop(x, y + 1);
|
||||
pushIfBackdrop(x, y - 1);
|
||||
}
|
||||
|
||||
const mask: Mask = {
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
bounds: { minX: width, minY: height, maxX: -1, maxY: -1 },
|
||||
coverage: 0,
|
||||
};
|
||||
|
||||
let filled = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (data[y * width + x] !== 1) continue;
|
||||
filled++;
|
||||
if (x < mask.bounds.minX) mask.bounds.minX = x;
|
||||
if (x > mask.bounds.maxX) mask.bounds.maxX = x;
|
||||
if (y < mask.bounds.minY) mask.bounds.minY = y;
|
||||
if (y > mask.bounds.maxY) mask.bounds.maxY = y;
|
||||
}
|
||||
}
|
||||
|
||||
mask.coverage = filled / total;
|
||||
if (mask.bounds.maxX < 0) {
|
||||
throw new Error("Segmentation found no foreground — is the background plain and uniform?");
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes salt-and-pepper speckle left by JPEG ringing near the silhouette
|
||||
* edge, which otherwise adds spurious width to the row profile.
|
||||
*/
|
||||
export function denoise(mask: Mask, minNeighbours = 5): Mask {
|
||||
const { width, height, data } = mask;
|
||||
const out = new Uint8Array(data);
|
||||
|
||||
for (let y = 1; y < height - 1; y++) {
|
||||
for (let x = 1; x < width - 1; x++) {
|
||||
let neighbours = 0;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
if (dx === 0 && dy === 0) continue;
|
||||
neighbours += data[(y + dy) * width + (x + dx)]!;
|
||||
}
|
||||
}
|
||||
const index = y * width + x;
|
||||
if (data[index] === 1 && neighbours < minNeighbours - 2) out[index] = 0;
|
||||
else if (data[index] === 0 && neighbours > minNeighbours + 1) out[index] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...mask, data: out };
|
||||
}
|
||||
|
||||
export const isForeground = (mask: Mask, x: number, y: number): boolean =>
|
||||
x >= 0 && y >= 0 && x < mask.width && y < mask.height && mask.data[y * mask.width + x] === 1;
|
||||
|
||||
/** Foreground pixel count and horizontal extent for one scanline. */
|
||||
export function rowProfile(mask: Mask, y: number): { count: number; minX: number; maxX: number } {
|
||||
let count = 0;
|
||||
let minX = mask.width;
|
||||
let maxX = -1;
|
||||
|
||||
for (let x = 0; x < mask.width; x++) {
|
||||
if (mask.data[y * mask.width + x] !== 1) continue;
|
||||
count++;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
}
|
||||
return { count, minX, maxX };
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = sorted.length >> 1;
|
||||
return sorted.length % 2 === 0
|
||||
? Math.round((sorted[mid - 1]! + sorted[mid]!) / 2)
|
||||
: sorted[mid]!;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { Raster, Rgb } from "./Raster.js";
|
||||
|
||||
/**
|
||||
* Synthetic reference figures for testing the analyzer.
|
||||
*
|
||||
* Deterministic and with known ground truth, so a landmark assertion means
|
||||
* something precise. Testing only against a real generated image would prove
|
||||
* the analyzer works on *that* image; these prove it works against stated
|
||||
* proportions, and let us build adversarial cases — a backpack that merges with
|
||||
* the collar, legs that never separate — on demand.
|
||||
*/
|
||||
|
||||
export interface FigureSpec {
|
||||
width: number;
|
||||
height: number;
|
||||
backdrop: Rgb;
|
||||
/** All values are fractions of image height, measured from the crown. */
|
||||
headTop: number;
|
||||
neckY: number;
|
||||
shoulderY: number;
|
||||
crotchY: number;
|
||||
ankleY: number;
|
||||
soleY: number;
|
||||
headWidth: number;
|
||||
neckWidth: number;
|
||||
torsoWidth: number;
|
||||
legWidth: number;
|
||||
legGap: number;
|
||||
footWidth: number;
|
||||
armWidth: number;
|
||||
/** Arms separate from the torso below this height. */
|
||||
armSeparationY: number;
|
||||
armReach: number;
|
||||
/** Ankle width as a fraction of thigh width. */
|
||||
ankleTaper: number;
|
||||
colors: {
|
||||
hair: Rgb;
|
||||
skin: Rgb;
|
||||
torso: Rgb;
|
||||
arm: Rgb;
|
||||
leg: Rgb;
|
||||
foot: Rgb;
|
||||
};
|
||||
/** Optional pack that projects sideways beside the neck, merging with it. */
|
||||
backpack?: { fromY: number; toY: number; width: number; color: Rgb };
|
||||
}
|
||||
|
||||
export const DEFAULT_FIGURE: FigureSpec = {
|
||||
width: 512,
|
||||
height: 768,
|
||||
backdrop: { r: 128, g: 128, b: 128 },
|
||||
headTop: 0.02,
|
||||
neckY: 0.14,
|
||||
shoulderY: 0.2,
|
||||
crotchY: 0.55,
|
||||
ankleY: 0.93,
|
||||
soleY: 0.98,
|
||||
headWidth: 0.1,
|
||||
neckWidth: 0.05,
|
||||
torsoWidth: 0.26,
|
||||
legWidth: 0.09,
|
||||
legGap: 0.035,
|
||||
footWidth: 0.11,
|
||||
armWidth: 0.075,
|
||||
armSeparationY: 0.3,
|
||||
armReach: 0.38,
|
||||
ankleTaper: 0.62,
|
||||
colors: {
|
||||
hair: { r: 40, g: 34, b: 30 },
|
||||
skin: { r: 198, g: 156, b: 130 },
|
||||
torso: { r: 86, g: 92, b: 60 },
|
||||
arm: { r: 74, g: 80, b: 52 },
|
||||
leg: { r: 48, g: 50, b: 58 },
|
||||
foot: { r: 92, g: 66, b: 44 },
|
||||
},
|
||||
};
|
||||
|
||||
export function renderFigure(spec: FigureSpec = DEFAULT_FIGURE): Raster {
|
||||
const { width, height } = spec;
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
|
||||
const put = (x: number, y: number, color: Rgb) => {
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) return;
|
||||
const i = (y * width + x) * 4;
|
||||
data[i] = color.r;
|
||||
data[i + 1] = color.g;
|
||||
data[i + 2] = color.b;
|
||||
data[i + 3] = 255;
|
||||
};
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) put(x, y, spec.backdrop);
|
||||
}
|
||||
|
||||
const midX = Math.round(width / 2);
|
||||
const py = (t: number) => Math.round(t * height);
|
||||
const px = (t: number) => Math.round(t * height); // widths are height-relative
|
||||
|
||||
// Half-widths must be integers: a fractional loop bound produces fractional
|
||||
// array indices, which typed arrays silently discard, and the shape simply
|
||||
// never gets drawn.
|
||||
const bar = (fromY: number, toY: number, halfWidth: number, color: Rgb, centre = midX) => {
|
||||
const half = Math.round(halfWidth);
|
||||
for (let y = py(fromY); y < py(toY); y++) {
|
||||
for (let x = centre - half; x <= centre + half; x++) put(x, y, color);
|
||||
}
|
||||
};
|
||||
|
||||
// Head: a hair cap over a face, so the palette sampler has two bands to find.
|
||||
const headHalf = Math.round(px(spec.headWidth) / 2);
|
||||
const hairEnd = spec.headTop + (spec.neckY - spec.headTop) * 0.34;
|
||||
bar(spec.headTop, hairEnd, headHalf, spec.colors.hair);
|
||||
bar(hairEnd, spec.neckY, headHalf, spec.colors.skin);
|
||||
|
||||
bar(spec.neckY, spec.shoulderY, Math.round(px(spec.neckWidth) / 2), spec.colors.skin);
|
||||
|
||||
if (spec.backpack) {
|
||||
bar(
|
||||
spec.backpack.fromY,
|
||||
spec.backpack.toY,
|
||||
Math.round(px(spec.backpack.width) / 2),
|
||||
spec.backpack.color,
|
||||
);
|
||||
}
|
||||
|
||||
bar(spec.shoulderY, spec.crotchY, Math.round(px(spec.torsoWidth) / 2), spec.colors.torso);
|
||||
|
||||
// Arms: angled outward, leaving the torso at `armSeparationY`.
|
||||
const armHalf = Math.round(px(spec.armWidth) / 2);
|
||||
const torsoHalf = Math.round(px(spec.torsoWidth) / 2);
|
||||
const reachPx = px(spec.armReach);
|
||||
const armStartY = py(spec.shoulderY);
|
||||
const armEndY = py(spec.crotchY);
|
||||
|
||||
for (let y = armStartY; y < armEndY; y++) {
|
||||
const t = (y - armStartY) / Math.max(1, armEndY - armStartY);
|
||||
const offset = Math.round(torsoHalf * 0.6 + (reachPx - armHalf - torsoHalf * 0.6) * t);
|
||||
for (const side of [-1, 1]) {
|
||||
const centre = midX + side * offset;
|
||||
for (let x = centre - armHalf; x <= centre + armHalf; x++) put(x, y, spec.colors.arm);
|
||||
}
|
||||
}
|
||||
|
||||
// Legs and boots.
|
||||
const legHalf = Math.round(px(spec.legWidth) / 2);
|
||||
const gapHalf = Math.round(px(spec.legGap) / 2);
|
||||
const footHalf = Math.round(px(spec.footWidth) / 2);
|
||||
const ankleHalf = Math.round(legHalf * spec.ankleTaper);
|
||||
|
||||
for (const side of [-1, 1]) {
|
||||
const centre = midX + side * (gapHalf + legHalf);
|
||||
|
||||
// Legs taper from thigh to ankle, so the ankle is a genuine local minimum
|
||||
// in the width profile rather than an arbitrary row of a constant column.
|
||||
const fromY = py(spec.crotchY);
|
||||
const toY = py(spec.ankleY);
|
||||
for (let y = fromY; y < toY; y++) {
|
||||
const t = (y - fromY) / Math.max(1, toY - fromY);
|
||||
const half = Math.round(legHalf + (ankleHalf - legHalf) * t);
|
||||
for (let x = centre - half; x <= centre + half; x++) put(x, y, spec.colors.leg);
|
||||
}
|
||||
bar(spec.ankleY, spec.soleY, footHalf, spec.colors.foot, centre);
|
||||
}
|
||||
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
/** The adversarial case: a high pack that merges with the collar in silhouette. */
|
||||
export const PACKED_FIGURE: FigureSpec = {
|
||||
...DEFAULT_FIGURE,
|
||||
backpack: {
|
||||
fromY: 0.155,
|
||||
toY: 0.45,
|
||||
width: 0.3,
|
||||
color: { r: 70, g: 64, b: 50 },
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user