update from asus 106
This commit is contained in:
1 parent
6637fb1302
commit
8285400254
28 files changed
+3215
-459
No files matched your search
@@ -6,6 +6,7 @@ import LibraryPage from './pages/LibraryPage'
|
||||
import TrimPage from './pages/TrimPage'
|
||||
import ReviewPage from './pages/ReviewPage'
|
||||
import ModelsPage from './pages/ModelsPage'
|
||||
import Sam3PlaygroundPage from './pages/Sam3PlaygroundPage'
|
||||
import './roboflow.css'
|
||||
|
||||
function parseRoute(hash) {
|
||||
@@ -14,6 +15,10 @@ function parseRoute(hash) {
|
||||
const query = new URLSearchParams(queryString || '')
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
|
||||
if (parts[0] === 'sam3-playground') {
|
||||
return { name: 'sam3-playground' }
|
||||
}
|
||||
|
||||
if (parts[0] === 'batches' && parts[1]) {
|
||||
return { name: 'review', batchId: Number(parts[1]) }
|
||||
}
|
||||
@@ -135,6 +140,7 @@ export default function App() {
|
||||
onProject={(p) => setCurrentProject(p)}
|
||||
/>
|
||||
)}
|
||||
{route.name === 'sam3-playground' && <Sam3PlaygroundPage />}
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -60,8 +60,32 @@ export const api = {
|
||||
|
||||
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}`),
|
||||
|
||||
|
||||
@@ -74,11 +74,15 @@ export default function Sidebar({ route, currentProject, theme, onToggleTheme })
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
{!collapsed && <div className="sidebar-section-title">MODELS</div>}
|
||||
{!collapsed && <div className="sidebar-section-title">MODELS & TOOLS</div>}
|
||||
<a href={`#/projects/${pId}/models`} onClick={(e) => handleNav(e, `/projects/${pId}/models`)} className={`sidebar-item ${route.name === 'models' ? 'active' : ''}`} title="Train & Select Engine">
|
||||
<span className="sidebar-icon"><RocketIcon size={16} /></span>
|
||||
{!collapsed && <span>Train & Select Engine</span>}
|
||||
</a>
|
||||
<a href="#/sam3-playground" onClick={(e) => handleNav(e, '/sam3-playground')} className={`sidebar-item ${route.name === 'sam3-playground' ? 'active' : ''}`} title="SAM3 Playground">
|
||||
<span className="sidebar-icon">🤖</span>
|
||||
{!collapsed && <span>SAM3 Playground</span>}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-spacer" />
|
||||
|
||||
+493
-273
@@ -43,40 +43,27 @@ function ActiveJobsBanner({ jobs, onCancel }) {
|
||||
|
||||
function BatchList({ project, batches, activeJobs, onChanged, onError }) {
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
const [selectedEngine, setSelectedEngine] = useState({})
|
||||
const [engineClassMap, setEngineClassMap] = useState({})
|
||||
const [expandedFilterBatchId, setExpandedFilterBatchId] = useState(null)
|
||||
const [appendChoiceBatch, setAppendChoiceBatch] = useState(null)
|
||||
const [appendModalState, setAppendModalState] = useState(null)
|
||||
const [sam3AppendState, setSam3AppendState] = useState(null)
|
||||
const [customPromptInput, setCustomPromptInput] = useState('')
|
||||
const [baseModelModalState, setBaseModelModalState] = useState(null)
|
||||
|
||||
const hasSecondaryModel = Boolean(project?.secondary_model_path)
|
||||
const model1Classes = project?.classes?.map((c) => c.name) || []
|
||||
const model2Classes = project?.secondary_model_classes?.length > 0
|
||||
? project.secondary_model_classes
|
||||
: model1Classes
|
||||
function openBaseModelAutolabelModal(batch) {
|
||||
const projectClasses = project?.classes?.map((c) => c.name) || []
|
||||
setBaseModelModalState({
|
||||
batch,
|
||||
selectedClasses: [...projectClasses],
|
||||
threshold: 0.35,
|
||||
iouThreshold: 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
const defaultEngines = project?.base_model_path ? ['base_model'] : ['sam3']
|
||||
const [selectedThreshold, setSelectedThreshold] = useState({})
|
||||
|
||||
async function autolabel(batch, resume = false) {
|
||||
async function resetAutoAnnotations(batch) {
|
||||
if (!window.confirm(`Reset auto-annotations for batch "${batch.batch_label}"?\n\nThis will clear all auto-generated shapes and reset frame review statuses back to pending.`)) return
|
||||
setBusyId(batch.id)
|
||||
const engines = selectedEngine[batch.id] || defaultEngines
|
||||
const threshold = selectedThreshold[batch.id] ?? 0.35
|
||||
|
||||
// Default class filter for each active engine if not customized
|
||||
const currentBatchMap = engineClassMap[batch.id] || {}
|
||||
const engine_classes = {}
|
||||
|
||||
if (engines.includes('sam3')) {
|
||||
engine_classes.sam3 = currentBatchMap.sam3 || model1Classes
|
||||
}
|
||||
if (engines.includes('base_model')) {
|
||||
engine_classes.base_model = currentBatchMap.base_model || model1Classes
|
||||
}
|
||||
if (engines.includes('secondary_model')) {
|
||||
engine_classes.secondary_model = currentBatchMap.secondary_model || model2Classes
|
||||
}
|
||||
|
||||
try {
|
||||
await api.startAutolabel(batch.id, { resume, engines, engine_classes, threshold })
|
||||
await api.resetBatchAutoAnnotations(batch.id)
|
||||
onChanged()
|
||||
} catch (exc) {
|
||||
onError(exc.message)
|
||||
@@ -85,35 +72,45 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEngine = (batchId, engineKey) => {
|
||||
const current = selectedEngine[batchId] || defaultEngines
|
||||
let next
|
||||
if (current.includes(engineKey)) {
|
||||
if (current.length === 1) return // keep at least 1 engine selected
|
||||
next = current.filter((e) => e !== engineKey)
|
||||
} else {
|
||||
next = [...current, engineKey]
|
||||
function openFilePickerForYolo(batch) {
|
||||
setAppendChoiceBatch(null)
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.pt'
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setBusyId(batch.id)
|
||||
try {
|
||||
const info = await api.inspectModel(file)
|
||||
setAppendModalState({
|
||||
batch,
|
||||
file,
|
||||
filename: info.filename,
|
||||
classes: info.classes || [],
|
||||
selectedClasses: info.classes || [],
|
||||
threshold: 0.35,
|
||||
iouThreshold: 0.8,
|
||||
})
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
setSelectedEngine({ ...selectedEngine, [batchId]: next })
|
||||
input.click()
|
||||
}
|
||||
|
||||
const toggleEngineClass = (batchId, engineKey, className, defaultClasses) => {
|
||||
const batchFilters = engineClassMap[batchId] || {}
|
||||
const currentEngClasses = batchFilters[engineKey] || defaultClasses
|
||||
let next
|
||||
if (currentEngClasses.includes(className)) {
|
||||
if (currentEngClasses.length === 1) return // keep at least 1 class
|
||||
next = currentEngClasses.filter((c) => c !== className)
|
||||
} else {
|
||||
next = [...currentEngClasses, className]
|
||||
}
|
||||
setEngineClassMap({
|
||||
...engineClassMap,
|
||||
[batchId]: {
|
||||
...batchFilters,
|
||||
[engineKey]: next,
|
||||
},
|
||||
function openSam3AppendModal(batch) {
|
||||
setAppendChoiceBatch(null)
|
||||
const projectClasses = project?.classes?.map((c) => c.name) || []
|
||||
setSam3AppendState({
|
||||
batch,
|
||||
selectedClasses: [...projectClasses],
|
||||
threshold: 0.35,
|
||||
iouThreshold: 0.8,
|
||||
})
|
||||
setCustomPromptInput('')
|
||||
}
|
||||
|
||||
async function deleteBatch(batch) {
|
||||
@@ -151,20 +148,13 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Batch</th><th>Range</th><th>Frames</th><th>Reviewed</th>
|
||||
<th>Shapes</th><th>Model & Class Configuration</th><th>Status</th><th />
|
||||
<th>Shapes</th><th>Status</th><th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map((batch) => {
|
||||
const batchJob = activeJobs?.find((j) => j.batch_id === batch.id)
|
||||
const isProcessing = Boolean(batchJob || busyId === batch.id)
|
||||
const activeEngines = selectedEngine[batch.id] || defaultEngines
|
||||
const isFilterExpanded = expandedFilterBatchId === batch.id
|
||||
const batchMap = engineClassMap[batch.id] || {}
|
||||
|
||||
const sam3Active = batchMap.sam3 || model1Classes
|
||||
const model1Active = batchMap.base_model || model1Classes
|
||||
const model2Active = batchMap.secondary_model || model2Classes
|
||||
|
||||
return (
|
||||
<React.Fragment key={batch.id}>
|
||||
@@ -180,96 +170,6 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
|
||||
<td className="num">{batch.frame_count}</td>
|
||||
<td className="num">{batch.reviewed}/{batch.frame_count}</td>
|
||||
<td className="num">{batch.annotation_count}</td>
|
||||
<td style={{ minWidth: 260 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${activeEngines.includes('sam3') ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ padding: '2px 6px', fontSize: '0.72rem', cursor: 'pointer', opacity: activeEngines.includes('sam3') ? 1 : 0.6 }}
|
||||
onClick={() => toggleEngine(batch.id, 'sam3')}
|
||||
disabled={isProcessing}
|
||||
title="SAM3 Zero-shot Text Prompt Engine"
|
||||
>
|
||||
🤖 SAM3
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${activeEngines.includes('base_model') ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ padding: '2px 6px', fontSize: '0.72rem', cursor: 'pointer', opacity: activeEngines.includes('base_model') ? 1 : 0.6 }}
|
||||
onClick={() => toggleEngine(batch.id, 'base_model')}
|
||||
disabled={isProcessing}
|
||||
title="Primary Model 1 (Base / Trained)"
|
||||
>
|
||||
⚡ Model 1
|
||||
</button>
|
||||
{hasSecondaryModel && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${activeEngines.includes('secondary_model') ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ padding: '2px 6px', fontSize: '0.72rem', cursor: 'pointer', opacity: activeEngines.includes('secondary_model') ? 1 : 0.6 }}
|
||||
onClick={() => toggleEngine(batch.id, 'secondary_model')}
|
||||
disabled={isProcessing}
|
||||
title={project?.secondary_model_name || "Secondary Uploaded Model 2"}
|
||||
>
|
||||
🎯 Model 2
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '0.72rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
borderColor: isFilterExpanded ? '#c084fc' : 'rgba(255,255,255,0.2)',
|
||||
color: isFilterExpanded ? '#c084fc' : '#e4e4e7',
|
||||
}}
|
||||
onClick={() => setExpandedFilterBatchId(isFilterExpanded ? null : batch.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
⚙️ {isFilterExpanded ? 'Hide' : 'Per-Engine'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center', fontSize: '0.72rem' }}>
|
||||
<span className="muted" style={{ fontSize: '0.7rem' }}>Classes:</span>
|
||||
{model1Classes.map((clsName) => {
|
||||
const isModel1On = activeEngines.includes('base_model') && model1Active.includes(clsName)
|
||||
const isSam3On = activeEngines.includes('sam3') && sam3Active.includes(clsName)
|
||||
const isSecOn = activeEngines.includes('secondary_model') && hasSecondaryModel && model2Active.includes(clsName)
|
||||
const isActiveAny = isModel1On || isSam3On || isSecOn
|
||||
|
||||
return (
|
||||
<button
|
||||
key={clsName}
|
||||
type="button"
|
||||
className="tag"
|
||||
style={{
|
||||
padding: '1px 5px',
|
||||
fontSize: '0.7rem',
|
||||
cursor: 'pointer',
|
||||
background: isActiveAny ? 'rgba(56, 189, 248, 0.25)' : 'rgba(255,255,255,0.05)',
|
||||
color: isActiveAny ? '#38bdf8' : '#71717a',
|
||||
border: isActiveAny ? '1px solid rgba(56, 189, 248, 0.5)' : '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
title={`Toggle ${clsName} filter`}
|
||||
disabled={isProcessing}
|
||||
onClick={() => {
|
||||
if (activeEngines.includes('base_model')) toggleEngineClass(batch.id, 'base_model', clsName, model1Classes)
|
||||
if (activeEngines.includes('sam3')) toggleEngineClass(batch.id, 'sam3', clsName, model1Classes)
|
||||
if (activeEngines.includes('secondary_model')) toggleEngineClass(batch.id, 'secondary_model', clsName, model2Classes)
|
||||
}}
|
||||
>
|
||||
{isActiveAny ? '✓ ' : ''}{clsName}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`tag ${batchJob ? 'info' : ''}`}>
|
||||
{batchJob ? `${batchJob.type} (${batchJob.status})` : batch.status}
|
||||
@@ -278,16 +178,20 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
|
||||
<td>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<button className="btn btn-primary" disabled={isProcessing || batch.frame_count === 0}
|
||||
onClick={() => autolabel(batch)}>
|
||||
{batchJob?.type === 'autolabel' ? 'Processing…' : `Auto-annotate (${activeEngines.map(e => e === 'sam3' ? 'SAM3' : e === 'base_model' ? 'Model 1' : 'Model 2').join('+')})`}
|
||||
title="Annotate using project base model with target class selection"
|
||||
onClick={() => openBaseModelAutolabelModal(batch)}>
|
||||
{batchJob?.type === 'autolabel' ? 'Processing…' : 'Auto-annotate'}
|
||||
</button>
|
||||
<button className="btn" disabled={isProcessing || batch.frame_count === 0}
|
||||
title="Append new class detections using custom YOLO model or SAM3 text prompts"
|
||||
onClick={() => setAppendChoiceBatch(batch)}>
|
||||
+ Append
|
||||
</button>
|
||||
<button className="btn" disabled={isProcessing || batch.annotation_count === 0}
|
||||
title="Clear all auto-generated shapes and reset frame review statuses to pending"
|
||||
onClick={() => resetAutoAnnotations(batch)}>
|
||||
🔄 Reset Auto
|
||||
</button>
|
||||
{batch.annotation_count > 0 && (
|
||||
<button className="btn" disabled={isProcessing || batch.frame_count === 0}
|
||||
title="Skip frames that already have automatic shapes"
|
||||
onClick={() => autolabel(batch, true)}>
|
||||
Resume
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" disabled={batch.frame_count === 0}
|
||||
onClick={() => navigate(`/projects/${project.id}/review?batch=${batch.id}`)}>
|
||||
Review ({batch.reviewed}/{batch.frame_count})
|
||||
@@ -299,122 +203,438 @@ function BatchList({ project, batches, activeJobs, onChanged, onError }) {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Inline Per-Engine Class Filter Panel */}
|
||||
{isFilterExpanded && (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ background: '#09090b', padding: '12px 16px', borderBottom: '1px solid rgba(168,85,247,0.3)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ fontSize: '0.82rem', fontWeight: 600, color: '#c084fc' }}>
|
||||
🛠️ Inline Per-Engine Class Filters (Select target classes per detector):
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
|
||||
{/* SAM3 Section */}
|
||||
{activeEngines.includes('sam3') && (
|
||||
<div style={{ background: 'rgba(24, 24, 27, 0.8)', padding: 10, borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: '#a855f7', marginBottom: 6 }}>
|
||||
🤖 SAM3 Text Prompts
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{model1Classes.map((clsName) => {
|
||||
const isActive = sam3Active.includes(clsName)
|
||||
return (
|
||||
<button
|
||||
key={clsName}
|
||||
type="button"
|
||||
className="tag"
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? 'rgba(168, 85, 247, 0.3)' : 'rgba(255,255,255,0.05)',
|
||||
color: isActive ? '#f3e8ff' : '#666',
|
||||
border: isActive ? '1px solid rgba(168, 85, 247, 0.6)' : '1px solid transparent',
|
||||
}}
|
||||
onClick={() => toggleEngineClass(batch.id, 'sam3', clsName, model1Classes)}
|
||||
>
|
||||
{isActive ? '✓ ' : ''}{clsName}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model 1 Section */}
|
||||
{activeEngines.includes('base_model') && (
|
||||
<div style={{ background: 'rgba(24, 24, 27, 0.8)', padding: 10, borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: '#38bdf8', marginBottom: 6 }}>
|
||||
⚡ Model 1 (Primary Base) Classes
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{model1Classes.map((clsName) => {
|
||||
const isActive = model1Active.includes(clsName)
|
||||
return (
|
||||
<button
|
||||
key={clsName}
|
||||
type="button"
|
||||
className="tag"
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? 'rgba(56, 189, 248, 0.3)' : 'rgba(255,255,255,0.05)',
|
||||
color: isActive ? '#e0f2fe' : '#666',
|
||||
border: isActive ? '1px solid rgba(56, 189, 248, 0.6)' : '1px solid transparent',
|
||||
}}
|
||||
onClick={() => toggleEngineClass(batch.id, 'base_model', clsName, model1Classes)}
|
||||
>
|
||||
{isActive ? '✓ ' : ''}{clsName}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model 2 Section */}
|
||||
{activeEngines.includes('secondary_model') && hasSecondaryModel && (
|
||||
<div style={{ background: 'rgba(24, 24, 27, 0.8)', padding: 10, borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: '#f43f5e', marginBottom: 6 }}>
|
||||
🎯 Model 2 (Secondary Engine) Native Classes
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{model2Classes.map((clsName) => {
|
||||
const isActive = model2Active.includes(clsName)
|
||||
return (
|
||||
<button
|
||||
key={clsName}
|
||||
type="button"
|
||||
className="tag"
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? 'rgba(244, 63, 94, 0.3)' : 'rgba(255,255,255,0.05)',
|
||||
color: isActive ? '#ffe4e6' : '#666',
|
||||
border: isActive ? '1px solid rgba(244, 63, 94, 0.6)' : '1px solid transparent',
|
||||
}}
|
||||
onClick={() => toggleEngineClass(batch.id, 'secondary_model', clsName, model2Classes)}
|
||||
>
|
||||
{isActive ? '✓ ' : ''}{clsName}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Choice Modal: SAM3 vs Custom YOLO */}
|
||||
{appendChoiceBatch && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 9999
|
||||
}}>
|
||||
<div className="panel" style={{ width: 480, maxWidth: '92vw', padding: 22, border: '1px solid rgba(168, 85, 247, 0.4)', background: '#18181b' }}>
|
||||
<h3 style={{ margin: '0 0 6px 0', color: '#c084fc', fontSize: '1.05rem' }}>Select Engine to Append Annotations</h3>
|
||||
<p className="hint" style={{ fontSize: '0.82rem', marginBottom: 18 }}>
|
||||
Choose how you want to detect and append new classes to batch <strong>{appendChoiceBatch.batch_label}</strong>:
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 12, marginBottom: 20 }}>
|
||||
{/* SAM3 Card */}
|
||||
<div
|
||||
style={{
|
||||
padding: 14, background: 'rgba(24,24,27,0.9)', borderRadius: 8,
|
||||
border: '1px solid rgba(168, 85, 247, 0.4)', cursor: 'pointer',
|
||||
transition: 'all 150ms ease'
|
||||
}}
|
||||
onClick={() => openSam3AppendModal(appendChoiceBatch)}
|
||||
>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<h4 style={{ margin: 0, color: '#c084fc', fontSize: '0.92rem' }}>🤖 SAM3 Zero-Shot (Text Prompts)</h4>
|
||||
<span className="btn btn-ghost" style={{ padding: '2px 8px', fontSize: '0.75rem' }}>Select ></span>
|
||||
</div>
|
||||
<p className="hint" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||
Detect objects by typing any text prompt (e.g. "box", "sack", "person") without needing a pre-trained model.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* YOLO Card */}
|
||||
<div
|
||||
style={{
|
||||
padding: 14, background: 'rgba(24,24,27,0.9)', borderRadius: 8,
|
||||
border: '1px solid rgba(56, 189, 248, 0.4)', cursor: 'pointer',
|
||||
transition: 'all 150ms ease'
|
||||
}}
|
||||
onClick={() => openFilePickerForYolo(appendChoiceBatch)}
|
||||
>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<h4 style={{ margin: 0, color: '#38bdf8', fontSize: '0.92rem' }}>⚡ Custom YOLO Model (.pt)</h4>
|
||||
<span className="btn btn-ghost" style={{ padding: '2px 8px', fontSize: '0.75rem' }}>Upload ></span>
|
||||
</div>
|
||||
<p className="hint" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||
Upload a custom trained `.pt` model file from your computer and select which classes to append.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-ghost" onClick={() => setAppendChoiceBatch(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SAM3 Append Modal */}
|
||||
{sam3AppendState && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 9999
|
||||
}}>
|
||||
<div className="panel" style={{ width: 500, maxWidth: '92vw', padding: 22, border: '1px solid rgba(168, 85, 247, 0.4)', background: '#18181b' }}>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#c084fc', fontSize: '1.05rem' }}>Append Annotations with SAM3</h3>
|
||||
<p className="hint" style={{ fontSize: '0.82rem', marginBottom: 16 }}>
|
||||
Select target text prompts to detect with SAM3:
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<span className="hint" style={{ fontSize: '0.82rem' }}>Confidence Threshold:</span>
|
||||
<strong style={{ color: '#c084fc', fontSize: '0.85rem' }}>{sam3AppendState.threshold}</strong>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="0.95"
|
||||
step="0.05"
|
||||
value={sam3AppendState.threshold}
|
||||
onChange={(e) => setSam3AppendState({ ...sam3AppendState, threshold: parseFloat(e.target.value) })}
|
||||
style={{ width: '100%', cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<span className="hint" style={{ fontSize: '0.82rem' }}>NMS IoU Threshold:</span>
|
||||
<strong style={{ color: '#38bdf8', fontSize: '0.85rem' }}>{sam3AppendState.iouThreshold ?? 0.8}</strong>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="0.9"
|
||||
step="0.05"
|
||||
value={sam3AppendState.iouThreshold ?? 0.8}
|
||||
onChange={(e) => setSam3AppendState({ ...sam3AppendState, iouThreshold: parseFloat(e.target.value) })}
|
||||
style={{ width: '100%', cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span className="hint" style={{ fontSize: '0.82rem', display: 'block', marginBottom: 6 }}>Target Prompts to Detect:</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 120, overflowY: 'auto', padding: 10, background: 'rgba(0,0,0,0.4)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)', marginBottom: 10 }}>
|
||||
{sam3AppendState.selectedClasses.map((clsName) => (
|
||||
<button
|
||||
key={clsName}
|
||||
type="button"
|
||||
className="tag"
|
||||
style={{
|
||||
cursor: 'pointer', padding: '4px 9px', fontSize: '0.8rem',
|
||||
background: 'rgba(168, 85, 247, 0.25)', color: '#f3e8ff',
|
||||
border: '1px solid rgba(168, 85, 247, 0.6)'
|
||||
}}
|
||||
onClick={() => {
|
||||
const next = sam3AppendState.selectedClasses.filter((c) => c !== clsName)
|
||||
setSam3AppendState({ ...sam3AppendState, selectedClasses: next })
|
||||
}}
|
||||
>
|
||||
✕ {clsName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Custom SAM3 Prompt */}
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type new SAM3 text prompt (e.g. box)..."
|
||||
value={customPromptInput}
|
||||
onChange={(e) => setCustomPromptInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && customPromptInput.trim()) {
|
||||
const val = customPromptInput.trim().toLowerCase()
|
||||
if (!sam3AppendState.selectedClasses.includes(val)) {
|
||||
setSam3AppendState({
|
||||
...sam3AppendState,
|
||||
selectedClasses: [...sam3AppendState.selectedClasses, val]
|
||||
})
|
||||
}
|
||||
setCustomPromptInput('')
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1, padding: '6px 10px', fontSize: '0.82rem', background: '#09090b', border: '1px solid rgba(255,255,255,0.15)', borderRadius: 4, color: '#fff' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ fontSize: '0.8rem' }}
|
||||
onClick={() => {
|
||||
if (customPromptInput.trim()) {
|
||||
const val = customPromptInput.trim().toLowerCase()
|
||||
if (!sam3AppendState.selectedClasses.includes(val)) {
|
||||
setSam3AppendState({
|
||||
...sam3AppendState,
|
||||
selectedClasses: [...sam3AppendState.selectedClasses, val]
|
||||
})
|
||||
}
|
||||
setCustomPromptInput('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ Add Prompt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ justifyContent: 'flex-end', gap: 10 }}>
|
||||
<button className="btn btn-ghost" onClick={() => setSam3AppendState(null)}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={sam3AppendState.selectedClasses.length === 0}
|
||||
onClick={async () => {
|
||||
const { batch, threshold, iouThreshold, selectedClasses } = sam3AppendState
|
||||
setSam3AppendState(null)
|
||||
setBusyId(batch.id)
|
||||
try {
|
||||
await api.startAutolabel(batch.id, {
|
||||
resume: false,
|
||||
append: true,
|
||||
engine: 'sam3',
|
||||
threshold,
|
||||
iou_threshold: iouThreshold ?? 0.8,
|
||||
engine_classes: { sam3: selectedClasses },
|
||||
class_ids: null
|
||||
})
|
||||
onChanged()
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Start SAM3 Append
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* YOLO Custom Model Append Modal */}
|
||||
{appendModalState && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 9999
|
||||
}}>
|
||||
<div className="panel" style={{ width: 500, maxWidth: '92vw', padding: 22, border: '1px solid rgba(56, 189, 248, 0.4)', background: '#18181b' }}>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#38bdf8', fontSize: '1.05rem' }}>Append Annotations with Custom Model</h3>
|
||||
<p className="hint" style={{ fontSize: '0.82rem', marginBottom: 16 }}>
|
||||
Model file: <strong style={{ color: '#fff' }}>{appendModalState.filename}</strong>
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<span className="hint" style={{ fontSize: '0.82rem' }}>Confidence Threshold:</span>
|
||||
<strong style={{ color: '#38bdf8', fontSize: '0.85rem' }}>{appendModalState.threshold}</strong>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="0.95"
|
||||
step="0.05"
|
||||
value={appendModalState.threshold}
|
||||
onChange={(e) => setAppendModalState({ ...appendModalState, threshold: parseFloat(e.target.value) })}
|
||||
style={{ width: '100%', cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<span className="hint" style={{ fontSize: '0.82rem' }}>NMS IoU Threshold:</span>
|
||||
<strong style={{ color: '#c084fc', fontSize: '0.85rem' }}>{appendModalState.iouThreshold ?? 0.8}</strong>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="0.9"
|
||||
step="0.05"
|
||||
value={appendModalState.iouThreshold ?? 0.8}
|
||||
onChange={(e) => setAppendModalState({ ...appendModalState, iouThreshold: parseFloat(e.target.value) })}
|
||||
style={{ width: '100%', cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<span className="hint" style={{ fontSize: '0.82rem' }}>Select Classes to Append:</span>
|
||||
<span style={{ fontSize: '0.78rem', color: '#a1a1aa' }}>
|
||||
{appendModalState.selectedClasses.length} of {appendModalState.classes.length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{appendModalState.classes.length === 0 ? (
|
||||
<p className="hint" style={{ fontStyle: 'italic', color: '#e4e4e7' }}>No embedded class names found in model file. All predictions will be appended.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 150, overflowY: 'auto', padding: 10, background: 'rgba(0,0,0,0.4)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
{appendModalState.classes.map((clsName) => {
|
||||
const isChecked = appendModalState.selectedClasses.includes(clsName)
|
||||
return (
|
||||
<button
|
||||
key={clsName}
|
||||
type="button"
|
||||
className="tag"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '4px 9px',
|
||||
fontSize: '0.8rem',
|
||||
background: isChecked ? 'rgba(56, 189, 248, 0.25)' : 'rgba(255,255,255,0.05)',
|
||||
color: isChecked ? '#e0f2fe' : '#71717a',
|
||||
border: isChecked ? '1px solid rgba(56, 189, 248, 0.6)' : '1px solid rgba(255,255,255,0.1)',
|
||||
transition: 'all 150ms ease'
|
||||
}}
|
||||
onClick={() => {
|
||||
const next = isChecked
|
||||
? appendModalState.selectedClasses.filter((c) => c !== clsName)
|
||||
: [...appendModalState.selectedClasses, clsName]
|
||||
setAppendModalState({ ...appendModalState, selectedClasses: next })
|
||||
}}
|
||||
>
|
||||
{isChecked ? '✓ ' : ''}{clsName}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ justifyContent: 'flex-end', gap: 10 }}>
|
||||
<button className="btn btn-ghost" onClick={() => setAppendModalState(null)}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={appendModalState.classes.length > 0 && appendModalState.selectedClasses.length === 0}
|
||||
onClick={async () => {
|
||||
const { batch, file, threshold, iouThreshold, selectedClasses } = appendModalState
|
||||
setAppendModalState(null)
|
||||
setBusyId(batch.id)
|
||||
try {
|
||||
await api.autolabelWithModel(batch.id, file, threshold, selectedClasses, iouThreshold ?? 0.8)
|
||||
onChanged()
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Start Append Auto-Annotation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base Model Auto-annotate Modal */}
|
||||
{baseModelModalState && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 9999
|
||||
}}>
|
||||
<div className="panel" style={{ width: 480, maxWidth: '92vw', padding: 22, border: '1px solid rgba(56, 189, 248, 0.4)', background: '#18181b' }}>
|
||||
<h3 style={{ margin: '0 0 6px 0', color: '#38bdf8', fontSize: '1.05rem' }}>Auto-annotate Batch (Base Model)</h3>
|
||||
<p className="hint" style={{ fontSize: '0.82rem', marginBottom: 16 }}>
|
||||
Select target classes to detect using the project's base model:
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<span className="hint" style={{ fontSize: '0.82rem' }}>Confidence Threshold:</span>
|
||||
<strong style={{ color: '#38bdf8', fontSize: '0.85rem' }}>{baseModelModalState.threshold}</strong>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="0.95"
|
||||
step="0.05"
|
||||
value={baseModelModalState.threshold}
|
||||
onChange={(e) => setBaseModelModalState({ ...baseModelModalState, threshold: parseFloat(e.target.value) })}
|
||||
style={{ width: '100%', cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<span className="hint" style={{ fontSize: '0.82rem' }}>NMS IoU Threshold:</span>
|
||||
<strong style={{ color: '#38bdf8', fontSize: '0.85rem' }}>{baseModelModalState.iouThreshold ?? 0.8}</strong>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="0.9"
|
||||
step="0.05"
|
||||
value={baseModelModalState.iouThreshold ?? 0.8}
|
||||
onChange={(e) => setBaseModelModalState({ ...baseModelModalState, iouThreshold: parseFloat(e.target.value) })}
|
||||
style={{ width: '100%', cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<span className="hint" style={{ fontSize: '0.82rem', display: 'block', marginBottom: 6 }}>Target Classes to Detect:</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, padding: 10, background: 'rgba(0,0,0,0.4)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
{project?.classes?.map((cls) => {
|
||||
const isChecked = baseModelModalState.selectedClasses.includes(cls.name)
|
||||
return (
|
||||
<button
|
||||
key={cls.class_id}
|
||||
type="button"
|
||||
className="tag"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '4px 9px',
|
||||
fontSize: '0.8rem',
|
||||
background: isChecked ? 'rgba(56, 189, 248, 0.25)' : 'rgba(255,255,255,0.05)',
|
||||
color: isChecked ? '#e0f2fe' : '#71717a',
|
||||
border: isChecked ? '1px solid rgba(56, 189, 248, 0.6)' : '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
onClick={() => {
|
||||
const newSel = isChecked
|
||||
? baseModelModalState.selectedClasses.filter((n) => n !== cls.name)
|
||||
: [...baseModelModalState.selectedClasses, cls.name]
|
||||
setBaseModelModalState({ ...baseModelModalState, selectedClasses: newSel })
|
||||
}}
|
||||
>
|
||||
{isChecked ? '✓ ' : ''}{cls.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ justifyContent: 'flex-end', gap: 10 }}>
|
||||
<button className="btn btn-ghost" onClick={() => setBaseModelModalState(null)}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={baseModelModalState.selectedClasses.length === 0}
|
||||
onClick={async () => {
|
||||
const { batch, threshold, iouThreshold, selectedClasses } = baseModelModalState
|
||||
setBaseModelModalState(null)
|
||||
setBusyId(batch.id)
|
||||
try {
|
||||
await api.startAutolabel(batch.id, {
|
||||
resume: false,
|
||||
append: false,
|
||||
engine: 'base_model',
|
||||
threshold,
|
||||
iou_threshold: iouThreshold ?? 0.8,
|
||||
target_class_names: selectedClasses,
|
||||
})
|
||||
onChanged()
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Start Auto-Annotation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export default function ModelsPage({ projectId, onProject }) {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [selectedBatchIds, setSelectedBatchIds] = useState([])
|
||||
const [selectedClassIds, setSelectedClassIds] = useState([])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [loadedProject, loadedSummary, modelPayload, hw, jobsPayload] = await Promise.all([
|
||||
@@ -103,6 +104,7 @@ export default function ModelsPage({ projectId, onProject }) {
|
||||
setModels(modelPayload.models)
|
||||
setHardware(hw)
|
||||
setSelectedBatchIds(loadedSummary.batches.map((b) => b.id))
|
||||
setSelectedClassIds(loadedProject.classes?.map((c) => c.class_id) || [])
|
||||
const activeJob = jobsPayload.jobs?.find((j) => ['running', 'queued'].includes(j.status))
|
||||
if (activeJob) setJob(activeJob)
|
||||
}, [projectId])
|
||||
@@ -126,6 +128,7 @@ export default function ModelsPage({ projectId, onProject }) {
|
||||
setJob(await api.startTraining(projectId, {
|
||||
epochs: Number(epochs),
|
||||
batch_ids: selectedBatchIds.length > 0 ? selectedBatchIds : null,
|
||||
class_ids: selectedClassIds.length > 0 ? selectedClassIds : null,
|
||||
}))
|
||||
} catch (exc) {
|
||||
setError(exc.message)
|
||||
@@ -146,6 +149,12 @@ export default function ModelsPage({ projectId, onProject }) {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleClassSelect = (classId) => {
|
||||
setSelectedClassIds((prev) =>
|
||||
prev.includes(classId) ? prev.filter((cId) => cId !== classId) : [...prev, classId]
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !project) return <p className="error-banner"><AlertIcon size={14} /> {error}</p>
|
||||
if (!project || !summary) return <p className="empty">Loading…</p>
|
||||
|
||||
@@ -169,103 +178,40 @@ export default function ModelsPage({ projectId, onProject }) {
|
||||
<AlertIcon size={14} /> {error}
|
||||
</p>}
|
||||
|
||||
<div className="select-engine-section">
|
||||
<h2>Select Engine</h2>
|
||||
<p className="hint">
|
||||
Select how you want to train your model. Configure custom fine-tuning parameters or use SAM3 grounding backbone.
|
||||
</p>
|
||||
|
||||
<div className="engine-grid">
|
||||
<div className="engine-card selected">
|
||||
<div className="engine-card-header">
|
||||
<span className="project-badge">Selected</span>
|
||||
<span className="engine-card-title">Custom Training (YOLO11)</span>
|
||||
</div>
|
||||
<p className="engine-card-desc">
|
||||
Fine-tune on the merged master dataset using pre-configured hardware batch size and image resolution.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="engine-card" title="Neural Architecture Search / Rapid Auto-train">
|
||||
<div className="engine-card-header">
|
||||
<span className="chip-badge info">Rapid NAS</span>
|
||||
<span className="engine-card-title">Neural Architecture Search</span>
|
||||
</div>
|
||||
<p className="engine-card-desc">
|
||||
Automated model selection optimized for latency and accuracy trade-offs on your specific project dataset.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel side-panel" style={{ marginBottom: 20 }}>
|
||||
<h2>Uploaded Models for Training & Auto-Annotation</h2>
|
||||
<p className="hint">Upload up to 2 models to use for fine-tuning baseline or auto-annotating new video batches:</p>
|
||||
<h2>Base Model Configuration</h2>
|
||||
<p className="hint">Upload a base YOLO model checkpoint (.pt) to use for fine-tuning baseline and auto-annotation:</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16, marginTop: 12 }}>
|
||||
<div style={{ padding: 14, background: 'rgba(0,0,0,0.3)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<h3 style={{ fontSize: '0.9rem', color: '#c084fc', margin: '0 0 6px 0' }}>Primary Model 1 (Base Model)</h3>
|
||||
<p className="hint" style={{ fontSize: '0.78rem', margin: '0 0 10px 0' }}>
|
||||
Used as fine-tuning starting point and benchmark baseline.
|
||||
</p>
|
||||
<div style={{ fontSize: '0.8rem', color: '#a1a1aa', marginBottom: 10 }}>
|
||||
Status: <strong style={{ color: '#fff' }}>{project.base_model_path ? 'Custom model.pt loaded' : 'Default yolo11n.pt'}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.78rem', color: '#38bdf8', marginBottom: 10 }}>
|
||||
<strong>Model 1 Classes:</strong> {project.classes?.map((c) => c.name).join(', ')}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pt"
|
||||
id="upload-primary-model"
|
||||
style={{ display: 'none' }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await api.uploadBaseModel(projectId, file)
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="upload-primary-model" className="btn btn-ghost" style={{ cursor: 'pointer', padding: '4px 10px', fontSize: '0.8rem' }}>
|
||||
Upload Primary Model 1 (.pt)
|
||||
</label>
|
||||
<div style={{ padding: 14, background: 'rgba(0,0,0,0.3)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)', marginTop: 12 }}>
|
||||
<h3 style={{ fontSize: '0.9rem', color: '#c084fc', margin: '0 0 6px 0' }}>Base Model</h3>
|
||||
<p className="hint" style={{ fontSize: '0.78rem', margin: '0 0 10px 0' }}>
|
||||
Used as fine-tuning starting point and baseline benchmark.
|
||||
</p>
|
||||
<div style={{ fontSize: '0.8rem', color: '#a1a1aa', marginBottom: 10 }}>
|
||||
Status: <strong style={{ color: '#fff' }}>{project.base_model_path ? 'Custom model.pt loaded' : 'Default yolo11n.pt'}</strong>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 14, background: 'rgba(0,0,0,0.3)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<h3 style={{ fontSize: '0.9rem', color: '#38bdf8', margin: '0 0 6px 0' }}>Secondary Model 2 (Auto-Annotate Engine)</h3>
|
||||
<p className="hint" style={{ fontSize: '0.78rem', margin: '0 0 10px 0' }}>
|
||||
Used as an auxiliary engine choice for fast auto-annotation.
|
||||
</p>
|
||||
<div style={{ fontSize: '0.8rem', color: '#a1a1aa', marginBottom: 10 }}>
|
||||
Status: <strong style={{ color: '#fff' }}>{project.secondary_model_path ? (project.secondary_model_name || 'secondary_model.pt') : 'None uploaded'}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.78rem', color: '#c084fc', marginBottom: 10 }}>
|
||||
<strong>Model 2 Native Classes:</strong> {project.secondary_model_classes?.length > 0 ? project.secondary_model_classes.join(', ') : 'Extracted on upload'}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pt"
|
||||
id="upload-secondary-model"
|
||||
style={{ display: 'none' }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await api.uploadSecondaryModel(projectId, file)
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="upload-secondary-model" className="btn btn-ghost" style={{ cursor: 'pointer', padding: '4px 10px', fontSize: '0.8rem' }}>
|
||||
Upload Secondary Model 2 (.pt)
|
||||
</label>
|
||||
<div style={{ fontSize: '0.78rem', color: '#38bdf8', marginBottom: 10 }}>
|
||||
<strong>Base Model Classes:</strong> {project.classes?.map((c) => c.name).join(', ')}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pt"
|
||||
id="upload-primary-model"
|
||||
style={{ display: 'none' }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await api.uploadBaseModel(projectId, file)
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="upload-primary-model" className="btn btn-ghost" style={{ cursor: 'pointer', padding: '4px 10px', fontSize: '0.8rem' }}>
|
||||
Upload Base Model (.pt)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -291,6 +237,34 @@ export default function ModelsPage({ projectId, onProject }) {
|
||||
: `${project.base_model_fallback} (no base model uploaded)`}{' '}
|
||||
on the selected dataset batches ({selectedBatchIds.length}/{summary.batches.length} selected).
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ fontSize: '0.82rem', display: 'block', marginBottom: 6 }}>Target Classes to Train:</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{project.classes?.map((cls) => {
|
||||
const isChecked = selectedClassIds.includes(cls.class_id)
|
||||
return (
|
||||
<button
|
||||
key={cls.class_id}
|
||||
type="button"
|
||||
className="tag"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '4px 9px',
|
||||
fontSize: '0.8rem',
|
||||
background: isChecked ? 'rgba(192, 132, 252, 0.25)' : 'rgba(255,255,255,0.05)',
|
||||
color: isChecked ? '#f3e8ff' : '#71717a',
|
||||
border: isChecked ? '1px solid rgba(192, 132, 252, 0.6)' : '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
onClick={() => toggleClassSelect(cls.class_id)}
|
||||
>
|
||||
{isChecked ? '✓ ' : ''}{cls.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="epochs">Epochs</label>
|
||||
<input id="epochs" type="number" min="1" max="500" value={epochs}
|
||||
@@ -303,7 +277,7 @@ export default function ModelsPage({ projectId, onProject }) {
|
||||
</p>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={train}
|
||||
disabled={running || summary.splits.train === 0 || selectedBatchIds.length === 0}>
|
||||
disabled={running || summary.splits.train === 0 || selectedBatchIds.length === 0 || selectedClassIds.length === 0}>
|
||||
{running ? 'Training…' : 'Start training'}
|
||||
</button>
|
||||
{summary.splits.train === 0 && (
|
||||
@@ -312,6 +286,9 @@ export default function ModelsPage({ projectId, onProject }) {
|
||||
{selectedBatchIds.length === 0 && summary.splits.train > 0 && (
|
||||
<p className="hint" style={{ color: '#ef4444' }}>Select at least one dataset batch to train.</p>
|
||||
)}
|
||||
{selectedClassIds.length === 0 && (
|
||||
<p className="hint" style={{ color: '#ef4444' }}>Select at least one class to train.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{job && (
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
const COLORS = [
|
||||
'#38bdf8', '#c084fc', '#f43f5e', '#34d399', '#fbbf24',
|
||||
'#818cf8', '#f472b6', '#a3e635', '#2dd4bf', '#fb923c'
|
||||
]
|
||||
|
||||
function getColor(index) {
|
||||
return COLORS[index % COLORS.length]
|
||||
}
|
||||
|
||||
export default function Sam3PlaygroundPage() {
|
||||
const [imageFile, setImageFile] = useState(null)
|
||||
const [previewUrl, setPreviewUrl] = useState(null)
|
||||
const [promptsText, setPromptsText] = useState('white plastic bag, forklift, pallet')
|
||||
const [threshold, setThreshold] = useState(0.35)
|
||||
const [iouThreshold, setIouThreshold] = useState(0.8)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [result, setResult] = useState(null)
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const handleFileSelect = (file) => {
|
||||
if (!file) return
|
||||
setImageFile(file)
|
||||
setPreviewUrl(URL.createObjectURL(file))
|
||||
setResult(null)
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
handleFileSelect(file)
|
||||
}
|
||||
}
|
||||
|
||||
const runTest = async () => {
|
||||
if (!imageFile) {
|
||||
setError('Please select or upload an image first.')
|
||||
return
|
||||
}
|
||||
if (!promptsText.trim()) {
|
||||
setError('Please enter at least one text prompt.')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const payload = await api.sam3PlaygroundTest(imageFile, promptsText, threshold, iouThreshold)
|
||||
setResult(payload)
|
||||
} catch (err) {
|
||||
setError(err.message || 'SAM3 test failed.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="roboflow-page" style={{ padding: 24, maxWidth: 1400, margin: '0 auto' }}>
|
||||
<div className="page-header" style={{ marginBottom: 20 }}>
|
||||
<div>
|
||||
<h1 style={{ margin: '0 0 6px 0', fontSize: '1.4rem', color: '#c084fc', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
🤖 SAM3 Global Playground
|
||||
</h1>
|
||||
<p className="hint" style={{ margin: 0, fontSize: '0.85rem' }}>
|
||||
Test SAM3 zero-shot text grounding prompts on any image without affecting projects or database records.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert error" style={{ marginBottom: 16, padding: '10px 14px', borderRadius: 6, background: 'rgba(239, 68, 68, 0.15)', border: '1px solid rgba(239, 68, 68, 0.4)', color: '#fca5a5', fontSize: '0.85rem' }}>
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '360px 1fr', gap: 20, alignItems: 'start' }}>
|
||||
{/* Left Control Panel */}
|
||||
<div className="panel" style={{ padding: 18, background: '#18181b', borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<h3 style={{ margin: '0 0 14px 0', fontSize: '0.95rem', color: '#fff' }}>Configuration</h3>
|
||||
|
||||
{/* Image Selection / Dropzone */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label className="hint" style={{ fontSize: '0.82rem', display: 'block', marginBottom: 6 }}>
|
||||
Select Test Image:
|
||||
</label>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
border: '2px dashed rgba(192, 132, 252, 0.4)',
|
||||
borderRadius: 8,
|
||||
padding: '16px 12px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
transition: 'all 150ms ease'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => handleFileSelect(e.target.files?.[0])}
|
||||
/>
|
||||
{imageFile ? (
|
||||
<div style={{ fontSize: '0.82rem', color: '#38bdf8', wordBreak: 'break-all' }}>
|
||||
🖼️ {imageFile.name} ({(imageFile.size / 1024).toFixed(0)} KB)
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '0.82rem', color: '#a1a1aa' }}>
|
||||
Click to choose image or drag & drop here (.jpg, .png)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Prompts Input */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label className="hint" style={{ fontSize: '0.82rem', display: 'block', marginBottom: 6 }}>
|
||||
Descriptive Text Prompts (Comma Separated):
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={promptsText}
|
||||
onChange={(e) => setPromptsText(e.target.value)}
|
||||
placeholder="e.g. white plastic bag, forklift, pallet"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '0.83rem',
|
||||
background: '#09090b',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
borderRadius: 6,
|
||||
color: '#fff',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
<span className="hint" style={{ fontSize: '0.75rem', color: '#71717a' }}>
|
||||
Tip: Use specific descriptive phrases for custom objects (e.g. "woven white bag" instead of "sack").
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Threshold Sliders */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span className="hint" style={{ fontSize: '0.82rem' }}>Confidence Threshold:</span>
|
||||
<strong style={{ color: '#c084fc', fontSize: '0.85rem' }}>{threshold}</strong>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="0.95"
|
||||
step="0.05"
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(parseFloat(e.target.value))}
|
||||
style={{ width: '100%', cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span className="hint" style={{ fontSize: '0.82rem' }}>NMS IoU Threshold:</span>
|
||||
<strong style={{ color: '#38bdf8', fontSize: '0.85rem' }}>{iouThreshold}</strong>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="0.9"
|
||||
step="0.05"
|
||||
value={iouThreshold}
|
||||
onChange={(e) => setIouThreshold(parseFloat(e.target.value))}
|
||||
style={{ width: '100%', cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Run Button */}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={loading || !imageFile}
|
||||
onClick={runTest}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 600,
|
||||
background: '#c084fc',
|
||||
borderColor: '#c084fc',
|
||||
color: '#000',
|
||||
cursor: loading || !imageFile ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{loading ? 'Running SAM3 Engine…' : '🚀 Run SAM3 Test'}
|
||||
</button>
|
||||
|
||||
{/* Detection Stats List */}
|
||||
{result && (
|
||||
<div style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<h4 style={{ margin: '0 0 10px 0', fontSize: '0.88rem', color: '#34d399' }}>
|
||||
Results: {result.detections?.length || 0} Object(s) Found
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 220, overflowY: 'auto' }}>
|
||||
{result.detections.map((det, i) => {
|
||||
const color = getColor(det.class_id)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
borderRadius: 6,
|
||||
borderLeft: `4px solid ${color}`,
|
||||
fontSize: '0.78rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<strong style={{ color }}>{det.prompt}</strong>
|
||||
<span style={{ color: '#a1a1aa' }}>{(det.score * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Canvas Viewer */}
|
||||
<div className="panel" style={{ padding: 18, background: '#18181b', borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)', minHeight: 500 }}>
|
||||
{!previewUrl ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: 450, color: '#71717a' }}>
|
||||
<div style={{ fontSize: '2.5rem', marginBottom: 10 }}>🖼️</div>
|
||||
<p style={{ margin: 0, fontSize: '0.9rem' }}>Upload an image on the left to start testing SAM3 text prompts.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ position: 'relative', width: '100%', overflow: 'hidden', display: 'flex', justifyContent: 'center', background: '#09090b', borderRadius: 6 }}>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="SAM3 Test Target"
|
||||
style={{ maxWidth: '100%', maxHeight: '75vh', objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
|
||||
{/* Overlay Canvas SVG */}
|
||||
{result && result.width && result.height && (
|
||||
<svg
|
||||
viewBox={`0 0 ${result.width} ${result.height}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, width: '100%', height: '100%',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
{result.detections.map((det, i) => {
|
||||
const color = getColor(det.class_id)
|
||||
const [x0, y0, x1, y1] = [
|
||||
det.box[0] * result.width,
|
||||
det.box[1] * result.height,
|
||||
det.box[2] * result.width,
|
||||
det.box[3] * result.height
|
||||
]
|
||||
const bw = x1 - x0
|
||||
const bh = y1 - y0
|
||||
|
||||
return (
|
||||
<g key={i}>
|
||||
{/* Polygon Masks */}
|
||||
{det.polygons?.map((poly, pIdx) => {
|
||||
const ptsString = poly.map(([px, py]) => `${px * result.width},${py * result.height}`).join(' ')
|
||||
return (
|
||||
<polygon
|
||||
key={pIdx}
|
||||
points={ptsString}
|
||||
fill={color}
|
||||
fillOpacity={0.35}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Bounding Box */}
|
||||
<rect
|
||||
x={x0}
|
||||
y={y0}
|
||||
width={bw}
|
||||
height={bh}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
|
||||
{/* Label Badge */}
|
||||
<rect
|
||||
x={x0}
|
||||
y={Math.max(0, y0 - 22)}
|
||||
width={Math.max(80, det.prompt.length * 8 + 45)}
|
||||
height={20}
|
||||
fill={color}
|
||||
rx={3}
|
||||
/>
|
||||
<text
|
||||
x={x0 + 6}
|
||||
y={Math.max(14, y0 - 8)}
|
||||
fill="#000"
|
||||
fontSize={12}
|
||||
fontWeight="bold"
|
||||
fontFamily="sans-serif"
|
||||
>
|
||||
{det.prompt} ({Math.round(det.score * 100)}%)
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user