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.
539 lines
20 KiB
TypeScript
539 lines
20 KiB
TypeScript
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]!;
|
|
}
|