331 lines
7.9 KiB
Go
331 lines
7.9 KiB
Go
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 = 8
|
|
analyzeVideoMAEMaxClips = 180
|
|
analyzeVideoMAERequestTimeout = 75 * time.Second
|
|
)
|
|
|
|
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 limitAnalyzeVideoMAEClips(clips []analyzeVideoMAEClipReqItem, maxClips int) []analyzeVideoMAEClipReqItem {
|
|
if maxClips <= 0 || len(clips) <= maxClips {
|
|
return clips
|
|
}
|
|
if maxClips == 1 {
|
|
return clips[:1]
|
|
}
|
|
|
|
out := make([]analyzeVideoMAEClipReqItem, 0, maxClips)
|
|
lastIdx := -1
|
|
for i := 0; i < maxClips; i++ {
|
|
idx := int(math.Round(float64(i) * float64(len(clips)-1) / float64(maxClips-1)))
|
|
if idx <= lastIdx {
|
|
idx = lastIdx + 1
|
|
}
|
|
if idx >= len(clips) {
|
|
idx = len(clips) - 1
|
|
}
|
|
out = append(out, clips[idx])
|
|
lastIdx = idx
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func predictVideoMAEPositionClipsForAnalyze(
|
|
ctx context.Context,
|
|
clips []analyzeVideoMAEClipReqItem,
|
|
onProgress func(current int, total int),
|
|
) ([]analyzeVideoMAEClipPrediction, error) {
|
|
if len(clips) == 0 {
|
|
return []analyzeVideoMAEClipPrediction{}, nil
|
|
}
|
|
|
|
if !trainingRecognitionEnabled() {
|
|
if onProgress != nil {
|
|
onProgress(len(clips), len(clips))
|
|
}
|
|
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
|
|
}
|
|
|
|
parsed, err := func() (analyzeVideoMAEClipPredictResp, error) {
|
|
var parsed analyzeVideoMAEClipPredictResp
|
|
|
|
reqCtx, cancel := context.WithTimeout(ctx, analyzeVideoMAERequestTimeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(
|
|
reqCtx,
|
|
http.MethodPost,
|
|
analyzeAIServerURL()+"/predict-position-clips",
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
return parsed, err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
addAIServerAuth(req)
|
|
|
|
client := &http.Client{
|
|
Timeout: analyzeVideoMAERequestTimeout + 5*time.Second,
|
|
}
|
|
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return parsed, ctxErr
|
|
}
|
|
return parsed, err
|
|
}
|
|
|
|
rawBody, readErr := io.ReadAll(res.Body)
|
|
statusCode := res.StatusCode
|
|
_ = res.Body.Close()
|
|
if readErr != nil {
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return parsed, ctxErr
|
|
}
|
|
return parsed, readErr
|
|
}
|
|
|
|
if err := json.Unmarshal(rawBody, &parsed); err != nil {
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return parsed, ctxErr
|
|
}
|
|
return parsed, fmt.Errorf("AI server VideoMAE JSON ungueltig: HTTP %d: %s", statusCode, strings.TrimSpace(string(rawBody)))
|
|
}
|
|
|
|
if statusCode < 200 || statusCode >= 300 || !parsed.OK {
|
|
msg := strings.TrimSpace(parsed.Error)
|
|
if msg == "" {
|
|
msg = fmt.Sprintf("AI server VideoMAE HTTP %d", statusCode)
|
|
}
|
|
return parsed, fmt.Errorf("%s", msg)
|
|
}
|
|
|
|
return parsed, nil
|
|
}()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !parsed.Available {
|
|
if onProgress != nil {
|
|
onProgress(len(clips), len(clips))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
out = append(out, parsed.Predictions...)
|
|
if onProgress != nil {
|
|
onProgress(end, len(clips))
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func applyVideoMAEPositionClipsForAnalyze(
|
|
ctx context.Context,
|
|
samples []videoFrameSample,
|
|
duration float64,
|
|
highlightHits []analyzeHit,
|
|
positionEvidence []analyzePositionEvidence,
|
|
onProgress func(current int, total int),
|
|
) ([]analyzeHit, []analyzePositionEvidence) {
|
|
if !analyzeVideoMAEEnabled() {
|
|
return highlightHits, positionEvidence
|
|
}
|
|
|
|
clips := buildAnalyzeVideoMAEClips(samples, duration)
|
|
if len(clips) == 0 {
|
|
return highlightHits, positionEvidence
|
|
}
|
|
if len(clips) > analyzeVideoMAEMaxClips {
|
|
appLogf(
|
|
"VideoMAE Clip-Analyse begrenzt: clips=%d max=%d",
|
|
len(clips),
|
|
analyzeVideoMAEMaxClips,
|
|
)
|
|
clips = limitAnalyzeVideoMAEClips(clips, analyzeVideoMAEMaxClips)
|
|
}
|
|
|
|
if onProgress != nil {
|
|
onProgress(0, len(clips))
|
|
}
|
|
|
|
predictions, err := predictVideoMAEPositionClipsForAnalyze(ctx, clips, onProgress)
|
|
if err != nil {
|
|
if ctx.Err() == nil {
|
|
if onProgress != nil {
|
|
onProgress(len(clips), len(clips))
|
|
}
|
|
appLogln("VideoMAE Clip-Analyse übersprungen:", err)
|
|
}
|
|
return highlightHits, positionEvidence
|
|
}
|
|
|
|
for _, pred := range predictions {
|
|
label := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
|
if !isAnalyzeTimelinePositionLabel(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"
|
|
}
|
|
|
|
positionEvidence = append(positionEvidence, analyzePositionEvidence{
|
|
Time: pred.Time,
|
|
Start: start,
|
|
End: end,
|
|
Label: label,
|
|
Score: score,
|
|
Source: source,
|
|
HasClip: true,
|
|
})
|
|
}
|
|
|
|
return highlightHits, positionEvidence
|
|
}
|