added videomae

This commit is contained in:
Linrador 2026-06-22 15:22:29 +02:00
parent 9d3caeca86
commit 0a6fefa663
9 changed files with 2019 additions and 12 deletions

View File

@ -70,6 +70,15 @@ def existing_file(path: Path) -> Optional[Path]:
return None return None
def existing_model_dir(path: Path) -> Optional[Path]:
try:
if path.exists() and path.is_dir() and existing_file(path / "config.json"):
return path
except OSError:
pass
return None
def resolve_training_root() -> Path: def resolve_training_root() -> Path:
env_root = os.environ.get("TRAINING_ROOT", "").strip() env_root = os.environ.get("TRAINING_ROOT", "").strip()
if env_root: if env_root:
@ -95,6 +104,7 @@ def resolve_training_root() -> Path:
if ( if (
existing_file(root / "detection_labels.json") existing_file(root / "detection_labels.json")
or existing_file(root / "detector" / "model" / "best.pt") or existing_file(root / "detector" / "model" / "best.pt")
or existing_model_dir(root / "videomae" / "model")
): ):
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
return root.resolve() return root.resolve()
@ -108,6 +118,7 @@ def resolve_training_root() -> Path:
TRAINING_ROOT = resolve_training_root() TRAINING_ROOT = resolve_training_root()
DEFAULT_MODEL_PATH = TRAINING_ROOT / "detector" / "model" / "best.pt" DEFAULT_MODEL_PATH = TRAINING_ROOT / "detector" / "model" / "best.pt"
DEFAULT_POSE_MODEL_PATH = TRAINING_ROOT / "pose" / "model" / "best.pt" DEFAULT_POSE_MODEL_PATH = TRAINING_ROOT / "pose" / "model" / "best.pt"
DEFAULT_VIDEOMAE_MODEL_PATH = TRAINING_ROOT / "videomae" / "model"
def resolve_detection_labels_path() -> Path: def resolve_detection_labels_path() -> Path:
@ -153,6 +164,20 @@ def resolve_pose_model_path() -> Optional[Path]:
return None return None
def resolve_videomae_model_path() -> Optional[Path]:
env_path = os.environ.get("VIDEOMAE_MODEL", "").strip()
if env_path:
p = Path(env_path).expanduser().resolve()
if existing_model_dir(p):
return p
raise RuntimeError(f"VIDEOMAE_MODEL not found: {p}")
if existing_model_dir(DEFAULT_VIDEOMAE_MODEL_PATH):
return DEFAULT_VIDEOMAE_MODEL_PATH.resolve()
return None
# Server darf auch ohne Labels/Model starten. # Server darf auch ohne Labels/Model starten.
DETECTION_LABELS_PATH: Optional[Path] = None DETECTION_LABELS_PATH: Optional[Path] = None
@ -178,6 +203,9 @@ _MODEL_PATH = ""
_MODEL_ERROR = "" _MODEL_ERROR = ""
_POSE_MODEL_PATH = "" _POSE_MODEL_PATH = ""
_POSE_MODEL_ERROR = "" _POSE_MODEL_ERROR = ""
_VIDEOMAE_MODEL_PATH = ""
_VIDEOMAE_MODEL_ERROR = ""
_VIDEOMAE_DEVICE_ACTIVE = ""
_LABEL_ERROR = "" _LABEL_ERROR = ""
_DEVICE = os.environ.get("YOLO_DEVICE", "") _DEVICE = os.environ.get("YOLO_DEVICE", "")
@ -193,9 +221,13 @@ _POSITION_CONTEXT_OVERRIDE_MARGIN = float(os.environ.get("YOLO_POSITION_CONTEXT_
_BATCH = int(os.environ.get("YOLO_BATCH", "16")) _BATCH = int(os.environ.get("YOLO_BATCH", "16"))
_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640")) _IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640"))
_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"} _HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"}
_VIDEOMAE_DEVICE = os.environ.get("VIDEOMAE_DEVICE", "auto")
_VIDEOMAE_NUM_FRAMES = int(os.environ.get("VIDEOMAE_NUM_FRAMES", "16"))
model = None model = None
pose_model = None pose_model = None
videomae_model = None
videomae_processor = None
app = FastAPI() app = FastAPI()
@ -243,6 +275,18 @@ class PredictBatchRequest(BaseModel):
model: Optional[str] = None model: Optional[str] = None
class PositionClipItem(BaseModel):
time: float = 0.0
start: float = 0.0
end: float = 0.0
paths: List[str]
class PredictPositionClipsRequest(BaseModel):
clips: List[PositionClipItem]
numFrames: int = 16
def empty_prediction(source: str = "model_missing") -> dict: def empty_prediction(source: str = "model_missing") -> dict:
return { return {
"modelAvailable": False, "modelAvailable": False,
@ -390,6 +434,155 @@ def get_pose_model():
return None return None
def get_videomae_components():
global videomae_model
global videomae_processor
global _VIDEOMAE_MODEL_PATH
global _VIDEOMAE_MODEL_ERROR
global _VIDEOMAE_DEVICE_ACTIVE
if videomae_model is not None and videomae_processor is not None:
return videomae_model, videomae_processor
try:
path = resolve_videomae_model_path()
if path is None:
videomae_model = None
videomae_processor = None
_VIDEOMAE_MODEL_PATH = ""
_VIDEOMAE_MODEL_ERROR = ""
_VIDEOMAE_DEVICE_ACTIVE = ""
return None, None
import torch
from transformers import AutoImageProcessor, VideoMAEForVideoClassification
if str(_VIDEOMAE_DEVICE).lower() == "auto":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
else:
device = torch.device(_VIDEOMAE_DEVICE)
processor = AutoImageProcessor.from_pretrained(path)
loaded = VideoMAEForVideoClassification.from_pretrained(path)
loaded.to(device)
loaded.eval()
videomae_model = loaded
videomae_processor = processor
_VIDEOMAE_MODEL_PATH = str(path)
_VIDEOMAE_MODEL_ERROR = ""
_VIDEOMAE_DEVICE_ACTIVE = str(device)
return videomae_model, videomae_processor
except Exception as exc:
videomae_model = None
videomae_processor = None
_VIDEOMAE_MODEL_PATH = ""
_VIDEOMAE_DEVICE_ACTIVE = ""
_VIDEOMAE_MODEL_ERROR = str(exc)
return None, None
def resample_values(values: list, count: int) -> list:
if not values:
return []
if count <= 1:
return [values[0]]
if len(values) == 1:
return [values[0] for _ in range(count)]
last = len(values) - 1
return [
values[int(round((i * last) / max(1, count - 1)))]
for i in range(count)
]
def load_videomae_clip_frames(paths: list[str], num_frames: int):
from PIL import Image
clean_paths = [
str(path).strip()
for path in paths or []
if str(path).strip()
]
selected = resample_values(clean_paths, max(2, int(num_frames or _VIDEOMAE_NUM_FRAMES or 16)))
frames = []
for path in selected:
with Image.open(path) as img:
frames.append(img.convert("RGB").copy())
return frames
def predict_videomae_clip(clip: PositionClipItem, num_frames: int) -> dict:
current_model, current_processor = get_videomae_components()
if current_model is None or current_processor is None:
return {
"time": float(clip.time or 0.0),
"start": float(clip.start or 0.0),
"end": float(clip.end or 0.0),
"sexPosition": NO_SEX_POSITION_LABEL,
"sexPositionScore": 0.0,
"source": "videomae_missing",
"scores": [],
}
import torch
frames = load_videomae_clip_frames(clip.paths, num_frames)
if not frames:
return {
"time": float(clip.time or 0.0),
"start": float(clip.start or 0.0),
"end": float(clip.end or 0.0),
"sexPosition": NO_SEX_POSITION_LABEL,
"sexPositionScore": 0.0,
"source": "videomae_no_frames",
"scores": [],
}
inputs = current_processor(frames, return_tensors="pt")
device = next(current_model.parameters()).device
pixel_values = inputs["pixel_values"].to(device)
with torch.no_grad():
logits = current_model(pixel_values=pixel_values).logits
probs = torch.softmax(logits, dim=-1)[0].detach().cpu().tolist()
id_to_label = getattr(current_model.config, "id2label", {}) or {}
scores = []
best_label = NO_SEX_POSITION_LABEL
best_score = 0.0
for idx, score in enumerate(probs):
raw_label = id_to_label.get(idx, id_to_label.get(str(idx), idx))
label = normalize_sex_position_label(raw_label)
score = clamp01(score)
scores.append({
"label": label,
"score": score,
})
if score > best_score:
best_label = label
best_score = score
scores.sort(key=lambda item: item["score"], reverse=True)
return {
"time": float(clip.time or 0.0),
"start": float(clip.start or 0.0),
"end": float(clip.end or 0.0),
"sexPosition": best_label,
"sexPositionScore": best_score,
"source": "videomae",
"scores": scores[:10],
}
def scored(label: str, score: float) -> dict: def scored(label: str, score: float) -> dict:
return { return {
"label": label, "label": label,
@ -1383,6 +1576,27 @@ def pose_model_status() -> dict:
} }
def videomae_model_status() -> dict:
try:
expected = resolve_videomae_model_path()
expected_text = str(expected) if expected else str(DEFAULT_VIDEOMAE_MODEL_PATH)
exists = expected is not None
error = _VIDEOMAE_MODEL_ERROR
except Exception as exc:
expected_text = str(DEFAULT_VIDEOMAE_MODEL_PATH)
exists = False
error = str(exc)
return {
"videoMAEModelAvailable": exists,
"videoMAEModelLoaded": videomae_model is not None,
"videoMAEModel": _VIDEOMAE_MODEL_PATH or (expected_text if exists else ""),
"videoMAEModelError": error,
"expectedVideoMAEModel": expected_text,
"videoMAEDevice": _VIDEOMAE_DEVICE_ACTIVE,
}
@app.post("/predict-batch", dependencies=[Depends(require_ai_server_auth)]) @app.post("/predict-batch", dependencies=[Depends(require_ai_server_auth)])
def predict_batch(req: PredictBatchRequest): def predict_batch(req: PredictBatchRequest):
paths = [str(path).strip() for path in req.paths if str(path).strip()] paths = [str(path).strip() for path in req.paths if str(path).strip()]
@ -1439,6 +1653,49 @@ def predict_batch(req: PredictBatchRequest):
} }
@app.post("/predict-position-clips", dependencies=[Depends(require_ai_server_auth)])
def predict_position_clips(req: PredictPositionClipsRequest):
clips = [clip for clip in req.clips if clip.paths]
if not clips:
return {
"ok": True,
"available": False,
"predictions": [],
"error": "no clips supplied",
}
current_model, current_processor = get_videomae_components()
if current_model is None or current_processor is None:
return {
"ok": True,
"available": False,
"predictions": [],
"error": _VIDEOMAE_MODEL_ERROR or f"VideoMAE model not found: {DEFAULT_VIDEOMAE_MODEL_PATH}",
}
predictions = []
for clip in clips:
try:
predictions.append(predict_videomae_clip(clip, int(req.numFrames or _VIDEOMAE_NUM_FRAMES or 16)))
except Exception as exc:
predictions.append({
"time": float(clip.time or 0.0),
"start": float(clip.start or 0.0),
"end": float(clip.end or 0.0),
"sexPosition": NO_SEX_POSITION_LABEL,
"sexPositionScore": 0.0,
"source": "videomae_predict_failed",
"error": repr(exc),
"scores": [],
})
return {
"ok": True,
"available": True,
"predictions": predictions,
}
@app.get("/health", dependencies=[Depends(require_ai_server_auth)]) @app.get("/health", dependencies=[Depends(require_ai_server_auth)])
def health(): def health():
current_model = get_model() current_model = get_model()
@ -1459,24 +1716,35 @@ def health():
} }
status_payload.update(pose_model_status()) status_payload.update(pose_model_status())
status_payload.update(videomae_model_status())
return status_payload return status_payload
@app.post("/reload", dependencies=[Depends(require_ai_server_auth)]) @app.post("/reload", dependencies=[Depends(require_ai_server_auth)])
def reload_model(): def reload_model():
global model global model
global pose_model global pose_model
global videomae_model
global videomae_processor
global _MODEL_PATH global _MODEL_PATH
global _MODEL_ERROR global _MODEL_ERROR
global _POSE_MODEL_PATH global _POSE_MODEL_PATH
global _POSE_MODEL_ERROR global _POSE_MODEL_ERROR
global _VIDEOMAE_MODEL_PATH
global _VIDEOMAE_MODEL_ERROR
global _VIDEOMAE_DEVICE_ACTIVE
global DETECTION_LABELS_PATH global DETECTION_LABELS_PATH
model = None model = None
pose_model = None pose_model = None
videomae_model = None
videomae_processor = None
_MODEL_PATH = "" _MODEL_PATH = ""
_MODEL_ERROR = "" _MODEL_ERROR = ""
_POSE_MODEL_PATH = "" _POSE_MODEL_PATH = ""
_POSE_MODEL_ERROR = "" _POSE_MODEL_ERROR = ""
_VIDEOMAE_MODEL_PATH = ""
_VIDEOMAE_MODEL_ERROR = ""
_VIDEOMAE_DEVICE_ACTIVE = ""
DETECTION_LABELS_PATH = None DETECTION_LABELS_PATH = None
current_model = get_model() current_model = get_model()
@ -1497,4 +1765,5 @@ def reload_model():
} }
status_payload.update(pose_model_status()) status_payload.update(pose_model_status())
status_payload.update(videomae_model_status())
return status_payload return status_payload

