diff --git a/.github/actions/spell-check/allow/zoomit.txt b/.github/actions/spell-check/allow/zoomit.txt
index 98f3b62ca1..e417d383ed 100644
--- a/.github/actions/spell-check/allow/zoomit.txt
+++ b/.github/actions/spell-check/allow/zoomit.txt
@@ -1,9 +1,23 @@
+accelscroll
acq
+adr
+Adr
APPLYTOSUBMENUS
AUDCLNT
+axisdefer
+axisflip
+axisstart
bitmaps
+BREAKSCR
BUFFERFLAGS
+Cands
+capturepath
centiseconds
+CLASSW
+coeffs
+coprime
+CREATEDIBSECTION
+crossfades
Ctl
CTLCOLOR
CTLCOLORBTN
@@ -11,53 +25,163 @@ CTLCOLORDLG
CTLCOLOREDIT
CTLCOLORLISTBOX
CTrim
+ddy
DFCS
dlg
dlu
DONTCARE
+downsample
DRAWITEM
DRAWITEMSTRUCT
+droppedband
+Droppedband
+dsum
+dupburst
+dupsegments
DWLP
EDITCONTROL
ENABLEHOOK
+expectedlock
+fastscroll
FDE
GETCHANNELRECT
GETCHECK
+GETSCREENSAVEACTIVE
+GETSCREENSAVETIMEOUT
GETTHUMBRECT
GIFs
+hcfdark
+hcfwhitespace
HTBOTTOMRIGHT
HTHEME
+htol
+ICONINFORMATION
+ICONWARNING
+Inj
+jumprecover
KSDATAFORMAT
+latestcapture
+ldx
LEFTNOWORDWRAP
+legitjumps
letterbox
lld
+llu
+llums
logfont
+lookback
lround
+lte
+luma
+Luma
+manualdrop
+maskcache
+maxstep
MENUINFO
mic
+middledrop
+middledrop
MMRESULT
+momentumreversal
+mrate
+mrt
+narrowstrip
+ncapture
+ncm
+nduplicates
+niterations
+nmonitor
+NONCLIENTMETRICS
+nonvle
+nredraw
+nstop
+nsubpixel
+ntorn
+nvw
+osc
OWNERDRAW
PBGRA
+periodictrap
pfdc
playhead
+pointerreuse
pwfx
+Qpc
quantums
+RCZOOMITSCR
+realcapture
REFKNOWNFOLDERID
reposted
+SCREENSAVE
+SCRNSAVE
+SCRNSAVECONFIGURE
+scrnsavw
+Scrnsavw
+scrollramp
SCROLLSIZEGRIP
+selftest
+SETBARCOLOR
+SETBKCOLOR
SETDEFID
SETRECT
+SETSCREENSAVETIMEOUT
SHAREMODE
SHAREVIOLATION
+shortlist
+slowthenfast
+smallstart
+SNIPOCR
+ssi
+startuprecovery
+stf
+stopafter
STREAMFLAGS
submix
+sxx
+sxy
+syy
+tallportal
tci
+tcsicmp
TEXTMETRIC
+tinystep
tme
+toolbars
TRACKMOUSEEVENT
Unadvise
+vaddq
+vaddvq
+vandq
+vcgeq
+vdup
+vld
+vle
+Vle
+VLE
+vminq
+vmlal
+vmull
+vqaddq
+vshrn
+vsntprintf
+vsnwprintf
+vsync
WASAPI
WAVEFORMATEX
WAVEFORMATEXTENSIBLE
+wfopen
+wideportal
wil
WMU
+wrapjump
+wtol
+WTSSESSION
+WTSUn
+XEnd
+XStart
+XStep
+YInternal
+ZMBS
+zncc
+Zncc
+ZNCC
diff --git a/PowerToys.slnx b/PowerToys.slnx
index 6574c208ab..9e14ce1a6c 100644
--- a/PowerToys.slnx
+++ b/PowerToys.slnx
@@ -427,7 +427,7 @@
-
+
@@ -438,7 +438,7 @@
-
+
@@ -464,13 +464,13 @@
-
-
-
+
-
+
+
+
@@ -1027,7 +1027,10 @@
-
+
+
+
+
diff --git a/src/common/interop/shared_constants.h b/src/common/interop/shared_constants.h
index 1166e1e305..72e3155e1f 100644
--- a/src/common/interop/shared_constants.h
+++ b/src/common/interop/shared_constants.h
@@ -151,6 +151,7 @@ namespace CommonSharedConstants
const wchar_t ZOOMIT_BREAK_EVENT[] = L"Local\\PowerToysZoomIt-BreakEvent-17f2e63c-4c56-41dd-90a0-2d12f9f50c6b";
const wchar_t ZOOMIT_LIVEZOOM_EVENT[] = L"Local\\PowerToysZoomIt-LiveZoomEvent-390bf0c7-616f-47dc-bafe-a2d228add20d";
const wchar_t ZOOMIT_SNIP_EVENT[] = L"Local\\PowerToysZoomIt-SnipEvent-2fd9c211-436d-4f17-a902-2528aaae3e30";
+ const wchar_t ZOOMIT_SNIPOCR_EVENT[] = L"Local\\PowerToysZoomIt-SnipOcrEvent-a7c3b1d2-9e4f-4a6b-8d5c-1f2e3a4b5c6d";
const wchar_t ZOOMIT_RECORD_EVENT[] = L"Local\\PowerToysZoomIt-RecordEvent-74539344-eaad-4711-8e83-23946e424512";
// Path to the events used by PowerDisplay
diff --git a/src/modules/ZoomIt/ZoomIt/PanoramaCapture.cpp b/src/modules/ZoomIt/ZoomIt/PanoramaCapture.cpp
new file mode 100644
index 0000000000..106aa825b3
--- /dev/null
+++ b/src/modules/ZoomIt/ZoomIt/PanoramaCapture.cpp
@@ -0,0 +1,18003 @@
+//============================================================================
+//
+// PanoramaCapture.cpp
+//
+// Panorama (scrolling) screen capture and stitching.
+//
+// Copyright (C) Mark Russinovich
+// Sysinternals - www.sysinternals.com
+//
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+//
+//============================================================================
+//
+// Algorithm overview
+// ==================
+//
+// A panorama is produced in two stages: real-time screen capture, then
+// offline frame stitching.
+//
+// 1. Capture
+// --------
+// The user selects a rectangular region via the SelectRectangle overlay.
+// The capture loop runs at ~16 ms intervals, grabbing the absolute screen
+// rect each iteration. Consecutive near-duplicate frames (average
+// per-pixel RGB difference < 6, sampled every 6th pixel with a 2.5%
+// margin on all edges) are discarded. Capture stops when the user
+// presses the stop hotkey or kMaxCaptureFrames frames have been collected.
+//
+// 2. Stitching (StitchPanoramaFrames)
+// ---------------------------------
+// All accepted frames are read into 32-bpp BGRA pixel arrays. They are
+// then composed onto a single canvas by computing relative displacements
+// between each consecutive accepted pair. Displacement detection uses a
+// two-phase search in FindBestFrameShift:
+//
+// Phase 1 - Windowed coarse search on downsampled luma
+// Each frame is converted to single-channel luma and downsampled by
+// 4x (or 2x for small frames < 240 px). The downsampled images are
+// compared at every candidate vertical shift within a search window
+// determined by the expected scroll direction. The first frame pair
+// searches in both directions; subsequent pairs search only in the
+// established direction across the full feasible range (minStep to
+// maxStep). This full-range search handles variable scroll speeds
+// (e.g. 40 px -> 202 px between consecutive frames).
+//
+// Each candidate computes the mean absolute difference (MAD) of luma
+// values across the overlapping region (skipping x-margins of ~5%).
+// Early termination discards candidates whose running average exceeds
+// the worst score in the current top-12 shortlist.
+//
+// A stationary score is also computed (zero shift MAD). If the
+// stationary score is <= 2, the frames are considered identical and
+// the pair is rejected.
+//
+// Phase 2 - Full-resolution refinement
+// The top-12 coarse candidates (pruned to those within 30 MAD of the
+// best) are refined at pixel resolution. For each candidate, a
+// neighborhood of +/- (downsampleScale+1) pixels vertically and +/-1
+// pixel horizontally is searched. Full luma arrays are precomputed
+// from the BGRA data using integer (77R + 150G + 29B) >> 8.
+//
+// On x64, the inner comparison loop uses SSE2 _mm_sad_epu8 to
+// process 16 luma bytes per iteration; a scalar fallback is used on
+// ARM64. Early termination again prunes candidates exceeding the
+// current best fine score. The candidate with the lowest fine MAD
+// is selected.
+//
+// Validation
+// Cross-validation rejects matches where the stationary score is low
+// (< 15) but the detected shift is large (> frameHeight/3) and the
+// fine score is non-zero. This catches spurious harmonic matches on
+// repetitive content like social media layouts.
+//
+// An adaptive fine-score threshold (30 for high-stationary, 15 for
+// low-stationary content) rejects poor alignments while tolerating
+// subpixel rendering and ClearType artifacts.
+//
+// Composition
+// Accepted frames are placed on a canvas according to cumulative
+// (stepX, stepY) offsets. The output is normalized so the first
+// frame appears at the top. In overlapping regions, a vertical
+// feather blend (configurable, ~frameHeight/18 pixels wide,
+// clamped to 4-28) linearly crossfades between the old and new
+// frame content using per-pixel alpha weighting.
+//
+// Output
+// The stitched pixel array is converted to an HBITMAP via
+// CreateDIBSection. The caller either copies it to the clipboard
+// as CF_DIB or saves it as a PNG file through IFileSaveDialog.
+//
+// Debug support
+// ----------------------------------
+// In debug builds, every grabbed and accepted frame is saved as a BMP
+// to %TEMP%\ZoomItPanoramaDebug\. A StitchLog function writes
+// tracing output to OutputDebugString and optionally to a file.
+// In release builds, launch with /panorama-debug to enable the same
+// frame dumps and stitch log output.
+// Command-line switches /panorama-selftest, /panorama-stitch-latest,
+// and /panorama-stitch-replay (debug only) allow offline re-stitching
+// and automated regression testing.
+//
+//============================================================================
+#include "pch.h"
+
+#include "PanoramaCapture.h"
+#include "Utility.h"
+#include "WindowsVersions.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#if defined(_M_X64) || defined(_M_IX86)
+#include
+#elif defined(_M_ARM64)
+#include
+#endif
+
+// Externs from Zoomit.cpp
+extern BOOL g_RecordCropping;
+extern SelectRectangle g_SelectRectangle;
+extern HINSTANCE g_hInstance;
+extern bool g_bSaveInProgress;
+extern std::wstring g_ScreenshotSaveLocation;
+void OutputDebug(const TCHAR* format, ...);
+const wchar_t* HotkeyIdToString( WPARAM hotkeyId );
+DWORD SavePng( LPCTSTR Filename, HBITMAP hBitmap );
+std::wstring GetUniqueFilename( const std::wstring& lastSavePath, const wchar_t* defaultFilename, REFKNOWNFOLDERID defaultFolderId );
+
+// Maximum number of frames the capture loop will collect before auto-stopping.
+// Temporary debugging limit: keep frame-limit captures short in Debug so the
+// limit-stop flow can be exercised quickly and repeatedly.
+#ifdef _DEBUG
+static constexpr size_t kMaxCaptureFrames = 1024;
+#else
+static constexpr size_t kMaxCaptureFrames = 1024;
+#endif
+
+static HBITMAP StitchPanoramaFrames( const std::vector& frames,
+ bool lowContrastMode,
+ std::function progressCallback = nullptr,
+ size_t* outComposedFrameCount = nullptr,
+ std::vector* outComposedAxisSteps = nullptr );
+static bool RunPanoramaCaptureCommon( HWND hWnd, bool saveToFile );
+
+//----------------------------------------------------------------------------
+// Lightweight parallel_for using std::thread.
+// Distributes [begin, end) work items across up to hardware_concurrency
+// threads using atomic work-stealing. Falls back to serial execution
+// for single items or single-core machines.
+//----------------------------------------------------------------------------
+template
+static void parallel_for( int begin, int end, const Func& body )
+{
+ const int count = end - begin;
+ if( count <= 0 )
+ return;
+ const int maxThreads = static_cast( std::thread::hardware_concurrency() );
+ const int numThreads = min( maxThreads, count );
+ if( numThreads <= 1 )
+ {
+ for( int i = begin; i < end; ++i )
+ body( i );
+ return;
+ }
+ std::vector threads( numThreads - 1 );
+ std::atomic nextIndex( begin );
+ auto worker = [&]()
+ {
+ for( ;; )
+ {
+ const int i = nextIndex.fetch_add( 1 );
+ if( i >= end )
+ break;
+ body( i );
+ }
+
+ };
+ for( auto& t : threads )
+ t = std::thread( worker );
+ worker();
+ for( auto& t : threads )
+ t.join();
+}
+
+//----------------------------------------------------------------------------
+// Progress dialog for panorama stitching.
+//----------------------------------------------------------------------------
+class PanoramaProgressDialog
+{
+public:
+ PanoramaProgressDialog() : m_hWnd( nullptr ), m_hProgress( nullptr ), m_hLabel( nullptr ), m_hButton( nullptr ), m_cancelled( false ) {}
+
+ void Create( HWND hWndParent )
+ {
+ EnsureWindowClass();
+
+ m_cancelled = false;
+
+ // Get DPI for proper sizing
+ const UINT dpi = GetDpiForWindowHelper( hWndParent ? hWndParent : GetDesktopWindow() );
+ const int margin = ScaleForDpi( 14, dpi );
+ const int labelHeight = ScaleForDpi( 20, dpi );
+ const int barHeight = ScaleForDpi( 16, dpi );
+ const int buttonHeight = ScaleForDpi( 26, dpi );
+ const int buttonWidth = ScaleForDpi( 80, dpi );
+ const int spacing = ScaleForDpi( 10, dpi );
+
+ // Compute desired client area, then inflate to full window size
+ const int clientWidth = ScaleForDpi( 340, dpi );
+ const int clientHeight = margin + labelHeight + spacing + barHeight + spacing + buttonHeight + margin;
+ const DWORD style = WS_POPUP | WS_CAPTION | WS_VISIBLE | WS_CLIPCHILDREN;
+ const DWORD exStyle = WS_EX_TOOLWINDOW | WS_EX_TOPMOST;
+ RECT rcWindow = { 0, 0, clientWidth, clientHeight };
+ AdjustWindowRectEx( &rcWindow, style, FALSE, exStyle );
+ const int dlgWidth = rcWindow.right - rcWindow.left;
+ const int dlgHeight = rcWindow.bottom - rcWindow.top;
+
+ RECT rcDesktop{};
+ GetWindowRect( GetDesktopWindow(), &rcDesktop );
+ const int x = ( rcDesktop.right - dlgWidth ) / 2;
+ const int y = ( rcDesktop.bottom - dlgHeight ) / 2;
+
+ m_hWnd = CreateWindowExW(
+ exStyle,
+ L"ZoomItProgressDialog",
+ L"ZoomIt",
+ style,
+ x, y, dlgWidth, dlgHeight,
+ hWndParent, nullptr, g_hInstance, nullptr );
+ if( m_hWnd == nullptr )
+ return;
+
+ SetWindowLongPtr( m_hWnd, GWLP_USERDATA, reinterpret_cast( this ) );
+
+ // Apply dark mode to title bar
+ const bool darkMode = IsDarkModeEnabled();
+ SetDarkModeForWindow( m_hWnd, darkMode );
+
+ m_hLabel = CreateWindowExW(
+ 0, L"STATIC", L"Processing panorama...",
+ WS_CHILD | WS_VISIBLE | SS_LEFT,
+ margin, margin, clientWidth - margin * 2, labelHeight,
+ m_hWnd, nullptr, g_hInstance, nullptr );
+
+ m_hProgress = CreateWindowExW(
+ 0, PROGRESS_CLASSW, nullptr,
+ WS_CHILD | WS_VISIBLE | PBS_SMOOTH,
+ margin, margin + labelHeight + spacing, clientWidth - margin * 2, barHeight,
+ m_hWnd, nullptr, g_hInstance, nullptr );
+
+ m_hButton = CreateWindowExW(
+ 0, L"BUTTON", L"Cancel",
+ WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON,
+ clientWidth - margin - buttonWidth, margin + labelHeight + spacing + barHeight + spacing, buttonWidth, buttonHeight,
+ m_hWnd, reinterpret_cast( static_cast( IDCANCEL ) ), g_hInstance, nullptr );
+ if( m_hButton && darkMode )
+ {
+ SetWindowTheme( m_hButton, L"DarkMode_Explorer", nullptr );
+ }
+
+ if( m_hProgress )
+ {
+ SendMessage( m_hProgress, PBM_SETRANGE, 0, MAKELPARAM( 0, 100 ) );
+ SendMessage( m_hProgress, PBM_SETPOS, 0, 0 );
+ // Remove sunken border
+ SetWindowLongPtr( m_hProgress, GWL_EXSTYLE,
+ GetWindowLongPtr( m_hProgress, GWL_EXSTYLE ) & ~WS_EX_STATICEDGE );
+ SetWindowPos( m_hProgress, nullptr, 0, 0, 0, 0,
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED );
+
+ // Disable visual styles so PBM_SETBARCOLOR is honored
+ SetWindowTheme( m_hProgress, L"", L"" );
+ SendMessage( m_hProgress, PBM_SETBARCOLOR, 0, static_cast( RGB( 0x00, 0x78, 0xD4 ) ) );
+ if( darkMode )
+ {
+ SendMessage( m_hProgress, PBM_SETBKCOLOR, 0, static_cast( DarkMode::SurfaceColor ) );
+ }
+ }
+
+ // Set font scaled for DPI
+ NONCLIENTMETRICSW ncm{};
+ ncm.cbSize = sizeof( ncm );
+ SystemParametersInfoW( SPI_GETNONCLIENTMETRICS, sizeof( ncm ), &ncm, 0 );
+ ncm.lfMessageFont.lfHeight = -ScaleForDpi( 12, dpi );
+ m_hFont = CreateFontIndirectW( &ncm.lfMessageFont );
+ if( m_hFont )
+ {
+ SendMessage( m_hLabel, WM_SETFONT, reinterpret_cast( m_hFont ), TRUE );
+ SendMessage( m_hButton, WM_SETFONT, reinterpret_cast( m_hFont ), TRUE );
+ }
+
+ HICON hIcon = LoadIcon( g_hInstance, L"APPICON" );
+ if( hIcon )
+ {
+ SendMessage( m_hWnd, WM_SETICON, ICON_SMALL, reinterpret_cast( hIcon ) );
+ }
+
+ UpdateWindow( m_hWnd );
+ }
+
+ void SetProgress( int percent )
+ {
+ if( m_hProgress )
+ {
+ SendMessage( m_hProgress, PBM_SETPOS, percent, 0 );
+ }
+ PumpMessages();
+ }
+
+ bool IsCancelled() const { return m_cancelled; }
+
+ void Destroy()
+ {
+ if( m_hWnd )
+ {
+ DestroyWindow( m_hWnd );
+ m_hWnd = nullptr;
+ m_hLabel = nullptr;
+ m_hProgress = nullptr;
+ m_hButton = nullptr;
+ }
+ if( m_hFont )
+ {
+ DeleteObject( m_hFont );
+ m_hFont = nullptr;
+ }
+ }
+
+private:
+ void PumpMessages()
+ {
+ MSG msg{};
+ while( PeekMessage( &msg, nullptr, 0, 0, PM_REMOVE ) )
+ {
+ if( msg.message == WM_KEYDOWN && msg.wParam == VK_ESCAPE )
+ {
+ m_cancelled = true;
+ continue;
+ }
+ TranslateMessage( &msg );
+ DispatchMessage( &msg );
+ }
+ }
+
+ HWND m_hWnd;
+ HWND m_hProgress;
+ HWND m_hLabel;
+ HWND m_hButton;
+ bool m_cancelled;
+ HFONT m_hFont = nullptr;
+
+ static LRESULT CALLBACK WndProc( HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam )
+ {
+ PanoramaProgressDialog* pThis = reinterpret_cast(
+ GetWindowLongPtr( hWnd, GWLP_USERDATA ) );
+ switch( uMsg )
+ {
+ case WM_COMMAND:
+ if( LOWORD( wParam ) == IDCANCEL && pThis )
+ {
+ pThis->m_cancelled = true;
+ return 0;
+ }
+ break;
+
+ case WM_CLOSE:
+ if( pThis )
+ {
+ pThis->m_cancelled = true;
+ }
+ return 0;
+
+ case WM_CTLCOLORSTATIC:
+ case WM_CTLCOLORBTN:
+ if( IsDarkModeEnabled() )
+ {
+ HDC hdc = reinterpret_cast( wParam );
+ SetTextColor( hdc, DarkMode::TextColor );
+ SetBkColor( hdc, DarkMode::BackgroundColor );
+ return reinterpret_cast( GetDarkModeBrush() );
+ }
+ break;
+
+ case WM_ERASEBKGND:
+ if( IsDarkModeEnabled() )
+ {
+ HDC hdc = reinterpret_cast( wParam );
+ RECT rc{};
+ GetClientRect( hWnd, &rc );
+ FillRect( hdc, &rc, GetDarkModeBrush() );
+ return 1;
+ }
+ break;
+ }
+ return DefWindowProcW( hWnd, uMsg, wParam, lParam );
+ }
+
+ static void EnsureWindowClass()
+ {
+ static bool registered = false;
+ if( !registered )
+ {
+ WNDCLASSEXW wc{};
+ wc.cbSize = sizeof( wc );
+ wc.lpfnWndProc = WndProc;
+ wc.hInstance = g_hInstance;
+ wc.hCursor = LoadCursor( nullptr, IDC_ARROW );
+ wc.hbrBackground = reinterpret_cast( COLOR_WINDOW + 1 );
+ wc.lpszClassName = L"ZoomItProgressDialog";
+ RegisterClassExW( &wc );
+ registered = true;
+ }
+ }
+};
+
+static PanoramaProgressDialog g_ProgressDialog;
+
+// Temporary file-based trace for stitch debugging (debug builds only).
+static FILE* g_StitchLogFile = nullptr;
+
+// Returns true when panorama debug output (frame dumps + stitch log) is active.
+// Debug builds always enable it; release builds enable it via /panorama-debug.
+static bool PanoramaDebugEnabled()
+{
+#ifdef _DEBUG
+ return true;
+#else
+ return g_PanoramaDebugMode;
+#endif
+}
+
+static void StitchLog( const wchar_t* format, ... )
+{
+ if( !PanoramaDebugEnabled() )
+ {
+ return;
+ }
+ va_list args;
+#pragma warning(push)
+#pragma warning(disable: 26492) // Don't use const_cast - unavoidable in va_start macro
+ va_start( args, format );
+#pragma warning(pop)
+ wchar_t buffer[1024]{};
+ _vsnwprintf_s( buffer, _TRUNCATE, format, args );
+ va_end( args );
+ OutputDebug( L"%s", buffer );
+ if( g_StitchLogFile != nullptr )
+ {
+ // Convert to narrow for easy reading
+ char narrow[2048]{};
+ WideCharToMultiByte( CP_UTF8, 0, buffer, -1, narrow, sizeof( narrow ) - 1, nullptr, nullptr );
+ fputs( narrow, g_StitchLogFile );
+ fflush( g_StitchLogFile );
+ }
+}
+
+// Emit a compact transition trace for composed frames so capture repros can
+// pinpoint skipped-content jumps and zero-overlap seams without changing
+// stitch behavior.
+static void LogComposedFrameDiagnostics( const std::vector& composedFrameIndices,
+ const std::vector& composedFrameOrigins,
+ const std::vector& composedFrameSteps,
+ int frameWidth,
+ int frameHeight )
+{
+ if( !PanoramaDebugEnabled() || composedFrameIndices.size() < 2 ||
+ composedFrameOrigins.size() != composedFrameIndices.size() ||
+ composedFrameSteps.size() != composedFrameIndices.size() )
+ {
+ return;
+ }
+
+ StitchLog( L"[Panorama/Stitch] Composed transition diagnostics begin count=%zu\n",
+ composedFrameIndices.size() );
+
+ std::vector recentAxisSteps;
+ recentAxisSteps.reserve( 8 );
+ for( size_t i = 1; i < composedFrameIndices.size(); ++i )
+ {
+ const POINT& step = composedFrameSteps[i];
+ const POINT& origin = composedFrameOrigins[i];
+ const int gap = static_cast( composedFrameIndices[i] - composedFrameIndices[i - 1] );
+ const bool mostlyVertical = abs( step.y ) >= abs( step.x );
+ const int axisFrame = mostlyVertical ? frameHeight : frameWidth;
+ const int axisStep = max( abs( step.x ), abs( step.y ) );
+ const int axisOverlap = axisFrame - axisStep;
+
+ int recentMedian = 0;
+ if( !recentAxisSteps.empty() )
+ {
+ std::vector sorted = recentAxisSteps;
+ std::sort( sorted.begin(), sorted.end() );
+ recentMedian = sorted[sorted.size() / 2];
+ }
+
+ const bool suspiciousGapBridge = gap > 1;
+ const bool suspiciousMissingOverlap = axisOverlap <= 0;
+ const bool suspiciousSpike = recentMedian > 0 &&
+ axisStep >= max( axisFrame / 6, recentMedian * 3 ) &&
+ axisOverlap < axisFrame * 3 / 4;
+
+ StitchLog( L"[Panorama/Stitch] Transition %zu: frames %zu->%zu gap=%d step=(%d,%d) axis=%ls axisStep=%d overlap=%d origin=(%d,%d)%ls%ls%ls recentMedian=%d\n",
+ i,
+ composedFrameIndices[i - 1],
+ composedFrameIndices[i],
+ gap,
+ step.x,
+ step.y,
+ mostlyVertical ? L"vertical" : L"horizontal",
+ axisStep,
+ axisOverlap,
+ origin.x,
+ origin.y,
+ suspiciousGapBridge ? L" [gap-bridge]" : L"",
+ suspiciousMissingOverlap ? L" [no-overlap]" : L"",
+ suspiciousSpike ? L" [spike]" : L"",
+ recentMedian );
+
+ recentAxisSteps.push_back( axisStep );
+ if( recentAxisSteps.size() > 8 )
+ {
+ recentAxisSteps.erase( recentAxisSteps.begin() );
+ }
+ }
+
+ StitchLog( L"[Panorama/Stitch] Composed transition diagnostics end\n" );
+}
+
+// Emit row-level ownership diagnostics after composition so seam artifacts can
+// be traced back to specific frames, blended handoffs, or true unwritten gaps.
+static void LogCompositionCoverageDiagnostics( const std::vector& stitchedOwner,
+ const std::vector& stitchedWritten,
+ const std::vector& stitchedBlended,
+ int stitchedWidth,
+ int stitchedHeight )
+{
+ if( !PanoramaDebugEnabled() || stitchedWidth <= 0 || stitchedHeight <= 0 ||
+ stitchedOwner.size() != stitchedWritten.size() ||
+ stitchedWritten.size() != stitchedBlended.size() )
+ {
+ return;
+ }
+
+ struct SuspiciousRowInfo
+ {
+ int y;
+ int unwrittenCount;
+ int blendedCount;
+ size_t segmentCount;
+ bool smallSegment;
+ std::wstring summary;
+ };
+
+ const int smallSegmentWidth = max( 4, min( 24, stitchedWidth / 40 ) );
+ std::vector suspiciousRows;
+ suspiciousRows.reserve( 16 );
+ int rowsWithGaps = 0;
+ int rowsWithBlend = 0;
+
+ for( int y = 0; y < stitchedHeight; ++y )
+ {
+ const size_t rowBase = static_cast( y ) * static_cast( stitchedWidth );
+ int unwrittenCount = 0;
+ int blendedCount = 0;
+ std::vector> segments;
+ segments.reserve( 12 );
+
+ auto markerForPixel = [&]( size_t idx )
+ {
+ if( stitchedWritten[idx] == 0 )
+ {
+ return -1;
+ }
+ if( stitchedBlended[idx] != 0 )
+ {
+ return -2;
+ }
+ return stitchedOwner[idx];
+ };
+
+ int currentMarker = markerForPixel( rowBase );
+ int currentLength = 0;
+ for( int x = 0; x < stitchedWidth; ++x )
+ {
+ const size_t idx = rowBase + static_cast( x );
+ const int marker = markerForPixel( idx );
+ if( stitchedWritten[idx] == 0 )
+ {
+ ++unwrittenCount;
+ }
+ if( stitchedBlended[idx] != 0 )
+ {
+ ++blendedCount;
+ }
+
+ if( marker != currentMarker )
+ {
+ segments.push_back( { currentMarker, currentLength } );
+ currentMarker = marker;
+ currentLength = 1;
+ }
+ else
+ {
+ ++currentLength;
+ }
+ }
+ segments.push_back( { currentMarker, currentLength } );
+
+ bool smallSegment = false;
+ for( size_t si = 1; si + 1 < segments.size(); ++si )
+ {
+ const int marker = segments[si].first;
+ const int length = segments[si].second;
+ if( marker != -1 && length <= smallSegmentWidth )
+ {
+ smallSegment = true;
+ break;
+ }
+ }
+
+ if( unwrittenCount > 0 )
+ {
+ ++rowsWithGaps;
+ }
+ if( blendedCount > 0 )
+ {
+ ++rowsWithBlend;
+ }
+
+ const bool suspiciousRow = unwrittenCount > 0 || smallSegment ||
+ ( blendedCount > max( 8, stitchedWidth / 12 ) && segments.size() >= 4 );
+ if( !suspiciousRow )
+ {
+ continue;
+ }
+
+ std::wstring summary;
+ for( size_t si = 0; si < segments.size() && si < 8; ++si )
+ {
+ if( si > 0 )
+ {
+ summary += L"|";
+ }
+
+ wchar_t segmentText[48]{};
+ const int marker = segments[si].first;
+ const int length = segments[si].second;
+ if( marker == -1 )
+ {
+ swprintf_s( segmentText, L"gap:%d", length );
+ }
+ else if( marker == -2 )
+ {
+ swprintf_s( segmentText, L"blend:%d", length );
+ }
+ else
+ {
+ swprintf_s( segmentText, L"f%d:%d", marker, length );
+ }
+ summary += segmentText;
+ }
+ if( segments.size() > 8 )
+ {
+ summary += L"|...";
+ }
+
+ suspiciousRows.push_back( { y, unwrittenCount, blendedCount, segments.size(), smallSegment, summary } );
+ }
+
+ StitchLog( L"[Panorama/Stitch] Coverage diagnostics rowsWithGaps=%d rowsWithBlend=%d suspiciousRows=%zu\n",
+ rowsWithGaps,
+ rowsWithBlend,
+ suspiciousRows.size() );
+ if( suspiciousRows.empty() )
+ {
+ return;
+ }
+
+ const size_t maxRowsToLog = 40;
+ for( size_t i = 0; i < suspiciousRows.size() && i < maxRowsToLog; ++i )
+ {
+ const auto& row = suspiciousRows[i];
+ StitchLog( L"[Panorama/Stitch] Coverage row y=%d unwritten=%d blended=%d segments=%zu smallSegment=%d summary=%s\n",
+ row.y,
+ row.unwrittenCount,
+ row.blendedCount,
+ row.segmentCount,
+ row.smallSegment ? 1 : 0,
+ row.summary.c_str() );
+ }
+ if( suspiciousRows.size() > maxRowsToLog )
+ {
+ StitchLog( L"[Panorama/Stitch] Coverage diagnostics truncated %zu additional suspicious row(s)\n",
+ suspiciousRows.size() - maxRowsToLog );
+ }
+}
+
+static std::wstring BuildStitchedRowSummary( const std::vector& stitchedOwner,
+ const std::vector& stitchedWritten,
+ const std::vector& stitchedBlended,
+ int stitchedWidth,
+ int stitchedHeight,
+ int y,
+ size_t maxSegments = 8 )
+{
+ std::wstring summary;
+ if( stitchedWidth <= 0 || stitchedHeight <= 0 || y < 0 || y >= stitchedHeight ||
+ stitchedOwner.size() != stitchedWritten.size() ||
+ stitchedWritten.size() != stitchedBlended.size() )
+ {
+ return summary;
+ }
+
+ const size_t rowBase = static_cast( y ) * static_cast( stitchedWidth );
+ auto markerForPixel = [&]( size_t idx )
+ {
+ if( stitchedWritten[idx] == 0 )
+ {
+ return -1;
+ }
+ if( stitchedBlended[idx] != 0 )
+ {
+ return -2;
+ }
+ return stitchedOwner[idx];
+ };
+
+ int currentMarker = markerForPixel( rowBase );
+ int currentLength = 0;
+ size_t emittedSegments = 0;
+ for( int x = 0; x < stitchedWidth; ++x )
+ {
+ const int marker = markerForPixel( rowBase + static_cast( x ) );
+ if( marker != currentMarker )
+ {
+ if( emittedSegments > 0 )
+ {
+ summary += L"|";
+ }
+
+ wchar_t segmentText[48]{};
+ if( currentMarker == -1 )
+ {
+ swprintf_s( segmentText, L"gap:%d", currentLength );
+ }
+ else if( currentMarker == -2 )
+ {
+ swprintf_s( segmentText, L"blend:%d", currentLength );
+ }
+ else
+ {
+ swprintf_s( segmentText, L"f%d:%d", currentMarker, currentLength );
+ }
+ summary += segmentText;
+
+ ++emittedSegments;
+ if( emittedSegments >= maxSegments )
+ {
+ summary += L"|...";
+ return summary;
+ }
+
+ currentMarker = marker;
+ currentLength = 1;
+ }
+ else
+ {
+ ++currentLength;
+ }
+ }
+
+ if( emittedSegments > 0 )
+ {
+ summary += L"|";
+ }
+ wchar_t segmentText[48]{};
+ if( currentMarker == -1 )
+ {
+ swprintf_s( segmentText, L"gap:%d", currentLength );
+ }
+ else if( currentMarker == -2 )
+ {
+ swprintf_s( segmentText, L"blend:%d", currentLength );
+ }
+ else
+ {
+ swprintf_s( segmentText, L"f%d:%d", currentLength == 0 ? -1 : currentMarker, currentLength );
+ }
+ summary += segmentText;
+ return summary;
+}
+
+static void LogSuspiciousTransitionWindowDiagnostics( const std::vector& stitchedPixels,
+ const std::vector& stitchedOwner,
+ const std::vector& stitchedWritten,
+ const std::vector& stitchedBlended,
+ const std::vector& rowBlendPixelCount,
+ const std::vector& rowBlendWeightSum,
+ const std::vector& rowBlendWeightMin,
+ const std::vector& rowBlendWeightMax,
+ const std::vector& rowBlendDominantFrame,
+ const std::vector& rowBlendDominantPixels,
+ const std::vector& rowFullWidthBlendFirstFrame,
+ const std::vector& rowFullWidthBlendFirstPass,
+ const std::vector& rowFullWidthBlendFirstWeight,
+ const std::vector& rowFullWidthBlendLastFrame,
+ const std::vector& rowFullWidthBlendLastPass,
+ const std::vector& rowFullWidthBlendLastWeight,
+ const std::vector& rowFullWidthBlendPassCount,
+ int stitchedWidth,
+ int stitchedHeight,
+ const std::vector& composedFrameIndices,
+ const std::vector& composedFrameOrigins,
+ const std::vector& composedFrameSteps,
+ int frameWidth,
+ int frameHeight,
+ int verticalFeather,
+ int horizontalFeather,
+ int minX,
+ int minY )
+{
+ if( !PanoramaDebugEnabled() || stitchedWidth <= 0 || stitchedHeight <= 0 ||
+ stitchedPixels.size() != static_cast( stitchedWidth ) * static_cast( stitchedHeight ) * 4 ||
+ stitchedOwner.size() != stitchedWritten.size() ||
+ stitchedWritten.size() != stitchedBlended.size() ||
+ rowBlendPixelCount.size() != static_cast( stitchedHeight ) ||
+ rowBlendWeightSum.size() != static_cast( stitchedHeight ) ||
+ rowBlendWeightMin.size() != static_cast( stitchedHeight ) ||
+ rowBlendWeightMax.size() != static_cast( stitchedHeight ) ||
+ rowBlendDominantFrame.size() != static_cast( stitchedHeight ) ||
+ rowBlendDominantPixels.size() != static_cast( stitchedHeight ) ||
+ rowFullWidthBlendFirstFrame.size() != static_cast( stitchedHeight ) ||
+ rowFullWidthBlendFirstPass.size() != static_cast( stitchedHeight ) ||
+ rowFullWidthBlendFirstWeight.size() != static_cast( stitchedHeight ) ||
+ rowFullWidthBlendLastFrame.size() != static_cast( stitchedHeight ) ||
+ rowFullWidthBlendLastPass.size() != static_cast( stitchedHeight ) ||
+ rowFullWidthBlendLastWeight.size() != static_cast( stitchedHeight ) ||
+ rowFullWidthBlendPassCount.size() != static_cast( stitchedHeight ) ||
+ composedFrameIndices.size() < 2 ||
+ composedFrameOrigins.size() != composedFrameIndices.size() ||
+ composedFrameSteps.size() != composedFrameIndices.size() )
+ {
+ return;
+ }
+
+ int totalAbsStepX = 0;
+ int totalAbsStepY = 0;
+ for( size_t i = 1; i < composedFrameSteps.size(); ++i )
+ {
+ totalAbsStepX += abs( composedFrameSteps[i].x );
+ totalAbsStepY += abs( composedFrameSteps[i].y );
+ }
+
+ const bool mostlyVerticalCapture = totalAbsStepY >= totalAbsStepX;
+ const int axisFrame = mostlyVerticalCapture ? frameHeight : frameWidth;
+ const int windowRadius = max( 12, min( 48, axisFrame / 10 ) );
+ const int minWrittenForSignal = stitchedWidth * 9 / 10;
+ const int maxPriorityTransitionsToLog = 12;
+ const int maxNonPriorityTransitionsToLog = 10;
+ int loggedTransitions = 0;
+ int loggedPriorityTransitions = 0;
+ int loggedNonPriorityTransitions = 0;
+
+ auto rowAverageLuma = [&]( int y )
+ {
+ if( y < 0 || y >= stitchedHeight )
+ {
+ return -1;
+ }
+
+ const size_t pixelRowBase = static_cast( y ) * static_cast( stitchedWidth ) * 4;
+ unsigned __int64 totalLuma = 0;
+ for( int x = 0; x < stitchedWidth; ++x )
+ {
+ const size_t pixelIdx = pixelRowBase + static_cast( x ) * 4;
+ totalLuma += ( static_cast( stitchedPixels[pixelIdx + 2] ) * 77 +
+ static_cast( stitchedPixels[pixelIdx + 1] ) * 150 +
+ static_cast( stitchedPixels[pixelIdx + 0] ) * 29 ) >> 8;
+ }
+ return static_cast( totalLuma / max( 1, stitchedWidth ) );
+ };
+
+ auto rowWrittenCount = [&]( int y )
+ {
+ if( y < 0 || y >= stitchedHeight )
+ {
+ return 0;
+ }
+
+ const size_t rowBase = static_cast( y ) * static_cast( stitchedWidth );
+ int written = 0;
+ for( int x = 0; x < stitchedWidth; ++x )
+ {
+ written += stitchedWritten[rowBase + static_cast( x )] != 0 ? 1 : 0;
+ }
+ return written;
+ };
+
+ auto rowBlendedCount = [&]( int y )
+ {
+ if( y < 0 || y >= stitchedHeight )
+ {
+ return 0;
+ }
+
+ const size_t rowBase = static_cast( y ) * static_cast( stitchedWidth );
+ int blended = 0;
+ for( int x = 0; x < stitchedWidth; ++x )
+ {
+ blended += stitchedBlended[rowBase + static_cast( x )] != 0 ? 1 : 0;
+ }
+ return blended;
+ };
+
+ auto rowBlendAverageWeight = [&]( int y )
+ {
+ if( y < 0 || y >= stitchedHeight || rowBlendPixelCount[y] <= 0 )
+ {
+ return 0;
+ }
+ return rowBlendWeightSum[y] / rowBlendPixelCount[y];
+ };
+
+ StitchLog( L"[Panorama/Stitch] Seam window diagnostics begin axis=%ls radius=%d\n",
+ mostlyVerticalCapture ? L"vertical" : L"horizontal",
+ windowRadius );
+
+ for( size_t i = 1; i < composedFrameIndices.size(); ++i )
+ {
+ const POINT& step = composedFrameSteps[i];
+ const int gap = static_cast( composedFrameIndices[i] - composedFrameIndices[i - 1] );
+ const int axisStep = mostlyVerticalCapture ? abs( step.y ) : abs( step.x );
+ const int axisOverlap = axisFrame - axisStep;
+ const bool suspiciousTransition =
+ gap > 1 || axisOverlap < axisFrame * 3 / 4 || axisStep >= axisFrame / 6;
+ const bool priorityTransition =
+ gap > 1 || i + 4 >= composedFrameIndices.size();
+ if( !suspiciousTransition )
+ {
+ continue;
+ }
+ if( priorityTransition )
+ {
+ if( loggedPriorityTransitions >= maxPriorityTransitionsToLog )
+ {
+ continue;
+ }
+ }
+ else if( loggedNonPriorityTransitions >= maxNonPriorityTransitionsToLog )
+ {
+ continue;
+ }
+
+ const int boundary = mostlyVerticalCapture
+ ? ( composedFrameOrigins[i].y - minY )
+ : ( composedFrameOrigins[i].x - minX );
+ const int windowStart = max( 0, boundary - windowRadius );
+ const int windowEnd = min( stitchedHeight - 1, boundary + windowRadius );
+ int featherStart = -1;
+ int featherEnd = -1;
+ int featherStartWeight = -1;
+ if( mostlyVerticalCapture && axisOverlap > 0 )
+ {
+ const int destinationY = composedFrameOrigins[i].y - minY;
+ if( step.y > 0 )
+ {
+ featherStart = destinationY + max( 0, axisOverlap - verticalFeather );
+ featherEnd = destinationY + max( 0, axisOverlap - 1 );
+ }
+ else if( step.y < 0 )
+ {
+ featherStart = destinationY + abs( step.y );
+ featherEnd = featherStart + max( 0, verticalFeather - 1 );
+ }
+ if( featherStart >= 0 )
+ {
+ featherStartWeight = 255 / max( 1, verticalFeather );
+ }
+ }
+ else if( !mostlyVerticalCapture && axisOverlap > 0 )
+ {
+ const int destinationX = composedFrameOrigins[i].x - minX;
+ if( step.x > 0 )
+ {
+ featherStart = destinationX + max( 0, axisOverlap - horizontalFeather );
+ featherEnd = destinationX + max( 0, axisOverlap - 1 );
+ }
+ else if( step.x < 0 )
+ {
+ featherStart = destinationX + abs( step.x );
+ featherEnd = featherStart + max( 0, horizontalFeather - 1 );
+ }
+ if( featherStart >= 0 )
+ {
+ featherStartWeight = 255 / max( 1, horizontalFeather );
+ }
+ }
+
+ int darkestRow = -1;
+ int darkestLuma = ( std::numeric_limits::max )();
+ int maxBlendRow = -1;
+ int maxBlendCount = -1;
+ for( int y = windowStart; y <= windowEnd; ++y )
+ {
+ const int writtenCount = rowWrittenCount( y );
+ if( writtenCount < minWrittenForSignal )
+ {
+ continue;
+ }
+
+ const int luma = rowAverageLuma( y );
+ if( luma >= 0 && luma < darkestLuma )
+ {
+ darkestLuma = luma;
+ darkestRow = y;
+ }
+
+ const int blendedCount = rowBlendedCount( y );
+ if( blendedCount > maxBlendCount )
+ {
+ maxBlendCount = blendedCount;
+ maxBlendRow = y;
+ }
+ }
+
+ StitchLog( L"[Panorama/Stitch] Seam transition %zu frames %zu->%zu gap=%d boundary=%d axisStep=%d overlap=%d feather=%d..%d featherStartWeight=%d window=%d..%d darkestRow=%d darkestLuma=%d maxBlendRow=%d maxBlend=%d\n",
+ i,
+ composedFrameIndices[i - 1],
+ composedFrameIndices[i],
+ gap,
+ boundary,
+ axisStep,
+ axisOverlap,
+ featherStart,
+ featherEnd,
+ featherStartWeight,
+ windowStart,
+ windowEnd,
+ darkestRow,
+ darkestLuma == ( std::numeric_limits::max )() ? -1 : darkestLuma,
+ maxBlendRow,
+ maxBlendCount );
+
+ const int featherMid = ( featherStart >= 0 && featherEnd >= featherStart )
+ ? ( featherStart + featherEnd ) / 2
+ : -1;
+ const int sampleRows[] = { boundary - 1, boundary, boundary + 1, featherStart, featherMid, featherEnd, darkestRow, maxBlendRow };
+ const wchar_t* sampleLabels[] = { L"boundary-1", L"boundary", L"boundary+1", L"featherStart", L"featherMid", L"featherEnd", L"darkest", L"maxBlend" };
+ for( int sampleIndex = 0; sampleIndex < ARRAYSIZE( sampleRows ); ++sampleIndex )
+ {
+ const int sampleRow = sampleRows[sampleIndex];
+ if( sampleRow < 0 || sampleRow >= stitchedHeight )
+ {
+ continue;
+ }
+
+ bool alreadyLogged = false;
+ for( int prior = 0; prior < sampleIndex; ++prior )
+ {
+ if( sampleRows[prior] == sampleRow )
+ {
+ alreadyLogged = true;
+ break;
+ }
+ }
+ if( alreadyLogged )
+ {
+ continue;
+ }
+
+ StitchLog( L"[Panorama/Stitch] Seam row transition=%zu label=%ls y=%d luma=%d written=%d blended=%d blendPixels=%d blendAvg=%d blendMin=%d blendMax=%d blendDominantFrame=%d blendDominantPixels=%d fullBlendFirst=(frame:%d pass:%d weight:%d) fullBlendLast=(frame:%d pass:%d weight:%d) fullBlendPasses=%d summary=%s\n",
+ i,
+ sampleLabels[sampleIndex],
+ sampleRow,
+ rowAverageLuma( sampleRow ),
+ rowWrittenCount( sampleRow ),
+ rowBlendedCount( sampleRow ),
+ rowBlendPixelCount[sampleRow],
+ rowBlendAverageWeight( sampleRow ),
+ rowBlendPixelCount[sampleRow] > 0 ? rowBlendWeightMin[sampleRow] : 0,
+ rowBlendPixelCount[sampleRow] > 0 ? rowBlendWeightMax[sampleRow] : 0,
+ rowBlendDominantFrame[sampleRow],
+ rowBlendDominantPixels[sampleRow],
+ rowFullWidthBlendFirstFrame[sampleRow],
+ rowFullWidthBlendFirstPass[sampleRow],
+ rowFullWidthBlendFirstWeight[sampleRow],
+ rowFullWidthBlendLastFrame[sampleRow],
+ rowFullWidthBlendLastPass[sampleRow],
+ rowFullWidthBlendLastWeight[sampleRow],
+ rowFullWidthBlendPassCount[sampleRow],
+ BuildStitchedRowSummary( stitchedOwner,
+ stitchedWritten,
+ stitchedBlended,
+ stitchedWidth,
+ stitchedHeight,
+ sampleRow ).c_str() );
+ }
+
+ ++loggedTransitions;
+ if( priorityTransition )
+ {
+ ++loggedPriorityTransitions;
+ }
+ else
+ {
+ ++loggedNonPriorityTransitions;
+ }
+ }
+
+ StitchLog( L"[Panorama/Stitch] Seam window diagnostics end logged=%d priority=%d nonPriority=%d\n",
+ loggedTransitions,
+ loggedPriorityTransitions,
+ loggedNonPriorityTransitions );
+}
+
+// Detect visually dark stitched bands even when the canvas has no unwritten
+// gaps so we can correlate full-width artifacts with a specific frame handoff.
+static void LogStitchedBandDiagnostics( const std::vector& stitchedPixels,
+ const std::vector& stitchedOwner,
+ const std::vector& stitchedWritten,
+ const std::vector& stitchedBlended,
+ const std::vector& rowBlendPixelCount,
+ const std::vector& rowBlendWeightSum,
+ const std::vector& rowBlendWeightMin,
+ const std::vector& rowBlendWeightMax,
+ const std::vector& rowFullWidthBlendFirstFrame,
+ const std::vector& rowFullWidthBlendFirstPass,
+ const std::vector& rowFullWidthBlendFirstWeight,
+ const std::vector& rowFullWidthBlendLastFrame,
+ const std::vector& rowFullWidthBlendLastPass,
+ const std::vector& rowFullWidthBlendLastWeight,
+ const std::vector& rowFullWidthBlendPassCount,
+ int stitchedWidth,
+ int stitchedHeight,
+ const std::vector& composedFrameIndices,
+ const std::vector& composedFrameOrigins,
+ int minY )
+{
+ if( !PanoramaDebugEnabled() || stitchedWidth <= 0 || stitchedHeight <= 0 ||
+ stitchedPixels.size() != static_cast( stitchedWidth ) * static_cast( stitchedHeight ) * 4 ||
+ stitchedOwner.size() != stitchedWritten.size() ||
+ stitchedWritten.size() != stitchedBlended.size() ||
+ rowBlendPixelCount.size() != static_cast( stitchedHeight ) ||
+ rowBlendWeightSum.size() != static_cast( stitchedHeight ) ||
+ rowBlendWeightMin.size() != static_cast( stitchedHeight ) ||
+ rowBlendWeightMax.size() != static_cast( stitchedHeight ) ||
+ rowFullWidthBlendFirstFrame.size() != static_cast( stitchedHeight ) ||
+ rowFullWidthBlendFirstPass.size() != static_cast( stitchedHeight ) ||
+ rowFullWidthBlendFirstWeight.size() != static_cast( stitchedHeight ) ||
+ rowFullWidthBlendLastFrame.size() != static_cast( stitchedHeight ) ||
+ rowFullWidthBlendLastPass.size() != static_cast( stitchedHeight ) ||
+ rowFullWidthBlendLastWeight.size() != static_cast( stitchedHeight ) ||
+ rowFullWidthBlendPassCount.size() != static_cast( stitchedHeight ) )
+ {
+ return;
+ }
+
+ std::vector rowLuma( stitchedHeight, 0 );
+ std::vector rowBlended( stitchedHeight, 0 );
+ std::vector rowWritten( stitchedHeight, 0 );
+ std::vector> darkBandFirstPassCounts;
+ std::vector> darkBandLastPassCounts;
+ auto incrementPassCount = []( std::vector>& counts, int pass )
+ {
+ if( pass < 0 )
+ {
+ return;
+ }
+ for( auto& entry : counts )
+ {
+ if( entry.first == pass )
+ {
+ entry.second++;
+ return;
+ }
+ }
+ counts.push_back( { pass, 1 } );
+ };
+ auto rowBlendAverageWeight = [&]( int y )
+ {
+ if( y < 0 || y >= stitchedHeight || rowBlendPixelCount[y] <= 0 )
+ {
+ return 0;
+ }
+ return rowBlendWeightSum[y] / rowBlendPixelCount[y];
+ };
+ for( int y = 0; y < stitchedHeight; ++y )
+ {
+ const size_t pixelRowBase = static_cast( y ) * static_cast( stitchedWidth ) * 4;
+ const size_t maskRowBase = static_cast( y ) * static_cast( stitchedWidth );
+ unsigned __int64 totalLuma = 0;
+ for( int x = 0; x < stitchedWidth; ++x )
+ {
+ const size_t pixelIdx = pixelRowBase + static_cast( x ) * 4;
+ totalLuma += ( static_cast( stitchedPixels[pixelIdx + 2] ) * 77 +
+ static_cast( stitchedPixels[pixelIdx + 1] ) * 150 +
+ static_cast( stitchedPixels[pixelIdx + 0] ) * 29 ) >> 8;
+
+ const size_t maskIdx = maskRowBase + static_cast( x );
+ if( stitchedWritten[maskIdx] != 0 )
+ {
+ ++rowWritten[y];
+ }
+ if( stitchedBlended[maskIdx] != 0 )
+ {
+ ++rowBlended[y];
+ }
+ }
+ rowLuma[y] = static_cast( totalLuma / max( 1, stitchedWidth ) );
+ }
+
+ struct DarkBandInfo
+ {
+ int startY;
+ int endY;
+ int centerY;
+ int avgLuma;
+ int referenceLuma;
+ int delta;
+ };
+
+ const int referenceRadius = max( 8, min( 24, stitchedHeight / 40 ) );
+ const int minBandThickness = 2;
+ const int maxBandsToLog = 12;
+ std::vector darkBands;
+ std::vector darkRowMask( stitchedHeight, 0 );
+
+ for( int y = referenceRadius; y < stitchedHeight - referenceRadius; ++y )
+ {
+ if( rowWritten[y] < stitchedWidth * 9 / 10 )
+ {
+ continue;
+ }
+
+ unsigned __int64 neighborhoodTotal = 0;
+ int neighborhoodCount = 0;
+ for( int offset = -referenceRadius; offset <= referenceRadius; ++offset )
+ {
+ if( offset == 0 || abs( offset ) <= 2 )
+ {
+ continue;
+ }
+
+ const int sampleY = y + offset;
+ neighborhoodTotal += static_cast( rowLuma[sampleY] );
+ ++neighborhoodCount;
+ }
+ if( neighborhoodCount <= 0 )
+ {
+ continue;
+ }
+
+ const int referenceLuma = static_cast( neighborhoodTotal / neighborhoodCount );
+ const int delta = referenceLuma - rowLuma[y];
+ const bool isDarkOutlier = referenceLuma >= 24 &&
+ delta >= max( 18, referenceLuma / 5 ) &&
+ rowBlended[y] >= stitchedWidth / 3;
+ if( isDarkOutlier )
+ {
+ darkRowMask[y] = 1;
+ }
+ }
+
+ for( int y = 0; y < stitchedHeight; )
+ {
+ if( darkRowMask[y] == 0 )
+ {
+ ++y;
+ continue;
+ }
+
+ const int startY = y;
+ int endY = y;
+ while( endY + 1 < stitchedHeight && darkRowMask[endY + 1] != 0 )
+ {
+ ++endY;
+ }
+
+ if( endY - startY + 1 >= minBandThickness )
+ {
+ unsigned __int64 bandLumaTotal = 0;
+ unsigned __int64 refLumaTotal = 0;
+ for( int bandY = startY; bandY <= endY; ++bandY )
+ {
+ bandLumaTotal += static_cast( rowLuma[bandY] );
+
+ unsigned __int64 neighborhoodTotal = 0;
+ int neighborhoodCount = 0;
+ for( int offset = -referenceRadius; offset <= referenceRadius; ++offset )
+ {
+ if( abs( offset ) <= 2 )
+ {
+ continue;
+ }
+
+ const int sampleY = bandY + offset;
+ if( sampleY < 0 || sampleY >= stitchedHeight )
+ {
+ continue;
+ }
+ neighborhoodTotal += static_cast( rowLuma[sampleY] );
+ ++neighborhoodCount;
+ }
+ if( neighborhoodCount > 0 )
+ {
+ refLumaTotal += neighborhoodTotal / neighborhoodCount;
+ }
+ }
+
+ const int rowCount = endY - startY + 1;
+ const int avgLuma = static_cast( bandLumaTotal / rowCount );
+ const int referenceLuma = static_cast( refLumaTotal / rowCount );
+ darkBands.push_back( { startY, endY, ( startY + endY ) / 2, avgLuma, referenceLuma, referenceLuma - avgLuma } );
+ }
+
+ y = endY + 1;
+ }
+
+ StitchLog( L"[Panorama/Stitch] Band diagnostics darkBands=%zu referenceRadius=%d\n",
+ darkBands.size(),
+ referenceRadius );
+ if( darkBands.empty() )
+ {
+ return;
+ }
+
+ for( size_t i = 0; i < darkBands.size() && i < maxBandsToLog; ++i )
+ {
+ const auto& band = darkBands[i];
+ int nearestBoundaryRow = -1;
+ size_t nearestBoundaryFrame = static_cast( -1 );
+ int nearestBoundaryDistance = ( std::numeric_limits::max )();
+ for( size_t framePos = 0; framePos < composedFrameIndices.size() && framePos < composedFrameOrigins.size(); ++framePos )
+ {
+ const int boundaryRow = composedFrameOrigins[framePos].y - minY;
+ const int distance = abs( boundaryRow - band.centerY );
+ if( distance < nearestBoundaryDistance )
+ {
+ nearestBoundaryDistance = distance;
+ nearestBoundaryRow = boundaryRow;
+ nearestBoundaryFrame = composedFrameIndices[framePos];
+ }
+ }
+
+ incrementPassCount( darkBandFirstPassCounts, rowFullWidthBlendFirstPass[band.centerY] );
+ incrementPassCount( darkBandLastPassCounts, rowFullWidthBlendLastPass[band.centerY] );
+
+ StitchLog( L"[Panorama/Stitch] Dark band y=%d..%d rows=%d avgLuma=%d refLuma=%d delta=%d blendedCenter=%d writtenCenter=%d blendPixelsCenter=%d blendAvgCenter=%d blendMinCenter=%d blendMaxCenter=%d fullBlendFirst=(frame:%d pass:%d weight:%d) fullBlendLast=(frame:%d pass:%d weight:%d) fullBlendPasses=%d nearestBoundaryRow=%d nearestBoundaryFrame=%zu boundaryDistance=%d summary=%s\n",
+ band.startY,
+ band.endY,
+ band.endY - band.startY + 1,
+ band.avgLuma,
+ band.referenceLuma,
+ band.delta,
+ rowBlended[band.centerY],
+ rowWritten[band.centerY],
+ rowBlendPixelCount[band.centerY],
+ rowBlendAverageWeight( band.centerY ),
+ rowBlendPixelCount[band.centerY] > 0 ? rowBlendWeightMin[band.centerY] : 0,
+ rowBlendPixelCount[band.centerY] > 0 ? rowBlendWeightMax[band.centerY] : 0,
+ rowFullWidthBlendFirstFrame[band.centerY],
+ rowFullWidthBlendFirstPass[band.centerY],
+ rowFullWidthBlendFirstWeight[band.centerY],
+ rowFullWidthBlendLastFrame[band.centerY],
+ rowFullWidthBlendLastPass[band.centerY],
+ rowFullWidthBlendLastWeight[band.centerY],
+ rowFullWidthBlendPassCount[band.centerY],
+ nearestBoundaryRow,
+ nearestBoundaryFrame,
+ nearestBoundaryDistance,
+ BuildStitchedRowSummary( stitchedOwner,
+ stitchedWritten,
+ stitchedBlended,
+ stitchedWidth,
+ stitchedHeight,
+ band.centerY ).c_str() );
+ }
+ if( !darkBandFirstPassCounts.empty() )
+ {
+ std::sort( darkBandFirstPassCounts.begin(), darkBandFirstPassCounts.end(), []( const auto& lhs, const auto& rhs )
+ {
+ return lhs.second > rhs.second;
+ } );
+ std::sort( darkBandLastPassCounts.begin(), darkBandLastPassCounts.end(), []( const auto& lhs, const auto& rhs )
+ {
+ return lhs.second > rhs.second;
+ } );
+
+ std::wstring firstSummary;
+ std::wstring lastSummary;
+ for( size_t idx = 0; idx < darkBandFirstPassCounts.size() && idx < 6; ++idx )
+ {
+ if( idx > 0 )
+ {
+ firstSummary += L"|";
+ }
+ wchar_t buffer[32]{};
+ swprintf_s( buffer, L"p%d:%d", darkBandFirstPassCounts[idx].first, darkBandFirstPassCounts[idx].second );
+ firstSummary += buffer;
+ }
+ for( size_t idx = 0; idx < darkBandLastPassCounts.size() && idx < 6; ++idx )
+ {
+ if( idx > 0 )
+ {
+ lastSummary += L"|";
+ }
+ wchar_t buffer[32]{};
+ swprintf_s( buffer, L"p%d:%d", darkBandLastPassCounts[idx].first, darkBandLastPassCounts[idx].second );
+ lastSummary += buffer;
+ }
+
+ StitchLog( L"[Panorama/Stitch] Dark band provenance firstPasses=%s lastPasses=%s\n",
+ firstSummary.c_str(),
+ lastSummary.c_str() );
+ }
+ if( darkBands.size() > maxBandsToLog )
+ {
+ StitchLog( L"[Panorama/Stitch] Band diagnostics truncated %zu additional dark band(s)\n",
+ darkBands.size() - maxBandsToLog );
+ }
+}
+
+// Post-composition content-duplication diagnostic.
+// For each composed frame, compute how many of its "unique" (non-overlap)
+// rows are pixel-identical to rows elsewhere on the canvas. This reveals
+// whether the matcher placed frames redundantly or the source content is
+// genuinely repetitive.
+static void LogContentDuplicationDiagnostics( const std::vector& stitchedPixels,
+ const std::vector& stitchedOwner,
+ int stitchedWidth,
+ int stitchedHeight,
+ const std::vector& composedFrameIndices,
+ const std::vector& composedFrameOrigins,
+ const std::vector& composedFrameSteps,
+ int frameWidth,
+ int frameHeight,
+ int minX,
+ int minY )
+{
+ if( !PanoramaDebugEnabled() || stitchedWidth <= 0 || stitchedHeight <= 0 ||
+ stitchedPixels.size() != static_cast( stitchedWidth ) * static_cast( stitchedHeight ) * 4 ||
+ composedFrameIndices.size() < 2 ||
+ composedFrameOrigins.size() != composedFrameIndices.size() ||
+ composedFrameSteps.size() != composedFrameIndices.size() )
+ {
+ return;
+ }
+
+ // Helper: compute average per-pixel RGB difference between two canvas rows.
+ auto rowDifference = [&]( int yA, int yB ) -> double
+ {
+ if( yA < 0 || yA >= stitchedHeight || yB < 0 || yB >= stitchedHeight )
+ return 999.0;
+ const size_t baseA = static_cast( yA ) * static_cast( stitchedWidth ) * 4;
+ const size_t baseB = static_cast( yB ) * static_cast( stitchedWidth ) * 4;
+ long long sum = 0;
+ // Sample every 4th pixel for speed.
+ int count = 0;
+ for( int x = 0; x < stitchedWidth; x += 4 )
+ {
+ const size_t offA = baseA + static_cast( x ) * 4;
+ const size_t offB = baseB + static_cast( x ) * 4;
+ sum += abs( static_cast( stitchedPixels[offA + 0] ) - static_cast( stitchedPixels[offB + 0] ) )
+ + abs( static_cast( stitchedPixels[offA + 1] ) - static_cast( stitchedPixels[offB + 1] ) )
+ + abs( static_cast( stitchedPixels[offA + 2] ) - static_cast( stitchedPixels[offB + 2] ) );
+ ++count;
+ }
+ return count > 0 ? static_cast( sum ) / ( count * 3.0 ) : 999.0;
+ };
+
+ // Helper: compute the dominant owner frame for a canvas row.
+ auto rowDominantOwner = [&]( int y ) -> int
+ {
+ if( y < 0 || y >= stitchedHeight )
+ return -1;
+ const size_t rowBase = static_cast( y ) * static_cast( stitchedWidth );
+ // Count the first owner seen (they're usually uniform for full-width frames).
+ return stitchedOwner[rowBase + static_cast( stitchedWidth / 2 )];
+ };
+
+ // Helper: average luma of a canvas row.
+ auto rowAverageLuma = [&]( int y ) -> int
+ {
+ if( y < 0 || y >= stitchedHeight )
+ return -1;
+ const size_t base = static_cast( y ) * static_cast( stitchedWidth ) * 4;
+ unsigned long long totalLuma = 0;
+ for( int x = 0; x < stitchedWidth; x += 4 )
+ {
+ const size_t off = base + static_cast( x ) * 4;
+ totalLuma += ( static_cast( stitchedPixels[off + 2] ) * 77 +
+ static_cast( stitchedPixels[off + 1] ) * 150 +
+ static_cast( stitchedPixels[off + 0] ) * 29 ) >> 8;
+ }
+ const int sampleCount = ( stitchedWidth + 3 ) / 4;
+ return static_cast( totalLuma / max( 1, sampleCount ) );
+ };
+
+ StitchLog( L"[Panorama/Stitch] Content duplication diagnostics begin\n" );
+
+ // Only inspect the last ~40% of composed transitions where artifacts cluster.
+ const size_t startTransition = composedFrameIndices.size() / 2;
+
+ int totalDuplicateTransitions = 0;
+ for( size_t i = startTransition; i < composedFrameIndices.size(); ++i )
+ {
+ const int stepY = composedFrameSteps[i].y;
+ const int absStepY = abs( stepY );
+ const int destY = composedFrameOrigins[i].y - minY;
+
+ // Determine the "new content" region: rows that should be unique.
+ // For downward scrolling (stepY > 0 i.e. step on canvas is positive),
+ // the new content is the bottom portion: rows [destY + overlapHeight, destY + frameHeight).
+ // For upward, the new content is the top portion.
+ const int overlapHeight = max( 0, frameHeight - absStepY );
+
+ int newContentStart = -1;
+ int newContentEnd = -1;
+ if( stepY > 0 && absStepY > 0 )
+ {
+ // Downward scroll: new content at the bottom of this frame's span.
+ newContentStart = destY + overlapHeight;
+ newContentEnd = destY + frameHeight;
+ }
+ else if( stepY < 0 && absStepY > 0 )
+ {
+ // Upward scroll: new content at the top.
+ newContentStart = destY;
+ newContentEnd = destY + absStepY;
+ }
+ else
+ {
+ continue; // No movement, skip.
+ }
+
+ // Clamp to canvas bounds.
+ newContentStart = max( 0, min( stitchedHeight, newContentStart ) );
+ newContentEnd = max( 0, min( stitchedHeight, newContentEnd ) );
+ const int newContentRows = newContentEnd - newContentStart;
+ if( newContentRows <= 0 )
+ continue;
+
+ // Check each new-content row against the canvas row that is
+ // exactly one step above. If pixel-identical, the frame is
+ // painting redundant content.
+ int identicalToStepAbove = 0;
+ int nearIdenticalToStepAbove = 0;
+ // Also check if it matches row at offset = overlapHeight above (full frame repeat).
+ int identicalToOverlapAbove = 0;
+ // Also scan for best-matching row within a search window above.
+ int identicalToBestMatch = 0;
+ int bestMatchSampleOffset = 0;
+
+ for( int row = newContentStart; row < newContentEnd; ++row )
+ {
+ // Compare to the row absStepY rows above (one "frame step" earlier).
+ const double diffStep = rowDifference( row, row - absStepY );
+ if( diffStep <= 0.5 )
+ ++identicalToStepAbove;
+ else if( diffStep <= 4.0 )
+ ++nearIdenticalToStepAbove;
+
+ // Compare to row overlapHeight above.
+ const double diffOverlap = rowDifference( row, row - overlapHeight );
+ if( diffOverlap <= 0.5 )
+ ++identicalToOverlapAbove;
+ }
+
+ // Sample a few rows for the best-match scan to keep it fast.
+ const int sampleRow = ( newContentStart + newContentEnd ) / 2;
+ double bestSampleDiff = 999.0;
+ for( int offset = 24; offset < min( stitchedHeight, 800 ); ++offset )
+ {
+ if( sampleRow - offset < 0 )
+ break;
+ const double d = rowDifference( sampleRow, sampleRow - offset );
+ if( d < bestSampleDiff )
+ {
+ bestSampleDiff = d;
+ bestMatchSampleOffset = offset;
+ }
+ }
+ if( bestSampleDiff <= 0.5 )
+ ++identicalToBestMatch;
+
+ const int gap = ( i > 0 ) ? static_cast( composedFrameIndices[i] - composedFrameIndices[i - 1] ) : 0;
+ const bool significantDuplication =
+ identicalToStepAbove > newContentRows / 3 ||
+ identicalToOverlapAbove > newContentRows / 3 ||
+ nearIdenticalToStepAbove > newContentRows * 2 / 3;
+
+ if( significantDuplication )
+ ++totalDuplicateTransitions;
+
+ // Log every transition in the tail region regardless of whether it's duplicate,
+ // so we can see the pattern. But use a compact format.
+ StitchLog( L"[Panorama/Stitch] FrameDup trans=%zu frame=%zu gap=%d step=(%d,%d) dest=%d newRows=%d..%d(%d) "
+ L"identStep=%d nearStep=%d identOverlap=%d bestMatchOff=%d bestMatchDiff=%.1f "
+ L"ownerAtNew=%d ownerAbove=%d lumaNew=%d lumaAbove=%d%ls\n",
+ i,
+ composedFrameIndices[i],
+ gap,
+ composedFrameSteps[i].x,
+ composedFrameSteps[i].y,
+ destY,
+ newContentStart,
+ newContentEnd,
+ newContentRows,
+ identicalToStepAbove,
+ nearIdenticalToStepAbove,
+ identicalToOverlapAbove,
+ bestMatchSampleOffset,
+ bestSampleDiff,
+ rowDominantOwner( ( newContentStart + newContentEnd ) / 2 ),
+ rowDominantOwner( ( newContentStart + newContentEnd ) / 2 - absStepY ),
+ rowAverageLuma( ( newContentStart + newContentEnd ) / 2 ),
+ rowAverageLuma( ( newContentStart + newContentEnd ) / 2 - absStepY ),
+ significantDuplication ? L" [DUPLICATE]" : L"" );
+ }
+
+ StitchLog( L"[Panorama/Stitch] Content duplication diagnostics end duplicateTransitions=%d/%zu\n",
+ totalDuplicateTransitions,
+ composedFrameIndices.size() - startTransition );
+}
+
+static bool FindBestFrameShiftVerticalOnly( const std::vector& previousPixels,
+ const std::vector& currentPixels,
+ int frameWidth,
+ int frameHeight,
+ int expectedDx,
+ int expectedDy,
+ int& bestDx,
+ int& bestDy,
+ bool lowContrastMode,
+ const std::vector& precomputedPrevLuma,
+ const std::vector& precomputedCurrLuma,
+ int precomputedVeryLowEntropy,
+ bool* outNearStationaryOverride,
+ bool allowHighConstStationaryRelax,
+ unsigned __int64* outMaskedStationaryScore,
+ bool forceExhaustiveProbeBudget,
+ bool forceExhaustiveFineDx );
+
+static void LogGapBridgeProbeDiagnostics( size_t frameIndex,
+ size_t lastAcceptedIndex,
+ int gap,
+ int acceptedDx,
+ int acceptedDy,
+ int expectedDx,
+ int expectedDy,
+ int frameWidth,
+ int frameHeight,
+ bool lowContrastMode,
+ const std::vector>& framePixels,
+ const std::vector>& frameLuma,
+ const std::vector& frameConstantFraction,
+ const std::vector& composedFrameSteps )
+{
+ if( gap <= 1 || frameIndex == 0 || lastAcceptedIndex >= frameIndex )
+ {
+ return;
+ }
+
+ int histAbsX = 0;
+ int histAbsY = 0;
+ for( size_t si = 1; si < composedFrameSteps.size(); ++si )
+ {
+ histAbsX += abs( composedFrameSteps[si].x );
+ histAbsY += abs( composedFrameSteps[si].y );
+ }
+
+ const bool mostlyVerticalHist = histAbsY > histAbsX * 3;
+ const bool mostlyHorizontalHist = histAbsX > histAbsY * 3;
+ std::vector recentAxisAbs;
+ recentAxisAbs.reserve( 8 );
+ for( int si = static_cast( composedFrameSteps.size() ) - 1;
+ si >= 1 && static_cast( recentAxisAbs.size() ) < 8;
+ --si )
+ {
+ const int axisValue = mostlyVerticalHist
+ ? abs( composedFrameSteps[static_cast( si )].y )
+ : ( mostlyHorizontalHist
+ ? abs( composedFrameSteps[static_cast( si )].x )
+ : 0 );
+ if( axisValue > 0 )
+ {
+ recentAxisAbs.push_back( axisValue );
+ }
+ }
+
+ int recentMedian = 0;
+ if( !recentAxisAbs.empty() )
+ {
+ std::sort( recentAxisAbs.begin(), recentAxisAbs.end() );
+ recentMedian = recentAxisAbs[recentAxisAbs.size() / 2];
+ }
+
+ StitchLog( L"[Panorama/Stitch] GapBridgeProbe begin frame=%zu ref=%zu gap=%d accepted=(%d,%d) expected=(%d,%d) recentMedian=%d mode=%ls\n",
+ frameIndex,
+ lastAcceptedIndex,
+ gap,
+ acceptedDx,
+ acceptedDy,
+ expectedDx,
+ expectedDy,
+ recentMedian,
+ mostlyVerticalHist ? L"vertical" : ( mostlyHorizontalHist ? L"horizontal" : L"neutral" ) );
+
+ int bridgeProbeDx = 0;
+ int bridgeProbeDy = 0;
+ bool bridgeProbeNearStationary = false;
+ const int bridgeVle = ( frameConstantFraction[lastAcceptedIndex] > 0.58 &&
+ frameConstantFraction[frameIndex] > 0.58 ) ? 1 : 0;
+ const bool bridgeProbeOk = FindBestFrameShiftVerticalOnly( framePixels[lastAcceptedIndex],
+ framePixels[frameIndex],
+ frameWidth,
+ frameHeight,
+ expectedDx * gap,
+ expectedDy * gap,
+ bridgeProbeDx,
+ bridgeProbeDy,
+ lowContrastMode,
+ frameLuma[lastAcceptedIndex],
+ frameLuma[frameIndex],
+ bridgeVle,
+ &bridgeProbeNearStationary,
+ false,
+ nullptr,
+ true,
+ true );
+ StitchLog( L"[Panorama/Stitch] GapBridgeProbe bridge-pair frame=%zu ref=%zu ok=%d probe=(%d,%d) expectedTotal=(%d,%d) nearStationary=%d\n",
+ frameIndex,
+ lastAcceptedIndex,
+ bridgeProbeOk ? 1 : 0,
+ bridgeProbeDx,
+ bridgeProbeDy,
+ expectedDx * gap,
+ expectedDy * gap,
+ bridgeProbeNearStationary ? 1 : 0 );
+
+ int adjacentProbeDx = 0;
+ int adjacentProbeDy = 0;
+ bool adjacentProbeNearStationary = false;
+ const int adjacentVle = ( frameConstantFraction[frameIndex - 1] > 0.58 &&
+ frameConstantFraction[frameIndex] > 0.58 ) ? 1 : 0;
+ const bool adjacentProbeOk = FindBestFrameShiftVerticalOnly( framePixels[frameIndex - 1],
+ framePixels[frameIndex],
+ frameWidth,
+ frameHeight,
+ expectedDx,
+ expectedDy,
+ adjacentProbeDx,
+ adjacentProbeDy,
+ lowContrastMode,
+ frameLuma[frameIndex - 1],
+ frameLuma[frameIndex],
+ adjacentVle,
+ &adjacentProbeNearStationary,
+ false,
+ nullptr,
+ true,
+ true );
+ StitchLog( L"[Panorama/Stitch] GapBridgeProbe adjacent-pair frame=%zu prev=%zu ok=%d probe=(%d,%d) expectedSingle=(%d,%d) nearStationary=%d\n",
+ frameIndex,
+ frameIndex - 1,
+ adjacentProbeOk ? 1 : 0,
+ adjacentProbeDx,
+ adjacentProbeDy,
+ expectedDx,
+ expectedDy,
+ adjacentProbeNearStationary ? 1 : 0 );
+}
+
+static int DivideRounded( int value, int divisor )
+{
+ if( divisor <= 1 )
+ {
+ return value;
+ }
+
+ if( value >= 0 )
+ {
+ return ( value + divisor / 2 ) / divisor;
+ }
+
+ return -( ( -value + divisor / 2 ) / divisor );
+}
+
+//----------------------------------------------------------------------------
+//
+// Performance profiling for FindBestFrameShiftVerticalOnly
+//
+//----------------------------------------------------------------------------
+#ifdef _DEBUG
+struct StitchPerfCounters
+{
+ LARGE_INTEGER freqQpc;
+ __int64 totalCalls;
+ __int64 tBuildDsLuma; // BuildDownsampledLuma
+ __int64 tStationary; // Stationary score
+ __int64 tVleMask; // VLE/HCF mask build + dilation
+ __int64 tCoarseSearch; // Coarse search loop
+ __int64 tFullResLuma; // BuildFullLumaFrame (when not precomputed)
+ __int64 tProbeInject; // Probe/candidate injection
+ __int64 tFineSearch; // Fine search (Phase 2)
+ __int64 tPostValidation; // Post-search validation/ambiguity
+ __int64 tTotal; // Total function time
+ __int64 tEdgeProjection; // Edge-density NCC (HCF injection)
+ __int64 tMaskedFallback; // Full-res masked coarse fallback
+
+ StitchPerfCounters() { Reset(); QueryPerformanceFrequency( &freqQpc ); }
+ void Reset() { memset( &totalCalls, 0, reinterpret_cast(&tMaskedFallback + 1) - reinterpret_cast(&totalCalls) ); }
+
+ double UsFromTicks( __int64 ticks ) const
+ {
+ return ticks * 1000000.0 / freqQpc.QuadPart;
+ }
+
+ void Report()
+ {
+ if( totalCalls == 0 ) return;
+ StitchLog( L"[Panorama/Perf] === FindBestFrameShiftVerticalOnly profiling (%lld calls) ===\n", totalCalls );
+ StitchLog( L"[Panorama/Perf] Total: %8.0f us (%.0f us/call)\n", UsFromTicks( tTotal ), UsFromTicks( tTotal ) / totalCalls );
+ StitchLog( L"[Panorama/Perf] BuildDsLuma: %8.0f us (%.1f%%)\n", UsFromTicks( tBuildDsLuma ), tBuildDsLuma * 100.0 / max( tTotal, 1LL ) );
+ StitchLog( L"[Panorama/Perf] Stationary: %8.0f us (%.1f%%)\n", UsFromTicks( tStationary ), tStationary * 100.0 / max( tTotal, 1LL ) );
+ StitchLog( L"[Panorama/Perf] VLE/HCF mask: %8.0f us (%.1f%%)\n", UsFromTicks( tVleMask ), tVleMask * 100.0 / max( tTotal, 1LL ) );
+ StitchLog( L"[Panorama/Perf] CoarseSearch: %8.0f us (%.1f%%)\n", UsFromTicks( tCoarseSearch ), tCoarseSearch * 100.0 / max( tTotal, 1LL ) );
+ StitchLog( L"[Panorama/Perf] MaskedFallback: %8.0f us (%.1f%%)\n", UsFromTicks( tMaskedFallback ), tMaskedFallback * 100.0 / max( tTotal, 1LL ) );
+ StitchLog( L"[Panorama/Perf] FullResLuma: %8.0f us (%.1f%%)\n", UsFromTicks( tFullResLuma ), tFullResLuma * 100.0 / max( tTotal, 1LL ) );
+ StitchLog( L"[Panorama/Perf] ProbeInject: %8.0f us (%.1f%%)\n", UsFromTicks( tProbeInject ), tProbeInject * 100.0 / max( tTotal, 1LL ) );
+ StitchLog( L"[Panorama/Perf] EdgeProjection: %8.0f us (%.1f%%)\n", UsFromTicks( tEdgeProjection ), tEdgeProjection * 100.0 / max( tTotal, 1LL ) );
+ StitchLog( L"[Panorama/Perf] FineSearch: %8.0f us (%.1f%%)\n", UsFromTicks( tFineSearch ), tFineSearch * 100.0 / max( tTotal, 1LL ) );
+ StitchLog( L"[Panorama/Perf] PostValidation: %8.0f us (%.1f%%)\n", UsFromTicks( tPostValidation ), tPostValidation * 100.0 / max( tTotal, 1LL ) );
+ StitchLog( L"[Panorama/Perf] ===================================================\n" );
+ }
+};
+static StitchPerfCounters g_StitchPerf;
+
+struct ScopedPerfTimer
+{
+ __int64& accumulator;
+ LARGE_INTEGER start;
+ ScopedPerfTimer( __int64& acc ) : accumulator( acc ) { QueryPerformanceCounter( &start ); }
+ ~ScopedPerfTimer() { LARGE_INTEGER end; QueryPerformanceCounter( &end ); accumulator += end.QuadPart - start.QuadPart; }
+};
+#define PERF_TIMER(field) ScopedPerfTimer _pt_##field( g_StitchPerf.field )
+#define PERF_START(field) LARGE_INTEGER _ps_##field; QueryPerformanceCounter( &_ps_##field )
+#define PERF_STOP(field) { LARGE_INTEGER _pe; QueryPerformanceCounter( &_pe ); g_StitchPerf.field += _pe.QuadPart - _ps_##field.QuadPart; }
+#else
+#define PERF_TIMER(field) ((void)0)
+#define PERF_START(field) ((void)0)
+#define PERF_STOP(field) ((void)0)
+#endif
+
+//----------------------------------------------------------------------------
+//
+// Panorama capture helpers
+//
+//----------------------------------------------------------------------------
+
+static HBITMAP CaptureAbsoluteScreenRectToBitmap(HDC hdcSource, const RECT& absoluteRect)
+{
+ const int captureWidth = absoluteRect.right - absoluteRect.left;
+ const int captureHeight = absoluteRect.bottom - absoluteRect.top;
+ if( captureWidth <= 0 || captureHeight <= 0 )
+ {
+ return nullptr;
+ }
+
+ // Use a DIB section instead of CreateCompatibleBitmap so that pixel
+ // data is stored in system memory. DDB bitmaps returned by
+ // CreateCompatibleBitmap may reside in video memory, and the driver
+ // can invalidate/repurpose that storage once the bitmap is deselected
+ // from all DCs. Later GetDIBits calls then read stale data, causing
+ // frames that have actually changed to appear identical.
+ BITMAPINFO bmi{};
+ bmi.bmiHeader.biSize = sizeof( BITMAPINFOHEADER );
+ bmi.bmiHeader.biWidth = captureWidth;
+ bmi.bmiHeader.biHeight = -captureHeight; // top-down
+ bmi.bmiHeader.biPlanes = 1;
+ bmi.bmiHeader.biBitCount = 32;
+ bmi.bmiHeader.biCompression = BI_RGB;
+
+ void* bits = nullptr;
+ HBITMAP hBitmap = CreateDIBSection( hdcSource, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0 );
+ if( hBitmap == nullptr )
+ {
+ return nullptr;
+ }
+
+ HDC hdcMem = CreateCompatibleDC( hdcSource );
+ if( hdcMem == nullptr )
+ {
+ DeleteObject( hBitmap );
+ return nullptr;
+ }
+
+ SelectObject( hdcMem, hBitmap );
+ BitBlt( hdcMem, 0, 0, captureWidth, captureHeight, hdcSource,
+ absoluteRect.left, absoluteRect.top, SRCCOPY | CAPTUREBLT );
+ GdiFlush();
+ DeleteDC( hdcMem );
+ return hBitmap;
+}
+
+static bool ReadBitmapPixels32(HBITMAP hBitmap, std::vector& pixels, int& width, int& height)
+{
+ BITMAP bitmap{};
+ if( GetObject( hBitmap, sizeof(bitmap), &bitmap ) == 0 )
+ {
+ return false;
+ }
+
+ width = bitmap.bmWidth;
+ height = bitmap.bmHeight;
+ if( width <= 0 || height <= 0 )
+ {
+ return false;
+ }
+
+ pixels.resize( static_cast(width) * static_cast(height) * 4 );
+ BITMAPINFO bmi{};
+ bmi.bmiHeader.biSize = sizeof( BITMAPINFOHEADER );
+ bmi.bmiHeader.biWidth = width;
+ bmi.bmiHeader.biHeight = -height;
+ bmi.bmiHeader.biPlanes = 1;
+ bmi.bmiHeader.biBitCount = 32;
+ bmi.bmiHeader.biCompression = BI_RGB;
+
+ HDC hdc = GetDC( nullptr );
+ const int copied = GetDIBits( hdc, hBitmap, 0, static_cast(height), pixels.data(), &bmi, DIB_RGB_COLORS );
+ ReleaseDC( nullptr, hdc );
+ return copied == height;
+}
+
+static HBITMAP CreateBitmapFromPixels32( const std::vector& pixels, int width, int height )
+{
+ if( width <= 0 || height <= 0 || pixels.size() != static_cast( width ) * static_cast( height ) * 4 )
+ {
+ return nullptr;
+ }
+
+ BITMAPINFO bmi{};
+ bmi.bmiHeader.biSize = sizeof( BITMAPINFOHEADER );
+ bmi.bmiHeader.biWidth = width;
+ bmi.bmiHeader.biHeight = -height;
+ bmi.bmiHeader.biPlanes = 1;
+ bmi.bmiHeader.biBitCount = 32;
+ bmi.bmiHeader.biCompression = BI_RGB;
+
+ HDC hdc = GetDC( nullptr );
+ if( hdc == nullptr )
+ {
+ return nullptr;
+ }
+
+ void* bits = nullptr;
+ HBITMAP bitmap = CreateDIBSection( hdc, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0 );
+ if( bitmap != nullptr && bits != nullptr )
+ {
+ memcpy( bits, pixels.data(), pixels.size() );
+ }
+ else if( bitmap != nullptr )
+ {
+ DeleteObject( bitmap );
+ bitmap = nullptr;
+ }
+
+ ReleaseDC( nullptr, hdc );
+ return bitmap;
+}
+
+static std::wstring CreatePanoramaDebugDumpDirectory()
+{
+ std::error_code errorCode;
+ std::filesystem::path debugRoot;
+
+ wchar_t tempPath[MAX_PATH]{};
+ const DWORD tempPathLength = GetTempPathW( ARRAYSIZE( tempPath ), tempPath );
+ if( tempPathLength != 0 && tempPathLength < ARRAYSIZE( tempPath ) )
+ {
+ debugRoot = std::filesystem::path( tempPath ) / L"ZoomItPanoramaDebug";
+ }
+ else
+ {
+ wchar_t modulePath[MAX_PATH]{};
+ if( GetModuleFileNameW( nullptr, modulePath, ARRAYSIZE( modulePath ) ) == 0 )
+ {
+ return {};
+ }
+
+ debugRoot = std::filesystem::path( modulePath ).parent_path() / L"debug" / L"ZoomItPanoramaDebug";
+ }
+
+ std::filesystem::create_directories( debugRoot, errorCode );
+ if( errorCode )
+ {
+ return {};
+ }
+
+ SYSTEMTIME localTime{};
+ GetLocalTime( &localTime );
+ wchar_t stamp[96]{};
+ swprintf_s( stamp,
+ L"panorama_%04u%02u%02u_%02u%02u%02u_%lu",
+ static_cast( localTime.wYear ),
+ static_cast( localTime.wMonth ),
+ static_cast( localTime.wDay ),
+ static_cast( localTime.wHour ),
+ static_cast( localTime.wMinute ),
+ static_cast( localTime.wSecond ),
+ GetCurrentProcessId() );
+
+ const auto sessionDirectory = debugRoot / stamp;
+ std::filesystem::create_directories( sessionDirectory, errorCode );
+ if( errorCode )
+ {
+ return {};
+ }
+
+ return sessionDirectory.wstring();
+}
+
+#ifdef _DEBUG
+static std::filesystem::path GetPanoramaDebugRootDirectory()
+{
+ wchar_t tempPath[MAX_PATH]{};
+ const DWORD tempPathLength = GetTempPathW( ARRAYSIZE( tempPath ), tempPath );
+ if( tempPathLength != 0 && tempPathLength < ARRAYSIZE( tempPath ) )
+ {
+ return std::filesystem::path( tempPath ) / L"ZoomItPanoramaDebug";
+ }
+
+ wchar_t modulePath[MAX_PATH]{};
+ if( GetModuleFileNameW( nullptr, modulePath, ARRAYSIZE( modulePath ) ) == 0 )
+ {
+ return {};
+ }
+
+ return std::filesystem::path( modulePath ).parent_path() / L"debug" / L"ZoomItPanoramaDebug";
+}
+#endif // _DEBUG
+
+static bool SaveBitmapAsBmp( HBITMAP bitmap, const std::filesystem::path& filePath )
+{
+ if( bitmap == nullptr )
+ {
+ return false;
+ }
+
+ std::vector pixels;
+ int width = 0;
+ int height = 0;
+ if( !ReadBitmapPixels32( bitmap, pixels, width, height ) )
+ {
+ return false;
+ }
+
+ const DWORD imageSize = static_cast( pixels.size() );
+ BITMAPFILEHEADER fileHeader{};
+ fileHeader.bfType = 0x4D42;
+ fileHeader.bfOffBits = sizeof( BITMAPFILEHEADER ) + sizeof( BITMAPINFOHEADER );
+ fileHeader.bfSize = fileHeader.bfOffBits + imageSize;
+
+ BITMAPINFOHEADER infoHeader{};
+ infoHeader.biSize = sizeof( BITMAPINFOHEADER );
+ infoHeader.biWidth = width;
+ infoHeader.biHeight = -height;
+ infoHeader.biPlanes = 1;
+ infoHeader.biBitCount = 32;
+ infoHeader.biCompression = BI_RGB;
+ infoHeader.biSizeImage = imageSize;
+
+ std::ofstream stream( filePath, std::ios::binary | std::ios::trunc );
+ if( !stream.good() )
+ {
+ return false;
+ }
+
+ stream.write( reinterpret_cast( &fileHeader ), sizeof( fileHeader ) );
+ stream.write( reinterpret_cast( &infoHeader ), sizeof( infoHeader ) );
+ stream.write( reinterpret_cast( pixels.data() ), static_cast( pixels.size() ) );
+ return stream.good();
+}
+
+static void DumpPanoramaBitmap( const std::wstring& debugDumpDirectory,
+ const wchar_t* prefix,
+ size_t index,
+ HBITMAP bitmap )
+{
+ if( debugDumpDirectory.empty() || bitmap == nullptr )
+ {
+ return;
+ }
+
+ wchar_t fileName[96]{};
+ swprintf_s( fileName, L"%s_%04zu.bmp", prefix, index );
+ const auto outputPath = std::filesystem::path( debugDumpDirectory ) / fileName;
+ if( !SaveBitmapAsBmp( bitmap, outputPath ) )
+ {
+ OutputDebug( L"[Panorama/Debug] Failed to save %s\n", outputPath.c_str() );
+ }
+}
+
+static void DumpPanoramaText( const std::wstring& debugDumpDirectory,
+ const wchar_t* fileName,
+ const std::wstring& text )
+{
+ if( debugDumpDirectory.empty() )
+ {
+ return;
+ }
+
+ const auto outputPath = std::filesystem::path( debugDumpDirectory ) / fileName;
+ std::wofstream stream( outputPath, std::ios::trunc );
+ if( !stream.good() )
+ {
+ OutputDebug( L"[Panorama/Debug] Failed to write %s\n", outputPath.c_str() );
+ return;
+ }
+
+ stream << text;
+}
+
+#ifdef _DEBUG
+static HBITMAP LoadBitmapFromFile( const std::filesystem::path& filePath )
+{
+ return static_cast( LoadImageW( nullptr,
+ filePath.c_str(),
+ IMAGE_BITMAP,
+ 0,
+ 0,
+ LR_LOADFROMFILE | LR_CREATEDIBSECTION ) );
+}
+
+static bool RunPanoramaStitchFromDumpDirectory( const std::filesystem::path& dumpDirectory,
+ std::filesystem::path& outputPath )
+{
+ std::error_code errorCode;
+ if( !std::filesystem::exists( dumpDirectory, errorCode ) || errorCode )
+ {
+ StitchLog( L"[Panorama/Replay] Dump directory does not exist: %s\n", dumpDirectory.c_str() );
+ return false;
+ }
+
+ std::vector acceptedFramePaths;
+ std::vector grabbedFramePaths;
+ for( const auto& entry : std::filesystem::directory_iterator( dumpDirectory, errorCode ) )
+ {
+ if( errorCode )
+ {
+ break;
+ }
+
+ if( !entry.is_regular_file() )
+ {
+ continue;
+ }
+
+ const auto fileName = entry.path().filename().wstring();
+ if( fileName.rfind( L"accepted_", 0 ) == 0 && entry.path().extension() == L".bmp" )
+ {
+ acceptedFramePaths.push_back( entry.path() );
+ }
+ else if( fileName.rfind( L"grabbed_", 0 ) == 0 && entry.path().extension() == L".bmp" )
+ {
+ grabbedFramePaths.push_back( entry.path() );
+ }
+ }
+
+ const bool useGrabbedFrames = grabbedFramePaths.size() >= 2 && acceptedFramePaths.size() < 2;
+ std::vector& framePaths = useGrabbedFrames ? grabbedFramePaths : acceptedFramePaths;
+
+ if( framePaths.size() < 2 )
+ {
+ StitchLog( L"[Panorama/Replay] Need at least 2 replay frames in %s; accepted=%zu grabbed=%zu\n",
+ dumpDirectory.c_str(),
+ acceptedFramePaths.size(),
+ grabbedFramePaths.size() );
+ return false;
+ }
+
+ std::sort( framePaths.begin(), framePaths.end() );
+ StitchLog( L"[Panorama/Replay] Using %s frame set count=%zu in %s\n",
+ useGrabbedFrames ? L"grabbed" : L"accepted",
+ framePaths.size(),
+ dumpDirectory.c_str() );
+ wprintf( L"[Replay] Loading %zu %s frames from %s\n",
+ framePaths.size(),
+ useGrabbedFrames ? L"grabbed" : L"accepted",
+ dumpDirectory.c_str() );
+ fflush( stdout );
+
+ std::vector frames;
+ frames.reserve( framePaths.size() );
+ for( const auto& framePath : framePaths )
+ {
+ HBITMAP bitmap = LoadBitmapFromFile( framePath );
+ if( bitmap == nullptr )
+ {
+ StitchLog( L"[Panorama/Replay] Failed to load frame: %s\n", framePath.c_str() );
+ for( HBITMAP frame : frames )
+ {
+ DeleteObject( frame );
+ }
+ return false;
+ }
+
+ frames.push_back( bitmap );
+ }
+
+ // Replay writes into stitch_log.txt so before/after comparisons use
+ // the same canonical trace file as capture and selftest runs.
+ {
+ const auto logPath = dumpDirectory / L"stitch_log.txt";
+ FILE* replayLogFile = nullptr;
+ if( _wfopen_s( &replayLogFile, logPath.c_str(), L"ab" ) == 0 )
+ {
+ g_StitchLogFile = replayLogFile;
+ StitchLog( L"\n[Panorama/Replay] ===== Replay run begin =====\n" );
+ StitchLog( L"[Panorama/Replay] Dump directory: %s\n", dumpDirectory.c_str() );
+ }
+ }
+
+ struct ReplayLogCloser
+ {
+ ~ReplayLogCloser()
+ {
+ if( g_StitchLogFile != nullptr )
+ {
+ fclose( g_StitchLogFile );
+ g_StitchLogFile = nullptr;
+ }
+ }
+ } replayLogCloser;
+
+ wprintf( L"[Replay] Stitching %zu frames ...\n", frames.size() );
+ fflush( stdout );
+ int lastPercent = -1;
+ HBITMAP stitched = StitchPanoramaFrames( frames, false, [&]( int percent ) -> bool
+ {
+ if( percent != lastPercent )
+ {
+ lastPercent = percent;
+ wprintf( L"\r[Replay] Stitching ... %d%%", percent );
+ fflush( stdout );
+ }
+ return false; // false = not cancelled
+ } );
+ wprintf( L"\r[Replay] Stitching ... done \n" );
+ fflush( stdout );
+
+ for( HBITMAP frame : frames )
+ {
+ DeleteObject( frame );
+ }
+
+ if( stitched == nullptr )
+ {
+ wprintf( L"[Replay] FAILED: stitcher returned null\n" );
+ StitchLog( L"[Panorama/Replay] StitchPanoramaFrames failed for %s\n", dumpDirectory.c_str() );
+ return false;
+ }
+
+ outputPath = dumpDirectory / ( useGrabbedFrames ? L"stitched_replay_grabbed_0000.bmp" : L"stitched_replay_0000.bmp" );
+ const bool saved = SaveBitmapAsBmp( stitched, outputPath );
+ DeleteObject( stitched );
+ if( !saved )
+ {
+ wprintf( L"[Replay] FAILED: could not save output\n" );
+ StitchLog( L"[Panorama/Replay] Failed to save stitched replay: %s\n", outputPath.c_str() );
+ return false;
+ }
+
+ wprintf( L"[Replay] Saved: %s\n", outputPath.c_str() );
+ fflush( stdout );
+ StitchLog( L"[Panorama/Replay] Saved stitched replay: %s\n", outputPath.c_str() );
+ return true;
+}
+#endif // _DEBUG
+
+static bool ComputeAveragePixelDifference( const std::vector& currentPixels,
+ const std::vector& previousPixels,
+ int frameWidth,
+ int frameHeight,
+ unsigned __int64& avgDiff,
+ double& changedPixelFraction,
+ int sampleStep = 6,
+ unsigned phase = 0 )
+{
+ if( currentPixels.size() != previousPixels.size() || frameWidth <= 0 || frameHeight <= 0 )
+ return false;
+
+ const int stride = frameWidth * 4;
+ const int marginX = max( 4, frameWidth / 40 );
+ const int marginY = max( 4, frameHeight / 40 );
+ const int startX = marginX;
+ const int endX = frameWidth - marginX;
+ const int startY = marginY;
+ const int endY = frameHeight - marginY;
+
+ if( endX <= startX || endY <= startY )
+ return false;
+
+ const int step = max( 1, sampleStep );
+ const int phaseX = ( step > 1 ) ? static_cast( ( phase * 3u ) % static_cast( step ) ) : 0;
+ const int phaseY = ( step > 1 ) ? static_cast( ( phase * 5u ) % static_cast( step ) ) : 0;
+
+ int y0 = startY + phaseY;
+ if( y0 >= endY ) y0 = startY;
+
+ int x0 = startX + phaseX;
+ if( x0 >= endX ) x0 = startX;
+
+ unsigned __int64 totalDiff = 0;
+ unsigned __int64 samples = 0;
+ unsigned __int64 changedPixels = 0;
+ unsigned __int64 pixelSamples = 0;
+
+ for( int y = y0; y < endY; y += step )
+ {
+ const int rowOffset = y * stride;
+ for( int x = x0; x < endX; x += step )
+ {
+ const int index = rowOffset + x * 4;
+ const int d0 = abs( static_cast( currentPixels[index + 0] ) - static_cast( previousPixels[index + 0] ) );
+ const int d1 = abs( static_cast( currentPixels[index + 1] ) - static_cast( previousPixels[index + 1] ) );
+ const int d2 = abs( static_cast( currentPixels[index + 2] ) - static_cast( previousPixels[index + 2] ) );
+ const int sum = d0 + d1 + d2;
+
+ totalDiff += static_cast( sum );
+ samples += 3;
+ pixelSamples++;
+ if( sum > 30 )
+ changedPixels++;
+ }
+ }
+
+ if( samples == 0 )
+ return false;
+
+ avgDiff = totalDiff / samples;
+ changedPixelFraction = ( pixelSamples > 0 )
+ ? static_cast( changedPixels ) / static_cast( pixelSamples )
+ : 0.0;
+
+ return true;
+}
+
+static bool IsLowContrastSeedFrame( HBITMAP frame,
+ double* outSpread = nullptr,
+ double* outStdDev = nullptr,
+ double* outEdgeDelta = nullptr )
+{
+ std::vector pixels;
+ int frameWidth = 0;
+ int frameHeight = 0;
+ if( !ReadBitmapPixels32( frame, pixels, frameWidth, frameHeight ) || frameWidth <= 0 || frameHeight <= 0 )
+ {
+ return false;
+ }
+
+ const int sampleStep = max( 1, min( frameWidth, frameHeight ) / 320 );
+ unsigned __int64 histogram[256]{};
+ unsigned __int64 sampleCount = 0;
+ unsigned __int64 sum = 0;
+ unsigned __int64 sumSq = 0;
+ unsigned __int64 edgeDeltaSum = 0;
+ unsigned __int64 edgeSamples = 0;
+
+ auto pixelLuma = [&]( int x, int y ) -> int
+ {
+ const int idx = ( y * frameWidth + x ) * 4;
+ return ( pixels[idx + 2] * 77 + pixels[idx + 1] * 150 + pixels[idx + 0] * 29 ) >> 8;
+ };
+
+ for( int y = 0; y < frameHeight; y += sampleStep )
+ {
+ for( int x = 0; x < frameWidth; x += sampleStep )
+ {
+ const int luma = pixelLuma( x, y );
+ histogram[luma]++;
+ sampleCount++;
+ sum += static_cast( luma );
+ sumSq += static_cast( luma * luma );
+
+ const int nextX = min( frameWidth - 1, x + sampleStep );
+ const int nextY = min( frameHeight - 1, y + sampleStep );
+ if( nextX != x )
+ {
+ edgeDeltaSum += static_cast( abs( luma - pixelLuma( nextX, y ) ) );
+ edgeSamples++;
+ }
+ if( nextY != y )
+ {
+ edgeDeltaSum += static_cast( abs( luma - pixelLuma( x, nextY ) ) );
+ edgeSamples++;
+ }
+ }
+ }
+
+ if( sampleCount < 64 )
+ {
+ return false;
+ }
+
+ const auto percentileLuma = [&]( int percentile ) -> int
+ {
+ const unsigned __int64 target = ( sampleCount * static_cast( percentile ) ) / 100;
+ unsigned __int64 running = 0;
+ for( int l = 0; l < 256; ++l )
+ {
+ running += histogram[l];
+ if( running >= target )
+ {
+ return l;
+ }
+ }
+ return 255;
+ };
+
+ const int p10 = percentileLuma( 10 );
+ const int p90 = percentileLuma( 90 );
+ const double spread = static_cast( p90 - p10 );
+ const double mean = static_cast( sum ) / static_cast( sampleCount );
+ const double meanSq = static_cast( sumSq ) / static_cast