Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions core/src/CodecAnalysis.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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);
Expand Down
26 changes: 9 additions & 17 deletions core/src/ImageCodec.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
}

Expand Down
38 changes: 38 additions & 0 deletions core/tests/test_codecanalysis.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
18 changes: 18 additions & 0 deletions core/tests/test_imagecodec.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Binary file modified web/public/codec.wasm
Binary file not shown.
4 changes: 2 additions & 2 deletions web/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
48 changes: 40 additions & 8 deletions web/src/ArtifactInspectorMode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,35 @@
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];
}
}


// 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();
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -575,10 +602,15 @@
<span class="tooltip-type">{currentMode.label}</span>
</div>
<div class="tooltip-val">
<span class="val-number">{tooltip.val}</span>
<span class="val-scale">/ 255</span>
<span class="val-percent">({((tooltip.val / 255) * 100).toFixed(1)}%)</span>
<span class="val-scale">{tooltipReading.label}</span>
<span class="val-number">{tooltipReading.text}</span>
<span class="val-scale">levels</span>
</div>
{#if tooltipReading.saturated}
<div class="val-percent">
{appState.currentViewMode === ViewMode.Artifacts ? 'Saturated — lower the gain to measure' : 'Saturated'}
</div>
{/if}
</div>
{/if}

Expand Down
8 changes: 4 additions & 4 deletions web/src/InspectorMode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});

Expand All @@ -57,7 +57,7 @@
inspectBlock(currentBlock.x, currentBlock.y);
renderContextCrop(currentBlock.x, currentBlock.y);
}
computeSuggestedBlocks(document.getElementById('processedCanvas') as HTMLCanvasElement | null);
computeSuggestedBlocks();
}
});

Expand Down Expand Up @@ -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)),
Expand Down
32 changes: 13 additions & 19 deletions web/src/lib/inspection.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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];
Expand Down
Loading