View File

@ -106,6 +106,7 @@ type analyzePositionEvidence struct {
PersonCount int PersonCount int
HasPose bool HasPose bool
HasContext bool HasContext bool
HasClip bool
} }
func analyzeVideoFrameFilter(intervalSeconds int) string { func analyzeVideoFrameFilter(intervalSeconds int) string {
@ -1683,7 +1684,11 @@ func analyzePositionEvidenceFromPrediction(
func analyzePositionEvidenceWeight(item analyzePositionEvidence) float64 { func analyzePositionEvidenceWeight(item analyzePositionEvidence) float64 {
weight := 1.0 weight := 1.0
if item.HasPose && item.HasContext { if item.HasClip && item.HasPose {
weight = 1.28
} else if item.HasClip {
weight = 1.18
} else if item.HasPose && item.HasContext {
weight = 1.15 weight = 1.15
} else if item.HasPose { } else if item.HasPose {
weight = 1.0 weight = 1.0
@ -1733,6 +1738,7 @@ func buildClipPositionHitsFromEvidence(
Count int Count int
PoseCount int PoseCount int
ContextCount int ContextCount int
ClipCount int
Start float64 Start float64
End float64 End float64
Marker float64 Marker float64
@ -1795,6 +1801,9 @@ func buildClipPositionHitsFromEvidence(
if item.HasContext { if item.HasContext {
agg.ContextCount++ agg.ContextCount++
} }
if item.HasClip {
agg.ClipCount++
}
if item.Time < agg.Start { if item.Time < agg.Start {
agg.Start = item.Time agg.Start = item.Time
} }
@ -1820,12 +1829,16 @@ func buildClipPositionHitsFromEvidence(
sourceBonus := 0.0 sourceBonus := 0.0
if agg.PoseCount > 0 && agg.ContextCount > 0 { if agg.PoseCount > 0 && agg.ContextCount > 0 {
sourceBonus = 0.04 sourceBonus = 0.04
} else if agg.ClipCount > 0 && (agg.PoseCount > 0 || agg.ContextCount > 0) {
sourceBonus = 0.04
} else if agg.ClipCount > 0 {
sourceBonus = 0.03
} else if agg.PoseCount > 0 { } else if agg.PoseCount > 0 {
sourceBonus = 0.02 sourceBonus = 0.02
} }
score := clamp01(avg*(0.86+0.14*stability) + sourceBonus) score := clamp01(avg*(0.86+0.14*stability) + sourceBonus)
if agg.PoseCount == 0 { if agg.PoseCount == 0 && agg.ClipCount == 0 {
score = math.Min(score, trainingPositionContextMaxScore) score = math.Min(score, trainingPositionContextMaxScore)
} }
@ -2292,6 +2305,14 @@ func analyzeVideoFromFramesForGoal(
) )
} }
highlightHits, positionEvidence = applyVideoMAEPositionClipsForAnalyze(
ctx,
samples,
durationSec,
highlightHits,
positionEvidence,
)
highlightHits = append( highlightHits = append(
highlightHits, highlightHits,
buildClipPositionHitsFromEvidence(positionEvidence, durationSec)..., buildClipPositionHitsFromEvidence(positionEvidence, durationSec)...,
@ -2349,6 +2370,14 @@ func analyzeVideoFromFramesForGoal(
) )
} }
highlightHits, positionEvidence = applyVideoMAEPositionClipsForAnalyze(
ctx,
samples,
durationSec,
highlightHits,
positionEvidence,
)
highlightHits = append( highlightHits = append(
highlightHits, highlightHits,
buildClipPositionHitsFromEvidence(positionEvidence, durationSec)..., buildClipPositionHitsFromEvidence(positionEvidence, durationSec)...,

271
backend/analyze_videomae.go Normal file
View File

@ -0,0 +1,271 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"strings"
"time"
)
const (
analyzeVideoMAEClipWindowSeconds = 4.0
analyzeVideoMAEClipStrideSeconds = 2.0
analyzeVideoMAEMinScore = 0.34
analyzeVideoMAERequestBatchSize = 48
)
type analyzeVideoMAEClipReqItem struct {
Time float64 `json:"time"`
Start float64 `json:"start"`
End float64 `json:"end"`
Paths []string `json:"paths"`
}
type analyzeVideoMAEClipPredictReq struct {
Clips []analyzeVideoMAEClipReqItem `json:"clips"`
NumFrames int `json:"numFrames,omitempty"`
}
type analyzeVideoMAEClipPrediction struct {
Time float64 `json:"time"`
Start float64 `json:"start"`
End float64 `json:"end"`
SexPosition string `json:"sexPosition"`
SexPositionScore float64 `json:"sexPositionScore"`
Source string `json:"source,omitempty"`
Scores []TrainingScoredLabel `json:"scores,omitempty"`
}
type analyzeVideoMAEClipPredictResp struct {
OK bool `json:"ok"`
Available bool `json:"available"`
Predictions []analyzeVideoMAEClipPrediction `json:"predictions"`
Error string `json:"error,omitempty"`
}
func analyzeVideoMAEEnabled() bool {
raw := strings.ToLower(strings.TrimSpace(os.Getenv("VIDEOMAE_ANALYZE_ENABLED")))
return raw == "" || raw == "1" || raw == "true" || raw == "yes" || raw == "on"
}
func buildAnalyzeVideoMAEClips(
samples []videoFrameSample,
duration float64,
) []analyzeVideoMAEClipReqItem {
if len(samples) == 0 || duration <= 0 {
return []analyzeVideoMAEClipReqItem{}
}
clips := []analyzeVideoMAEClipReqItem{}
halfWindow := analyzeVideoMAEClipWindowSeconds / 2
if halfWindow <= 0 {
halfWindow = 2
}
lastCenter := -math.MaxFloat64
for _, sample := range samples {
center := math.Max(0, sample.Time)
if len(clips) > 0 && center-lastCenter < analyzeVideoMAEClipStrideSeconds-0.001 {
continue
}
start := math.Max(0, center-halfWindow)
end := center + halfWindow
if duration > 0 {
end = math.Min(duration, end)
}
if end <= start {
end = math.Min(duration, start+math.Max(1, float64(analyzeVideoFrameIntervalSeconds)))
}
paths := []string{}
for _, candidate := range samples {
if candidate.Time < start-0.001 || candidate.Time > end+0.001 {
continue
}
path := strings.TrimSpace(candidate.Path)
if path != "" {
paths = append(paths, path)
}
}
if len(paths) == 0 {
continue
}
clips = append(clips, analyzeVideoMAEClipReqItem{
Time: center,
Start: start,
End: end,
Paths: paths,
})
lastCenter = center
}
return clips
}
func predictVideoMAEPositionClipsForAnalyze(
ctx context.Context,
clips []analyzeVideoMAEClipReqItem,
) ([]analyzeVideoMAEClipPrediction, error) {
if len(clips) == 0 {
return []analyzeVideoMAEClipPrediction{}, nil
}
if !trainingRecognitionEnabled() {
return []analyzeVideoMAEClipPrediction{}, nil
}
out := []analyzeVideoMAEClipPrediction{}
for start := 0; start < len(clips); start += analyzeVideoMAERequestBatchSize {
end := start + analyzeVideoMAERequestBatchSize
if end > len(clips) {
end = len(clips)
}
payload := analyzeVideoMAEClipPredictReq{
Clips: clips[start:end],
NumFrames: trainingVideoMAENumFrames,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
analyzeAIServerURL()+"/predict-position-clips",
bytes.NewReader(body),
)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
addAIServerAuth(req)
client := &http.Client{
Timeout: 180 * time.Second,
}
res, err := client.Do(req)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return nil, err
}
rawBody, readErr := io.ReadAll(res.Body)
_ = res.Body.Close()
if readErr != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return nil, readErr
}
var parsed analyzeVideoMAEClipPredictResp
if err := json.Unmarshal(rawBody, &parsed); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return nil, fmt.Errorf("AI server VideoMAE JSON ungueltig: HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(rawBody)))
}
if res.StatusCode < 200 || res.StatusCode >= 300 || !parsed.OK {
msg := strings.TrimSpace(parsed.Error)
if msg == "" {
msg = fmt.Sprintf("AI server VideoMAE HTTP %d", res.StatusCode)
}
return nil, fmt.Errorf("%s", msg)
}
if !parsed.Available {
return out, nil
}
out = append(out, parsed.Predictions...)
}
return out, nil
}
func applyVideoMAEPositionClipsForAnalyze(
ctx context.Context,
samples []videoFrameSample,
duration float64,
highlightHits []analyzeHit,
positionEvidence []analyzePositionEvidence,
) ([]analyzeHit, []analyzePositionEvidence) {
if !analyzeVideoMAEEnabled() {
return highlightHits, positionEvidence
}
clips := buildAnalyzeVideoMAEClips(samples, duration)
if len(clips) == 0 {
return highlightHits, positionEvidence
}
predictions, err := predictVideoMAEPositionClipsForAnalyze(ctx, clips)
if err != nil {
if ctx.Err() == nil {
appLogln("VideoMAE Clip-Analyse uebersprungen:", err)
}
return highlightHits, positionEvidence
}
for _, pred := range predictions {
label := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if isNoSexPositionLabel(label) || !isKnownPositionLabel(label) {
continue
}
score := clamp01(pred.SexPositionScore)
if score < analyzeVideoMAEMinScore {
continue
}
start := math.Max(0, pred.Start)
end := pred.End
if duration > 0 {
end = math.Min(duration, end)
}
if end <= start {
end = math.Min(duration, start+math.Max(1, float64(analyzeVideoFrameIntervalSeconds)))
}
source := strings.ToLower(strings.TrimSpace(pred.Source))
if source == "" {
source = "videomae"
}
highlightHits = append(highlightHits, analyzeHit{
Time: pred.Time,
Label: "position:" + label,
Score: score,
Start: start,
End: end,
})
positionEvidence = append(positionEvidence, analyzePositionEvidence{
Time: pred.Time,
Label: label,
Score: score,
Source: source,
HasClip: true,
})
}
return highlightHits, positionEvidence
}

View File

@ -32,6 +32,8 @@ func trainingEmbeddedMLDir() (string, error) {
"detection_labels.json", "detection_labels.json",
"predict_pose_model.py", "predict_pose_model.py",
"train_pose_model.py", "train_pose_model.py",
"predict_videomae_model.py",
"train_videomae_model.py",
} }
// Falls du die alten Scene-Skripte noch embedded hast, kannst du sie optional mitkopieren. // Falls du die alten Scene-Skripte noch embedded hast, kannst du sie optional mitkopieren.

View File

@ -0,0 +1,174 @@
import argparse
import json
from pathlib import Path
import torch
from PIL import Image
from transformers import AutoImageProcessor, VideoMAEForVideoClassification
def clamp01(value):
try:
n = float(value)
except Exception:
return 0.0
return max(0.0, min(1.0, n))
def existing_model_dir(path: Path):
try:
if path.exists() and path.is_dir() and (path / "config.json").exists():
return path
except Exception:
pass
return None
def resolve_model_path(root: Path, requested: str):
requested = str(requested or "").strip()
if requested:
p = existing_model_dir(Path(requested).expanduser().resolve())
if p:
return p, "videomae_model"
return Path(requested).expanduser(), "videomae_missing"
trained = root / "videomae" / "model"
p = existing_model_dir(trained)
if p:
return p, "videomae_clip"
return trained, "videomae_missing"
def resample_values(values: list, count: int) -> list:
if not values:
return []
if len(values) == 1:
return [values[0] for _ in range(count)]
if count <= 1:
return [values[0]]
last = len(values) - 1
return [values[int(round((i * last) / max(1, count - 1)))] for i in range(count)]
def load_frames(paths: list[str], num_frames: int):
selected = resample_values(paths, num_frames)
frames = []
for path in selected:
with Image.open(path) as img:
frames.append(img.convert("RGB").copy())
return frames
def frame_paths_from_args(args):
paths = []
if args.frames_json:
with Path(args.frames_json).open("r", encoding="utf-8") as f:
data = json.load(f)
paths.extend(str(p) for p in data)
if args.clip_dir:
clip_dir = Path(args.clip_dir)
paths.extend(
str(p) for p in sorted(clip_dir.iterdir())
if p.is_file() and p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}
)
paths.extend(str(p) for p in args.frames)
return [p for p in paths if p.strip()]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root", required=True)
parser.add_argument("--model", default="")
parser.add_argument("--clip-dir", default="")
parser.add_argument("--frames-json", default="")
parser.add_argument("--frames", nargs="*", default=[])
parser.add_argument("--num-frames", type=int, default=16)
parser.add_argument("--device", default="auto")
args = parser.parse_args()
root = Path(args.root).resolve()
model_path, model_source = resolve_model_path(root, args.model)
if not existing_model_dir(model_path):
print(json.dumps({
"available": False,
"source": model_source,
"modelPath": str(model_path),
"sexPosition": "keine",
"sexPositionScore": 0.0,
"scores": [],
}, ensure_ascii=False))
return
paths = frame_paths_from_args(args)
if not paths:
print(json.dumps({
"available": False,
"source": "videomae_no_frames",
"modelPath": str(model_path),
"sexPosition": "keine",
"sexPositionScore": 0.0,
"scores": [],
}, ensure_ascii=False))
return
if str(args.device).lower() == "auto":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
else:
device = torch.device(args.device)
try:
processor = AutoImageProcessor.from_pretrained(model_path)
model = VideoMAEForVideoClassification.from_pretrained(model_path).to(device)
model.eval()
frames = load_frames(paths, max(2, int(args.num_frames or 16)))
inputs = processor(frames, return_tensors="pt")
pixel_values = inputs["pixel_values"].to(device)
with torch.no_grad():
logits = model(pixel_values=pixel_values).logits
probs = torch.softmax(logits, dim=-1)[0].detach().cpu().tolist()
id_to_label = getattr(model.config, "id2label", {}) or {}
scores = []
best_label = "keine"
best_score = 0.0
for idx, score in enumerate(probs):
label = str(id_to_label.get(idx, id_to_label.get(str(idx), idx))).strip()
score = clamp01(score)
scores.append({"label": label, "score": score})
if score > best_score:
best_label = label
best_score = score
scores.sort(key=lambda item: item["score"], reverse=True)
print(json.dumps({
"available": True,
"source": model_source,
"modelPath": str(model_path),
"device": str(device),
"sexPosition": best_label,
"sexPositionScore": best_score,
"scores": scores[:10],
}, ensure_ascii=False))
except Exception as exc:
print(json.dumps({
"available": False,
"source": "videomae_predict_failed",
"modelPath": str(model_path),
"error": repr(exc),
"sexPosition": "keine",
"sexPositionScore": 0.0,
"scores": [],
}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,397 @@
import argparse
import json
import math
import shutil
from datetime import datetime, timezone
from pathlib import Path
import torch
from PIL import Image
from torch.utils.data import DataLoader, Dataset
from transformers import AutoImageProcessor, VideoMAEForVideoClassification
DEFAULT_BASE_MODEL = "MCG-NJU/videomae-base-finetuned-kinetics"
def emit_progress(stage, progress, message="", **extra):
out = {
"type": "progress",
"stage": stage,
"progress": max(0.0, min(1.0, float(progress))),
"message": message,
}
out.update(extra)
print(json.dumps(out, ensure_ascii=False), flush=True)
def safe_int(value, fallback):
try:
return int(value)
except Exception:
return fallback
def safe_float(value, fallback):
try:
return float(value)
except Exception:
return fallback
def load_labels(dataset_root: Path) -> list[str]:
path = dataset_root / "labels.json"
if not path.exists():
raise SystemExit(f"labels.json not found: {path}")
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
labels = data.get("labels", [])
out = []
seen = set()
for value in labels:
label = str(value or "").strip()
if not label or label in seen:
continue
seen.add(label)
out.append(label)
if len(out) < 2:
raise SystemExit("VideoMAE needs at least two labels")
return out
def load_manifest(dataset_root: Path, label_to_id: dict[str, int]) -> list[dict]:
path = dataset_root / "manifest.jsonl"
if not path.exists():
raise SystemExit(f"manifest.jsonl not found: {path}")
entries = []
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
except Exception:
continue
label = str(item.get("label") or "").strip()
clip_dir = Path(str(item.get("clipDir") or "")).expanduser()
split = str(item.get("split") or "").strip().lower()
if label not in label_to_id or split not in {"train", "val"}:
continue
if not clip_dir.exists() or not clip_dir.is_dir():
continue
frames = sorted(
p for p in clip_dir.iterdir()
if p.is_file() and p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}
)
if not frames:
continue
item["label"] = label
item["split"] = split
item["clipDir"] = str(clip_dir)
item["frames"] = [str(p) for p in frames]
entries.append(item)
return entries
def resample_values(values: list, count: int) -> list:
if not values:
return []
if count <= 1:
return [values[0]]
if len(values) == 1:
return [values[0] for _ in range(count)]
out = []
last = len(values) - 1
for i in range(count):
idx = int(round((i * last) / max(1, count - 1)))
out.append(values[idx])
return out
def load_clip_frames(paths: list[str], num_frames: int) -> list[Image.Image]:
selected = resample_values(paths, num_frames)
frames = []
for path in selected:
with Image.open(path) as img:
frames.append(img.convert("RGB").copy())
return frames
class VideoMAEClipDataset(Dataset):
def __init__(self, entries, label_to_id, image_processor, num_frames):
self.entries = entries
self.label_to_id = label_to_id
self.image_processor = image_processor
self.num_frames = num_frames
def __len__(self):
return len(self.entries)
def __getitem__(self, idx):
item = self.entries[idx]
frames = load_clip_frames(item["frames"], self.num_frames)
inputs = self.image_processor(frames, return_tensors="pt")
pixel_values = inputs["pixel_values"].squeeze(0)
label_id = self.label_to_id[item["label"]]
return {
"pixel_values": pixel_values,
"labels": torch.tensor(label_id, dtype=torch.long),
"sample_id": str(item.get("sampleId") or ""),
}
def collate_batch(batch):
return {
"pixel_values": torch.stack([item["pixel_values"] for item in batch]),
"labels": torch.stack([item["labels"] for item in batch]),
"sample_ids": [item["sample_id"] for item in batch],
}
def evaluate(model, loader, device):
model.eval()
total = 0
correct = 0
loss_sum = 0.0
with torch.no_grad():
for batch in loader:
pixel_values = batch["pixel_values"].to(device)
labels = batch["labels"].to(device)
outputs = model(pixel_values=pixel_values, labels=labels)
logits = outputs.logits
loss = outputs.loss
preds = logits.argmax(dim=-1)
total += int(labels.numel())
correct += int((preds == labels).sum().item())
loss_sum += float(loss.item()) * int(labels.numel())
if total <= 0:
return 0.0, 0.0
return correct / total, loss_sum / total
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root", required=True)
parser.add_argument("--base", default=DEFAULT_BASE_MODEL)
parser.add_argument("--epochs", default="8")
parser.add_argument("--batch-size", default="2")
parser.add_argument("--lr", default="5e-5")
parser.add_argument("--device", default="auto")
parser.add_argument("--workers", default="0")
parser.add_argument("--num-frames", default="16")
parser.add_argument("--freeze-backbone", action="store_true")
args = parser.parse_args()
root = Path(args.root).resolve()
dataset_root = root / "videomae" / "dataset"
out_dir = root / "videomae" / "model"
tmp_dir = root / "videomae" / "runs" / "model_tmp"
epochs = max(1, safe_int(args.epochs, 8))
batch_size = max(1, safe_int(args.batch_size, 2))
workers = max(0, safe_int(args.workers, 0))
num_frames = max(2, safe_int(args.num_frames, 16))
lr = max(1e-7, safe_float(args.lr, 5e-5))
if str(args.device).lower() == "auto":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
else:
device = torch.device(args.device)
labels = load_labels(dataset_root)
label_to_id = {label: i for i, label in enumerate(labels)}
id_to_label = {i: label for label, i in label_to_id.items()}
entries = load_manifest(dataset_root, label_to_id)
train_entries = [item for item in entries if item["split"] == "train"]
val_entries = [item for item in entries if item["split"] == "val"]
emit_progress(
"videomae",
0.01,
"VideoMAE-Dataset wird geprueft...",
trainSamples=len(train_entries),
valSamples=len(val_entries),
epochs=epochs,
device=str(device),
)
if not train_entries:
raise SystemExit("no VideoMAE train clips found")
if not val_entries:
raise SystemExit("no VideoMAE val clips found")
emit_progress(
"videomae",
0.03,
"VideoMAE-Basismodell wird geladen...",
base=args.base,
labels=len(labels),
device=str(device),
)
image_processor = AutoImageProcessor.from_pretrained(args.base)
model = VideoMAEForVideoClassification.from_pretrained(
args.base,
num_labels=len(labels),
label2id=label_to_id,
id2label=id_to_label,
ignore_mismatched_sizes=True,
)
if args.freeze_backbone and hasattr(model, "videomae"):
for param in model.videomae.parameters():
param.requires_grad = False
model.to(device)
train_ds = VideoMAEClipDataset(train_entries, label_to_id, image_processor, num_frames)
val_ds = VideoMAEClipDataset(val_entries, label_to_id, image_processor, num_frames)
train_loader = DataLoader(
train_ds,
batch_size=batch_size,
shuffle=True,
num_workers=workers,
collate_fn=collate_batch,
)
val_loader = DataLoader(
val_ds,
batch_size=batch_size,
shuffle=False,
num_workers=workers,
collate_fn=collate_batch,
)
optimizer = torch.optim.AdamW(
[p for p in model.parameters() if p.requires_grad],
lr=lr,
)
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
tmp_dir.mkdir(parents=True, exist_ok=True)
best_accuracy = -1.0
best_loss = math.inf
best_epoch = 0
for epoch in range(1, epochs + 1):
model.train()
running_loss = 0.0
seen = 0
total_batches = max(1, len(train_loader))
for batch_idx, batch in enumerate(train_loader, start=1):
pixel_values = batch["pixel_values"].to(device)
labels_tensor = batch["labels"].to(device)
optimizer.zero_grad(set_to_none=True)
outputs = model(pixel_values=pixel_values, labels=labels_tensor)
loss = outputs.loss
loss.backward()
optimizer.step()
batch_size_seen = int(labels_tensor.numel())
seen += batch_size_seen
running_loss += float(loss.item()) * batch_size_seen
completed = (epoch - 1) + min(1.0, batch_idx / total_batches)
emit_progress(
"videomae",
0.04 + 0.84 * (completed / max(1, epochs)),
"VideoMAE trainiert...",
epoch=epoch,
epochs=epochs,
sampleId=(batch["sample_ids"][0] if batch["sample_ids"] else ""),
trainSamples=len(train_entries),
valSamples=len(val_entries),
device=str(device),
loss=(running_loss / max(1, seen)),
)
val_accuracy, val_loss = evaluate(model, val_loader, device)
is_best = val_accuracy > best_accuracy or (
math.isclose(val_accuracy, best_accuracy) and val_loss < best_loss
)
if is_best:
best_accuracy = val_accuracy
best_loss = val_loss
best_epoch = epoch
model.save_pretrained(tmp_dir)
image_processor.save_pretrained(tmp_dir)
emit_progress(
"videomae",
0.88 + 0.08 * (epoch / max(1, epochs)),
"VideoMAE wird validiert...",
epoch=epoch,
epochs=epochs,
trainSamples=len(train_entries),
valSamples=len(val_entries),
device=str(device),
accuracy=val_accuracy,
loss=val_loss,
)
if best_epoch <= 0:
model.save_pretrained(tmp_dir)
image_processor.save_pretrained(tmp_dir)
best_epoch = epochs
status = {
"trainedAt": datetime.now(timezone.utc).isoformat(),
"epochs": epochs,
"bestEpoch": best_epoch,
"trainSamples": len(train_entries),
"valSamples": len(val_entries),
"numFrames": num_frames,
"baseModel": args.base,
"device": str(device),
"accuracy": best_accuracy if best_accuracy >= 0 else 0.0,
"loss": best_loss if math.isfinite(best_loss) else 0.0,
"labels": labels,
}
with (tmp_dir / "status.json").open("w", encoding="utf-8") as f:
json.dump(status, f, ensure_ascii=False, indent=2)
if out_dir.exists():
shutil.rmtree(out_dir)
shutil.copytree(tmp_dir, out_dir)
emit_progress(
"videomae",
1.0,
"VideoMAE-Training abgeschlossen.",
epoch=best_epoch,
epochs=epochs,
trainSamples=len(train_entries),
valSamples=len(val_entries),
device=str(device),
accuracy=status["accuracy"],
loss=status["loss"],
)
if __name__ == "__main__":
main()

View File

@ -229,6 +229,8 @@ type TrainingStatsResponse struct {
DetectorModelInfo *TrainingModelInfo `json:"detectorModelInfo,omitempty"` DetectorModelInfo *TrainingModelInfo `json:"detectorModelInfo,omitempty"`
PoseModelAvailable bool `json:"poseModelAvailable"` PoseModelAvailable bool `json:"poseModelAvailable"`
PoseModelInfo *TrainingModelInfo `json:"poseModelInfo,omitempty"` PoseModelInfo *TrainingModelInfo `json:"poseModelInfo,omitempty"`
VideoMAEModelAvailable bool `json:"videoMAEModelAvailable"`
VideoMAEModelInfo *TrainingModelInfo `json:"videoMAEModelInfo,omitempty"`
Confidence TrainingConfidence `json:"confidence"` Confidence TrainingConfidence `json:"confidence"`
Labels TrainingStatsLabels `json:"labels"` Labels TrainingStatsLabels `json:"labels"`
} }
@ -2377,6 +2379,10 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
trainingWriteError(w, http.StatusInternalServerError, err.Error()) trainingWriteError(w, http.StatusInternalServerError, err.Error())
return return
} }
if err := trainingEnsureVideoMAEDirs(root); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
if err := trainingEnsureDetectorValidationSample(root); err != nil { if err := trainingEnsureDetectorValidationSample(root); err != nil {
appLogln("⚠️ detector val sample ensure failed:", err) appLogln("⚠️ detector val sample ensure failed:", err)
@ -2412,6 +2418,7 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
poseValImages := filepath.Join(root, "pose", "dataset", "images", "val") poseValImages := filepath.Join(root, "pose", "dataset", "images", "val")
poseValLabels := filepath.Join(root, "pose", "dataset", "labels", "val") poseValLabels := filepath.Join(root, "pose", "dataset", "labels", "val")
poseDatasetYAML := filepath.Join(root, "pose", "dataset", "dataset.yaml") poseDatasetYAML := filepath.Join(root, "pose", "dataset", "dataset.yaml")
videoMAEManifest := trainingVideoMAEManifestPath(root)
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
@ -2419,6 +2426,23 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels) positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels)
poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels) poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels)
poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels) poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels)
videoMAETrainCount, videoMAEValCount := trainingCountVideoMAEManifestSamples(root)
videoMAEEligibleCount, _ := trainingCountVideoMAEEligibleAnnotations(root)
detectorDataReady := fileExistsNonEmpty(detectorDatasetYAML) &&
trainCount >= minDetectorTrainCount &&
valCount >= minDetectorValCount &&
positiveTrainCount > 0 &&
positiveValCount > 0
poseDataReady := fileExistsNonEmpty(poseDatasetYAML) &&
poseTrainCount >= minPoseTrainCount &&
poseValCount >= minPoseValCount
videoMAEDataReady := videoMAEEligibleCount >= minVideoMAETrainCount ||
(videoMAETrainCount >= minVideoMAETrainCount && videoMAEValCount >= minVideoMAEValCount)
if detectorDataReady || poseDataReady || videoMAEDataReady {
goto startTraining
}
if !fileExistsNonEmpty(detectorDatasetYAML) || if !fileExistsNonEmpty(detectorDatasetYAML) ||
trainCount < minDetectorTrainCount || trainCount < minDetectorTrainCount ||
@ -2458,6 +2482,7 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
startTraining:
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
trainingStartJob(cancel) trainingStartJob(cancel)
@ -2489,6 +2514,15 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
"datasetYAML": poseDatasetYAML, "datasetYAML": poseDatasetYAML,
"source": "yolo26_pose", "source": "yolo26_pose",
}, },
"videomae": map[string]any{
"eligibleCount": videoMAEEligibleCount,
"trainCount": videoMAETrainCount,
"valCount": videoMAEValCount,
"requiredTrain": minVideoMAETrainCount,
"requiredVal": minVideoMAEValCount,
"manifest": videoMAEManifest,
"source": "videomae_clip",
},
}) })
} }
@ -2549,6 +2583,8 @@ func trainingRunJob(ctx context.Context, root string, count int) {
detectorStatus := "skipped" detectorStatus := "skipped"
poseOutput := "" poseOutput := ""
poseStatus := "skipped" poseStatus := "skipped"
videoMAEOutput := ""
videoMAEStatus := "skipped"
detectorDatasetYAML := filepath.Join(root, "detector", "dataset", "dataset.yaml") detectorDatasetYAML := filepath.Join(root, "detector", "dataset", "dataset.yaml")
detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train") detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train")
@ -2702,7 +2738,7 @@ func trainingRunJob(ctx context.Context, root string, count int) {
return trainingHandleProgressLine( return trainingHandleProgressLine(
line, line,
62, 62,
98, 82,
"YOLO26 Pose wird trainiert...", "YOLO26 Pose wird trainiert...",
) )
}, },
@ -2749,6 +2785,104 @@ func trainingRunJob(ctx context.Context, root string, count int) {
poseOutputClean := cleanOutput(poseOutput) poseOutputClean := cleanOutput(poseOutput)
trainingSetJobStatus(func(s *TrainingJobStatus) {
if s.Progress < 84 {
s.Progress = 84
}
s.Step = "VideoMAE Clip-Daten werden aufgebaut..."
})
videoMAETrainCount, videoMAEValCount, videoMAEWritten, videoMAESyncErr :=
trainingSyncVideoMAEDataset(ctx, root)
if errors.Is(videoMAESyncErr, context.Canceled) || errors.Is(videoMAESyncErr, errTrainingCancelled) {
appLogln("VideoMAE dataset sync cancelled")
trainingFinishCancelled(root)
return
}
if videoMAESyncErr != nil {
videoMAEStatus = "failed"
videoMAEOutput = "VideoMAE-Dataset konnte nicht aufgebaut werden: " + videoMAESyncErr.Error()
appLogln(videoMAEOutput)
} else {
appLogf(
"VideoMAE samples synced: written=%d train=%d val=%d",
videoMAEWritten,
videoMAETrainCount,
videoMAEValCount,
)
}
if videoMAEStatus != "failed" &&
videoMAETrainCount >= minVideoMAETrainCount &&
videoMAEValCount >= minVideoMAEValCount {
trainingSetJobStatus(func(s *TrainingJobStatus) {
if s.Progress < 86 {
s.Progress = 86
}
s.Step = "VideoMAE Clip-Classifier wird trainiert..."
})
videoMAEScript := trainingScriptPath("train_videomae_model.py")
videoMAEArgs := []string{
"--root", root,
"--epochs", strconv.Itoa(trainingVideoMAEEpochs()),
"--batch-size", strconv.Itoa(trainingVideoMAEBatchSize()),
"--num-frames", strconv.Itoa(trainingVideoMAENumFrames),
}
if base := strings.TrimSpace(os.Getenv("VIDEOMAE_BASE_MODEL")); base != "" {
videoMAEArgs = append(videoMAEArgs, "--base", base)
}
videoMAEOut, videoMAEErr := trainingRunCommandStreaming(
ctx,
python,
videoMAEScript,
func(line string) bool {
return trainingHandleProgressLine(
line,
86,
98,
"VideoMAE Clip-Classifier wird trainiert...",
)
},
videoMAEArgs...,
)
if errors.Is(videoMAEErr, errTrainingCancelled) {
appLogln("VideoMAE training cancelled")
trainingFinishCancelled(root)
return
}
videoMAEOutput = videoMAEOut
videoMAEOutputClean := cleanOutput(videoMAEOutput)
if videoMAEErr != nil {
videoMAEStatus = "failed"
appLogln("VideoMAE training failed:", videoMAEErr)
if videoMAEOutputClean != "" {
appLogln("VideoMAE output:", videoMAEOutputClean)
}
} else {
videoMAEStatus = "trained"
if videoMAEOutputClean != "" {
appLogln("VideoMAE training:", videoMAEOutputClean)
}
}
} else if videoMAEStatus != "failed" {
videoMAEStatus = "skipped_no_videomae_data"
videoMAEOutput = fmt.Sprintf(
"VideoMAE uebersprungen: zu wenige Clip-Beispiele. Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.",
videoMAETrainCount,
videoMAEValCount,
minVideoMAETrainCount,
minVideoMAEValCount,
)
appLogln(videoMAEOutput)
}
videoMAEOutputClean := cleanOutput(videoMAEOutput)
message := "Training abgeschlossen." message := "Training abgeschlossen."
errorText := "" errorText := ""
@ -2791,7 +2925,23 @@ func trainingRunJob(ctx context.Context, root string, count int) {
errorText = message errorText = message
} }
if detectorStatus == "trained" { if videoMAEStatus == "trained" {
if !strings.Contains(message, "Training abgeschlossen.") {
message = "Training abgeschlossen. " + message
}
message += " VideoMAE wurde trainiert."
} else if videoMAEStatus == "skipped_no_videomae_data" {
if detectorStatus == "trained" || poseStatus == "trained" {
message += " VideoMAE wurde uebersprungen: zu wenige Clip-Beispiele."
}
} else if videoMAEStatus == "failed" {
message += " VideoMAE ist fehlgeschlagen."
if videoMAEOutputClean != "" {
message += " Grund: " + videoMAEOutputClean
}
}
if detectorStatus == "trained" || poseStatus == "trained" || videoMAEStatus == "trained" {
// Verlaufseintrag schreiben, solange die Job-Startzeit für die Dauer noch verfügbar ist. // Verlaufseintrag schreiben, solange die Job-Startzeit für die Dauer noch verfügbar ist.
trainingAppendRunHistory(root) trainingAppendRunHistory(root)
} }
@ -3109,6 +3259,10 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
trainingWriteError(w, http.StatusInternalServerError, err.Error()) trainingWriteError(w, http.StatusInternalServerError, err.Error())
return return
} }
if err := trainingEnsureVideoMAEDirs(root); err != nil {
trainingWriteError(w, http.StatusInternalServerError, err.Error())
return
}
if err := trainingEnsureDetectorValidationSample(root); err != nil { if err := trainingEnsureDetectorValidationSample(root); err != nil {
appLogln("⚠️ detector val sample ensure failed:", err) appLogln("⚠️ detector val sample ensure failed:", err)
@ -3133,6 +3287,8 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
poseValLabels := filepath.Join(root, "pose", "dataset", "labels", "val") poseValLabels := filepath.Join(root, "pose", "dataset", "labels", "val")
detectorModel := trainingResolveDetectorModel(root) detectorModel := trainingResolveDetectorModel(root)
poseModel := trainingResolvePoseModel(root) poseModel := trainingResolvePoseModel(root)
videoMAEModel := trainingResolveVideoMAEModel(root)
videoMAEManifest := trainingVideoMAEManifestPath(root)
trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels)
valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels)
@ -3140,6 +3296,8 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels) positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels)
poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels) poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels)
poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels) poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels)
videoMAETrainCount, videoMAEValCount := trainingCountVideoMAEManifestSamples(root)
videoMAEEligibleCount, _ := trainingCountVideoMAEEligibleAnnotations(root)
datasetReady := fileExistsNonEmpty(detectorDatasetYAML) datasetReady := fileExistsNonEmpty(detectorDatasetYAML)
detectorDataReady := datasetReady && detectorDataReady := datasetReady &&
@ -3151,8 +3309,13 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
poseDataReady := poseDatasetReady && poseDataReady := poseDatasetReady &&
poseTrainCount >= minPoseTrainCount && poseTrainCount >= minPoseTrainCount &&
poseValCount >= minPoseValCount poseValCount >= minPoseValCount
videoMAEDatasetReady := fileExistsNonEmpty(videoMAEManifest)
videoMAEDataReady := (videoMAETrainCount >= minVideoMAETrainCount &&
videoMAEValCount >= minVideoMAEValCount) ||
videoMAEEligibleCount >= minVideoMAETrainCount
canTrain := feedbackCount >= minTrainingFeedbackCount && detectorDataReady && poseDataReady canTrain := feedbackCount >= minTrainingFeedbackCount &&
(detectorDataReady || poseDataReady || videoMAEDataReady)
trainingWriteJSON(w, http.StatusOK, map[string]any{ trainingWriteJSON(w, http.StatusOK, map[string]any{
"ok": true, "ok": true,
@ -3215,13 +3378,14 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
}, },
"scene": map[string]any{ "scene": map[string]any{
"source": "disabled", "source": "videomae_clip",
"usesVideoMAE": true,
"usesSceneCLIP": false, "usesSceneCLIP": false,
"usesSceneKNN": false, "usesSceneKNN": false,
"usesResNet18KNN": false, "usesResNet18KNN": false,
"usesLogisticRegression": false, "usesLogisticRegression": false,
"predictsSexPosition": false, "predictsSexPosition": videoMAEModel.TrainedExists,
"predictsPeople": false, "predictsPeople": false,
"predictsGender": false, "predictsGender": false,
"predictsBodyParts": false, "predictsBodyParts": false,
@ -3230,17 +3394,27 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
"predictsBoxes": false, "predictsBoxes": false,
"feedbackCount": feedbackCount, "feedbackCount": feedbackCount,
"requiredCount": minTrainingFeedbackCount, "eligibleCount": videoMAEEligibleCount,
"dataReady": false, "trainCount": videoMAETrainCount,
"modelReady": false, "valCount": videoMAEValCount,
"requiredTrain": minVideoMAETrainCount,
"requiredVal": minVideoMAEValCount,
"requiredCount": minVideoMAETrainCount,
"datasetReady": videoMAEDatasetReady,
"manifest": videoMAEManifest,
"dataReady": videoMAEDataReady,
"modelReady": videoMAEModel.EffectiveExists,
"modelExists": videoMAEModel.EffectiveExists,
"modelPath": videoMAEModel.EffectivePath,
"modelSource": videoMAEModel.Source,
}, },
"pipeline": map[string]any{ "pipeline": map[string]any{
"variant": "YOLO26_ONLY", "variant": "YOLO26_VIDEO_CLIP_HYBRID",
"peopleSource": "yolo26_detector", "peopleSource": "yolo26_detector",
"genderSource": "yolo26_detector", "genderSource": "yolo26_detector",
"sexPositionSource": "yolo26_pose", "sexPositionSource": "yolo26_pose+box_context+videomae_clip",
"bodyPartsSource": "yolo26_detector", "bodyPartsSource": "yolo26_detector",
"objectsSource": "yolo26_detector", "objectsSource": "yolo26_detector",
"clothingSource": "yolo26_detector", "clothingSource": "yolo26_detector",
@ -3249,6 +3423,7 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) {
"usesSceneKNNForDetection": false, "usesSceneKNNForDetection": false,
"usesSceneCLIP": false, "usesSceneCLIP": false,
"usesSceneKNN": false, "usesSceneKNN": false,
"usesVideoMAE": true,
"usesYOLOForDetection": true, "usesYOLOForDetection": true,
"usesYOLOForSexPosition": true, "usesYOLOForSexPosition": true,
}, },
@ -3260,6 +3435,8 @@ func trainingApplyStatsModelInfo(root string, stats *TrainingStatsResponse) {
detectorInfo := trainingReadModelInfoFor(root, "detector") detectorInfo := trainingReadModelInfoFor(root, "detector")
poseAvailable := trainingStatsModelAvailableFor(root, "pose") poseAvailable := trainingStatsModelAvailableFor(root, "pose")
poseInfo := trainingReadModelInfoFor(root, "pose") poseInfo := trainingReadModelInfoFor(root, "pose")
videoMAEModel := trainingResolveVideoMAEModel(root)
videoMAEInfo := trainingReadModelInfoFor(root, "videomae")
// modelAvailable/modelInfo bleiben aus Kompatibilitaetsgruenden der Detector. // modelAvailable/modelInfo bleiben aus Kompatibilitaetsgruenden der Detector.
stats.ModelAvailable = detectorAvailable stats.ModelAvailable = detectorAvailable
@ -3268,6 +3445,8 @@ func trainingApplyStatsModelInfo(root string, stats *TrainingStatsResponse) {
stats.DetectorModelInfo = detectorInfo stats.DetectorModelInfo = detectorInfo
stats.PoseModelAvailable = poseAvailable stats.PoseModelAvailable = poseAvailable
stats.PoseModelInfo = poseInfo stats.PoseModelInfo = poseInfo
stats.VideoMAEModelAvailable = videoMAEModel.EffectiveExists
stats.VideoMAEModelInfo = videoMAEInfo
} }
func trainingStatsModelAvailable(root string) bool { func trainingStatsModelAvailable(root string) bool {
@ -3276,6 +3455,9 @@ func trainingStatsModelAvailable(root string) bool {
func trainingStatsModelAvailableFor(root string, kind string) bool { func trainingStatsModelAvailableFor(root string, kind string) bool {
modelPath := filepath.Join(root, kind, "model", "best.pt") modelPath := filepath.Join(root, kind, "model", "best.pt")
if kind == "videomae" {
modelPath = filepath.Join(root, kind, "model", "config.json")
}
return fileExistsNonEmpty(modelPath) return fileExistsNonEmpty(modelPath)
} }
@ -3288,6 +3470,9 @@ func trainingReadModelInfo(root string) *TrainingModelInfo {
func trainingReadModelInfoFor(root string, kind string) *TrainingModelInfo { func trainingReadModelInfoFor(root string, kind string) *TrainingModelInfo {
modelPath := filepath.Join(root, kind, "model", "best.pt") modelPath := filepath.Join(root, kind, "model", "best.pt")
if kind == "videomae" {
modelPath = filepath.Join(root, kind, "model", "config.json")
}
fi, err := os.Stat(modelPath) fi, err := os.Stat(modelPath)
if err != nil || fi.IsDir() || fi.Size() <= 0 { if err != nil || fi.IsDir() || fi.Size() <= 0 {

View File

@ -0,0 +1,610 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
)
const (
trainingVideoMAEClipSeconds = 4.0
trainingVideoMAENumFrames = 16
trainingVideoMAEFrameSize = 224
minVideoMAETrainCount = 5
minVideoMAEValCount = 1
)
func trainingVideoMAEEpochs() int {
raw := strings.TrimSpace(os.Getenv("TRAINING_VIDEOMAE_EPOCHS"))
if raw == "" {
return 8
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return 8
}
if n > 200 {
return 200
}
return n
}
func trainingVideoMAEBatchSize() int {
raw := strings.TrimSpace(os.Getenv("TRAINING_VIDEOMAE_BATCH"))
if raw == "" {
return 2
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return 2
}
if n > 32 {
return 32
}
return n
}
type trainingVideoMAEManifestEntry struct {
SampleID string `json:"sampleId"`
Split string `json:"split"`
Label string `json:"label"`
ClipDir string `json:"clipDir"`
SourcePath string `json:"sourcePath,omitempty"`
Second float64 `json:"second,omitempty"`
FrameCount int `json:"frameCount"`
}
func trainingResolveVideoMAEModel(root string) trainingModelResolution {
modelDir := filepath.Join(root, "videomae", "model")
configPath := filepath.Join(modelDir, "config.json")
if fileExistsNonEmpty(configPath) {
return trainingModelResolution{
BestPath: modelDir,
EffectivePath: modelDir,
Source: "videomae_clip",
TrainedExists: true,
EffectiveExists: true,
}
}
return trainingModelResolution{
BestPath: modelDir,
EffectivePath: modelDir,
Source: "videomae_missing",
TrainedExists: false,
EffectiveExists: false,
}
}
func trainingEnsureVideoMAEDirs(root string) error {
dirs := []string{
filepath.Join(root, "videomae"),
filepath.Join(root, "videomae", "dataset"),
filepath.Join(root, "videomae", "dataset", "clips"),
filepath.Join(root, "videomae", "dataset", "clips", "train"),
filepath.Join(root, "videomae", "dataset", "clips", "val"),
filepath.Join(root, "videomae", "model"),
filepath.Join(root, "videomae", "runs"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
}
return trainingWriteVideoMAELabelsFile(root)
}
func trainingVideoMAELabels() ([]string, error) {
grouped, err := trainingGroupedLabels()
if err != nil {
return nil, err
}
out := []string{trainingNoSexPositionLabel}
seen := map[string]bool{
trainingNoSexPositionLabel: true,
}
for _, value := range grouped.SexPositions {
label := normalizeSexPositionLabel(value)
if seen[label] {
continue
}
seen[label] = true
out = append(out, label)
}
return out, nil
}
func trainingVideoMAELabelSet() (map[string]bool, error) {
labels, err := trainingVideoMAELabels()
if err != nil {
return nil, err
}
out := map[string]bool{}
for _, label := range labels {
out[label] = true
}
return out, nil
}
func trainingWriteVideoMAELabelsFile(root string) error {
labels, err := trainingVideoMAELabels()
if err != nil {
return err
}
body, err := json.MarshalIndent(map[string]any{
"labels": labels,
}, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(root, "videomae", "dataset", "labels.json"), body, 0644)
}
func trainingVideoMAEManifestPath(root string) string {
return filepath.Join(root, "videomae", "dataset", "manifest.jsonl")
}
func trainingVideoMAELabelForAnnotation(item TrainingAnnotation) string {
if item.Negative {
return trainingNoSexPositionLabel
}
effective := trainingEffectiveCorrection(item)
label := normalizeSexPositionLabel(effective.SexPosition)
if isNoSexPositionLabel(label) {
return trainingNoSexPositionLabel
}
return label
}
func trainingVideoMAEFrameFallbackPath(root string, sampleID string) string {
return filepath.Join(root, "frames", sampleID+".jpg")
}
func trainingVideoMAEAnnotationHasSource(root string, item TrainingAnnotation) bool {
sourcePath := strings.TrimSpace(item.SourcePath)
if sourcePath != "" && trainingSupportedImportVideo(sourcePath) && fileExistsNonEmpty(sourcePath) {
return true
}
return fileExistsNonEmpty(trainingVideoMAEFrameFallbackPath(root, item.SampleID))
}
func trainingCountVideoMAEEligibleAnnotations(root string) (int, error) {
items, err := trainingReadAnnotations(root)
if err != nil {
return 0, err
}
labelSet, err := trainingVideoMAELabelSet()
if err != nil {
return 0, err
}
count := 0
for _, item := range items {
sampleID := strings.TrimSpace(item.SampleID)
if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
continue
}
if !labelSet[trainingVideoMAELabelForAnnotation(item)] {
continue
}
if trainingVideoMAEAnnotationHasSource(root, item) {
count++
}
}
return count, nil
}
func trainingReadVideoMAEManifest(root string) ([]trainingVideoMAEManifestEntry, error) {
path := trainingVideoMAEManifestPath(root)
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return []trainingVideoMAEManifestEntry{}, nil
}
return nil, err
}
defer f.Close()
out := []trainingVideoMAEManifestEntry{}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var entry trainingVideoMAEManifestEntry
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue
}
out = append(out, entry)
}
if err := scanner.Err(); err != nil {
return nil, err
}
return out, nil
}
func trainingWriteVideoMAEManifest(root string, entries []trainingVideoMAEManifestEntry) error {
path := trainingVideoMAEManifestPath(root)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
var b strings.Builder
for _, entry := range entries {
line, err := json.Marshal(entry)
if err != nil {
return err
}
b.Write(line)
b.WriteByte('\n')
}
return os.WriteFile(path, []byte(b.String()), 0644)
}
func trainingCountVideoMAEManifestSamples(root string) (train int, val int) {
entries, err := trainingReadVideoMAEManifest(root)
if err != nil {
return 0, 0
}
for _, entry := range entries {
if entry.FrameCount <= 0 || !fileExistsNonEmpty(filepath.Join(entry.ClipDir, "frame_001.jpg")) {
continue
}
switch strings.ToLower(strings.TrimSpace(entry.Split)) {
case "train":
train++
case "val":
val++
}
}
return train, val
}
func trainingRemoveVideoMAEGeneratedClips(root string) error {
clipsDir := filepath.Join(root, "videomae", "dataset", "clips")
if err := os.RemoveAll(clipsDir); err != nil {
return err
}
for _, split := range []string{"train", "val"} {
if err := os.MkdirAll(filepath.Join(clipsDir, split), 0755); err != nil {
return err
}
}
return nil
}
func trainingVideoMAEClipFrameCount(clipDir string) int {
entries, err := os.ReadDir(clipDir)
if err != nil {
return 0
}
count := 0
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" {
count++
}
}
return count
}
func trainingExtractVideoMAEClipFrames(
ctx context.Context,
videoPath string,
centerSecond float64,
clipDir string,
) error {
if strings.TrimSpace(videoPath) == "" {
return errors.New("video path missing")
}
if err := os.MkdirAll(clipDir, 0755); err != nil {
return err
}
ffmpegPath := strings.TrimSpace(getSettings().FFmpegPath)
if ffmpegPath == "" {
ffmpegPath = "ffmpeg"
}
start := math.Max(0, centerSecond-trainingVideoMAEClipSeconds/2)
fps := float64(trainingVideoMAENumFrames) / trainingVideoMAEClipSeconds
vf := fmt.Sprintf(
"fps=%.6f,scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2",
fps,
trainingVideoMAEFrameSize,
trainingVideoMAEFrameSize,
trainingVideoMAEFrameSize,
trainingVideoMAEFrameSize,
)
cmd := exec.CommandContext(
ctx,
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-ss", fmt.Sprintf("%.3f", start),
"-i", videoPath,
"-t", fmt.Sprintf("%.3f", trainingVideoMAEClipSeconds),
"-vf", vf,
"-frames:v", strconv.Itoa(trainingVideoMAENumFrames),
"-q:v", "3",
filepath.Join(clipDir, "frame_%03d.jpg"),
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg clip extract failed: %w: %s", err, strings.TrimSpace(string(out)))
}
if trainingVideoMAEClipFrameCount(clipDir) <= 0 {
return errors.New("ffmpeg erzeugte keine Clip-Frames")
}
return nil
}
func trainingWriteStillVideoMAEClip(framePath string, clipDir string) error {
if !fileExistsNonEmpty(framePath) {
return errors.New("fallback frame missing")
}
if err := os.MkdirAll(clipDir, 0755); err != nil {
return err
}
for i := 1; i <= trainingVideoMAENumFrames; i++ {
dst := filepath.Join(clipDir, fmt.Sprintf("frame_%03d.jpg", i))
if err := copyFile(framePath, dst); err != nil {
return err
}
}
return nil
}
func trainingWriteVideoMAEClipForAnnotation(
ctx context.Context,
root string,
item TrainingAnnotation,
clipDir string,
) (int, error) {
if err := os.RemoveAll(clipDir); err != nil {
return 0, err
}
if err := os.MkdirAll(clipDir, 0755); err != nil {
return 0, err
}
sourcePath := strings.TrimSpace(item.SourcePath)
if sourcePath != "" && trainingSupportedImportVideo(sourcePath) && fileExistsNonEmpty(sourcePath) {
if err := trainingExtractVideoMAEClipFrames(ctx, sourcePath, item.Second, clipDir); err == nil {
return trainingVideoMAEClipFrameCount(clipDir), nil
} else {
appLogln("videomae clip extract fallback:", item.SampleID, err)
}
}
fallbackFrame := trainingVideoMAEFrameFallbackPath(root, item.SampleID)
if err := trainingWriteStillVideoMAEClip(fallbackFrame, clipDir); err != nil {
return 0, err
}
return trainingVideoMAEClipFrameCount(clipDir), nil
}
func trainingCopyVideoMAEClip(srcDir string, dstDir string) (int, error) {
if err := os.RemoveAll(dstDir); err != nil {
return 0, err
}
if err := os.MkdirAll(dstDir, 0755); err != nil {
return 0, err
}
entries, err := os.ReadDir(srcDir)
if err != nil {
return 0, err
}
copied := 0
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
continue
}
if err := copyFile(filepath.Join(srcDir, entry.Name()), filepath.Join(dstDir, entry.Name())); err != nil {
return copied, err
}
copied++
}
return copied, nil
}
func trainingEnsureVideoMAEValidationEntries(
root string,
entries []trainingVideoMAEManifestEntry,
) []trainingVideoMAEManifestEntry {
trainCount := 0
valCount := 0
trainEntries := []trainingVideoMAEManifestEntry{}
for _, entry := range entries {
switch strings.ToLower(strings.TrimSpace(entry.Split)) {
case "train":
trainCount++
trainEntries = append(trainEntries, entry)
case "val":
valCount++
}
}
if valCount >= minVideoMAEValCount || trainCount < minVideoMAETrainCount {
return entries
}
sort.SliceStable(trainEntries, func(i, j int) bool {
if trainEntries[i].Label == trainEntries[j].Label {
return trainEntries[i].SampleID < trainEntries[j].SampleID
}
return trainEntries[i].Label < trainEntries[j].Label
})
for _, entry := range trainEntries {
if valCount >= minVideoMAEValCount {
break
}
copyID := entry.SampleID + "_valcopy"
dstDir := filepath.Join(root, "videomae", "dataset", "clips", "val", copyID)
frameCount, err := trainingCopyVideoMAEClip(entry.ClipDir, dstDir)
if err != nil || frameCount <= 0 {
if err != nil {
appLogln("videomae val copy failed:", entry.SampleID, err)
}
continue
}
copyEntry := entry
copyEntry.SampleID = copyID
copyEntry.Split = "val"
copyEntry.ClipDir = dstDir
copyEntry.FrameCount = frameCount
entries = append(entries, copyEntry)
valCount++
}
return entries
}
func trainingSyncVideoMAEDataset(
ctx context.Context,
root string,
) (trainCount int, valCount int, written int, err error) {
if err := trainingEnsureVideoMAEDirs(root); err != nil {
return 0, 0, 0, err
}
if err := trainingRemoveVideoMAEGeneratedClips(root); err != nil {
return 0, 0, 0, err
}
items, err := trainingReadAnnotations(root)
if err != nil {
return 0, 0, 0, err
}
labelSet, err := trainingVideoMAELabelSet()
if err != nil {
return 0, 0, 0, err
}
entries := []trainingVideoMAEManifestEntry{}
for _, item := range items {
if ctx.Err() != nil {
return 0, 0, written, ctx.Err()
}
sampleID := strings.TrimSpace(item.SampleID)
if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") {
continue
}
label := trainingVideoMAELabelForAnnotation(item)
if !labelSet[label] {
continue
}
split := trainingStableSplit(sampleID)
clipDir := filepath.Join(root, "videomae", "dataset", "clips", split, sampleID)
frameCount, clipErr := trainingWriteVideoMAEClipForAnnotation(ctx, root, item, clipDir)
if clipErr != nil {
appLogln("videomae sample sync skipped:", sampleID, clipErr)
continue
}
entry := trainingVideoMAEManifestEntry{
SampleID: sampleID,
Split: split,
Label: label,
ClipDir: clipDir,
SourcePath: strings.TrimSpace(item.SourcePath),
Second: item.Second,
FrameCount: frameCount,
}
entries = append(entries, entry)
written++
}
entries = trainingEnsureVideoMAEValidationEntries(root, entries)
for _, entry := range entries {
switch strings.ToLower(strings.TrimSpace(entry.Split)) {
case "train":
trainCount++
case "val":
valCount++
}
}
if err := trainingWriteVideoMAEManifest(root, entries); err != nil {
return trainCount, valCount, written, err
}
return trainCount, valCount, written, nil
}

