175 lines
12 KiB
JavaScript
175 lines
12 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
|
|
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
|
|
const paletteShader = await readFile(new URL('../src/shaders/region-palette.frag', import.meta.url), 'utf8');
|
|
const historyShader = await readFile(new URL('../src/shaders/region-history.frag', import.meta.url), 'utf8');
|
|
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
|
|
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
|
|
assert.match(main, /uGridSize: \{ value: 13 \}/);
|
|
assert.match(main, /uStrength: \{ value: 1 \}/);
|
|
assert.match(main, /uRegionStrength: \{ value: 1 \}/);
|
|
assert.match(main, /uCurvature: \{ value: 1 \}/);
|
|
assert.match(main, /uRegionPaletteSize: \{ value: new THREE\.Vector2\(65, 13\) \}/);
|
|
assert.match(main, /new THREE\.WebGLRenderTarget\(65, 13/);
|
|
assert.match(main, /paletteWidth = gridX \* 5/);
|
|
assert.match(html, /id="grid-size" type="range" min="10" max="24" step="1" value="13"/);
|
|
assert.match(html, /id="curvature" type="range" min="-1" max="1" step="0\.01" value="1"/);
|
|
assert.match(html, /id="gloss-strength" type="range" min="0" max="0\.4" step="0\.01" value="0\.30"/);
|
|
assert.match(html, /id="region-fixed-note"/);
|
|
assert.doesNotMatch(html, /id="region-strength"|id="strength"|id="strengthOut"/);
|
|
assert.doesNotMatch(main, /strengthInput|strengthOutput|regionStrengthInput|regionStrengthOutput|region-strength|strengthOut/);
|
|
for (const [, id] of main.matchAll(/document\.querySelector\('#([^']+)'\)/g)) {
|
|
assert.ok(html.includes(`id="${id}"`), `main DOM reference #${id} must exist in index.html`);
|
|
}
|
|
assert.match(main, /uniforms\.uGridSize\.value = style === 0 \? 13 : 24; uniforms\.uStrength\.value = 1/);
|
|
assert.match(main, /setMode\('final'\);\s*status\.textContent = automatic \?/);
|
|
assert.match(html, /<details>\s*<summary>사진 구도<\/summary>/);
|
|
assert.match(html, /<details>\s*<summary>개발 비교<\/summary>/);
|
|
assert.match(html, /<summary>개발 비교<\/summary>[\s\S]*id="video"[\s\S]*id="depth-image"[\s\S]*id="view-mode"[\s\S]*id="effect"[\s\S]*id="mosaic-style"[\s\S]*id="depth-scale"[\s\S]*id="light-x"[\s\S]*id="sensitivity"/);
|
|
assert.match(shader, /cell\.x\*5\.\+index/);
|
|
assert.match(shader, /for\(int y=-1;y<=1;y\+\+\)for\(int x=-1;x<=1;x\+\+\)/);
|
|
assert.match(shader, /candidateColor=mix\(radialColor,splitColor,splitHistory\.a\)/);
|
|
assert.match(shader, /splitColor=splitHistory\.a>\.00001\?splitHistory\.rgb\/splitHistory\.a:radialColor/);
|
|
assert.match(historyShader, /gl_FragColor=mix\(previous,current,clamp\(uHistoryBlend,0\.,1\.\)\)/);
|
|
assert.match(historyShader, /vec4 current=vec4\(paletteColor\(cell,label\)\*confidence,confidence\)/);
|
|
assert.match(historyShader, /cell\.x\*5\.\+4\./);
|
|
assert.doesNotMatch(shader, /protectDark|ownPalette|darkestPalette|protectedColor|assignedColor/);
|
|
assert.match(shader, /atlasEvidence=regionProtectionWeight\(finalSceneUv\)\*\(1\.-smoothstep\(\.10,\.20,regionLuminance\(atlasCenterColor\)\)\)/);
|
|
assert.match(shader, /color\.a=atlasEvidence/);
|
|
assert.equal((shader.match(/regionAtlasProbe\(/g) ?? []).length, 5, 'atlas sampling must use one probe helper plus exactly four fixed probe calls');
|
|
assert.match(shader, /probeStep=uPaneSize\/\(uRegionGridSize\*8\.\)\*\.25/);
|
|
assert.match(shader, /probeSceneUv=projectUv\(sceneHit\+vec3\(worldOffset,0\.\)\)/);
|
|
assert.match(shader, /sampleScene\(probeSceneUv\)/);
|
|
assert.match(shader, /regionAtlasEvidence\(probeSceneUv,probe\.rgb,glassHit\)/);
|
|
assert.match(shader, /uRegionStrength\*clamp\(uStrength,0\.,1\.\)\*regionSurfaceValid/);
|
|
assert.match(shader, /worldDelta\+=boundedIncidence\*cellEnvelope\*cellDimensions\*signedCurvatureScale\(\)\*\.18/);
|
|
assert.match(paletteShader, /for\(int candidateIndex=0;candidateIndex<5;candidateIndex\+\+\)/);
|
|
assert.match(paletteShader, /if\(candidateIndex==1\)cut=3\.;else if\(candidateIndex==2\)cut=5\.;else if\(candidateIndex==3\)cut=2\.;else if\(candidateIndex==4\)cut=6\./);
|
|
assert.match(paletteShader, /candidateCost=sideColorCost\(weightedVotesA,colorA,c0,c1,c2,c3\)\+sideColorCost\(weightedVotesB,colorB,c0,c1,c2,c3\)/);
|
|
assert.match(paletteShader, /weightedVotesA\+=oneHot\*sampleWeight/);
|
|
assert.match(paletteShader, /sideColorCost\(weightedVotesA,colorA,c0,c1,c2,c3\)/);
|
|
assert.match(paletteShader, /if\(pairCost<bestCost\)/);
|
|
assert.match(paletteShader, /smoothstep\(\.55,\.75,accuracy\)\*smoothstep\(\.015625,\.078125,gain\)/);
|
|
assert.match(paletteShader, /a!=b&&votesA\[a\]>0\.&&votesB\[b\]>0\./);
|
|
assert.match(paletteShader, /float confidence=\.85\+\.15\*oldConfidence/);
|
|
assert.match(paletteShader, /confidence=mix\(confidence,1\.,smoothstep\(\.02,\.06,cellEvidence\/64\.\)\)/);
|
|
assert.ok(paletteShader.includes('metadata=vec4((bestAxis*8.+bestCut)/255.,bestA/255.,bestB/255.,bestConfidence);'));
|
|
|
|
const cuts = [4, 3, 5, 2, 6];
|
|
const max4 = counts => Math.max(...counts);
|
|
const palette = [0, 0.30, 0.70, 1].map(value => [value, value, value]);
|
|
const distanceColor = (a, b) => {
|
|
const d = a.map((value, i) => value - b[i]);
|
|
const y = d[0] * 0.299 + d[1] * 0.587 + d[2] * 0.114;
|
|
return d.reduce((sum, value) => sum + value * value, 0) + 0.35 * y * y;
|
|
};
|
|
function sideColorCost(votes, target) {
|
|
return votes.reduce((sum, count, label) => sum + count * distanceColor(palette[label], palette[target]), 0);
|
|
}
|
|
const smoothstep = (a, b, x) => {
|
|
const t = Math.max(0, Math.min(1, (x - a) / (b - a)));
|
|
return t * t * (3 - 2 * t);
|
|
};
|
|
function fitSplit(labels) {
|
|
const whole = [0, 0, 0, 0];
|
|
for (const label of labels) whole[label]++;
|
|
const singleMajority = max4(whole) / 64;
|
|
let best = null, bestCost = Infinity;
|
|
for (let axis = 0; axis < 2; axis++) for (const cut of cuts) {
|
|
const sideA = [0, 0, 0, 0], sideB = [0, 0, 0, 0];
|
|
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) {
|
|
const label = labels[y * 8 + x];
|
|
const isA = axis === 0 ? x + 0.5 < cut : y + 0.5 < cut;
|
|
(isA ? sideA : sideB)[label]++;
|
|
}
|
|
let pair = null, pairCost = Infinity;
|
|
for (let labelA = 0; labelA < 4; labelA++) for (let labelB = 0; labelB < 4; labelB++) {
|
|
if (labelA === labelB || sideA[labelA] === 0 || sideB[labelB] === 0) continue;
|
|
const cost = sideColorCost(sideA, labelA) + sideColorCost(sideB, labelB);
|
|
if (cost < pairCost) { pairCost = cost; pair = { labelA, labelB }; }
|
|
}
|
|
if (pair && pairCost < bestCost) {
|
|
bestCost = pairCost;
|
|
const accuracy = (sideA[pair.labelA] + sideB[pair.labelB]) / 64;
|
|
const gain = accuracy - singleMajority;
|
|
const oldConfidence = smoothstep(0.55, 0.75, accuracy) * smoothstep(0.015625, 0.078125, gain);
|
|
best = { axis, cut, ...pair, accuracy, gain, cost: pairCost, confidence: 0.85 + 0.15 * oldConfidence };
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
function encodeDecode({ axis, cut, labelA, labelB, confidence }) {
|
|
const rgba = [(axis * 8 + cut) / 255, labelA / 255, labelB / 255, confidence]
|
|
.map((value, index) => index === 3 ? value : Math.round(value * 255) / 255);
|
|
const packed = Math.floor(rgba[0] * 255 + 0.5);
|
|
return { axis: Math.floor(packed / 8), cut: packed % 8,
|
|
labelA: Math.floor(rgba[1] * 255 + 0.5), labelB: Math.floor(rgba[2] * 255 + 0.5), confidence: rgba[3] };
|
|
}
|
|
function splitFixture(axis, cut, labelA = 0, labelB = 1) {
|
|
return Array.from({ length: 64 }, (_, index) => {
|
|
const x = index % 8, y = Math.floor(index / 8);
|
|
return (axis === 0 ? x + 0.5 < cut : y + 0.5 < cut) ? labelA : labelB;
|
|
});
|
|
}
|
|
function darkSafeColor(sourceLuminance, ownIndex, paletteLuminance, candidateIndex) {
|
|
let assignedIndex = candidateIndex;
|
|
if (sourceLuminance < 0.20 && paletteLuminance[candidateIndex] - sourceLuminance > 0.16) {
|
|
assignedIndex = ownIndex;
|
|
if (paletteLuminance[ownIndex] - sourceLuminance > 0.16) {
|
|
let darkestIndex = 0;
|
|
for (let index = 1; index < paletteLuminance.length; index++) {
|
|
if (paletteLuminance[index] < paletteLuminance[darkestIndex]) darkestIndex = index;
|
|
}
|
|
assignedIndex = paletteLuminance[darkestIndex] <= sourceLuminance + 0.16 ? darkestIndex : null;
|
|
}
|
|
}
|
|
return assignedIndex;
|
|
}
|
|
for (const [axis, cut] of [[0, 2], [0, 6], [1, 2], [1, 6]]) {
|
|
const model = fitSplit(splitFixture(axis, cut, 2, 3));
|
|
assert.equal(model.axis, axis, `axis mismatch for split ${axis}/${cut}`);
|
|
assert.equal(model.cut, cut, `cut mismatch for split ${axis}/${cut}`);
|
|
assert.equal(model.accuracy, 1, `exact split should explain every sample for ${axis}/${cut}`);
|
|
const decoded = encodeDecode(model);
|
|
assert.deepEqual([decoded.axis, decoded.cut, decoded.labelA, decoded.labelB], [axis, cut, 2, 3], 'RGBA metadata must round-trip');
|
|
}
|
|
|
|
const checkerboard = Array.from({ length: 64 }, (_, index) => ((index % 8) + Math.floor(index / 8)) % 2);
|
|
const checkerboardModel = fitSplit(checkerboard);
|
|
assert.ok(checkerboardModel, 'a cell with two observed labels on both sides can use the two-color model');
|
|
assert.notEqual(checkerboardModel.labelA, checkerboardModel.labelB);
|
|
assert.equal(checkerboardModel.accuracy, 0.5, 'checkerboard split should reflect its measured pair accuracy');
|
|
assert.equal(checkerboardModel.confidence, 0.85, 'valid two-color model starts at the documented confidence floor');
|
|
assert.equal(fitSplit(Array(64).fill(2)), null, 'a single-label cell must not invent a split');
|
|
|
|
const darkSource = 0.10;
|
|
assert.equal(darkSafeColor(darkSource, 1, [0.70, 0.18, 0.30, 0.44], 0), 1,
|
|
'a sufficiently dark own-palette color should preserve the observed dark shape');
|
|
assert.equal(darkSafeColor(darkSource, 1, [0.70, 0.40, 0.12, 0.44], 0), 2,
|
|
'otherwise use the darkest palette color within the source luminance tolerance');
|
|
assert.equal(darkSafeColor(darkSource, 1, [0.70, 0.40, 0.31, 0.44], 0), null,
|
|
'retain original pixel RGB only if no palette color can safely represent it');
|
|
assert.equal(darkSafeColor(0.25, 1, [0.70, 0.40, 0.31, 0.44], 0), 0,
|
|
'ordinary and non-dark source pixels follow the selected color without protection');
|
|
|
|
const skewedVotes = [6, 5, 0, 4];
|
|
assert.equal(skewedVotes.indexOf(max4(skewedVotes)), 0, 'fixture has a count majority at label 0');
|
|
assert.equal([0, 1, 2, 3].reduce((best, label) => sideColorCost(skewedVotes, label) < sideColorCost(skewedVotes, best) ? label : best, 0), 1,
|
|
'SSE should choose the color representative closer to the full observed distribution');
|
|
const nearDarkVotes = [8, 3, 0, 4];
|
|
const nearDarkWeighted = nearDarkVotes.map((count, label) => count * (label === 0 ? 4 : 1));
|
|
const sseWinner = votes => [0, 1, 2, 3].reduce((best, label) => sideColorCost(votes, label) < sideColorCost(votes, best) ? label : best, 0);
|
|
assert.equal(sseWinner(nearDarkVotes), 1, 'without near-dark evidence, the ordinary SSE representative wins');
|
|
assert.equal(sseWinner(nearDarkWeighted), 0, 'near-dark evidence changes only the weighted SSE representative');
|
|
const evidenceConfidence = (confidence, evidence) => confidence + (1 - confidence) * smoothstep(0.02, 0.06, evidence);
|
|
assert.equal(evidenceConfidence(0.85, 0), 0.85, 'far or invalid evidence leaves ordinary confidence unchanged');
|
|
assert.equal(evidenceConfidence(0.85, 0.06), 1, 'enough near-dark evidence raises confidence to full strength');
|
|
assert.ok(evidenceConfidence(0.85, 0.04) > 0.85 && evidenceConfidence(0.85, 0.04) < 1,
|
|
'partial near-dark evidence increases confidence smoothly');
|
|
|
|
const radial = [0.2, 0.4, 0.6], split = [0.8, 0.4, 0.2], conf = 0.5;
|
|
assert.deepEqual(radial.map((value, i) => value * (1 - conf) + split[i] * conf), [0.5, 0.4, 0.4], 'confidence blends split with radial fallback');
|
|
|
|
console.log('Region axis-split checks passed: SSE axis/cut/pair scoring, vertical/horizontal cuts 2/6, observed side-label pairs, checkerboard model, single-label fallback, metadata round-trip, confidence blending, dark eye/nose protection, and edge-independent color strength.');
|