Run ID
-
{run.id}
+
+ {run.id}
+
Status
@@ -277,12 +516,26 @@ export default function ExperimentDetail() {
{exportJobId && (
- ONNX export queued · JOB {exportJobId}{' '}
-
+ ONNX export queued · JOB{' '}
+ {exportJobId}{' '}
+
View Artifacts →
)}
+
+ {/* Zoom lightbox */}
+ {zoom && (
+
setZoom(null)}
+ >
+

+
+ )}
);
}
diff --git a/frontend/src/pages/experiments/new.tsx b/frontend/src/pages/experiments/new.tsx
index 079412f..29c2f92 100644
--- a/frontend/src/pages/experiments/new.tsx
+++ b/frontend/src/pages/experiments/new.tsx
@@ -6,26 +6,299 @@ import Button from '@/components/ui/Button';
import Alert from '@/components/ui/Alert';
import Spinner from '@/components/ui/Spinner';
import ClusterSelect from '@/components/common/ClusterSelect';
+import SplitPanel, { SplitConfig, DEFAULT_SPLIT } from '@/components/common/SplitPanel';
import { apiGet, apiPost } from '@/services/api';
-interface Project { id: string; name: string; }
-interface Dataset { id: string; name: string; latest_version_id?: string; }
+interface Project {
+ id: string;
+ name: string;
+}
+interface Dataset {
+ id: string;
+ name: string;
+ latest_version_id?: string;
+}
const BASE_MODELS = ['yolov8n.pt', 'yolov8s.pt', 'yolov8m.pt', 'yolov8l.pt', 'yolov8x.pt'];
-const DEFAULT_AUGMENTATIONS = {
- hsv_h: 0.015,
- hsv_s: 0.7,
- hsv_v: 0.4,
- degrees: 0.0,
- translate: 0.1,
- scale: 0.5,
- shear: 0.0,
- flipud: 0.0,
- fliplr: 0.5,
- mosaic: 1.0,
- mixup: 0.0,
-};
+type FieldType = 'number' | 'bool' | 'select';
+interface FieldDef {
+ key: string;
+ label: string;
+ type: FieldType;
+ default: number | boolean | string;
+ min?: number;
+ max?: number;
+ step?: number;
+ options?: string[];
+ help?: string;
+}
+
+// Single source of truth for every tunable hyperparameter / augmentation knob.
+// Adding a field here exposes it in the UI and forwards it to the backend.
+const GROUPS: { title: string; fields: FieldDef[] }[] = [
+ {
+ title: 'Core',
+ fields: [
+ { key: 'epochs', label: 'Epochs', type: 'number', default: 50, min: 1, max: 2000 },
+ { key: 'batch', label: 'Batch Size', type: 'number', default: 16, min: 1, max: 512 },
+ {
+ key: 'imgsz',
+ label: 'Image Size',
+ type: 'number',
+ default: 640,
+ min: 32,
+ max: 1920,
+ step: 32,
+ },
+ {
+ key: 'patience',
+ label: 'Patience',
+ type: 'number',
+ default: 100,
+ min: 0,
+ max: 1000,
+ help: 'Early-stop after N epochs w/o improvement',
+ },
+ { key: 'seed', label: 'Seed', type: 'number', default: 0, min: 0 },
+ {
+ key: 'rect',
+ label: 'Rectangular',
+ type: 'bool',
+ default: false,
+ help: 'Rectangular batches (min padding)',
+ },
+ { key: 'single_cls', label: 'Single class', type: 'bool', default: false },
+ ],
+ },
+ {
+ title: 'Optimizer & Schedule',
+ fields: [
+ {
+ key: 'optimizer',
+ label: 'Optimizer',
+ type: 'select',
+ default: 'auto',
+ options: ['auto', 'SGD', 'Adam', 'AdamW', 'NAdam', 'RAdam', 'RMSProp'],
+ },
+ {
+ key: 'lr0',
+ label: 'Initial LR (lr0)',
+ type: 'number',
+ default: 0.01,
+ min: 0.00001,
+ max: 1,
+ step: 0.0001,
+ },
+ {
+ key: 'lrf',
+ label: 'Final LR (lrf)',
+ type: 'number',
+ default: 0.01,
+ min: 0.00001,
+ max: 1,
+ step: 0.0001,
+ },
+ {
+ key: 'momentum',
+ label: 'Momentum',
+ type: 'number',
+ default: 0.937,
+ min: 0,
+ max: 1,
+ step: 0.001,
+ },
+ {
+ key: 'weight_decay',
+ label: 'Weight Decay',
+ type: 'number',
+ default: 0.0005,
+ min: 0,
+ max: 0.1,
+ step: 0.0001,
+ },
+ {
+ key: 'warmup_epochs',
+ label: 'Warmup Epochs',
+ type: 'number',
+ default: 3.0,
+ min: 0,
+ max: 20,
+ step: 0.5,
+ },
+ {
+ key: 'warmup_momentum',
+ label: 'Warmup Momentum',
+ type: 'number',
+ default: 0.8,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ },
+ {
+ key: 'warmup_bias_lr',
+ label: 'Warmup Bias LR',
+ type: 'number',
+ default: 0.1,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ },
+ { key: 'cos_lr', label: 'Cosine LR', type: 'bool', default: false },
+ {
+ key: 'close_mosaic',
+ label: 'Close Mosaic',
+ type: 'number',
+ default: 10,
+ min: 0,
+ max: 100,
+ help: 'Disable mosaic for last N epochs',
+ },
+ { key: 'nbs', label: 'Nominal Batch', type: 'number', default: 64, min: 1, max: 256 },
+ { key: 'amp', label: 'AMP', type: 'bool', default: true, help: 'Automatic mixed precision' },
+ ],
+ },
+ {
+ title: 'Regularization & Loss Gains',
+ fields: [
+ {
+ key: 'dropout',
+ label: 'Dropout',
+ type: 'number',
+ default: 0.0,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ },
+ {
+ key: 'label_smoothing',
+ label: 'Label Smoothing',
+ type: 'number',
+ default: 0.0,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ },
+ { key: 'box', label: 'Box Gain', type: 'number', default: 7.5, min: 0, max: 20, step: 0.1 },
+ { key: 'cls', label: 'Cls Gain', type: 'number', default: 0.5, min: 0, max: 10, step: 0.1 },
+ { key: 'dfl', label: 'DFL Gain', type: 'number', default: 1.5, min: 0, max: 10, step: 0.1 },
+ { key: 'overlap_mask', label: 'Overlap Mask', type: 'bool', default: true },
+ { key: 'mask_ratio', label: 'Mask Ratio', type: 'number', default: 4, min: 1, max: 16 },
+ ],
+ },
+ {
+ title: 'Augmentation',
+ fields: [
+ {
+ key: 'hsv_h',
+ label: 'hsv_h',
+ type: 'number',
+ default: 0.015,
+ min: 0,
+ max: 1,
+ step: 0.001,
+ help: 'Hue jitter fraction',
+ },
+ {
+ key: 'hsv_s',
+ label: 'hsv_s',
+ type: 'number',
+ default: 0.7,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ help: 'Saturation jitter',
+ },
+ {
+ key: 'hsv_v',
+ label: 'hsv_v',
+ type: 'number',
+ default: 0.4,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ help: 'Value jitter',
+ },
+ {
+ key: 'degrees',
+ label: 'degrees',
+ type: 'number',
+ default: 0.0,
+ min: 0,
+ max: 180,
+ step: 1,
+ help: 'Rotation range',
+ },
+ {
+ key: 'translate',
+ label: 'translate',
+ type: 'number',
+ default: 0.1,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ },
+ { key: 'scale', label: 'scale', type: 'number', default: 0.5, min: 0, max: 1, step: 0.01 },
+ { key: 'shear', label: 'shear', type: 'number', default: 0.0, min: 0, max: 10, step: 0.1 },
+ {
+ key: 'perspective',
+ label: 'perspective',
+ type: 'number',
+ default: 0.0,
+ min: 0,
+ max: 0.001,
+ step: 0.0001,
+ },
+ { key: 'flipud', label: 'flipud', type: 'number', default: 0.0, min: 0, max: 1, step: 0.01 },
+ { key: 'fliplr', label: 'fliplr', type: 'number', default: 0.5, min: 0, max: 1, step: 0.01 },
+ { key: 'bgr', label: 'bgr', type: 'number', default: 0.0, min: 0, max: 1, step: 0.01 },
+ { key: 'mosaic', label: 'mosaic', type: 'number', default: 1.0, min: 0, max: 1, step: 0.01 },
+ { key: 'mixup', label: 'mixup', type: 'number', default: 0.0, min: 0, max: 1, step: 0.01 },
+ {
+ key: 'copy_paste',
+ label: 'copy_paste',
+ type: 'number',
+ default: 0.0,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ },
+ {
+ key: 'erasing',
+ label: 'erasing',
+ type: 'number',
+ default: 0.4,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ },
+ {
+ key: 'crop_fraction',
+ label: 'crop_fraction',
+ type: 'number',
+ default: 1.0,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ },
+ {
+ key: 'auto_augment',
+ label: 'auto_augment',
+ type: 'select',
+ default: 'randaugment',
+ options: ['randaugment', 'autoaugment', 'augmix'],
+ },
+ ],
+ },
+];
+
+const DEVICES = ['cpu', 'cuda', 'mps', '0', '0,1'];
+
+function buildDefaults(): Record
{
+ const out: Record = {};
+ for (const g of GROUPS) for (const f of g.fields) out[f.key] = f.default;
+ return out;
+}
function FieldLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) {
return (
@@ -35,31 +308,80 @@ function FieldLabel({ htmlFor, children }: { htmlFor: string; children: React.Re
);
}
+function HpField({
+ field,
+ value,
+ onChange,
+}: {
+ field: FieldDef;
+ value: number | boolean | string;
+ onChange: (v: number | boolean | string) => void;
+}) {
+ if (field.type === 'bool') {
+ return (
+
+ );
+ }
+ if (field.type === 'select') {
+ return (
+
+ {field.label}
+
+
+ );
+ }
+ return (
+
+ {field.label}
+ {
+ const n = parseFloat(e.target.value);
+ onChange(Number.isFinite(n) ? n : (field.default as number));
+ }}
+ title={field.help}
+ />
+
+ );
+}
+
export default function ExperimentsNew() {
const [searchParams] = useSearchParams();
const preselectedProject = searchParams.get('projectId') || '';
const [projects, setProjects] = useState([]);
const [datasets, setDatasets] = useState([]);
- const [form, setForm] = useState({
+ const [run, setRun] = useState({
projectId: preselectedProject,
+ datasetId: '',
datasetVersionId: '',
name: 'Baseline',
task: 'detect',
baseModel: 'yolov8n.pt',
clusterId: '',
- epochs: 50,
- batchSize: 16,
- imageSize: 640,
- learningRate: 0.01,
- lrf: 0.01,
- momentum: 0.937,
- weightDecay: 0.0005,
- warmupEpochs: 3.0,
device: 'cpu',
- augmentations: { ...DEFAULT_AUGMENTATIONS },
});
- const [showAugmentations, setShowAugmentations] = useState(false);
+ const [params, setParams] = useState>(buildDefaults());
+ const [splitCfg, setSplitCfg] = useState(DEFAULT_SPLIT);
+ const [open, setOpen] = useState>({ Core: true });
const [loading, setLoading] = useState(false);
const [jobId, setJobId] = useState(null);
const [error, setError] = useState(null);
@@ -72,43 +394,62 @@ export default function ExperimentsNew() {
}, []);
useEffect(() => {
- if (!form.projectId) { setDatasets([]); return; }
- apiGet<{ items: Dataset[] }>(
- `/api/datasets?project_id=${form.projectId}&page=1&page_size=200`,
- )
+ if (!run.projectId) {
+ setDatasets([]);
+ return;
+ }
+ apiGet<{ items: Dataset[] }>(`/api/datasets?project_id=${run.projectId}&page=1&page_size=200`)
.then((d) => setDatasets(d.items || []))
.catch(console.error);
- }, [form.projectId]);
+ }, [run.projectId]);
- function setField(key: K, value: (typeof form)[K]) {
- setForm((prev) => ({ ...prev, [key]: value }));
+ function setParam(key: string, value: number | boolean | string) {
+ setParams((prev) => ({ ...prev, [key]: value }));
+ }
+
+ function resetGroup(title: string) {
+ const group = GROUPS.find((g) => g.title === title);
+ if (!group) return;
+ setParams((prev) => {
+ const next = { ...prev };
+ for (const f of group.fields) next[f.key] = f.default;
+ return next;
+ });
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
- if (!form.projectId) { setError('Select a project'); return; }
- if (!form.datasetVersionId) { setError('Select a dataset/version'); return; }
+ if (!run.projectId) return setError('Select a project');
+ if (!run.datasetVersionId) return setError('Select a dataset/version');
setLoading(true);
setError(null);
try {
+ // Persist the split first so what we train on matches what we visualize.
+ if (run.datasetId && run.datasetVersionId) {
+ try {
+ await apiPost(
+ `/api/datasets/${run.datasetId}/versions/${run.datasetVersionId}/split`,
+ splitCfg,
+ );
+ } catch {
+ /* training re-resolves deterministically from the same seed/ratios */
+ }
+ }
const job = await apiPost<{ id: string; status: string }>('/api/train', {
- projectId: form.projectId,
- datasetVersionId: form.datasetVersionId,
- task: form.task,
- baseModel: form.baseModel,
- name: form.name,
- clusterId: form.clusterId || null,
+ projectId: run.projectId,
+ datasetVersionId: run.datasetVersionId,
+ task: run.task,
+ baseModel: run.baseModel,
+ name: run.name,
+ clusterId: run.clusterId || null,
params: {
- epochs: form.epochs,
- batch: form.batchSize,
- imgsz: form.imageSize,
- lr0: form.learningRate,
- lrf: form.lrf,
- momentum: form.momentum,
- weight_decay: form.weightDecay,
- warmup_epochs: form.warmupEpochs,
- device: form.device,
- ...form.augmentations,
+ ...params,
+ device: run.device,
+ split_train: splitCfg.train,
+ split_val: splitCfg.val,
+ split_test: splitCfg.test,
+ split_seed: splitCfg.seed,
+ split_stratify: splitCfg.stratify,
},
});
setJobId(job.id);
@@ -136,58 +477,91 @@ export default function ExperimentsNew() {
}
return (
-
- {/* Header */}
-
+
+
// Experiments / New
New Training Run
-
+
← EXPERIMENTS
- {/* Form */}