Сохранять каждый запуск датасета в отдельную папку и показывать прогресс генерации.

Файлы пишутся в sonar_dataset/дата-время-модель без удаления прошлых запусков; кнопка «Генерация…» заполняется по мере записи сцен через NDJSON-стрим.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 15:30:07 +03:00
co-authored by Cursor
parent 643d834ba7
commit 8b248e1d54
5 changed files with 258 additions and 42 deletions
+67 -2
View File
@@ -289,7 +289,7 @@ export const api = {
});
triggerDownload(blob, safeName || suggested);
},
datasetGenerate: ({
datasetGenerate: async ({
count = 5,
seed = 42,
outputDir = "sonar_dataset",
@@ -298,6 +298,7 @@ export const api = {
beamCount = 45,
lengthCount = 45,
modelFile,
onProgress,
} = {}) => {
if (!modelFile) {
return Promise.reject(new Error("Выберите файл модели .obj"));
@@ -311,10 +312,74 @@ export const api = {
formData.append("beamCount", String(beamCount ?? 45));
formData.append("lengthCount", String(lengthCount ?? 45));
formData.append("model", modelFile, modelFile.name || "model.obj");
return request("/api/dataset/generate", {
const response = await fetch("/api/dataset/generate", {
method: "POST",
body: formData,
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(formatApiError(payload.detail, `Request failed: /api/dataset/generate`));
}
if (!response.body) {
throw new Error("Пустой ответ генерации датасета");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let result = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let event;
try {
event = JSON.parse(trimmed);
} catch {
continue;
}
if (event.type === "error") {
throw new Error(event.detail || "Ошибка генерации датасета");
}
if (event.type === "start" || event.type === "progress") {
if (typeof onProgress === "function") onProgress(event);
}
if (event.type === "done") {
result = event.result;
}
}
}
if (buffer.trim()) {
try {
const event = JSON.parse(buffer.trim());
if (event.type === "error") {
throw new Error(event.detail || "Ошибка генерации датасета");
}
if (event.type === "done") result = event.result;
if ((event.type === "start" || event.type === "progress") && typeof onProgress === "function") {
onProgress(event);
}
} catch (error) {
if (error instanceof SyntaxError) {
/* ignore trailing junk */
} else {
throw error;
}
}
}
if (!result) {
throw new Error("Генерация завершилась без результата");
}
return result;
},
datasetPreview: ({ stem, outputDir = "sonar_dataset", maxPoints = 25000 } = {}) =>
request("/api/dataset/preview", {
+32 -8
View File
@@ -16,6 +16,7 @@ export const useDatasetStore = defineStore("dataset", {
objectScaleIsMax: false,
beamCount: 45,
lengthCount: 45,
generateProgress: 0,
lastResult: null,
selectedStem: null,
previewPoints: [],
@@ -116,6 +117,7 @@ export const useDatasetStore = defineStore("dataset", {
return;
}
this.busy = true;
this.generateProgress = 0;
this.statusText = "Генерация датасета…";
this.pushLog(
`Старт: count=${this.count}, seed=${this.seed}, beams=${this.beamCount}, length=${this.lengthCount}, scale=${this.objectScale}${this.objectScaleIsMax ? " (макс.)" : ""}, dir=${this.outputDir}, model=${this.modelFileName}`,
@@ -130,7 +132,35 @@ export const useDatasetStore = defineStore("dataset", {
beamCount: Number(this.beamCount) || 45,
lengthCount: Number(this.lengthCount) || 45,
modelFile: this.modelFile,
onProgress: (event) => {
if (event.type === "start") {
this.generateProgress = 0;
if (event.outputDir) {
this.resolvedOutputDir = event.outputDir;
this.pushLog(`Каталог запуска: ${event.outputDir}`);
}
this.statusText = `Генерация… 0/${event.total || "?"}`;
return;
}
if (event.type === "progress") {
const total = Number(event.total) || 1;
const current = Number(event.current) || 0;
this.generateProgress = Math.min(100, Math.round((100 * current) / total));
this.statusText = `Генерация… ${current}/${total}`;
const entry = event.entry;
if (entry?.stem) {
const scalePart =
entry.objectScale != null
? `, scale=${Number(entry.objectScale).toFixed(3)}`
: "";
this.pushLog(
`${entry.stem}: pts=${entry.pointCount}, object=${entry.objectPointCount}, ${entry.visibility}${scalePart}`,
);
}
}
},
});
this.generateProgress = 100;
this.lastResult = result;
this.resolvedOutputDir = result?.outputDir || null;
const s = result?.stats || {};
@@ -142,18 +172,11 @@ export const useDatasetStore = defineStore("dataset", {
`Модель: ${result.objectName || this.modelFileName} (${result.objectVertexCount || "?"} вершин), ${scaleNote}, ширина=${result.beamCount ?? this.beamCount}, длина=${result.lengthCount ?? this.lengthCount}. class 1 = object.`,
);
this.pushLog(
`Записано ${result.count} сцен. С объектом: ${s.withObject}, без: ${s.withoutObject}.`,
`Записано ${result.count} сцен в ${result.runName || result.outputDir}. С объектом: ${s.withObject}, без: ${s.withoutObject}.`,
);
this.pushLog(
`Видимость: nearly_hidden=${s.nearly_hidden || 0}, partial=${s.partial || 0}, visible=${s.visible || 0}, absent=${s.absent || 0}.`,
);
for (const item of result.written || []) {
const scalePart =
item.objectScale != null ? `, scale=${Number(item.objectScale).toFixed(3)}` : "";
this.pushLog(
`${item.stem}: pts=${item.pointCount}, object=${item.objectPointCount}, ${item.visibility}${scalePart}`,
);
}
const initialStem =
result?.preview?.stem ||
@@ -190,6 +213,7 @@ export const useDatasetStore = defineStore("dataset", {
throw error;
} finally {
this.busy = false;
this.generateProgress = 0;
}
},
async selectScene(stem) {
+40 -2
View File
@@ -193,11 +193,18 @@ function onHighlightClassChange(event) {
<button
type="button"
class="primary"
class="primary generate-btn"
:class="{ busy: store.busy }"
:disabled="!store.canGenerate"
@click="onGenerate"
>
{{ store.busy ? "Генерация…" : "Сгенерировать" }}
<span
class="generate-btn-fill"
:style="{ width: `${store.busy ? store.generateProgress : 0}%` }"
/>
<span class="generate-btn-label">
{{ store.busy ? "Генерация…" : "Сгенерировать" }}
</span>
</button>
<p class="status">{{ store.statusText }}</p>
@@ -400,10 +407,41 @@ function onHighlightClassChange(event) {
font-weight: 600;
cursor: pointer;
}
.generate-btn {
position: relative;
overflow: hidden;
isolation: isolate;
}
.generate-btn-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0;
background: color-mix(in srgb, var(--primary-button-text, #142033) 22%, transparent);
transition: width 0.18s ease;
z-index: 0;
pointer-events: none;
}
.generate-btn.busy .generate-btn-fill {
background: linear-gradient(
90deg,
color-mix(in srgb, #fff 35%, var(--primary-button-bg, #ffb020)),
color-mix(in srgb, #fff 12%, var(--primary-button-bg, #ffb020))
);
}
.generate-btn-label {
position: relative;
z-index: 1;
}
.primary:disabled {
opacity: 0.6;
cursor: wait;
}
.generate-btn.busy:disabled {
opacity: 1;
cursor: wait;
}
.status {
margin: 0;
font-size: 12px;