Initial commit: scan_web integrated recording UI

FastAPI backend + vanilla JS frontend for LiDAR/camera startup, camera
settings, and rosbag recording, unifying the previous scan_gui/scan_gui_dual/
scan_gui_triple desktop tools into one web app.
This commit is contained in:
gardentech
2026-08-07 14:26:00 +09:00
commit f34817d5b6
38 changed files with 5430 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
// Per-camera exposure/gain settings tabs — mirrors the QTabWidget layout in
// scan_gui_triple.py (auto-exposure toggle, brightness/exp_min/exp_max vs.
// exp_time mutually exclusive groups, gain; Apply = live ros2 param set,
// Save = persist into the base YAML).
async function getJSON(url, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`${url}: ${res.status} ${await res.text()}`);
return res.json();
}
function field(labelText, id, type, step) {
const wrap = document.createDocumentFragment();
const label = document.createElement("label");
label.textContent = labelText;
label.htmlFor = id;
const input = document.createElement("input");
input.type = type;
input.id = id;
if (step) input.step = step;
wrap.appendChild(label);
wrap.appendChild(input);
return { wrap, input };
}
export async function buildCameraTabs(cameras) {
const tabBar = document.getElementById("camera-tabs");
const panelsRoot = document.getElementById("camera-tab-panels");
tabBar.innerHTML = "";
panelsRoot.innerHTML = "";
for (let i = 0; i < cameras.length; i++) {
const cam = cameras[i];
const tabBtn = document.createElement("button");
tabBtn.className = "tab-btn" + (i === 0 ? " active" : "");
tabBtn.textContent = cam.id;
tabBtn.onclick = () => {
document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active"));
tabBtn.classList.add("active");
document.getElementById(`cam-panel-${cam.id}`).classList.add("active");
};
tabBar.appendChild(tabBtn);
const panel = document.createElement("div");
panel.className = "tab-panel" + (i === 0 ? " active" : "");
panel.id = `cam-panel-${cam.id}`;
panelsRoot.appendChild(panel);
await buildCameraPanel(panel, cam.id);
}
}
async function buildCameraPanel(panel, camId) {
const values = await getJSON(`/api/camera/${camId}/params`);
const autoLabel = document.createElement("label");
const autoChk = document.createElement("input");
autoChk.type = "checkbox";
autoChk.checked = values.exposure_auto;
autoLabel.appendChild(autoChk);
autoLabel.appendChild(document.createTextNode(" 자동 노출"));
panel.appendChild(autoLabel);
const grid = document.createElement("div");
grid.className = "cam-field-grid";
panel.appendChild(grid);
const fBrightness = field("목표 밝기:", `${camId}-brightness`, "number");
fBrightness.input.min = 0; fBrightness.input.max = 255; fBrightness.input.value = values.exposure_auto_target_brightness;
const fExpMin = field("노출 하한 (us):", `${camId}-exp-min`, "number");
fExpMin.input.min = 10; fExpMin.input.max = 1000000; fExpMin.input.step = 100; fExpMin.input.value = values.exposure_auto_min;
const fExpMax = field("노출 상한 (us):", `${camId}-exp-max`, "number");
fExpMax.input.min = 10; fExpMax.input.max = 1000000; fExpMax.input.step = 1000; fExpMax.input.value = values.exposure_auto_max;
const fExpTime = field("노출 시간 (us):", `${camId}-exp-time`, "number");
fExpTime.input.min = 10; fExpTime.input.max = 1000000; fExpTime.input.value = values.exposure_time;
const fGain = field("게인 (dB):", `${camId}-gain`, "number");
fGain.input.min = 0; fGain.input.max = 16.9; fGain.input.step = 0.5; fGain.input.value = values.gain;
for (const f of [fBrightness, fExpMin, fExpMax, fExpTime, fGain]) grid.appendChild(f.wrap);
const autoWidgets = [fBrightness.input, fExpMin.input, fExpMax.input];
const manualWidgets = [fExpTime.input];
function applyToggleState(checked) {
autoWidgets.forEach((w) => (w.disabled = !checked));
manualWidgets.forEach((w) => (w.disabled = checked));
}
autoChk.onchange = () => applyToggleState(autoChk.checked);
applyToggleState(values.exposure_auto);
const btnRow = document.createElement("div");
btnRow.className = "btn-row";
const btnApply = document.createElement("button");
btnApply.className = "btn btn-blue";
btnApply.textContent = "적용";
const btnSave = document.createElement("button");
btnSave.className = "btn btn-green";
btnSave.textContent = "저장";
btnRow.appendChild(btnApply);
btnRow.appendChild(btnSave);
panel.appendChild(btnRow);
const result = document.createElement("div");
result.className = "cam-result";
panel.appendChild(result);
function collect() {
return {
exposure_auto: autoChk.checked,
exposure_auto_target_brightness: parseInt(fBrightness.input.value, 10),
exposure_auto_min: parseFloat(fExpMin.input.value),
exposure_auto_max: parseFloat(fExpMax.input.value),
exposure_time: parseInt(fExpTime.input.value, 10),
gain: parseFloat(fGain.input.value),
};
}
btnApply.onclick = async () => {
result.textContent = "적용 중…";
try {
await getJSON(`/api/camera/${camId}/params`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(collect()),
});
const r = await getJSON(`/api/camera/${camId}/params/apply`, { method: "POST" });
result.textContent = r.ok ? "적용 완료" : r.results.filter(x => !x.ok).map(x => `${x.name}: ${x.message}`).join(" / ");
result.style.color = r.ok ? "var(--color-success)" : "var(--color-danger)";
} catch (e) {
result.textContent = String(e);
result.style.color = "var(--color-danger)";
}
};
btnSave.onclick = async () => {
result.textContent = "저장 중…";
try {
await getJSON(`/api/camera/${camId}/params`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(collect()),
});
const r = await getJSON(`/api/camera/${camId}/params/save`, { method: "POST" });
result.textContent = `저장됨 → ${r.path}`;
result.style.color = "var(--color-success)";
} catch (e) {
result.textContent = String(e);
result.style.color = "var(--color-danger)";
}
};
}