Add automatic best segment detection
This commit is contained in:
@@ -175,6 +175,9 @@ struct ContentView: View {
|
|||||||
.disabled(processor.state.isRunning)
|
.disabled(processor.state.isRunning)
|
||||||
|
|
||||||
Section("Gesicht") {
|
Section("Gesicht") {
|
||||||
|
Toggle("Bestes Segment automatisch finden", isOn: $settings.bestSegmentDetectionEnabled)
|
||||||
|
.help("Analysiert das Originalvideo vor dem Schneiden und sucht das Fenster, in dem Gesicht und beide Augen am besten sichtbar sind. Wenn nichts Brauchbares gefunden wird, nutzt GrowthLapse die normale Mitte/Start-Offset-Logik.")
|
||||||
|
|
||||||
Toggle("Gesichtsgröße normalisieren", isOn: $settings.faceNormalizationEnabled)
|
Toggle("Gesichtsgröße normalisieren", isOn: $settings.faceNormalizationEnabled)
|
||||||
.help("Analysiert mehrere Frames mit Apple Vision und berechnet einen statischen Crop pro Clip. Kein Frame-by-frame Tracking.")
|
.help("Analysiert mehrere Frames mit Apple Vision und berechnet einen statischen Crop pro Clip. Kein Frame-by-frame Tracking.")
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ struct RenderSettings: Equatable {
|
|||||||
var stabilizationAnchorX: Double = 0.5
|
var stabilizationAnchorX: Double = 0.5
|
||||||
var stabilizationAnchorY: Double = 0.5
|
var stabilizationAnchorY: Double = 0.5
|
||||||
var stabilizationAnchorSize: Double = 0.35
|
var stabilizationAnchorSize: Double = 0.35
|
||||||
|
var bestSegmentDetectionEnabled: Bool = false
|
||||||
var faceNormalizationEnabled: Bool = false
|
var faceNormalizationEnabled: Bool = false
|
||||||
var targetFaceHeightRatio: Double = 0.28
|
var targetFaceHeightRatio: Double = 0.28
|
||||||
}
|
}
|
||||||
@@ -168,6 +169,7 @@ struct PersistedRenderSettings: Codable {
|
|||||||
var stabilizationAnchorX: Double
|
var stabilizationAnchorX: Double
|
||||||
var stabilizationAnchorY: Double
|
var stabilizationAnchorY: Double
|
||||||
var stabilizationAnchorSize: Double
|
var stabilizationAnchorSize: Double
|
||||||
|
var bestSegmentDetectionEnabled: Bool?
|
||||||
var faceNormalizationEnabled: Bool
|
var faceNormalizationEnabled: Bool
|
||||||
var targetFaceHeightRatio: Double
|
var targetFaceHeightRatio: Double
|
||||||
|
|
||||||
@@ -198,6 +200,7 @@ struct PersistedRenderSettings: Codable {
|
|||||||
stabilizationAnchorX = settings.stabilizationAnchorX
|
stabilizationAnchorX = settings.stabilizationAnchorX
|
||||||
stabilizationAnchorY = settings.stabilizationAnchorY
|
stabilizationAnchorY = settings.stabilizationAnchorY
|
||||||
stabilizationAnchorSize = settings.stabilizationAnchorSize
|
stabilizationAnchorSize = settings.stabilizationAnchorSize
|
||||||
|
bestSegmentDetectionEnabled = settings.bestSegmentDetectionEnabled
|
||||||
faceNormalizationEnabled = settings.faceNormalizationEnabled
|
faceNormalizationEnabled = settings.faceNormalizationEnabled
|
||||||
targetFaceHeightRatio = settings.targetFaceHeightRatio
|
targetFaceHeightRatio = settings.targetFaceHeightRatio
|
||||||
}
|
}
|
||||||
@@ -230,6 +233,7 @@ struct PersistedRenderSettings: Codable {
|
|||||||
stabilizationAnchorX: stabilizationAnchorX,
|
stabilizationAnchorX: stabilizationAnchorX,
|
||||||
stabilizationAnchorY: stabilizationAnchorY,
|
stabilizationAnchorY: stabilizationAnchorY,
|
||||||
stabilizationAnchorSize: stabilizationAnchorSize,
|
stabilizationAnchorSize: stabilizationAnchorSize,
|
||||||
|
bestSegmentDetectionEnabled: bestSegmentDetectionEnabled ?? false,
|
||||||
faceNormalizationEnabled: faceNormalizationEnabled,
|
faceNormalizationEnabled: faceNormalizationEnabled,
|
||||||
targetFaceHeightRatio: targetFaceHeightRatio
|
targetFaceHeightRatio: targetFaceHeightRatio
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -209,13 +209,18 @@ final class VideoProcessor: ObservableObject {
|
|||||||
let targetDimensions = outputDimensions(for: effectiveSettings, firstVideo: videos[0])
|
let targetDimensions = outputDimensions(for: effectiveSettings, firstVideo: videos[0])
|
||||||
appendLog("Zielauflösung der Zwischenclips: \(targetDimensions.width)x\(targetDimensions.height)")
|
appendLog("Zielauflösung der Zwischenclips: \(targetDimensions.width)x\(targetDimensions.height)")
|
||||||
|
|
||||||
var renderProject = existingProject ?? makeProject(
|
var renderProject: GrowthLapseProject
|
||||||
outputFile: outputFile,
|
if let existingProject {
|
||||||
cacheDirectory: tempDirectory,
|
renderProject = existingProject
|
||||||
targetDimensions: targetDimensions,
|
} else {
|
||||||
videos: videos,
|
renderProject = try await makeProject(
|
||||||
settings: effectiveSettings
|
outputFile: outputFile,
|
||||||
)
|
cacheDirectory: tempDirectory,
|
||||||
|
targetDimensions: targetDimensions,
|
||||||
|
videos: videos,
|
||||||
|
settings: effectiveSettings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let normalizedClips = try await normalizeVideos(
|
let normalizedClips = try await normalizeVideos(
|
||||||
videos,
|
videos,
|
||||||
@@ -1045,18 +1050,21 @@ final class VideoProcessor: ObservableObject {
|
|||||||
targetDimensions: VideoDimensions,
|
targetDimensions: VideoDimensions,
|
||||||
videos: [VideoFile],
|
videos: [VideoFile],
|
||||||
settings: RenderSettings
|
settings: RenderSettings
|
||||||
) -> GrowthLapseProject {
|
) async throws -> GrowthLapseProject {
|
||||||
let clips = videos.enumerated().map { index, video in
|
var clips: [GrowthLapseProjectClip] = []
|
||||||
|
|
||||||
|
for (index, video) in videos.enumerated() {
|
||||||
|
try Task.checkCancellation()
|
||||||
let segmentLength = min(settings.segmentLength, video.duration)
|
let segmentLength = min(settings.segmentLength, video.duration)
|
||||||
let rawStart: Double
|
let safeStart = try await initialSegmentStart(
|
||||||
if settings.takeMiddleSegment, video.duration > settings.segmentLength {
|
for: video,
|
||||||
rawStart = max(0, (video.duration - settings.segmentLength) / 2 + settings.startOffset)
|
segmentLength: segmentLength,
|
||||||
} else {
|
settings: settings,
|
||||||
rawStart = max(0, settings.startOffset)
|
clipNumber: index + 1,
|
||||||
}
|
totalClips: videos.count
|
||||||
let safeStart = min(rawStart, max(0, video.duration - segmentLength))
|
)
|
||||||
let normalizedClipURL = cacheDirectory.appendingPathComponent(String(format: "clip_%04d.mp4", index + 1))
|
let normalizedClipURL = cacheDirectory.appendingPathComponent(String(format: "clip_%04d.mp4", index + 1))
|
||||||
return GrowthLapseProjectClip(
|
clips.append(GrowthLapseProjectClip(
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
sourcePath: video.url.path,
|
sourcePath: video.url.path,
|
||||||
displayName: video.displayName,
|
displayName: video.displayName,
|
||||||
@@ -1069,7 +1077,7 @@ final class VideoProcessor: ObservableObject {
|
|||||||
normalizedClipPath: normalizedClipURL.path,
|
normalizedClipPath: normalizedClipURL.path,
|
||||||
needsRender: true,
|
needsRender: true,
|
||||||
variants: video.variants.isEmpty ? nil : video.variants
|
variants: video.variants.isEmpty ? nil : video.variants
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
return GrowthLapseProject(
|
return GrowthLapseProject(
|
||||||
@@ -1081,6 +1089,45 @@ final class VideoProcessor: ObservableObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func initialSegmentStart(
|
||||||
|
for video: VideoFile,
|
||||||
|
segmentLength: Double,
|
||||||
|
settings: RenderSettings,
|
||||||
|
clipNumber: Int,
|
||||||
|
totalClips: Int
|
||||||
|
) async throws -> Double {
|
||||||
|
let fallbackStart: Double
|
||||||
|
if settings.takeMiddleSegment, video.duration > settings.segmentLength {
|
||||||
|
fallbackStart = max(0, (video.duration - settings.segmentLength) / 2 + settings.startOffset)
|
||||||
|
} else {
|
||||||
|
fallbackStart = max(0, settings.startOffset)
|
||||||
|
}
|
||||||
|
let safeFallback = min(fallbackStart, max(0, video.duration - segmentLength))
|
||||||
|
|
||||||
|
guard settings.bestSegmentDetectionEnabled, video.duration > segmentLength else {
|
||||||
|
return safeFallback
|
||||||
|
}
|
||||||
|
|
||||||
|
progressText = "Bestes Segment \(clipNumber) von \(totalClips)"
|
||||||
|
appendLog("Bestes Segment: analysiere Augen/Gesicht in \(video.displayName)")
|
||||||
|
do {
|
||||||
|
let result = try await BestSegmentAnalyzer.detectBestSegment(
|
||||||
|
source: video.url,
|
||||||
|
duration: video.duration,
|
||||||
|
segmentLength: segmentLength
|
||||||
|
)
|
||||||
|
guard let result else {
|
||||||
|
appendLog("Bestes Segment Clip \(clipNumber): nichts Sicheres gefunden, nutze Fallback @ \(formatSeconds(safeFallback))")
|
||||||
|
return safeFallback
|
||||||
|
}
|
||||||
|
appendLog("Bestes Segment Clip \(clipNumber): Start \(formatSeconds(result.start)), gute Frames \(result.goodFrameCount)/\(result.totalFrameCount), Score \(String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), result.score))")
|
||||||
|
return result.start
|
||||||
|
} catch {
|
||||||
|
appendLog("Warnung: Bestes Segment Clip \(clipNumber) fehlgeschlagen: \(error.localizedDescription). Nutze Fallback @ \(formatSeconds(safeFallback))")
|
||||||
|
return safeFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func reorderedProject(_ project: GrowthLapseProject, sortMode: SortMode) -> GrowthLapseProject {
|
private func reorderedProject(_ project: GrowthLapseProject, sortMode: SortMode) -> GrowthLapseProject {
|
||||||
var copy = project
|
var copy = project
|
||||||
copy.clips = sortedProjectClips(project.clips, sortMode: sortMode).enumerated().map { offset, clip in
|
copy.clips = sortedProjectClips(project.clips, sortMode: sortMode).enumerated().map { offset, clip in
|
||||||
@@ -1278,6 +1325,7 @@ final class VideoProcessor: ObservableObject {
|
|||||||
"Übergangslänge: \(settings.transitionLength)s",
|
"Übergangslänge: \(settings.transitionLength)s",
|
||||||
"Audio entfernen: \(settings.removeAudio ? "ja" : "nein")",
|
"Audio entfernen: \(settings.removeAudio ? "ja" : "nein")",
|
||||||
"Segment aus Mitte: \(settings.takeMiddleSegment ? "ja" : "nein")",
|
"Segment aus Mitte: \(settings.takeMiddleSegment ? "ja" : "nein")",
|
||||||
|
"Bestes Segment automatisch: \(settings.bestSegmentDetectionEnabled ? "an, Gesicht/Augen sichtbar" : "aus")",
|
||||||
"Stabilisierung: \(settings.stabilizationEnabled ? settings.stabilizationMethod.rawValue : "aus")",
|
"Stabilisierung: \(settings.stabilizationEnabled ? settings.stabilizationMethod.rawValue : "aus")",
|
||||||
"Stabilisierungsstärke: \(settings.stabilizationStrength.rawValue)",
|
"Stabilisierungsstärke: \(settings.stabilizationStrength.rawValue)",
|
||||||
"Stabilisierung Hintergrund-Anker: \(settings.stabilizationAnchorEnabled ? "an, X \(Int(settings.stabilizationAnchorX * 100))%, Y \(Int(settings.stabilizationAnchorY * 100))%, Bereich \(Int(settings.stabilizationAnchorSize * 100))%" : "aus")",
|
"Stabilisierung Hintergrund-Anker: \(settings.stabilizationAnchorEnabled ? "an, X \(Int(settings.stabilizationAnchorX * 100))%, Y \(Int(settings.stabilizationAnchorY * 100))%, Bereich \(Int(settings.stabilizationAnchorSize * 100))%" : "aus")",
|
||||||
@@ -1413,6 +1461,165 @@ private struct DetectedFaceSample {
|
|||||||
let imageHeight: Int
|
let imageHeight: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct BestSegmentResult {
|
||||||
|
let start: Double
|
||||||
|
let score: Double
|
||||||
|
let goodFrameCount: Int
|
||||||
|
let totalFrameCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EyeVisibilitySample {
|
||||||
|
let time: Double
|
||||||
|
let score: Double
|
||||||
|
let isGood: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum BestSegmentAnalyzer {
|
||||||
|
static func detectBestSegment(
|
||||||
|
source: URL,
|
||||||
|
duration: Double,
|
||||||
|
segmentLength: Double
|
||||||
|
) async throws -> BestSegmentResult? {
|
||||||
|
try await Task.detached(priority: .userInitiated) {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
|
||||||
|
let sampleInterval = duration <= 60 ? 0.5 : 1.0
|
||||||
|
let samples = try analyzeSamples(
|
||||||
|
source: source,
|
||||||
|
duration: duration,
|
||||||
|
interval: sampleInterval
|
||||||
|
)
|
||||||
|
let goodSamples = samples.filter(\.isGood)
|
||||||
|
guard !samples.isEmpty, !goodSamples.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxStart = max(0, duration - segmentLength)
|
||||||
|
var best: BestSegmentResult?
|
||||||
|
var start = 0.0
|
||||||
|
|
||||||
|
while start <= maxStart + 0.0001 {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let end = start + segmentLength
|
||||||
|
let windowSamples = samples.filter { $0.time >= start && $0.time <= end }
|
||||||
|
guard !windowSamples.isEmpty else {
|
||||||
|
start += sampleInterval
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let goodCount = windowSamples.filter(\.isGood).count
|
||||||
|
let averageScore = windowSamples.map(\.score).reduce(0, +) / Double(windowSamples.count)
|
||||||
|
let goodRatio = Double(goodCount) / Double(windowSamples.count)
|
||||||
|
let longestRun = longestGoodRun(in: windowSamples)
|
||||||
|
let windowScore = Double(goodCount) * 1_000
|
||||||
|
+ Double(longestRun) * 180
|
||||||
|
+ goodRatio * 120
|
||||||
|
+ averageScore * 25
|
||||||
|
|
||||||
|
let result = BestSegmentResult(
|
||||||
|
start: min(max(0, start), maxStart),
|
||||||
|
score: windowScore,
|
||||||
|
goodFrameCount: goodCount,
|
||||||
|
totalFrameCount: windowSamples.count
|
||||||
|
)
|
||||||
|
|
||||||
|
if let currentBest = best {
|
||||||
|
if result.score > currentBest.score {
|
||||||
|
best = result
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
best = result
|
||||||
|
}
|
||||||
|
|
||||||
|
start += sampleInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let best, best.goodFrameCount > 0 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func analyzeSamples(source: URL, duration: Double, interval: Double) throws -> [EyeVisibilitySample] {
|
||||||
|
let asset = AVAsset(url: source)
|
||||||
|
let generator = AVAssetImageGenerator(asset: asset)
|
||||||
|
generator.appliesPreferredTrackTransform = true
|
||||||
|
generator.maximumSize = CGSize(width: 960, height: 960)
|
||||||
|
generator.requestedTimeToleranceBefore = CMTime(seconds: 0.12, preferredTimescale: 600)
|
||||||
|
generator.requestedTimeToleranceAfter = CMTime(seconds: 0.12, preferredTimescale: 600)
|
||||||
|
|
||||||
|
var samples: [EyeVisibilitySample] = []
|
||||||
|
var time = 0.0
|
||||||
|
while time <= duration {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let cmTime = CMTime(seconds: time, preferredTimescale: 600)
|
||||||
|
let cgImage = try generator.copyCGImage(at: cmTime, actualTime: nil)
|
||||||
|
let score = try eyeVisibilityScore(in: cgImage)
|
||||||
|
samples.append(EyeVisibilitySample(time: time, score: score, isGood: score >= 0.62))
|
||||||
|
time += interval
|
||||||
|
}
|
||||||
|
return samples
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func eyeVisibilityScore(in image: CGImage) throws -> Double {
|
||||||
|
let request = VNDetectFaceLandmarksRequest()
|
||||||
|
let handler = VNImageRequestHandler(cgImage: image, options: [:])
|
||||||
|
try handler.perform([request])
|
||||||
|
|
||||||
|
guard let faces = request.results, !faces.isEmpty else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return faces.map(scoreFace).max() ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func scoreFace(_ face: VNFaceObservation) -> Double {
|
||||||
|
let faceArea = Double(face.boundingBox.width * face.boundingBox.height)
|
||||||
|
let faceSizeScore = clamp(faceArea / 0.035, min: 0, max: 1)
|
||||||
|
let centerDistance = hypot(Double(face.boundingBox.midX - 0.5), Double(face.boundingBox.midY - 0.5))
|
||||||
|
let centerScore = clamp(1 - centerDistance * 1.4, min: 0, max: 1)
|
||||||
|
|
||||||
|
let leftEyePoints = face.landmarks?.leftEye?.pointCount ?? 0
|
||||||
|
let rightEyePoints = face.landmarks?.rightEye?.pointCount ?? 0
|
||||||
|
let hasBothEyes = leftEyePoints >= 4 && rightEyePoints >= 4
|
||||||
|
let eyePointScore = clamp(Double(min(leftEyePoints, rightEyePoints)) / 8.0, min: 0, max: 1)
|
||||||
|
|
||||||
|
let yawPenalty = min(abs(face.yaw?.doubleValue ?? 0) / 0.55, 1)
|
||||||
|
let rollPenalty = min(abs(face.roll?.doubleValue ?? 0) / 0.45, 1)
|
||||||
|
let poseScore = clamp(1 - yawPenalty * 0.85 - rollPenalty * 0.35, min: 0, max: 1)
|
||||||
|
let qualityScore = face.faceCaptureQuality.map(Double.init) ?? 0.55
|
||||||
|
|
||||||
|
guard hasBothEyes else {
|
||||||
|
return faceSizeScore * 0.20 + centerScore * 0.08 + poseScore * 0.12
|
||||||
|
}
|
||||||
|
|
||||||
|
return eyePointScore * 0.46
|
||||||
|
+ poseScore * 0.22
|
||||||
|
+ faceSizeScore * 0.14
|
||||||
|
+ qualityScore * 0.12
|
||||||
|
+ centerScore * 0.06
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func longestGoodRun(in samples: [EyeVisibilitySample]) -> Int {
|
||||||
|
var best = 0
|
||||||
|
var current = 0
|
||||||
|
for sample in samples {
|
||||||
|
if sample.isGood {
|
||||||
|
current += 1
|
||||||
|
best = max(best, current)
|
||||||
|
} else {
|
||||||
|
current = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func clamp(_ value: Double, min minimum: Double, max maximum: Double) -> Double {
|
||||||
|
Swift.max(minimum, Swift.min(maximum, value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private enum FaceCropAnalyzer {
|
private enum FaceCropAnalyzer {
|
||||||
static func detectCrop(
|
static func detectCrop(
|
||||||
source: URL,
|
source: URL,
|
||||||
|
|||||||
Reference in New Issue
Block a user