From e980fe992bc35fc422bdc8b029f214bf69b8ceed Mon Sep 17 00:00:00 2001 From: Abhinav Date: Fri, 9 Oct 2026 23:02:38 +0530 Subject: [PATCH] fix(inspectors): luma-based maps, edge blocks, true error units - Edge Distortion and Blocking maps read channel 0 of a BGR image, i.e. blue only; a red/green-only distortion barely registered. They now use BT.601 luma (or the single channel of 1-channel input). - Block inspector: partial right/bottom edge blocks are reachable (ceil instead of floor for the last block index) and padded by edge replication in both inspectBlock and the RGB block reader, matching how the codec now codes them. Previously they were unreachable and inspectBlock zero-filled them. - Artifact tooltip shows the real value in pixel levels (map value divided by its gain) instead of the gained 0-255 map value, flags saturation, and reads the largest channel of the colour error map rather than red only (the heatmap now uses the same intensity). The WASM gain is re-synced on mount: it outlived the component while the slider reset to 5x, so map and slider could disagree. - inspection.ts read the reconstruction from a #processedCanvas that no longer exists (renamed, and unmounted while inspecting). It now copies the RGB view from WASM via getProcessedRgba(). This path is only hit in RGB mode, which the inspector currently normalises away, so it had no visible symptom. suggested-blocks used the same canvas for an error score no category consumed; that dead path is removed. Co-Authored-By: Claude Opus 5.5 --- core/src/CodecAnalysis.cpp | 20 +++++--- core/src/ImageCodec.cpp | 26 ++++------ core/tests/test_codecanalysis.cpp | 38 +++++++++++++++ core/tests/test_imagecodec.cpp | 18 +++++++ web/public/codec.wasm | Bin 81325 -> 83560 bytes web/src/App.svelte | 4 +- web/src/ArtifactInspectorMode.svelte | 48 +++++++++++++++---- web/src/InspectorMode.svelte | 8 ++-- web/src/lib/inspection.ts | 32 +++++-------- web/src/lib/suggested-blocks.ts | 40 +++------------- web/src/lib/wasm-bridge.ts | 15 +++++- web/tests/browser/codec.browser.test.js | 20 +++++++- web/tests/browser/inspection.browser.test.js | 4 +- web/tests/unit/inspection.test.js | 46 +++++++++++------- web/tests/unit/suggested-blocks.test.js | 39 +-------------- 15 files changed, 212 insertions(+), 146 deletions(-) diff --git a/core/src/CodecAnalysis.cpp b/core/src/CodecAnalysis.cpp index cd5ad47..e403893 100644 --- a/core/src/CodecAnalysis.cpp +++ b/core/src/CodecAnalysis.cpp @@ -53,6 +53,14 @@ Image CodecAnalysis::computeArtifactMap( return artifact; } +// Luma of pixel (x, y): BT.601 weights for a 3-channel BGR image, or the single +// channel itself for 1-channel input. The structural maps below work on luma so +// that a distortion in any colour channel is seen (channel 0 alone is blue). +static double lumaAt(const Image& img, int x, int y) { + if (img.channels() == 1) return img.at(x, y, 0); + return 0.114 * img.at(x, y, 0) + 0.587 * img.at(x, y, 1) + 0.299 * img.at(x, y, 2); +} + Image CodecAnalysis::computeEdgeDistortionMap( const Image& original, const Image& reconstructed @@ -65,13 +73,13 @@ Image CodecAnalysis::computeEdgeDistortionMap( for (int y = 1; y < h - 1; ++y) { for (int x = 1; x < w - 1; ++x) { // Compute gradient in original - double gx = original.at(x+1, y, 0) - original.at(x-1, y, 0); - double gy = original.at(x, y+1, 0) - original.at(x, y-1, 0); + double gx = lumaAt(original, x+1, y) - lumaAt(original, x-1, y); + double gy = lumaAt(original, x, y+1) - lumaAt(original, x, y-1); double gradOrig = std::sqrt(gx*gx + gy*gy); // Compute gradient in reconstructed - double rgx = reconstructed.at(x+1, y, 0) - reconstructed.at(x-1, y, 0); - double rgy = reconstructed.at(x, y+1, 0) - reconstructed.at(x, y-1, 0); + double rgx = lumaAt(reconstructed, x+1, y) - lumaAt(reconstructed, x-1, y); + double rgy = lumaAt(reconstructed, x, y+1) - lumaAt(reconstructed, x, y-1); double gradRecon = std::sqrt(rgx*rgx + rgy*rgy); // Error is the loss of edge strength or introduction of spurious edges @@ -94,11 +102,11 @@ Image CodecAnalysis::computeBlockingMap( for (int x = 0; x < w; ++x) { double score = 0.0; if (x % 8 == 0 && x > 0 && x < w) { - double diff = std::abs(reconstructed.at(x, y, 0) - reconstructed.at(x-1, y, 0)); + double diff = std::abs(lumaAt(reconstructed, x, y) - lumaAt(reconstructed, x-1, y)); score += diff; } if (y % 8 == 0 && y > 0 && y < h) { - double diff = std::abs(reconstructed.at(x, y, 0) - reconstructed.at(x, y-1, 0)); + double diff = std::abs(lumaAt(reconstructed, x, y) - lumaAt(reconstructed, x, y-1)); score += diff; } blocking.at(x, y, 0) = std::min(255.0, score * 8.0); diff --git a/core/src/ImageCodec.cpp b/core/src/ImageCodec.cpp index f983542..ea0f33b 100644 --- a/core/src/ImageCodec.cpp +++ b/core/src/ImageCodec.cpp @@ -476,27 +476,19 @@ ImageCodec::BlockDebugData ImageCodec::inspectBlock(const Image& channel, int bl } } - // 2. Extract Original Block - // Ensure we don't go out of bounds - int startX = blockX * 8; - int startY = blockY * 8; - - // Fill with zeros first - for(int i=0; i<8; ++i) - for(int j=0; j<8; ++j) - data.original[i][j] = 0.0; - + // 2. Extract Original Block. Partial blocks at the right/bottom edges are + // padded by edge replication, exactly as processChannel codes them. + const int startX = blockX * 8; + const int startY = blockY * 8; const double* channelData = channel.data(); - int width = channel.width(); - int height = channel.height(); + const int width = channel.width(); + const int height = channel.height(); for(int i=0; i<8; ++i) { + const int y = std::max(0, std::min(startY + i, height - 1)); for(int j=0; j<8; ++j) { - int y = startY + i; - int x = startX + j; - if (x < width && y < height) { - data.original[i][j] = channelData[y * width + x]; - } + const int x = std::max(0, std::min(startX + j, width - 1)); + data.original[i][j] = channelData[y * width + x]; } } diff --git a/core/tests/test_codecanalysis.cpp b/core/tests/test_codecanalysis.cpp index 0e6556d..387bdea 100644 --- a/core/tests/test_codecanalysis.cpp +++ b/core/tests/test_codecanalysis.cpp @@ -70,3 +70,41 @@ TEST(CodecAnalysisTest, ComputeMetrics_Structure) { EXPECT_DOUBLE_EQ(metrics.ssimCr, 1.0); EXPECT_DOUBLE_EQ(metrics.ssimCb, 1.0); } + +// The structural maps must react to distortion in any colour channel, not just +// channel 0 (blue in BGR order). +TEST(CodecAnalysisTest, EdgeDistortionSeesNonBlueChannels) { + Image original = createFlatImage(16, 16, 100.0, 100.0, 100.0); + Image distorted = original; + // Vertical red/green-only edge: blue channel stays flat. + for (int y = 0; y < 16; ++y) + for (int x = 8; x < 16; ++x) { + distorted.at(x, y, 1) = 200.0; // G + distorted.at(x, y, 2) = 200.0; // R + } + Image map = CodecAnalysis::computeEdgeDistortionMap(original, distorted); + ASSERT_EQ(map.channels(), 1); + EXPECT_GT(map.at(8, 8, 0), 0.0); +} + +TEST(CodecAnalysisTest, BlockingMapSeesNonBlueChannels) { + Image recon = createFlatImage(16, 16, 100.0, 100.0, 100.0); + // Step in red only, exactly on the x = 8 block boundary. + for (int y = 0; y < 16; ++y) + for (int x = 8; x < 16; ++x) + recon.at(x, y, 2) = 140.0; + Image map = CodecAnalysis::computeBlockingMap(recon); + EXPECT_GT(map.at(8, 4, 0), 0.0); // boundary column + EXPECT_EQ(map.at(5, 4, 0), 0.0); // block interior +} + +TEST(CodecAnalysisTest, StructuralMapsAcceptSingleChannel) { + Image a(16, 16, 1), b(16, 16, 1); + for (int y = 0; y < 16; ++y) + for (int x = 0; x < 16; ++x) { + a.at(x, y, 0) = 100.0; + b.at(x, y, 0) = x < 8 ? 100.0 : 150.0; + } + EXPECT_GT(CodecAnalysis::computeEdgeDistortionMap(a, b).at(8, 8, 0), 0.0); + EXPECT_GT(CodecAnalysis::computeBlockingMap(b).at(8, 8, 0), 0.0); +} diff --git a/core/tests/test_imagecodec.cpp b/core/tests/test_imagecodec.cpp index f8cdb38..23996c8 100644 --- a/core/tests/test_imagecodec.cpp +++ b/core/tests/test_imagecodec.cpp @@ -422,3 +422,21 @@ TEST(ImageCodecTest, ChromaUpsamplingIsSmooth) { if (std::abs(output.at(x, 4, 2) - output.at(x + 1, 4, 2)) < 0.5) ++flatPairs; EXPECT_EQ(flatPairs, 0); } + +TEST(ImageCodecTest, InspectBlockPadsPartialEdgeBlocksByReplication) { + // 12×10 channel: block (1, 1) covers x 8..11 and y 8..9 only. + Image channel(12, 10, 1); + for (int y = 0; y < 10; ++y) + for (int x = 0; x < 12; ++x) + channel.at(x, y, 0) = x * 10 + y; + + ImageCodec codec(50.0); + auto debug = codec.inspectBlock(channel, 1, 1, false); + + for (int i = 0; i < 8; ++i) + for (int j = 0; j < 8; ++j) { + const int x = std::min(8 + j, 11); + const int y = std::min(8 + i, 9); + EXPECT_DOUBLE_EQ(debug.original[i][j], channel.at(x, y, 0)) << i << "," << j; + } +} diff --git a/web/public/codec.wasm b/web/public/codec.wasm index b4b5b01b99b198b3547268854cc9292d98ca1418..c96723162b8f11c8f430d754042730beb185eb08 100755 GIT binary patch delta 6274 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 EUk7&ZM*si- diff --git a/web/src/App.svelte b/web/src/App.svelte index 84ca3a5..dc970c7 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -41,8 +41,8 @@ e.preventDefault(); if (!appState.inspectedBlock || !appState.originalImageData) return; - const maxBx = Math.floor(appState.imgWidth / 8) - 1; - const maxBy = Math.floor(appState.imgHeight / 8) - 1; + const maxBx = Math.ceil(appState.imgWidth / 8) - 1; // includes partial edge blocks + const maxBy = Math.ceil(appState.imgHeight / 8) - 1; let { x: bx, y: by } = appState.inspectedBlock; if (e.key === 'ArrowLeft') bx = Math.max(0, bx - 1); diff --git a/web/src/ArtifactInspectorMode.svelte b/web/src/ArtifactInspectorMode.svelte index fec9b3b..13db7d0 100644 --- a/web/src/ArtifactInspectorMode.svelte +++ b/web/src/ArtifactInspectorMode.svelte @@ -89,9 +89,9 @@ return lut; })(); - function applyHeatmap(data: Uint8ClampedArray, pixelCount: number) { + function applyHeatmap(data: Uint8ClampedArray, intensity: Uint8Array, pixelCount: number) { for (let i = 0; i < pixelCount; i++) { - const v = data[i * 4]; // R channel (WASM outputs grayscale: R=G=B) + const v = intensity[i]; data[i * 4 ] = VIRIDIS_LUT[v * 3 ]; data[i * 4 + 1] = VIRIDIS_LUT[v * 3 + 1]; data[i * 4 + 2] = VIRIDIS_LUT[v * 3 + 2]; @@ -99,6 +99,25 @@ } + // Each map stores min(255, value × gain). Gains for the structural maps + // mirror the constants in core/src/CodecAnalysis.cpp. + const EDGE_DISTORTION_GAIN = 4.0; + const BLOCKING_GAIN = 8.0; + + const tooltipReading = $derived.by(() => { + const mode = appState.currentViewMode; + const gain = mode === ViewMode.EdgeDistortion ? EDGE_DISTORTION_GAIN + : mode === ViewMode.BlockingMap ? BLOCKING_GAIN + : gainValue; + const label = mode === ViewMode.EdgeDistortion ? 'Edge strength Δ' + : mode === ViewMode.BlockingMap ? 'Boundary step' + : '|Error|'; + const value = tooltip.val / gain; + // A saturated pixel only tells us the true value is at least 255 / gain. + const text = `${tooltip.val >= 255 ? '≥ ' : ''}${value < 10 ? value.toFixed(1) : value.toFixed(0)}`; + return { label, text, saturated: tooltip.val >= 255 }; + }); + function onArtifactMouseMove(e: MouseEvent) { if (!rawArtifactData || !artifactCanvas) { tooltip.visible = false; return; } const rect = artifactCanvas.getBoundingClientRect(); @@ -136,6 +155,9 @@ if (!ARTIFACT_MODES.some(m => m.val === appState.currentViewMode)) { appState.currentViewMode = ViewMode.Artifacts; } + // The WASM gain outlives this component while the slider resets, so + // re-sync it; otherwise the map and tooltip would disagree with the slider. + if (appState.wasmReady) setArtifactGain(gainValue); renderOriginal(); renderArtifact(); }); @@ -168,9 +190,14 @@ // @ts-ignore const src = new Uint8ClampedArray(Module.HEAPU8.buffer, ptr, artifactImageData.data.length); artifactImageData.data.set(src); - // Stash raw grayscale values before colour-mapping (used by tooltip) - for (let i = 0; i < w * h; i++) rawArtifactData[i] = artifactImageData.data[i * 4]; - if (showHeatmap) applyHeatmap(artifactImageData.data, w * h); + // Stash per-pixel intensity before colour-mapping (used by tooltip and + // heatmap). The error map is per-channel colour, so take the largest + // channel; the structural maps are grayscale (R = G = B). + const px = artifactImageData.data; + for (let i = 0; i < w * h; i++) { + rawArtifactData[i] = Math.max(px[i * 4], px[i * 4 + 1], px[i * 4 + 2]); + } + if (showHeatmap) applyHeatmap(px, rawArtifactData, w * h); artifactCtx.putImageData(artifactImageData, 0, 0); } catch (err) { console.error('Artifact render error:', err); @@ -575,10 +602,15 @@ {currentMode.label}
- {tooltip.val} - / 255 - ({((tooltip.val / 255) * 100).toFixed(1)}%) + {tooltipReading.label} + {tooltipReading.text} + levels +
+ {#if tooltipReading.saturated} +
+ {appState.currentViewMode === ViewMode.Artifacts ? 'Saturated — lower the gain to measure' : 'Saturated'}
+ {/if} {/if} diff --git a/web/src/InspectorMode.svelte b/web/src/InspectorMode.svelte index ad8d6f1..5686268 100644 --- a/web/src/InspectorMode.svelte +++ b/web/src/InspectorMode.svelte @@ -36,7 +36,7 @@ // Reactive: recompute suggestions when entering inspector or quality changes $effect(() => { if (appState.appMode === 'inspector' && appState.wasmReady && appState.originalImageData) { - computeSuggestedBlocks(document.getElementById('processedCanvas') as HTMLCanvasElement | null); + computeSuggestedBlocks(); } }); @@ -57,7 +57,7 @@ inspectBlock(currentBlock.x, currentBlock.y); renderContextCrop(currentBlock.x, currentBlock.y); } - computeSuggestedBlocks(document.getElementById('processedCanvas') as HTMLCanvasElement | null); + computeSuggestedBlocks(); } }); @@ -171,8 +171,8 @@ const scaleY = appState.imgHeight / rect.height; const bx = Math.floor(x * scaleX / 8); const by = Math.floor(y * scaleY / 8); - const maxBx = Math.floor(appState.imgWidth / 8) - 1; - const maxBy = Math.floor(appState.imgHeight / 8) - 1; + const maxBx = Math.ceil(appState.imgWidth / 8) - 1; // includes partial edge blocks + const maxBy = Math.ceil(appState.imgHeight / 8) - 1; return { bx: Math.max(0, Math.min(maxBx, bx)), by: Math.max(0, Math.min(maxBy, by)), diff --git a/web/src/lib/inspection.ts b/web/src/lib/inspection.ts index 1b4a492..df245ca 100644 --- a/web/src/lib/inspection.ts +++ b/web/src/lib/inspection.ts @@ -1,5 +1,5 @@ import { appState, ViewMode } from './state.svelte.js'; -import { inspectBlockData } from './wasm-bridge.js'; +import { inspectBlockData, getProcessedRgba } from './wasm-bridge.js'; import { hideBasisPopover, setCachedGridData } from './basis-popover.js'; import { renderGrid, renderLossMeter, renderZigzagArray, renderEntropySummary, stopReconstructionAnimation } from './grid-renderer.js'; import { estimateBlockBits, getEntropySymbols } from './dct-utils.js'; @@ -56,19 +56,13 @@ export function inspectBlock(blockX: number, blockY: number): void { let reconstructedRGB: Uint8ClampedArray | null = null; if (appState.currentViewMode === ViewMode.RGB) { - originalRGB = getBlockRGB(appState.originalImageData!, blockX, blockY); - const processedCanvas = document.getElementById('processedCanvas') as HTMLCanvasElement | null; - if (processedCanvas) { - try { - const ctx = processedCanvas.getContext('2d', { willReadFrequently: true }); - if (ctx) { - const reconImgData = ctx.getImageData(blockX * 8, blockY * 8, 8, 8); - reconstructedRGB = getBlockRGB(reconImgData, 0, 0); - } - } catch { - console.warn('Could not read processedCanvas for RGB block view'); - } - } + const w = appState.imgWidth; + const h = appState.imgHeight; + originalRGB = getBlockRGB(appState.originalImageData!.data, w, h, blockX, blockY); + // The viewer's canvases are unmounted while inspecting, so read the + // reconstruction straight from the codec session. + const recon = getProcessedRgba(); + if (recon) reconstructedRGB = getBlockRGB(recon, w, h, blockX, blockY); } let mse = 0; @@ -130,15 +124,15 @@ export function inspectBlock(blockX: number, blockY: number): void { renderEntropySummary(entropySymbols); } -function getBlockRGB(imageData: ImageData, bx: number, by: number): Uint8ClampedArray { +// Extracts an 8×8 RGB block from an RGBA buffer. Partial blocks at the +// right/bottom edges are padded by edge replication, as the codec does. +function getBlockRGB(pixels: Uint8ClampedArray, w: number, h: number, bx: number, by: number): Uint8ClampedArray { const data = new Uint8ClampedArray(64 * 3); - const pixels = imageData.data; - const w = imageData.width; for (let dy = 0; dy < 8; dy++) { for (let dx = 0; dx < 8; dx++) { - const x = bx * 8 + dx; - const y = by * 8 + dy; + const x = Math.min(bx * 8 + dx, w - 1); + const y = Math.min(by * 8 + dy, h - 1); const idx = (y * w + x) * 4; const i = dy * 8 + dx; data[i * 3] = pixels[idx]; diff --git a/web/src/lib/suggested-blocks.ts b/web/src/lib/suggested-blocks.ts index de59621..20a0416 100644 --- a/web/src/lib/suggested-blocks.ts +++ b/web/src/lib/suggested-blocks.ts @@ -1,17 +1,16 @@ /** * Suggested Blocks: Auto-detect interesting 8×8 blocks for guided exploration. - * Scores blocks by edge content, texture complexity, reconstruction error, and smoothness. + * Scores blocks by edge content, texture complexity, and smoothness. */ import { appState } from './state.svelte.js'; import type { SuggestedBlock } from './state.svelte.js'; /** - * Compute suggested blocks from the loaded image data. - * Reads appState.originalImageData and the processedCanvas for error scoring. + * Compute suggested blocks from appState.originalImageData. * Stores results in appState.suggestedBlocks. */ -export function computeSuggestedBlocks(processedCanvas?: HTMLCanvasElement | null): void { +export function computeSuggestedBlocks(): void { if (!appState.originalImageData) { appState.suggestedBlocks = []; return; @@ -30,30 +29,15 @@ export function computeSuggestedBlocks(processedCanvas?: HTMLCanvasElement | nul const origData = appState.originalImageData.data; - // Get reconstructed data from processedCanvas - let reconData: Uint8ClampedArray | null = null; - try { - const canvas = processedCanvas ?? (document.getElementById('processedCanvas') as HTMLCanvasElement | null); - if (canvas) { - const ctx = canvas.getContext('2d', { willReadFrequently: true }); - if (ctx) { - const imgData = ctx.getImageData(0, 0, w, h); - reconData = imgData.data; - } - } - } catch { - // If we can't read the canvas, skip error scoring - } - const maxCandidates = 200; const step = Math.max(1, Math.floor(totalBlocks / maxCandidates)); - const candidates: Array<{ x: number; y: number; edge: number; texture: number; smooth: number; error: number }> = []; + const candidates: Array<{ x: number; y: number; edge: number; texture: number; smooth: number }> = []; for (let idx = 0; idx < totalBlocks; idx += step) { const bx = idx % blocksX; const by = Math.floor(idx / blocksX); - const scores = scoreBlock(origData, reconData, bx, by, w, h); + const scores = scoreBlock(origData, bx, by, w, h); candidates.push({ x: bx, y: by, ...scores }); } @@ -95,7 +79,6 @@ export function computeSuggestedBlocks(processedCanvas?: HTMLCanvasElement | nul function scoreBlock( origData: Uint8ClampedArray, - reconData: Uint8ClampedArray | null, bx: number, by: number, imgW: number, @@ -107,7 +90,6 @@ function scoreBlock( let sumLum = 0; let sumLum2 = 0; let edgeSum = 0; - let errorSum = 0; let count = 0; for (let dy = 0; dy < 8; dy++) { @@ -129,17 +111,10 @@ function scoreBlock( const lumD = origData[idxD] * 0.299 + origData[idxD + 1] * 0.587 + origData[idxD + 2] * 0.114; edgeSum += Math.abs(lumR - lum) + Math.abs(lumD - lum); } - - if (reconData) { - const dr = origData[idx] - reconData[idx]; - const dg = origData[idx + 1] - reconData[idx + 1]; - const db = origData[idx + 2] - reconData[idx + 2]; - errorSum += (dr * dr + dg * dg + db * db) / 3; - } } } - if (count === 0) return { edge: 0, texture: 0, smooth: 0, error: 0 }; + if (count === 0) return { edge: 0, texture: 0, smooth: 0 }; const mean = sumLum / count; const variance = (sumLum2 / count) - (mean * mean); @@ -147,8 +122,7 @@ function scoreBlock( return { edge: edgeSum, texture: variance, - smooth: 1 / (1 + variance), - error: errorSum / count + smooth: 1 / (1 + variance) }; } diff --git a/web/src/lib/wasm-bridge.ts b/web/src/lib/wasm-bridge.ts index 848cd4d..b175490 100644 --- a/web/src/lib/wasm-bridge.ts +++ b/web/src/lib/wasm-bridge.ts @@ -1,4 +1,4 @@ -import { appState } from './state.svelte.js'; +import { appState, ViewMode } from './state.svelte.js'; export function setupWasm(onReady: () => void, onError?: (message: string) => void): void { // codec.js is loaded via a classic