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 b4b5b01..c967231 100755 Binary files a/web/public/codec.wasm and b/web/public/codec.wasm differ 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