diff options
Diffstat (limited to 'cnn_v2/tools/cnn_v2_test')
| -rw-r--r-- | cnn_v2/tools/cnn_v2_test/README.md | 251 | ||||
| -rw-r--r-- | cnn_v2/tools/cnn_v2_test/index.html | 2014 |
2 files changed, 2265 insertions, 0 deletions
diff --git a/cnn_v2/tools/cnn_v2_test/README.md b/cnn_v2/tools/cnn_v2_test/README.md new file mode 100644 index 0000000..d41a00f --- /dev/null +++ b/cnn_v2/tools/cnn_v2_test/README.md @@ -0,0 +1,251 @@ +# CNN v2 Testing Tool + +WebGPU-based browser tool for testing trained CNN v2 weights. + +--- + +## Features + +- 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 + +--- + +## Requirements + +- Browser with WebGPU support: + - Chrome/Edge 113+ (enable `chrome://flags/#enable-unsafe-webgpu` if needed) + - Safari 18+ (macOS Ventura+) +- Trained CNN v2 weights in binary format (`.bin`) +- Test images (PNG format) + +--- + +## Usage + +### 1. Open Tool + +```bash +open tools/cnn_v2_test/index.html +``` + +Or use a local server to avoid CORS: +```bash +python3 -m http.server 8000 +# Open http://localhost:8000/tools/cnn_v2_test/ +``` + +### 2. Load Data + +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. Layout + +**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) + +**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) + +**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 + +**Footer:** +- Status: GPU timing (ms), image dimensions, view mode +- Console: Timestamped event log (file loads, errors) + +--- + +## Preparing Test Data + +### Export Weights + +```bash +# From trained checkpoint +./training/export_cnn_v2_weights.py \ + checkpoints/checkpoint_epoch_100.pth \ + --output-weights tools/cnn_v2_test/test_weights.bin +``` + +Binary format: 16-byte header + 20 bytes per layer + f16 weights (~3.2 KB for 3-layer model) + +### Test Images + +Use training images or any PNG: +```bash +# Copy test image +cp training/input/test.png tools/cnn_v2_test/ +``` + +**Note:** Grayscale images automatically converted to RGB. + +--- + +## Validation + +### Visual Comparison + +Compare browser output with C++ tool: + +```bash +# Generate C++ output +./build/cnn_test training/input/test.png /tmp/cpp_output.png + +# Load same image in browser tool +# Visually compare outputs +``` + +### GPU Timing + +Expected performance: +- 512×512: ~1-2 ms (integrated GPU) +- 1024×1024: ~3-5 ms +- 1920×1080: ~5-8 ms + +Slower than expected? Check: +- WebGPU enabled in browser +- Dedicated GPU selected (if available) +- No background tabs consuming GPU + +--- + +## Troubleshooting + +### "WebGPU not supported" + +- Update browser to latest version +- Enable WebGPU flag: `chrome://flags/#enable-unsafe-webgpu` +- Try Safari 18+ (native WebGPU on macOS) + +### "Invalid .bin file" + +- Check magic number: `hexdump -C weights.bin | head` +- Should start with: `43 4e 4e 32` ('CNN2') +- Re-export weights: `./training/export_cnn_v2_weights.py` + +### Black output / incorrect colors + +- Check blend slider (set to 1.0 for full CNN output) +- Verify training converged (loss < 0.01) +- Compare with C++ tool output + +### Shader compilation errors + +Open browser console (F12) for detailed errors. Common issues: +- Image too large (>4096×4096 not tested) +- Unsupported texture format (rare on modern GPUs) + +--- + +## Architecture + +**Pipeline:** +1. **Static Features Pass** - Generate 8D features (RGBD, UV, sin, bias) +2. **CNN Layer Passes** - Compute N layers with ping-pong textures +3. **Display Pass** - Unpack and render with view mode + +**Textures:** +- Input: RGBA8 (original image) +- Depth: R32F (uniform depth) +- Static features: RGBA32Uint (8×f16 packed) +- Layer buffers: RGBA32Uint (ping-pong) + +**Data-Driven Execution:** +- Layer count read from binary header +- Per-layer params (kernel size, channels, offsets) from binary +- Single CNN shader dispatched N times + +--- + +## 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 + +**✓ 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 + +**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 + +--- + +## Extensions (v2+) + +Planned enhancements: + +**Variable Feature Count:** +- Binary v2: Add `num_features` to header +- Shader: Dynamic feature array or multiple textures + +**Multi-Scale Input (Mip Levels):** +- Uncomment mip bindings in static shader +- No binary format change needed + +**8-bit Quantized Weights:** +- Binary version bump (format field already present) +- Add quantization codepath in `get_weight()` function +- 2× size reduction (~1.6 KB) + +**Pre-defined Test Images:** +- Dropdown menu with training/input/*.png +- Requires local file server + +--- + +## Size + +- 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 +- Layer viz shader: ~2 KB +- Zoom shader: ~1 KB +- **Total: ~22 KB** (single file, no dependencies) + +--- + +## See Also + +- `doc/CNN_V2.md` - Architecture and design +- `doc/HOWTO.md` - Training workflows +- `training/export_cnn_v2_weights.py` - Binary format +- `src/effects/cnn_v2_effect.cc` - C++ reference implementation diff --git a/cnn_v2/tools/cnn_v2_test/index.html b/cnn_v2/tools/cnn_v2_test/index.html new file mode 100644 index 0000000..84702d5 --- /dev/null +++ b/cnn_v2/tools/cnn_v2_test/index.html @@ -0,0 +1,2014 @@ +<!DOCTYPE html> +<html lang="en"> +<!-- + CNN v2 Testing Tool - WebGPU-based inference validator + + Architecture: + - Static features (8D): p0-p3 (parametric), uv_x, uv_y, sin(10*uv_x), bias (NOT a CNN layer) + - Layer 0: input RGBD (4D) + static (8D) = 12D → 4 channels + - Layer 1+: previous layer (4D) + static (8D) = 12D → 4 channels + - All CNN layers: uniform 12D input, 4D output (ping-pong buffer) + + Naming convention (matches train_cnn_v2.py / .wgsl / .cc): + - UI shows: "Static 0-3", "Static 4-7", "Layer 0", "Layer 1", "Layer 2" + - weights.layers[] array: Layer 0 = weights.layers[0], Layer 1 = weights.layers[1] + + Features: + - Input: PNG images or video files (MP4, WebM, etc.) + - Video playback: Play/Pause, frame-by-frame navigation (◄/► buttons) + - Video mode: Non-realtime processing (drops frames if CNN slower than playback) + - 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) + - Optimization: Layer viz updates only on pause/seek during video playback + + WGSL Shader Reuse: + - CNN_SHADER (inference), STATIC_SHADER, LAYER_VIZ_SHADER are inline for single-file deployment + - Can extract to .wgsl files for: better IDE support, testing, cross-tool reuse + - Tradeoff: extraction needs fetch() or build step, breaks single-file portability + - C++ sync: manual (WGSL ≠ GLSL) but logic identical +--> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>CNN v2 Testing Tool</title> + <link rel="stylesheet" href="../common/style.css"> + <style> + body { + display: flex; + flex-direction: column; + height: 100vh; + } + .header { + padding: 16px; + border-bottom: 1px solid #404040; + gap: 24px; + } + h1 { font-size: 18px; } + .controls { + gap: 16px; + } + .control-group { + display: flex; + gap: 8px; + align-items: center; + } + .control-group label { font-size: 12px; } + input[type="range"] { width: 120px; } + input[type="number"] { width: 60px; padding: 4px; } + .drop-zone { + border: 3px dashed #606060; + padding: 20px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + font-size: 13px; + font-weight: bold; + background: #252525; + border-radius: 6px; + color: #4a9eff; + } + button { + padding: 6px 12px; + font-size: 12px; + } + button:hover { border-color: #606060; background: #252525; } + video { display: none; } + .drop-zone:hover { border-color: #4a9eff; background: #2a3545; } + .drop-zone.active { border-color: #4a9eff; background: #1a2a3a; } + .drop-zone.error { border-color: #ff4a4a; background: #3a1a1a; } + .content { + flex: 1; + display: flex; + overflow: hidden; + gap: 1px; + background: #404040; + } + .left-sidebar { + width: 315px; + background: #2a2a2a; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + } + .main { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + padding: 24px; + overflow: auto; + position: relative; + } + .video-controls-float { + position: absolute; + top: 16px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 8px; + background: rgba(42, 42, 42, 0.95); + padding: 8px 12px; + border-radius: 4px; + border: 1px solid #404040; + z-index: 100; + } + .bottom-controls-float { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 16px; + align-items: center; + background: rgba(42, 42, 42, 0.95); + padding: 8px 16px; + border-radius: 4px; + border: 1px solid #404040; + z-index: 100; + } + .bottom-controls-float .control-group { + display: flex; + gap: 8px; + align-items: center; + } + .bottom-controls-float #videoControls { + display: flex; + gap: 8px; + align-items: center; + padding-right: 16px; + border-right: 1px solid #404040; + } + .main.drop-active::after { + content: 'Drop PNG/video here'; + position: absolute; + inset: 24px; + display: flex; + align-items: center; + justify-content: center; + border: 3px dashed #4a9eff; + background: rgba(74, 158, 255, 0.1); + font-size: 24px; + color: #4a9eff; + pointer-events: none; + z-index: 10; + } + .sidebar { + width: 400px; + background: #2a2a2a; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + } + .panel { + 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 { + padding: 6px 12px; + font-size: 10px; + } + .layer-buttons button.active { + background: #4a9eff; + border-color: #4a9eff; + color: #1a1a1a; + } + .layer-buttons button:disabled:hover { + border-color: #404040; + background: #1a1a1a; + } + .layer-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px; + margin-bottom: 12px; + } + .layer-view { + aspect-ratio: 1; + background: #1a1a1a; + border: 1px solid #404040; + display: flex; + flex-direction: column; + overflow: hidden; + } + .layer-preview { + background: #1a1a1a; + border: 1px solid #404040; + display: flex; + flex-direction: column; + overflow: hidden; + margin-top: 8px; + } + .layer-preview canvas { + width: 100%; + height: 100%; + image-rendering: pixelated; + } + .layer-view.active { + border: 2px solid #ffffff; + } + .layer-view canvas { + cursor: pointer; + } + .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%; + image-rendering: pixelated; + box-shadow: 0 4px 12px rgba(0,0,0,0.5); + } + .footer { + background: #2a2a2a; + border-top: 1px solid #404040; + font-size: 11px; + display: flex; + flex-direction: column; + gap: 8px; + } + .footer-top { + padding: 12px 16px 0; + display: flex; + justify-content: space-between; + } + .status { color: #4a9eff; } + .shortcuts { color: #808080; } + .console { + background: #1a1a1a; + padding: 8px 16px; + font-family: 'Courier New', monospace; + font-size: 10px; + color: #808080; + max-height: 100px; + overflow-y: auto; + border-top: 1px solid #404040; + } + .console-line { margin: 2px 0; } + .console-line.error { color: #ff4a4a; } + .console-line.info { color: #4a9eff; } + </style> +</head> +<body> + <div class="header"> + <h1>CNN v2 Testing Tool</h1> + </div> + <video id="videoSource" muted loop></video> + <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 class="panel"> + <div class="panel-content"> + <label for="mipLevel" style="font-size: 11px;">Mip Level:</label> + <select id="mipLevel" style="width: 100%; background: #1a1a1a; color: #e0e0e0; border: 1px solid #404040; padding: 4px; margin-top: 4px;"> + <option value="0">Mip 0 (original)</option> + <option value="1">Mip 1 (half res)</option> + <option value="2">Mip 2 (quarter res)</option> + </select> + </div> + </div> + </div> + <div class="main" id="mainDrop"> + <div class="bottom-controls-float"> + <div id="videoControls"> + <button id="playPauseBtn" disabled>Play</button> + <button id="stepBackBtn" disabled>◄ Frame</button> + <button id="stepForwardBtn" disabled>Frame ►</button> + </div> + <div class="control-group"> + <label>Blend:</label> + <input type="range" id="blend" min="0" max="1" step="0.01" value="1.0"> + <span id="blendValue">1.0</span> + </div> + <div class="control-group"> + <label>Depth:</label> + <input type="range" id="depth" min="0" max="1" step="0.01" value="1.0"> + <span id="depthValue">1.0</span> + </div> + <button id="savePngBtn">Save PNG</button> + </div> + <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: hidden;"> + <p style="color: #808080; text-align: center;">Load image + weights</p> + </div> + </div> + </div> + </div> + <div class="footer"> + <div class="footer-top"> + <span class="status" id="status">Drop PNG/video anywhere to begin</span> + <span class="shortcuts">[SPACE] Original | [D] Diff (×10)</span> + </div> + <div class="console" id="console"></div> + </div> + + <script> +// ============================================================================ +// EMBEDDED WEIGHTS & CONSTANTS +// ============================================================================ + +// Default pre-trained weights (base64-encoded binary format) +// Version 2: 4 layers (3×3, 5×5, 3×3, 3×3), 2496 f16 weights, mip_level=2 +const DEFAULT_WEIGHTS_B64 = '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'; + +// Reusable fullscreen quad vertex shader (2 triangles covering NDC) +const FULLSCREEN_QUAD_VS = ` +@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); +}`; + +// ============================================================================ +// WGSL SHADERS +// ============================================================================ + +// Static features: 7D parametric features (RGBD + UV + sin(10*uv_x) + bias) +const STATIC_SHADER = ` +@group(0) @binding(0) var input_tex: texture_2d<f32>; +@group(0) @binding(1) var linear_sampler: sampler; +@group(0) @binding(2) var depth_tex: texture_2d<f32>; +@group(0) @binding(3) var output_tex: texture_storage_2d<rgba32uint, write>; +@group(0) @binding(4) var<uniform> mip_level: u32; + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) id: vec3<u32>) { + let coord = vec2<i32>(id.xy); + let dims = textureDimensions(input_tex); + if (coord.x >= i32(dims.x) || coord.y >= i32(dims.y)) { return; } + + // Use normalized UV coords with linear sampler (bilinear filtering) + let uv = (vec2<f32>(coord) + 0.5) / vec2<f32>(dims); + let rgba = textureSampleLevel(input_tex, linear_sampler, uv, f32(mip_level)); + + let p0 = rgba.r; + let p1 = rgba.g; + let p2 = rgba.b; + let p3 = textureLoad(depth_tex, coord, 0).r; + + let uv_x = f32(coord.x) / f32(dims.x); + let uv_y = f32(coord.y) / f32(dims.y); + let sin20_y = sin(20.0 * uv_y); + let bias = 1.0; + + let packed = vec4<u32>( + pack2x16float(vec2<f32>(p0, p1)), + pack2x16float(vec2<f32>(p2, p3)), + pack2x16float(vec2<f32>(uv_x, uv_y)), + pack2x16float(vec2<f32>(sin20_y, bias)) + ); + textureStore(output_tex, coord, packed); +}`; + +const CNN_SHADER = ` +struct LayerParams { + kernel_size: u32, + in_channels: u32, + out_channels: u32, + weight_offset: u32, + is_output_layer: u32, + blend_amount: f32, + is_layer_0: u32, +} + +@group(0) @binding(0) var static_features: texture_2d<u32>; +@group(0) @binding(1) var layer_input: texture_2d<u32>; +@group(0) @binding(2) var output_tex: texture_storage_2d<rgba32uint, write>; +@group(0) @binding(3) var<storage, read> weights_buffer: array<u32>; +@group(0) @binding(4) var<uniform> params: LayerParams; +@group(0) @binding(5) var original_input: texture_2d<f32>; + +fn unpack_static_features(coord: vec2<i32>) -> array<f32, 8> { + let packed = textureLoad(static_features, coord, 0); + let v0 = unpack2x16float(packed.x); + let v1 = unpack2x16float(packed.y); + let v2 = unpack2x16float(packed.z); + let v3 = unpack2x16float(packed.w); + return array<f32, 8>(v0.x, v0.y, v1.x, v1.y, v2.x, v2.y, v3.x, v3.y); +} + +fn unpack_layer_channels(coord: vec2<i32>) -> vec4<f32> { + let packed = textureLoad(layer_input, coord, 0); + let v0 = unpack2x16float(packed.x); + let v1 = unpack2x16float(packed.y); + return vec4<f32>(v0.x, v0.y, v1.x, v1.y); +} + +fn pack_channels(values: vec4<f32>) -> vec4<u32> { + return vec4<u32>( + pack2x16float(vec2<f32>(values.x, values.y)), + pack2x16float(vec2<f32>(values.z, values.w)), + 0u, + 0u + ); +} + +fn get_weight(idx: u32) -> f32 { + let pair_idx = idx / 2u; + let packed = weights_buffer[pair_idx]; + let unpacked = unpack2x16float(packed); + return select(unpacked.y, unpacked.x, (idx & 1u) == 0u); +} + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) id: vec3<u32>) { + let coord = vec2<i32>(id.xy); + let dims = textureDimensions(static_features); + if (coord.x >= i32(dims.x) || coord.y >= i32(dims.y)) { return; } + + let kernel_size = params.kernel_size; + let in_channels = params.in_channels; // Always 12 (4 prev + 8 static) + let out_channels = params.out_channels; // Always 4 + let weight_offset = params.weight_offset; + let is_output = params.is_output_layer != 0u; + let kernel_radius = i32(kernel_size / 2u); + + let static_feat = unpack_static_features(coord); + + var output: vec4<f32> = vec4<f32>(0.0); + for (var c: u32 = 0u; c < 4u; c++) { + var sum: f32 = 0.0; + for (var ky: i32 = -kernel_radius; ky <= kernel_radius; ky++) { + for (var kx: i32 = -kernel_radius; kx <= kernel_radius; kx++) { + let sample_coord = coord + vec2<i32>(kx, ky); + let clamped = vec2<i32>( + clamp(sample_coord.x, 0, i32(dims.x) - 1), + clamp(sample_coord.y, 0, i32(dims.y) - 1) + ); + let static_local = unpack_static_features(clamped); + let layer_local = unpack_layer_channels(clamped); + + let ky_idx = u32(ky + kernel_radius); + let kx_idx = u32(kx + kernel_radius); + let spatial_idx = ky_idx * kernel_size + kx_idx; + + // Previous layer channels (4D) + for (var i: u32 = 0u; i < 4u; i++) { + let w_idx = weight_offset + + c * in_channels * kernel_size * kernel_size + + i * kernel_size * kernel_size + spatial_idx; + sum += get_weight(w_idx) * layer_local[i]; + } + + // Static features (8D) + for (var i: u32 = 0u; i < 8u; i++) { + let w_idx = weight_offset + + c * in_channels * kernel_size * kernel_size + + (4u + i) * kernel_size * kernel_size + spatial_idx; + sum += get_weight(w_idx) * static_local[i]; + } + } + } + + if (is_output || params.is_layer_0 != 0u) { + output[c] = 1.0 / (1.0 + exp(-sum)); // Sigmoid [0,1] + } else { + output[c] = max(0.0, sum); // ReLU + } + } + + if (is_output) { + let original = textureLoad(original_input, coord, 0).rgb; + let result_rgb = vec3<f32>(output.x, output.y, output.z); + let blended = mix(original, result_rgb, params.blend_amount); + output.x = blended.r; + output.y = blended.g; + output.z = blended.b; + } + + textureStore(output_tex, coord, pack_channels(output)); +}`; + +const DISPLAY_SHADER = ` +@group(0) @binding(0) var result_tex: texture_2d<u32>; +@group(0) @binding(1) var original_tex: texture_2d<f32>; +@group(0) @binding(2) var<uniform> mode: u32; + +@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 packed = textureLoad(result_tex, coord, 0); + let v0 = unpack2x16float(packed.x); + let v1 = unpack2x16float(packed.y); + let result = vec3<f32>(v0.x, v0.y, v1.x); + + if (mode == 0u) { + return vec4<f32>(result, 1.0); + } else if (mode == 1u) { + let original = textureLoad(original_tex, coord, 0).rgb; + return vec4<f32>(original, 1.0); + } else { + let original = textureLoad(original_tex, coord, 0).rgb; + let diff = abs(result - original) * 10.0; + return vec4<f32>(diff, 1.0); + } +}`; + +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'); + this.status = document.getElementById('status'); + this.console = document.getElementById('console'); + this.image = null; + this.video = document.getElementById('videoSource'); + this.weights = null; + this.viewMode = 0; + this.blendAmount = 1.0; + this.depth = 1.0; + this.currentLayerIdx = null; + this.currentChannelOffset = null; + this.isVideo = false; + this.fps = 30; + this.isProcessing = false; + this.mipLevel = 0; + this.selectedChannel = 0; + this.init(); + } + + log(msg, type = 'info') { + const line = document.createElement('div'); + line.className = `console-line ${type}`; + line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`; + this.console.appendChild(line); + this.console.scrollTop = this.console.scrollHeight; + } + + async init() { + if (!navigator.gpu) { + this.setStatus('WebGPU not supported', true); + this.log('WebGPU not supported in this browser', 'error'); + return; + } + + try { + this.adapter = await navigator.gpu.requestAdapter(); + this.device = await this.adapter.requestDevice(); + this.context = this.canvas.getContext('webgpu'); + this.format = navigator.gpu.getPreferredCanvasFormat(); + this.log('WebGPU initialized successfully'); + } catch (e) { + this.setStatus(`GPU init failed: ${e.message}`, true); + this.log(`GPU initialization failed: ${e.message}`, 'error'); + } + } + + setStatus(msg, isError = false) { + this.status.textContent = msg; + this.status.style.color = isError ? '#ff4a4a' : '#4a9eff'; + } + + // Get current source dimensions (video or image) + getDimensions() { + if (this.isVideo) { + return { width: this.video.videoWidth, height: this.video.videoHeight }; + } + return { width: this.image.width, height: this.image.height }; + } + + // Enable/disable video playback controls + setVideoControlsEnabled(enabled) { + ['playPauseBtn', 'stepBackBtn', 'stepForwardBtn'].forEach(id => + document.getElementById(id).disabled = !enabled + ); + } + + parseWeights(buffer) { + const view = new DataView(buffer); + const magic = view.getUint32(0, true); + if (magic !== 0x32_4E_4E_43) { + throw new Error('Invalid .bin file (bad magic)'); + } + + const version = view.getUint32(4, true); + const numLayers = view.getUint32(8, true); + const totalWeights = view.getUint32(12, true); + + // Version 2: added mip_level field (20-byte header) + let mipLevel = 0; + let headerSize = 16; + if (version === 2) { + mipLevel = view.getUint32(16, true); + headerSize = 20; + this.log(`Binary header: version=${version}, layers=${numLayers}, weights=${totalWeights}, mip_level=${mipLevel}`); + } else if (version === 1) { + this.log(`Binary header: version=${version}, layers=${numLayers}, weights=${totalWeights}`); + } else { + throw new Error(`Unsupported binary version: ${version}`); + } + + const layers = []; + for (let i = 0; i < numLayers; i++) { + const offset = headerSize + i * 20; + const layer = { + kernelSize: view.getUint32(offset, true), + inChannels: view.getUint32(offset + 4, true), + outChannels: view.getUint32(offset + 8, true), + weightOffset: view.getUint32(offset + 12, true), + weightCount: view.getUint32(offset + 16, true), + }; + layers.push(layer); + this.log(` Layer ${i}: ${layer.inChannels}→${layer.outChannels}, kernel=${layer.kernelSize}×${layer.kernelSize}, weights=${layer.weightCount}`); + } + + const weightsOffset = headerSize + numLayers * 20; + const weights = new Uint32Array(buffer.slice(weightsOffset)); + + // 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 { version, layers, weights, mipLevel, 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) { + const img = await createImageBitmap(file); + this.image = img; + this.isVideo = false; + this.canvas.width = img.width; + this.canvas.height = img.height; + this.setVideoControlsEnabled(false); + this.log(`Loaded image: ${file.name} (${img.width}×${img.height})`); + if (this.weights) { + this.setStatus(`Ready: ${img.width}×${img.height}`); + this.run(); + } else { + this.setStatus(`Image loaded (${img.width}×${img.height}) - drop .bin weights to process`); + this.displayOriginal(); + } + } + + // Video loading: wait for metadata, then first frame decode (readyState≥2) + async loadVideo(file) { + return new Promise((resolve, reject) => { + this.video.src = URL.createObjectURL(file); + + this.video.onloadedmetadata = () => { + const w = this.video.videoWidth; + const h = this.video.videoHeight; + if (w === 0 || h === 0) { + reject(new Error('Video has invalid dimensions')); + return; + } + + this.isVideo = true; + this.canvas.width = w; + this.canvas.height = h; + this.fps = 30; + this.log(`Loaded video: ${file.name} (${w}×${h}, ${this.video.duration.toFixed(1)}s)`); + this.setVideoControlsEnabled(true); + + // Set up event handlers + this.video.onpause = () => { document.getElementById('playPauseBtn').textContent = 'Play'; }; + this.video.onplay = () => { document.getElementById('playPauseBtn').textContent = 'Pause'; this.playbackLoop(); }; + + // Wait for first frame to be decoded before displaying + const displayFirstFrame = () => { + this.video.onseeked = () => { if (!this.isProcessing) this.processVideoFrame(); }; + if (this.video.readyState >= 2) { // HAVE_CURRENT_DATA or better + if (this.weights) { + this.setStatus(`Ready: ${w}×${h}`); + this.processVideoFrame().then(() => resolve()); + } else { + this.setStatus(`Video loaded - drop .bin weights to process`); + this.displayOriginal(); + resolve(); + } + } else { + setTimeout(displayFirstFrame, 50); // Poll until frame ready + } + }; + + this.video.onseeked = displayFirstFrame; + this.video.currentTime = 0; + }; + + this.video.onerror = () => reject(new Error('Failed to load video')); + }); + } + + // Video playback loop (non-realtime, drops frames if CNN slow) + playbackLoop() { + if (this.video.paused || this.video.ended) return; + if (!this.isProcessing) this.processVideoFrame(); + requestAnimationFrame(() => this.playbackLoop()); + } + + // Process current video frame through CNN pipeline + async processVideoFrame() { + if (!this.weights || this.isProcessing) return; + this.isProcessing = true; + await this.run(); + this.isProcessing = false; + } + + // Video controls + togglePlayPause() { + this.video.paused ? this.video.play() : this.video.pause(); + } + + stepFrame(direction) { + if (!this.isVideo) return; + this.video.pause(); + this.video.currentTime = Math.max(0, Math.min(this.video.duration, + this.video.currentTime + direction / this.fps)); + } + + async loadWeights(file) { + const buffer = await file.arrayBuffer(); + this.weights = this.parseWeights(buffer); + this.weightsBuffer = buffer; + this.mipLevel = this.weights.mipLevel; // Set mip level from binary format + this.log(`Loaded weights: ${file.name} (${this.weights.layers.length} layers, ${(buffer.byteLength/1024).toFixed(1)} KB)`); + + // Update UI dropdown to reflect loaded mip level + const mipLevelSelect = document.getElementById('mipLevel'); + if (mipLevelSelect) { + mipLevelSelect.value = this.mipLevel.toString(); + } + + this.updateWeightsPanel(); + if (this.image) { + this.setStatus(`Ready: ${this.image.width}×${this.image.height}`); + this.run(); + } else { + this.setStatus('Weights loaded - drop PNG image to process'); + } + } + + updateWeightsPanel() { + const panel = document.getElementById('weightsInfo'); + const { version, layers, mipLevel, fileSize } = this.weights; + + let html = ` + <div style="margin-bottom: 12px;"> + <div><strong>File Size:</strong> ${(fileSize / 1024).toFixed(2)} KB</div> + <div><strong>Version:</strong> ${version}</div> + <div><strong>CNN Layers:</strong> ${layers.length}</div> + <div><strong>Mip Level:</strong> ${mipLevel} (p0-p3 features)</div> + <div style="font-size: 9px; color: #808080; margin-top: 4px;">Static features (input) + ${layers.length} conv layers</div> + </div> + <table> + <thead> + <tr> + <th>Layer</th> + <th>Size</th> + <th>Weights</th> + <th>Min</th> + <th>Max</th> + </tr> + </thead> + <tbody> + `; + + // Display layers as "Layer 0", "Layer 1", etc. (matching codebase convention) + for (let i = 0; i < layers.length; i++) { + const l = layers[i]; + html += ` + <tr> + <td>Layer ${i}</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; + + // 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); + } + + generateMipmaps(texture, width, height) { + if (!this.mipmapPipeline) { + const mipmapShader = FULLSCREEN_QUAD_VS + ` + @group(0) @binding(0) var src: texture_2d<f32>; + @fragment + fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> { + let coord = vec2<i32>(i32(pos.x) * 2, i32(pos.y) * 2); + var sum = vec4<f32>(0.0); + for (var y: i32 = 0; y < 2; y++) { + for (var x: i32 = 0; x < 2; x++) { + sum += textureLoad(src, coord + vec2<i32>(x, y), 0); + } + } + return sum * 0.25; + } + `; + this.mipmapPipeline = this.device.createRenderPipeline({ + layout: 'auto', + vertex: { module: this.device.createShaderModule({ code: mipmapShader }), entryPoint: 'vs_main' }, + fragment: { + module: this.device.createShaderModule({ code: mipmapShader }), + entryPoint: 'fs_main', + targets: [{ format: 'rgba8unorm' }] + } + }); + } + + const encoder = this.device.createCommandEncoder(); + + for (let mip = 1; mip < 3; mip++) { + const mipWidth = Math.max(1, width >> mip); + const mipHeight = Math.max(1, height >> mip); + + const bindGroup = this.device.createBindGroup({ + layout: this.mipmapPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: texture.createView({ baseMipLevel: mip - 1, mipLevelCount: 1 }) } + ] + }); + + const renderPass = encoder.beginRenderPass({ + colorAttachments: [{ + view: texture.createView({ baseMipLevel: mip, mipLevelCount: 1 }), + loadOp: 'clear', + storeOp: 'store' + }] + }); + + renderPass.setPipeline(this.mipmapPipeline); + renderPass.setBindGroup(0, bindGroup); + renderPass.setViewport(0, 0, mipWidth, mipHeight, 0, 1); + renderPass.draw(6); + renderPass.end(); + } + + this.device.queue.submit([encoder.finish()]); + } + + displayOriginal() { + const source = this.isVideo ? this.video : this.image; + if (!source || !this.device) return; + + const { width, height } = this.getDimensions(); + this.context.configure({ device: this.device, format: this.format }); + + const inputTex = this.device.createTexture({ + size: [width, height], + format: 'rgba8unorm', + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT + }); + + this.device.queue.copyExternalImageToTexture( + { source: source }, + { texture: inputTex }, + [width, height] + ); + + const simpleShader = FULLSCREEN_QUAD_VS + ` + @group(0) @binding(0) var tex: texture_2d<f32>; + @fragment + fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> { + let coord = vec2<i32>(pos.xy); + return textureLoad(tex, coord, 0); + } + `; + + const pipeline = this.device.createRenderPipeline({ + layout: 'auto', + vertex: { module: this.device.createShaderModule({ code: simpleShader }), entryPoint: 'vs_main' }, + fragment: { + module: this.device.createShaderModule({ code: simpleShader }), + entryPoint: 'fs_main', + targets: [{ format: this.format }] + } + }); + + const bindGroup = this.device.createBindGroup({ + layout: pipeline.getBindGroupLayout(0), + entries: [{ binding: 0, resource: inputTex.createView() }] + }); + + const encoder = this.device.createCommandEncoder(); + const renderPass = encoder.beginRenderPass({ + colorAttachments: [{ + view: this.context.getCurrentTexture().createView(), + loadOp: 'clear', + storeOp: 'store' + }] + }); + renderPass.setPipeline(pipeline); + renderPass.setBindGroup(0, bindGroup); + renderPass.draw(6); + renderPass.end(); + + this.device.queue.submit([encoder.finish()]); + } + + // Run CNN inference pipeline on current source (image or video frame) + async run() { + const t0 = performance.now(); + const source = this.isVideo ? this.video : this.image; + if (!source) return; + const { width, height } = this.getDimensions(); + + this.context.configure({ device: this.device, format: this.format }); + + // Create persistent input texture for original view with mipmaps + if (this.inputTexture) this.inputTexture.destroy(); + this.inputTexture = this.device.createTexture({ + size: [width, height], + format: 'rgba8unorm', + mipLevelCount: 3, + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT + }); + + this.device.queue.copyExternalImageToTexture( + { source: source }, + { texture: this.inputTexture, mipLevel: 0 }, + [width, height] + ); + + // Generate mipmaps + this.generateMipmaps(this.inputTexture, width, height); + + const staticTex = this.device.createTexture({ + size: [width, height], + format: 'rgba32uint', + usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC + }); + + // 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 | GPUTextureUsage.COPY_SRC + }), + this.device.createTexture({ + size: [width, height], + format: 'rgba32uint', + usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC + }) + ]; + + const weightsGPU = this.device.createBuffer({ + size: this.weightsBuffer.byteLength, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST + }); + this.device.queue.writeBuffer(weightsGPU, 0, this.weightsBuffer); + const staticPipeline = this.device.createComputePipeline({ + layout: 'auto', + compute: { module: this.device.createShaderModule({ code: STATIC_SHADER }), entryPoint: 'main' } + }); + + const cnnPipeline = this.device.createComputePipeline({ + layout: 'auto', + compute: { module: this.device.createShaderModule({ code: CNN_SHADER }), entryPoint: 'main' } + }); + + const displayPipeline = this.device.createRenderPipeline({ + layout: 'auto', + vertex: { module: this.device.createShaderModule({ code: DISPLAY_SHADER }), entryPoint: 'vs_main' }, + fragment: { + module: this.device.createShaderModule({ code: DISPLAY_SHADER }), + entryPoint: 'fs_main', + targets: [{ format: this.format }] + } + }); + + const encoder = this.device.createCommandEncoder(); + + const mipLevelBuffer = this.device.createBuffer({ + size: 4, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + this.device.queue.writeBuffer(mipLevelBuffer, 0, new Uint32Array([this.mipLevel])); + + if (!this.pointSampler) { + this.pointSampler = this.device.createSampler({ + magFilter: 'linear', + minFilter: 'linear', + mipmapFilter: 'linear' + }); + } + + // Extract depth from alpha channel (or 1.0 if no alpha) + const depthTex = this.device.createTexture({ + size: [width, height, 1], + format: 'r32float', + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST + }); + + // Read image data to extract alpha channel + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = width; + tempCanvas.height = height; + const tempCtx = tempCanvas.getContext('2d'); + tempCtx.drawImage(source, 0, 0, width, height); + const imageData = tempCtx.getImageData(0, 0, width, height); + const pixels = imageData.data; + + // Extract alpha channel (RGBA format: every 4th byte) + const depthData = new Float32Array(width * height); + for (let i = 0; i < width * height; i++) { + depthData[i] = pixels[i * 4 + 3] / 255.0; // Alpha channel [0, 255] → [0, 1] + } + + this.device.queue.writeTexture( + { texture: depthTex }, + depthData, + { bytesPerRow: width * 4 }, + [width, height, 1] + ); + + const staticBG = this.device.createBindGroup({ + layout: staticPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: this.inputTexture.createView() }, + { binding: 1, resource: this.pointSampler }, + { binding: 2, resource: depthTex.createView() }, // Depth from alpha (matches training) + { binding: 3, resource: staticTex.createView() }, + { binding: 4, resource: { buffer: mipLevelBuffer } } + ] + }); + + const staticPass = encoder.beginComputePass(); + staticPass.setPipeline(staticPipeline); + staticPass.setBindGroup(0, staticBG); + staticPass.dispatchWorkgroups(Math.ceil(width / 8), Math.ceil(height / 8)); + staticPass.end(); + + // Copy static features to persistent storage (visualization index 0, shown as Static 0-3 / Static 4-7) + encoder.copyTextureToTexture( + { texture: staticTex }, + { texture: layerTextures[0] }, + [width, height] + ); + this.layerOutputs.push(layerTextures[0]); + + let srcTex = staticTex; + let dstTex = computeTextures[0]; + + for (let i = 0; i < this.weights.layers.length; i++) { + const layer = this.weights.layers[i]; + const isOutput = i === this.weights.layers.length - 1; + + // Calculate absolute weight offset in f16 units (add header offset) + // Version 1: 4 u32 header, Version 2: 5 u32 header + const headerSizeU32 = (this.weights.version === 1) ? 4 : 5; + const headerOffsetU32 = headerSizeU32 + this.weights.layers.length * 5; // Header + layer info in u32 + const absoluteWeightOffset = headerOffsetU32 * 2 + layer.weightOffset; // Convert to f16 units + + const paramsData = new Uint32Array(7); + paramsData[0] = layer.kernelSize; + paramsData[1] = layer.inChannels; + paramsData[2] = layer.outChannels; + paramsData[3] = absoluteWeightOffset; // Use absolute offset + paramsData[4] = isOutput ? 1 : 0; + paramsData[6] = (i === 0) ? 1 : 0; // is_layer_0 flag + + const paramsView = new Float32Array(paramsData.buffer); + paramsView[5] = this.blendAmount; + + const paramsBuffer = this.device.createBuffer({ + size: 28, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + this.device.queue.writeBuffer(paramsBuffer, 0, paramsData); + + const cnnBG = this.device.createBindGroup({ + layout: cnnPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: layerTextures[0].createView() }, + { binding: 1, resource: srcTex.createView() }, + { binding: 2, resource: dstTex.createView() }, + { binding: 3, resource: { buffer: weightsGPU } }, + { binding: 4, resource: { buffer: paramsBuffer } }, + { binding: 5, resource: this.inputTexture.createView() } + ] + }); + + const cnnPass = encoder.beginComputePass(); + cnnPass.setPipeline(cnnPipeline); + cnnPass.setBindGroup(0, cnnBG); + cnnPass.dispatchWorkgroups(Math.ceil(width / 8), Math.ceil(height / 8)); + cnnPass.end(); + + [srcTex, dstTex] = [dstTex, srcTex]; + + // Copy CNN layer output to persistent storage for visualization + // i=0: Layer 0 → layerTextures[1] + // i=1: Layer 1 → layerTextures[2], etc. + encoder.copyTextureToTexture( + { texture: srcTex }, + { texture: layerTextures[i + 1] }, + [width, height] + ); + + // Always push layer outputs for visualization (including output layer) + this.layerOutputs.push(layerTextures[i + 1]); + } + + const modeBuffer = this.device.createBuffer({ + size: 4, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + this.device.queue.writeBuffer(modeBuffer, 0, new Uint32Array([this.viewMode])); + + // Store result texture and display pipeline for view mode switching + this.resultTexture = srcTex; + this.displayPipeline = displayPipeline; + this.modeBuffer = modeBuffer; + + const displayBG = this.device.createBindGroup({ + layout: displayPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: srcTex.createView() }, + { binding: 1, resource: this.inputTexture.createView() }, + { binding: 2, resource: { buffer: modeBuffer } } + ] + }); + this.displayBindGroup = displayBG; + + const renderPass = encoder.beginRenderPass({ + colorAttachments: [{ + view: this.context.getCurrentTexture().createView(), + loadOp: 'clear', + storeOp: 'store' + }] + }); + renderPass.setPipeline(displayPipeline); + renderPass.setBindGroup(0, displayBG); + renderPass.draw(6); + renderPass.end(); + + 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; + } + + // Only rebuild panel structure if layer count changed + const needsRebuild = !this.lastLayerCount || this.lastLayerCount !== this.layerOutputs.length; + + if (needsRebuild) { + let html = '<div class="layer-buttons">'; + 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>`; + + for (let i = 1; i < this.layerOutputs.length; i++) { + const label = `Layer ${i - 1}`; + html += `<button onclick="tester.visualizeLayer(${i})" id="layerBtn${i}">${label}</button>`; + } + html += `<button onclick="tester.saveCompositedLayer()" style="margin-left: 20px; background: #28a745;">Save Composited</button>`; + html += '</div>'; + + html += '<div class="layer-grid" id="layerGrid"></div>'; + html += '<div class="layer-preview"><div class="layer-view-label" id="previewLabel">Ch0</div><canvas id="previewCanvas"></canvas></div>'; + + panel.innerHTML = html; + this.log(`Layer visualization ready: ${this.layerOutputs.length} layers`); + this.recreateCanvases(); + this.lastLayerCount = this.layerOutputs.length; + } + + // Update current visualization + if (this.currentLayerIdx !== null) { + this.visualizeLayer(this.currentLayerIdx, this.currentChannelOffset || 0); + } else { + this.visualizeLayer(0, 0); + } + } + + recreateCanvases() { + const grid = document.getElementById('layerGrid'); + if (!grid) return; + + // Force removal of old canvases to clear any WebGPU contexts + const oldCanvases = grid.querySelectorAll('canvas'); + oldCanvases.forEach(canvas => { + canvas.width = 0; + canvas.height = 0; + }); + + 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> + `; + div.onclick = () => this.selectChannel(c); + grid.appendChild(div); + } + this.selectedChannel = 0; + } + + async visualizeLayer(layerIdx, channelOffset = 0) { + if (!this.layerOutputs || layerIdx >= this.layerOutputs.length) { + this.log(`Cannot visualize layer ${layerIdx}: no data`, 'error'); + 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) { + // Static features + const btnId = `layerBtn0_${channelOffset}`; + const btn = document.getElementById(btnId); + if (btn) btn.classList.add('active'); + } else { + const btn = document.getElementById(`layerBtn${layerIdx}`); + if (btn) btn.classList.add('active'); + } + + const layerName = layerIdx === 0 ? `Static Features (${channelOffset}-${channelOffset + 3})` : `Layer ${layerIdx - 1}`; + const layerTex = this.layerOutputs[layerIdx]; + const { width, height } = this.getDimensions(); + + // Update channel labels based on layer type + // Static features (layerIdx=0): 8 channels split into two views + // CNN layers (layerIdx≥1): 4 channels per layer + const staticLabels = [ + ['Ch0 (p0)', 'Ch1 (p1)', 'Ch2 (p2)', 'Ch3 (p3)'], + ['Ch4 (uv_x)', 'Ch5 (uv_y)', 'Ch6 (sin10_x)', 'Ch7 (bias)'] + ]; + const channelLabels = layerIdx === 0 + ? staticLabels[channelOffset / 4] + : ['Ch0', 'Ch1', 'Ch2', 'Ch3']; + + 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; + } + + // Set canvas size BEFORE getting context + canvas.width = width; + canvas.height = 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 = 1.0; // Always 1.0, shader clamps to [0,1] + const paramsBuffer = this.device.createBuffer({ + size: 8, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + // Use channel index with offset for static features + const actualChannel = channelOffset + c; + const paramsData = new Float32Array([actualChannel, 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()]); + } + + // Wait for all renders to complete + await this.device.queue.onSubmittedWorkDone(); + + // Update active channel highlighting and preview + this.updateChannelSelection(); + await this.renderChannelPreview(); + } + + selectChannel(channelIdx) { + this.selectedChannel = channelIdx; + this.updateChannelSelection(); + this.renderChannelPreview(); + } + + updateChannelSelection() { + const grid = document.getElementById('layerGrid'); + if (!grid) return; + + const views = grid.querySelectorAll('.layer-view'); + views.forEach((view, idx) => { + view.classList.toggle('active', idx === this.selectedChannel); + }); + } + + async renderChannelPreview() { + const previewCanvas = document.getElementById('previewCanvas'); + const previewLabel = document.getElementById('previewLabel'); + if (!previewCanvas || !this.device) return; + + const { width, height } = this.getDimensions(); + previewCanvas.width = width; + previewCanvas.height = height; + + const ctx = previewCanvas.getContext('webgpu'); + if (!ctx) return; + + try { + ctx.configure({ device: this.device, format: this.format }); + } catch (e) { + return; + } + + // Update label + const channelLabel = document.getElementById(`channelLabel${this.selectedChannel}`); + if (channelLabel && previewLabel) { + previewLabel.textContent = channelLabel.textContent; + } + + // Render selected channel + const layerIdx = this.currentLayerIdx; + const channelOffset = this.currentChannelOffset; + const layerTex = this.layerOutputs[layerIdx]; + if (!layerTex) return; + + // Always 1.0, shader clamps to [0,1] - show exact layer values + const vizScale = 1.0; + const actualChannel = channelOffset + this.selectedChannel; + + const paramsBuffer = this.device.createBuffer({ + size: 8, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + const paramsData = new Float32Array([actualChannel, 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', + storeOp: 'store' + }] + }); + + renderPass.setPipeline(this.layerVizPipeline); + renderPass.setBindGroup(0, bindGroup); + renderPass.draw(6); + renderPass.end(); + + this.device.queue.submit([encoder.finish()]); + } + + visualizeWeights(cnnLayerIdx) { + const layer = this.weights.layers[cnnLayerIdx]; + if (!layer) { + this.log(`Layer ${cnnLayerIdx} not found`, 'error'); + return; + } + + // 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'); + + const { kernelSize, inChannels, outChannels, weightOffset, min, max } = layer; + + const canvas = document.getElementById('weightsCanvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: false }); + + // 1 pixel per weight, show all input channels horizontally + const width = inChannels * kernelSize; + const height = outChannels * kernelSize; + + canvas.width = width; + canvas.height = height; + + ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(0, 0, width, height); + + // Stack output channels vertically + for (let outCh = 0; outCh < outChannels; outCh++) { + const yOffset = outCh * kernelSize; + + for (let inCh = 0; inCh < inChannels; inCh++) { + const xOffset = inCh * kernelSize; + + 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); + const intensity = Math.floor(normalized * 255); + + ctx.fillStyle = `rgb(${intensity}, ${intensity}, ${intensity})`; + ctx.fillRect(xOffset + kx, yOffset + ky, 1, 1); + } + } + } + } + } + + 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() { + if (!this.displayPipeline || !this.displayBindGroup) return; + + this.device.queue.writeBuffer(this.modeBuffer, 0, new Uint32Array([this.viewMode])); + + const encoder = this.device.createCommandEncoder(); + const renderPass = encoder.beginRenderPass({ + colorAttachments: [{ + view: this.context.getCurrentTexture().createView(), + loadOp: 'clear', + storeOp: 'store' + }] + }); + renderPass.setPipeline(this.displayPipeline); + renderPass.setBindGroup(0, this.displayBindGroup); + renderPass.draw(6); + renderPass.end(); + + this.device.queue.submit([encoder.finish()]); + } + + async savePNG() { + if (!this.image && !this.isVideo) { + this.log('No image loaded', 'error'); + return; + } + + if (!this.resultTexture) { + this.log('No result to save', 'error'); + return; + } + + try { + const { width, height } = this.getDimensions(); + + // GPU readback from result texture + const bytesPerRow = width * 16; // 4×u32 per pixel + const paddedBytesPerRow = Math.ceil(bytesPerRow / 256) * 256; + const bufferSize = paddedBytesPerRow * height; + + const stagingBuffer = this.device.createBuffer({ + size: bufferSize, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ + }); + + const encoder = this.device.createCommandEncoder(); + encoder.copyTextureToBuffer( + { texture: this.resultTexture }, + { buffer: stagingBuffer, bytesPerRow: paddedBytesPerRow, rowsPerImage: height }, + { width, height, depthOrArrayLayers: 1 } + ); + this.device.queue.submit([encoder.finish()]); + + await stagingBuffer.mapAsync(GPUMapMode.READ); + const mapped = new Uint8Array(stagingBuffer.getMappedRange()); + + // Unpack f16 to RGBA8 + const pixels = new Uint8Array(width * height * 4); + for (let y = 0; y < height; y++) { + const rowOffset = y * paddedBytesPerRow; + for (let x = 0; x < width; x++) { + const pixelOffset = rowOffset + x * 16; + const data = new Uint32Array(mapped.buffer, mapped.byteOffset + pixelOffset, 4); + + // Unpack f16 (first 4 channels only) + const unpack = (u32, idx) => { + const h = (idx === 0) ? (u32 & 0xFFFF) : ((u32 >> 16) & 0xFFFF); + const sign = (h >> 15) & 1; + const exp = (h >> 10) & 0x1F; + const frac = h & 0x3FF; + if (exp === 0) return 0; + if (exp === 31) return sign ? 0 : 255; + const e = exp - 15; + const val = (1 + frac / 1024) * Math.pow(2, e); + return Math.max(0, Math.min(255, Math.round(val * 255))); + }; + + const outIdx = (y * width + x) * 4; + pixels[outIdx + 0] = unpack(data[0], 0); // R + pixels[outIdx + 1] = unpack(data[0], 1); // G + pixels[outIdx + 2] = unpack(data[1], 0); // B + pixels[outIdx + 3] = 255; // A + } + } + + stagingBuffer.unmap(); + stagingBuffer.destroy(); + + // Create blob from pixels + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + const imageData = new ImageData(new Uint8ClampedArray(pixels), width, height); + ctx.putImageData(imageData, 0, 0); + + const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png')); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + const mode = ['cnn', 'original', 'diff'][this.viewMode]; + a.href = url; + a.download = `output_${width}x${height}_${mode}.png`; + a.click(); + URL.revokeObjectURL(url); + + this.log(`Saved PNG: ${a.download}`); + this.setStatus(`Saved: ${a.download}`); + } catch (err) { + this.log(`Failed to save PNG: ${err.message}`, 'error'); + this.setStatus(`Save failed: ${err.message}`, true); + } + } + + async saveCompositedLayer() { + if (!this.currentLayerIdx) { + this.log('No layer selected for compositing', 'error'); + return; + } + + try { + const canvases = []; + for (let i = 0; i < 4; i++) { + const canvas = document.getElementById(`layerCanvas${i}`); + if (!canvas) { + this.log(`Canvas layerCanvas${i} not found`, 'error'); + return; + } + canvases.push(canvas); + } + + const width = canvases[0].width; + const height = canvases[0].height; + const compositedWidth = width * 4; + + // Create composited canvas + const compositedCanvas = document.createElement('canvas'); + compositedCanvas.width = compositedWidth; + compositedCanvas.height = height; + const ctx = compositedCanvas.getContext('2d'); + + // Composite horizontally + for (let i = 0; i < 4; i++) { + ctx.drawImage(canvases[i], i * width, 0); + } + + // Convert to grayscale + const imageData = ctx.getImageData(0, 0, compositedWidth, height); + const pixels = imageData.data; + for (let i = 0; i < pixels.length; i += 4) { + const gray = 0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2]; + pixels[i] = pixels[i + 1] = pixels[i + 2] = gray; + } + ctx.putImageData(imageData, 0, 0); + + // Save as PNG + const blob = await new Promise(resolve => compositedCanvas.toBlob(resolve, 'image/png')); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `composited_layer${this.currentLayerIdx - 1}_${compositedWidth}x${height}.png`; + a.click(); + URL.revokeObjectURL(url); + + this.log(`Saved composited layer: ${a.download}`); + this.setStatus(`Saved: ${a.download}`); + } catch (err) { + this.log(`Failed to save composited layer: ${err.message}`, 'error'); + this.setStatus(`Compositing failed: ${err.message}`, true); + } + } +} + +const tester = new CNNTester(); + +// Load default weights on startup +(async () => { + try { + const binaryString = atob(DEFAULT_WEIGHTS_B64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + await tester.loadWeights({ name: 'default.bin', arrayBuffer: () => Promise.resolve(bytes.buffer) }); + tester.log('Loaded default weights'); + } catch (err) { + tester.log(`Failed to load default weights: ${err.message}`, 'error'); + } +})(); + +function setupDropZone(id, callback) { + const zone = document.getElementById(id); + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(e => { + zone.addEventListener(e, ev => { ev.preventDefault(); ev.stopPropagation(); }); + }); + ['dragenter', 'dragover'].forEach(e => zone.addEventListener(e, () => zone.classList.add('active'))); + ['dragleave', 'drop'].forEach(e => zone.addEventListener(e, () => zone.classList.remove('active'))); + zone.addEventListener('drop', e => { + const file = e.dataTransfer.files[0]; + if (file) callback(file).catch(err => { + zone.classList.add('error'); + tester.setStatus(err.message, true); + tester.log(err.message, 'error'); + setTimeout(() => zone.classList.remove('error'), 2000); + }); + }); +} + +// Whole window drop for PNG images and videos +const mainArea = document.getElementById('mainDrop'); +['dragenter', 'dragover', 'dragleave', 'drop'].forEach(e => { + mainArea.addEventListener(e, ev => { ev.preventDefault(); ev.stopPropagation(); }); +}); +['dragenter', 'dragover'].forEach(e => mainArea.addEventListener(e, () => mainArea.classList.add('drop-active'))); +['dragleave', 'drop'].forEach(e => mainArea.addEventListener(e, () => mainArea.classList.remove('drop-active'))); +mainArea.addEventListener('drop', e => { + const file = e.dataTransfer.files[0]; + if (file) { + if (file.type.startsWith('image/')) { + tester.loadImage(file).catch(err => { + tester.setStatus(err.message, true); + tester.log(err.message, 'error'); + }); + } else if (file.type.startsWith('video/')) { + tester.loadVideo(file).catch(err => { + tester.setStatus(err.message, true); + tester.log(err.message, 'error'); + }); + } + } +}); + +// 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; + if ((tester.image || tester.isVideo) && tester.weights) { + tester.log(`Blend changed to ${e.target.value}`); + tester.run(); + } +}); + +document.getElementById('depth').addEventListener('input', e => { + tester.depth = parseFloat(e.target.value); + document.getElementById('depthValue').textContent = e.target.value; + if ((tester.image || tester.isVideo) && tester.weights) tester.run(); +}); + +document.getElementById('mipLevel').addEventListener('change', e => { + tester.mipLevel = parseInt(e.target.value); + tester.log(`Mip level changed to ${e.target.value}`); + if ((tester.image || tester.isVideo) && tester.weights) tester.run(); +}); + +document.getElementById('playPauseBtn').addEventListener('click', () => tester.togglePlayPause()); +document.getElementById('stepBackBtn').addEventListener('click', () => tester.stepFrame(-1)); +document.getElementById('stepForwardBtn').addEventListener('click', () => tester.stepFrame(1)); +document.getElementById('savePngBtn').addEventListener('click', () => tester.savePNG()); + +document.addEventListener('keydown', e => { + if (e.code === 'Space') { + e.preventDefault(); + if (tester.viewMode === 1) { + tester.viewMode = 0; + } else { + tester.viewMode = 1; + } + const modeName = ['CNN Output', 'Original', 'Diff (×10)'][tester.viewMode]; + if ((tester.image || tester.isVideo) && tester.weights) { + tester.log(`View mode: ${modeName}`); + tester.updateDisplay(); + const width = tester.isVideo ? tester.video.videoWidth : tester.image.width; + const height = tester.isVideo ? tester.video.videoHeight : tester.image.height; + tester.setStatus(`${width}×${height} | ${modeName}`); + } + } else if (e.code === 'KeyD') { + e.preventDefault(); + if (tester.viewMode === 2) { + tester.viewMode = 0; + } else { + tester.viewMode = 2; + } + const modeName = ['CNN Output', 'Original', 'Diff (×10)'][tester.viewMode]; + if ((tester.image || tester.isVideo) && tester.weights) { + tester.log(`View mode: ${modeName}`); + tester.updateDisplay(); + const width = tester.isVideo ? tester.video.videoWidth : tester.image.width; + const height = tester.isVideo ? tester.video.videoHeight : tester.image.height; + tester.setStatus(`${width}×${height} | ${modeName}`); + } + } +}); + </script> +</body> +</html> |
