feat: harden end-to-end simulation workflow

This commit is contained in:
Codex
2026-08-28 12:34:11 +09:00
parent 2ce96064cf
commit 3317a03d1a
17 changed files with 577 additions and 24 deletions
+2
View File
@@ -8,6 +8,7 @@ import {
} from "/packages/contracts/validation.js";
import { createCameraAssetRuntime } from "./asset-runtime.js";
import {
alignExperimentToScene,
canonicalStringify,
createHistorySummary,
createResultDocument,
@@ -919,6 +920,7 @@ async function importDocument(kind, input) {
} else if (kind === "scene") {
state.scene = value;
state.experiment.sceneId = value.id;
alignExperimentToScene(state.experiment, value);
assetRuntime.reset();
} else {
state.experiment = value;
+71 -2
View File
@@ -1,6 +1,12 @@
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
const RESULT_METRIC_KEYS = ["peakToSidelobeDb", "energyInsideTruthRatio", "precision", "recall"];
const RESULT_METRIC_KEYS = [
"peakToSidelobeDb",
"energyInsideTruthRatio",
"precision",
"recall",
"mainLobeWidthM",
];
function sortForCanonicalJson(value) {
if (Array.isArray(value)) return value.map(sortForCanonicalJson);
@@ -60,6 +66,53 @@ export function shiftSceneDistance(scene, experiment, nextDistance) {
if (scene.image?.plane) scene.image.plane.z += delta;
}
export function alignExperimentToScene(experiment, scene) {
const plane = scene.image?.plane;
if (plane) {
Object.assign(experiment.scan, {
z: plane.z,
xMin: plane.xMin,
xMax: plane.xMax,
yMin: plane.yMin,
yMax: plane.yMax,
});
return "image-plane";
}
const positions = (scene.sources ?? [])
.map((source) => source.position)
.filter((position) =>
Array.isArray(position) &&
position.length === 3 &&
position.every(Number.isFinite),
);
if (positions.length === 0) return "unchanged";
const paddedBounds = (values) => {
const minimum = Math.min(...values);
const maximum = Math.max(...values);
const padding = Math.max(0.25, (maximum - minimum) * 0.15);
return [minimum - padding, maximum + padding];
};
const median = (values) => {
const sorted = [...values].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[middle];
};
const [xMin, xMax] = paddedBounds(positions.map((position) => position[0]));
const [yMin, yMax] = paddedBounds(positions.map((position) => position[1]));
Object.assign(experiment.scan, {
z: median(positions.map((position) => position[2])),
xMin,
xMax,
yMin,
yMax,
});
return "source-bounds";
}
function finiteMetricSubset(metrics = {}) {
return Object.fromEntries(
RESULT_METRIC_KEYS
@@ -75,6 +128,9 @@ function peakForResult(peak) {
};
if (peak.sourceId) result.sourceId = peak.sourceId;
if (Number.isFinite(peak.localizationErrorM)) result.localizationErrorM = peak.localizationErrorM;
if (Number.isFinite(peak.localizationErrorPixels)) {
result.localizationErrorPixels = peak.localizationErrorPixels;
}
return result;
}
@@ -85,6 +141,17 @@ function environmentForResult({ arrayDefinition, experiment, run }) {
microphoneCount: arrayDefinition.microphones.length,
gridPoints: experiment.scan.rows * experiment.scan.columns,
};
for (const [key, value] of Object.entries({
gridRows: experiment.scan.rows,
gridColumns: experiment.scan.columns,
scanXMin: experiment.scan.xMin,
scanXMax: experiment.scan.xMax,
scanYMin: experiment.scan.yMin,
scanYMax: experiment.scan.yMax,
scanZ: experiment.scan.z,
})) {
if (Number.isFinite(value)) environment[key] = value;
}
if (run.fallback?.code) environment.fallbackCode = run.fallback.code;
if (run.fallback?.message) environment.fallbackMessage = run.fallback.message;
const adapter = run.gpu?.adapterInfo;
@@ -127,7 +194,7 @@ export function createResultDocument(options) {
environment: environmentForResult({ arrayDefinition, experiment, run }),
bands: run.bands.map((band) => {
const evaluation = evaluations.get(band.id) ?? { peaks: [], metrics: {} };
return {
const bandResult = {
id: band.id,
color: band.color,
rawMap: {
@@ -145,6 +212,8 @@ export function createResultDocument(options) {
peaks: evaluation.peaks.map(peakForResult),
metrics: finiteMetricSubset(evaluation.metrics),
};
if (evaluation.evaluation) bandResult.evaluation = { ...evaluation.evaluation };
return bandResult;
}),
};
}
+1
View File
@@ -19,6 +19,7 @@
<button class="button button--ghost" id="preview-button" type="button">▶ 미리 듣기</button>
<button class="button button--ghost" id="wav-button" type="button" title="공간정보 없이 모든 음원을 합친 mono 확인용 WAV입니다.">Preview WAV 저장</button>
<button class="button button--primary" id="export-button" type="button">Scene JSON 저장</button>
<a class="button button--ghost" href="/apps/acoustic-camera/">Scene 저장 후 Camera 열기 →</a>
</div>
</header>
+1
View File
@@ -32,6 +32,7 @@
cursor: pointer;
font-size: 0.82rem;
font-weight: 720;
text-decoration: none;
transition: border-color 140ms ease, background 140ms ease, transform 140ms ease;
}