Files
reTraining/frontend/src/api.js
T
asus 4f15335092 feat: configurable track forward, track to end, skip-and-mark (REQ-189)
Track 5 Frames becomes Track Forward with a 1-100 count input, remembered in
localStorage (default 5). A separate -> End action runs to the last frame
behind a confirm and can be cancelled mid-flight.

Frames that already hold a shape of the tracked class are skipped instead of
re-annotated: hidden and dimmed shapes count as present. Skips are reported in
the result banner and marked with a dot plus a class name on the filmstrip
thumbnail. frames() now returns the class ids each frame holds, the same query
row, no migration.

Chaining and seed growth stay out: every frame is still seeded from the drawn
box only.
2026-10-05 13:46:24 +07:00

274 lines
13 KiB
JavaScript

/* One place that knows how to talk to the backend.
*
* FastAPI reports failures as {"detail": "..."} — unwrapped here so callers can
* show the message the backend actually wrote instead of "500". */
async function request(path, { method = 'GET', body, form, signal } = {}) {
const options = { method, headers: {}, signal }
if (form) {
options.body = form
} else if (body !== undefined) {
options.headers['Content-Type'] = 'application/json'
options.body = JSON.stringify(body)
}
const response = await fetch(`/api${path}`, options)
const text = await response.text()
let payload = null
if (text) {
try {
payload = JSON.parse(text)
} catch {
payload = null
}
}
if (!response.ok) {
const detail = payload?.detail
throw new Error(
typeof detail === 'string'
? detail
: text && text.length < 120
? text
: `${response.status} ${response.statusText}`
)
}
return payload
}
export const api = {
health: () => request('/health'),
listProjects: () => request('/projects'),
getProject: (id) => request(`/projects/${id}`),
createProject: (body) => request('/projects', { method: 'POST', body }),
patchProject: (id, body) => request(`/projects/${id}`, { method: 'PATCH', body }),
deleteProject: (id) => request(`/projects/${id}`, { method: 'DELETE' }),
addClass: (id, body) => request(`/projects/${id}/classes`, { method: 'POST', body }),
deleteClass: (id, classId) =>
request(`/projects/${id}/classes/${classId}`, { method: 'DELETE' }),
uploadBaseModel: (id, file) => {
const form = new FormData()
form.append('file', file)
return request(`/projects/${id}/base-model`, { method: 'POST', form })
},
uploadSecondaryModel: (id, file) => {
const form = new FormData()
form.append('file', file)
return request(`/projects/${id}/secondary-model`, { method: 'POST', form })
},
listDates: (id) => request(`/projects/${id}/library`),
listVideos: (id, date) => request(`/projects/${id}/library/${encodeURIComponent(date)}`),
videoInfo: (id, rel) => request(`/projects/${id}/video/info?rel=${encodeURIComponent(rel)}`),
videoUrl: (id, rel) => `/api/projects/${id}/video?rel=${encodeURIComponent(rel)}`,
createLibraryDate: (id, date) =>
request(`/projects/${id}/library/dates?date=${encodeURIComponent(date)}`, { method: 'POST' }),
uploadLibraryVideo: (id, date, file, onProgress) =>
new Promise((resolve, reject) => {
const form = new FormData()
form.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', `/api/projects/${id}/library/upload?date=${encodeURIComponent(date)}`)
xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total) }
xhr.onload = () => {
let payload = {}
try { payload = JSON.parse(xhr.responseText || '{}') } catch {}
if (xhr.status >= 200 && xhr.status < 300) resolve(payload)
else reject(new Error(payload.detail || `${xhr.status} ${xhr.statusText}`))
}
xhr.onerror = () => reject(new Error('network error'))
xhr.send(form)
}),
createBatch: (id, body) => request(`/projects/${id}/batches`, { method: 'POST', body }),
listBatches: (id) => request(`/projects/${id}/batches`),
getBatch: (id) => request(`/batches/${id}`),
patchBatch: (id, body) => request(`/batches/${id}`, { method: 'PATCH', body }),
deleteBatch: (id) => request(`/batches/${id}`, { method: 'DELETE' }),
listFrames: (id) => request(`/batches/${id}/frames`),
frameUrl: (id, width) => `/api/frames/${id}/image${width ? `?w=${width}` : ''}`,
preview: (batchId, body) => request(`/batches/${batchId}/preview`, { method: 'POST', body }),
startAutolabel: (batchId, body) =>
request(`/batches/${batchId}/autolabel`, { method: 'POST', body: body ?? {} }),
inspectModel: (file) => {
const form = new FormData()
form.append('file', file)
return request('/batches/inspect-model', { method: 'POST', form })
},
autolabelWithModel: (batchId, file, threshold, selectedClasses, iouThreshold = 0.8) => {
const form = new FormData()
form.append('file', file)
form.append('threshold', threshold)
form.append('iou_threshold', iouThreshold)
form.append('selected_classes', JSON.stringify(selectedClasses))
form.append('append', 'true')
return request(`/batches/${batchId}/autolabel-with-model`, { method: 'POST', form })
},
sam3PlaygroundTest: (file, prompts, threshold = 0.35, iouThreshold = 0.8) => {
const form = new FormData()
form.append('file', file)
form.append('prompts', prompts)
form.append('threshold', threshold)
form.append('iou_threshold', iouThreshold)
return request('/sam3/playground-test', { method: 'POST', form })
},
clearBatchClassAnnotations: (batchId, classId) =>
request(`/batches/${batchId}/classes/${classId}/annotations`, { method: 'DELETE' }),
resetBatchAutoAnnotations: (batchId) =>
request(`/batches/${batchId}/reset-auto-annotations`, { method: 'POST' }),
nextPending: (batchId, afterIdx = -1) =>
request(`/batches/${batchId}/next-pending?after_idx=${afterIdx}`),
frameAnnotations: (frameId) => request(`/frames/${frameId}/annotations`),
addAnnotation: (frameId, body) =>
request(`/frames/${frameId}/annotations`, { method: 'POST', body }),
patchAnnotation: (id, body) => request(`/annotations/${id}`, { method: 'PATCH', body }),
deleteAnnotation: (id) => request(`/annotations/${id}`, { method: 'DELETE' }),
bulkDeleteAnnotations: (annotationIds) =>
request('/annotations/bulk-delete', { method: 'POST', body: { annotation_ids: annotationIds } }),
bulkReclassAnnotations: (annotationIds, classId) =>
request('/annotations/bulk-reclass', {
method: 'POST',
body: { annotation_ids: annotationIds, class_id: classId },
}),
assist: (frameId, body, { signal } = {}) => request(`/frames/${frameId}/assist`, { method: 'POST', body, signal }),
// The whole frame-local exemplar pool, re-sent on every drag (REQ-173/174).
exemplarLabel: (frameId, body) =>
request(`/frames/${frameId}/exemplar-label`, { method: 'POST', body }),
setFrameStatus: (frameId, status) =>
request(`/frames/${frameId}/status`, { method: 'POST', body: { status } }),
approveAllBatchFrames: (batchId) => request(`/batches/${batchId}/approve-all`, { method: 'POST' }),
// `batchIds` is one id or an array — one merge, one dataset (REQ-131).
approveBatch: (batchIds, { datasetId = null, datasetName = '' } = {}) =>
request(`/batches/${[].concat(batchIds).join(',')}/approve`, {
method: 'POST',
body: { dataset_id: datasetId, dataset_name: datasetName },
}),
datasetSummary: (projectId) => request(`/projects/${projectId}/dataset`),
// Named datasets (REQ-110…113)
listDatasets: (projectId) => request(`/projects/${projectId}/datasets`),
createDataset: (projectId, body) =>
request(`/projects/${projectId}/datasets`, { method: 'POST', body }),
renameDataset: (id, body) => request(`/datasets/${id}`, { method: 'PATCH', body }),
deleteDataset: (id) => request(`/datasets/${id}`, { method: 'DELETE' }),
combinePreview: (projectId, datasetIds) =>
request(`/projects/${projectId}/datasets/combine-preview`, {
method: 'POST',
body: { dataset_ids: datasetIds },
}),
resyncDataset: (id) => request(`/datasets/${id}/resync`, { method: 'POST' }),
datasetZipUrl: (id) => `/api/datasets/${id}/download`,
// Live counting test bench
liveCountModels: (projectId) => request(`/projects/${projectId}/live-count/models`),
liveCountStart: (projectId, body) =>
request(`/projects/${projectId}/live-count/start`, { method: 'POST', body }),
liveCountStop: () => request('/live-count/stop', { method: 'POST' }),
liveCountMoveLine: (body) => request('/live-count/line', { method: 'PATCH', body }),
liveCountStatus: () => request('/live-count/status'),
// Polled far faster than status: this is what the WebRTC preview draws.
liveCountOverlay: () => request('/live-count/overlay'),
// `key` busts the browser cache so a restarted session gets a fresh connection.
liveCountStreamUrl: (key = 0) => `/api/live-count/stream?k=${key}`,
// Counting bench: scored table of archive videos (REQ-150…153)
countingBench: (projectId, date = '') =>
request(`/projects/${projectId}/counting-bench${date ? `?date=${date}` : ''}`),
setGroundTruth: (projectId, videoRel, groundTruth) =>
request(`/projects/${projectId}/counting-bench/ground-truth`, {
method: 'PATCH', body: { video_rel: videoRel, ground_truth: groundTruth },
}),
runCountingBench: (projectId, body) =>
request(`/projects/${projectId}/counting-bench/run`, { method: 'POST', body }),
archiveCycles: (projectId) => request(`/projects/${projectId}/archive/cycles`),
truckScan: (projectId, body = {}) =>
request(`/projects/${projectId}/archive/truck-scan`, { method: 'POST', body }),
archiveCycleVideos: (projectId, cycle) =>
request(`/projects/${projectId}/archive/cycles/${cycle}`),
scanClock: (projectId, rescan = false) =>
request(`/projects/${projectId}/counting-bench/scan-clock`, { method: 'POST', body: { rescan } }),
setClock: (projectId, videoRel, startedAt) =>
request(`/projects/${projectId}/counting-bench/clock`, {
method: 'PATCH', body: { video_rel: videoRel, started_at: startedAt },
}),
// Base datasets (REQ-130…123)
listBaseDatasets: (projectId) => request(`/projects/${projectId}/base-datasets`),
deleteBaseDataset: (baseId) => request(`/base-datasets/${baseId}`, { method: 'DELETE' }),
// Augmentation (REQ-110…113)
getAugment: (projectId) => request(`/projects/${projectId}/augment`),
saveAugment: (projectId, settings) =>
request(`/projects/${projectId}/augment`, { method: 'PUT', body: { settings } }),
// Triage (REQ-100…105)
triageRules: (projectId) => request(`/projects/${projectId}/triage/rules`),
saveTriageRules: (projectId, rules) =>
request(`/projects/${projectId}/triage/rules`, { method: 'PUT', body: { rules } }),
triageSummary: (batchIds) => request(`/batches/${[].concat(batchIds).join(',')}/triage/summary`),
triageShapes: (batchIds, { sort = 'score', offset = 0, limit = 120 } = {}) =>
request(`/batches/${[].concat(batchIds).join(',')}/triage/shapes`
+ `?sort=${sort}&offset=${offset}&limit=${limit}`),
triagePreview: (projectId) => request(`/projects/${projectId}/triage/preview`),
triageSuggest: (batchIds) => request(`/batches/${[].concat(batchIds).join(',')}/triage/suggest`),
triageSimulate: (batchIds, rules) =>
request(`/batches/${[].concat(batchIds).join(',')}/triage/simulate`,
{ method: 'POST', body: { rules } }),
setTriageOverrides: (annotationIds, verdict, targetClass = null) =>
request('/triage/overrides', {
method: 'POST',
body: { annotation_ids: annotationIds, verdict, target_class: targetClass },
}),
clearTriageOverrides: (annotationIds) =>
request('/triage/overrides', { method: 'DELETE', body: { annotation_ids: annotationIds } }),
cropUrl: (annotationId) => `/api/annotations/${annotationId}/crop`,
importAnnotations: (projectId, zipFile, batchLabel = '') => {
const form = new FormData()
form.append('file', zipFile)
form.append('batch_label', batchLabel)
return request(`/projects/${projectId}/import`, { method: 'POST', form })
},
exportUrl: (projectId, { batchIds = [], approvedOnly = false, includeEmpty = false } = {}) => {
const params = new URLSearchParams()
if (batchIds.length) params.set('batch_ids', batchIds.join(','))
if (approvedOnly) params.set('approved_only', 'true')
if (includeEmpty) params.set('include_empty', 'true')
const query = params.toString()
return `/api/projects/${projectId}/export${query ? `?${query}` : ''}`
},
hardware: () => request('/hardware'),
startTraining: (projectId, body) =>
request(`/projects/${projectId}/train`, { method: 'POST', body }),
listModels: (projectId) => request(`/projects/${projectId}/models`),
promoteModel: (modelId) => request(`/models/${modelId}/promote`, { method: 'POST' }),
renameModel: (modelId, name) =>
request(`/models/${modelId}/rename`, { method: 'PATCH', body: { name } }),
weightsUrl: (modelId) => `/api/models/${modelId}/weights`,
listJobs: (projectId) => request(`/jobs${projectId ? `?project_id=${projectId}` : ''}`),
getJob: (id) => request(`/jobs/${id}`),
cancelJob: (id) => request(`/jobs/${id}/cancel`, { method: 'POST' }),
}
export function classColor(classId) {
return `var(--class-${classId % 8})`
}
export function formatDuration(seconds) {
// 0 is a real timestamp — a trim starting at the first frame reads 0:00, not a dash.
if (seconds == null || Number.isNaN(seconds)) return '—'
const total = Math.round(seconds)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
const pad = (n) => String(n).padStart(2, '0')
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
}