diff --git a/backend/ai_server.py b/backend/ai_server.py index fdd71d7..5dc135d 100644 --- a/backend/ai_server.py +++ b/backend/ai_server.py @@ -1607,12 +1607,21 @@ def predict_batch(req: PredictBatchRequest): "error": "no paths supplied", } + imgsz = int(req.imageSize or _IMGSZ or 640) + current_model = get_model() if current_model is None: + predictions = [empty_prediction("detector_model_missing") for _ in paths] + if not req.detectorOnly: + apply_pose_batch_to_predictions(paths, predictions, imgsz) + return { "ok": True, - "predictions": [empty_prediction("model_missing") for _ in paths], - "error": _MODEL_ERROR or f"YOLO model not found: {DEFAULT_MODEL_PATH}", + "available": False, + "modelAvailable": False, + "predictions": predictions, + "modelError": _MODEL_ERROR, + "expectedModel": str(DEFAULT_MODEL_PATH), } if DETECTION_LABELS_PATH is None or _LABEL_ERROR: @@ -1622,8 +1631,6 @@ def predict_batch(req: PredictBatchRequest): "error": f"detection labels missing: {_LABEL_ERROR}", } - imgsz = int(req.imageSize or _IMGSZ or 640) - try: results = current_model.predict( source=paths, @@ -1642,6 +1649,8 @@ def predict_batch(req: PredictBatchRequest): return { "ok": True, + "available": True, + "modelAvailable": True, "predictions": predictions, } @@ -1703,7 +1712,7 @@ def health(): status_payload = { "ok": True, - "ready": current_model is not None, + "ready": True, "modelAvailable": current_model is not None, "model": _MODEL_PATH, "modelError": _MODEL_ERROR, @@ -1751,8 +1760,8 @@ def reload_model(): names = getattr(current_model, "names", {}) or {} if current_model is not None else {} status_payload = { - "ok": current_model is not None, - "ready": current_model is not None, + "ok": True, + "ready": True, "modelAvailable": current_model is not None, "model": _MODEL_PATH, "modelError": _MODEL_ERROR, diff --git a/backend/dist/nsfwapp-linux-amd64 b/backend/dist/nsfwapp-linux-amd64 index 15400ac..3b43f25 100644 Binary files a/backend/dist/nsfwapp-linux-amd64 and b/backend/dist/nsfwapp-linux-amd64 differ diff --git a/backend/dist/nsfwapp.exe b/backend/dist/nsfwapp.exe index b180390..010066f 100644 Binary files a/backend/dist/nsfwapp.exe and b/backend/dist/nsfwapp.exe differ diff --git a/backend/dist/nsfwapp_amd64.deb b/backend/dist/nsfwapp_amd64.deb index bc353f7..dbe5cee 100644 Binary files a/backend/dist/nsfwapp_amd64.deb and b/backend/dist/nsfwapp_amd64.deb differ diff --git a/backend/ml_setup.go b/backend/ml_setup.go index 93cd651..7c422c1 100644 --- a/backend/ml_setup.go +++ b/backend/ml_setup.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "sync" "time" @@ -112,11 +113,22 @@ func mlPythonSetupConfigFromEnv() mlPythonSetupConfig { appDir: filepath.Clean(appDir), requirementsPath: filepath.Clean(requirementsPath), venvDir: filepath.Clean(venvDir), - venvPython: filepath.Join(filepath.Clean(venvDir), "bin", "python"), + venvPython: mlPythonVenvPythonPath(venvDir), requirementsMark: filepath.Join(filepath.Clean(venvDir), ".requirements-ml.txt"), } } +func mlPythonVenvPythonPath(venvDir string) string { + venvDir = filepath.Clean(strings.TrimSpace(venvDir)) + if venvDir == "" { + return "" + } + if runtime.GOOS == "windows" { + return filepath.Join(venvDir, "Scripts", "python.exe") + } + return filepath.Join(venvDir, "bin", "python") +} + func defaultMLPythonVenvDir() string { base := strings.TrimSpace(os.TempDir()) if base == "" { diff --git a/backend/server.go b/backend/server.go index a1c02ae..2585f9b 100644 --- a/backend/server.go +++ b/backend/server.go @@ -206,6 +206,10 @@ func aiServerPythonPath() string { return raw } + if venvPython := configuredMLPythonVenvPythonPath(); venvPython != "" { + return venvPython + } + // Windows py launcher zuerst versuchen. if runtime.GOOS == "windows" { if _, err := exec.LookPath("py"); err == nil { @@ -224,6 +228,34 @@ func aiServerPythonPath() string { return "python" } +func configuredMLPythonVenvPythonPath() string { + if strings.TrimSpace(os.Getenv("NSFWAPP_SKIP_ML_SETUP")) == "1" { + return "" + } + + candidates := []string{} + if venvDir := strings.TrimSpace(os.Getenv("NSFWAPP_ML_VENV")); venvDir != "" { + candidates = append(candidates, venvDir) + } + if strings.TrimSpace(os.Getenv("NSFWAPP_ML_SETUP")) == "1" { + candidates = append(candidates, defaultMLPythonVenvDir()) + } + + seen := map[string]bool{} + for _, dir := range candidates { + path := mlPythonVenvPythonPath(dir) + key := filepath.Clean(path) + if path == "" || seen[key] { + continue + } + seen[key] = true + if info, err := os.Stat(path); err == nil && info != nil && !info.IsDir() { + return path + } + } + return "" +} + func findAIServerScriptDir() (string, error) { cwd, _ := os.Getwd() @@ -544,7 +576,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { // Modell ebenfalls standardmäßig aus generated/training nehmen, // aber YOLO_MODEL darf weiterhin extern überschrieben werden. - if strings.TrimSpace(os.Getenv("YOLO_MODEL")) == "" { + if strings.TrimSpace(os.Getenv("YOLO_MODEL")) == "" && defaultModel.EffectiveExists { env = upsertEnv(env, "YOLO_MODEL", defaultModelPath) } diff --git a/backend/training.go b/backend/training.go index c77b792..779e1c3 100644 --- a/backend/training.go +++ b/backend/training.go @@ -2557,7 +2557,38 @@ func trainingCancelHandler(w http.ResponseWriter, r *http.Request) { } func trainingRunJob(ctx context.Context, root string, count int) { + if err := ensureMLPythonSetup(ctx); err != nil { + if errors.Is(err, context.Canceled) { + trainingFinishCancelled(root) + return + } + + appLogln("ML-Python setup for training failed:", err) + trainingSetJobStatus(func(s *TrainingJobStatus) { + finishedAt := time.Now().UTC() + var durationMs int64 + if startedAt, parseErr := time.Parse(time.RFC3339, strings.TrimSpace(s.StartedAt)); parseErr == nil { + durationMs = finishedAt.Sub(startedAt).Milliseconds() + if durationMs < 0 { + durationMs = 0 + } + } + + s.Running = false + s.Progress = 100 + s.Step = "Training fehlgeschlagen." + s.Message = "ML-Python-Umgebung konnte nicht vorbereitet werden." + s.Error = err.Error() + s.FinishedAt = finishedAt.Format(time.RFC3339) + s.DurationMs = durationMs + s.PreviewURL = "" + }) + trainingClearJobCancel() + return + } + python := trainingPythonExe() + appLogln("ML-Python für Training:", python) cleanOutput := func(text string) string { out := strings.TrimSpace(text) @@ -6504,7 +6535,7 @@ func trainingPythonExe() string { if v != "" { return v } - return "python" + return aiServerPythonPath() } func trainingProjectRoot() string {