70
backend/videomae_test.go Normal file
View File

@ -0,0 +1,70 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestTrainingVideoMAELabelForAnnotationUsesNegativeAsKeine(t *testing.T) {
got := trainingVideoMAELabelForAnnotation(TrainingAnnotation{
Negative: true,
Correction: &TrainingCorrection{
SexPosition: "doggy",
},
})
if got != trainingNoSexPositionLabel {
t.Fatalf("label = %q, want %q", got, trainingNoSexPositionLabel)
}
}
func TestTrainingVideoMAELabelForAnnotationUsesCorrection(t *testing.T) {
got := trainingVideoMAELabelForAnnotation(TrainingAnnotation{
Correction: &TrainingCorrection{
SexPosition: "cowgirl",
},
})
if got != "cowgirl" {
t.Fatalf("label = %q, want cowgirl", got)
}
}
func TestBuildAnalyzeVideoMAEClipsUsesWindowAndStride(t *testing.T) {
samples := []videoFrameSample{
{Time: 0, Path: "f0.jpg"},
{Time: 1, Path: "f1.jpg"},
{Time: 2, Path: "f2.jpg"},
{Time: 3, Path: "f3.jpg"},
{Time: 4, Path: "f4.jpg"},
{Time: 5, Path: "f5.jpg"},
}
clips := buildAnalyzeVideoMAEClips(samples, 6)
if len(clips) != 3 {
t.Fatalf("clip count = %d, want 3", len(clips))
}
if clips[0].Time != 0 || clips[1].Time != 2 || clips[2].Time != 4 {
t.Fatalf("clip times = %.1f, %.1f, %.1f; want 0, 2, 4", clips[0].Time, clips[1].Time, clips[2].Time)
}
if len(clips[1].Paths) < 4 {
t.Fatalf("middle clip paths = %d, want at least 4", len(clips[1].Paths))
}
}
func TestTrainingResolveVideoMAEModelUsesConfigDir(t *testing.T) {
root := t.TempDir()
modelDir := filepath.Join(root, "videomae", "model")
if err := os.MkdirAll(modelDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(modelDir, "config.json"), []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
got := trainingResolveVideoMAEModel(root)
if !got.EffectiveExists || got.EffectivePath != modelDir || got.Source != "videomae_clip" {
t.Fatalf("unexpected model resolution: %+v", got)
}
}