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]!; }