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.
274 lines
13 KiB
JavaScript
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)}`
|
|
}
|