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}