summaryrefslogtreecommitdiff
path: root/tools/cnn_v2_test/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'tools/cnn_v2_test/index.html')
-rw-r--r--tools/cnn_v2_test/index.html622
1 files changed, 609 insertions, 13 deletions
diff --git a/tools/cnn_v2_test/index.html b/tools/cnn_v2_test/index.html
index 9c28455..bfc91c5 100644
--- a/tools/cnn_v2_test/index.html
+++ b/tools/cnn_v2_test/index.html
@@ -3,10 +3,10 @@
<!--
CNN v2 Testing Tool - WebGPU-based inference validator
- TODO:
- - Side panel: .bin metadata display, weight statistics, validation
- - Layer inspection: R/G/B/A plane split, intermediate layer visualization
- - Activation heatmaps for debugging
+ Features:
+ - Side panel: .bin metadata display, weight statistics per layer
+ - Layer inspection: 4-channel grayscale split, intermediate layer visualization
+ - View modes: CNN output, original, diff (×10)
-->
<head>
<meta charset="UTF-8">
@@ -55,6 +55,11 @@
.drop-zone:hover { border-color: #606060; background: #252525; }
.drop-zone.active { border-color: #4a9eff; background: #1a2a3a; }
.drop-zone.error { border-color: #ff4a4a; background: #3a1a1a; }
+ .content {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
+ }
.main {
flex: 1;
display: flex;
@@ -78,6 +83,103 @@
pointer-events: none;
z-index: 10;
}
+ .sidebar {
+ width: 350px;
+ background: #2a2a2a;
+ border-left: 1px solid #404040;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 16px;
+ }
+ .panel {
+ border: 1px solid #404040;
+ border-radius: 4px;
+ overflow: hidden;
+ }
+ .panel.collapsed .panel-content {
+ display: none;
+ }
+ .panel-header {
+ background: #1a1a1a;
+ padding: 8px 12px;
+ font-size: 12px;
+ font-weight: bold;
+ border-bottom: 1px solid #404040;
+ }
+ .panel-content {
+ padding: 12px;
+ font-size: 11px;
+ }
+ .panel-content table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+ .panel-content th {
+ text-align: left;
+ padding: 4px;
+ font-size: 10px;
+ color: #808080;
+ border-bottom: 1px solid #404040;
+ }
+ .panel-content td {
+ padding: 4px;
+ font-size: 10px;
+ }
+ .panel-content tr:hover {
+ background: #1a1a1a;
+ }
+ .layer-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 12px;
+ }
+ .layer-buttons button {
+ background: #1a1a1a;
+ border: 1px solid #404040;
+ color: #e0e0e0;
+ padding: 6px 12px;
+ font-size: 10px;
+ font-family: 'Courier New', monospace;
+ cursor: pointer;
+ transition: all 0.2s;
+ }
+ .layer-buttons button:hover {
+ border-color: #606060;
+ background: #252525;
+ }
+ .layer-buttons button.active {
+ background: #4a9eff;
+ border-color: #4a9eff;
+ color: #1a1a1a;
+ }
+ .layer-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ }
+ .layer-view {
+ aspect-ratio: 1;
+ background: #1a1a1a;
+ border: 1px solid #404040;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+ .layer-view-label {
+ background: #2a2a2a;
+ padding: 4px;
+ font-size: 9px;
+ text-align: center;
+ border-bottom: 1px solid #404040;
+ }
+ .layer-view canvas {
+ width: 100%;
+ height: 100%;
+ image-rendering: pixelated;
+ }
canvas {
max-width: 100%;
max-height: 100%;
@@ -134,8 +236,26 @@
</div>
<div class="drop-zone" id="weightsDrop">Drop .bin Weights</div>
</div>
- <div class="main" id="mainDrop">
- <canvas id="canvas"></canvas>
+ <div class="content">
+ <div class="main" id="mainDrop">
+ <canvas id="canvas"></canvas>
+ </div>
+ <div class="sidebar">
+ <div class="panel" style="flex: 1; display: flex; flex-direction: column; min-height: 0;">
+ <div class="panel-header">Layer Visualization</div>
+ <div class="panel-content" id="layerViz" style="flex: 1; overflow-y: auto;">
+ <p style="color: #808080; text-align: center;">Load image + weights</p>
+ </div>
+ </div>
+ <div class="panel" id="weightsInfoPanel">
+ <div class="panel-header" style="cursor: pointer; user-select: none;" onclick="tester.toggleWeightsInfo()">
+ Weights Info <span id="weightsInfoToggle">▼</span>
+ </div>
+ <div class="panel-content" id="weightsInfo">
+ <p style="color: #808080; text-align: center;">No weights loaded</p>
+ </div>
+ </div>
+ </div>
</div>
<div class="footer">
<div class="footer-top">
@@ -329,6 +449,71 @@ fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
}
}`;
+const LAYER_VIZ_SHADER = `
+@group(0) @binding(0) var layer_tex: texture_2d<u32>;
+@group(0) @binding(1) var<uniform> viz_params: vec2<f32>; // x=channel_idx, y=scale
+
+@vertex
+fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
+ var pos = array<vec2<f32>, 6>(
+ vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(-1.0, 1.0),
+ vec2<f32>(-1.0, 1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0)
+ );
+ return vec4<f32>(pos[idx], 0.0, 1.0);
+}
+
+@fragment
+fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
+ let coord = vec2<i32>(pos.xy);
+ let dims = textureDimensions(layer_tex);
+
+ let channel = u32(viz_params.x);
+
+ // DEBUG MODE 1: Texture coordinates (channel 10)
+ if (channel == 10u) {
+ let uv = vec2<f32>(f32(coord.x) / f32(dims.x), f32(coord.y) / f32(dims.y));
+ return vec4<f32>(uv.x, uv.y, 0.0, 1.0);
+ }
+
+ let packed = textureLoad(layer_tex, coord, 0);
+
+ // DEBUG MODE 2: Raw packed data (channel 11)
+ if (channel == 11u) {
+ let raw_val = f32(packed.x) / 4294967295.0;
+ return vec4<f32>(raw_val, raw_val, raw_val, 1.0);
+ }
+
+ let v0 = unpack2x16float(packed.x);
+ let v1 = unpack2x16float(packed.y);
+ let v2 = unpack2x16float(packed.z);
+ let v3 = unpack2x16float(packed.w);
+
+ // DEBUG MODE 3: First unpacked value (channel 12)
+ if (channel == 12u) {
+ return vec4<f32>(v0.x, v0.x, v0.x, 1.0);
+ }
+
+ var channels: array<f32, 8>;
+ channels[0] = v0.x;
+ channels[1] = v0.y;
+ channels[2] = v1.x;
+ channels[3] = v1.y;
+ channels[4] = v2.x;
+ channels[5] = v2.y;
+ channels[6] = v3.x;
+ channels[7] = v3.y;
+
+ let scale = viz_params.y;
+
+ let idx = min(channel, 7u);
+ let raw = channels[idx];
+
+ // Apply scale: multiply and clamp to [0, 1]
+ let val = clamp(raw * scale, 0.0, 1.0);
+
+ return vec4<f32>(val, val, val, 1.0);
+}`;
+
class CNNTester {
constructor() {
this.canvas = document.getElementById('canvas');
@@ -404,14 +589,48 @@ class CNNTester {
const weightsOffset = 16 + numLayers * 20;
const weights = new Uint32Array(buffer.slice(weightsOffset));
- // Verify weights are non-zero
+ // Calculate min/max per layer
+ for (let i = 0; i < numLayers; i++) {
+ const layer = layers[i];
+ let min = Infinity, max = -Infinity;
+ const startIdx = layer.weightOffset;
+ const endIdx = startIdx + layer.weightCount;
+
+ for (let j = startIdx; j < endIdx; j++) {
+ const pairIdx = Math.floor(j / 2);
+ const packed = weights[pairIdx];
+ const unpacked = this.unpackF16(packed);
+ const val = (j % 2 === 0) ? unpacked[0] : unpacked[1];
+ min = Math.min(min, val);
+ max = Math.max(max, val);
+ }
+
+ layer.min = min;
+ layer.max = max;
+ this.log(` Layer ${i} range: [${min.toFixed(4)}, ${max.toFixed(4)}]`);
+ }
+
let nonZero = 0;
for (let i = 0; i < weights.length; i++) {
if (weights[i] !== 0) nonZero++;
}
this.log(` Weight buffer: ${weights.length} u32 (${nonZero} non-zero)`);
- return { layers, weights };
+ return { layers, weights, fileSize: buffer.byteLength };
+ }
+
+ unpackF16(packed) {
+ const lo = packed & 0xFFFF;
+ const hi = (packed >> 16) & 0xFFFF;
+ const toFloat = (bits) => {
+ const sign = (bits >> 15) & 1;
+ const exp = (bits >> 10) & 0x1F;
+ const frac = bits & 0x3FF;
+ if (exp === 0) return (sign ? -1 : 1) * Math.pow(2, -14) * (frac / 1024);
+ if (exp === 31) return frac ? NaN : (sign ? -Infinity : Infinity);
+ return (sign ? -1 : 1) * Math.pow(2, exp - 15) * (1 + frac / 1024);
+ };
+ return [toFloat(lo), toFloat(hi)];
}
async loadImage(file) {
@@ -434,6 +653,7 @@ class CNNTester {
this.weights = this.parseWeights(buffer);
this.weightsBuffer = buffer;
this.log(`Loaded weights: ${file.name} (${this.weights.layers.length} layers, ${(buffer.byteLength/1024).toFixed(1)} KB)`);
+ this.updateWeightsPanel();
if (this.image) {
this.setStatus(`Ready: ${this.image.width}×${this.image.height}`);
this.run();
@@ -442,6 +662,49 @@ class CNNTester {
}
}
+ updateWeightsPanel() {
+ const panel = document.getElementById('weightsInfo');
+ const { layers, fileSize } = this.weights;
+
+ let html = `
+ <div style="margin-bottom: 12px;">
+ <div><strong>File Size:</strong> ${(fileSize / 1024).toFixed(2)} KB</div>
+ <div><strong>Layers:</strong> ${layers.length}</div>
+ </div>
+ <table>
+ <thead>
+ <tr>
+ <th>Layer</th>
+ <th>Size</th>
+ <th>Weights</th>
+ <th>Min</th>
+ <th>Max</th>
+ </tr>
+ </thead>
+ <tbody>
+ `;
+
+ for (let i = 0; i < layers.length; i++) {
+ const l = layers[i];
+ html += `
+ <tr>
+ <td>${i + 1}</td>
+ <td>${l.inChannels}→${l.outChannels} (${l.kernelSize}×${l.kernelSize})</td>
+ <td>${l.weightCount}</td>
+ <td>${l.min.toFixed(3)}</td>
+ <td>${l.max.toFixed(3)}</td>
+ </tr>
+ `;
+ }
+
+ html += `
+ </tbody>
+ </table>
+ `;
+
+ panel.innerHTML = html;
+ }
+
displayOriginal() {
if (!this.image || !this.device) return;
@@ -547,19 +810,32 @@ class CNNTester {
const staticTex = this.device.createTexture({
size: [width, height],
format: 'rgba32uint',
- usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING
+ usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC
});
- const layerTextures = [
+ // Create one texture per layer output (static + all CNN layers)
+ this.layerOutputs = [];
+ const numLayers = this.weights.layers.length + 1; // +1 for static features
+ const layerTextures = [];
+ for (let i = 0; i < numLayers; i++) {
+ layerTextures.push(this.device.createTexture({
+ size: [width, height],
+ format: 'rgba32uint',
+ usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
+ }));
+ }
+
+ // Ping-pong buffers for computation
+ const computeTextures = [
this.device.createTexture({
size: [width, height],
format: 'rgba32uint',
- usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING
+ usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC
}),
this.device.createTexture({
size: [width, height],
format: 'rgba32uint',
- usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING
+ usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC
})
];
@@ -606,8 +882,16 @@ class CNNTester {
staticPass.dispatchWorkgroups(Math.ceil(width / 8), Math.ceil(height / 8));
staticPass.end();
+ // Copy static features to persistent storage (Layer 0)
+ encoder.copyTextureToTexture(
+ { texture: staticTex },
+ { texture: layerTextures[0] },
+ [width, height]
+ );
+ this.layerOutputs.push(layerTextures[0]);
+
let srcTex = staticTex;
- let dstTex = layerTextures[0];
+ let dstTex = computeTextures[0];
for (let i = 0; i < this.weights.layers.length; i++) {
const layer = this.weights.layers[i];
@@ -652,6 +936,17 @@ class CNNTester {
cnnPass.end();
[srcTex, dstTex] = [dstTex, srcTex];
+
+ // Copy layer output to persistent storage for visualization
+ encoder.copyTextureToTexture(
+ { texture: srcTex },
+ { texture: layerTextures[i + 1] }, // +1 because layerTextures[0] is static features
+ [width, height]
+ );
+
+ if (!isOutput) {
+ this.layerOutputs.push(layerTextures[i + 1]);
+ }
}
const modeBuffer = this.device.createBuffer({
@@ -689,10 +984,311 @@ class CNNTester {
this.device.queue.submit([encoder.finish()]);
+ // Wait for GPU to finish before visualizing layers
+ await this.device.queue.onSubmittedWorkDone();
+
const t1 = performance.now();
const mode = ['CNN Output', 'Original', 'Diff (×10)'][this.viewMode];
this.setStatus(`GPU: ${(t1-t0).toFixed(1)}ms | ${width}×${height} | ${mode}`);
this.log(`Completed in ${(t1-t0).toFixed(1)}ms`);
+
+ // Update layer visualization panel
+ this.updateLayerVizPanel();
+ }
+
+ updateLayerVizPanel() {
+ const panel = document.getElementById('layerViz');
+
+ if (!this.layerOutputs || this.layerOutputs.length === 0) {
+ panel.innerHTML = '<p style="color: #808080; text-align: center;">No layers to visualize</p>';
+ return;
+ }
+
+ let html = `
+ <div style="display: flex; gap: 8px; margin-bottom: 12px; border-bottom: 1px solid #404040; padding-bottom: 8px;">
+ <button onclick="tester.setVizMode('activations')" id="vizModeActivations" style="flex:1; padding: 6px; background: #4a9eff; border: 1px solid #4a9eff; color: #1a1a1a; cursor: pointer; font-family: 'Courier New'; font-size: 10px;">Activations</button>
+ <button onclick="tester.setVizMode('weights')" id="vizModeWeights" style="flex:1; padding: 6px; background: #1a1a1a; border: 1px solid #404040; color: #e0e0e0; cursor: pointer; font-family: 'Courier New'; font-size: 10px;">Weights</button>
+ </div>
+ `;
+
+ html += '<div class="layer-buttons">';
+ for (let i = 0; i < this.layerOutputs.length; i++) {
+ const label = i === 0 ? 'Static (L0)' : `Layer ${i}`;
+ html += `<button onclick="tester.visualizeLayer(${i})" id="layerBtn${i}">${label}</button>`;
+ }
+ html += '</div>';
+
+ html += '<div class="layer-grid" id="layerGrid"></div>';
+
+ panel.innerHTML = html;
+
+ this.log(`Layer visualization ready: ${this.layerOutputs.length} layers`);
+ this.vizMode = 'activations';
+
+ // Create initial canvases
+ this.recreateCanvases();
+
+ // Auto-select static features (Layer 0)
+ this.visualizeLayer(0);
+ }
+
+ setVizMode(mode) {
+ this.vizMode = mode;
+ document.getElementById('vizModeActivations').style.background = mode === 'activations' ? '#4a9eff' : '#1a1a1a';
+ document.getElementById('vizModeActivations').style.borderColor = mode === 'activations' ? '#4a9eff' : '#404040';
+ document.getElementById('vizModeActivations').style.color = mode === 'activations' ? '#1a1a1a' : '#e0e0e0';
+ document.getElementById('vizModeWeights').style.background = mode === 'weights' ? '#4a9eff' : '#1a1a1a';
+ document.getElementById('vizModeWeights').style.borderColor = mode === 'weights' ? '#4a9eff' : '#404040';
+ document.getElementById('vizModeWeights').style.color = mode === 'weights' ? '#1a1a1a' : '#e0e0e0';
+
+ // Clear and recreate canvas grid
+ this.recreateCanvases();
+
+ // Re-visualize current layer
+ const activeBtn = document.querySelector('.layer-buttons button.active');
+ if (activeBtn) {
+ const layerIdx = parseInt(activeBtn.id.replace('layerBtn', ''));
+ this.visualizeLayer(layerIdx);
+ }
+ }
+
+ recreateCanvases() {
+ const grid = document.getElementById('layerGrid');
+ if (!grid) return;
+
+ grid.innerHTML = '';
+ for (let c = 0; c < 4; c++) {
+ const div = document.createElement('div');
+ div.className = 'layer-view';
+ div.innerHTML = `
+ <div class="layer-view-label" id="channelLabel${c}">Ch ${c}</div>
+ <canvas id="layerCanvas${c}"></canvas>
+ `;
+ grid.appendChild(div);
+ }
+ }
+
+ async visualizeLayer(layerIdx) {
+ if (!this.layerOutputs || layerIdx >= this.layerOutputs.length) {
+ this.log(`Cannot visualize layer ${layerIdx}: no data`, 'error');
+ return;
+ }
+
+ // Update button states
+ for (let i = 0; i < this.layerOutputs.length; i++) {
+ const btn = document.getElementById(`layerBtn${i}`);
+ if (btn) btn.classList.toggle('active', i === layerIdx);
+ }
+
+ const layerName = layerIdx === 0 ? 'Static Features' : `Layer ${layerIdx}`;
+
+ // Check mode
+ if (this.vizMode === 'weights' && layerIdx > 0) {
+ this.visualizeWeights(layerIdx - 1); // Layer 1 → weights.layers[0]
+ return;
+ }
+
+ if (this.vizMode === 'weights' && layerIdx === 0) {
+ this.log('Static features have no weights', 'error');
+ // Clear canvases
+ for (let c = 0; c < 4; c++) {
+ const canvas = document.getElementById(`layerCanvas${c}`);
+ if (canvas) {
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = '#1a1a1a';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ }
+ }
+ return;
+ }
+
+ const layerTex = this.layerOutputs[layerIdx];
+ const { width, height } = this.image;
+
+ this.log(`Visualizing ${layerName} activations (${width}×${height})`);
+
+ // Update channel labels based on layer type
+ const channelLabels = layerIdx === 0
+ ? ['R', 'G', 'B', 'D'] // Static features: RGBA (R,G,B,Depth,UV_X,UV_Y,sin,bias)
+ : ['Ch0', 'Ch1', 'Ch2', 'Ch3']; // CNN layers
+
+ for (let c = 0; c < 4; c++) {
+ const label = document.getElementById(`channelLabel${c}`);
+ if (label) label.textContent = channelLabels[c];
+ }
+
+ // Create layer viz pipeline if needed
+ if (!this.layerVizPipeline) {
+ this.layerVizPipeline = this.device.createRenderPipeline({
+ layout: 'auto',
+ vertex: {
+ module: this.device.createShaderModule({ code: LAYER_VIZ_SHADER }),
+ entryPoint: 'vs_main'
+ },
+ fragment: {
+ module: this.device.createShaderModule({ code: LAYER_VIZ_SHADER }),
+ entryPoint: 'fs_main',
+ targets: [{ format: this.format }]
+ }
+ });
+ this.log('Created layer visualization pipeline');
+ }
+
+ // Render each channel to its canvas
+ for (let c = 0; c < 4; c++) {
+ const canvas = document.getElementById(`layerCanvas${c}`);
+ if (!canvas) {
+ this.log(`Canvas layerCanvas${c} not found`, 'error');
+ continue;
+ }
+
+ canvas.width = width;
+ canvas.height = height;
+ this.log(`Canvas ${c}: ${width}×${height}`);
+
+ const ctx = canvas.getContext('webgpu');
+ if (!ctx) {
+ this.log(`Failed to get WebGPU context for channel ${c}`, 'error');
+ continue;
+ }
+
+ try {
+ ctx.configure({ device: this.device, format: this.format });
+ } catch (e) {
+ this.log(`Failed to configure canvas ${c}: ${e.message}`, 'error');
+ continue;
+ }
+
+ const vizScale = layerIdx === 0 ? 1.0 : 0.2; // Static: 1.0, CNN layers: 0.2 (assumes ~5 max)
+ const paramsBuffer = this.device.createBuffer({
+ size: 8,
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
+ });
+ // Use normal channel (0-3), or debug modes (10=UV, 11=raw, 12=unpacked)
+ const paramsData = new Float32Array([c, vizScale]);
+ this.device.queue.writeBuffer(paramsBuffer, 0, paramsData);
+
+ const bindGroup = this.device.createBindGroup({
+ layout: this.layerVizPipeline.getBindGroupLayout(0),
+ entries: [
+ { binding: 0, resource: layerTex.createView() },
+ { binding: 1, resource: { buffer: paramsBuffer } }
+ ]
+ });
+
+ const encoder = this.device.createCommandEncoder();
+ const renderPass = encoder.beginRenderPass({
+ colorAttachments: [{
+ view: ctx.getCurrentTexture().createView(),
+ loadOp: 'clear',
+ clearValue: { r: 1.0, g: 0.0, b: 1.0, a: 1.0 }, // Magenta clear for debugging
+ storeOp: 'store'
+ }]
+ });
+
+ renderPass.setPipeline(this.layerVizPipeline);
+ renderPass.setBindGroup(0, bindGroup);
+ renderPass.draw(6);
+ renderPass.end();
+
+ this.device.queue.submit([encoder.finish()]);
+ this.log(`Submitted render for channel ${c}`);
+ }
+
+ // Wait for all renders to complete
+ await this.device.queue.onSubmittedWorkDone();
+ this.log(`Rendered 4 channels for ${layerName}`);
+ }
+
+ visualizeWeights(cnnLayerIdx) {
+ const layer = this.weights.layers[cnnLayerIdx];
+ if (!layer) {
+ this.log(`Layer ${cnnLayerIdx} not found`, 'error');
+ return;
+ }
+
+ const { kernelSize, inChannels, outChannels, weightOffset, min, max } = layer;
+ this.log(`Visualizing Layer ${cnnLayerIdx + 1} weights: ${inChannels}→${outChannels}, ${kernelSize}×${kernelSize}, offset=${weightOffset}`);
+ this.log(`Weight range: [${min.toFixed(3)}, ${max.toFixed(3)}]`);
+
+ // Update channel labels to show output channels
+ const channelLabels = [`Out 0`, `Out 1`, `Out 2`, `Out 3`];
+ for (let c = 0; c < 4; c++) {
+ const label = document.getElementById(`channelLabel${c}`);
+ if (label) label.textContent = c < outChannels ? channelLabels[c] : '-';
+ }
+
+ // Render each output channel's weights
+ for (let outCh = 0; outCh < 4; outCh++) {
+ const canvas = document.getElementById(`layerCanvas${outCh}`);
+ if (!canvas) {
+ this.log(`Canvas ${outCh} not found`, 'error');
+ continue;
+ }
+
+ // Need to clear WebGPU context and use 2D
+ const ctx = canvas.getContext('2d', { willReadFrequently: false });
+ const cellSize = 20; // Pixels per weight
+ const padding = 2;
+ const gridWidth = inChannels * (kernelSize * cellSize + padding);
+ const gridHeight = kernelSize * cellSize;
+
+ canvas.width = gridWidth;
+ canvas.height = gridHeight;
+ this.log(`Canvas ${outCh}: ${gridWidth}×${gridHeight}`);
+
+ ctx.fillStyle = '#1a1a1a';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ if (outCh >= outChannels) {
+ this.log(`Channel ${outCh} >= outChannels ${outChannels}, skipping`);
+ continue;
+ }
+
+ // Draw kernels for each input channel
+ for (let inCh = 0; inCh < inChannels; inCh++) {
+ const xOffset = inCh * (kernelSize * cellSize + padding);
+
+ for (let ky = 0; ky < kernelSize; ky++) {
+ for (let kx = 0; kx < kernelSize; kx++) {
+ const spatialIdx = ky * kernelSize + kx;
+ const wIdx = weightOffset +
+ outCh * inChannels * kernelSize * kernelSize +
+ inCh * kernelSize * kernelSize +
+ spatialIdx;
+
+ const weight = this.getWeightValue(wIdx);
+ const normalized = (weight - min) / (max - min); // [0, 1]
+ const intensity = Math.floor(normalized * 255);
+
+ ctx.fillStyle = `rgb(${intensity}, ${intensity}, ${intensity})`;
+ ctx.fillRect(
+ xOffset + kx * cellSize,
+ ky * cellSize,
+ cellSize - 1,
+ cellSize - 1
+ );
+ }
+ }
+ }
+ this.log(`Rendered output channel ${outCh}`);
+ }
+
+ this.log(`Weight visualization complete`);
+ }
+
+ getWeightValue(idx) {
+ const pairIdx = Math.floor(idx / 2);
+ const packed = this.weights.weights[pairIdx];
+ const unpacked = this.unpackF16(packed);
+ return (idx % 2 === 0) ? unpacked[0] : unpacked[1];
+ }
+
+ toggleWeightsInfo() {
+ const panel = document.getElementById('weightsInfoPanel');
+ const toggle = document.getElementById('weightsInfoToggle');
+ panel.classList.toggle('collapsed');
+ toggle.textContent = panel.classList.contains('collapsed') ? '▶' : '▼';
}
updateDisplay() {