bugfixes
This commit is contained in:
parent
bbab8e5e81
commit
b1618fd218
@ -17,6 +17,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
@ -105,6 +106,46 @@ type TrainingAnnotation struct {
|
||||
|
||||
const minTrainingFeedbackCount = 5
|
||||
|
||||
type TrainingJobStatus struct {
|
||||
Running bool `json:"running"`
|
||||
Progress int `json:"progress"`
|
||||
Step string `json:"step"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt string `json:"startedAt,omitempty"`
|
||||
FinishedAt string `json:"finishedAt,omitempty"`
|
||||
}
|
||||
|
||||
var trainingJob = struct {
|
||||
mu sync.Mutex
|
||||
status TrainingJobStatus
|
||||
}{}
|
||||
|
||||
func trainingSetJobStatus(update func(*TrainingJobStatus)) {
|
||||
trainingJob.mu.Lock()
|
||||
defer trainingJob.mu.Unlock()
|
||||
update(&trainingJob.status)
|
||||
}
|
||||
|
||||
func trainingGetJobStatus() TrainingJobStatus {
|
||||
trainingJob.mu.Lock()
|
||||
defer trainingJob.mu.Unlock()
|
||||
return trainingJob.status
|
||||
}
|
||||
|
||||
func trainingRunCommand(python string, script string, args ...string) (string, error) {
|
||||
cmdArgs := append([]string{script}, args...)
|
||||
cmd := exec.Command(python, cmdArgs...)
|
||||
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: 0x08000000,
|
||||
}
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
func trainingLabelsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
@ -390,6 +431,16 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
current := trainingGetJobStatus()
|
||||
if current.Running {
|
||||
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"message": "Training läuft bereits.",
|
||||
"training": current,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
root, err := trainingRootDir()
|
||||
if err != nil {
|
||||
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
||||
@ -417,38 +468,50 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
||||
*s = TrainingJobStatus{
|
||||
Running: true,
|
||||
Progress: 5,
|
||||
Step: "Training wird vorbereitet…",
|
||||
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
})
|
||||
|
||||
go trainingRunJob(root, count)
|
||||
|
||||
trainingWriteJSON(w, http.StatusAccepted, map[string]any{
|
||||
"ok": true,
|
||||
"message": "Training gestartet.",
|
||||
"training": trainingGetJobStatus(),
|
||||
})
|
||||
}
|
||||
|
||||
func trainingRunJob(root string, count int) {
|
||||
python := trainingPythonExe()
|
||||
|
||||
// --------------------------------------------------
|
||||
// 1) Scene-Modell trainieren: ResNet18-KNN
|
||||
// --------------------------------------------------
|
||||
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
||||
s.Progress = 15
|
||||
s.Step = "Scene-Modell wird trainiert…"
|
||||
})
|
||||
|
||||
sceneScript := trainingScriptPath("train_scene_model.py")
|
||||
|
||||
sceneCmd := exec.Command(
|
||||
python,
|
||||
sceneScript,
|
||||
"--root", root,
|
||||
)
|
||||
|
||||
sceneCmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: 0x08000000,
|
||||
}
|
||||
|
||||
sceneOut, err := sceneCmd.CombinedOutput()
|
||||
sceneOut, err := trainingRunCommand(python, sceneScript, "--root", root)
|
||||
if err != nil {
|
||||
trainingWriteError(
|
||||
w,
|
||||
http.StatusInternalServerError,
|
||||
fmt.Sprintf("scene training failed: %v: %s", err, strings.TrimSpace(string(sceneOut))),
|
||||
)
|
||||
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
||||
s.Running = false
|
||||
s.Progress = 0
|
||||
s.Step = ""
|
||||
s.Error = fmt.Sprintf("scene training failed: %v: %s", err, sceneOut)
|
||||
s.FinishedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// 2) Detector trainieren: YOLO
|
||||
// Nur starten, wenn dataset.yaml existiert.
|
||||
// --------------------------------------------------
|
||||
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
||||
s.Progress = 65
|
||||
s.Step = "Detector-Daten werden geprüft…"
|
||||
})
|
||||
|
||||
detectorOutput := ""
|
||||
detectorStatus := "skipped"
|
||||
|
||||
@ -462,9 +525,13 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
|
||||
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
|
||||
|
||||
if fileExistsNonEmpty(detectorDatasetYAML) && trainCount >= 5 && valCount >= 1 {
|
||||
detectorScript := trainingScriptPath("train_detector_model.py")
|
||||
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
||||
s.Progress = 75
|
||||
s.Step = "Detector wird trainiert…"
|
||||
})
|
||||
|
||||
detectorCmd := exec.Command(
|
||||
detectorScript := trainingScriptPath("train_detector_model.py")
|
||||
detectorOut, detectorErr := trainingRunCommand(
|
||||
python,
|
||||
detectorScript,
|
||||
"--root", root,
|
||||
@ -473,13 +540,7 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"--imgsz", "640",
|
||||
)
|
||||
|
||||
detectorCmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: 0x08000000,
|
||||
}
|
||||
|
||||
detectorOut, detectorErr := detectorCmd.CombinedOutput()
|
||||
detectorOutput = strings.TrimSpace(string(detectorOut))
|
||||
detectorOutput = detectorOut
|
||||
|
||||
if detectorErr != nil {
|
||||
detectorStatus = "failed"
|
||||
@ -504,14 +565,13 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
|
||||
message = "Scene-Modell wurde trainiert, Detector-Training ist fehlgeschlagen."
|
||||
}
|
||||
|
||||
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"message": message,
|
||||
"annotationCount": count,
|
||||
"sceneStatus": "trained",
|
||||
"sceneOutput": strings.TrimSpace(string(sceneOut)),
|
||||
"detectorStatus": detectorStatus,
|
||||
"detectorOutput": detectorOutput,
|
||||
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
||||
s.Running = false
|
||||
s.Progress = 100
|
||||
s.Step = "Training abgeschlossen."
|
||||
s.Message = message
|
||||
s.Error = ""
|
||||
s.FinishedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
})
|
||||
}
|
||||
|
||||
@ -530,11 +590,14 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
feedbackPath := filepath.Join(root, "feedback.jsonl")
|
||||
count, _ := trainingCountAnnotations(feedbackPath)
|
||||
|
||||
job := trainingGetJobStatus()
|
||||
|
||||
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"feedbackCount": count,
|
||||
"requiredCount": minTrainingFeedbackCount,
|
||||
"canTrain": count >= minTrainingFeedbackCount,
|
||||
"training": job,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -11,10 +11,21 @@ type ScoredLabel = {
|
||||
score: number
|
||||
}
|
||||
|
||||
type TrainingJobStatus = {
|
||||
running: boolean
|
||||
progress: number
|
||||
step: string
|
||||
message?: string
|
||||
error?: string
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
}
|
||||
|
||||
type TrainingStatus = {
|
||||
feedbackCount: number
|
||||
requiredCount: number
|
||||
canTrain: boolean
|
||||
training?: TrainingJobStatus
|
||||
}
|
||||
|
||||
type TrainingPrediction = {
|
||||
@ -211,6 +222,8 @@ export default function TrainingTab() {
|
||||
const [training, setTraining] = useState(false)
|
||||
const [trainingStatus, setTrainingStatus] = useState<TrainingStatus | null>(null)
|
||||
const [deletingTrainingData, setDeletingTrainingData] = useState(false)
|
||||
const [trainingProgress, setTrainingProgress] = useState(0)
|
||||
const [trainingStep, setTrainingStep] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
@ -247,6 +260,15 @@ export default function TrainingTab() {
|
||||
const feedbackCount = trainingStatus?.feedbackCount ?? 0
|
||||
const requiredCount = trainingStatus?.requiredCount ?? 5
|
||||
|
||||
const trainingRunning = training || Boolean(trainingStatus?.training?.running)
|
||||
const uiLocked = loading || saving || trainingRunning || deletingTrainingData
|
||||
const shownTrainingProgress = trainingRunning
|
||||
? trainingStatus?.training?.progress ?? trainingProgress
|
||||
: trainingProgress
|
||||
const shownTrainingStep = trainingRunning
|
||||
? trainingStatus?.training?.step || trainingStep || 'Training läuft…'
|
||||
: trainingStep
|
||||
|
||||
const loadLabels = useCallback(async () => {
|
||||
const res = await fetch('/api/training/labels', { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
@ -287,11 +309,34 @@ export default function TrainingTab() {
|
||||
|
||||
if (!res.ok || !data) return
|
||||
|
||||
const job = data.training || null
|
||||
|
||||
setTrainingStatus({
|
||||
feedbackCount: Number(data.feedbackCount ?? 0),
|
||||
requiredCount: Number(data.requiredCount ?? 5),
|
||||
canTrain: Boolean(data.canTrain),
|
||||
training: job
|
||||
? {
|
||||
running: Boolean(job.running),
|
||||
progress: Number(job.progress ?? 0),
|
||||
step: String(job.step ?? ''),
|
||||
message: job.message,
|
||||
error: job.error,
|
||||
startedAt: job.startedAt,
|
||||
finishedAt: job.finishedAt,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
setTraining(Boolean(job?.running))
|
||||
|
||||
if (job?.message && !job.running) {
|
||||
setMessage((prev) => prev || String(job.message))
|
||||
}
|
||||
|
||||
if (job?.error && !job.running) {
|
||||
setError((prev) => prev || String(job.error))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@ -300,11 +345,92 @@ export default function TrainingTab() {
|
||||
}, [boxLabel, boxLabels])
|
||||
|
||||
useEffect(() => {
|
||||
void loadLabels()
|
||||
void loadNext()
|
||||
void loadTrainingStatus()
|
||||
let cancelled = false
|
||||
|
||||
async function init() {
|
||||
await loadLabels()
|
||||
await loadTrainingStatus()
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
const res = await fetch('/api/training/status', { cache: 'no-store' })
|
||||
const data = await res.json().catch(() => null)
|
||||
|
||||
if (!cancelled && !data?.training?.running) {
|
||||
await loadNext()
|
||||
}
|
||||
}
|
||||
|
||||
void init()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [loadLabels, loadNext, loadTrainingStatus])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
void loadTrainingStatus()
|
||||
}, trainingRunning ? 1500 : 5000)
|
||||
|
||||
return () => window.clearInterval(timer)
|
||||
}, [loadTrainingStatus, trainingRunning])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trainingRunning) return
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
void loadTrainingStatus()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
}, [loadTrainingStatus, trainingRunning])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trainingRunning) {
|
||||
setTrainingProgress(0)
|
||||
setTrainingStep('')
|
||||
return
|
||||
}
|
||||
|
||||
setTrainingProgress((prev) => (prev > 0 ? prev : 8))
|
||||
setTrainingStep((prev) => prev || 'Training wird vorbereitet…')
|
||||
|
||||
const startedAt = Date.now()
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
const elapsed = Date.now() - startedAt
|
||||
|
||||
setTrainingProgress((prev) => {
|
||||
const serverProgress = trainingStatus?.training?.progress
|
||||
if (typeof serverProgress === 'number' && serverProgress > prev) {
|
||||
return serverProgress
|
||||
}
|
||||
|
||||
if (elapsed > 90_000) {
|
||||
setTrainingStep('Detector wird trainiert…')
|
||||
return Math.min(prev + 0.4, 92)
|
||||
}
|
||||
|
||||
if (elapsed > 25_000) {
|
||||
setTrainingStep('Scene-Modell wird trainiert…')
|
||||
return Math.min(prev + 0.8, 80)
|
||||
}
|
||||
|
||||
setTrainingStep('Trainingsdaten werden verarbeitet…')
|
||||
return Math.min(prev + 1.2, 55)
|
||||
})
|
||||
}, 700)
|
||||
|
||||
return () => window.clearInterval(timer)
|
||||
}, [trainingRunning, trainingStatus?.training?.progress])
|
||||
|
||||
const saveFeedback = useCallback(
|
||||
async (accepted: boolean) => {
|
||||
if (!sample) return
|
||||
@ -356,6 +482,8 @@ export default function TrainingTab() {
|
||||
|
||||
const startTraining = useCallback(async () => {
|
||||
setTraining(true)
|
||||
setTrainingProgress(5)
|
||||
setTrainingStep('Training wird gestartet…')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
@ -370,13 +498,14 @@ export default function TrainingTab() {
|
||||
throw new Error(data?.error || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
setMessage(data?.message || 'Training gestartet.')
|
||||
await loadTrainingStatus()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setTraining(false)
|
||||
setTrainingProgress(0)
|
||||
setTrainingStep('')
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [])
|
||||
}, [loadTrainingStatus])
|
||||
|
||||
const deleteAllTrainingData = useCallback(async () => {
|
||||
const confirmed = window.confirm(
|
||||
@ -434,7 +563,7 @@ export default function TrainingTab() {
|
||||
|
||||
const startDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!boxLabel) return
|
||||
if (loading || saving) return
|
||||
if (uiLocked) return
|
||||
|
||||
const pos = getPointerPosInImage(e.clientX, e.clientY)
|
||||
if (!pos) return
|
||||
@ -448,7 +577,7 @@ export default function TrainingTab() {
|
||||
w: 0,
|
||||
h: 0,
|
||||
})
|
||||
}, [boxLabel, getPointerPosInImage, loading, saving])
|
||||
}, [boxLabel, getPointerPosInImage, uiLocked])
|
||||
|
||||
const moveDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!drawingBox) return
|
||||
@ -517,7 +646,7 @@ export default function TrainingTab() {
|
||||
variant="primary"
|
||||
color='red'
|
||||
className="shrink-0 px-2 py-1 text-[11px]"
|
||||
disabled={loading || saving || training || deletingTrainingData}
|
||||
disabled={uiLocked}
|
||||
onClick={() => void deleteAllTrainingData()}
|
||||
title="Löscht alle gespeicherten Trainingsdaten."
|
||||
>
|
||||
@ -600,7 +729,7 @@ export default function TrainingTab() {
|
||||
<select
|
||||
value={boxLabel}
|
||||
onChange={(e) => setBoxLabel(e.target.value)}
|
||||
disabled={boxLabels.length === 0}
|
||||
disabled={uiLocked || boxLabels.length === 0}
|
||||
className="mt-2 h-9 w-full rounded-md border border-gray-200 bg-white px-2 text-sm dark:border-white/10 dark:bg-gray-950"
|
||||
>
|
||||
{boxLabels.length === 0 ? (
|
||||
@ -625,13 +754,22 @@ export default function TrainingTab() {
|
||||
key={`${box.label}-${index}`}
|
||||
className="flex items-center justify-between gap-2 rounded-md bg-white px-2 py-1 text-[11px] ring-1 ring-gray-200 dark:bg-gray-950 dark:ring-white/10"
|
||||
>
|
||||
<div className="min-w-0 truncate text-gray-700 dark:text-gray-200">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-2 text-gray-700 dark:text-gray-200">
|
||||
<span className="min-w-0 truncate">
|
||||
{index + 1}. {box.label}
|
||||
</span>
|
||||
|
||||
{typeof box.score === 'number' ? (
|
||||
<span className="shrink-0 text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{percent(box.score)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-500/10"
|
||||
disabled={uiLocked}
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-300 dark:hover:bg-red-500/10"
|
||||
onClick={() => removeBox(index)}
|
||||
>
|
||||
löschen
|
||||
@ -646,7 +784,7 @@ export default function TrainingTab() {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="mt-2 w-full"
|
||||
disabled={saving || loading}
|
||||
disabled={uiLocked}
|
||||
onClick={clearBoxes}
|
||||
>
|
||||
Alle Boxen entfernen
|
||||
@ -660,7 +798,7 @@ export default function TrainingTab() {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
disabled={loading || saving}
|
||||
disabled={uiLocked}
|
||||
onClick={() => void loadNext({ forceNew: true })}
|
||||
>
|
||||
Anderes Bild
|
||||
@ -670,7 +808,7 @@ export default function TrainingTab() {
|
||||
size="sm"
|
||||
variant="soft"
|
||||
className="w-full"
|
||||
disabled={training || !canStartTraining}
|
||||
disabled={uiLocked || !canStartTraining}
|
||||
onClick={() => void startTraining()}
|
||||
title={
|
||||
canStartTraining
|
||||
@ -678,9 +816,25 @@ export default function TrainingTab() {
|
||||
: `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
|
||||
}
|
||||
>
|
||||
Training starten
|
||||
{trainingRunning ? 'Training läuft…' : 'Training starten'}
|
||||
</Button>
|
||||
|
||||
{trainingRunning ? (
|
||||
<div className="rounded-lg bg-gray-50 p-2 text-xs ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] text-gray-600 dark:text-gray-300">
|
||||
<span>{shownTrainingStep || 'Training läuft…'}</span>
|
||||
<span>{Math.round(shownTrainingProgress)}%</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
|
||||
style={{ width: `${clampPercent(shownTrainingProgress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="text-[11px] leading-relaxed text-gray-500 dark:text-gray-400">
|
||||
{canStartTraining ? (
|
||||
<>
|
||||
@ -722,7 +876,10 @@ export default function TrainingTab() {
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<div
|
||||
ref={imageBoxRef}
|
||||
className="relative inline-block max-h-[72dvh] max-w-full select-none"
|
||||
className={[
|
||||
'relative inline-block max-h-[72dvh] max-w-full select-none transition',
|
||||
trainingRunning ? 'pointer-events-none blur-sm' : '',
|
||||
].join(' ')}
|
||||
onPointerDown={startDrawBox}
|
||||
onPointerMove={moveDrawBox}
|
||||
onPointerUp={finishDrawBox}
|
||||
@ -746,13 +903,11 @@ export default function TrainingTab() {
|
||||
return (
|
||||
<div
|
||||
key={`${box.label}-${index}-${isDraft ? 'draft' : 'box'}`}
|
||||
role={isDraft ? undefined : 'button'}
|
||||
tabIndex={isDraft ? undefined : 0}
|
||||
className={[
|
||||
'absolute rounded border-2 shadow-[0_0_0_1px_rgba(0,0,0,0.75)]',
|
||||
isDraft
|
||||
? 'pointer-events-none border-amber-400'
|
||||
: 'cursor-pointer border-emerald-400 hover:border-red-400',
|
||||
: 'pointer-events-none border-emerald-400',
|
||||
].join(' ')}
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
@ -760,8 +915,18 @@ export default function TrainingTab() {
|
||||
width: `${width}%`,
|
||||
height: `${height}%`,
|
||||
backgroundColor: 'transparent',
|
||||
appearance: 'none',
|
||||
}}
|
||||
title={box.label}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'pointer-events-auto absolute left-0 top-0 flex -translate-y-full items-center rounded-t px-1.5 py-0.5 text-[11px] font-semibold text-black shadow',
|
||||
isDraft
|
||||
? 'cursor-default bg-amber-400'
|
||||
: 'cursor-pointer bg-emerald-400 hover:bg-red-400',
|
||||
].join(' ')}
|
||||
disabled={Boolean(isDraft)}
|
||||
title={isDraft ? box.label : `${box.label} löschen`}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
@ -772,28 +937,48 @@ export default function TrainingTab() {
|
||||
e.stopPropagation()
|
||||
if (!isDraft) removeBox(index)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isDraft) return
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
>
|
||||
<span>{box.label}</span>
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
removeBox(index)
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'absolute left-0 top-0 -translate-y-full rounded-t px-1.5 py-0.5 text-[11px] font-semibold text-black shadow',
|
||||
isDraft ? 'bg-amber-400' : 'bg-emerald-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{box.label} {typeof box.score === 'number' ? percent(box.score) : ''}
|
||||
{typeof box.score === 'number' ? (
|
||||
<span className="ml-1 opacity-80">
|
||||
({percent(box.score)})
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{trainingRunning ? (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/45 text-center text-white backdrop-blur-[1px]">
|
||||
<LoadingSpinner
|
||||
size="lg"
|
||||
className="text-white"
|
||||
srLabel="Training läuft…"
|
||||
/>
|
||||
|
||||
<div className="mt-3 text-sm font-semibold">
|
||||
Training läuft…
|
||||
</div>
|
||||
|
||||
<div className="mt-1 max-w-[260px] px-4 text-xs text-white/80">
|
||||
{shownTrainingStep || 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.'}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-white/20">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-400 transition-all duration-500"
|
||||
style={{ width: `${clampPercent(shownTrainingProgress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-[11px] text-white/70">
|
||||
{Math.round(shownTrainingProgress)}%
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-white/80">Kein Frame geladen</div>
|
||||
@ -804,7 +989,7 @@ export default function TrainingTab() {
|
||||
<Button
|
||||
variant="soft"
|
||||
color="emerald"
|
||||
disabled={!sample || saving || loading || !sample.prediction.modelAvailable}
|
||||
disabled={uiLocked || !sample || !sample.prediction.modelAvailable}
|
||||
onClick={() => void saveFeedback(true)}
|
||||
className="w-full justify-center"
|
||||
title={
|
||||
@ -818,7 +1003,7 @@ export default function TrainingTab() {
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!sample || saving || loading}
|
||||
disabled={uiLocked || !sample}
|
||||
onClick={() => void saveFeedback(false)}
|
||||
className="w-full justify-center"
|
||||
title="Die Werte rechts werden als richtige Korrektur gespeichert."
|
||||
@ -828,7 +1013,7 @@ export default function TrainingTab() {
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={loading || saving}
|
||||
disabled={uiLocked || !sample}
|
||||
onClick={() => void loadNext({ forceNew: true })}
|
||||
className="w-full justify-center"
|
||||
title="Dieses Bild nicht bewerten und ein anderes laden."
|
||||
@ -843,7 +1028,12 @@ export default function TrainingTab() {
|
||||
</section>
|
||||
|
||||
{/* Rechte Details/Korrektur */}
|
||||
<aside className="rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
|
||||
<aside
|
||||
className={[
|
||||
'rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60',
|
||||
uiLocked ? 'pointer-events-none opacity-60' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Korrektur
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user