fix: stabilize localization comparison semantics

This commit is contained in:
2026-08-28 20:57:13 +09:00
parent 9b9244be85
commit e8dac792d9
10 changed files with 340 additions and 31 deletions
+32
View File
@@ -118,6 +118,38 @@ test("WebGPU DAS가 CPU 수치 기준과 일치하거나 auto CPU fallback한다
document.documentElement.dataset.webgpuRmsError = error.rms.toExponential(3);
});
test("WebGPU DAS-PHAT가 CPU score map과 일치하거나 auto CPU fallback한다", async () => {
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
const fixture = createFixture(adapter ? "webgpu" : "auto");
fixture.experiment.beamforming.algorithm = "das-phat";
fixture.experiment.frequencyBands = [
{ id: "phase-1k", minHz: 900, maxHz: 1_100, color: "#65f2bf" },
];
if (!adapter) {
const result = await computeDasWithBackend(fixture);
assertEqual(result.backend, "cpu", "DAS-PHAT auto fallback backend");
assertEqual(result.algorithm, "das-phat", "DAS-PHAT fallback algorithm");
assertEqual(result.bands[0].rawScale, "score", "DAS-PHAT fallback scale");
return;
}
const cpuResult = await computeDasMap(fixture);
const gpuResult = await computeDasWebGpu(fixture);
const cpuBand = cpuResult.bands[0];
const gpuBand = gpuResult.bands[0];
const error = normalizedMapError(cpuBand.rawMap, gpuBand.rawMap);
assertEqual(gpuResult.algorithm, "das-phat", "actual WebGPU DAS-PHAT algorithm");
assertEqual(gpuBand.rawScale, "score", "actual WebGPU DAS-PHAT scale");
assert(!("rawPower" in gpuBand.peak), "DAS-PHAT GPU peak is not mislabeled as power");
assert(error.rms <= 0.005, `DAS-PHAT CPU/GPU normalized RMS error=${error.rms}`);
assert(
error.maximumAbsoluteError <= 0.02,
`DAS-PHAT CPU/GPU normalized max error=${error.maximumAbsoluteError}`,
);
});
test("192채널 · 50×50 WebGPU map이 CPU baseline 허용 오차를 만족한다", async () => {
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
if (!adapter) {
+45 -2
View File
@@ -410,7 +410,28 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
id: "tone-1k",
color: "#65f2bf",
rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) },
peaks: [{ sourceId: "source-1", position: [0.08, 0.04, 2], value: 1, localizationErrorM: 0 }],
peaks: [{
sourceId: "source-1",
position: [0.08, 0.04, 2],
value: 1,
localizationErrorM: 0,
beamSpread: {
calibrated: false,
method: "connected-half-power-power-weighted-covariance",
halfPowerRatio: 0.5,
thresholdValue: 0.5,
pixelCount: 1,
centroid: [0.08, 0.04, 2],
covarianceM2: { xx: 0, xy: 0, yy: 0 },
ellipse: {
axisScale: "rms",
angleReference: "counterclockwise-from-positive-x",
majorRmsRadiusM: 0,
minorRmsRadiusM: 0,
angleDeg: 0,
},
},
}],
metrics: { energyInsideTruthRatio: 1, precision: 1, recall: 1 },
}],
};
@@ -419,17 +440,39 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
experiment.beamforming.algorithm = "das-phat";
result.algorithm = "das-phat";
result.bands[0].rawMap.scale = "score";
result.bands[0].peaks[0].beamSpread.method =
"connected-half-maximum-score-weighted-covariance";
assertEqual(
(await validateResult(result, { experiment, scene })).length,
0,
"phase-weighted score result issues",
);
result.algorithm = "das";
result.bands[0].rawMap.scale = "power";
let issues = await validateResult(result, { experiment, scene });
assert(
issues.some(({ path }) => path.endsWith("rawMap.scale")),
"algorithm and raw map scale must agree",
);
result.bands[0].rawMap.scale = "score";
result.bands[0].peaks[0].beamSpread.method =
"connected-half-power-power-weighted-covariance";
issues = await validateResult(result, { experiment, scene });
assert(
issues.some(({ path }) => path.endsWith("beamSpread.method")),
"score map and beam spread method must agree",
);
result.bands[0].peaks[0].beamSpread.method =
"connected-half-maximum-score-weighted-covariance";
result.algorithm = "das";
issues = await validateResult(result, { experiment, scene });
assert(issues.some(({ path }) => path === "$.algorithm"), "result algorithm must match its experiment");
experiment.beamforming.algorithm = "das";
result.bands[0].rawMap.scale = "power";
result.bands[0].peaks[0].beamSpread.method =
"connected-half-power-power-weighted-covariance";
result.bands[0].rawMap.values.pop();
result.bands[0].rawMap.scale = "normalized";
result.bands[0].peaks[0].sourceId = "missing-source";
+56 -2
View File
@@ -174,6 +174,18 @@ test("GCC-PHAT workload reports reference-pair scan reduction and rejects aliase
"maximum geometric lag is exposed and unaliased",
);
let preflightError;
try {
estimateGccPhatWorkload({ ...fixture, maximumWorkingBytes: 1 });
} catch (error) {
preflightError = error;
}
assert(
preflightError instanceof RangeError &&
preflightError.message.includes("before geometry allocation"),
"unplanned workload is rejected before allocating its distance matrix",
);
let memoryError;
try {
createGccPhatAnalysisPlan({ ...fixture, maximumPlanBytes: 1 });
@@ -241,8 +253,8 @@ test("GCC-PHAT corrected lag sign finds a deterministic broadband source and emi
assertNear(result.maximumLagSamples, result.workload.maximumLagSamples, 0, "lag metadata");
assertEqual(band.rawScale, "score", "raw map scale");
assert(
band.rawMap.every((value) => Number.isFinite(value) && value >= 0),
"shifted and squared score map is finite and nonnegative",
band.rawMap.every((value) => Number.isFinite(value) && value >= 0 && value <= 1),
"pair- and bandwidth-normalized score map is finite and bounded",
);
assert(
distance3(peakPosition, fixture.target) <= 0.15,
@@ -263,6 +275,48 @@ test("GCC-PHAT corrected lag sign finds a deterministic broadband source and emi
"X_m * conj(X_ref) correlation uses the physical arrival-lag sign",
);
assertNear(Math.max(...band.displayMap), 1, 1e-6, "normalized score display");
const expandedFixture = createBroadbandFixture();
expandedFixture.experiment.scan = {
...expandedFixture.experiment.scan,
xMin: -0.8,
xMax: 0.8,
yMin: -0.8,
yMax: 0.8,
columns: 17,
rows: 17,
};
const expanded = await computeGccPhatMap({
...expandedFixture,
phatFloorRatio: 1e-5,
pointBatchSize: 1024,
stftTransformBatchSize: 1024,
});
const expandedTargetIndex = scanIndexFor(
expandedFixture.experiment.scan,
expandedFixture.target[0],
expandedFixture.target[1],
);
assertNear(
expanded.bands[0].rawMap[expandedTargetIndex],
band.rawMap[targetIndex],
1e-6,
"raw score at a fixed hypothesis is independent of scan extent",
);
});
test("GCC-PHAT compute rejects an oversized unplanned working set before geometry", async () => {
const fixture = createBroadbandFixture();
let error;
try {
await computeGccPhatMap({ ...fixture, maximumWorkingBytes: 1 });
} catch (candidate) {
error = candidate;
}
assert(
error instanceof RangeError && error.message.includes("before geometry allocation"),
"compute preflight error",
);
});
test("GCC-PHAT analysis plan reuses geometry and FFT workspaces and releases its busy lock", async () => {
+16
View File
@@ -3,6 +3,10 @@ import {
createLocalizationAnalysisPlan,
estimateLocalizationWorkload,
} from "/packages/acoustics-core/localization.js";
import {
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
} from "/packages/acoustics-core/gcc-phat.js";
import {
DEFAULT_LOCALIZATION_ALGORITHM,
LOCALIZATION_ALGORITHM_IDS,
@@ -167,10 +171,22 @@ test("portable and streaming API accept an algorithm choice without Scene synthe
computeOptions: { pointBatchSize: 1024, stftTransformBatchSize: 1024 },
};
const plan = createLocalizationPlan(config);
const defaultBatchPlan = createLocalizationPlan({ ...config, computeOptions: undefined });
const localized = await localizeMultichannel({ plan, channels: fixture.channels });
assertEqual(plan.algorithm, "gcc-phat", "portable plan algorithm");
assertEqual(plan.computeOptions.phatFloorRatio, 1e-5, "portable PHAT floor forwarding");
assertEqual(plan.analysisPlan, plan.gccPhatPlan, "portable GCC plan alias");
assertEqual(plan.computeOptions.pointBatchSize, 1024, "explicit portable point batch override");
assertEqual(
defaultBatchPlan.computeOptions.pointBatchSize,
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
"GCC lookup-oriented default batch",
);
assertEqual(
defaultBatchPlan.computeOptions.crossSpectrumBatchSize,
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
"GCC cross-spectrum cancellation batch",
);
assertEqual(localized.algorithm, "gcc-phat", "portable result algorithm");
assertEqual(localized.phatFloorRatio, 1e-5, "portable PHAT floor reaches GCC core");
assertEqual(localized.bands[0].rawScale, "score", "portable score map");