diff options
Diffstat (limited to 'tools/cnn_v2_test')
| -rw-r--r-- | tools/cnn_v2_test/README.md | 94 | ||||
| -rw-r--r-- | tools/cnn_v2_test/index.html | 403 |
2 files changed, 322 insertions, 175 deletions
diff --git a/tools/cnn_v2_test/README.md b/tools/cnn_v2_test/README.md index 2a8e08d..b9439e5 100644 --- a/tools/cnn_v2_test/README.md +++ b/tools/cnn_v2_test/README.md @@ -6,12 +6,14 @@ WebGPU-based browser tool for testing trained CNN v2 weights. ## Features -- Drag-drop PNG images and `.bin` weights +- Drag-drop PNG images and `.bin` weights (or click to browse) - Real-time CNN inference with WebGPU compute shaders - View modes: CNN output, original input, difference (×10) - Adjustable blend amount and depth - Data-driven pipeline (supports variable layer count) - GPU timing display +- **Left Panel:** Weights info + kernel visualization (1px/weight, all layers) +- **Right Panel:** Layer activation viewer with 4-channel split + 4× zoom --- @@ -41,28 +43,36 @@ python3 -m http.server 8000 ### 2. Load Data -1. **Drop PNG image** anywhere in window (shows preview immediately) -2. **Drop `.bin` weights** into header drop zone +1. **Drop `.bin` weights** into left sidebar zone (or click to browse) +2. **Drop PNG image** anywhere in center canvas area 3. CNN runs automatically when both loaded -### 3. Controls +### 3. Layout -**Sliders:** -- **Blend:** Mix between original (0.0) and CNN output (1.0) -- **Depth:** Uniform depth value for all pixels (0.0–1.0) +**Left Sidebar:** +- Weights drop zone (click or drag-drop `.bin` files) +- Weights info panel (layer specs, ranges, file size) +- Weights visualization (click Layer 0/1/2 buttons) + - 1 pixel per weight, all input channels horizontally + - Output channels (Out 0-3) stacked vertically + +**Center Canvas:** +- Main output view (CNN result, original, or diff) +- Keyboard: `SPACE` = original, `D` = diff (×10) -**Keyboard:** -- `SPACE` - Toggle original input view -- `D` - Toggle difference view (×10 amplification) +**Right Sidebar:** +- Layer selection buttons (Static 0-3/4-7, Layer 0/1/2) +- 4 small activation views (Ch0/1/2/3) in a row +- Large zoom view below (4× magnification, follows mouse) -**Status Bar:** -- Shows GPU timing (ms), image dimensions, and current view mode -- Red text indicates errors +**Header Controls:** +- **Blend:** Mix between original (0.0) and CNN output (1.0) +- **Depth:** Uniform depth value for all pixels (0.0–1.0) +- **View:** Current display mode -**Console Log:** -- Timestamped event log at bottom -- Tracks file loads, pipeline execution, errors -- Auto-scrolls to latest messages +**Footer:** +- Status: GPU timing (ms), image dimensions, view mode +- Console: Timestamped event log (file loads, errors) --- @@ -167,24 +177,32 @@ Open browser console (F12) for detailed errors. Common issues: --- -## TODO +## Implemented Features + +**✓ Weights Metadata Panel:** +- Layer descriptions (kernel size, channels, weight count) +- Weight statistics (min/max per layer) +- File size and layer count + +**✓ Weights Visualization:** +- Per-layer kernel heatmaps (1px/weight) +- All input channels displayed horizontally +- Output channels stacked vertically +- Normalized grayscale display -**Side Panel (Right):** -- Display .bin content metadata: - - Layer descriptions (kernel size, channels, weight count) - - Weight statistics (min/max/mean per layer) - - Weight heatmap visualization - - Binary format validation status - - Memory usage breakdown +**✓ Layer Activation Viewer:** +- Static features (8D split into 0-3 and 4-7 views) +- All CNN layer outputs (Layer 0/1/2...) +- 4-channel split view (grayscale per channel) +- Mouse-driven 4× zoom view + +## TODO -**Layer Inspection Views:** -- Split R/G/B/A plane visualization -- Intermediate layer output display: - - View static features (8D packed as heatmaps) - - View layer 0 output (before activation) - - View layer 1 output - - Toggle between channels -- Activation heatmaps (where neurons fire) +**Future Enhancements:** +- Weight distribution histograms per layer +- Activation statistics (min/max/mean overlay) +- Side-by-side diff mode (browser vs C++ output) +- Export rendered layers as PNG --- @@ -213,13 +231,15 @@ Planned enhancements: ## Size -- HTML structure: ~1 KB -- CSS styling: ~1 KB -- JavaScript logic: ~5 KB +- HTML structure: ~2 KB +- CSS styling: ~2 KB +- JavaScript logic: ~10 KB (includes zoom + weights viz) - Static shader: ~1 KB - CNN shader: ~3 KB - Display shader: ~1 KB -- **Total: ~12 KB** (single file, no dependencies) +- Layer viz shader: ~2 KB +- Zoom shader: ~1 KB +- **Total: ~22 KB** (single file, no dependencies) --- diff --git a/tools/cnn_v2_test/index.html b/tools/cnn_v2_test/index.html index 91f7942..cc045f1 100644 --- a/tools/cnn_v2_test/index.html +++ b/tools/cnn_v2_test/index.html @@ -37,8 +37,12 @@ background: #2a2a2a; padding: 16px; border-bottom: 1px solid #404040; + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; } - h1 { font-size: 18px; margin-bottom: 12px; } + h1 { font-size: 18px; } .controls { display: flex; gap: 16px; @@ -60,7 +64,8 @@ cursor: pointer; transition: all 0.2s; font-size: 12px; - margin-top: 12px; + background: #1a1a1a; + border-radius: 4px; } .drop-zone:hover { border-color: #606060; background: #252525; } .drop-zone.active { border-color: #4a9eff; background: #1a2a3a; } @@ -69,6 +74,17 @@ flex: 1; display: flex; overflow: hidden; + gap: 1px; + background: #404040; + } + .left-sidebar { + width: 300px; + background: #2a2a2a; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; } .main { flex: 1; @@ -78,6 +94,7 @@ padding: 24px; overflow: auto; position: relative; + background: #1a1a1a; } .main.drop-active::after { content: 'Drop PNG image here'; @@ -94,9 +111,8 @@ z-index: 10; } .sidebar { - width: 350px; + width: 400px; background: #2a2a2a; - border-left: 1px solid #404040; overflow-y: auto; display: flex; flex-direction: column; @@ -175,8 +191,9 @@ } .layer-grid { display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; + grid-template-columns: repeat(4, 1fr); + gap: 4px; + margin-bottom: 12px; } .layer-view { aspect-ratio: 1; @@ -186,6 +203,18 @@ flex-direction: column; overflow: hidden; } + .layer-zoom { + background: #1a1a1a; + border: 1px solid #404040; + display: flex; + flex-direction: column; + overflow: hidden; + } + .layer-zoom canvas { + width: 100%; + height: 100%; + image-rendering: pixelated; + } .layer-view-label { background: #2a2a2a; padding: 4px; @@ -252,9 +281,27 @@ <span id="viewMode">CNN Output</span> </div> </div> - <div class="drop-zone" id="weightsDrop">Drop .bin Weights</div> </div> <div class="content"> + <div class="left-sidebar"> + <input type="file" id="weightsFile" accept=".bin" style="display: none;"> + <div class="drop-zone" id="weightsDrop" onclick="document.getElementById('weightsFile').click()"> + Drop .bin Weights or Click to Browse + </div> + <div class="panel" id="weightsInfoPanel"> + <div class="panel-header">Weights Info</div> + <div class="panel-content" id="weightsInfo"> + <p style="color: #808080; text-align: center;">No weights loaded</p> + </div> + </div> + <div class="panel" id="weightsVizPanel" style="display: none;"> + <div class="panel-header">Weights Visualization</div> + <div class="panel-content" id="weightsViz"> + <div class="layer-buttons" id="weightsLayerButtons"></div> + <canvas id="weightsCanvas" style="width: 100%; image-rendering: pixelated; border: 1px solid #404040;"></canvas> + </div> + </div> + </div> <div class="main" id="mainDrop"> <canvas id="canvas"></canvas> </div> @@ -265,14 +312,6 @@ <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"> @@ -537,6 +576,8 @@ class CNNTester { this.viewMode = 0; this.blendAmount = 1.0; this.depth = 1.0; + this.currentLayerIdx = null; + this.currentChannelOffset = null; this.init(); } @@ -718,6 +759,20 @@ class CNNTester { `; panel.innerHTML = html; + + // Show weights visualization panel and create layer buttons + const weightsVizPanel = document.getElementById('weightsVizPanel'); + weightsVizPanel.style.display = 'block'; + + const weightsLayerButtons = document.getElementById('weightsLayerButtons'); + let buttonsHtml = ''; + for (let i = 0; i < layers.length; i++) { + buttonsHtml += `<button onclick="tester.visualizeWeights(${i})" id="weightsBtn${i}">Layer ${i}</button>`; + } + weightsLayerButtons.innerHTML = buttonsHtml; + + // Auto-select first layer + this.visualizeWeights(0); } displayOriginal() { @@ -1019,20 +1074,10 @@ class CNNTester { 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 style="font-size: 9px; color: #808080; margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid #404040;">Static features (8D, split 0-3/4-7) + ${this.weights.layers.length} CNN layers (Layer 0-${this.weights.layers.length - 1}). All CNN layers: 12D→4D.</div>`; - - html += '<div class="layer-buttons">'; + let html = '<div class="layer-buttons">'; // Static features: two buttons for 8D (0-3 and 4-7) - const staticDisabled = (this.vizMode === 'weights') ? ' disabled' : ''; - html += `<button onclick="tester.visualizeLayer(0, 0)" id="layerBtn0_0"${staticDisabled}>Static 0-3</button>`; - html += `<button onclick="tester.visualizeLayer(0, 4)" id="layerBtn0_4"${staticDisabled}>Static 4-7</button>`; + html += `<button onclick="tester.visualizeLayer(0, 0)" id="layerBtn0_0">Static 0-3</button>`; + html += `<button onclick="tester.visualizeLayer(0, 4)" id="layerBtn0_4">Static 4-7</button>`; // CNN layers: Layer 0, Layer 1, Layer 2, ... for (let i = 1; i < this.layerOutputs.length; i++) { @@ -1044,50 +1089,20 @@ class CNNTester { html += '</div>'; html += '<div class="layer-grid" id="layerGrid"></div>'; + html += '<div class="layer-zoom"><div class="layer-view-label">Zoom x4</div><canvas id="zoomCanvas"></canvas></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 (channels 0-3) - this.visualizeLayer(0, 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'; - - // Update Static button states - const staticBtn0 = document.getElementById('layerBtn0_0'); - const staticBtn4 = document.getElementById('layerBtn0_4'); - if (staticBtn0) staticBtn0.disabled = (mode === 'weights'); - if (staticBtn4) staticBtn4.disabled = (mode === 'weights'); - - // Clear and recreate canvas grid - this.recreateCanvases(); - - // Re-visualize current layer (switch to Layer 0 if Static was active in weights mode) - const activeBtn = document.querySelector('.layer-buttons button.active'); - if (activeBtn) { - const isStatic = activeBtn.id.startsWith('layerBtn0_'); - if (isStatic && mode === 'weights') { - this.visualizeLayer(1); // Switch to Layer 0 (first CNN layer) - } else if (!isStatic) { - const layerIdx = parseInt(activeBtn.id.replace('layerBtn', '')); - this.visualizeLayer(layerIdx); - } else { - const channelOffset = parseInt(activeBtn.id.split('_')[1]); - this.visualizeLayer(0, channelOffset); - } + // Restore previous selection or default to static features (channels 0-3) + if (this.currentLayerIdx !== null) { + this.visualizeLayer(this.currentLayerIdx, this.currentChannelOffset || 0); + } else { + this.visualizeLayer(0, 0); } } @@ -1120,6 +1135,10 @@ class CNNTester { return; } + // Store current selection + this.currentLayerIdx = layerIdx; + this.currentChannelOffset = channelOffset; + // Update button states document.querySelectorAll('.layer-buttons button').forEach(btn => btn.classList.remove('active')); if (layerIdx === 0) { @@ -1133,30 +1152,6 @@ class CNNTester { } const layerName = layerIdx === 0 ? `Static Features (${channelOffset}-${channelOffset + 3})` : `Layer ${layerIdx - 1}`; - - // Check mode - if (this.vizMode === 'weights' && layerIdx > 0) { - // Map visualization index to weights.layers[] array index: - // layerIdx=1 → Layer 0 → weights.layers[0] - // layerIdx=2 → Layer 1 → weights.layers[1], etc. - this.visualizeWeights(layerIdx - 1); - 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; @@ -1261,64 +1256,191 @@ class CNNTester { // Wait for all renders to complete await this.device.queue.onSubmittedWorkDone(); this.log(`Rendered 4 channels for ${layerName}`); + + // Set up mouse tracking for zoom view + this.setupZoomTracking(layerTex, channelOffset); + } + + setupZoomTracking(layerTex, channelOffset) { + const zoomCanvas = document.getElementById('zoomCanvas'); + if (!zoomCanvas) return; + + const { width, height } = this.image; + const zoomSize = 32; // Show 32x32 area + zoomCanvas.width = zoomSize; + zoomCanvas.height = zoomSize; + + // Add mousemove handlers to all layer canvases + for (let c = 0; c < 4; c++) { + const canvas = document.getElementById(`layerCanvas${c}`); + if (!canvas) continue; + + const updateZoom = (e) => { + const rect = canvas.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / rect.width * width); + const y = Math.floor((e.clientY - rect.top) / rect.height * height); + this.renderZoom(layerTex, channelOffset, x, y, zoomSize); + }; + + canvas.onmousemove = updateZoom; + canvas.onmouseenter = updateZoom; + } + } + + async renderZoom(layerTex, channelOffset, centerX, centerY, zoomSize) { + const zoomCanvas = document.getElementById('zoomCanvas'); + if (!zoomCanvas || !this.device) return; + + const ctx = zoomCanvas.getContext('webgpu'); + if (!ctx) return; + + try { + ctx.configure({ device: this.device, format: this.format }); + } catch (e) { + return; + } + + const halfSize = Math.floor(zoomSize / 2); + const { width, height } = this.image; + + // Create shader for zoomed view (samples 4 channels and displays as 2x2 grid) + const zoomShader = ` + @group(0) @binding(0) var layer_tex: texture_2d<u32>; + @group(0) @binding(1) var<uniform> params: vec4<f32>; // centerX, centerY, channelOffset, 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 dims = textureDimensions(layer_tex); + let centerX = i32(params.x); + let centerY = i32(params.y); + let channelOffset = u32(params.z); + let scale = params.w; + + // Map output pixel to source pixel + let halfSize = 16; + let localX = i32(pos.x) - halfSize; + let localY = i32(pos.y) - halfSize; + let srcX = clamp(centerX + localX, 0, i32(dims.x) - 1); + let srcY = clamp(centerY + localY, 0, i32(dims.y) - 1); + + let coord = vec2<i32>(srcX, srcY); + let packed = textureLoad(layer_tex, coord, 0); + let v0 = unpack2x16float(packed.x); + let v1 = unpack2x16float(packed.y); + let v2 = unpack2x16float(packed.z); + let v3 = unpack2x16float(packed.w); + + 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; + + // Determine which quadrant (channel) to show + let quadX = i32(pos.x) / 16; + let quadY = i32(pos.y) / 16; + let channelIdx = min(channelOffset + u32(quadY * 2 + quadX), 7u); + + let val = clamp(channels[channelIdx] * scale, 0.0, 1.0); + return vec4<f32>(val, val, val, 1.0); + } + `; + + if (!this.zoomPipeline) { + this.zoomPipeline = this.device.createRenderPipeline({ + layout: 'auto', + vertex: { + module: this.device.createShaderModule({ code: zoomShader }), + entryPoint: 'vs_main' + }, + fragment: { + module: this.device.createShaderModule({ code: zoomShader }), + entryPoint: 'fs_main', + targets: [{ format: this.format }] + } + }); + } + + const vizScale = channelOffset === 0 ? 1.0 : 0.5; + const paramsBuffer = this.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + const paramsData = new Float32Array([centerX, centerY, channelOffset, vizScale]); + this.device.queue.writeBuffer(paramsBuffer, 0, paramsData); + + const bindGroup = this.device.createBindGroup({ + layout: this.zoomPipeline.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', + storeOp: 'store' + }] + }); + + renderPass.setPipeline(this.zoomPipeline); + renderPass.setBindGroup(0, bindGroup); + renderPass.draw(6); + renderPass.end(); + + this.device.queue.submit([encoder.finish()]); } visualizeWeights(cnnLayerIdx) { - // cnnLayerIdx is index into weights.layers[] array (0-indexed) - // Display as "Layer N" matching codebase convention - // Example: weights.layers[0] = Layer 0, weights.layers[1] = Layer 1 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} 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] : '-'; - } + // Update button states + document.querySelectorAll('#weightsLayerButtons button').forEach(btn => btn.classList.remove('active')); + const btn = document.getElementById(`weightsBtn${cnnLayerIdx}`); + if (btn) btn.classList.add('active'); - // 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; - } + const { kernelSize, inChannels, outChannels, weightOffset, min, max } = layer; + this.log(`Visualizing Layer ${cnnLayerIdx} weights: ${inChannels}→${outChannels}, ${kernelSize}×${kernelSize}`); - // Get 2D context (canvases recreated by recreateCanvases()) - const ctx = canvas.getContext('2d', { willReadFrequently: false }); - if (!ctx) { - this.log(`Failed to get 2D context for canvas ${outCh}`, 'error'); - continue; - } + const canvas = document.getElementById('weightsCanvas'); + 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; + // 1 pixel per weight, show all input channels horizontally + const width = inChannels * kernelSize; + const height = outChannels * kernelSize; - canvas.width = gridWidth; - canvas.height = gridHeight; - this.log(`Canvas ${outCh}: ${gridWidth}×${gridHeight}`); + canvas.width = width; + canvas.height = height; - ctx.fillStyle = '#1a1a1a'; - ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(0, 0, width, height); - if (outCh >= outChannels) { - this.log(`Channel ${outCh} >= outChannels ${outChannels}, skipping`); - continue; - } + // Stack output channels vertically + for (let outCh = 0; outCh < outChannels; outCh++) { + const yOffset = outCh * kernelSize; - // Draw kernels for each input channel for (let inCh = 0; inCh < inChannels; inCh++) { - const xOffset = inCh * (kernelSize * cellSize + padding); + const xOffset = inCh * kernelSize; for (let ky = 0; ky < kernelSize; ky++) { for (let kx = 0; kx < kernelSize; kx++) { @@ -1329,23 +1451,17 @@ class CNNTester { spatialIdx; const weight = this.getWeightValue(wIdx); - const normalized = (weight - min) / (max - min); // [0, 1] + const normalized = (weight - min) / (max - min); const intensity = Math.floor(normalized * 255); ctx.fillStyle = `rgb(${intensity}, ${intensity}, ${intensity})`; - ctx.fillRect( - xOffset + kx * cellSize, - ky * cellSize, - cellSize - 1, - cellSize - 1 - ); + ctx.fillRect(xOffset + kx, yOffset + ky, 1, 1); } } } - this.log(`Rendered output channel ${outCh}`); } - this.log(`Weight visualization complete`); + this.log(`Rendered ${outChannels} output channels (${width}×${height}px)`); } getWeightValue(idx) { @@ -1424,6 +1540,17 @@ mainArea.addEventListener('drop', e => { // Weights drop zone setupDropZone('weightsDrop', f => tester.loadWeights(f)); +// Weights file input +document.getElementById('weightsFile').addEventListener('change', e => { + const file = e.target.files[0]; + if (file) { + tester.loadWeights(file).catch(err => { + tester.setStatus(err.message, true); + tester.log(err.message, 'error'); + }); + } +}); + document.getElementById('blend').addEventListener('input', e => { tester.blendAmount = parseFloat(e.target.value); document.getElementById('blendValue').textContent = e.target.value; |
