[CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ReferenceDirectory, [Parameter(Mandatory = $true)] [string]$Checkpoint, [string]$GodotPath, [string]$Output = "user://render_camera_pose_sweep", [double[]]$YawOffsets = @(-10.0, 0.0, 10.0), [double[]]$PitchOffsets = @(-10.0, 0.0, 10.0), [double]$CameraFov = 62.0, [double[]]$CameraFovValues = @(), [int]$ViewportWidth = 1280, [int]$ViewportHeight = 900, [double]$WaitSeconds = 8.0, [double]$MeasureSeconds = 0.1, [switch]$PlanOnly ) $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $PSScriptRoot if (-not $GodotPath) { $godotCommand = Get-Command godot -ErrorAction SilentlyContinue if ($godotCommand) { $GodotPath = $godotCommand.Source } else { $GodotPath = Join-Path $env:TEMP "godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe" } } if (-not (Test-Path -LiteralPath $GodotPath)) { throw "Godot executable not found: $GodotPath" } if (-not (Test-Path -LiteralPath $ReferenceDirectory)) { throw "Reference directory not found: $ReferenceDirectory" } $invariantCulture = [Globalization.CultureInfo]::InvariantCulture $isJointFovSweep = $CameraFovValues.Count -gt 0 $effectiveCameraFovValues = if ($isJointFovSweep) { $CameraFovValues } else { @($CameraFov) } $waitSecondsText = $WaitSeconds.ToString($invariantCulture) $measureSecondsText = $MeasureSeconds.ToString($invariantCulture) $absoluteOutput = if ($Output.StartsWith("user://")) { Join-Path $env:APPDATA "Godot\app_userdata\OpenWC\$($Output.Substring(7))" } else { [IO.Path]::GetFullPath((Join-Path $repoRoot $Output)) } $ranking = [Collections.Generic.List[object]]::new() Push-Location $repoRoot try { foreach ($cameraFovValue in $effectiveCameraFovValues) { foreach ($yawOffset in $YawOffsets) { foreach ($pitchOffset in $PitchOffsets) { $cameraFovText = $cameraFovValue.ToString("0.###", $invariantCulture) $yawText = $yawOffset.ToString("0.###", $invariantCulture) $pitchText = $pitchOffset.ToString("0.###", $invariantCulture) $candidateName = "yaw_$($yawText.Replace('-', 'n').Replace('.', '_'))__pitch_$($pitchText.Replace('-', 'n').Replace('.', '_'))" if ($isJointFovSweep) { $candidateName = "fov_$($cameraFovText.Replace('.', '_'))__$candidateName" } $candidateOutput = "$Output/$candidateName" $comparisonReport = Join-Path $absoluteOutput "$candidateName.json" if ($PlanOnly) { $ranking.Add([pscustomobject]@{ yaw_offset_degrees = $yawOffset pitch_offset_degrees = $pitchOffset camera_fov_degrees = $cameraFovValue candidate_directory = $candidateOutput }) continue } $captureArguments = @( "--path", ".", "--script", "res://src/tools/capture_render_checkpoints.gd", "--", "--output", $candidateOutput, "--only", $Checkpoint, "--single-pass", "--camera-fov", $cameraFovText, "--camera-yaw-offset", $yawText, "--camera-pitch-offset", $pitchText, "--viewport-width", $ViewportWidth, "--viewport-height", $ViewportHeight, "--wait", $waitSecondsText, "--measure", $measureSecondsText ) $captureProcess = Start-Process -FilePath $GodotPath -ArgumentList $captureArguments -Wait -PassThru if ($captureProcess.ExitCode -ne 0) { throw "Capture failed for yaw=$yawText pitch=$pitchText" } $comparisonArguments = @( "--headless", "--path", ".", "--script", "res://src/tools/compare_render_checkpoints.gd", "--", "--reference", $ReferenceDirectory, "--candidate", $candidateOutput, "--only", $Checkpoint, "--output", $comparisonReport ) $comparisonProcess = Start-Process -FilePath $GodotPath -ArgumentList $comparisonArguments -Wait -PassThru -NoNewWindow if ($comparisonProcess.ExitCode -notin @(0, 1)) { throw "Comparison failed for yaw=$yawText pitch=$pitchText" } $comparison = Get-Content -Raw -LiteralPath $comparisonReport | ConvertFrom-Json if ($comparison.compared_count -lt 1) { throw "No paired image was compared for yaw=$yawText pitch=$pitchText" } $metricResults = @($comparison.results | Where-Object { $null -ne $_.mean_perceptual_error }) if ($metricResults.Count -ne $comparison.compared_count) { $statuses = ($comparison.results.status | Sort-Object -Unique) -join "," throw "Comparison produced no numeric metric for yaw=$yawText pitch=$pitchText statuses=$statuses" } $meanError = ($metricResults | Measure-Object -Property mean_perceptual_error -Average).Average $changedRatio = ($metricResults | Measure-Object -Property changed_pixel_ratio -Average).Average $ranking.Add([pscustomobject]@{ yaw_offset_degrees = $yawOffset pitch_offset_degrees = $pitchOffset camera_fov_degrees = $cameraFovValue mean_perceptual_error = $meanError changed_pixel_ratio = $changedRatio candidate_directory = $candidateOutput comparison_report = $comparisonReport }) } } } $rankedCandidates = if ($PlanOnly) { @($ranking) } else { @($ranking | Sort-Object mean_perceptual_error, changed_pixel_ratio) } $rankingPath = Join-Path $absoluteOutput "ranking.json" $rankingDocument = [ordered]@{ schema_version = 1 checkpoint = $Checkpoint reference_directory = [IO.Path]::GetFullPath($ReferenceDirectory) viewport = @($ViewportWidth, $ViewportHeight) wait_seconds = $WaitSeconds measure_seconds = $MeasureSeconds candidates = $rankedCandidates } New-Item -ItemType Directory -Force -Path $absoluteOutput | Out-Null $rankingDocument | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 -LiteralPath $rankingPath $rankedCandidates | Format-Table camera_fov_degrees, yaw_offset_degrees, pitch_offset_degrees, mean_perceptual_error, changed_pixel_ratio $completionKind = if ($PlanOnly) { "plan" } else { "ranking" } Write-Output "Camera pose sweep $completionKind completed: $rankingPath" } finally { Pop-Location }