update from asus 106

This commit is contained in:
asus committed 2026-08-05 15:56:11 +07:00
1 parent 6637fb1302
commit 8285400254
28 files changed
+3215 -459

No files matched your search

+6
View File
@@ -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>
+24
View File
@@ -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}`),
+5 -1
View File
@@ -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
View File
@@ -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 &gt;</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 &gt;</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>
)
}
+71 -94
View File
@@ -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 && (
+328
View File
@@ -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>
)
}