From f1e00436f49248ff6cc01b1342c26fad922d7d11 Mon Sep 17 00:00:00 2001 From: Kyuwon Kim Date: Wed, 14 Oct 2026 06:37:21 +0900 Subject: [PATCH 1/2] Add reference-based multi-source metrics and high-DPI analysis layout (#139) Deliver independent A/B, A/C and A/D current, ROI and sequence results, bounded per-pair refreshes, four Timeline lanes and compact per-candidate footers. Record native verification limits and prepare the 2.8.0 release notes. --- CHANGELOG.md | 21 +- Comparator/AnalysisLayout.h | 33 ++ Comparator/AnalysisScrollView.h | 157 +++++++ Comparator/Comparator.vcxproj | 3 + Comparator/ComparatorDoc.cpp | 299 ++++++------ Comparator/ComparatorDoc.h | 28 +- Comparator/ComparatorPane.h | 12 +- Comparator/ComparatorView.cpp | 33 +- Comparator/ComparisonResults.h | 39 ++ Comparator/FileScanThread.cpp | 398 ++++++++++------ Comparator/FileScanThread.h | 47 +- Comparator/FrmInfoView.cpp | 185 ++++++-- Comparator/FrmInfoView.h | 8 +- Comparator/FrmsInfoView.cpp | 433 +++++++---------- Comparator/FrmsInfoView.h | 20 +- Comparator/LpipsEngine.cpp | 32 +- Comparator/LpipsEngine.h | 1 + Comparator/LpipsScanThread.cpp | 176 ++++--- Comparator/LpipsScanThread.h | 6 +- Comparator/MainFrm.cpp | 33 +- Comparator/MainFrm.h | 4 +- Comparator/PosInfoView.cpp | 147 +++--- Comparator/PosInfoView.h | 2 + Comparator/RgbFrmCmpStrategy.cpp | 2 + Comparator/YuvFrmCmpStrategy.cpp | 2 + Tests/ComparerTypographyIntegrationTests.cpp | 434 +++++++++++++++++- Tests/WindowsUiTypographyTests.cpp | 25 + docs/STORE_LISTING.md | 2 +- docs/USER_GUIDE.md | 34 +- .../issue-139-reference-comparisons.md | 88 ++++ 30 files changed, 1877 insertions(+), 827 deletions(-) create mode 100644 Comparator/AnalysisLayout.h create mode 100644 Comparator/AnalysisScrollView.h create mode 100644 Comparator/ComparisonResults.h create mode 100644 docs/verification/issue-139-reference-comparisons.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 90146152..2ada628f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,25 @@ All notable changes to Q1View are documented here. Releases follow [semantic ver ## [Unreleased] +--- + +## [2.8.0] — 2026-10-14 + ### Added -- Windows Viewer and Comparator: choose a shared System, Light, or Dark theme from Viewer's Options > Theme menu; Comparator follows automatically without a duplicate theme menu. Application colors and image-surround brightness change together while honoring Windows high contrast and preserving source/capture pixels, zoom/pan, ROI, comparison results, playback, and thumbnail caches. (issue #118) +- Windows Comparator: compare up to four sources against reference A, with independent A/B, A/C and A/D PSNR, SSIM, LPIPS and ROI results directly under each candidate, separate sequence graphs, and four Timeline lanes. Pair-specific frame ranges and shared reference decoding avoid repeated work. (#139) + +- Windows Viewer and Comparator: choose shared System, Light or Dark colors from Viewer's Options > Theme. Comparator follows automatically, including its Timeline scrollbar. UI and image-surround colors change together, respecting Windows high contrast without changing image pixels or media state. (#118) + +### Fixed + +- Comparator: keep useful image space in small high-DPI windows with wrapped menus and scrollable results/graphs; prevent negative canvas heights. + +- Comparator: flag unaligned frames, refresh only affected pairs after seeking/reloading, preserve other results, and reject superseded requests. (#139) + +- Viewer and Comparator: eliminate bright flashes in Help/zoom menus, result controls and empty/loading views; preserve keyboard/accessibility behavior and stable menu placement. -- Windows Comparator: the Timeline scrollbar now matches the application theme, with hover and drag feedback, while retaining native keyboard and accessibility support and native rendering in Windows high contrast. Empty, still-image, and fully fitting timelines hide the scrollbar; wheel scrolling no longer applies its movement twice. (issue #118) +Native display checks passed at 100–175%; 200% layout/font calculations passed, but native 200% display testing remains unverified on this PC. --- @@ -489,7 +503,8 @@ All notable changes to Q1View are documented here. Releases follow [semantic ver --- -[Unreleased]: https://github.com/chammoru/Q1View/compare/v2.7.28...HEAD +[Unreleased]: https://github.com/chammoru/Q1View/compare/v2.8.0...HEAD +[2.8.0]: https://github.com/chammoru/Q1View/compare/v2.7.28...v2.8.0 [2.7.28]: https://github.com/chammoru/Q1View/compare/v2.7.27...v2.7.28 [2.7.27]: https://github.com/chammoru/Q1View/compare/v2.7.26...v2.7.27 [2.7.26]: https://github.com/chammoru/Q1View/compare/v2.7.25...v2.7.26 diff --git a/Comparator/AnalysisLayout.h b/Comparator/AnalysisLayout.h new file mode 100644 index 00000000..6ee51dfa --- /dev/null +++ b/Comparator/AnalysisLayout.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +struct AnalysisHeights { + int source = 0, footer = 0, graph = 0; +}; + +// Preserve the preferred layout when it fits. In small/high-DPI windows first +// shrink the scrollable graph, then the scrollable footer, rather than letting +// their ideal heights consume the image canvas. All inputs/results are pixels. +inline AnalysisHeights ResolveAnalysisHeights(int content, int graph, int footer, + int minimumSource, int minimumGraph, int minimumFooter, int dividers) +{ + const int available=(std::max)(0,content-(std::max)(0,dividers)); + AnalysisHeights result; + result.graph=(std::min)((std::max)(0,graph),available); + result.footer=(std::min)((std::max)(0,footer),available-result.graph); + int deficit=(std::max)(0,(std::min)((std::max)(0,minimumSource),available) + -(available-result.graph-result.footer)); + auto shrink=[&](int& height,int floor) { + const int reduction=(std::min)(deficit,(std::max)(0,height-(std::max)(0,floor))); + height-=reduction; deficit-=reduction; + }; + shrink(result.graph,minimumGraph); + shrink(result.footer,minimumFooter); + // Extremely small windows cannot fit every minimum. Keep sizes nonnegative + // and prioritize the image; normal windows retain readable panel minima. + shrink(result.graph,0); + shrink(result.footer,0); + result.source=available-result.graph-result.footer; + return result; +} diff --git a/Comparator/AnalysisScrollView.h b/Comparator/AnalysisScrollView.h new file mode 100644 index 00000000..73e72500 --- /dev/null +++ b/Comparator/AnalysisScrollView.h @@ -0,0 +1,157 @@ +#pragma once + +#include "ThemeScrollBar.h" +#include "QViewerCmn.h" +#include "Q1UiFontWin.h" +#include "Q1UiBufferedPaintWin.h" +#include + +// The same themed, accessible native scrollbar used by Timeline. The child +// remains viewport anchored when content scrolls (CScrollView::ScrollWindow +// would move it, as well as any native pair-selection buttons). +class CAnalysisScrollView : public CScrollView +{ + DECLARE_DYNAMIC(CAnalysisScrollView) +protected: + mutable CThemeScrollBar mAnalysisScroll; + int mContentHeight = 0; + CSize mExtentViewport = CSize(-1,-1); + void SetAnalysisExtent(int height) + { + CRect rc; GetClientRect(rc); + if (height==mContentHeight && rc.Size()==mExtentViewport) return; + mExtentViewport=rc.Size(); + mContentHeight = max(0,height); + SetScrollSizes(MM_TEXT,CSize(0,mContentHeight),CSize(0,max(1,rc.Height()*9/10)),CSize(0,24)); + int width=q1view::WindowsUiPixels(17,q1view::WindowsUiDpi(m_hWnd)); + mAnalysisScroll.SetWindowPos(nullptr,max(0,rc.Width()-width),0,width,rc.Height(),SWP_NOZORDER|SWP_NOACTIVATE); + mAnalysisScroll.ShowWindow(height>rc.Height() ? SW_SHOWNA : SW_HIDE); + } + int ContentWidth() const + { + CRect rc; GetClientRect(rc); + return max(0,rc.Width()-(mAnalysisScroll.IsWindowVisible() ? q1view::WindowsUiPixels(17,q1view::WindowsUiDpi(m_hWnd)) : 0)); + } +public: + CScrollBar* GetScrollBarCtrl(int bar) const override + { + return bar==SB_VERT && mAnalysisScroll.GetSafeHwnd() ? &mAnalysisScroll : CScrollView::GetScrollBarCtrl(bar); + } +protected: + BOOL OnScrollBy(CSize scroll, BOOL apply=TRUE) override + { + int old=GetScrollPos(SB_VERT); + int pos=int(max(0LL,min((long long)GetScrollLimit(SB_VERT),(long long)old+scroll.cy))); + if (old==pos) return FALSE; + if (apply) { SetScrollPos(SB_VERT,pos); Invalidate(FALSE); } + return TRUE; + } + virtual BOOL AnalysisMouseWheel(UINT flags,short delta,CPoint point) + { + return DoMouseWheel(flags,delta,point); + } + LRESULT WindowProc(UINT message, WPARAM w, LPARAM l) override + { + if (message==WM_CTLCOLORBTN || message==WM_CTLCOLORSTATIC) { + const HWND control=reinterpret_cast(l); + const int id=::GetDlgCtrlID(control); + if (::GetParent(control)==m_hWnd && + ((message==WM_CTLCOLORBTN && ((id>=0x7900 && id<0x7903) || id==0x7950)) || + (message==WM_CTLCOLORSTATIC && id==0x7903))) { + SetBkColor(reinterpret_cast(w),Q1UI_COLOR_SURFACE_ALT); + return reinterpret_cast(q1view::WindowsUiColorBrush(q1view::WindowsUiColor::SurfaceAlt)); + } + } + if (message==WM_MOUSEWHEEL) { + CPoint pt(GET_X_LPARAM(l),GET_Y_LPARAM(l)); + return AnalysisMouseWheel(LOWORD(w),GET_WHEEL_DELTA_WPARAM(w),pt); + } + if (message==WM_VSCROLL && (LOWORD(w)==SB_THUMBTRACK || LOWORD(w)==SB_THUMBPOSITION)) { + SCROLLINFO si={}; GetScrollInfo(SB_VERT,&si,SIF_TRACKPOS); + int pos=mAnalysisScroll.IsTrackingThumb() ? mAnalysisScroll.TrackPosition() : si.nTrackPos; + return OnScrollBy(CSize(0,pos-GetScrollPos(SB_VERT))); + } + LRESULT result=CScrollView::WindowProc(message,w,l); + if (message==WM_CREATE && result!=-1) { + ModifyStyle(WS_VSCROLL|WS_HSCROLL,WS_CLIPCHILDREN); + if (!mAnalysisScroll.Create(WS_CHILD|WS_TABSTOP|SBS_VERT,CRect(),this,0x7811)) return -1; + mAnalysisScroll.SetWindowText(_T("Analysis scroll bar")); + SetAnalysisExtent(0); + } + if (message==WM_SIZE && mAnalysisScroll.GetSafeHwnd()) SetAnalysisExtent(mContentHeight); + return result; + } +}; + +// A non-interactive native label for reference A, using the same footer style. +class CReferenceLabel : public CStatic +{ +protected: + LRESULT WindowProc(UINT message, WPARAM w, LPARAM l) override + { + if (message==WM_ERASEBKGND && (GetStyle()&SS_TYPEMASK)==SS_OWNERDRAW) return TRUE; + return CStatic::WindowProc(message,w,l); + } +public: + CFont mFont; + void DrawItem(LPDRAWITEMSTRUCT item) override + { + q1view::WindowsUiBufferedPaint paint(item->hDC,item->rcItem,q1view::WindowsUiColor::SurfaceAlt); + CDC dc; dc.Attach(paint.Dc()); + CRect rc(item->rcItem); + dc.Draw3dRect(rc,Q1UI_COLOR_BORDER_SOFT,Q1UI_COLOR_BORDER_SOFT); + q1view::EnsureWindowsUiFont(mFont,q1view::WindowsUiFontRole::Body,m_hWnd); + auto* old=dc.SelectObject(&mFont); + dc.SetBkMode(TRANSPARENT); dc.SetTextColor(Q1UI_COLOR_TEXT_MUTED); + CString text; GetWindowText(text); rc.DeflateRect(8,4); + dc.DrawText(text,rc,DT_LEFT|DT_WORDBREAK|DT_NOPREFIX); + dc.SelectObject(old); dc.Detach(); + } +}; + +// A real Win32 button provides focus, keyboard activation, and accessible +// names; owner drawing only changes its themed presentation. +class CComparisonButton : public CButton +{ +protected: + LRESULT WindowProc(UINT message, WPARAM w, LPARAM l) override + { + // Keep native focus, keyboard, accessible names and button messages. + // Only owner-drawn controls repaint the entire background themselves. + if (message==WM_ERASEBKGND && (GetStyle()&BS_TYPEMASK)==BS_OWNERDRAW) return TRUE; + return CButton::WindowProc(message,w,l); + } +public: + bool active=false; + CFont mFont; + CToolTipCtrl mInfoTip; + CString mInfoText; + void SetInfoTip(const CString& text) + { + if (mInfoText==text) return; + mInfoText=text; + if (!mInfoTip.GetSafeHwnd()) { + mInfoTip.Create(this); mInfoTip.AddTool(this,text); + mInfoTip.SetMaxTipWidth(q1view::WindowsUiPixels(560,q1view::WindowsUiDpi(m_hWnd))); + } else mInfoTip.UpdateTipText(text,this); + } + BOOL PreTranslateMessage(MSG* message) override + { + if (mInfoTip.GetSafeHwnd()) mInfoTip.RelayEvent(message); + return CButton::PreTranslateMessage(message); + } + void DrawItem(LPDRAWITEMSTRUCT item) override + { + q1view::WindowsUiBufferedPaint paint(item->hDC,item->rcItem,q1view::WindowsUiColor::SurfaceAlt); + CDC dc; dc.Attach(paint.Dc()); + CRect rc(item->rcItem); + dc.Draw3dRect(rc,active ? Q1UI_COLOR_ACCENT : Q1UI_COLOR_BORDER_SOFT,active ? Q1UI_COLOR_ACCENT : Q1UI_COLOR_BORDER_SOFT); + q1view::EnsureWindowsUiFont(mFont,q1view::WindowsUiFontRole::Body,m_hWnd); + auto* old=dc.SelectObject(&mFont); + dc.SetBkMode(TRANSPARENT); dc.SetTextColor(Q1UI_COLOR_TEXT); + CString text; GetWindowText(text); rc.DeflateRect(8,4); + dc.DrawText(text,rc,DT_LEFT|DT_WORDBREAK|DT_NOPREFIX); + if (item->itemState&ODS_FOCUS) dc.DrawFocusRect(rc); + dc.SelectObject(old); dc.Detach(); + } +}; diff --git a/Comparator/Comparator.vcxproj b/Comparator/Comparator.vcxproj index e84a255f..3a76a301 100644 --- a/Comparator/Comparator.vcxproj +++ b/Comparator/Comparator.vcxproj @@ -262,6 +262,9 @@ copy /Y "$(ProjectDir)..\assets\fonts\Pretendard-LICENSE.txt" "$(TargetDir)Fonts + + + diff --git a/Comparator/ComparatorDoc.cpp b/Comparator/ComparatorDoc.cpp index 7a1721a6..efb42c97 100644 --- a/Comparator/ComparatorDoc.cpp +++ b/Comparator/ComparatorDoc.cpp @@ -190,6 +190,7 @@ bool CComparatorDoc::ReadSource4View(ComparatorPane *pane) BYTE *RGB = pane->rgbBuf; pane->csc2rgb888(RGB, y, u, v, ROUNDUP_DWORD(mW), mW, mH); + pane->rgbValid = true; return true; } @@ -308,51 +309,106 @@ void CComparatorDoc::RefleshPaneImages(ComparatorPane *pane, bool settingChanged CMainFrame *pMainFrm = static_cast(AfxGetMainWnd()); pMainFrm->KillTimer(CTI_ID_POS_INVALIDATE); KillPlayTimer(); - StopLpipsScanThread(); + StopAnalysis(); LoadSourceImage(pane); + if (settingChanged) + for (auto other : GetOtherPanes(pane)) if (other->isAvail()) LoadSourceImage(other); + if (!mBatchOpening) RebuildComparisons(settingChanged ? 0xf : 1u << (pane - mPane)); +} - ComparatorPane *opposite = GetOppositePane(pane); - if (opposite == nullptr) { - // Only the first two panes participate in frame-diff scanning. - return; - } - - if (opposite->isAvail()) { - if (settingChanged) - LoadSourceImage(opposite); +void CComparatorDoc::StopAnalysis() +{ + StopLpipsScanThread(); + mFileScanThread->requestExitAndWait(); + mFileScanThread->cancelCurrent(); + // Completed unchanged pairs remain visible. Cancelled/error work must be + // resubmitted even when its displayed frame/ROI key has not changed. + for (int i=0;i<3;++i) + if (mPairResults[i].state!=_T("Ready")) mCurrentSignature[i].Empty(); +} - // Frame counts must be refreshed before FileScanThread::setup(). - mMaxFrames = max(pane->frames, opposite->frames); - mMinFrames = min(pane->frames, opposite->frames); +std::vector CComparatorDoc::TimelineSources() const +{ + std::vector slots; + for (int i = 0; i < 4; ++i) if (mPane[i].isAvail()) slots.push_back(i); + return slots; +} - bool rgbCompare = IsRGBCompare(pane, opposite); - if (rgbCompare) - mFrmCmpStrategy = mRgbCompare; - else - mFrmCmpStrategy = mYuvCompare; +std::vector CComparatorDoc::ComparisonTargets() const +{ + std::vector slots; + if (mPane[0].isAvail()) + for (int i = 1; i < 4; ++i) if (mPane[i].isAvail()) slots.push_back(i); + return slots; +} - mFrmCmpStrategy->Setup(mW, mH); - mFrmCmpStrategy->CalMetrics(pane, opposite, pMainFrm->mMetricIdx, mFrmState); +int CComparatorDoc::PairFrames(int target) const +{ + return target >= 1 && target <= 3 && mPane[0].isAvail() && mPane[target].isAvail() + ? (int)min(mPane[0].frames, mPane[target].frames) : 0; +} - mFileScanThread->requestExitAndWait(); - mFileScanThread->setup(); // uses mMinFrames and frmCmpInfo is updated +IFrmCmpStrategy* CComparatorDoc::PairStrategy(int target) const +{ + return target >= 1 && target <= 3 ? mPairStrategies[target-1] : nullptr; +} - MetricCal *psnrCal = mFrmsInfoView->mPsnrCal; - psnrCal->Update(mFileScanThread, mMinFrames); // uses mMinFrames and FileScanThread +CString CComparatorDoc::PairTitle(int target) const +{ + CString title; + title.Format(_T("A \x2194 %c"), _T('A') + target); + return title; +} - mFileScanThread->run(); +void CComparatorDoc::SelectComparison(int target) +{ + if (!PairFrames(target)) return; + mActiveTarget = target; + UpdateAllViews(nullptr); +} - pMainFrm->SetTimer(CTI_ID_POS_INVALIDATE, VIT_INVALIDATE_DUR, NULL); - } else { - mMaxFrames = max(pane->frames, opposite->frames); - mMinFrames = 0; +void CComparatorDoc::RebuildComparisons(unsigned changedSlots) +{ + StopAnalysis(); + mRequestedFrame=-1; + mMaxFrames = mMinFrames = 0; + for (int slot : TimelineSources()) mMaxFrames = max(mMaxFrames, (int)mPane[slot].frames); + mRgbCompare->Setup(mW, mH); mYuvCompare->Setup(mW, mH); + for (int t = 1; t < 4; ++t) { + mPairStrategies[t-1] = PairFrames(t) ? (IsRGBCompare(mPane, mPane+t) + ? static_cast(mRgbCompare) : static_cast(mYuvCompare)) : nullptr; + mMinFrames = max(mMinFrames, PairFrames(t)); + if (!PairFrames(t)) { + mPairResults[t-1] = PairCurrentResult(); + mCurrentSignature[t-1].Empty(); mCurrentPairRequests[t-1]=0; + } } + mFrmCmpStrategy = PairStrategy(1); // compatibility: this alias always means A/B. + auto targets = ComparisonTargets(); + if (!PairFrames(mActiveTarget)) mActiveTarget = targets.empty() ? 1 : targets.front(); + mFileScanThread->setup(changedSlots); + if ((changedSlots&1) && mFrmsInfoView) mFrmsInfoView->ResetViewport(); + if (mPosInfoView) mPosInfoView->ConfigureScrollSizes(this); + auto* frame = static_cast(AfxGetMainWnd()); + SelectMetric(frame->mMetricIdx); + CRect rc; frame->GetClientRect(rc); + frame->SendMessage(WM_SIZE, SIZE_RESTORED, MAKELPARAM(rc.Width(), rc.Height())); + UpdateAllViews(nullptr); +} + +void CComparatorDoc::CloseSource(int slot) +{ + if (slot < 0 || slot > 3) return; + KillPlayTimer(); StopAnalysis(); + mPane[slot].Release(); + if (mPane[slot].pView) mPane[slot].pView->UpdateFileName(_T("")); + if (!mBatchOpening) RebuildComparisons(1u << slot); } void CComparatorDoc::ProcessDocument(ComparatorPane *pane) { - StopLpipsScanThread(); + StopAnalysis(); pane->pathName.Replace(_T('/'), _T('\\')); @@ -360,6 +416,7 @@ void CComparatorDoc::ProcessDocument(ComparatorPane *pane) bool success = pane->GetResolution(pane->pathName , &srcW, &srcH); if (!success) { LOGWRN("FrmSrc failed to get resolution info"); + CloseSource((int)(pane - mPane)); return; } pane->srcW = srcW; @@ -426,16 +483,7 @@ void CComparatorDoc::ReloadPane(int paneIdx) // resolution / color-space change after the file was rewritten. ProcessDocument(pane); - // Recompute metrics between the active pair so the pixel-diff overlay, - // PSNR/SSIM, and any visible labels reflect the new contents. - if (mFrmCmpStrategy) { - ComparatorPane *paneL = &mPane[IMG_VIEW_1]; - ComparatorPane *paneR = &mPane[IMG_VIEW_2]; - CMainFrame *pMainFrm = static_cast(AfxGetMainWnd()); - if (paneL->isAvail() && paneR->isAvail()) - mFrmCmpStrategy->CalMetrics(paneL, paneR, - pMainFrm->mMetricIdx, mFrmState); - } + RefreshSelectionMetric(); UpdateAllViews(NULL); } @@ -530,6 +578,7 @@ BOOL CComparatorDoc::OpenMultiFiles(const std::vector &filenames) { CMainFrame* pMainFrm = static_cast(AfxGetMainWnd()); int numOfFiles = (int)filenames.size(); + if (!numOfFiles) return FALSE; if (numOfFiles > IMG_VIEW_MAX) { pMainFrm->MessageBox( _T("Comparator supports up to 4 files.\nPlease select 4 files or fewer."), @@ -538,6 +587,8 @@ BOOL CComparatorDoc::OpenMultiFiles(const std::vector &filenames) { return FALSE; } + StopAnalysis(); + mBatchOpening = true; if (numOfFiles > pMainFrm->mNumOfViews) { pMainFrm->ChangeNumOfViews(numOfFiles); } @@ -576,6 +627,9 @@ BOOL CComparatorDoc::OpenMultiFiles(const std::vector &filenames) { bool success = pane->GetResolution(pane->pathName, &srcW, &srcH); if (!success) { LOGWRN("FrmSrc failed to get resolution info for file idx %d", i); + mBatchOpening = false; + for (int slot = 0; slot < 4; ++slot) mPane[slot].Release(); + RebuildComparisons(); return FALSE; } srcWidths[i] = srcW; @@ -626,7 +680,8 @@ BOOL CComparatorDoc::OpenMultiFiles(const std::vector &filenames) { ProcessDocument(pane); } - mPosInfoView->ConfigureScrollSizes(this); + mBatchOpening = false; + RebuildComparisons(); pMainFrm->UpdateResolutionLabel(mW, mH); // also, disable the resolution change if necessary pMainFrm->CheckResolutionRadio(mW, mH); @@ -681,6 +736,7 @@ BOOL CComparatorDoc::OnOpenDocument(LPCTSTR lpszPathName) void CComparatorDoc::SetScene(long frameID, ComparatorPane* pane, bool& updated) { + if (!pane->isAvail() || frameID < 0 || frameID >= pane->frames) return; if (frameID < 0) { pane->SetNextFrameID(0); } else if (frameID < pane->frames) { @@ -690,11 +746,12 @@ void CComparatorDoc::SetScene(long frameID, ComparatorPane* pane, bool& updated) pane->SetNextFrameID(pane->frames - 1); } - ReadSource4View(pane); + updated = ReadSource4View(pane) || updated; } bool CComparatorDoc::OffsetScenes(long offset) { + mRequestedFrame=-1; CMainFrame* pMainFrm = static_cast(AfxGetMainWnd()); bool updated = false; @@ -706,11 +763,13 @@ bool CComparatorDoc::OffsetScenes(long offset) SetScene(pane->curFrameID + offset, pane, updated); } + RefreshSelectionMetric(); return updated; } bool CComparatorDoc::SetScenes(long frameID) { + mRequestedFrame=frameID; bool updated = false; for (int i = 0; i < IMG_VIEW_MAX; i++) { @@ -721,6 +780,7 @@ bool CComparatorDoc::SetScenes(long frameID) SetScene(frameID, pane, updated); } + RefreshSelectionMetric(); return updated; } @@ -743,6 +803,7 @@ bool CComparatorDoc::NextScenes() updated = true; } + RefreshSelectionMetric(); return updated; } @@ -842,22 +903,20 @@ void CComparatorDoc::SelectMetric(int metricIdx) if (pMainFrm) pMainFrm->mMetricIdx = metricIdx; - // Stop any existing background lazy-metric evaluation. This must happen - // before any FileScanThread realloc; here only the metric changes so the - // base cache stays intact (no image reload, no base rescan). - StopLpipsScanThread(); - - ComparatorPane* pane = mPane + IMG_VIEW_1; - ComparatorPane* opposite = mPane + IMG_VIEW_2; + // Join both workers before switching a shared model/strategy. Keep the + // per-pair sequence caches and continue an unfinished eager scan in place. + StopAnalysis(); - if (!pane->isAvail() || !opposite->isAvail() || mMinFrames <= 0) { + if (ComparisonTargets().empty()) { mFrmState.Empty(); UpdateAllViews(NULL); return; } + mFileScanThread->ensureRunning(); const qmetric_info* qminfo = &qmetric_info_table[metricIdx]; if (qminfo->lazy) { + for (int target:ComparisonTargets()) mFileScanThread->setPairError(target,_T(""),metricIdx); // Start the worker regardless of whether the base scan is still running: // it trails the base scanner, waiting per-frame for the eager parse, and // fills every frame in order. Run() sets the running flag synchronously. @@ -873,6 +932,7 @@ void CComparatorDoc::SelectMetric(int metricIdx) } UpdateCurrentMetricState(metricIdx); + if (pMainFrm) pMainFrm->SetTimer(CTI_ID_POS_INVALIDATE, VIT_INVALIDATE_DUR, NULL); if (mFrmsInfoView) mFrmsInfoView->Invalidate(); @@ -897,93 +957,68 @@ bool CComparatorDoc::GetSelectionRect(int &l, int &t, int &r, int &b) const return (r >= l && b >= t); } -// Append the selected metric measured over just the selection rectangle, so a -// localized region can be compared against the whole-image score on the same -// line (issue #74). PSNR/SSIM use the color-space-native crop; LPIPS reuses the -// RGB engine on the cropped region. -void CComparatorDoc::AppendCropMetric(int metricIdx, ComparatorPane *pane, ComparatorPane *opposite) -{ - int l, t, r, b; - if (!GetSelectionRect(l, t, r, b)) - return; - if (!pane->rgbBuf || !opposite->rgbBuf) - return; - - const qmetric_info *qminfo = &qmetric_info_table[metricIdx]; - const int cw = r - l + 1; - const int ch = b - t + 1; - CString cropScore; - - if (qminfo->lazy) { - if (qminfo->ml_id && LpipsEngine::getInstance().availableFor(qminfo->ml_id)) { - const int stride = ROUNDUP_DWORD(mW); - const size_t off = (size_t)t * stride * QIMG_DST_RGB_BYTES - + (size_t)l * QIMG_DST_RGB_BYTES; - double dist = LpipsEngine::getInstance().distance(pane->rgbBuf + off, - opposite->rgbBuf + off, cw, ch, stride * QIMG_DST_RGB_BYTES); - const CString metricName = CA2W(qminfo->name); - cropScore.Format(_T("%s(%.4f)"), (const TCHAR*)metricName, dist); - } - } else if (mFrmCmpStrategy) { - cropScore = mFrmCmpStrategy->CropScore(pane, opposite, metricIdx, l, t, r, b); - } - - if (!cropScore.IsEmpty()) - mFrmState.AppendFormat(_T(" | crop %dx%d %s"), cw, ch, (const TCHAR*)cropScore); -} - +// Publish each pair's latest request for the actual displayed source frames and +// committed ROI. Numerical work runs on owned snapshots, never on the UI thread. void CComparatorDoc::UpdateCurrentMetricState(int metricIdx) { - mFrmState.Empty(); - ComparatorPane *pane = mPane + IMG_VIEW_1; - ComparatorPane *opposite = mPane + IMG_VIEW_2; - - if (!pane->isAvail() || !opposite->isAvail()) - return; - - const qmetric_info *qminfo = &qmetric_info_table[metricIdx]; - if (!qminfo->lazy) { - if (mFrmCmpStrategy) - mFrmCmpStrategy->CalMetrics(pane, opposite, metricIdx, mFrmState); - AppendCropMetric(metricIdx, pane, opposite); - return; + const auto& metric = qmetric_info_table[metricIdx]; + int l=0,t=0,r=0,b=0; + bool roi = GetSelectionRect(l,t,r,b); + const auto targets = ComparisonTargets(); + if (targets.empty()) { mFrmState.Empty(); return; } + const bool modelAvailable=metric.ml_id && LpipsEngine::getInstance().availableFor(metric.ml_id); + CString signatures[3]; unsigned changedTargets=0; + for (int target:targets) { + auto& signature=signatures[target-1]; + signature.Format(_T("%u:%d:%d:%d:%d:%d:%d:%d:%d:%d:%ld:%ld"), + mFileScanThread->pairGeneration(target),metricIdx,modelAvailable,mW,mH,roi,l,t,r,b, + mPane[0].curFrameID,mPane[target].curFrameID); + if (signature!=mCurrentSignature[target-1]) changedTargets|=1u<name); - double metrics[QPLANES]; - bool frameAligned = pane->curFrameID == opposite->curFrameID; - if (frameAligned && mFileScanThread && mFileScanThread->copyMetrics(pane->curFrameID, metricIdx, metrics)) { - if (qminfo->plane_count == 1) - mFrmState.Format(_T("%s(%.4f)"), (const TCHAR*)metricName, metrics[0]); - else - mFrmState.Format(_T("%s(%.4f %.4f %.4f)"), (const TCHAR*)metricName, - metrics[0], metrics[1], metrics[2]); - } else if (pane->rgbBuf && opposite->rgbBuf) { - // Only compute live when the engine has *this* backbone loaded; right after - // a backend switch it may still hold the previous model, so fall back to - // "computing..." until the worker reloads the requested one. - if (qminfo->ml_id && LpipsEngine::getInstance().availableFor(qminfo->ml_id)) { - double dist = LpipsEngine::getInstance().distance(pane->rgbBuf, opposite->rgbBuf, - mW, mH, ROUNDUP_DWORD(mW) * QIMG_DST_RGB_BYTES); - mFrmState.Format(_T("%s(%.4f)"), (const TCHAR*)metricName, dist); - } else if (IsLpipsScanRunning()) { - mFrmState.Format(_T("%s(computing...)"), (const TCHAR*)metricName); - } else { - mFrmState.Format(_T("%s(N/A)"), (const TCHAR*)metricName); + if (changedTargets) { + auto request = std::make_shared(); + request->id = ++mCurrentRequest; + request->targets=changedTargets; + request->metric = metricIdx; request->width=mW; request->height=mH; + request->hasRoi=roi; request->l=l; request->t=t; request->r=r; request->b=b; + for (int i=0;i<4;++i) if ((i==0 || (changedTargets&(1u<=0) { + const auto& source=mPane[i]; + auto snapshot=std::make_shared(); + snapshot->curFrameID=source.curFrameID; snapshot->frames=source.frames; + snapshot->colorSpace=source.colorSpace; snapshot->csLoadInfo=source.csLoadInfo; + snapshot->csc2rgb888=source.csc2rgb888; snapshot->csc2yuv420=source.csc2yuv420; + snapshot->origBufSize=snapshot->origSceneSize=source.origSceneSize; + snapshot->origBuf=new BYTE[source.origSceneSize]; + memcpy(snapshot->origBuf,source.origBuf,source.origSceneSize); + snapshot->rgbBufSize=source.rgbBufSize; snapshot->rgbBuf=new BYTE[source.rgbBufSize]; + memcpy(snapshot->rgbBuf,source.rgbBuf,source.rgbBufSize); snapshot->rgbValid=true; + request->sources[i]=snapshot; } - } else if (IsLpipsScanRunning()) { - mFrmState.Format(_T("%s(computing...)"), (const TCHAR*)metricName); - } else if (!(qminfo->ml_id && LpipsEngine::getInstance().availableFor(qminfo->ml_id))) { - mFrmState.Format(_T("%s(N/A)"), (const TCHAR*)metricName); - } else { - mFrmState.Format(_T("%s(...)"), (const TCHAR*)metricName); + for (int target : targets) if (changedTargets&(1u<id; + request->strategies[target-1]=PairStrategy(target); + mPairResults[target-1]=PairCurrentResult(); + mPairResults[target-1].request=request->id; + mPairResults[target-1].state=_T("Computing"); + if (!request->sources[0] || !request->sources[target]) + mPairResults[target-1].state=_T("Unavailable: current frame could not be decoded"); + } + mFileScanThread->queueCurrent(request); + static_cast(AfxGetMainWnd())->SetTimer(CTI_ID_POS_INVALIDATE,VIT_INVALIDATE_DUR,nullptr); } - - AppendCropMetric(metricIdx, pane, opposite); + for (int target : targets) { + mFileScanThread->copyCurrent(target,mCurrentPairRequests[target-1],mPairResults[target-1]); + auto summary=mFileScanThread->summary(target); + if (metric.lazy && mPairResults[target-1].state==_T("Preparing")) + mPairResults[target-1].state=IsLpipsScanRunning() ? _T("Preparing model") : _T("Unavailable"); + if (metric.lazy && !IsLpipsScanRunning() && !summary.metricError[metricIdx].IsEmpty() + && mPairResults[target-1].full.IsEmpty()) mPairResults[target-1].state=summary.metricError[metricIdx]; + } + mFrmState=mPairResults[0].full; + if (!mPairResults[0].roi.IsEmpty()) mFrmState+=_T(" | ")+mPairResults[0].roi; + if (mFrmState.IsEmpty() && PairFrames(1)) mFrmState=mPairResults[0].state; + mFileScanThread->ensureRunning(); } // Recompute the current-frame metric line (which now includes the crop score) diff --git a/Comparator/ComparatorDoc.h b/Comparator/ComparatorDoc.h index af1cb765..5e145daf 100644 --- a/Comparator/ComparatorDoc.h +++ b/Comparator/ComparatorDoc.h @@ -8,6 +8,7 @@ #include "afx.h" #include "ComparatorPane.h" +#include "ComparisonResults.h" #define CANVAS_DEF_W 352 #define CANVAS_DEF_H 288 @@ -18,7 +19,6 @@ #define COMPARER_DEF_FPS 30.0 #define FPS_ADJUSTMENT 7 #define COMPARER_DEF_VIEWS 2 -#define COMPARING_VIEWS_NUM 2 class CComparatorDoc; class FileScanThread; @@ -71,6 +71,14 @@ class CComparatorDoc : public CDocument IFrmCmpStrategy *mFrmCmpStrategy; YuvFrmCmpStrategy *mYuvCompare; RgbFrmCmpStrategy *mRgbCompare; + IFrmCmpStrategy* mPairStrategies[3] = {}; + PairCurrentResult mPairResults[3]; + int mActiveTarget = 1; + bool mBatchOpening = false; + unsigned long long mCurrentRequest = 0; + unsigned long long mCurrentPairRequests[3] = {}; + CString mCurrentSignature[3]; + long mRequestedFrame = -1; CString mPendingFile; bool mIsPlaying; float mPreN; @@ -114,14 +122,17 @@ class CComparatorDoc : public CDocument void ReloadPane(int paneIdx); bool IsRGBCompare(const SQPane *paneA, const SQPane *paneB) const; void RefleshPaneImages(ComparatorPane *pane, bool settingChanged); + void StopAnalysis(); + void RebuildComparisons(unsigned changedSlots = 0xf); + void CloseSource(int slot); + std::vector ComparisonTargets() const; + std::vector TimelineSources() const; + int PairFrames(int target) const; + IFrmCmpStrategy* PairStrategy(int target) const; + CString PairTitle(int target) const; + void SelectComparison(int target); inline ComparatorPane* GetOppositePane(ComparatorPane* pane) { - if (pane == mPane + IMG_VIEW_1) { - return mPane + IMG_VIEW_2; - } else if (pane == mPane + IMG_VIEW_2) { - return mPane + IMG_VIEW_1; - } else { - return nullptr; - } + return pane == mPane ? mPane + mActiveTarget : mPane; } std::vector GetOtherPanes(ComparatorPane* pane); void KillPlayTimer(); @@ -138,7 +149,6 @@ class CComparatorDoc : public CDocument BOOL OpenMultiFiles(const std::vector& filenames); void SelectMetric(int metricIdx); void UpdateCurrentMetricState(int metricIdx); - void AppendCropMetric(int metricIdx, ComparatorPane *pane, ComparatorPane *opposite); void RefreshSelectionMetric(); bool IsLpipsScanRunning() const; void StopLpipsScanThread(); diff --git a/Comparator/ComparatorPane.h b/Comparator/ComparatorPane.h index 13ef83a0..a0a1f7cf 100644 --- a/Comparator/ComparatorPane.h +++ b/Comparator/ComparatorPane.h @@ -33,6 +33,7 @@ struct SQPane std::vector frmSrcs; FrmSrc* frmSrc; bool disableImageSequence; + bool rgbValid = false, yuvValid = false; inline SQPane() : origSceneSize(0) @@ -92,6 +93,7 @@ struct SQPane closeFrmSrcs(); auto it = std::begin(frmSrcs); + curFrameID = -1; for (; it != std::end(frmSrcs); it++) { bool ok = (*it)->Open(pathName, sortedCscInfo, srcW, srcH, dstW, dstH); if (ok) { @@ -138,6 +140,8 @@ struct SQPane } inline bool FillSceneBuf(BYTE* origBuf) { + if (!isAvail()) return false; + rgbValid = yuvValid = false; long candidateCurFrameID = frmSrc->GetNextFrameID(); bool ok = frmSrc->FillSceneBuf(origBuf); if (ok) @@ -164,6 +168,7 @@ struct ComparatorPane : public SQPane , bufOffset2(0) , bufOffset3(0) , pView(NULL) + , srcW(0), srcH(0) , frames(0) { } @@ -171,13 +176,14 @@ struct ComparatorPane : public SQPane void OpenFrmSrc(struct qcsc_info* sortedCscInfo, int dstW, int dstH) { SQPane::OpenFrmSrc(pathName, sortedCscInfo, srcW, srcH, dstW, dstH); - frames = QMAX(frmSrc->GetFrameNum(), 1); + frames = frmSrc ? QMAX(frmSrc->GetFrameNum(), 1) : 0; } inline void Release() { closeFrmSrcs(); frames = 0; + curFrameID = -1; disableImageSequence = false; pathName.Empty(); } @@ -193,7 +199,7 @@ struct ComparatorPane : public SQPane inline bool SetNextFrameID(long nextFrameID) { if (frmSrc) { - if (nextFrameID < frames) { + if (nextFrameID >= 0 && nextFrameID < frames) { return frmSrc->SetNextFrameID(nextFrameID); } else { frmSrc->SetNextFrameID(curFrameID); // repeat current frame @@ -205,6 +211,6 @@ struct ComparatorPane : public SQPane inline double GetFps() { - return frmSrc->GetFps(); + return frmSrc ? frmSrc->GetFps() : 0; } }; diff --git a/Comparator/ComparatorView.cpp b/Comparator/ComparatorView.cpp index 65a9f8f3..da9766a2 100644 --- a/Comparator/ComparatorView.cpp +++ b/Comparator/ComparatorView.cpp @@ -8,6 +8,7 @@ #include "ComparatorViews.h" #include "MainFrm.h" #include "PosInfoView.h" +#include "FrmInfoView.h" #include "QCommon.h" #include "QDebug.h" @@ -329,18 +330,8 @@ void CComparatorView::DrawDiffOverlay(CDC *pDC, CComparatorDoc *pDoc, Comparator if (!pane || !pane->isAvail() || !pane->rgbBuf) return; - // Find any other available pane to compare against (pilot assumes 2 panes). - ComparatorPane *other = nullptr; - for (int i = 0; i < CComparatorDoc::IMG_VIEW_MAX; i++) { - ComparatorPane *p = &pDoc->mPane[i]; - if (p == pane) - continue; - if (p->isAvail() && p->rgbBuf) { - other = p; - break; - } - } - if (!other || pDoc->mW <= 0 || pDoc->mH <= 0) + ComparatorPane *other = pDoc->GetOppositePane(pane); + if (!other || !other->isAvail() || !other->rgbBuf || pDoc->mW <= 0 || pDoc->mH <= 0) return; const int rowBytes = ROUNDUP_DWORD(pDoc->mW) * QIMG_DST_RGB_BYTES; @@ -790,9 +781,7 @@ void CComparatorView::ClosePane() // Stop playback if running — the pane that fed it is gone — and clear the // pane's source + filename label. mNumOfViews stays put: this slot just // becomes empty and the user can drop a new file onto it. - pDoc->KillPlayTimer(); - pane->Release(); - UpdateFileName(_T("")); + pDoc->CloseSource((int)(pane - pDoc->mPane)); pDoc->UpdateAllViews(NULL); } @@ -902,6 +891,7 @@ void CComparatorView::OnDropFiles(HDROP hDropInfo) const int previousW = pDoc->mW; const int previousH = pDoc->mH; + pDoc->StopAnalysis(); pane->pathName = filenames[0]; pane->disableImageSequence = false; pDoc->ProcessDocument(mPane); @@ -1043,6 +1033,8 @@ void CComparatorView::OnSize(UINT nType, int cx, int cy) return; mRcControls.right = cx; + // Footer columns follow actual pane geometry, including splitter changes. + if (pDoc->mFrmInfoView) pDoc->mFrmInfoView->Invalidate(FALSE); if (mCsQMenu.GetSafeHwnd() && mNameQMenu.GetSafeHwnd()) { CRect measured(0, 0, 0, 0); mCsQMenu.CalcRect(&measured); @@ -1059,7 +1051,7 @@ void CComparatorView::OnSize(UINT nType, int cx, int cy) mHClient = cy; mWCanvas = mWClient; - mHCanvas = mHClient - mRcControls.bottom; + mHCanvas = std::max(0,mHClient - static_cast(mRcControls.bottom)); // Reserve a square at the right edge of the controls strip for the close // button (sized to the strip height so it lines up with mCsQMenu / @@ -1254,7 +1246,13 @@ void CComparatorView::UpdateCsLabel(const TCHAR *csLabel) void CComparatorView::UpdateFileName(const TCHAR* filename) { - mNameQMenu.SetWindowText(filename); + CString title; + const auto* doc = GetDocument(); + if (doc) { + int slot = (int)(GetPane(const_cast(doc)) - doc->mPane); + title.Format(_T("%c%s %s"), _T('A') + slot, slot == 0 ? _T(" (Reference)") : _T(""), filename); + } else title = filename; + mNameQMenu.SetWindowText(title); mCsQMenu.CalcRect(&mRcCsQMenu); mRcNameQMenu.left = mRcCsQMenu.right; @@ -1283,6 +1281,7 @@ void CComparatorView::OnCsChange(UINT nID) UpdateCsLabel(str.MakeUpper()); CheckCsRadio(ci->cs); + pDoc->StopAnalysis(); pane->SetColorInfo(ci); if (pane->isAvail()) { diff --git a/Comparator/ComparisonResults.h b/Comparator/ComparisonResults.h new file mode 100644 index 00000000..b63fb0ea --- /dev/null +++ b/Comparator/ComparisonResults.h @@ -0,0 +1,39 @@ +#pragma once + +#include "ComparatorPane.h" +#include "FrmCmpInfo.h" +#include + +struct IFrmCmpStrategy; + +// Stable source slots: A = 0, B = 1, C = 2, D = 3. Closing B does not rename C. +struct PairCurrentResult +{ + unsigned long long request = 0; + CString full, channels, roi, state; +}; + +struct PairSequenceSummary +{ + int eligible = 0, parsed = 0; + int measured[METRIC_COUNT] = {}; + double sum[METRIC_COUNT][QPLANES] = {}; + double minimum[METRIC_COUNT][QPLANES] = {}; + double maximum[METRIC_COUNT][QPLANES] = {}; + int finite[METRIC_COUNT][QPLANES] = {}; + int infinite[METRIC_COUNT][QPLANES] = {}; + CString error, metricError[METRIC_COUNT]; +}; + +// UI buffers are copied once per affected source. Workers never borrow a buffer +// that playback, seeking, or source replacement can mutate. +struct CurrentComparisonRequest +{ + unsigned long long id = 0; + unsigned targets = 0; // Candidate source-slot bits: B=2, C=4, D=8. + int metric = 0, width = 0, height = 0; + bool hasRoi = false; + int l = 0, t = 0, r = 0, b = 0; + std::shared_ptr sources[4]; + IFrmCmpStrategy* strategies[3] = {}; +}; diff --git a/Comparator/FileScanThread.cpp b/Comparator/FileScanThread.cpp index c0ab3ac3..71baa9d5 100644 --- a/Comparator/FileScanThread.cpp +++ b/Comparator/FileScanThread.cpp @@ -1,195 +1,309 @@ #include "StdAfx.h" -#include "ComparatorDoc.h" -#include "ComparatorView.h" -#include "FrmCmpInfo.h" #include "FileScanThread.h" #include "FrmCmpStrategy.h" -#include "QDebug.h" +#include "LpipsEngine.h" +#include -FileScanThread::FileScanThread(const CComparatorDoc *pDoc) -: mDoc(pDoc) -, mCurFrameID(0) -, mFrmCmpInfoSize(1) -, mFrmCmpInfo(new FrmCmpInfo[mFrmCmpInfoSize]) -, mCurFrames(0) -, mFrmCmpStrategy(NULL) -, mScanGeneration(0) -{ -} - -FileScanThread::~FileScanThread(void) -{ - delete [] mFrmCmpInfo; -} +FileScanThread::FileScanThread(const CComparatorDoc* doc) : mDoc(doc) {} +FileScanThread::~FileScanThread() { requestExitAndWait(); } -void FileScanThread::setup() +void FileScanThread::setup(unsigned changedSlots) { - // NOTE: this delete should be here in the same thread of a caller + // Called on the UI thread only after both workers have joined. SMutex::Autolock lock(mFrmCmpInfoLock); - mScanGeneration++; - - mCurFrames = mDoc->mMinFrames; - if (mCurFrames > mFrmCmpInfoSize) { - delete [] mFrmCmpInfo; - - mFrmCmpInfoSize = mCurFrames; - mFrmCmpInfo = new FrmCmpInfo[mCurFrames]; - } else { - for (int i = 0; i < mCurFrames; i++) { - mFrmCmpInfo[i].mParseDone = false; - mFrmCmpInfo[i].deinit(); + ++mScanGeneration; + for (int i=0;i<3;++i) { mPending[i].reset(); mLatestRequest[i]=0; } + mLiveRunning = false; mNextCurrentTarget=0; + mCurFrameID = mCurFrames = 0; mReadersReady = false; + mDirtyReaders |= changedSlots; + for (int i=0;i<4;++i) if (!mDoc->mPane[i].isAvail()) mScanInfo[i].closeFrmSrcs(); + memset(mDecodeCounts, 0, sizeof(mDecodeCounts)); + for (int t = 1; t < 4; ++t) { + int n = mDoc->PairFrames(t); + if ((changedSlots & (1u | (1u << t))) || n != mSummary[t-1].eligible) { + ++mPairGeneration[t-1]; + mFrames[t-1].clear(); mFrames[t-1].resize(n); + mSummary[t-1] = PairSequenceSummary(); + mSummary[t-1].eligible = n; } + mStrategies[t-1] = mDoc->PairStrategy(t); + mCurFrames = max(mCurFrames, n); } } SmpError FileScanThread::readyToRun() { - if (mCurFrames <= 0) { - LOGWRN("no frame to compare: minFrames(%d)", mCurFrames); - return SMP_FAIL; + try { + if (mReadersReady) return SMP_OK; + // UI source metadata is immutable between setup and requestExitAndWait. + for (int i = 0; i < 4; ++i) { + auto& scan = mScanInfo[i]; + const auto& pane = mDoc->mPane[i]; + if (scan.isAvail() && !(mDirtyReaders&(1u<mSortedCscInfo, + pane.srcW, pane.srcH, mDoc->mW, mDoc->mH); + } + for (int t = 1; t < 4; ++t) + if (mStrategies[t-1]) mStrategies[t-1]->AllocBuffer(&mScanInfo[0], &mScanInfo[t]); + mReadersReady = true; mDirtyReaders=0; + return SMP_OK; + } catch (const std::exception&) { + FailAnalysis(_T("Error: source preparation failed")); return SMP_FAIL; } +} - mCurFrameID = 0; - - for (int i = 0; i < COMPARING_VIEWS_NUM; i++) { - const ComparatorPane *pane = &mDoc->mPane[i]; - SQPane *scanInfo = &mScanInfo[i]; - scanInfo->closeFrmSrcs(); - scanInfo->origSceneSize = pane->origSceneSize; - if (scanInfo->origSceneSize > scanInfo->origBufSize) { - if (scanInfo->origBuf) - delete [] scanInfo->origBuf; - - scanInfo->origBuf = new BYTE[scanInfo->origSceneSize]; - scanInfo->origBufSize = scanInfo->origSceneSize; +void FileScanThread::Accumulate(int target, int frame, int metric) +{ + // Caller holds the cache lock. Averages count actual finite samples only; + // positive infinity is separately represented, never padded with zeros. + auto& s = mSummary[target-1]; + const auto& f = mFrames[target-1][frame]; + ++s.measured[metric]; + for (int p = 0; p < qmetric_info_table[metric].plane_count; ++p) { + double v = f.mMetrics[metric][p]; + if (std::isinf(v)) { ++s.infinite[metric][p]; continue; } + if (!std::isfinite(v)) continue; + if (!s.finite[metric][p]) s.minimum[metric][p] = s.maximum[metric][p] = v; + else { + s.minimum[metric][p] = min(s.minimum[metric][p], v); + s.maximum[metric][p] = max(s.maximum[metric][p], v); } - - scanInfo->colorSpace = pane->colorSpace; - scanInfo->csc2yuv420 = pane->csc2yuv420; - scanInfo->csc2rgb888 = pane->csc2rgb888; - scanInfo->csLoadInfo = pane->csLoadInfo; - scanInfo->OpenFrmSrc(pane->pathName, mDoc->mSortedCscInfo, - pane->srcW, pane->srcH, mDoc->mW, mDoc->mH); + ++s.finite[metric][p]; s.sum[metric][p] += v; } - - mFrmCmpStrategy = mDoc->mFrmCmpStrategy; - mFrmCmpStrategy->AllocBuffer(&mScanInfo[CComparatorDoc::IMG_VIEW_1], - &mScanInfo[CComparatorDoc::IMG_VIEW_2]); - - return SMP_OK; } bool FileScanThread::threadLoop() { - for (int i = 0; i < COMPARING_VIEWS_NUM; i++) { - SQPane *scanInfo = &mScanInfo[i]; - scanInfo->FillSceneBuf(scanInfo->origBuf); - } - - SQPane *scanInfoL = &mScanInfo[CComparatorDoc::IMG_VIEW_1]; - SQPane *scanInfoR = &mScanInfo[CComparatorDoc::IMG_VIEW_2]; - - FrmCmpInfo frmCmpInfo; - double (*metrics)[QPLANES] = frmCmpInfo.mMetrics; - mFrmCmpStrategy->DiffNMetrics(scanInfoL, scanInfoR, metrics, frmCmpInfo.diffRLC); - frmCmpInfo.mParseDone = true; - - { - SMutex::Autolock lock(mFrmCmpInfoLock); - for (int i = 0; i < METRIC_COUNT; i++) { - if (!qmetric_info_table[i].lazy) { - frmCmpInfo.mMetricDone[i] = true; + try { + std::shared_ptr live; + int target=0; + { + SMutex::Autolock lock(mFrmCmpInfoLock); + // Round-robin keeps another pair responsive during a rapid C-only seek. + for (int offset=0;offset<3;++offset) { + const int index=(mNextCurrentTarget+offset)%3; + if (!mPending[index]) continue; + live.swap(mPending[index]); target=index+1; + mNextCurrentTarget=(index+1)%3; break; + } + mLiveRunning = !!live; + } + if (live) { + MeasureCurrent(*live,target); + SMutex::Autolock lock(mFrmCmpInfoLock); + mLiveRunning = false; + } + if (exitPending()) return false; + if (mCurFrameID < mCurFrames) { + bool needed[4] = {}; + for (int t = 1; t < 4; ++t) + if (mCurFrameID < mSummary[t-1].eligible && !isFrameParsed(mCurFrameID, t) + && mSummary[t-1].error.IsEmpty()) needed[0] = needed[t] = true; + bool decoded[4] = {}; + for (int i = 0; i < 4; ++i) if (needed[i] && !exitPending()) { + auto& scan = mScanInfo[i]; + decoded[i] = scan.isAvail() && scan.frmSrc->SetNextFrameID(mCurFrameID) + && scan.FillSceneBuf(scan.origBuf); + SMutex::Autolock lock(mFrmCmpInfoLock); + if (decoded[i]) ++mDecodeCounts[i]; + } + for (int t = 1; t < 4 && !exitPending(); ++t) if (needed[t]) { + if (!decoded[0] || !decoded[t]) { + setPairError(t, _T("Error: frame decode failed")); continue; + } + FrmCmpInfo result; + mStrategies[t-1]->DiffNMetrics(&mScanInfo[0], &mScanInfo[t], result.mMetrics, result.diffRLC); + if (exitPending()) return false; + result.mParseDone = true; + SMutex::Autolock lock(mFrmCmpInfoLock); + mFrames[t-1][mCurFrameID] = result; + ++mSummary[t-1].parsed; + for (int m = 0; m < METRIC_COUNT; ++m) + if (!qmetric_info_table[m].lazy && qmetric_info_table[m].measure) { + bool valid=true; + for (int p=0;p= mDoc->mMinFrames) - return false; - - return true; } -void FileScanThread::deinit() +void FileScanThread::FailAnalysis(const CString& error) { SMutex::Autolock lock(mFrmCmpInfoLock); - mScanGeneration++; + for (int t=1;t<4;++t) if (mSummary[t-1].eligible) { + mSummary[t-1].error=error; + mLive[t-1]=PairCurrentResult(); mLive[t-1].request=mLatestRequest[t-1]; mLive[t-1].state=error; + } + for (auto& pending:mPending) pending.reset(); + mLiveRunning=false; mCurFrameID=mCurFrames; +} - for (int i = 0; i < mCurFrames; i++) { - mFrmCmpInfo[i].mParseDone = false; - mFrmCmpInfo[i].deinit(); +void FileScanThread::MeasureCurrent(const CurrentComparisonRequest& req, int t) +{ + const auto& metric = qmetric_info_table[req.metric]; + if (!exitPending()) { + { + SMutex::Autolock lock(mFrmCmpInfoLock); + if (req.id!=mLatestRequest[t-1]) return; + ++mCurrentMeasureCounts[t-1]; + } + if (!req.sources[0] || !req.sources[t] || !req.strategies[t-1]) return; + PairCurrentResult result; result.request = req.id; + auto* a = req.sources[0].get(); auto* b = req.sources[t].get(); + if (metric.lazy) { + if (!metric.ml_id || !LpipsEngine::getInstance().availableFor(metric.ml_id)) { + result.state = _T("Preparing"); + } else { + double cached[QPLANES]; + const bool aligned=a->curFrameID==b->curFrameID; + double value = aligned && copyMetrics(a->curFrameID,req.metric,cached,t) ? cached[0] : + LpipsEngine::getInstance().distance(a->rgbBuf, b->rgbBuf, + req.width, req.height, ROUNDUP_DWORD(req.width) * 3); + if (std::isfinite(value)) { + result.full.Format(_T("%s %.4f"), CString(CA2W(metric.name)).GetString(), value); + result.state = _T("Ready"); + } else result.state = _T("Error: inference failed"); + if (req.hasRoi && !exitPending()) { + size_t off = (size_t)req.t * ROUNDUP_DWORD(req.width) * 3 + req.l * 3; + double crop = LpipsEngine::getInstance().distance(a->rgbBuf + off, b->rgbBuf + off, + req.r - req.l + 1, req.b - req.t + 1, ROUNDUP_DWORD(req.width) * 3); + if (std::isfinite(crop)) result.roi.Format(_T("crop %dx%d %s(%.4f)"), + req.r - req.l + 1, req.b - req.t + 1, CString(CA2W(metric.name)).GetString(), crop); + else result.roi = _T("ROI: inference error"); + } + } + } else { + auto* strategy = req.strategies[t-1]; + strategy->AllocBuffer(a, b); + strategy->CalMetrics(a, b, req.metric, result.full); + result.full.Replace(_T("inf"), _T("\x221e")); + FrmCmpInfo channels; + strategy->DiffNMetrics(a, b, channels.mMetrics, channels.diffRLC); + const auto labels = strategy->GetPlaneLabels(); + for (int p = 0; p < 3; ++p) { + CString value; + if (std::isinf(channels.mMetrics[req.metric][p])) value = _T("\x221e"); + else if (!std::isfinite(channels.mMetrics[req.metric][p])) value=_T("N/A"); + else value.Format(_T("%.4f"), channels.mMetrics[req.metric][p]); + result.channels.AppendFormat(_T("%s%s %s"), p ? _T(" ") : _T(""), + CString(CA2W(labels[p])).GetString(), value.GetString()); + } + result.state = _T("Ready"); + if (result.channels.Find(_T("N/A"))>=0) result.state=_T("Error: channel measurement unavailable"); + if (req.hasRoi) result.roi.Format(_T("crop %dx%d %s"), req.r-req.l+1, req.b-req.t+1, + strategy->CropScore(a, b, req.metric, req.l, req.t, req.r, req.b).GetString()); + result.roi.Replace(_T("inf"),_T("\x221e")); + } + if (exitPending()) return; + SMutex::Autolock lock(mFrmCmpInfoLock); + if (req.id == mLatestRequest[t-1]) mLive[t-1] = result; } } -bool FileScanThread::isFrameParsed(int frameID) const +void FileScanThread::deinit() { setup(); } +bool FileScanThread::isFrameParsed(int frame, int t) const { SMutex::Autolock lock(mFrmCmpInfoLock); - - if (frameID < 0 || frameID >= mCurFrames) - return false; - - return mFrmCmpInfo[frameID].mParseDone; + return t >= 1 && t <= 3 && frame >= 0 && frame < (int)mFrames[t-1].size() + && mFrames[t-1][frame].mParseDone; } - bool FileScanThread::isScanComplete() const { SMutex::Autolock lock(mFrmCmpInfoLock); - - if (mCurFrames <= 0) - return true; - - return mFrmCmpInfo[mCurFrames - 1].mParseDone; + for (const auto& s : mSummary) if (s.error.IsEmpty() && s.parsed < s.eligible) return false; + return true; } - -bool FileScanThread::copyDiffRLC(int frameID, list diffRLC[QPLANES]) const +bool FileScanThread::copyDiffRLC(int frame, list rlc[QPLANES], int t) const { SMutex::Autolock lock(mFrmCmpInfoLock); - - if (frameID < 0 || frameID >= mCurFrames || !mFrmCmpInfo[frameID].mParseDone) - return false; - - for (int i = 0; i < QPLANES; i++) - diffRLC[i] = mFrmCmpInfo[frameID].diffRLC[i]; - + if (t < 1 || t > 3 || frame < 0 || frame >= (int)mFrames[t-1].size() || !mFrames[t-1][frame].mParseDone) return false; + for (int p = 0; p < QPLANES; ++p) rlc[p] = mFrames[t-1][frame].diffRLC[p]; return true; } - -bool FileScanThread::copyMetrics(int frameID, int metricIdx, double metrics[QPLANES]) const +bool FileScanThread::copyMetrics(int frame, int metric, double values[QPLANES], int t) const { SMutex::Autolock lock(mFrmCmpInfoLock); - - if (frameID < 0 || frameID >= mCurFrames || !mFrmCmpInfo[frameID].mParseDone) - return false; - - const qmetric_info *qminfo = &qmetric_info_table[metricIdx]; - if (qminfo->lazy && !mFrmCmpInfo[frameID].mMetricDone[metricIdx]) - return false; - - for (int i = 0; i < QPLANES; i++) - metrics[i] = mFrmCmpInfo[frameID].mMetrics[metricIdx][i]; - + if (t < 1 || t > 3 || metric < 0 || metric >= METRIC_COUNT || frame < 0 + || frame >= (int)mFrames[t-1].size() || !mFrames[t-1][frame].mMetricDone[metric]) return false; + for (int p = 0; p < QPLANES; ++p) values[p] = mFrames[t-1][frame].mMetrics[metric][p]; return true; } - -unsigned FileScanThread::getScanGeneration() const +unsigned FileScanThread::getScanGeneration() const { SMutex::Autolock lock(mFrmCmpInfoLock); return mScanGeneration; } +unsigned FileScanThread::pairGeneration(int t) const { SMutex::Autolock lock(mFrmCmpInfoLock); return mPairGeneration[t-1]; } +bool FileScanThread::setLazyMetric(int frame, int metric, double value, unsigned gen, int t) { SMutex::Autolock lock(mFrmCmpInfoLock); - - return mScanGeneration; + if (t < 1 || t > 3 || metric < 0 || metric >= METRIC_COUNT || gen != mPairGeneration[t-1] + || frame < 0 || frame >= (int)mFrames[t-1].size() || !mFrames[t-1][frame].mParseDone + || !std::isfinite(value)) return false; + auto& f = mFrames[t-1][frame]; + if (f.mMetricDone[metric]) return true; + f.mMetrics[metric][0] = value; f.mMetricDone[metric] = true; + Accumulate(t, frame, metric); return true; } - -bool FileScanThread::setLazyMetric(int frameID, int metricIdx, double v, unsigned gen) +PairSequenceSummary FileScanThread::summary(int t) const { SMutex::Autolock lock(mFrmCmpInfoLock); return mSummary[t-1]; } +void FileScanThread::setPairError(int t, const CString& error, int metric) { SMutex::Autolock lock(mFrmCmpInfoLock); - - if (gen != mScanGeneration) - return false; - - if (frameID < 0 || frameID >= mCurFrames || !mFrmCmpInfo[frameID].mParseDone) - return false; - - mFrmCmpInfo[frameID].mMetrics[metricIdx][0] = v; - mFrmCmpInfo[frameID].mMetricDone[metricIdx] = true; - - return true; + if (metric < 0) mSummary[t-1].error = error; + else mSummary[t-1].metricError[metric] = error; +} +unsigned FileScanThread::decodeCount(int slot) const { SMutex::Autolock lock(mFrmCmpInfoLock); return mDecodeCounts[slot]; } +void FileScanThread::queueCurrent(std::shared_ptr request) +{ + if (!request || !request->id) return; + SMutex::Autolock lock(mFrmCmpInfoLock); + for (int t=1;t<4;++t) if (request->targets&(1u<id; mPending[t-1] = request; + } +} +bool FileScanThread::copyCurrent(int t, unsigned long long id, PairCurrentResult& result) const +{ + SMutex::Autolock lock(mFrmCmpInfoLock); + if (t<1 || t>3 || !id || id!=mLatestRequest[t-1] || mLive[t-1].request != id) return false; + result = mLive[t-1]; return true; +} +unsigned long long FileScanThread::currentMeasureCount(int t) const +{ + SMutex::Autolock lock(mFrmCmpInfoLock); + return t>=1 && t<=3 ? mCurrentMeasureCounts[t-1] : 0; +} +unsigned FileScanThread::pendingCurrentMask() const +{ + SMutex::Autolock lock(mFrmCmpInfoLock); + unsigned mask=0; + for (int t=1;t<4;++t) if (mPending[t-1]) mask|=1u< mFrames[3]; + PairSequenceSummary mSummary[3]; + unsigned mPairGeneration[3] = {}, mScanGeneration = 0; + int mCurFrameID = 0, mCurFrames = 0; + bool mReadersReady = false; + unsigned mDirtyReaders = 0xf; + SQPane mScanInfo[4]; + IFrmCmpStrategy* mStrategies[3] = {}; + unsigned mDecodeCounts[4] = {}; + // Independently replaceable pair slots keep unrelated in-flight work valid. + std::shared_ptr mPending[3]; + unsigned long long mLatestRequest[3] = {}, mCurrentMeasureCounts[3] = {}; + int mNextCurrentTarget = 0; + bool mLiveRunning = false; + PairCurrentResult mLive[3]; + void MeasureCurrent(const CurrentComparisonRequest& request, int target); + void Accumulate(int target, int frame, int metric); + void FailAnalysis(const CString& error); public: @@ -28,14 +40,25 @@ class FileScanThread : public SThread virtual ~FileScanThread(void); virtual SmpError readyToRun(); virtual bool threadLoop(); - void setup(); + void setup(unsigned changedSlots = 0xf); void deinit(); - bool isFrameParsed(int frameID) const; + bool isFrameParsed(int frameID, int target = 1) const; bool isScanComplete() const; bool isScanRunning() const { return isRunning(); } - bool copyDiffRLC(int frameID, list diffRLC[QPLANES]) const; - bool copyMetrics(int frameID, int metricIdx, double metrics[QPLANES]) const; + bool copyDiffRLC(int frameID, list diffRLC[QPLANES], int target = 1) const; + bool copyMetrics(int frameID, int metricIdx, double metrics[QPLANES], int target = 1) const; unsigned getScanGeneration() const; - bool setLazyMetric(int frameID, int metricIdx, double v, unsigned gen); + unsigned pairGeneration(int target) const; + bool setLazyMetric(int frameID, int metricIdx, double v, unsigned gen, int target = 1); + PairSequenceSummary summary(int target) const; + void setPairError(int target, const CString& error, int metric = -1); + unsigned decodeCount(int slot) const; + void queueCurrent(std::shared_ptr request); + bool copyCurrent(int target, unsigned long long request, PairCurrentResult& result) const; + unsigned long long currentMeasureCount(int target) const; + unsigned pendingCurrentMask() const; + void cancelCurrent(); + bool hasWork() const; + void ensureRunning(); }; diff --git a/Comparator/FrmInfoView.cpp b/Comparator/FrmInfoView.cpp index d9bf60d6..50eaf1c0 100644 --- a/Comparator/FrmInfoView.cpp +++ b/Comparator/FrmInfoView.cpp @@ -4,13 +4,20 @@ #include "stdafx.h" #include "Comparator.h" #include "ComparatorDoc.h" +#include "ComparatorView.h" #include "FrmInfoView.h" #include "QViewerCmn.h" #include "Q1UiFontWin.h" +#include "MainFrm.h" +#include "QImageStr.h" +#include "ComparatorUtil.h" +#include "FrmsInfoView.h" +#include "PosInfoView.h" // CFrmInfoView -IMPLEMENT_DYNCREATE(CFrmInfoView, CView) +IMPLEMENT_DYNAMIC(CAnalysisScrollView, CScrollView) +IMPLEMENT_DYNCREATE(CFrmInfoView, CAnalysisScrollView) CFrmInfoView::CFrmInfoView() : mWClient(0) @@ -30,7 +37,7 @@ CFrmInfoView::~CFrmInfoView() { } -BEGIN_MESSAGE_MAP(CFrmInfoView, CView) +BEGIN_MESSAGE_MAP(CFrmInfoView, CAnalysisScrollView) ON_WM_CREATE() ON_WM_ERASEBKGND() ON_WM_SIZE() @@ -38,43 +45,147 @@ END_MESSAGE_MAP() int CFrmInfoView::OnCreate(LPCREATESTRUCT lpCreateStruct) { - if (CView::OnCreate(lpCreateStruct) == -1) + if (CAnalysisScrollView::OnCreate(lpCreateStruct) == -1) return -1; GetDocument()->mFrmInfoView = this; + if (!mReferenceLabel.Create(_T("Reference not loaded"),WS_CHILD|SS_OWNERDRAW,CRect(),this,0x7903)) return -1; + for (int i=0;i<3;++i) + if (!mPairButtons[i].Create(_T(""),WS_CHILD|WS_TABSTOP|BS_OWNERDRAW,CRect(),this,0x7900+i)) return -1; return 0; } -void CFrmInfoView::OnDraw(CDC* pDC) +CRect CFrmInfoView::SourceColumnBounds(int slot) const { - q1view::EnsureWindowsUiFont(mStateFont, q1view::WindowsUiFontRole::Body, m_hWnd); - CComparatorDoc *pDoc = GetDocument(); - - CString frameState = pDoc->mFrmState; - bool hasState = frameState.GetLength() > 0; - if (!hasState) - frameState = _T("Ready"); - - CDC memDC; - memDC.CreateCompatibleDC(pDC); - - CBitmap bitmap; - bitmap.CreateCompatibleBitmap(pDC, mWClient, mHClient); - - memDC.SelectObject(bitmap); - memDC.SetStretchBltMode(COLORONCOLOR); + CRect bounds(0,0,0,0); + auto* frame=static_cast(AfxGetMainWnd()); + auto* doc=GetDocument(); + if (!doc || !frame || slot<0 || slot>=frame->mNumOfViews) return bounds; + auto* view=doc->mPane[slot].pView; + if (!view || !view->GetSafeHwnd()) return bounds; + view->GetClientRect(bounds); + view->ClientToScreen(bounds); ScreenToClient(bounds); + CRect client; GetClientRect(client); + bounds.SetRect(max(0L,bounds.left),0,min(client.right,bounds.right),0); + return bounds; +} - memDC.FillSolidRect(CRect(0, 0, mWClient, mHClient), Q1UI_COLOR_SURFACE_ALT); - memDC.Draw3dRect(CRect(0, 0, mWClient, mHClient), Q1UI_COLOR_BORDER_SOFT, Q1UI_COLOR_BORDER_SOFT); +void CFrmInfoView::OnDraw(CDC* dc) +{ + auto* doc=GetDocument(); + auto* frame=static_cast(AfxGetMainWnd()); + q1view::EnsureWindowsUiFont(mStateFont,q1view::WindowsUiFontRole::Body,m_hWnd); + CRect rc; GetClientRect(rc); + const UINT dpi=q1view::WindowsUiDpi(m_hWnd); + const double scale=q1view::WindowsUiSettings().Scale(); + auto px=[&](int dip) { return q1view::WindowsUiPixels(dip,dpi,scale); }; + const int metric=frame->mMetricIdx; + CString texts[4]; CRect columns[4]; int extent=0; + if (doc->mPane[0].isAvail()) + texts[0].Format(_T("Reference \x00b7 #%ld"),doc->mPane[0].curFrameID); + else texts[0]=_T("Reference not loaded"); + auto* oldFont=dc->SelectObject(&mStateFont); + for (int slot=0;slot<4;++slot) { + columns[slot]=SourceColumnBounds(slot); + if (columns[slot].Width()<=0 || (slot && !doc->mPane[slot].isAvail())) continue; + if (slot) { + CString& text=texts[slot]; + text=doc->PairTitle(slot); + if (doc->PairFrames(slot) && slot==doc->mActiveTarget) text+=_T(" (Active)"); + if (!doc->mPane[0].isAvail()) text+=_T("\nLoad reference A to compare"); + else { + const auto& value=doc->mPairResults[slot-1]; + text.AppendFormat(_T(" | A #%ld / %c #%ld\n"),doc->mPane[0].curFrameID,_T('A')+slot,doc->mPane[slot].curFrameID); + if (doc->PairFrames(slot)) text+=value.full.IsEmpty() ? value.state : value.full; + else text+=_T("No comparable frames"); + if (!value.full.IsEmpty() && metric==METRIC_PSNR_IDX) text+=_T(" dB"); + if (!value.full.IsEmpty() && value.state!=_T("Ready")) text+=_T("\n")+value.state; + if (!value.roi.IsEmpty()) text+=_T("\n")+value.roi; + if (doc->mPane[0].curFrameID>=0 && doc->mPane[slot].curFrameID>=0 && + doc->mPane[0].curFrameID!=doc->mPane[slot].curFrameID) + text+=_T("\nUnaligned: frame indices differ"); + if (doc->mRequestedFrame>=doc->PairFrames(slot)) text.AppendFormat(_T("\nNo aligned frame #%ld; showing actual frames above"),doc->mRequestedFrame); + if (doc->mPane[0].GetFps()!=doc->mPane[slot].GetFps()) text+=_T("\nFrame-index comparison; FPS differs"); + } + } + // Reserve scrollbar width while measuring the last column, avoiding + // a wrap/extent oscillation when overflow becomes visible. + int width=columns[slot].Width()-(slot==frame->mNumOfViews-1 ? px(17) : 0); + CRect measure(0,0,max(1,width-16),0); + dc->DrawText(texts[slot],measure,DT_CALCRECT|DT_WORDBREAK|DT_NOPREFIX); + extent=max(extent,measure.Height()+8); + } + dc->SelectObject(oldFont); + SetAnalysisExtent(extent); + dc->FillSolidRect(CRect(0,GetScrollPos(SB_VERT),rc.Width(),GetScrollPos(SB_VERT)+rc.Height()),Q1UI_COLOR_SURFACE); + for (int slot=0;slot<4;++slot) { + CWnd& footer=slot ? static_cast(mPairButtons[slot-1]) : static_cast(mReferenceLabel); + if (columns[slot].Width()<=0 || (slot && !doc->mPane[slot].isAvail())) { + footer.ShowWindow(SW_HIDE); continue; + } + CString old; footer.GetWindowText(old); + if (old!=texts[slot]) footer.SetWindowText(texts[slot]); + if (slot) { + auto& button=mPairButtons[slot-1]; + button.active=doc->PairFrames(slot) && slot==doc->mActiveTarget; + button.EnableWindow(doc->PairFrames(slot)>0); + CString info; info.Format(_T("A: %s\n%c: %s\nA %dx%d / %c %dx%d\nFrame-index comparison"), + doc->mPane[0].pathName.GetString(),_T('A')+slot,doc->mPane[slot].pathName.GetString(), + doc->mPane[0].srcW,doc->mPane[0].srcH,_T('A')+slot,doc->mPane[slot].srcW,doc->mPane[slot].srcH); + if (doc->PairFrames(slot)) { + if (!qmetric_info_table[metric].lazy && doc->IsRGBCompare(doc->mPane,doc->mPane+slot)) + info+=_T("\nComparison basis: RGB aggregate"); + const auto& value=doc->mPairResults[slot-1]; + if (!value.channels.IsEmpty()) + info.AppendFormat(_T("\n\n%s channels%s\n%s"),CString(CA2W(qmetric_info_table[metric].name)).GetString(), + metric==METRIC_PSNR_IDX ? _T(" (dB)") : _T(""),value.channels.GetString()); + } + button.SetInfoTip(info); + } + CRect desired(columns[slot].left,-GetScrollPos(SB_VERT),min(columns[slot].right,(LONG)ContentWidth()),extent-GetScrollPos(SB_VERT)); + CRect current; footer.GetWindowRect(current); ScreenToClient(current); + if (current!=desired || !footer.IsWindowVisible()) + footer.SetWindowPos(nullptr,desired.left,desired.top,max(0,desired.Width()),desired.Height(),SWP_NOZORDER|SWP_NOACTIVATE|SWP_SHOWWINDOW); + footer.Invalidate(FALSE); + } +} - memDC.SelectObject(&mStateFont); - memDC.SetBkMode(TRANSPARENT); - memDC.SetTextColor(hasState ? Q1UI_COLOR_TEXT : Q1UI_COLOR_TEXT_MUTED); - memDC.DrawText(frameState, &mRcClient, DT_SINGLELINE | DT_CENTER | DT_VCENTER); +BOOL CFrmInfoView::OnCommand(WPARAM w, LPARAM l) +{ + if (HIWORD(w)==BN_CLICKED && LOWORD(w)>=0x7900 && LOWORD(w)<0x7903) { + GetDocument()->SelectComparison(LOWORD(w)-0x7900+1); return TRUE; + } + return CAnalysisScrollView::OnCommand(w,l); +} - pDC->BitBlt(0, 0, mWClient, mHClient, &memDC, 0, 0, SRCCOPY); +BOOL CFrmInfoView::PreTranslateMessage(MSG* message) +{ + if (message->message==WM_KEYDOWN) { + auto slots=GetDocument()->ComparisonTargets(); + int focused=-1; + for (int i=0;i<(int)slots.size();++i) + if (message->hwnd==mPairButtons[slots[i]-1].m_hWnd) focused=i; + if (!slots.empty() && message->wParam==VK_TAB) { + int direction=(GetKeyState(VK_SHIFT)&0x8000) ? -1 : 1; + if (direction>0 && (focused==(int)slots.size()-1 || message->hwnd==mAnalysisScroll.m_hWnd)) { + GetDocument()->mFrmsInfoView->FocusHeader(); return TRUE; + } + if (focused==0 && direction<0) { GetDocument()->mPosInfoView->SetFocus(); return TRUE; } + int next=focused<0 ? 0 : (focused+direction+(int)slots.size())%(int)slots.size(); + auto& button=mPairButtons[slots[next]-1]; + CRect bounds; button.GetWindowRect(bounds); ScreenToClient(bounds); + int scroll=GetScrollPos(SB_VERT); + if (bounds.top<0) OnScrollBy(CSize(0,bounds.top)); + else if (bounds.bottom>mHClient) OnScrollBy(CSize(0,bounds.top)); + UpdateWindow(); button.SetFocus(); return TRUE; + } + if (focused>=0 && message->wParam==VK_RETURN) { + mPairButtons[slots[focused]-1].SendMessage(BM_CLICK); return TRUE; + } + } + return CAnalysisScrollView::PreTranslateMessage(message); } @@ -97,25 +208,17 @@ BOOL CFrmInfoView::OnEraseBkgnd(CDC* pDC) { CComparatorDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); - if (!pDoc) - return TRUE; - - BOOL ret; - - CString &frameState = pDoc->mFrmState; - if (frameState.GetLength()) { - // The state text is painted from an off-screen buffer. - ret = TRUE; - } else { - ret = CView::OnEraseBkgnd(pDC); + if (!pDoc || pDoc->mFrmState.IsEmpty()) { + // Empty/loading/missing-reference states must not expose COLOR_WINDOW. + CRect rc; GetClientRect(rc); + pDC->FillSolidRect(rc,Q1UI_COLOR_SURFACE); } - - return ret; + return TRUE; } void CFrmInfoView::OnSize(UINT nType, int cx, int cy) { - CView::OnSize(nType, cx, cy); + CAnalysisScrollView::OnSize(nType, cx, cy); GetClientRect(&mRcClient); diff --git a/Comparator/FrmInfoView.h b/Comparator/FrmInfoView.h index b3afd75d..faa19e86 100644 --- a/Comparator/FrmInfoView.h +++ b/Comparator/FrmInfoView.h @@ -1,10 +1,11 @@ #pragma once #include "afxwin.h" +#include "AnalysisScrollView.h" #define FRM_STATE_FONT_H 16 // CFrmInfoView view -class CFrmInfoView : public CView +class CFrmInfoView : public CAnalysisScrollView { DECLARE_DYNCREATE(CFrmInfoView) @@ -32,6 +33,11 @@ class CFrmInfoView : public CView CFont mStateFont; int mWClient, mHClient; CRect mRcClient; + CReferenceLabel mReferenceLabel; + CComparisonButton mPairButtons[3]; + CRect SourceColumnBounds(int slot) const; + BOOL OnCommand(WPARAM wParam, LPARAM lParam) override; + BOOL PreTranslateMessage(MSG* message) override; afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct); afx_msg BOOL OnEraseBkgnd(CDC* pDC); diff --git a/Comparator/FrmsInfoView.cpp b/Comparator/FrmsInfoView.cpp index 5c3a48a9..2460c37d 100644 --- a/Comparator/FrmsInfoView.cpp +++ b/Comparator/FrmsInfoView.cpp @@ -1,56 +1,14 @@ -// FrmsInfoView.cpp : implementation file -// - #include "stdafx.h" - #include "Comparator.h" #include "ComparatorDoc.h" #include "MainFrm.h" - -#include "FrmCmpInfo.h" #include "FrmsInfoView.h" #include "FileScanThread.h" #include "FrmCmpStrategy.h" -#include "MetricCal.h" -#include "Q1UiFontWin.h" - -#include "QDebug.h" - -// CFrmsInfoView - -IMPLEMENT_DYNCREATE(CFrmsInfoView, CView) - -CFrmsInfoView::CFrmsInfoView() -: mWClient(0) -, mHClient(0) -, mFileScanThread(NULL) -{ - GdiplusStartupInput gdiplusStartupInput; - GdiplusStartup(&mGdiplusToken, &gdiplusStartupInput, NULL); - - LOGFONT lf; - - ::ZeroMemory(&lf, sizeof(lf)); - lf.lfHeight = LABEL_FONT_H; - ::lstrcpy(lf.lfFaceName, q1view::WindowsUiTextFontFamily()); - - mLabelFont.CreateFontIndirect(&lf); +#include - lf.lfHeight = RESULT_FONT_H; - mResultFont.CreateFontIndirect(&lf); - - // MetricCal owns GDI+ objects, so create it after GdiplusStartup(). - mPsnrCal = new MetricCal(); -} - -CFrmsInfoView::~CFrmsInfoView() -{ - delete mPsnrCal; - - GdiplusShutdown(mGdiplusToken); -} - -BEGIN_MESSAGE_MAP(CFrmsInfoView, CView) +IMPLEMENT_DYNCREATE(CFrmsInfoView,CAnalysisScrollView) +BEGIN_MESSAGE_MAP(CFrmsInfoView,CAnalysisScrollView) ON_WM_SIZE() ON_WM_CREATE() ON_WM_ERASEBKGND() @@ -60,243 +18,186 @@ BEGIN_MESSAGE_MAP(CFrmsInfoView, CView) ON_WM_KEYDOWN() END_MESSAGE_MAP() +CFrmsInfoView::CFrmsInfoView() : mWClient(0),mHClient(0),mFileScanThread(nullptr) {} +CFrmsInfoView::~CFrmsInfoView() {} -static void DrawCenteredMessage(CDC *pDC, CRect rect, CFont *titleFont, CFont *bodyFont, - const CString &title, const CString &body) +int CFrmsInfoView::OnCreate(LPCREATESTRUCT cs) { - CRect titleRect = rect; - titleRect.bottom = rect.CenterPoint().y - 2; - CRect bodyRect = rect; - bodyRect.top = rect.CenterPoint().y + 5; - - pDC->SetBkMode(TRANSPARENT); - CFont *prevFont = pDC->SelectObject(titleFont); - pDC->SetTextColor(Q1UI_COLOR_TEXT); - pDC->DrawText(title, &titleRect, DT_SINGLELINE | DT_CENTER | DT_BOTTOM | DT_END_ELLIPSIS); - pDC->SelectObject(bodyFont); - pDC->SetTextColor(Q1UI_COLOR_TEXT_MUTED); - pDC->DrawText(body, &bodyRect, DT_SINGLELINE | DT_CENTER | DT_TOP | DT_END_ELLIPSIS); - pDC->SelectObject(prevFont); + if (CAnalysisScrollView::OnCreate(cs)==-1) return -1; + GetDocument()->mFrmsInfoView=this; + mFileScanThread=GetDocument()->mFileScanThread; + if (!mCollapse.Create(_T("Sequence analysis - collapse"),WS_CHILD|WS_VISIBLE|WS_TABSTOP|BS_OWNERDRAW,CRect(),this,0x7950)) return -1; + return 0; } -void CFrmsInfoView::OnDraw(CDC* pDC) +void CFrmsInfoView::OnDraw(CDC* dc) { - mPsnrCal->UpdateAppearance(); - q1view::EnsureWindowsUiFont(mLabelFont, q1view::WindowsUiFontRole::Numeric, m_hWnd); - q1view::EnsureWindowsUiFont(mResultFont, q1view::WindowsUiFontRole::Status, m_hWnd); - q1view::EnsureWindowsUiFont(mBodyFont, q1view::WindowsUiFontRole::Body, m_hWnd); - CComparatorDoc* pDoc = GetDocument(); - - CDC memDC; - memDC.CreateCompatibleDC(pDC); - - CBitmap bitmap; - bitmap.CreateCompatibleBitmap(pDC, mWClient, mHClient); - - memDC.SetStretchBltMode(COLORONCOLOR); - memDC.SelectObject(bitmap); - - memDC.FillSolidRect(CRect(0, 0, mWClient, mHClient), Q1UI_COLOR_SURFACE); - memDC.Draw3dRect(CRect(0, 0, mWClient, mHClient), Q1UI_COLOR_BORDER_SOFT, Q1UI_COLOR_BORDER_SOFT); - - size_t stepCount = pDoc->mMinFrames; - if (stepCount <= 0) { - DrawCenteredMessage(&memDC, CRect(0, 0, mWClient, mHClient), - &mResultFont, &mBodyFont, _T("Metrics"), - _T("Open two sources to calculate PSNR or SSIM")); - pDC->BitBlt(0, 0, mWClient, mHClient, &memDC, 0, 0, SRCCOPY); - - return; + auto* doc=GetDocument(); + const int metric=static_cast(AfxGetMainWnd())->mMetricIdx; + const auto& info=qmetric_info_table[metric]; + auto targets=doc->ComparisonTargets(); + const auto dpi=q1view::WindowsUiDpi(m_hWnd); + const double scale=q1view::WindowsUiSettings().Scale(); + auto px=[&](int dip) { return q1view::WindowsUiPixels(dip,dpi,scale); }; + bool onlyStills=true; + for (int target:targets) onlyStills=onlyStills && doc->PairFrames(target)<=1; + const int header=px(30), chartHeight=px(onlyStills ? 68 : 180); + q1view::EnsureWindowsUiFont(mLabelFont,q1view::WindowsUiFontRole::Numeric,m_hWnd); + q1view::EnsureWindowsUiFont(mBodyFont,q1view::WindowsUiFontRole::Body,m_hWnd); + bool stacked=ContentWidth() < (int)targets.size()*px(330); + SetAnalysisExtent(mCollapsed || targets.empty() ? header : header+chartHeight*(stacked ? (int)targets.size() : 1)); + int width=ContentWidth(), scroll=GetScrollPos(SB_VERT); + CRect client; GetClientRect(client); + dc->FillSolidRect(CRect(0,scroll,client.Width(),scroll+client.Height()),Q1UI_COLOR_SURFACE); + dc->SetBkMode(TRANSPARENT); dc->SelectObject(&mBodyFont); + CRect current; mCollapse.GetWindowRect(current); ScreenToClient(current); + const CRect desired(0,0,width,header); + if (current!=desired) + mCollapse.SetWindowPos(nullptr,0,0,width,header,SWP_NOZORDER|SWP_NOACTIVATE); + for (int i=0;i<3;++i) { mPlots[i].SetRectEmpty(); mCards[i].SetRectEmpty(); } + if (mCollapsed) return; + if (targets.empty()) { + CRect message(0,header,width,client.Height()+scroll); dc->SetTextColor(Q1UI_COLOR_TEXT_MUTED); + dc->DrawText(_T("Open reference A and at least one candidate to compare"),message,DT_CENTER|DT_WORDBREAK); return; } - - CMainFrame *pMainFrm = static_cast(AfxGetMainWnd()); - size_t itemCount = mPsnrCal->CalculateCoords(&mGraphRect, pMainFrm->mMetricIdx); - if (itemCount <= 0) { - DrawCenteredMessage(&memDC, CRect(0, 0, mWClient, mHClient), - &mResultFont, &mBodyFont, _T("Scanning"), - _T("Per-frame measurements will appear as frames are processed")); - pDC->BitBlt(0, 0, mWClient, mHClient, &memDC, 0, 0, SRCCOPY); - return; + // Shared X viewport and shared finite Y scale for the selected metric. + double lo=0,hi=1; bool finite=false; + for (int t:targets) { + auto s=mFileScanThread->summary(t); + for (int p=0;pmMaxFrames); + if (mFrameSpan<=0 || mFrameSpan>total) mFrameSpan=total; + mFirstFrame=max(0.,min(mFirstFrame,total-mFrameSpan)); + COLORREF colors[3]={Q1UI_COLOR_ACCENT,Q1UI_COLOR_WARNING,Q1UI_COLOR_DANGER}; + int index=0; + for (int target:targets) { + auto s=mFileScanThread->summary(target); + int x=stacked ? 0 : width*index/(int)targets.size(); + int right=stacked ? width : width*(index+1)/(int)targets.size(); + CRect card(x,header+(stacked ? index*chartHeight : 0),right,header+(stacked ? (index+1)*chartHeight : chartHeight)); + mCards[target-1]=card; + dc->Draw3dRect(card,target==doc->mActiveTarget ? Q1UI_COLOR_ACCENT : Q1UI_COLOR_BORDER_SOFT,Q1UI_COLOR_BORDER_SOFT); + CString title=doc->PairTitle(target); + title.AppendFormat(_T(" %s%s %d / %d"),CString(CA2W(info.name)).GetString(),metric==METRIC_PSNR_IDX ? _T(" (dB)") : _T(""),s.measured[metric],s.eligible); + CRect label=card; label.DeflateRect(px(8),px(4)); label.bottom=label.top+px(22); + dc->SetTextColor(Q1UI_COLOR_TEXT); dc->DrawText(title,label,DT_SINGLELINE|DT_END_ELLIPSIS|DT_NOPREFIX); + CRect plot=card; plot.DeflateRect(px(52),px(32),px(8),px(58)); + if (s.eligible<=1 || plot.Width()<5 || plot.Height()<5) { + label.top+=px(26); label.bottom=card.bottom-px(4); dc->SetTextColor(Q1UI_COLOR_TEXT_MUTED); + dc->DrawText(_T("Single-frame comparison\nCurrent results above; no temporal curve"),label,DT_WORDBREAK); ++index; continue; + } + mPlots[target-1]=plot; + dc->Draw3dRect(plot,Q1UI_COLOR_BORDER_SOFT,Q1UI_COLOR_BORDER_SOFT); + dc->SelectObject(&mLabelFont); + CString value; value.Format(_T("%.3g"),hi); + CRect tick(card.left+2,plot.top,plot.left-px(3),plot.top+px(20)); dc->DrawText(value,tick,DT_RIGHT|DT_SINGLELINE); + value.Format(_T("%.3g"),lo); tick.top=plot.bottom-px(20); tick.bottom=plot.bottom; dc->DrawText(value,tick,DT_RIGHT|DT_SINGLELINE); + value.Format(_T("#%d \x2014 #%d"),(int)mFirstFrame,(int)min((double)total-1,mFirstFrame+mFrameSpan-1)); + tick=CRect(plot.left,plot.bottom+2,plot.right,plot.bottom+px(21)); dc->DrawText(value,tick,DT_CENTER|DT_SINGLELINE); + // Missing, uncomputed and out-of-domain samples break the line. Infinity + // is an explicit marker above the finite plot, not a fabricated y value. + for (int p=0;pSelectObject(&pen); + std::vector points; int previousFrame=-1; + auto flush=[&]() { + if (points.size()>1) dc->Polyline(points.data(),(int)points.size()); + else if (!points.empty()) dc->SetPixelV(points[0],colors[p]); + points.clear(); + }; + for (int pixel=0;pixelcopyMetrics(frame,metric,values,target) || !std::isfinite(values[p])) { + flush(); + if (framecopyMetrics(frame,metric,values,target) && std::isinf(values[p])) { + dc->SetTextColor(colors[p]); dc->TextOut(plot.left+pixel,plot.top-px(16),_T("\x221e")); + } + continue; + } + int y=plot.bottom-1-(int)((values[p]-lo)/(hi-lo)*max(1,plot.Height()-2)); + points.emplace_back(plot.left+pixel,y); + } + flush(); + dc->SelectObject(old); + } + long displayed[2]={doc->mPane[0].curFrameID,doc->mPane[target].curFrameID}; + for (int source=0;source<2;++source) if (displayed[source]>=mFirstFrame && displayed[source]SelectObject(&marker); dc->MoveTo(markerX,plot.top); dc->LineTo(markerX,plot.bottom); dc->SelectObject(old); + } + CString averages=_T("Avg "); + const auto labels=doc->PairStrategy(target)->GetPlaneLabels(); + for (int p=0;p=s.eligible ? _T("Ready") : (info.lazy && !doc->IsLpipsScanRunning() ? _T("Unavailable") : _T("Computing")); + averages+=_T("\n")+state; + tick=CRect(card.left+px(8),card.bottom-px(40),card.right-px(8),card.bottom); + dc->SelectObject(&mBodyFont); dc->SetTextColor(Q1UI_COLOR_TEXT_MUTED); dc->DrawText(averages,tick,DT_WORDBREAK|DT_NOPREFIX); + ++index; } - - memDC.SetBkMode(TRANSPARENT); - mPsnrCal->UpdateFont(memDC.GetSafeHdc(), static_cast(mLabelFont.GetSafeHandle())); - - mPsnrCal->DrawCmpResult(&memDC, &mResultFont); - mPsnrCal->DrawYLabel(&memDC, &mYLabelRect, &mLabelFont); - mPsnrCal->DrawXLabel(&memDC, mHClient, &mLabelFont); - - // GDI+ gives the metric graph anti-aliased lines and labels. - Graphics graphics(memDC.m_hDC); - graphics.SetSmoothingMode(SmoothingModeAntiAlias); - graphics.SetTextRenderingHint(TextRenderingHintAntiAlias); - - IFrmCmpStrategy *frmCmpStrategy = pDoc->mFrmCmpStrategy; - // Per-plane metrics (PSNR/SSIM) label each R/G/B (or Y/U/V) line; a - // whole-image metric (LPIPS) is a single line labelled by the metric name. - const qmetric_info *qmi = &qmetric_info_table[pMainFrm->mMetricIdx]; - const char *wholeImageLabel[1] = { qmi->name }; - const char **planeLabels = (qmi->kind == QMETRIC_WHOLE_IMAGE) - ? wholeImageLabel : frmCmpStrategy->GetPlaneLabels(); - - mPsnrCal->DrawLines(&graphics); - mPsnrCal->DrawAverages(&graphics, &mAverageRect, planeLabels); - - pDC->BitBlt(0, 0, mWClient, mHClient, &memDC, 0, 0, SRCCOPY); -} - - -#ifdef _DEBUG -void CFrmsInfoView::AssertValid() const -{ - CView::AssertValid(); -} - -#ifndef _WIN32_WCE -void CFrmsInfoView::Dump(CDumpContext& dc) const -{ - CView::Dump(dc); -} -#endif -#endif //_DEBUG - - -void CFrmsInfoView::OnSize(UINT nType, int cx, int cy) -{ - CView::OnSize(nType, cx, cy); - const UINT dpi = q1view::WindowsUiDpi(m_hWnd); - const double scale = q1view::WindowsUiSettings().Scale(); - const int left = q1view::WindowsUiPixels(GRAPH_OUT_MARGIN_L, dpi, scale); - const int right = q1view::WindowsUiPixels(GRAPH_OUT_MARGIN_R, dpi, scale); - const int bottom = q1view::WindowsUiPixels(18, dpi, scale); - - GetClientRect(&mGraphRect); - mGraphRect.DeflateRect(left, GRAPH_OUT_MARGIN_T, right, bottom); - mYLabelRect.SetRect(0, - Y_LABEL_MARGIN_T, - left, - cy - bottom); - mAverageRect.SetRect(mGraphRect.right + AVG_MARGIN_L, - AVG_MARGIN_T, - cx - AVG_MARGIN_R, - cy - AVG_MARGIN_B); - - mWClient = cx; - mHClient = cy; -} - -int CFrmsInfoView::OnCreate(LPCREATESTRUCT lpCreateStruct) -{ - if (CView::OnCreate(lpCreateStruct) == -1) - return -1; - - CComparatorDoc* pDoc = GetDocument(); - - pDoc->mFrmsInfoView = this; - mFileScanThread = pDoc->mFileScanThread; - - return 0; -} - -BOOL CFrmsInfoView::OnEraseBkgnd(CDC* pDC) -{ - return TRUE; } -BOOL CFrmsInfoView::OnMouseWheel(UINT /*nFlags*/, short zDelta, CPoint pt) +void CFrmsInfoView::OnSize(UINT type,int cx,int cy) { CAnalysisScrollView::OnSize(type,cx,cy); mWClient=cx; mHClient=cy; } +BOOL CFrmsInfoView::OnEraseBkgnd(CDC*) { return TRUE; } +BOOL CFrmsInfoView::OnCommand(WPARAM w,LPARAM l) { - // pt is in screen coordinates; convert to client coordinates so we can - // check the cursor against the graph area. - CPoint client = pt; - ScreenToClient(&client); - if (!mGraphRect.PtInRect(client)) - return FALSE; - - int graphX = client.x - mGraphRect.left - GRAPH_IN_MARGIN_L; - mPsnrCal->ZoomAtX(zDelta, graphX); - Invalidate(FALSE); - return TRUE; + if (LOWORD(w)==0x7950 && HIWORD(w)==BN_CLICKED) { + mCollapsed=!mCollapsed; + mCollapse.SetWindowText(mCollapsed ? _T("Sequence analysis - expand") : _T("Sequence analysis - collapse")); + auto* frame=static_cast(AfxGetMainWnd()); CRect rc; frame->GetClientRect(rc); + frame->SendMessage(WM_SIZE,SIZE_RESTORED,MAKELPARAM(rc.Width(),rc.Height())); Invalidate(FALSE); return TRUE; + } + return CAnalysisScrollView::OnCommand(w,l); } - -void CFrmsInfoView::OnLButtonDblClk(UINT /*nFlags*/, CPoint point) +BOOL CFrmsInfoView::OnMouseWheel(UINT flags,short delta,CPoint screen) { - // Double-click anywhere on the graph restores the full-range view. - if (mGraphRect.PtInRect(point)) { - mPsnrCal->ResetView(); - Invalidate(FALSE); + CPoint pt=screen; ScreenToClient(&pt); pt.y+=GetScrollPos(SB_VERT); + for (auto plot:mPlots) if (plot.PtInRect(pt)) { + double anchor=mFirstFrame+(pt.x-plot.left)*mFrameSpan/max(1,plot.Width()); + double span=max(2.,min((double)GetDocument()->mMaxFrames,mFrameSpan*(delta>0 ? .8 : 1.25))); + mFirstFrame=anchor-(anchor-mFirstFrame)*span/mFrameSpan; mFrameSpan=span; Invalidate(FALSE); return TRUE; } + return DoMouseWheel(flags,delta,screen); } - -void CFrmsInfoView::OnLButtonDown(UINT nFlags, CPoint point) +void CFrmsInfoView::OnLButtonDblClk(UINT,CPoint) { mFirstFrame=mFrameSpan=0; Invalidate(FALSE); } +void CFrmsInfoView::OnLButtonDown(UINT flags,CPoint pt) { - if (!mGraphRect.PtInRect(point)) { - CView::OnLButtonDown(nFlags, point); - return; - } - - CComparatorDoc *pDoc = GetDocument(); - if (pDoc->mMinFrames <= 0) { - CView::OnLButtonDown(nFlags, point); - return; - } - - int graphX = point.x - mGraphRect.left - GRAPH_IN_MARGIN_L; - long frameID = mPsnrCal->FrameAtX(graphX); - if (frameID < 0) { - CView::OnLButtonDown(nFlags, point); - return; + pt.y+=GetScrollPos(SB_VERT); + for (int t:GetDocument()->ComparisonTargets()) if (mCards[t-1].PtInRect(pt)) { + GetDocument()->SelectComparison(t); + if (mPlots[t-1].PtInRect(pt)) { + long frame=(long)(mFirstFrame+(mFrameSpan-1)*(pt.x-mPlots[t-1].left)/max(1,mPlots[t-1].Width()-1)); + GetDocument()->KillPlayTimer(); GetDocument()->SetScenes(frame); GetDocument()->UpdateAllViews(nullptr); + } + SetFocus(); return; } - - pDoc->KillPlayTimer(); - pDoc->SetScenes(frameID); - - // Refresh the per-frame metric label shown in FrmInfoView for the new - // pair of frames. - IFrmCmpStrategy *compareStrategy = pDoc->mFrmCmpStrategy; - if (compareStrategy) { - CMainFrame *pMainFrm = static_cast(AfxGetMainWnd()); - ComparatorPane *paneL = &pDoc->mPane[CComparatorDoc::IMG_VIEW_1]; - pDoc->UpdateCurrentMetricState(pMainFrm->mMetricIdx); - } - - pDoc->UpdateAllViews(NULL); - - // Take focus so the user can drive playback (Space / Left / Right) - // directly from the graph after picking a frame; otherwise the frame's - // active view stays on ComparatorView and OnKeyDown never fires here. - SetFocus(); - - CView::OnLButtonDown(nFlags, point); + CAnalysisScrollView::OnLButtonDown(flags,pt); } - -void CFrmsInfoView::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) +void CFrmsInfoView::OnKeyDown(UINT key,UINT repeat,UINT flags) { - // Mirror CComparatorView::OnKeyDown's playback subset so Space / Left / - // Right behave the same when the user has clicked into the graph instead - // of the image canvas. - CComparatorDoc* pDoc = GetDocument(); - - bool isProcessing = pDoc->CheckImgViewProcessing(); - if (nChar != VK_SPACE && isProcessing) { - CView::OnKeyDown(nChar, nRepCnt, nFlags); - return; - } - - switch (nChar) { - case VK_SPACE: - pDoc->TogglePlay(); - break; - case VK_LEFT: - pDoc->OffsetScenes(-1); - break; - case VK_RIGHT: - pDoc->OffsetScenes(1); - break; - default: - CView::OnKeyDown(nChar, nRepCnt, nFlags); - return; - } - - pDoc->MarkImgViewProcessing(); - pDoc->UpdateAllViews(NULL); + auto* doc=GetDocument(); + if (key==VK_SPACE) doc->TogglePlay(); + else if (key==VK_LEFT) doc->OffsetScenes(-1); + else if (key==VK_RIGHT) doc->OffsetScenes(1); + else { CAnalysisScrollView::OnKeyDown(key,repeat,flags); return; } + doc->MarkImgViewProcessing(); doc->UpdateAllViews(nullptr); } +#ifdef _DEBUG +void CFrmsInfoView::AssertValid() const { CAnalysisScrollView::AssertValid(); } +void CFrmsInfoView::Dump(CDumpContext& dc) const { CAnalysisScrollView::Dump(dc); } +#endif diff --git a/Comparator/FrmsInfoView.h b/Comparator/FrmsInfoView.h index 7cbbec94..c94ef718 100644 --- a/Comparator/FrmsInfoView.h +++ b/Comparator/FrmsInfoView.h @@ -1,6 +1,7 @@ #pragma once #include "QViewerCmn.h" +#include "AnalysisScrollView.h" #define GRAPH_OUT_MARGIN_L 50 #define GRAPH_OUT_MARGIN_T 5 @@ -31,10 +32,9 @@ class CComparatorDoc; class FileScanThread; -class MetricCal; // CFrmsInfoView view -class CFrmsInfoView : public CView +class CFrmsInfoView : public CAnalysisScrollView { DECLARE_DYNCREATE(CFrmsInfoView) @@ -42,17 +42,25 @@ class CFrmsInfoView : public CView { return reinterpret_cast(m_pDocument); } int mWClient, mHClient; - CRect mGraphRect, mYLabelRect, mAverageRect; FileScanThread *mFileScanThread; - CFont mLabelFont, mResultFont, mBodyFont; - ULONG_PTR mGdiplusToken; - MetricCal *mPsnrCal; + CFont mLabelFont, mBodyFont; + CRect mPlots[3], mCards[3]; + double mFirstFrame = 0, mFrameSpan = 0; + CComparisonButton mCollapse; + BOOL OnCommand(WPARAM w, LPARAM l) override; + BOOL AnalysisMouseWheel(UINT flags,short delta,CPoint point) override { return OnMouseWheel(flags,delta,point); } protected: CFrmsInfoView(); // protected constructor used by dynamic creation virtual ~CFrmsInfoView(); public: + bool mCollapsed = false; + CRect PlotBounds(int target) const { return mPlots[target-1]; } + double VisibleFirstFrame() const { return mFirstFrame; } + double VisibleFrameSpan() const { return mFrameSpan; } + void ResetViewport() { mFirstFrame=mFrameSpan=0; Invalidate(FALSE); } + void FocusHeader() { mCollapse.SetFocus(); } virtual void OnDraw(CDC* pDC); // overridden to draw this view #ifdef _DEBUG virtual void AssertValid() const; diff --git a/Comparator/LpipsEngine.cpp b/Comparator/LpipsEngine.cpp index 58d0402a..a114484b 100644 --- a/Comparator/LpipsEngine.cpp +++ b/Comparator/LpipsEngine.cpp @@ -1,5 +1,6 @@ #include "stdafx.h" #include "LpipsEngine.h" +#include #include #include #include @@ -20,8 +21,8 @@ LpipsEngine& LpipsEngine::getInstance() bool LpipsEngine::availableFor(const std::string& backendId) const { - std::lock_guard lock(m_mutex); - return m_available.load() && m_backendId == backendId; + const int index = backendId == "alex" ? 1 : backendId == "vgg" ? 2 : 0; + return index != 0 && m_available.load() && m_backendIndex.load() == index; } LpipsEngine::LpipsEngine() @@ -75,6 +76,7 @@ bool LpipsEngine::init(const std::wstring& modelPath, const std::string& backend teardown(); m_initialized.store(false); m_available.store(false); + m_backendIndex.store(0); m_modelPath.clear(); m_backendId.clear(); } @@ -142,6 +144,7 @@ bool LpipsEngine::init(const std::wstring& modelPath, const std::string& backend m_modelPath = modelPath; m_backendId = backendId; + m_backendIndex.store(backendId == "alex" ? 1 : backendId == "vgg" ? 2 : 0); m_available.store(true); m_initialized.store(true); return true; @@ -189,8 +192,8 @@ double LpipsEngine::distance(const unsigned char* rgbA, const unsigned char* rgb // init() cannot release m_session while Run() is using it. std::lock_guard lock(m_mutex); - if (!m_available.load() || !m_session) - return 0.0; + if (!m_available.load() || !m_session || !rgbA || !rgbB || w<=0 || h<=0) + return std::numeric_limits::quiet_NaN(); int targetW = 0, targetH = 0; std::vector nchwA, nchwB; @@ -200,8 +203,16 @@ double LpipsEngine::distance(const unsigned char* rgbA, const unsigned char* rgb int64_t inputShape[] = { 1, 3, targetH, targetW }; OrtValue* inputTensors[2] = { nullptr, nullptr }; - m_ortApi->CreateTensorWithDataAsOrtValue(m_memoryInfo, nchwA.data(), nchwA.size() * sizeof(float), inputShape, 4, ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT, &inputTensors[0]); - m_ortApi->CreateTensorWithDataAsOrtValue(m_memoryInfo, nchwB.data(), nchwB.size() * sizeof(float), inputShape, 4, ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT, &inputTensors[1]); + std::vector* inputs[2]={&nchwA,&nchwB}; + for (int i=0;i<2;++i) { + OrtStatus* tensorStatus=m_ortApi->CreateTensorWithDataAsOrtValue(m_memoryInfo,inputs[i]->data(),inputs[i]->size()*sizeof(float), + inputShape,4,ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT,&inputTensors[i]); + if (tensorStatus || !inputTensors[i]) { + if (tensorStatus) m_ortApi->ReleaseStatus(tensorStatus); + for (auto* tensor:inputTensors) if (tensor) m_ortApi->ReleaseValue(tensor); + return std::numeric_limits::quiet_NaN(); + } + } // Must match the names baked into the exported graph by // tools/lpips/export_lpips_onnx.py (input_names / output_names). @@ -211,13 +222,14 @@ double LpipsEngine::distance(const unsigned char* rgbA, const unsigned char* rgb OrtStatus* status = m_ortApi->Run(m_session, nullptr, inputNames, (const OrtValue* const*)inputTensors, 2, outputNames, 1, &outputTensor); - double dist = 0.0; + double dist = std::numeric_limits::quiet_NaN(); if (!status && outputTensor) { float* outData = nullptr; - m_ortApi->GetTensorMutableData(outputTensor, (void**)&outData); - if (outData) + OrtStatus* outputStatus=m_ortApi->GetTensorMutableData(outputTensor, (void**)&outData); + if (!outputStatus && outData) dist = (double)outData[0]; + if (outputStatus) m_ortApi->ReleaseStatus(outputStatus); } if (status) { @@ -236,5 +248,5 @@ double LpipsEngine::distance(const unsigned char* rgbA, const unsigned char* rgb #else LpipsEngine::~LpipsEngine() {} bool LpipsEngine::init(const std::wstring& modelPath, const std::string& backendId) { return false; } -double LpipsEngine::distance(const unsigned char* rgbA, const unsigned char* rgbB, int w, int h, int stride) { return 0.0; } +double LpipsEngine::distance(const unsigned char* rgbA, const unsigned char* rgbB, int w, int h, int stride) { return std::numeric_limits::quiet_NaN(); } #endif diff --git a/Comparator/LpipsEngine.h b/Comparator/LpipsEngine.h index 8f3b0136..52e81fd5 100644 --- a/Comparator/LpipsEngine.h +++ b/Comparator/LpipsEngine.h @@ -33,6 +33,7 @@ class LpipsEngine ~LpipsEngine(); std::atomic m_available; + std::atomic m_backendIndex{0}; std::atomic m_initialized; // Serializes init()/distance() so a backend switch (which releases and diff --git a/Comparator/LpipsScanThread.cpp b/Comparator/LpipsScanThread.cpp index 13354170..0d9e86cc 100644 --- a/Comparator/LpipsScanThread.cpp +++ b/Comparator/LpipsScanThread.cpp @@ -13,12 +13,12 @@ LpipsScanThread::LpipsScanThread(CComparatorDoc* doc, FileScanThread* baseScanne , m_metricIdx(metricIdx) , m_w(doc->mW) , m_h(doc->mH) - , m_frames(doc->mMinFrames) + , m_frames(0) { CWnd* pMainWnd = AfxGetMainWnd(); m_hMainWnd = pMainWnd ? pMainWnd->GetSafeHwnd() : NULL; - for (int i = 0; i < 2; i++) { + for (int i = 0; i < 4; i++) { const ComparatorPane *pane = &m_doc->mPane[i]; m_pane[i].pathName = pane->pathName; m_pane[i].srcW = pane->srcW; @@ -28,6 +28,13 @@ LpipsScanThread::LpipsScanThread(CComparatorDoc* doc, FileScanThread* baseScanne m_pane[i].csLoadInfo = pane->csLoadInfo; m_pane[i].csc2yuv420 = pane->csc2yuv420; m_pane[i].csc2rgb888 = pane->csc2rgb888; + m_pane[i].disableImageSequence = pane->disableImageSequence; + m_pane[i].available = pane->isAvail(); + } + for (int t = 1; t < 4; ++t) { + m_pairFrames[t-1] = doc->PairFrames(t); + m_generation[t-1] = baseScanner->pairGeneration(t); + m_frames = max(m_frames, m_pairFrames[t-1]); } } @@ -38,84 +45,113 @@ LpipsScanThread::~LpipsScanThread() bool LpipsScanThread::threadLoop() { - unsigned gen = m_baseScanner->getScanGeneration(); - - // The backbone id ("alex"/"vgg") for the selected LPIPS metric drives which - // model is provisioned and loaded; non-ML metrics never start this worker. - const char* mlId = qmetric_info_table[m_metricIdx].ml_id; - if (!mlId) - return false; - - std::wstring modelPath = MlModelProvisioner::provisionLpipsModel(m_hMainWnd, mlId); - if (modelPath.empty()) - return false; - - if (!LpipsEngine::getInstance().init(modelPath, mlId)) - return false; + try { + unsigned gen = m_baseScanner->getScanGeneration(); + + // The backbone id ("alex"/"vgg") for the selected LPIPS metric drives which + // model is provisioned and loaded; non-ML metrics never start this worker. + const char* mlId = qmetric_info_table[m_metricIdx].ml_id; + if (!mlId) + return false; + + std::wstring modelPath = MlModelProvisioner::provisionLpipsModel(m_hMainWnd, mlId); + if (modelPath.empty()) { + for (int t = 1; t < 4; ++t) if (m_pairFrames[t-1]) + m_baseScanner->setPairError(t, _T("Unavailable: model preparation cancelled or failed"), m_metricIdx); + return false; + } - SQPane scanInfo[2]; - for (int i = 0; i < 2; i++) { - const PaneSnapshot *pane = &m_pane[i]; - scanInfo[i].origSceneSize = pane->origSceneSize; - scanInfo[i].origBuf = new BYTE[pane->origSceneSize]; - scanInfo[i].origBufSize = pane->origSceneSize; - scanInfo[i].colorSpace = pane->colorSpace; - scanInfo[i].csc2yuv420 = pane->csc2yuv420; - scanInfo[i].csc2rgb888 = pane->csc2rgb888; - scanInfo[i].csLoadInfo = pane->csLoadInfo; - scanInfo[i].OpenFrmSrc(pane->pathName, m_doc->mSortedCscInfo, - pane->srcW, pane->srcH, m_w, m_h); - - int stride = ROUNDUP_DWORD(m_w); - scanInfo[i].rgbBufSize = stride * m_h * QIMG_DST_RGB_BYTES; - scanInfo[i].rgbBuf = new BYTE[scanInfo[i].rgbBufSize]; - } + if (!LpipsEngine::getInstance().init(modelPath, mlId)) { + for (int t = 1; t < 4; ++t) if (m_pairFrames[t-1]) + m_baseScanner->setPairError(t, _T("Error: model initialization failed"), m_metricIdx); + return false; + } - for (int frameID = 0; frameID < m_frames; frameID++) - { - if (exitPending() || m_baseScanner->getScanGeneration() != gen) - break; + SQPane scanInfo[4]; + for (int i = 0; i < 4; i++) { + const PaneSnapshot *pane = &m_pane[i]; + if (!pane->available) continue; + scanInfo[i].origSceneSize = pane->origSceneSize; + scanInfo[i].origBuf = new BYTE[pane->origSceneSize]; + scanInfo[i].origBufSize = pane->origSceneSize; + scanInfo[i].colorSpace = pane->colorSpace; + scanInfo[i].csc2yuv420 = pane->csc2yuv420; + scanInfo[i].csc2rgb888 = pane->csc2rgb888; + scanInfo[i].csLoadInfo = pane->csLoadInfo; + scanInfo[i].disableImageSequence = pane->disableImageSequence; + scanInfo[i].OpenFrmSrc(pane->pathName, m_doc->mSortedCscInfo, + pane->srcW, pane->srcH, m_w, m_h); + + int stride = ROUNDUP_DWORD(m_w); + scanInfo[i].rgbBufSize = stride * m_h * QIMG_DST_RGB_BYTES; + scanInfo[i].rgbBuf = new BYTE[scanInfo[i].rgbBufSize]; + } - while (!m_baseScanner->isFrameParsed(frameID)) + for (int frameID = 0; frameID < m_frames; frameID++) { if (exitPending() || m_baseScanner->getScanGeneration() != gen) - goto cleanup; - - // If the base scan is no longer running, it either finished (this - // frame may have just been parsed in the same instant the running - // flag dropped) or aborted. Re-check once; if the frame is still - // unparsed, the scan errored out, so stop instead of waiting forever. - if (!m_baseScanner->isScanRunning()) { - if (m_baseScanner->isFrameParsed(frameID)) + break; + + bool needed[4] = {}; + for (int t = 1; t < 4; ++t) { + double cached[QPLANES]; + if (frameID >= m_pairFrames[t-1] || m_baseScanner->copyMetrics(frameID, m_metricIdx, cached, t)) continue; + while (!m_baseScanner->isFrameParsed(frameID, t)) + { + if (exitPending() || m_baseScanner->getScanGeneration() != gen) + goto cleanup; + + // If the base scan is no longer running, it either finished (this + // frame may have just been parsed in the same instant the running + // flag dropped) or aborted. Re-check once; if the frame is still + // unparsed, the scan errored out, so stop instead of waiting forever. + if (!m_baseScanner->isScanRunning()) { + if (m_baseScanner->isFrameParsed(frameID, t)) + break; + m_baseScanner->setPairError(t, _T("Error: base frame unavailable"), m_metricIdx); break; - goto cleanup; + } + Sleep(20); + } + if (m_baseScanner->isFrameParsed(frameID, t)) needed[0] = needed[t] = true; } - Sleep(20); - } - - for (int i = 0; i < 2; i++) { - scanInfo[i].FillSceneBuf(scanInfo[i].origBuf); - int bufOff2 = 0, bufOff3 = 0; - scanInfo[i].csLoadInfo(m_w, m_h, &bufOff2, &bufOff3); - scanInfo[i].csc2rgb888(scanInfo[i].rgbBuf, scanInfo[i].origBuf, scanInfo[i].origBuf + bufOff2, scanInfo[i].origBuf + bufOff3, ROUNDUP_DWORD(m_w), m_w, m_h); - } - - double dist = LpipsEngine::getInstance().distance(scanInfo[0].rgbBuf, scanInfo[1].rgbBuf, m_w, m_h, ROUNDUP_DWORD(m_w) * QIMG_DST_RGB_BYTES); - m_baseScanner->setLazyMetric(frameID, m_metricIdx, dist, gen); - } + bool decoded[4] = {}; + for (int i = 0; i < 4; i++) if (needed[i]) { + decoded[i] = scanInfo[i].isAvail() && scanInfo[i].frmSrc->SetNextFrameID(frameID) + && scanInfo[i].FillSceneBuf(scanInfo[i].origBuf); + if (!decoded[i]) continue; + int bufOff2 = 0, bufOff3 = 0; + scanInfo[i].csLoadInfo(m_w, m_h, &bufOff2, &bufOff3); + scanInfo[i].csc2rgb888(scanInfo[i].rgbBuf, scanInfo[i].origBuf, scanInfo[i].origBuf + bufOff2, scanInfo[i].origBuf + bufOff3, ROUNDUP_DWORD(m_w), m_w, m_h); + } -cleanup: - for (int i = 0; i < 2; i++) { - if (scanInfo[i].origBuf) { - delete[] scanInfo[i].origBuf; - scanInfo[i].origBuf = NULL; + for (int t = 1; t < 4 && !exitPending(); ++t) if (needed[t]) { + if (!decoded[0] || !decoded[t]) { + m_baseScanner->setPairError(t, _T("Error: frame decode failed"), m_metricIdx); continue; + } + double dist = LpipsEngine::getInstance().distance(scanInfo[0].rgbBuf, scanInfo[t].rgbBuf, m_w, m_h, ROUNDUP_DWORD(m_w) * QIMG_DST_RGB_BYTES); + if (!m_baseScanner->setLazyMetric(frameID, m_metricIdx, dist, m_generation[t-1], t)) + m_baseScanner->setPairError(t, _T("Error: inference failed"), m_metricIdx); + } } - if (scanInfo[i].rgbBuf) { - delete[] scanInfo[i].rgbBuf; - scanInfo[i].rgbBuf = NULL; + + cleanup: + for (int i = 0; i < 4; i++) { + if (scanInfo[i].origBuf) { + delete[] scanInfo[i].origBuf; + scanInfo[i].origBuf = NULL; + } + if (scanInfo[i].rgbBuf) { + delete[] scanInfo[i].rgbBuf; + scanInfo[i].rgbBuf = NULL; + } } - } - return false; + return false; + } catch (const std::exception&) { + for (int t=1;t<4;++t) if (m_pairFrames[t-1]) + m_baseScanner->setPairError(t,_T("Error: perceptual metric worker stopped"),m_metricIdx); + return false; + } } diff --git a/Comparator/LpipsScanThread.h b/Comparator/LpipsScanThread.h index dce2ad22..2249d61b 100644 --- a/Comparator/LpipsScanThread.h +++ b/Comparator/LpipsScanThread.h @@ -30,6 +30,8 @@ class LpipsScanThread : public SThread QIMAGE_CS_INFO_FN csLoadInfo; QIMAGE_CSC_FN csc2yuv420; QIMAGE_CSC_FN csc2rgb888; + bool disableImageSequence; + bool available; }; CComparatorDoc* m_doc; @@ -39,5 +41,7 @@ class LpipsScanThread : public SThread int m_w; int m_h; int m_frames; - PaneSnapshot m_pane[2]; + PaneSnapshot m_pane[4]; + int m_pairFrames[3]; + unsigned m_generation[3]; }; diff --git a/Comparator/MainFrm.cpp b/Comparator/MainFrm.cpp index 3b3c7a4d..34029680 100644 --- a/Comparator/MainFrm.cpp +++ b/Comparator/MainFrm.cpp @@ -9,6 +9,7 @@ #include "ComparatorView.h" #include "ComparatorViews.h" #include "MainFrm.h" +#include "AnalysisLayout.h" #include "FrmsInfoView.h" #include "FrmInfoView.h" @@ -508,9 +509,28 @@ void CMainFrame::OnSize(UINT nType, int cx, int cy) // Enlarged text must not produce negative splitter dimensions in a small // window. Cap the graph at one third of the content height so enlarged // labels cannot consume almost all of the image canvas. - const int graphHeight = std::min(q1view::WindowsUiPixels(FRAMES_INFO_H, dpi, scale), + auto* analysisDoc = static_cast(GetActiveDocument()); + const bool collapsed = analysisDoc && analysisDoc->mFrmsInfoView && analysisDoc->mFrmsInfoView->mCollapsed; + const bool stills = analysisDoc && analysisDoc->mMaxFrames<=1; + const int preferredGraph = std::min(q1view::WindowsUiPixels(collapsed ? 30 : stills ? 106 : FRAMES_INFO_H, dpi, scale), std::max(0, cy / 3 - mSplitMargin)); - const int positionWidth = std::min(q1view::WindowsUiPixels(POS_INFO_W, dpi, scale), + int controlsHeight=q1view::WindowsUiPixels(26,dpi,scale); + if (analysisDoc) for (int slot=0;slotmPane[slot].pView; + if (!view || !view->mCsQMenu.GetSafeHwnd()) continue; + CRect measured(0,0,0,0); view->mCsQMenu.CalcRect(&measured); + controlsHeight=std::max(controlsHeight,measured.Height()+q1view::WindowsUiPixels(QMENUITEM_IN_MARGIN_H,dpi)); + } + // Measure source controls at the current DPI/text size; cached old-DPI + // rectangles are not sufficient during a live WM_DPICHANGED transition. + const int minimumSource=q1view::WindowsUiPixels(60,dpi)+controlsHeight+8; + const auto heights=ResolveAnalysisHeights(cy,preferredGraph,q1view::WindowsUiPixels(FRAME_INFO_H,dpi,scale), + minimumSource,q1view::WindowsUiPixels(collapsed ? 30 : 56,dpi,scale),q1view::WindowsUiPixels(26,dpi,scale), + std::max(8,mSplitMargin)+std::max(8,mSplitBarW)); + const int graphHeight=heights.graph; + auto* layoutDoc = static_cast(GetActiveDocument()); + const int lanes = layoutDoc ? (int)layoutDoc->TimelineSources().size() : 0; + const int positionWidth = std::min(lanes && layoutDoc->mPosInfoView ? layoutDoc->mPosInfoView->SuggestedWidth() : q1view::WindowsUiPixels(POS_INFO_W, dpi, scale), std::max(0, cx - MIN_SIDE - mSplitMargin)); hOutside = std::max(0, cy - graphHeight - mSplitMargin); @@ -534,7 +554,7 @@ void CMainFrame::OnSize(UINT nType, int cx, int cy) mPosInfoSplitter.SetColumnInfo(1, wOutside, MIN_SIDE); mPosInfoSplitter.RecalcLayout(); - const int stateHeight = std::min(q1view::WindowsUiPixels(FRAME_INFO_H, dpi, scale), + const int stateHeight = std::min(heights.footer, std::max(0, hOutside - MIN_SIDE - mSplitBarW)); hInside = std::max(0, hOutside - stateHeight - mSplitBarW); mFrmInfoSplitter.SetRowInfo(0, hInside, MIN_SIDE); @@ -598,14 +618,14 @@ void CMainFrame::OnTimer(UINT_PTR nIDEvent) // A lazy metric (e.g. LPIPS) is filled by a background worker after the // base scan, so refresh its current-frame label live and keep the timer // running until that worker also finishes. - if (pDoc->IsLpipsScanRunning()) { + { pDoc->UpdateCurrentMetricState(mMetricIdx); if (pDoc->mFrmInfoView) pDoc->mFrmInfoView->Invalidate(FALSE); } fileScanThread = pDoc->mFileScanThread; - if (fileScanThread->isScanComplete() && !pDoc->IsLpipsScanRunning()) { + if (!fileScanThread->hasWork() && !pDoc->IsLpipsScanRunning()) { KillTimer(CTI_ID_POS_INVALIDATE); // One last label refresh so a value/"N/A" replaces "computing...". pDoc->UpdateCurrentMetricState(mMetricIdx); @@ -739,6 +759,7 @@ void CMainFrame::OnResolutionChange(UINT nID) UpdateResolutionLabel(w, h); CheckResolutionRadio(w, h); + pDoc->StopAnalysis(); pDoc->mW = w; pDoc->mH = h; @@ -797,6 +818,7 @@ void CMainFrame::OnViewsChange(UINT nID) int CMainFrame::ChangeNumOfViews(int newNumOfViews) { CComparatorDoc* pDoc = static_cast(GetActiveDocument()); + pDoc->StopAnalysis(); UpdateViewsLabel(newNumOfViews); CheckViewsRadio(newNumOfViews); @@ -816,6 +838,7 @@ int CMainFrame::ChangeNumOfViews(int newNumOfViews) { if (IsZoomed()) SendMessage(WM_SYSCOMMAND, SC_RESTORE, (LPARAM)GetSafeHwnd()); + if (!pDoc->mBatchOpening) pDoc->RebuildComparisons(); return oldNumOfViews; } diff --git a/Comparator/MainFrm.h b/Comparator/MainFrm.h index d69720d5..5ed1fca7 100644 --- a/Comparator/MainFrm.h +++ b/Comparator/MainFrm.h @@ -12,8 +12,8 @@ #include "Q1UiMenuWin.h" #include "Q1UiFrameWin.h" -#define FRAME_INFO_H 28 -#define FRAMES_INFO_H 84 +#define FRAME_INFO_H 72 +#define FRAMES_INFO_H 180 #define POS_INFO_W 76 #define WM_OPEN_PENDING_FILE (WM_APP + 1) diff --git a/Comparator/PosInfoView.cpp b/Comparator/PosInfoView.cpp index aedf2a8e..0e18caf7 100644 --- a/Comparator/PosInfoView.cpp +++ b/Comparator/PosInfoView.cpp @@ -110,10 +110,11 @@ void CPosInfoView::DrawEachRect(CDC* pDC, // describes differences in the currently displayed frame. if (parseDone) { CPen *prev = pDC->SelectObject(mDiffPen); - IFrmCmpStrategy *frmCmpStrategy = pDoc->mFrmCmpStrategy; + const int target = pane == pDoc->mPane ? pDoc->mActiveTarget : int(pane-pDoc->mPane); + IFrmCmpStrategy *frmCmpStrategy = pDoc->PairStrategy(target); list diffRLC[QPLANES]; - if (frmCmpStrategy && mFileScanThread->copyDiffRLC(i, diffRLC)) { + if (frmCmpStrategy && mFileScanThread->copyDiffRLC(i, diffRLC, target)) { frmCmpStrategy->FlagTotalDiffLine(diffRLC, mDiffFlags, mPosLinesPerFrame); DrawDiffPosLines(pDC, frameRect, mDiffFlags, mPosLinesPerFrame); } @@ -129,7 +130,7 @@ void CPosInfoView::DrawEachRect(CDC* pDC, } CString numStr; - numStr.Format(_T("%d"), i); + numStr = i >= pane->frames ? _T("\x2014") : isCurFrame ? _T("\x25cf") : _T(""); pDC->DrawText(numStr, frameRect, DT_SINGLELINE | DT_CENTER | DT_VCENTER); if (highlighted) @@ -138,37 +139,43 @@ void CPosInfoView::DrawEachRect(CDC* pDC, frameRect->OffsetRect(0, mPosLinesPerFrame + 1); } -void CPosInfoView::DrawFrameRect(CDC* pDC, CRect *clipBox, int w) +void CPosInfoView::DrawFrameRect(CDC* dc, CRect* clip, int width) { - CComparatorDoc* pDoc = GetDocument(); - - ComparatorPane *paneL = &pDoc->mPane[CComparatorDoc::IMG_VIEW_1]; - ComparatorPane *paneR = &pDoc->mPane[CComparatorDoc::IMG_VIEW_2]; - - int outerRcH = mPosLinesPerFrame + 1; - int startFrameID = clipBox->top / outerRcH; - int endFrameID = MIN(clipBox->bottom / outerRcH, pDoc->mMaxFrames - 1); - - int y = outerRcH * startFrameID - clipBox->top; - CRect frameRectL(0, y, w / 2, y + outerRcH); - CRect frameRectR(w / 2, y, w, y + outerRcH); - - pDC->SelectStockObject(WHITE_PEN); - pDC->SetBkMode(TRANSPARENT); - pDC->SelectObject(&mPosNumFont); - pDC->SetTextColor(COLOR_POS_NUM_TEXT); - - int curBrush; - int preBrush = -1; - - int minFrames = pDoc->mMinFrames; - - for (int i = startFrameID; i <= endFrameID; i++) { - bool parseDone = i < minFrames && mFileScanThread->isFrameParsed(i); - - DrawEachRect(pDC, paneL, &frameRectL, parseDone, curBrush, preBrush, i); - DrawEachRect(pDC, paneR, &frameRectR, parseDone, curBrush, preBrush, i); + auto* doc=GetDocument(); auto slots=doc->TimelineSources(); + if (slots.empty()) return; + int pitch=mPosLinesPerFrame+1; + int start=max(0,int((clip->top-mHeaderHeight)/pitch)); + int end=min(int((clip->bottom-mHeaderHeight)/pitch),doc->mMaxFrames-1); + dc->SelectObject(&mPosNumFont); dc->SetBkMode(TRANSPARENT); dc->SetTextColor(COLOR_POS_NUM_TEXT); + for (int i=start;i<=end;++i) { + int y=mHeaderHeight+pitch*i-clip->top; + CRect gutter(0,y,mGutterWidth,y+pitch); CString number; number.Format(_T("%d"),i); + dc->SetTextColor(COLOR_POS_NUM_TEXT); dc->DrawText(number,gutter,DT_SINGLELINE|DT_CENTER|DT_VCENTER); + for (size_t col=0;colmActiveTarget : slot; + CRect cell(mGutterWidth+(width-mGutterWidth)*(int)col/(int)slots.size(),y, + mGutterWidth+(width-mGutterWidth)*(int)(col+1)/(int)slots.size(),y+pitch); + int current=0,previous=-1; + bool parsed=imPane[slot].frames && mFileScanThread->isFrameParsed(i,target); + DrawEachRect(dc,doc->mPane+slot,&cell,parsed,current,previous,i); + } } + // Sticky source identifiers and shared frame-index gutter. + dc->FillSolidRect(CRect(0,0,width,mHeaderHeight),Q1UI_COLOR_SURFACE); + CRect gutter(0,0,mGutterWidth,mHeaderHeight); dc->DrawText(_T("#"),gutter,DT_CENTER|DT_SINGLELINE|DT_VCENTER); + for (size_t col=0;colSetTextColor(slots[col]==0 ? Q1UI_COLOR_ACCENT : Q1UI_COLOR_TEXT); + dc->DrawText(letter,cell,DT_CENTER|DT_SINGLELINE|DT_VCENTER); + } +} + +int CPosInfoView::SuggestedWidth() +{ + const auto dpi=q1view::WindowsUiDpi(m_hWnd); + return mGutterWidth+q1view::WindowsUiPixels(32*(int)GetDocument()->TimelineSources().size()+17,dpi,q1view::WindowsUiSettings().Scale()); } void CPosInfoView::OnDraw(CDC* pDC) @@ -203,16 +210,15 @@ void CPosInfoView::OnDraw(CDC* pDC) // Pilot: hide the timeline entirely when neither pane is a video. The pink // grid overlay in ComparatorView already conveys diff information; the // timeline is only useful for picking a frame in a video source. - bool hasVideo = (paneL->isAvail() && paneL->frames > 1) - || (paneR->isAvail() && paneR->frames > 1); + bool hasVideo = pDoc->mMaxFrames>1; - if (!paneL->isAvail() && !paneR->isAvail() || !hasVideo) { + if (pDoc->TimelineSources().empty() || !hasVideo) { CFont *prevFont = memDC.SelectObject(CFont::FromHandle( mUiFonts.Get(q1view::WindowsUiFontRole::Command, m_hWnd))); memDC.SetBkMode(TRANSPARENT); memDC.SetTextColor(Q1UI_COLOR_TEXT_MUTED); CRect msgRect(0, 0, mWClient, h); - LPCTSTR msg = (paneL->isAvail() || paneR->isAvail()) + LPCTSTR msg = !pDoc->TimelineSources().empty() ? _T("Timeline\n(video only)") : _T("Timeline"); memDC.DrawText(msg, &msgRect, DT_CENTER | DT_VCENTER | DT_WORDBREAK | DT_NOCLIP); @@ -272,14 +278,18 @@ void CPosInfoView::ConfigureScrollSizes(CComparatorDoc *pDoc) mWClient = rcClient.Width(); mHClient = rcClient.Height(); - const auto& left = pDoc->mPane[CComparatorDoc::IMG_VIEW_1]; - const auto& right = pDoc->mPane[CComparatorDoc::IMG_VIEW_2]; - const bool hasVideo = (left.isAvail() && left.frames>1) || (right.isAvail() && right.frames>1); + const bool hasVideo = pDoc->mMaxFrames>1; const int maxFrames = hasVideo ? max(pDoc->mMaxFrames,0) : 0; const int minimum = q1view::WindowsUiPixels(18, q1view::WindowsUiDpi(m_hWnd), q1view::WindowsUiSettings().Scale()); + mHeaderHeight = minimum+1; + q1view::EnsureWindowsUiFont(mPosNumFont,q1view::WindowsUiFontRole::Numeric,m_hWnd); + CClientDC dc(this); auto* old=dc.SelectObject(&mPosNumFont); + CString number; number.Format(_T("%d"), max(0,pDoc->mMaxFrames-1)); + mGutterWidth=dc.GetTextExtent(number).cx+q1view::WindowsUiPixels(12,q1view::WindowsUiDpi(m_hWnd)); + dc.SelectObject(old); // Include each row's border in its pitch. The old height+2 calculation // created a scroll range even for an empty or fully fitting timeline. - const int pitch = maxFrames ? max(mHClient/maxFrames,minimum+1) : minimum+1; + const int pitch = maxFrames ? max((mHClient-mHeaderHeight)/maxFrames,minimum+1) : minimum+1; mPosLinesPerFrame = pitch-1; if (mPosLinesPerFrame > mDiffFlagSize) { delete [] mDiffFlags; @@ -288,7 +298,7 @@ void CPosInfoView::ConfigureScrollSizes(CComparatorDoc *pDoc) mDiffFlags = new bool[mDiffFlagSize]; } - mPosLines = int(min(static_cast(pitch)*maxFrames,static_cast(INT_MAX))); + mPosLines = maxFrames ? int(min(mHeaderHeight+static_cast(pitch)*maxFrames,static_cast(INT_MAX))) : 0; SetScrollSizes(MM_TEXT, CSize(0,mPosLines), CSize(0,max(1,mHClient*9/10)), CSize(0,pitch)); const bool visible = mPosLines>mHClient && mHClient>0; if (mTimelineScroll.GetSafeHwnd()) { @@ -330,47 +340,24 @@ void CPosInfoView::OnSize(UINT nType, int cx, int cy) ConfigureScrollSizes(pDoc); } -void CPosInfoView::OnLButtonDown(UINT nFlags, CPoint point) +void CPosInfoView::OnLButtonDown(UINT flags,CPoint point) { - CComparatorDoc* pDoc = GetDocument(); - - ComparatorPane *paneL = &pDoc->mPane[CComparatorDoc::IMG_VIEW_1]; - ComparatorPane *paneR = &pDoc->mPane[CComparatorDoc::IMG_VIEW_2]; - - if (point.x>=mWClient || (!paneL->isAvail() && !paneR->isAvail())) - return; - - pDoc->KillPlayTimer(); - - CPoint ulCorner = GetScrollPosition(); - - int frameID = (ulCorner.y + point.y) / (mPosLinesPerFrame + 1); - frameID = min(frameID, pDoc->mMaxFrames - 1); - - CComparatorDoc::PANE_ID idx; - if (point.x < (mWClient >> 1)) - idx = CComparatorDoc::IMG_VIEW_1; - else - idx = CComparatorDoc::IMG_VIEW_2; - - ComparatorPane *pane = &pDoc->mPane[idx]; - pane->SetNextFrameID(frameID); - pDoc->ReadSource4View(pane); - - IFrmCmpStrategy *compareStrategy = pDoc->mFrmCmpStrategy; - if (compareStrategy) { - CMainFrame* pMainFrm = static_cast(AfxGetMainWnd()); - pDoc->UpdateCurrentMetricState(pMainFrm->mMetricIdx); + auto* doc=GetDocument(); auto slots=doc->TimelineSources(); + if (slots.empty() || point.x>=mWClient || point.ymMaxFrames<=1) return; + int frame=(GetScrollPosition().y+point.y-mHeaderHeight)/(mPosLinesPerFrame+1); + if (frame<0 || frame>=doc->mMaxFrames) return; + if (point.xKillPlayTimer(); doc->SetScenes(frame); } + else { + doc->mRequestedFrame=-1; + int col=(point.x-mGutterWidth)*(int)slots.size()/max(1,mWClient-mGutterWidth); + if (col<0 || col>=(int)slots.size()) return; + auto* pane=doc->mPane+slots[col]; + if (frame>=pane->frames) return; // A missing frame is not the last frame. + doc->KillPlayTimer(); bool updated=false; doc->SetScene(frame,pane,updated); + doc->RefreshSelectionMetric(); } - - pDoc->UpdateAllViews(NULL); - - // Take focus so the user can drive playback (Space / Left / Right) - // directly from the timeline after seeking; otherwise the frame's active - // view stays on ComparatorView and OnKeyDown never fires here. - SetFocus(); - - CScrollView::OnLButtonDown(nFlags, point); + doc->UpdateAllViews(nullptr); SetFocus(); + CScrollView::OnLButtonDown(flags,point); } BOOL CPosInfoView::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) diff --git a/Comparator/PosInfoView.h b/Comparator/PosInfoView.h index e7cdd180..5a8bc55c 100644 --- a/Comparator/PosInfoView.h +++ b/Comparator/PosInfoView.h @@ -29,6 +29,8 @@ class CPosInfoView : public CScrollView #endif #endif void ConfigureScrollSizes(CComparatorDoc *pDoc); + int SuggestedWidth(); + int mHeaderHeight = 0, mGutterWidth = 0; CScrollBar* GetScrollBarCtrl(int bar) const override; void DrawFrameRect(CDC* pDC, CRect *clipBox, int w); void DrawEachRect(CDC* pDC, diff --git a/Comparator/RgbFrmCmpStrategy.cpp b/Comparator/RgbFrmCmpStrategy.cpp index 09cd6aa5..0368eb5f 100644 --- a/Comparator/RgbFrmCmpStrategy.cpp +++ b/Comparator/RgbFrmCmpStrategy.cpp @@ -19,6 +19,7 @@ void RgbFrmCmpStrategy::Rgb888AllocBuffer(SQPane *pane) const BYTE *RgbFrmCmpStrategy::ConvertToRgb888(SQPane *pane, int w, int h) { + if (pane->rgbValid) return pane->rgbBuf; int bufOffset2 = 0, bufOffset3 = 0; pane->csLoadInfo(w, h, &bufOffset2, &bufOffset3); @@ -28,6 +29,7 @@ BYTE *RgbFrmCmpStrategy::ConvertToRgb888(SQPane *pane, int w, int h) pane->origBuf + bufOffset2, pane->origBuf + bufOffset3, ROUNDUP_DWORD(w), w, h); + pane->rgbValid = true; return pane->rgbBuf; } diff --git a/Comparator/YuvFrmCmpStrategy.cpp b/Comparator/YuvFrmCmpStrategy.cpp index 818590c7..7d237bab 100644 --- a/Comparator/YuvFrmCmpStrategy.cpp +++ b/Comparator/YuvFrmCmpStrategy.cpp @@ -24,6 +24,7 @@ void YuvFrmCmpStrategy::Yuv420AllocBuffer(SQPane *pane) const BYTE *YuvFrmCmpStrategy::ConvertToYuv420(SQPane *pane, int w, int h) { + if (pane->yuvValid) return pane->yuv420Buf; int bufOffset2 = 0, bufOffset3 = 0; pane->csLoadInfo(w, h, &bufOffset2, &bufOffset3); @@ -33,6 +34,7 @@ BYTE *YuvFrmCmpStrategy::ConvertToYuv420(SQPane *pane, int w, int h) pane->origBuf + bufOffset2, pane->origBuf + bufOffset3, 0, w, h); + pane->yuvValid = true; return pane->yuv420Buf; } diff --git a/Tests/ComparerTypographyIntegrationTests.cpp b/Tests/ComparerTypographyIntegrationTests.cpp index a021a3c5..cef965b8 100644 --- a/Tests/ComparerTypographyIntegrationTests.cpp +++ b/Tests/ComparerTypographyIntegrationTests.cpp @@ -5,7 +5,13 @@ #include "../Comparator/ComparatorDoc.h" #include "../Comparator/ComparatorView.h" #include "../Comparator/FrmCmpStrategy.h" +#include "../Comparator/YuvFrmCmpStrategy.h" +#include "../Comparator/RgbFrmCmpStrategy.h" #include "../Comparator/PosInfoView.h" +#include "../Comparator/FileScanThread.h" +#include "../Comparator/FrmInfoView.h" +#include "../Comparator/FrmsInfoView.h" +#include "../Comparator/LpipsEngine.h" #include #pragma comment(lib, "oleacc.lib") #include "Q1UiFontWin.h" @@ -13,6 +19,10 @@ #include "SharedThemeTestWriter.h" #include #include +#include +#include +#include +#include "WindowsUiPaintTestProbe.h" namespace { FILE* report; @@ -25,7 +35,7 @@ void Pump(DWORD milliseconds) { const ULONGLONG end = GetTickCount64() + milliseconds; do { MSG message; - while (PeekMessage(&message, nullptr, 0, 0, PM_REMOVE)) { + for (int dispatched=0; dispatched<200 && PeekMessage(&message, nullptr, 0, 0, PM_REMOVE); ++dispatched) { if (message.message == WM_QUIT) throw std::runtime_error("unexpected quit"); if (!AfxGetApp()->PreTranslateMessage(&message)) { TranslateMessage(&message); DispatchMessage(&message); } } @@ -53,15 +63,16 @@ std::vector Pixels(HWND window) { struct RawTimelineFixture { CString path; - explicit RawTimelineFixture(int count) { + explicit RawTimelineFixture(int count, int delta=0, bool rgb=false, int width=16, int height=16) { wchar_t folder[MAX_PATH], name[MAX_PATH]; Check(GetTempPathW(MAX_PATH,folder) && GetTempFileNameW(folder,L"Q1T",0,name), "temporary timeline fixture allocated"); - path = CString(name)+L"_16x16_yuv420.yuv"; + CString suffix; suffix.Format(L"_%dx%d_%s.%s",width,height,rgb ? L"rgb888" : L"yuv420",rgb ? L"rgb" : L"yuv"); + path = CString(name)+suffix; DeleteFileW(name); FILE* file = nullptr; Check(_wfopen_s(&file,path,L"wb")==0 && file, "RAW timeline fixture opened"); - BYTE pixels[16*16*3/2]; memset(pixels,128,sizeof(pixels)); + std::vector pixels(rgb ? size_t(width)*height*3 : size_t(width)*height*3/2,128); bool written = true; - for (int i=0;iRelease(); Check(correct,"native accessible role remains ScrollBar"); } + +void WaitForAnalysis(CComparatorDoc* doc, DWORD limit=15000) { + ULONGLONG end=GetTickCount64()+limit; + for (;;) { + Pump(20); + doc->RefreshSelectionMetric(); + bool ready=doc->mFileScanThread->isScanComplete() && !doc->IsLpipsScanRunning(); + for (int target:doc->ComparisonTargets()) ready=ready && doc->mPairResults[target-1].state==_T("Ready"); + if (ready) return; + if (GetTickCount64()>=end) throw std::runtime_error("comparison analysis timed out"); + } +} + +bool SameCurrentResult(const PairCurrentResult& a,const PairCurrentResult& b) { + return a.request==b.request && a.full==b.full && a.channels==b.channels && a.roi==b.roi && a.state==b.state; +} + +std::shared_ptr TestCurrentRequest(CComparatorDoc* doc,unsigned long long id,unsigned targets) { + auto request=std::make_shared(); + request->id=id; request->targets=targets; request->width=doc->mW; request->height=doc->mH; + for (int slot=0;slot<4;++slot) if (!slot || (targets&(1u<mPane[slot]; + auto snapshot=std::make_shared(); + snapshot->curFrameID=source.curFrameID; snapshot->frames=source.frames; + snapshot->colorSpace=source.colorSpace; snapshot->csLoadInfo=source.csLoadInfo; + snapshot->csc2rgb888=source.csc2rgb888; snapshot->csc2yuv420=source.csc2yuv420; + snapshot->origBufSize=snapshot->origSceneSize=source.origSceneSize; + snapshot->origBuf=new BYTE[source.origSceneSize]; memcpy(snapshot->origBuf,source.origBuf,source.origSceneSize); + snapshot->rgbBufSize=source.rgbBufSize; snapshot->rgbBuf=new BYTE[source.rgbBufSize]; + memcpy(snapshot->rgbBuf,source.rgbBuf,source.rgbBufSize); snapshot->rgbValid=true; + request->sources[slot]=snapshot; + if (slot) request->strategies[slot-1]=doc->PairStrategy(slot); + } + return request; +} + +// Insert a newer request during a real metric calculation, deterministically +// testing the publication race without test hooks/sleeps in production code. +class QueueDuringMetricStrategy : public RgbFrmCmpStrategy { +public: + mutable std::function duringMeasure; + void CalMetricsImpl(ComparatorPane* a,ComparatorPane* b,int metric,std::vector& scores) const override { + auto callback=std::move(duringMeasure); + if (callback) callback(); + RgbFrmCmpStrategy::CalMetricsImpl(a,b,metric,scores); + } +}; + +void CheckCurrentRequestIsolation(CComparatorDoc* doc) { + doc->StopAnalysis(); + FileScanThread worker(doc); // No sequence domain: manually step the real current worker. + QueueDuringMetricStrategy strategyB,strategyC; + strategyB.Setup(doc->mW,doc->mH); strategyC.Setup(doc->mW,doc->mH); + auto b=TestCurrentRequest(doc,1,2),c=TestCurrentRequest(doc,2,4); + auto latestC=TestCurrentRequest(doc,3,4),d=TestCurrentRequest(doc,4,8); + b->strategies[0]=&strategyB; c->strategies[1]=&strategyC; + strategyB.duringMeasure=[&] { worker.queueCurrent(c); }; + strategyC.duringMeasure=[&] { worker.queueCurrent(latestC); worker.queueCurrent(d); }; + worker.queueCurrent(b); worker.threadLoop(); + PairCurrentResult resultB,resultC,resultD; + Check(worker.copyCurrent(1,1,resultB) && resultB.state==_T("Ready") && worker.pendingCurrentMask()==4, + "C queued during a real A/B calculation does not cancel A/B publication"); + worker.threadLoop(); + Check(!worker.copyCurrent(2,2,resultC) && worker.pendingCurrentMask()==12 && worker.copyCurrent(1,1,resultB), + "C superseded during its own calculation cannot publish and preserves unrelated B/D work"); + worker.threadLoop(); worker.threadLoop(); + Check(worker.copyCurrent(2,3,resultC) && resultC.state==_T("Ready") && worker.copyCurrent(3,4,resultD) && + resultD.state==_T("Ready") && worker.copyCurrent(1,1,resultB) && !worker.hasWork(), + "independent latest C and D requests both finish without dropping completed B"); + const auto measuredC=worker.currentMeasureCount(2); + for (unsigned long long id=5;id<=104;++id) worker.queueCurrent(TestCurrentRequest(doc,id,4)); + Check(worker.pendingCurrentMask()==4,"100 rapid C requests occupy exactly one replaceable pending pair slot"); + worker.threadLoop(); + Check(worker.copyCurrent(2,104,resultC) && !worker.copyCurrent(2,103,resultC) && + worker.currentMeasureCount(2)==measuredC+1 && worker.currentMeasureCount(1)==1 && worker.currentMeasureCount(3)==1, + "rapid C requests measure only the latest snapshot and never recompute B/D"); + worker.queueCurrent(TestCurrentRequest(doc,105,14)); worker.cancelCurrent(); + Check(!worker.pendingCurrentMask() && !worker.hasWork() && !worker.copyCurrent(1,1,resultB) && !worker.copyCurrent(1,0,resultB), + "cancelling clears all pending slots and rejects old or zero request IDs"); +} + +void CheckFooterAlignment(CComparatorDoc* doc) { + auto* frame=static_cast(AfxGetMainWnd()); + auto* info=doc->mFrmInfoView; + info->UpdateWindow(); + CRect area; info->GetClientRect(area); info->ClientToScreen(area); + LONG right=area.right; + if (info->GetScrollBarCtrl(SB_VERT)->IsWindowVisible()) + right-=q1view::WindowsUiPixels(17,q1view::WindowsUiDpi(info->m_hWnd)); + for (int slot=0;slotmNumOfViews;++slot) { + if (slot && !doc->mPane[slot].isAvail()) continue; + auto* view=doc->mPane[slot].pView; + CRect image; view->GetClientRect(image); view->ClientToScreen(image); + CWnd& footer=slot ? static_cast(info->mPairButtons[slot-1]) : static_cast(info->mReferenceLabel); + CRect result; footer.GetWindowRect(result); + Check(footer.IsWindowVisible() && result.left==max(image.left,area.left) && result.right==min(image.right,right), + "each result footer matches the actual horizontal bounds of its own image pane"); + } +} + +struct MenuUpdateProbe { + HWND window; + int paints = 0, positions = 0; + explicit MenuUpdateProbe(HWND value) : window(value) { + Check(SetWindowSubclass(window, Proc, 139, reinterpret_cast(this)) != FALSE, "zoom menu message probe installed"); + } + ~MenuUpdateProbe() { if (IsWindow(window)) RemoveWindowSubclass(window, Proc, 139); } + static LRESULT CALLBACK Proc(HWND window, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR data) { + auto* probe = reinterpret_cast(data); + if (message == WM_PAINT) ++probe->paints; + if (message == WM_WINDOWPOSCHANGED) ++probe->positions; + return DefSubclassProc(window, message, wp, lp); + } +}; } int RunComparerTypographyTests() { wchar_t output[MAX_PATH] = {}; GetEnvironmentVariableW(L"Q1VIEW_COMPARER_TEST_REPORT", output, _countof(output)); if (!output[0]) wcscpy_s(output, L"Tests\\bin\\x64\\Release\\comparer-typography-report.txt"); - if (_wfopen_s(&report, output, L"w") || !report) return 2; + report=_wfsopen(output,L"w",_SH_DENYNO); + if (!report) return 2; try { auto* frame = static_cast(AfxGetMainWnd()); Check(frame != nullptr, "real Comparer frame created"); auto* doc = static_cast(frame->GetActiveDocument()); Check(doc != nullptr, "real Comparer document created"); + doc->SelectMetric(METRIC_PSNR_IDX); // An isolated test profile may still restore a maximized startup state. // Normalize it before taking equal-size movement pixel baselines. frame->ShowWindow(SW_RESTORE); frame->MoveWindow(50, 50, 1100, 800); Pump(250); + { + ScopedWindowsUiPaintTestTheme restoreTheme; + for (auto theme : {q1view::WindowsUiAppearance::Dark,q1view::WindowsUiAppearance::Light}) { + q1view::WindowsUiAppearanceState().SetAppearance(theme); + Check(doc->mFrmState.IsEmpty(),"empty result area is tested without a reference source"); + CheckWindowsUiErase(doc->mFrmInfoView->m_hWnd,q1view::WindowsUiColor::Surface,true,Check); + CheckWindowsUiOwnerDraw(doc->mFrmsInfoView->GetDlgItem(0x7950)->m_hWnd,Check); + } + } auto* timeline = doc->mPosInfoView; Check(timeline && timeline->GetScrollBarCtrl(SB_VERT) && !timeline->GetScrollBarCtrl(SB_VERT)->IsWindowVisible() && timeline->GetScrollPosition().y==0, "empty Timeline has no scrollbar or phantom scroll offset"); @@ -102,13 +238,57 @@ int RunComparerTypographyTests() { GetFullPathNameW(L"docs\\images\\sample-colored-pencils-reference.png", MAX_PATH, first, nullptr); GetFullPathNameW(L"docs\\images\\sample-colored-pencils-changed-region.png", MAX_PATH, second, nullptr); std::vector files = {first, second}; + frame->ChangeNumOfViews(2); doc->OpenMultiFiles(files); Pump(500); // Opening a large source intentionally auto-maximizes the production // frame. Movement checks need a restored, fixed-size window instead. frame->ShowWindow(SW_RESTORE); frame->MoveWindow(50, 50, 1100, 800); Pump(250); Check(doc->mFrmCmpStrategy && doc->mPane[0].isAvail() && doc->mPane[1].isAvail(), "two real image sources and comparison strategy loaded"); + CheckFooterAlignment(doc); Check(!timeline->GetScrollBarCtrl(SB_VERT)->IsWindowVisible(), "still-image Timeline does not show an unnecessary scrollbar"); + if (frame->mUiFrame.Custom()) { + HWND help = GetDlgItem(frame->mUiFrame.MenuHost(), 7), zoom = GetDlgItem(frame->mUiFrame.MenuHost(), 8); + frame->UpdateMagnication(doc->mN); Pump(50); + MenuUpdateProbe helpProbe(help), zoomProbe(zoom); + CRect helpBefore, zoomBefore, imageBefore; + ::GetWindowRect(help, helpBefore); ::GetWindowRect(zoom, zoomBefore); + doc->mPane[0].pView->GetWindowRect(imageBefore); + const int menuHeight = frame->mUiFrame.Height(); + for (int repeat = 0; repeat < 4; ++repeat) for (float value : {.01f, .67f, 1.13f, 9.99f, 10.f, 99.99f, 128.f}) { + frame->UpdateMagnication(value); Pump(2); + CRect helpAfter, zoomAfter, imageAfter; + ::GetWindowRect(help, helpAfter); ::GetWindowRect(zoom, zoomAfter); + doc->mPane[0].pView->GetWindowRect(imageAfter); + Check(helpAfter == helpBefore && zoomAfter == zoomBefore && imageAfter == imageBefore && frame->mUiFrame.Height() == menuHeight, + "rapid magnification labels leave Help, zoom label, and source canvas geometry stable"); + CString label; label.Format(L"%.2fx", value); + CString accessible; ::GetWindowTextW(zoom, accessible.GetBuffer(80), 80); accessible.ReleaseBuffer(); + Check(accessible == label && frame->mUiMenus.Text(frame->GetMenu()->GetSafeHmenu(), ID_MAGNIFY) == static_cast(label), + "rapid zoom retains full accessible label and matching HMENU text"); + } + const float savedN = doc->mN, savedD = doc->mD, savedX = doc->mXOff, savedY = doc->mYOff; + auto* view = doc->mPane[0].pView; + CPoint center(view->mWCanvas / 2, view->mRcControls.bottom + view->mHCanvas / 2); view->ClientToScreen(¢er); + for (int repeat = 0; repeat < 4; ++repeat) for (short delta : {short(WHEEL_DELTA), short(-WHEEL_DELTA)}) { + const float previousN = doc->mN; + view->OnMouseWheel(0, delta, center); Pump(5); + Check(doc->mN != previousN, "real image-wheel handler still zooms in and out"); + CRect helpAfter; ::GetWindowRect(help, helpAfter); + Check(helpAfter == helpBefore, "real wheel handler does not move Help"); + } + doc->mN = savedN; doc->mD = savedD; doc->mXOff = savedX; doc->mYOff = savedY; doc->setDstSize(); + for (int slot = 0; slot < CComparatorDoc::IMG_VIEW_MAX; ++slot) + if (doc->mPane[slot].pView) doc->mPane[slot].pView->DeterminDestOriginCoord(doc); + frame->UpdateMagnication(savedN); doc->UpdateAllViews(nullptr); Pump(20); + fprintf(report, "ZOOM MENU PROBE: Help paints=%d positions=%d; zoom paints=%d positions=%d\n", + helpProbe.paints, helpProbe.positions, zoomProbe.paints, zoomProbe.positions); + Check(!helpProbe.paints && !helpProbe.positions && !zoomProbe.positions && zoomProbe.paints > 0, + "wheel and label updates repaint only magnification, not Help or menu geometry"); + const int zoomPaints = zoomProbe.paints; + frame->UpdateMagnication(savedN); Pump(20); + Check(zoomProbe.paints == zoomPaints, "unchanged rounded zoom label schedules no redundant paint"); + } doc->mHasSelection = true; doc->mSelStart = CPoint(2, 3); doc->mSelCur = CPoint(10, 12); doc->mN = 3; doc->mD = 1; doc->mXOff = 7; doc->mYOff = 9; int l, t, r, b; Check(doc->GetSelectionRect(l,t,r,b) && l==2 && t==3 && r==10 && b==12, "ROI uses committed source-pixel corners"); @@ -192,7 +372,15 @@ int RunComparerTypographyTests() { Check(moves>=1, "settled live native window move observed without forcing repaint"); } frame->MoveWindow(50, 50, 800, 500); Pump(250); + fprintf(report,"Narrow layout: DPI=%u client canvas=%dx%d controls=%ld frame chrome=%d required canvas=%d\n", + q1view::WindowsUiDpi(frame->m_hWnd),doc->mPane[0].pView->mWCanvas,doc->mPane[0].pView->mHCanvas, + doc->mPane[0].pView->mRcControls.Height(),frame->mUiFrame.Height(), + q1view::WindowsUiPixels(60,q1view::WindowsUiDpi(frame->m_hWnd))); Check(doc->mPane[0].pView->mHCanvas >= q1view::WindowsUiPixels(60, q1view::WindowsUiDpi(frame->m_hWnd)), "small window retains useful image canvas at current OS text size"); + Check(doc->OpenMultiFiles({first,second,first})!=FALSE,"three-source comparison opens an additional image and its footer"); + WaitForAnalysis(doc); + Check(frame->mNumOfViews==3,"three sources retain three matching source columns"); + CheckFooterAlignment(doc); // All four panes, including C/D, must repaint without changing their inputs. Check(doc->OpenMultiFiles({first,second,first,second})!=FALSE, "four same-folder still-image comparison sources open"); frame->ShowWindow(SW_RESTORE); frame->MoveWindow(50,50,1200,800); Pump(300); @@ -311,6 +499,240 @@ int RunComparerTypographyTests() { "all-fitting video Timeline has no phantom two-pixel scrollbar"); doc->OpenMultiFiles({first,second}); Pump(100); } + { + RawTimelineFixture a(90), bSource(100,10), c(20,20), dSource(120,30); + doc->mHasSelection=false; + Check(doc->OpenMultiFiles({a.path,bSource.path,c.path,dSource.path}),"four unequal-length RAW sources open"); + WaitForAnalysis(doc); + // The final result publication invalidates the view; flush that paint + // before inspecting native button and tooltip presentation. + doc->mFrmInfoView->UpdateWindow(); + Check(doc->ComparisonTargets()==std::vector({1,2,3}) && doc->TimelineSources()==std::vector({0,1,2,3}), + "A reference produces three stable pairs and four Timeline lanes"); + Check(doc->mMaxFrames==120 && doc->PairFrames(1)==90 && doc->PairFrames(2)==20 && doc->PairFrames(3)==90, + "Timeline uses max(all sources); each pair uses its own minimum frame count"); + auto* scanner=doc->mFileScanThread; + Check(scanner->decodeCount(0)==90 && scanner->decodeCount(1)==90 && scanner->decodeCount(2)==20 && scanner->decodeCount(3)==90, + "coordinated scan decodes A once per aligned frame and only eligible candidate frames"); + double values[3][QPLANES]; + for (int target=1;target<4;++target) { + Check(scanner->copyMetrics(0,METRIC_PSNR_IDX,values[target-1],target),"real PSNR cache available for every pair"); + double expected=qPSNR(doc->mPane[0].origBuf,doc->mPane[target].origBuf,16,16,16,1); + Check(fabs(values[target-1][0]-expected)<1e-10,"pair Y PSNR matches independent native-buffer calculation"); + Check(scanner->summary(target).measured[0]==doc->PairFrames(target),"progress and averages count only real eligible measurements"); + Check(doc->mFrmInfoView->mPairButtons[target-1].IsWindowVisible(),"one real accessible result button exists per candidate"); + auto& button=doc->mFrmInfoView->mPairButtons[target-1]; + CString body; button.GetWindowText(body); + Check(body.Find(_T("\nA:"))<0 && body.Find(a.path.Mid(a.path.ReverseFind(_T('\\'))+1))<0, + "compact result card does not repeat source filenames"); + const auto& result=doc->mPairResults[target-1]; + Check(!result.channels.IsEmpty() && body.Find(result.channels)<0, + "channel detail is hidden from the default current-result card"); + Check(button.mInfoText.Find(result.channels)>=0 && button.mInfoText.Find(_T("PSNR channels (dB)"))>=0, + "channel scores and metric units remain available in the info tooltip"); + Check(button.mInfoText.Find(doc->mPane[0].pathName)>=0 && button.mInfoText.Find(doc->mPane[target].pathName)>=0, + "tooltip retains both full source paths"); + CString nativeTip; button.mInfoTip.GetText(nativeTip,&button); + Check(nativeTip==button.mInfoText,"native tooltip contains the current channel and source details"); + Check(body.Find(_T("RGB aggregate"))<0,"comparison-basis annotation is omitted from default result text"); + } + CString reference; doc->mFrmInfoView->mReferenceLabel.GetWindowText(reference); + { + ScopedWindowsUiPaintTestTheme restoreTheme; + for (auto theme : {q1view::WindowsUiAppearance::Dark,q1view::WindowsUiAppearance::Light}) { + q1view::WindowsUiAppearanceState().SetAppearance(theme); + for (auto& button : doc->mFrmInfoView->mPairButtons) CheckWindowsUiOwnerDraw(button.m_hWnd,Check); + CheckWindowsUiOwnerDraw(doc->mFrmInfoView->mReferenceLabel.m_hWnd,Check); + } + } + Check(reference==_T("Reference \x00b7 #0"),"A footer is a non-interactive reference label with the actual frame number"); + CheckFooterAlignment(doc); + Check(values[0][0]>values[1][0] && values[1][0]>values[2][0],"three distinct candidates have three distinct numerical results"); + Check(!scanner->copyMetrics(20,0,values[0],2) && !scanner->copyMetrics(90,0,values[0],3),"pair domains do not pad missing frames with final-frame scores"); + frame->ShowWindow(SW_RESTORE); frame->MoveWindow(50,50,1500,900); Pump(100); + CheckFooterAlignment(doc); + int firstWidth,minimum; frame->mCompSplitter.GetColumnInfo(0,firstWidth,minimum); + frame->mCompSplitter.SetColumnInfo(0,firstWidth+30,minimum); frame->mCompSplitter.RecalcLayout(); Pump(50); + CheckFooterAlignment(doc); + auto* graphs=doc->mFrmsInfoView; + CRect plot=graphs->PlotBounds(2); Check(!plot.IsRectEmpty(),"second candidate has its own real plot"); + CPoint wheelPoint=plot.CenterPoint(); wheelPoint.y-=graphs->GetScrollPos(SB_VERT); graphs->ClientToScreen(&wheelPoint); + double span=graphs->VisibleFrameSpan(); long beforeFrame=doc->mPane[0].curFrameID; + graphs->SendMessage(WM_MOUSEWHEEL,MAKEWPARAM(0,short(WHEEL_DELTA)),MAKELPARAM(wheelPoint.x,wheelPoint.y)); Pump(50); + Check(graphs->VisibleFrameSpan()mPane[0].curFrameID==beforeFrame,"wheel zooms shared graph X viewport without seeking frames"); + graphs->SendMessage(WM_LBUTTONDBLCLK,0,MAKELPARAM(plot.CenterPoint().x,plot.CenterPoint().y)); Pump(50); + Check(graphs->VisibleFirstFrame()==0 && graphs->VisibleFrameSpan()==120,"double-click resets all graph viewports to the full domain"); + graphs->SendMessage(WM_COMMAND,MAKEWPARAM(0x7950,BN_CLICKED),0); Pump(50); + Check(graphs->mCollapsed && scanner->summary(2).measured[0]==20,"collapsing Sequence analysis retains pair caches"); + graphs->SendMessage(WM_COMMAND,MAKEWPARAM(0x7950,BN_CLICKED),0); Pump(50); + frame->MoveWindow(50,50,800,500); Pump(100); + CheckFooterAlignment(doc); + Check(graphs->GetScrollBarCtrl(SB_VERT)->IsWindowVisible(), + "narrow-window independent graphs retain themed overflow scrolling"); + Check(doc->mPane[0].pView->mHCanvas>=60,"stacked analysis does not consume the narrow-window image canvas"); + doc->SetScenes(10); WaitForAnalysis(doc); + const auto beforeB=doc->mPairResults[0],beforeD=doc->mPairResults[2]; + const auto countB=scanner->currentMeasureCount(1),countC=scanner->currentMeasureCount(2),countD=scanner->currentMeasureCount(3); + bool updated=false; doc->SetScene(12,doc->mPane+2,updated); doc->RefreshSelectionMetric(); + Check(doc->mPairResults[0].request==beforeB.request && doc->mPairResults[0].full==beforeB.full && + doc->mPairResults[0].state==_T("Ready") && doc->mPairResults[2].request==beforeD.request && + doc->mPairResults[2].full==beforeD.full && doc->mPairResults[2].state==_T("Ready"), + "C-only seek keeps completed A/B and A/D current results visible without resubmitting them"); + WaitForAnalysis(doc); + Check(SameCurrentResult(doc->mPairResults[0],beforeB) && SameCurrentResult(doc->mPairResults[2],beforeD) && + scanner->currentMeasureCount(1)==countB && scanner->currentMeasureCount(2)==countC+1 && scanner->currentMeasureCount(3)==countD, + "C-only seek performs one A/C current measurement and preserves all B/D score fields and request IDs"); + CString expected; doc->PairStrategy(2)->CalMetrics(doc->mPane,doc->mPane+2,0,expected); + expected.Replace(_T("inf"),_T("\x221e")); + Check(doc->mPane[0].curFrameID==10 && doc->mPane[2].curFrameID==12 && doc->mPairResults[1].full==expected, + "independent C seek measures displayed A10/C12, not aligned cached A10/C10"); + doc->mFrmInfoView->UpdateWindow(); + CString unaligned,aligned; doc->mFrmInfoView->mPairButtons[1].GetWindowText(unaligned); + doc->mFrmInfoView->mPairButtons[0].GetWindowText(aligned); + Check(unaligned.Find(_T("Unaligned"))>=0 && aligned.Find(_T("Unaligned"))<0, + "only a mismatched pair explicitly warns that the displayed frame indices are unaligned"); + doc->SetScene(10,doc->mPane+2,updated); doc->RefreshSelectionMetric(); WaitForAnalysis(doc); doc->mFrmInfoView->UpdateWindow(); + doc->mFrmInfoView->mPairButtons[1].GetWindowText(aligned); + Check(aligned.Find(_T("Unaligned"))<0 && SameCurrentResult(doc->mPairResults[0],beforeB) && SameCurrentResult(doc->mPairResults[2],beforeD), + "realigning C removes the warning without changing other current results"); + doc->SetScene(13,doc->mPane+2,updated); doc->RefreshSelectionMetric(); + doc->SetScene(16,doc->mPane+3,updated); doc->RefreshSelectionMetric(); WaitForAnalysis(doc); + Check(doc->mPairResults[1].state==_T("Ready") && doc->mPairResults[2].state==_T("Ready") && + SameCurrentResult(doc->mPairResults[0],beforeB) && scanner->currentMeasureCount(1)==countB, + "back-to-back independent C/D seeks finish both current results without recalculating B"); + CheckCurrentRequestIsolation(doc); + doc->RefreshSelectionMetric(); WaitForAnalysis(doc); + unsigned long long beforeReferenceCounts[3]; + for (int target=1;target<4;++target) beforeReferenceCounts[target-1]=scanner->currentMeasureCount(target); + doc->SetScene(11,doc->mPane,updated); doc->RefreshSelectionMetric(); WaitForAnalysis(doc); + for (int target=1;target<4;++target) Check(scanner->currentMeasureCount(target)==beforeReferenceCounts[target-1]+1, + "A-only seek refreshes every affected candidate current result"); + for (int target=1;target<4;++target) beforeReferenceCounts[target-1]=scanner->currentMeasureCount(target); + doc->mHasSelection=true; doc->mSelStart=CPoint(0,0); doc->mSelCur=CPoint(7,7); doc->RefreshSelectionMetric(); WaitForAnalysis(doc); + for (int target=1;target<4;++target) Check(scanner->currentMeasureCount(target)==beforeReferenceCounts[target-1]+1, + "global ROI change refreshes every pair rather than reusing an old current crop"); + doc->mFrmInfoView->UpdateWindow(); + Check(doc->mFrmInfoView->GetScrollBarCtrl(SB_VERT)->IsWindowVisible(),"narrow aligned footers scroll wrapped ROI details without moving to another image column"); + CheckFooterAlignment(doc); + for (int target=1;target<4;++target) Check(!doc->mPairResults[target-1].roi.IsEmpty(),"ROI result calculated independently for every pair"); + doc->SelectMetric(METRIC_SSIM_IDX); WaitForAnalysis(doc); + for (int target=1;target<4;++target) Check(doc->mPairResults[target-1].full.Find(_T("SSIM"))>=0 && + !doc->mPairResults[target-1].roi.IsEmpty(),"global SSIM selection updates all current and ROI results"); + const auto beforeSsimB=doc->mPairResults[0],beforeSsimD=doc->mPairResults[2]; + const auto ssimCountB=scanner->currentMeasureCount(1),ssimCountC=scanner->currentMeasureCount(2),ssimCountD=scanner->currentMeasureCount(3); + doc->SetScene(14,doc->mPane+2,updated); doc->RefreshSelectionMetric(); WaitForAnalysis(doc); + Check(SameCurrentResult(doc->mPairResults[0],beforeSsimB) && SameCurrentResult(doc->mPairResults[2],beforeSsimD) && + scanner->currentMeasureCount(1)==ssimCountB && scanner->currentMeasureCount(2)==ssimCountC+1 && scanner->currentMeasureCount(3)==ssimCountD, + "C-only SSIM seek retains unrelated current/ROI results and measures only A/C"); + CString crop; crop.Format(_T("crop 8x8 %s"),doc->PairStrategy(2)->CropScore(doc->mPane,doc->mPane+2,METRIC_SSIM_IDX,0,0,7,7).GetString()); + crop.Replace(_T("inf"),_T("\x221e")); + Check(doc->mPairResults[1].roi==crop,"C-only SSIM crop equals a direct measurement of actual A11/C14 pixels"); + unsigned genB=scanner->pairGeneration(1),genC=scanner->pairGeneration(2),genD=scanner->pairGeneration(3); + const auto beforeReloadB=doc->mPairResults[0],beforeReloadD=doc->mPairResults[2]; + const auto reloadCountB=scanner->currentMeasureCount(1),reloadCountC=scanner->currentMeasureCount(2),reloadCountD=scanner->currentMeasureCount(3); + doc->ReloadPane(2); + Check(SameCurrentResult(doc->mPairResults[0],beforeReloadB) && SameCurrentResult(doc->mPairResults[2],beforeReloadD), + "C reload leaves unrelated completed current/ROI results visible during analysis restart"); + WaitForAnalysis(doc); + Check(SameCurrentResult(doc->mPairResults[0],beforeReloadB) && SameCurrentResult(doc->mPairResults[2],beforeReloadD) && + scanner->currentMeasureCount(1)==reloadCountB && scanner->currentMeasureCount(2)==reloadCountC+1 && scanner->currentMeasureCount(3)==reloadCountD, + "C reload recomputes only A/C current/ROI results while retaining A/B and A/D request IDs"); + Check(scanner->pairGeneration(1)==genB && scanner->pairGeneration(2)!=genC && scanner->pairGeneration(3)==genD, + "replacing C invalidates A/C generation only"); + Check(!scanner->setLazyMetric(0,2,.5,genC,2),"stale asynchronous result cannot publish into replaced A/C generation"); + Check(scanner->decodeCount(0)==20 && scanner->decodeCount(2)==20 && !scanner->decodeCount(1) && !scanner->decodeCount(3), + "C reload reuses completed A/B and A/D caches without decoding their frames again"); + doc->SelectComparison(3); + Check(doc->GetOppositePane(doc->mPane)==doc->mPane+3 && doc->GetOppositePane(doc->mPane+2)==doc->mPane, + "A overlay inspects selected A/D; every candidate overlay references A"); + doc->SetScenes(110); WaitForAnalysis(doc); + Check(doc->mPane[0].curFrameID<90 && doc->mPane[1].curFrameID<100 && doc->mPane[2].curFrameID<20 && doc->mPane[3].curFrameID==110, + "synchronized seek does not clamp or fabricate unavailable source frames"); + const auto beforeCloseC=doc->mPairResults[1],beforeCloseD=doc->mPairResults[2]; + doc->CloseSource(1); WaitForAnalysis(doc); + Check(SameCurrentResult(doc->mPairResults[1],beforeCloseC) && SameCurrentResult(doc->mPairResults[2],beforeCloseD), + "closing B preserves unrelated completed A/C and A/D current/ROI results"); + doc->mFrmInfoView->UpdateWindow(); + Check(!doc->mFrmInfoView->mPairButtons[0].IsWindowVisible(),"closing B leaves its footer empty rather than moving C under B"); + CheckFooterAlignment(doc); + Check(doc->ComparisonTargets()==std::vector({2,3}) && doc->TimelineSources()==std::vector({0,2,3}), + "closing B retains A/C and A/D identities without renaming sources"); + doc->CloseSource(0); Pump(100); + CString noReference; doc->mFrmInfoView->mPairButtons[1].GetWindowText(noReference); + Check(noReference.Find(_T("Load reference A"))>=0 && !doc->mFrmInfoView->mPairButtons[1].IsWindowEnabled(), + "candidate footer explains a missing reference without promoting or selecting another source"); + CheckFooterAlignment(doc); + Check(doc->ComparisonTargets().empty() && doc->mFrmState.IsEmpty() && doc->mMaxFrames==120, + "closing reference A clears comparisons but retains other source lanes and frame domain"); + { + ScopedWindowsUiPaintTestTheme restoreTheme; + for (auto theme : {q1view::WindowsUiAppearance::Dark,q1view::WindowsUiAppearance::Light}) { + q1view::WindowsUiAppearanceState().SetAppearance(theme); + CheckWindowsUiErase(doc->mFrmInfoView->m_hWnd,q1view::WindowsUiColor::Surface,true,Check); + CheckWindowsUiOwnerDraw(doc->mFrmInfoView->mPairButtons[1].m_hWnd,Check); + } + } + doc->OpenMultiFiles({first,second}); doc->mHasSelection=false; WaitForAnalysis(doc); + } + { + RawTimelineFixture a(3), bSource(3,20), c(3,40,true), dSource(3,60); + doc->SelectMetric(METRIC_PSNR_IDX); + doc->mHasSelection=false; + Check(doc->OpenMultiFiles({a.path,bSource.path,c.path,dSource.path}),"mixed native YUV and RGB sources open with a shared reference"); WaitForAnalysis(doc); + doc->mFrmInfoView->UpdateWindow(); + CString rgbBody; doc->mFrmInfoView->mPairButtons[1].GetWindowText(rgbBody); + Check(rgbBody.Find(_T("RGB aggregate"))<0 && doc->mFrmInfoView->mPairButtons[1].mInfoText.Find(_T("Comparison basis: RGB aggregate"))>=0, + "RGB aggregate explanation is retained only in the corresponding tooltip"); + Check(doc->PairStrategy(1)==doc->mYuvCompare && doc->PairStrategy(2)==doc->mRgbCompare && doc->PairStrategy(3)==doc->mYuvCompare, + "each pair independently selects native YUV or RGB comparison semantics"); + for (int target=1;target<4;++target) { + CString direct; doc->PairStrategy(target)->CalMetrics(doc->mPane,doc->mPane+target,0,direct); direct.Replace(_T("inf"),_T("\x221e")); + Check(doc->mPairResults[target-1].full==direct,"mixed-mode background current result equals direct native strategy result"); + } + doc->OpenMultiFiles({first,second}); WaitForAnalysis(doc); + } + { + RawTimelineFixture a(2), bSource(2,20,false,32,16), c(2,40,false,16,32), dSource(2,60,false,8,8); + Check(doc->OpenMultiFiles({a.path,bSource.path,c.path,dSource.path}),"different-resolution sources use existing shared-canvas policy"); WaitForAnalysis(doc); + Check(doc->mDiffRes && doc->ComparisonTargets().size()==3,"different-resolution resizing retains all three reference comparisons"); + doc->OpenMultiFiles({first,second}); WaitForAnalysis(doc); + } + { + RawTimelineFixture a(2), bSource(2,20), c(2,40), dSource(2,60); + doc->mHasSelection=true; doc->mSelStart=CPoint(0,0); doc->mSelCur=CPoint(7,7); + Check(doc->OpenMultiFiles({a.path,bSource.path,c.path,dSource.path}),"small real RGB/RAW inputs prepared for both LPIPS backends"); + for (int metric : {2,3,0,2}) { + doc->SelectMetric(metric); WaitForAnalysis(doc,30000); + for (int target=1;target<4;++target) { + Check(!doc->mPairResults[target-1].full.IsEmpty() && !doc->mPairResults[target-1].roi.IsEmpty(), + "all three real current/ROI results survive metric/backend switches"); + double cached[QPLANES]; Check(doc->mFileScanThread->copyMetrics(0,metric,cached,target),"every pair has a completed backend-specific sequence score"); + if (qmetric_info_table[metric].lazy) { + double direct=LpipsEngine::getInstance().distance(doc->mPane[0].rgbBuf,doc->mPane[target].rgbBuf,16,16,ROUNDUP_DWORD(16)*3); + Check(std::isfinite(direct) && fabs(cached[0]-direct)<1e-6,"real ONNX sequence score matches direct inference for that pair/backend"); + } + } + if (qmetric_info_table[metric].lazy) { + const auto beforeB=doc->mPairResults[0],beforeD=doc->mPairResults[2]; + const auto countB=doc->mFileScanThread->currentMeasureCount(1),countC=doc->mFileScanThread->currentMeasureCount(2),countD=doc->mFileScanThread->currentMeasureCount(3); + bool updated=false; doc->SetScene(1,doc->mPane+2,updated); doc->RefreshSelectionMetric(); WaitForAnalysis(doc,30000); + Check(SameCurrentResult(doc->mPairResults[0],beforeB) && SameCurrentResult(doc->mPairResults[2],beforeD) && + doc->mFileScanThread->currentMeasureCount(1)==countB && doc->mFileScanThread->currentMeasureCount(2)==countC+1 && doc->mFileScanThread->currentMeasureCount(3)==countD, + "C-only seek preserves B/D current/ROI results and current-measurement counts for the real selected LPIPS backend"); + double direct=LpipsEngine::getInstance().distance(doc->mPane[0].rgbBuf,doc->mPane[2].rgbBuf,16,16,ROUNDUP_DWORD(16)*3); + CString expected; expected.Format(_T("%s %.4f"),CString(CA2W(qmetric_info_table[metric].name)).GetString(),direct); + Check(std::isfinite(direct) && doc->mPairResults[1].full==expected,"independent LPIPS seek measures actual A0/C1 instead of aligned sequence cache"); + double crop=LpipsEngine::getInstance().distance(doc->mPane[0].rgbBuf,doc->mPane[2].rgbBuf,8,8,ROUNDUP_DWORD(16)*3); + CString cropExpected; cropExpected.Format(_T("crop 8x8 %s(%.4f)"),CString(CA2W(qmetric_info_table[metric].name)).GetString(),crop); + Check(std::isfinite(crop) && doc->mPairResults[1].roi==cropExpected,"independent LPIPS ROI uses actual A0/C1 crop pixels for the selected backend"); + doc->SetScene(0,doc->mPane+2,updated); doc->RefreshSelectionMetric(); WaitForAnalysis(doc,30000); + } + } + doc->mFileScanThread->setPairError(2,_T("test model failure"),2); + doc->SelectMetric(2); WaitForAnalysis(doc); + Check(doc->mFileScanThread->summary(2).metricError[2].IsEmpty(),"reselecting a backend clears stale failure state while retaining valid cached scores"); + doc->SelectMetric(0); doc->mHasSelection=false; doc->OpenMultiFiles({first,second}); WaitForAnalysis(doc); + } fprintf(report, "Actual window DPI: %u; OS text factor: %.3f\n", q1view::WindowsUiDpi(frame->m_hWnd), q1view::WindowsUiSettings().Scale()); fprintf(report, "ALL COMPARER TYPOGRAPHY CHECKS PASSED\n"); fclose(report); return 0; } catch(const std::exception& error) { diff --git a/Tests/WindowsUiTypographyTests.cpp b/Tests/WindowsUiTypographyTests.cpp index afb4ac19..d7669ff3 100644 --- a/Tests/WindowsUiTypographyTests.cpp +++ b/Tests/WindowsUiTypographyTests.cpp @@ -5,6 +5,7 @@ #include "../QVisionCore/Q1UiFrameWin.h" #include "../Viewer/ViewerWindowGeometry.h" #include "../QVisionCore/Q1UiAppearanceMenuWin.h" +#include "../Comparator/AnalysisLayout.h" #include static void Require(bool value, const char* message) @@ -18,6 +19,29 @@ static double Luminance(COLORREF color) static double Contrast(COLORREF a, COLORREF b) { const double x = Luminance(a)+.05, y = Luminance(b)+.05; return (std::max)(x,y)/(std::min)(x,y); } +static void TestAnalysisLayout() +{ + const auto wide=ResolveAnalysisHeights(800,180,72,94,56,26,16); + Require(wide.graph==180 && wide.footer==72 && wide.source==532,"wide analysis layout retains preferred panel heights"); + for (UINT dpi:{96u,120u,144u,168u,192u}) for (double text:{1.,1.25,1.5,2.,2.25}) { + auto px=[&](int dip) { return q1view::WindowsUiPixels(dip,dpi,text); }; + const int source=q1view::WindowsUiPixels(60,dpi)+px(26)+8; + for (int content:{0,40,180,320,500,800}) { + const int preferred=(std::min)(px(180),(std::max)(0,content/3-8)); + const auto layout=ResolveAnalysisHeights(content,preferred,px(72),source,px(56),px(26),16); + const int available=(std::max)(0,content-16); + Require(layout.source>=0 && layout.graph>=0 && layout.footer>=0 && + layout.source+layout.graph+layout.footer==available,"analysis heights partition available space without negative sizes"); + Require(layout.source>=(std::min)(source,available),"small/high-DPI analysis reserves a useful source viewport when possible"); + if (available>=source+px(56)+px(26)) + Require(layout.graph>=(std::min)(preferred,px(56)) && layout.footer>=px(26),"fitting panel minima remain accessible above scrollable content"); + if (available>=source+preferred+px(72)) + Require(layout.graph==preferred && layout.footer==px(72),"roomy layouts do not shrink results or graphs unnecessarily"); + } + } + std::puts("Analysis layout tests passed (5 DPI inputs x 5 text scales x 6 content heights)."); +} + static void TestAppearance() { using namespace q1view; @@ -457,6 +481,7 @@ int wmain(int argc, wchar_t** argv) TestBufferedControls(); TestFrames(); TestViewerDefaultMenu(); + TestAnalysisLayout(); HDC dc = CreateCompatibleDC(nullptr); Require(dc != nullptr, "GDI test DC"); const std::vector rows = { diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md index ae890072..cbac03b5 100644 --- a/docs/STORE_LISTING.md +++ b/docs/STORE_LISTING.md @@ -53,7 +53,7 @@ Choose Auto, Smooth (Bilinear), or Pixel Exact (Nearest) image scaling from the COMPARATOR -Open two to four sources side by side. Comparator aligns them in a synchronized view and calculates objective quality metrics frame by frame, including PSNR, SSIM, and LPIPS. A bottom graph shows metric values across frames, the timeline helps locate frames that contain differences, and synchronized region selection can show crop scores next to whole-image scores. +Open two to four sources side by side. Comparator uses A as the fixed reference and calculates independent PSNR, SSIM, and LPIPS scores against B, C, and D. Compact results sit directly under each candidate without repeating filenames; separate sequence graphs keep each comparison clear, while Timeline shows up to four source lanes. Synchronized region selection adds crop scores for every comparison; missing frames are not padded with repeated measurements. Use Comparator to compare a reference image or video frame against processed outputs, encoded results, or alternative pipelines. Zoom in to inspect the exact pixels that changed, use the visual difference overlay to find small mismatches, and jump through frame sequences or video frames with synchronized controls. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index ad05f8cb..5b615821 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -119,7 +119,7 @@ and options. ### Typical Workflow 1. Open multiple files together or drop a source into each pane. -2. Select **PSNR** or **SSIM** from the metric menu. +2. Treat pane **A** as the fixed reference; B, C, and D are separate candidates. Select **PSNR**, **SSIM**, or either **LPIPS** backend from the metric menu. 3. Zoom and navigate the sources in a common view. 4. Use the bottom graph and channel values to find meaningful differences. 5. Enable **Allow Different Resolution** from **OPTIONS** when required. @@ -132,7 +132,7 @@ one sequence. ### Pixel-Level Diff Overlay -When two sources are loaded, Comparator highlights every region where the pixels +When sources are loaded, Comparator highlights every region where the pixels differ from the reference pane. The image area is divided into a fixed-size grid in display pixels; each cell that contains any differing pixel gets a translucent pink rectangle outline plus a center dot. Because the cell size is @@ -141,28 +141,20 @@ covers — at maximum zoom each dot resolves to a single differing source pixel. The overlay is hidden automatically when zoom is high enough to show per-pixel values, since the pixel labels already convey the diff. +Each candidate's overlay compares against A. A's overlay shows the active comparison, initially A/B; click a result card or its graph to inspect A/C or A/D instead. This changes the inspected pair, not the reference or which pairs are calculated. + ### Pane Layout Three secondary panes sit around the image canvases and surface per-frame and across-frame comparison information. -- **Position timeline (left column).** A vertical strip that lists every frame - in the active pair, top to bottom. Frames where the two panes differ are - marked with a red line; identical frames are blank. Click a row to seek both - panes to that frame. The currently shown frame is highlighted so it is easy - to find your place after scrolling. Space, Left, and Right are also routed - here, so the timeline owns playback control once it has been clicked. -- **Per-frame metric text (between the timeline and the graph).** Shows the - metric value (PSNR or SSIM, whichever is selected in the header) for the - currently displayed frame pair, along with auxiliary numbers such as channel - breakdowns where the metric provides them. The label updates whenever either - pane changes frames or the metric selection changes. -- **Metric-over-time graph (bottom strip).** Plots the same metric value across - every frame in the comparison, with the running average alongside. The - current frame is marked. Click a point on the graph to jump both panes to - that frame, mouse-wheel to zoom the horizontal axis, and double-click to - reset the zoom. Playback shortcuts work here as well once the graph has - focus. +- **Timeline (left column).** One lane per loaded source, with a shared frame-number gutter and sticky A/B/C/D headers. Click a lane to seek that source independently; click the gutter to seek all sources that have the requested frame. Missing lane frames are marked with a dash and cannot be selected. Difference lines in A describe the active pair; each candidate's lane describes its comparison against A. Space/Left/Right retain playback controls, and the themed scrollbar is shown only for real overflow. + +- **Current comparison footers.** A's image has a non-interactive **Reference** label and its actual frame number underneath. B, C, and D each show their own A/B, A/C, or A/D comparison directly below the corresponding image, even after resizing columns or closing another source. Footers identify actual displayed frame numbers, metric units, ROI scores, and necessary warnings without repeating source filenames or showing the calculation-basis annotation. RGB's existing aggregate score remains the default result; hover a result for its comparison basis, channel scores, full source paths, and resolutions. Channel curves and averages remain in the separate sequence graphs. Independently seeking C to frame 12 while A shows frame 10 measures A10/C12, not the cached aligned frame 10, and displays **Unaligned: frame indices differ**. Seeking or reloading only C refreshes A/C while keeping completed A/B and A/D current/ROI results visible and unchanged. Moving A, changing the metric, or changing the shared ROI refreshes all affected pairs. In narrow windows, results stay in their image columns and use wrapped text and vertical scrolling; Tab moves between available results and Enter selects the inspected pair. If A is closed, remaining results ask for reference A instead of promoting another source. + +- **Sequence analysis (bottom strip).** Each pair has its own graph, computed count, average and status. All graphs share the frame-index viewport and selected metric's finite Y scale. Mouse-wheel over a plot zooms without seeking; double-click resets the shared range. Clicking a plot seeks all sources that actually have that frame. A graph ends at the shorter of its two sources, even if another source extends Timeline farther. Infinity has an explicit marker; uncomputed and missing samples are gaps, not zeros. Graphs stack in narrow windows, with a themed scrollbar, and can be collapsed without clearing caches. Stills show single-frame results rather than fabricated temporal curves. + +Comparisons are frame-index based, not timestamp aligned. Different FPS is called out on the cards; Comparator does not resample sources. Closing B or C does not rename the remaining slots. Closing A clears numerical comparisons and asks for a reference instead of silently promoting another source. ### Controls @@ -179,7 +171,9 @@ Comparator also includes a built-in shortcut panel, opened with `?`. | Toggle pixel interpolation | `I` | | Toggle pink diff overlay (grid + dots) | `D` | | Toggle cursor pixel coordinates | `C` | -| Seek to a frame in the timeline (video) | Click left or right side | +| Seek one source to a Timeline frame | Click its A/B/C/D lane | +| Seek available sources to a Timeline frame | Click the shared frame-number gutter or a graph | +| Select the inspected reference pair | Click its result card; Tab / Enter also work | ## Input Notes diff --git a/docs/verification/issue-139-reference-comparisons.md b/docs/verification/issue-139-reference-comparisons.md new file mode 100644 index 00000000..d1536864 --- /dev/null +++ b/docs/verification/issue-139-reference-comparisons.md @@ -0,0 +1,88 @@ +# Issue #139: reference comparisons + +Date: 2026-10-12. Branch: `codex/issue-139-reference-comparisons`. The Windows MFC Comparator changes build on the unmerged #118 appearance branch (`71b5b19`), whose base is master `cf0c29e`. The reference-comparison feature leaves Viewer and Qt behavior unchanged; the Windows painting follow-ups below also harden Viewer backgrounds. No version increase, merge, release tag, Store submission or issue closure is part of this implementation. + +## Implemented behavior + +- Fixed reference A, stable candidate slots B/C/D, and one independent current-result footer and sequence graph per loaded candidate. A has a non-interactive Reference/frame label; B/C/D results follow the actual bounds of their own image pane, including unequal column widths. Closing B leaves its footer empty while A/C and A/D remain under C/D; closing A clears comparisons without promoting another source and shows a missing-reference instruction in the candidate footers. Source headers identify filenames; compact result names identify pairs and actual frames without repeating filenames. Full-path tooltips retain source resolutions, comparison basis, and channel-level results. RGB aggregate is a tooltip explanation, not a default result annotation. Clicking a result or its graph changes the inspected pair for A's overlay and Timeline detail, not which pairs are calculated. +- Existing native YUV scores and packed-RGB aggregate semantics are retained. RGB aggregate and R/G/B channel values are labelled separately. Both existing LPIPS backends and the existing ROI policy apply independently to every pair. Current results use actual displayed frame indices, including independent seeks, and are computed on owned snapshots in the background. Only each pair's latest request can publish; numerical inference errors are not reported as zero-distance successes. Mismatched displayed indices explicitly show `Unaligned: frame indices differ` without hiding the actual-frame score. C-only seeking/reloading preserves completed A/B and A/D current scores, channel details, ROI results, and request IDs as well as their sequence caches. +- Timeline spans the longest loaded source, with a shared index gutter and up to four sticky source headers. Pair domains are independently `min(A, candidate)`. Missing frames cannot be selected or synthesized by clamping. A global seek updates only sources that have that frame; cards identify actual displayed frames and flag an unavailable aligned frame. Different FPS is disclosed rather than resampled. +- Sequence graphs have separate computed counts, averages and states, a shared frame-index viewport and shared finite Y range. Uncomputed/out-of-domain samples are gaps; infinity is labelled explicitly. Channel line styles and legends distinguish channels without relying solely on color. Plot wheel zoom does not seek; double-click resets the range. A replacement resets the viewport; C-only replacement preserves it. Collapse retains all numerical caches. +- A single coordinated eager scanner decodes/converts A once per aligned frame and scans only eligible candidate frames. Unchanged pair caches and source readers survive candidate replacement. LPIPS uses one serial model session and one worker for the selected backend, not one model/worker per candidate. Current measurements use at most one active pair and three independently replaceable pending pair slots; affected pairs share their batch's owned source snapshots, with A copied once per batch. Round-robin dispatch prevents repeated C requests from starving B/D and supersession rejects only that pair's obsolete result. There is no unbounded work queue. Sequence caches still grow with frame count and RLC complexity, as before, now for up to three pairs; this is not a claim of constant memory usage or universal throughput improvement. +- Current results stay aligned with their source columns in narrow windows; measured footer heights retain wrapped ROI/warning text using the same themed native vertical scrollbar as #118 instead of shrinking fonts or moving a result to another source column. Separate sequence graphs still stack when narrow. The default current-result strip is 72 DIP, reduced from 120 DIP after moving repeated filenames and channel details into tooltips. Tab/Enter select available result footers, and focus can leave the result strip. The graph panel collapses independently of its caches. Image canvas height remains usable in the tested 800×500 window. Stills show compact single-frame messages, not artificial time curves. + +This supersedes the earlier A/B-only / C-and-D-view-only assumption in the #129–#131 design tasks. A selectable arbitrary reference, pairwise combinations, timestamp resampling, the #116 2×2 source layout, and a redesign of native menus are not included. No user-owned files in the untracked `docs/design/desktop-redesign-2026-10-03/` directory were changed. + +## Verification + +Visual Studio 2022 Community MSBuild, Release/x64/v143. The local development version remains `0.0.0-dev`. CoreRegressionTests and WindowsUiTypographyTests passed. Ordinary Comparator builds passed. The instrumented real MFC test build runs under an isolated profile, with repository samples and temporary RAW fixtures; `Tests/bin/x64/Release/comparer-issue139-verification-report.txt` contains 477 PASS checks, no FAIL/ERROR, and the final all-checks-passed marker at 96 DPI and 100% OS text size. The shared font/menu/theme unit suite covers four DPI values and five text scales, but is not a substitute for native OS-scale testing of these new views. OS DPI, text-size and high-contrast settings were not changed. + +The MFC checks cover two/four sources, real distinct numerical scores, native RGB and YUV pairs together, different resolutions using the existing canvas policy, real PSNR/SSIM ROI results, both real ONNX LPIPS backends and ROI after backend switching, clearing stale model errors on retry without discarding valid scores, independent A10/C12 seeking, 90/100/20/120-frame domains, measured decode counts of 90/90/20/90, C-only invalidation and A/B + A/D cache reuse, stale-generation rejection, closing B/A, shared graph zoom/reset without seeking, collapse without cache loss, two/three/four-source footer alignment, unequal splitter widths, stable footer columns after closing B, missing-reference instructions after closing A, tooltip-only RGB comparison basis, narrow-window aligned footers, graph stacking, wrapped ROI overflow and canvas retention, native Timeline keyboard/scrollbar behavior, and existing cross-process theme/pixel preservation tests. + +During verification, repeated scroll-extent updates inside painting caused a repaint loop; extent updates now run only on geometry/content changes. A prior one-frame graph range was also incorrectly retained across new reference files; A replacement now resets it. Both regressions have executable tests. Native visual/accessibility inspection confirmed three named result buttons with independent values and explicit infinity, source roles, click selection, Tab moving from A/C to A/D, and Enter selecting A/D without recalculating unrelated metrics. The compact-card follow-up adds 15 checks for omitted duplicate filenames/channel lines and retained native tooltip contents; presentation assertions flush the queued view repaint after asynchronous result publication. The aligned-footer ordinary build (`Comparator/x64/Issue139ReviewAligned/Comparator.exe`) was launched with four 640×360, 30 fps sample video variants (150/150/90/180 frames) and visually checked at frame 30: A's Reference/frame label stays below A and the independent A/B, A/C and A/D results are directly below B, C and D. Default results omit RGB aggregate, while tooltips retain the calculation basis and channels. Source filenames and channel curves remain in the headers and separate graphs. That video preview remains open for user review. + +```powershell +$msbuild = 'C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe' +& $msbuild Tests\CoreRegressionTests.vcxproj /m /p:Configuration=Release /p:Platform=x64 /p:PlatformToolset=v143 +.\Tests\bin\x64\Release\CoreRegressionTests.exe +& $msbuild Tests\WindowsUiTypographyTests.vcxproj /m /p:Configuration=Release /p:Platform=x64 /p:PlatformToolset=v143 +.\Tests\bin\x64\Release\WindowsUiTypographyTests.exe +& $msbuild Comparator\Comparator.sln /m /restore /p:Configuration=Release /p:Platform=x64 /p:PlatformToolset=v143 /p:IntDir=x64\Issue139Review\ /p:OutDir=x64\Issue139Review\ +& $msbuild Comparator\Comparator.sln /m /restore /p:Configuration=Release /p:Platform=x64 /p:PlatformToolset=v143 /p:Q1ViewComparerTests=true /p:IntDir=x64\Issue139Tests\ /p:OutDir=x64\Issue139Tests\ +$env:Q1VIEW_COMPARER_TEST_REPORT = "$PWD\Tests\bin\x64\Release\comparer-issue139-verification-report.txt" +& .\Comparator\x64\Issue139Tests\Comparator.exe +``` + +LPIPS tests currently exercise production provisioning and real bundled ONNX Runtime. A machine without cached models may display the normal download question; do not silently treat such a blocked/cancelled run as an inference pass. On this machine AlexNet was already cached; the normal application prompt was used to download and validate the 29.6 MB VGG model before exercising both backends. + +## Wheel-zoom menu flicker follow-up (2026-10-13) + +Native before/after captures confirmed that the trailing Help/zoom group moved as magnification text changed, but did not catch the brief bright intermediate frame. A deterministic regression probe then exercised the actual enabled Help and disabled zoom BUTTON erase/background handlers: before the fix both used Windows COLOR_BTNFACE (#F0F0F0), even when the Dark menu background was #181818. The added test failed on the original implementation and passes after the fix. This distinguishes measured background-path evidence from an uncaptured desktop animation frame. + +The shared Windows frame now suppresses the owner-drawn button's default erase, supplies a themed WM_CTLCOLORBTN brush, and paints each complete button through a temporary bitmap before presenting it. Unchanged button bounds are not moved again. Comparator reserves measured room for its magnification status using the active font/DPI/text scale; zoom text is right-aligned within that reservation, updates its real accessible name and HMENU metadata, and repaints only its own button. Identical rounded labels do nothing. Unusually long text still triggers responsive relayout when required, and native high-contrast/composition-disabled menu handling remains intact. Viewer shares the painting fix but retains its existing zoom-label content and update workflow. + +The extended real MFC regression run has 553 PASS checks, exit code 0, and no FAIL/ERROR in `Tests/bin/x64/Release/comparer-issue139-zoom-verification-report.txt`. Its message probe records Help paints=0, positions=0; zoom paints=37, positions=0 across repeated labels and actual image-wheel handler calls. Geometry, actual accessible text, zoom functionality, and redundant-paint avoidance are checked alongside all previous comparison/ROI/backend tests. WindowsUiTypographyTests also checks enabled/disabled button background paths in Light/Dark, right-aligned status pixels, bounded GDI resources over repeated buffered paints, and stable wrapped menu bounds at 200/320/640/1920-pixel window widths. CoreRegressionTests and ordinary Release/x64 Comparator and Viewer builds pass; OS display, text-size, and high-contrast settings are unchanged. + +After the final rebuild, native captures of the four demo videos at frame 30 through 0.67x -> 1.00x -> 2.00x -> 1.00x -> 0.67x showed unchanged Help placement and the themed menu background. A/B, A/C, and A/D current PSNR values stayed 33.9524, 37.4304, and 29.3136 dB. These point-in-time captures complement the deterministic erase-path and message-count tests; they are not a high-frame-rate recording of every intermediate presentation. + +The ordinary follow-up preview is `Comparator/x64/Issue139ZoomReview/Comparator.exe`, left open with the four videos at frame 30 and 0.67x. No release tag, merge, remote write, or version change is part of this follow-up. + +## Windows custom-view background audit follow-up (2026-10-13) + +The follow-up audit identified six additional default-background targets: the three B/C/D current-result buttons, the sequence-analysis collapse/expand button, the empty/missing-reference current-result view, and the empty/loading Viewer canvas. Actual HWND erase/parent-brush probes found COLOR_BTNFACE (#F0F0F0) for the four buttons and COLOR_WINDOW (#FFFFFF) for the two empty views in Dark mode. The reference A owner-drawn static label already retained pixels during erase, but its parent still supplied a default-color brush; it receives the same preventive hardening without being counted as another confirmed erase defect. Native file dialogs and tooltips remain system-owned UI. + +Owner-drawn analysis buttons and the reference label now suppress only their default erase, receive the appropriate SurfaceAlt parent brush, and compose their entire background, border, text, and focus indicator off-screen. A shared RAII `WindowsUiBufferedPaint` helper also replaces the menu buttons' duplicated buffer code. It restores DC state, releases temporary GDI objects, handles nonzero drawing origins, and falls back to direct themed painting if allocation fails. Native BUTTON/STATIC classes, accessible names, focus, keyboard activation, and tooltip behavior are retained. The collapse button is repositioned only when its actual bounds change. + +The empty Comparator result view explicitly clears with Surface; Viewer explicitly clears its empty/loading state with Canvas. Loaded Viewer images and nonempty Comparator results still retain existing pixels until their complete paint path presents the next frame. No image decoding, metric calculation, zoom/pan policy, GPU presentation, or Qt code is changed by this background fix. Brushes continue to use the shared theme/high-contrast role resolver rather than hardcoded dark colors. + +Red/green verification: both newly extended real-MFC suites failed on the original empty-view erase path (exit 1), then passed after the fixes. `Tests/bin/x64/Release/comparer-paint-regression-after.txt` has 659 PASS checks, exit 0, no FAIL/ERROR, including all earlier comparison/ROI/real ONNX LPIPS checks plus Light/Dark real-HWND erase, parent-brush, and complete owner-draw checks. Normal/focused/pressed/disabled drawing and repeated-control GDI resource counts are covered; the existing zoom probe still records Help paints=0/positions=0 and zoom paints=37/positions=0. The focused Viewer paint run has 19 PASS checks and the separate startup run has 39 PASS checks in `viewer-paint-regression-after.txt` and `viewer-paint-startup-after.txt`, both exit 0. These Viewer modes do not run the full gallery/GPU/playback/recycle suite. + +CoreRegressionTests and WindowsUiTypographyTests pass. The shared buffer tests check delayed presentation, drawing origins/bounds, font/background-mode restoration, zero-sized rectangles, and stable GDI counts over 1,000 Light/Dark buffer lifetimes. Ordinary Release/x64/v143 Comparator and Viewer builds pass; final Viewer intermediates are isolated from Comparator's shared-library intermediates to avoid parallel build-output collisions. + +Native inspection on this PC confirmed four demo videos at frame 30, independent unchanged PSNR values (33.9524/37.4304/29.3136 dB), mouse-wheel zoom, candidate selection, Tab/Space navigation, and sequence collapse/expand without losing scores. Viewer empty-canvas and Comparator result/graph presentations were inspected in Light and Dark. For cross-process theme verification both ordinary applications were launched through the same normal shell path: the shared production Theme value changed to 1 for Light and back to its original 2 for Dark, and both applications followed it. An earlier Viewer launch through the automation launcher did not change the production key observed by the shell, so that launch was not counted as shared-theme verification. No theme-persistence implementation change was required. + +The final ordinary previews are `Comparator/x64/Issue139PaintReview/Comparator.exe` (four videos at frame 30, Dark, 0.49x, maximized) and `Viewer/x64/Issue139ViewerPaintReview/Viewer.exe` (empty Dark canvas). Native observations are point-in-time captures, not a high-frame-rate recording of every transition. Actual UI tests used this PC at 96 DPI and 100% OS text size; no OS DPI/text/high-contrast settings were changed and no claim is made of native testing on every Windows version. No merge, release, remote write, or version bump was performed. + +## Pair-specific current refresh and unaligned-frame follow-up (2026-10-13) + +Re-review of #139 found that current results still used a global frame/signature/request ID, although sequence caches already had pair-specific generations. The new regression first failed on the original behavior: seeking only C reset completed A/B and A/D results to Computing (`Tests/bin/x64/Release/comparer-pair-refresh-before.txt`). Independent displayed indices also lacked the required explicit Unaligned notice. + +Current-result keys now include only the pair's source generation and actual A/candidate frames plus shared canvas dimensions, selected metric/model availability, and committed ROI. Only changed pairs copy source snapshots and receive a new request ID; completed unchanged pairs retain all score/channel/ROI fields through candidate reload and closing another candidate. A, metric, or shared ROI changes still refresh every affected pair. Interrupted/error requests are resubmitted when analysis restarts rather than leaving cancelled work stuck in Computing. + +The coordinator now has three replaceable pending pair slots and processes one current pair at a time, round-robin, interleaved with the eager sequence scan. Each pair independently checks its latest request before measuring and before publication. A newer C request cannot cancel an in-flight B result; a superseded C result cannot overwrite its replacement. A single request for several changed pairs shares its owned snapshots, copying A once. The worker exposes locked current-measurement counts and pending masks for diagnostic/regression checks; those counts are current jobs, not a direct LPIPS inference-call counter. + +The final real-MFC report, `Tests/bin/x64/Release/comparer-pair-refresh-after.txt`, has 690 PASS checks, exit 0, no FAIL/ERROR. The 31 added checks cover PSNR/SSIM C-only seek, preserved B/D result fields/request IDs/measurement counts, C-only reload and unchanged sequence decodes, closing B, explicit warning appearance/removal, back-to-back C/D seeks, global A/ROI invalidation, and real AlexNet/VGG LPIPS current/ROI results against direct inference on the actual mismatched frames. Deterministic callbacks queue replacements during real metric calculations to exercise both cross-pair and same-pair publication races without timing-dependent sleeps or production pause hooks. A burst of 100 C requests occupies one pending slot and measures only the latest snapshot. Cancellation rejects old/zero request IDs. + +CoreRegressionTests, WindowsUiTypographyTests, and the ordinary Release/x64/v143 Comparator build pass. The ordinary preview is `Comparator/x64/Issue139PairReview/Comparator.exe`. Native four-video inspection at A/B/C/D frame 30 produced 33.9524/37.4304/29.3136 dB. Seeking only C to 32 changed A/C to 29.7008 dB and visibly added `Unaligned: frame indices differ`; B/D stayed at 33.9524/29.3136 dB, while all three sequence graphs retained their cached data and eligible counts (150/90/150). The warning remains in the native result button's accessible name and wraps/scrolls with existing footer layout. This native check uses 96 DPI and 100% text size, not a newly completed high-DPI acceptance test. + +These code gaps are fixed locally, but this is not a declaration that #118/#139 are closed. Actual Windows System-theme/high-contrast transitions with loaded files and native high-DPI acceptance for the new analysis layout remain unverified. The approved compact-under-source presentation also supersedes the original #139 card/channel wording and the old #129/#130 A/B-only scope; remote issue/PR reconciliation remains pending. No OS settings, user-owned design files, remote issues, branches, tags, or versions were changed by this follow-up. + +## Shared Viewer regression continuation (2026-10-13) + +After the pair-specific follow-up, the full existing Viewer gallery/media/GPU/recycle integration was rebuilt and rerun against the latest shared painting changes: 830 PASS checks, process exit 0, no failed assertions (`Tests/bin/x64/Release/issue-118-final-full-gallery-report.txt`). The ordinary Viewer build also passes and is open with a repository sample. This supplements the earlier 19-check focused paint and 39-check startup runs; details are in `issue-118-appearance.md`. It does not remove the real Windows System/high-contrast/high-DPI acceptance limits above. Temporary global setting changes were requested explicitly and remain pending approval; no Windows settings or remote repository state was changed. + +## Real Windows System/contrast/DPI continuation (2026-10-14) + +Permission was granted and [actual OS acceptance evidence](2026-10-14-native-appearance.md) now supersedes the prior pending current-PC System/high-contrast/125-150% items. Loaded media/current/ROI results survived actual app Light -> Dark -> Light and Night sky contrast on/off. Full Comparator runs pass 690 checks at actual DPI 96/120/144/168; the 175% run first exposed and then verified the fix for a useful-canvas-height failure in an 800x500 window. Source-control height is measured at current DPI; graphs/results shrink and scroll before consuming the image viewport. The normal large layout remains unchanged. Viewer full/startup suites pass at available 125/150/175% scales as recorded separately. + +CoreRegressionTests, WindowsUiTypographyTests (including 5-DPI/5-text-factor/6-height analysis-allocation cases) and ordinary Comparator Release/x64/v143 build pass. Updated ordinary four-video preview: `Comparator/x64/Issue139NativeReview/Comparator.exe`, graph-seek frame 32, scores 33.7282/37.5699/29.1343 dB. This machine's regular scale selector stops at 175%; 192-DPI/font/layout inputs are tested, but native 200% and other OS text-size combinations are not falsely marked as completed. No custom scaling, sign-out, remote issue/PR write, merge, release or version change is included. From 58e5ef510f1470c126d17c2db81885bc853eca91 Mon Sep 17 00:00:00 2001 From: Kyuwon Kim Date: Wed, 14 Oct 2026 06:40:47 +0900 Subject: [PATCH 2/2] Record separate theme and comparison PR delivery history --- docs/verification/issue-139-reference-comparisons.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/verification/issue-139-reference-comparisons.md b/docs/verification/issue-139-reference-comparisons.md index d1536864..5f6a7403 100644 --- a/docs/verification/issue-139-reference-comparisons.md +++ b/docs/verification/issue-139-reference-comparisons.md @@ -1,6 +1,6 @@ # Issue #139: reference comparisons -Date: 2026-10-12. Branch: `codex/issue-139-reference-comparisons`. The Windows MFC Comparator changes build on the unmerged #118 appearance branch (`71b5b19`), whose base is master `cf0c29e`. The reference-comparison feature leaves Viewer and Qt behavior unchanged; the Windows painting follow-ups below also harden Viewer backgrounds. No version increase, merge, release tag, Store submission or issue closure is part of this implementation. +Initial implementation date: 2026-10-12. Branch: `codex/issue-139-reference-comparisons`. The Windows MFC Comparator changes originally built on the unmerged #118 appearance branch (`71b5b19`), whose base was master `cf0c29e`. On 2026-10-14, after the user's explicit delivery approval, #118 was merged separately through PR #138 (`c7a94fa`), and the feature branch incorporated that updated master without changing the verified feature tree. The reference-comparison feature leaves Viewer and Qt behavior unchanged; the shared painting follow-ups also harden Viewer backgrounds. PR #140 separately delivers #139 and the dated 2.8.0 release notes. Tagged Actions, not local checks, supplies the release version and final packaging/signing/Release/Store outcome. ## Implemented behavior