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