summaryrefslogtreecommitdiff
path: root/cnn_v3/tools
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-03-21 10:50:02 +0100
committerskal <pascal.massimino@gmail.com>2026-03-21 10:50:02 +0100
commit35355b17576e93b035a2a78ecd05771e98f068ee (patch)
treea1c1a4563a62ad69c808383fcf0bce1ccf4c5765 /cnn_v3/tools
parente343021ac007549c76e58b27a361b11dd3f6a136 (diff)
feat(cnn_v3): HTML WebGPU tool (index.html + shaders.js + tester.js)
3-file tool, 939 lines total. Implements full U-Net+FiLM inference in the browser: Pack→Enc0→Enc1→Bottleneck→Dec1→Dec0 compute passes, layer visualisation (Feat/Enc0/Enc1/BN/Dec1/Output), FiLM MLP sliders, drag-drop weights + image/video, Save PNG, diff/blend view modes. HOW_TO_CNN.md §7 updated to reflect tool is implemented. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'cnn_v3/tools')
-rw-r--r--cnn_v3/tools/index.html147
-rw-r--r--cnn_v3/tools/shaders.js252
-rw-r--r--cnn_v3/tools/tester.js540
3 files changed, 939 insertions, 0 deletions
diff --git a/cnn_v3/tools/index.html b/cnn_v3/tools/index.html
new file mode 100644
index 0000000..eba532e
--- /dev/null
+++ b/cnn_v3/tools/index.html
@@ -0,0 +1,147 @@
+<!DOCTYPE html>
+<html lang="en">
+<!-- CNN v3 WebGPU tool — see shaders.js + tester.js -->
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>CNN v3 Tool</title>
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+body{background:#1a1a1a;color:#e0e0e0;font-family:'Courier New',monospace;display:flex;flex-direction:column;height:100vh}
+.hdr{padding:8px 14px;border-bottom:1px solid #404040;display:flex;align-items:center;gap:16px;flex-shrink:0}
+h1{font-size:14px}.sub{font-size:10px;color:#555}
+.body{flex:1;display:flex;overflow:hidden;gap:1px;background:#404040}
+.left,.right{background:#2a2a2a;overflow-y:auto;display:flex;flex-direction:column;gap:10px;padding:10px;flex-shrink:0}
+.left{width:284px}.right{width:370px}
+.main{flex:1;display:flex;justify-content:center;align-items:center;padding:14px;overflow:auto;position:relative}
+.panel{border:1px solid #333;border-radius:3px;overflow:hidden}
+.ph{background:#1e1e1e;padding:5px 8px;font-size:10px;font-weight:bold;color:#888;cursor:pointer;display:flex;justify-content:space-between;user-select:none}
+.ph:hover{color:#ddd}
+.panel.collapsed .pc{display:none}
+.pc{padding:9px;font-size:11px}
+.dz{border:2px dashed #444;padding:12px 8px;text-align:center;cursor:pointer;font-size:10px;font-weight:bold;background:#222;border-radius:3px;color:#4a9eff;transition:border-color .15s,background .15s}
+.dz:hover,.dz.dragover{border-color:#4a9eff;background:#1a2535}
+.dz.ok{border-color:#28a745;color:#28a745}.dz.err{border-color:#ff4a4a;color:#ff4a4a}
+button{padding:4px 9px;font-size:10px;cursor:pointer;background:#1a1a1a;color:#e0e0e0;border:1px solid #404040;border-radius:2px}
+button:hover{border-color:#666;background:#252525}
+button.act{background:#4a9eff;border-color:#4a9eff;color:#111}
+button:disabled{opacity:.4;cursor:default}
+button:disabled:hover{border-color:#404040;background:#1a1a1a}
+input[type=range]{width:100%;accent-color:#4a9eff}
+label{font-size:10px;color:#888}
+.row{display:flex;gap:6px;align-items:center}
+.row label{flex-shrink:0;width:82px}
+.row span{font-size:10px;min-width:34px;text-align:right}
+table{width:100%;border-collapse:collapse}
+th{text-align:left;padding:2px 4px;font-size:9px;color:#555;border-bottom:1px solid #333}
+td{padding:2px 4px;font-size:9px}
+tr:hover td{background:#1e1e1e}
+.bf{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);display:flex;gap:10px;align-items:center;background:rgba(28,28,28,.95);padding:6px 12px;border-radius:3px;border:1px solid #404040;z-index:100}
+.sep{width:1px;height:16px;background:#444}
+canvas{max-width:100%;max-height:100%;image-rendering:pixelated;box-shadow:0 4px 12px rgba(0,0,0,.5)}
+.main.dragover::after{content:'Drop PNG/video';position:absolute;inset:14px;display:flex;align-items:center;justify-content:center;border:3px dashed #4a9eff;background:rgba(74,158,255,.08);font-size:20px;color:#4a9eff;pointer-events:none;z-index:10}
+.vzbtns{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px}
+.chgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:3px}
+.chcell{background:#111;border:1px solid #333;overflow:hidden;display:flex;flex-direction:column}
+.chcell-lbl{font-size:8px;padding:2px;background:#1e1e1e;color:#666;text-align:center}
+.chcell canvas{width:100%;display:block;image-rendering:pixelated;cursor:pointer}
+.fgrid{display:grid;grid-template-columns:1fr auto;gap:3px 8px;align-items:center;margin-top:4px}
+.fgrid label{font-size:10px;color:#888}
+.fgrid span{font-size:10px}
+.ftr{background:#222;border-top:1px solid #404040;font-size:11px;display:flex;flex-direction:column;flex-shrink:0}
+.ftr-top{padding:6px 14px 0;display:flex;justify-content:space-between}
+.st{color:#4a9eff}.sh{color:#555;font-size:10px}
+.con{background:#111;padding:4px 14px;font-size:10px;color:#555;max-height:72px;overflow-y:auto;border-top:1px solid #333}
+.cl{margin:1px 0}.cl.err{color:#f44}.cl.info{color:#4a9eff}
+.mtog{display:flex;gap:4px}
+video{display:none}
+</style>
+</head>
+<body>
+<div class="hdr"><h1>CNN v3 Testing Tool</h1><span class="sub">U-Net + FiLM · WebGPU</span></div>
+<video id="vid" muted loop></video>
+<div class="body">
+ <div class="left">
+ <input type="file" id="wFile" accept=".bin" style="display:none">
+ <input type="file" id="fFile" accept=".bin" style="display:none">
+
+ <div class="dz" id="wDrop" onclick="document.getElementById('wFile').click()">Drop cnn_v3_weights.bin</div>
+ <div class="dz" id="fDrop" onclick="document.getElementById('fFile').click()">Drop cnn_v3_film_mlp.bin (optional)</div>
+
+ <div class="panel">
+ <div class="ph" onclick="togglePanel(this)">Input Mode <span>▼</span></div>
+ <div class="pc">
+ <div class="mtog">
+ <button id="mSimple" class="act" onclick="tester.setMode('simple')">Simple (photo)</button>
+ <button id="mFull" onclick="tester.setMode('full')">Full (G-buffer)</button>
+ </div>
+ <div id="fullHelp" style="display:none;margin-top:6px;font-size:9px;color:#555;line-height:1.6">
+ Drop PNGs: *albedo*/color · *normal* · *depth* · *matid*/index · *shadow* · *transp*/alpha
+ </div>
+ </div>
+ </div>
+
+ <div class="panel">
+ <div class="ph" onclick="togglePanel(this)">Weights Info <span>▼</span></div>
+ <div class="pc" id="wInfo"><p style="color:#444;text-align:center">No weights loaded</p></div>
+ </div>
+
+ <div class="panel">
+ <div class="ph" onclick="togglePanel(this)">FiLM Conditioning <span>▼</span></div>
+ <div class="pc">
+ <div style="font-size:9px;color:#444;margin-bottom:5px" id="fSt">No FiLM MLP — identity (γ=1, β=0)</div>
+ <div class="fgrid">
+ <label>beat_phase</label><span id="vBP">0.00</span>
+ <input type="range" id="sBP" min="0" max="1" step=".01" value="0" oninput="tester.fslide('vBP',this)">
+ <label>beat_norm</label><span id="vBN">0.00</span>
+ <input type="range" id="sBN" min="0" max="1" step=".01" value="0" oninput="tester.fslide('vBN',this)">
+ <label>audio_int</label><span id="vAI">0.00</span>
+ <input type="range" id="sAI" min="0" max="1" step=".01" value="0" oninput="tester.fslide('vAI',this)">
+ <label>style_p0</label><span id="vP0">0.00</span>
+ <input type="range" id="sP0" min="-2" max="2" step=".05" value="0" oninput="tester.fslide('vP0',this)">
+ <label>style_p1</label><span id="vP1">0.00</span>
+ <input type="range" id="sP1" min="-2" max="2" step=".05" value="0" oninput="tester.fslide('vP1',this)">
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="main" id="mainDrop">
+ <div class="bf">
+ <div style="display:flex;gap:5px" id="vCtrl">
+ <button id="btnPP" disabled onclick="tester.togglePlay()">Play</button>
+ <button id="btnBk" disabled onclick="tester.stepFrame(-1)">◄</button>
+ <button id="btnFw" disabled onclick="tester.stepFrame(1)">►</button>
+ </div>
+ <div class="sep"></div>
+ <label>Blend</label>
+ <input type="range" id="blend" min="0" max="1" step=".01" value="1" style="width:70px" oninput="tester.setBlend(this.value)">
+ <span id="blendV">1.0</span>
+ <div class="sep"></div>
+ <button onclick="tester.savePNG()">Save PNG</button>
+ </div>
+ <canvas id="canvas"></canvas>
+ </div>
+
+ <div class="right">
+ <div class="panel" style="flex:1;display:flex;flex-direction:column;min-height:0">
+ <div class="ph">Layer Visualization</div>
+ <div class="pc" id="layerViz" style="flex:1;overflow:auto">
+ <p style="color:#444;text-align:center">Load image + weights</p>
+ </div>
+ </div>
+ </div>
+</div>
+
+<div class="ftr">
+ <div class="ftr-top">
+ <span class="st" id="status">Drop PNG/video on canvas · drop .bin weights on left</span>
+ <span class="sh">[SPACE] Original [D] Diff×10</span>
+ </div>
+ <div class="con" id="con"></div>
+</div>
+
+<script src="shaders.js"></script>
+<script src="tester.js"></script>
+</body>
+</html>
diff --git a/cnn_v3/tools/shaders.js b/cnn_v3/tools/shaders.js
new file mode 100644
index 0000000..c3e994d
--- /dev/null
+++ b/cnn_v3/tools/shaders.js
@@ -0,0 +1,252 @@
+'use strict';
+// CNN v3 WGSL shaders — matches cnn_v3/shaders/*.wgsl exactly.
+// Weight offsets (f16 index): enc0=0, enc1=724, bn=1020, dec1=1092, dec0=1672, total=1964
+
+const ENC0_OFF=0, ENC1_OFF=724, BN_OFF=1020, DEC1_OFF=1092, DEC0_OFF=1672;
+const TOTAL_F16=1964, TOTAL_U32=982;
+
+// Inlined helpers — prepended to shaders that need them.
+const H = `
+fn get_w(base:u32,idx:u32)->f32{
+ let i=base+idx; let v=unpack2x16float(weights[i>>1u]);
+ return select(v.y,v.x,(i&1u)==0u);
+}
+fn unpack8(tex:texture_2d<u32>,c:vec2i)->array<f32,8>{
+ let t=textureLoad(tex,c,0);
+ let a=unpack2x16float(t.x);let b=unpack2x16float(t.y);
+ let d=unpack2x16float(t.z);let e=unpack2x16float(t.w);
+ return array<f32,8>(a.x,a.y,b.x,b.y,d.x,d.y,e.x,e.y);
+}`;
+
+// Pack simple image (albedo+mips+alpha) into feat_tex0/1
+const PACK_SHADER=`
+@group(0) @binding(0) var inp:texture_2d<f32>;
+@group(0) @binding(1) var smp:sampler;
+@group(0) @binding(2) var f0:texture_storage_2d<rgba32uint,write>;
+@group(0) @binding(3) var f1:texture_storage_2d<rgba32uint,write>;
+@compute @workgroup_size(8,8)
+fn main(@builtin(global_invocation_id) id:vec3u){
+ let c=vec2i(id.xy); let d=vec2i(textureDimensions(inp));
+ if(c.x>=d.x||c.y>=d.y){return;}
+ let uv=(vec2f(c)+.5)/vec2f(d);
+ let px=textureLoad(inp,c,0);
+ let alb=px.rgb; let tr=1.-px.a;
+ let m1=textureSampleLevel(inp,smp,uv,1.).rgb;
+ let m2=textureSampleLevel(inp,smp,uv,2.).rgb;
+ textureStore(f0,c,vec4u(pack2x16float(alb.rg),pack2x16float(vec2f(alb.b,0.)),
+ pack2x16float(vec2f(0.,0.)),pack2x16float(vec2f(0.,0.))));
+ textureStore(f1,c,vec4u(pack4x8unorm(vec4f(0.,0.,0.,0.)),
+ pack4x8unorm(vec4f(m1.r,m1.g,m1.b,m2.r)),
+ pack4x8unorm(vec4f(m2.g,m2.b,1.,tr)),0u));
+}`;
+
+// Enc0: Conv(20→4, 3×3, zero-pad) + FiLM + ReLU → rgba16float
+// Params (48 bytes): weight_offset u32 _pad×3 gamma vec4f beta vec4f
+const ENC0_SHADER=H+`
+struct P{wo:u32,_a:u32,_b:u32,_c:u32,g:vec4f,b:vec4f}
+@group(0) @binding(0) var t0:texture_2d<u32>;
+@group(0) @binding(1) var t1:texture_2d<u32>;
+@group(0) @binding(2) var<storage,read> weights:array<u32>;
+@group(0) @binding(3) var<uniform> p:P;
+@group(0) @binding(4) var out:texture_storage_2d<rgba16float,write>;
+fn feat(c:vec2i,d:vec2i)->array<f32,20>{
+ if(c.x<0||c.y<0||c.x>=d.x||c.y>=d.y){return array<f32,20>(0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.);}
+ let a=unpack2x16float(textureLoad(t0,c,0).x); let b=unpack2x16float(textureLoad(t0,c,0).y);
+ let cc=unpack2x16float(textureLoad(t0,c,0).z);let dd=unpack2x16float(textureLoad(t0,c,0).w);
+ let e=unpack4x8unorm(textureLoad(t1,c,0).x); let f=unpack4x8unorm(textureLoad(t1,c,0).y);
+ let g=unpack4x8unorm(textureLoad(t1,c,0).z);
+ return array<f32,20>(a.x,a.y,b.x,b.y,cc.x,cc.y,dd.x,dd.y,e.x,e.y,e.z,e.w,f.x,f.y,f.z,f.w,g.x,g.y,g.z,g.w);
+}
+@compute @workgroup_size(8,8)
+fn main(@builtin(global_invocation_id) id:vec3u){
+ let c=vec2i(id.xy); let d=vec2i(textureDimensions(t0));
+ if(c.x>=d.x||c.y>=d.y){return;}
+ const IN:u32=20u; const OUT:u32=4u;
+ var o:array<f32,4>;
+ for(var oc:u32=0u;oc<OUT;oc++){
+ var s=get_w(p.wo,OUT*IN*9u+oc);
+ for(var ky:i32=-1;ky<=1;ky++){for(var kx:i32=-1;kx<=1;kx++){
+ let ft=feat(c+vec2i(kx,ky),d); let ki=u32(ky+1)*3u+u32(kx+1);
+ for(var i:u32=0u;i<IN;i++){s+=get_w(p.wo,oc*IN*9u+i*9u+ki)*ft[i];}
+ }}
+ o[oc]=max(0.,p.g[oc]*s+p.b[oc]);
+ }
+ textureStore(out,c,vec4f(o[0],o[1],o[2],o[3]));
+}`;
+
+// Enc1: AvgPool(enc0) + Conv(4→8, 3×3) + FiLM + ReLU → rgba32uint half-res
+// Params (80 bytes): wo u32 _pad×3 glo ghi blo bhi vec4f×4
+const ENC1_SHADER=H+`
+struct P{wo:u32,_a:u32,_b:u32,_c:u32,gl:vec4f,gh:vec4f,bl:vec4f,bh:vec4f}
+@group(0) @binding(0) var e0:texture_2d<f32>;
+@group(0) @binding(1) var<storage,read> weights:array<u32>;
+@group(0) @binding(2) var<uniform> p:P;
+@group(0) @binding(3) var out:texture_storage_2d<rgba32uint,write>;
+fn fg(o:u32)->f32{if(o<4u){return p.gl[o];}return p.gh[o-4u];}
+fn fb(o:u32)->f32{if(o<4u){return p.bl[o];}return p.bh[o-4u];}
+fn avg(hc:vec2i,fd:vec2i)->array<f32,4>{
+ let hd=fd/2; if(hc.x<0||hc.y<0||hc.x>=hd.x||hc.y>=hd.y){return array<f32,4>(0.,0.,0.,0.);}
+ var s=vec4f(0.);
+ for(var y:i32=0;y<2;y++){for(var x:i32=0;x<2;x++){s+=textureLoad(e0,clamp(hc*2+vec2i(x,y),vec2i(0),fd-vec2i(1)),0);}}
+ let a=s*.25; return array<f32,4>(a.x,a.y,a.z,a.w);
+}
+@compute @workgroup_size(8,8)
+fn main(@builtin(global_invocation_id) id:vec3u){
+ let fd=vec2i(textureDimensions(e0)); let hd=fd/2; let c=vec2i(id.xy);
+ if(c.x>=hd.x||c.y>=hd.y){return;}
+ const IN:u32=4u; const OUT:u32=8u;
+ var o:array<f32,8>;
+ for(var oc:u32=0u;oc<OUT;oc++){
+ var s=get_w(p.wo,OUT*IN*9u+oc);
+ for(var ky:i32=-1;ky<=1;ky++){for(var kx:i32=-1;kx<=1;kx++){
+ let ft=avg(c+vec2i(kx,ky),fd); let ki=u32(ky+1)*3u+u32(kx+1);
+ for(var i:u32=0u;i<IN;i++){s+=get_w(p.wo,oc*IN*9u+i*9u+ki)*ft[i];}
+ }}
+ o[oc]=max(0.,fg(oc)*s+fb(oc));
+ }
+ textureStore(out,c,vec4u(pack2x16float(vec2f(o[0],o[1])),pack2x16float(vec2f(o[2],o[3])),
+ pack2x16float(vec2f(o[4],o[5])),pack2x16float(vec2f(o[6],o[7]))));
+}`;
+
+// Bottleneck: AvgPool(enc1) + Conv(8→8, 1×1) + ReLU → rgba32uint quarter-res (no FiLM)
+// Params (16 bytes): wo u32 _pad×3
+const BN_SHADER=H+`
+struct P{wo:u32,_a:u32,_b:u32,_c:u32}
+@group(0) @binding(0) var e1:texture_2d<u32>;
+@group(0) @binding(1) var<storage,read> weights:array<u32>;
+@group(0) @binding(2) var<uniform> p:P;
+@group(0) @binding(3) var out:texture_storage_2d<rgba32uint,write>;
+fn avg(qc:vec2i,hd:vec2i)->array<f32,8>{
+ let qd=hd/2; if(qc.x<0||qc.y<0||qc.x>=qd.x||qc.y>=qd.y){return array<f32,8>(0.,0.,0.,0.,0.,0.,0.,0.);}
+ var s:array<f32,8>;
+ for(var y:i32=0;y<2;y++){for(var x:i32=0;x<2;x++){
+ let f=unpack8(e1,clamp(qc*2+vec2i(x,y),vec2i(0),hd-vec2i(1)));
+ for(var i:u32=0u;i<8u;i++){s[i]+=f[i];}
+ }}
+ for(var i:u32=0u;i<8u;i++){s[i]*=.25;} return s;
+}
+@compute @workgroup_size(8,8)
+fn main(@builtin(global_invocation_id) id:vec3u){
+ let hd=vec2i(textureDimensions(e1)); let qd=hd/2; let c=vec2i(id.xy);
+ if(c.x>=qd.x||c.y>=qd.y){return;}
+ let ft=avg(c,hd); var o:array<f32,8>;
+ for(var oc:u32=0u;oc<8u;oc++){
+ var s=get_w(p.wo,64u+oc);
+ for(var i:u32=0u;i<8u;i++){s+=get_w(p.wo,oc*8u+i)*ft[i];}
+ o[oc]=max(0.,s);
+ }
+ textureStore(out,c,vec4u(pack2x16float(vec2f(o[0],o[1])),pack2x16float(vec2f(o[2],o[3])),
+ pack2x16float(vec2f(o[4],o[5])),pack2x16float(vec2f(o[6],o[7]))));
+}`;
+
+// Dec1: NearestUp(bn)+cat(enc1_skip) → Conv(16→4,3×3) + FiLM + ReLU → rgba16float half-res
+// Params (48 bytes): same layout as enc0
+const DEC1_SHADER=H+`
+struct P{wo:u32,_a:u32,_b:u32,_c:u32,g:vec4f,b:vec4f}
+@group(0) @binding(0) var bn:texture_2d<u32>;
+@group(0) @binding(1) var e1:texture_2d<u32>;
+@group(0) @binding(2) var<storage,read> weights:array<u32>;
+@group(0) @binding(3) var<uniform> p:P;
+@group(0) @binding(4) var out:texture_storage_2d<rgba16float,write>;
+fn cat(hc:vec2i,hd:vec2i)->array<f32,16>{
+ if(hc.x<0||hc.y<0||hc.x>=hd.x||hc.y>=hd.y){return array<f32,16>(0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.,0.);}
+ let qd=hd/2; let b=unpack8(bn,clamp(hc/2,vec2i(0),qd-vec2i(1)));
+ let s=unpack8(e1,hc);
+ return array<f32,16>(b[0],b[1],b[2],b[3],b[4],b[5],b[6],b[7],s[0],s[1],s[2],s[3],s[4],s[5],s[6],s[7]);
+}
+@compute @workgroup_size(8,8)
+fn main(@builtin(global_invocation_id) id:vec3u){
+ let hd=vec2i(textureDimensions(e1)); let c=vec2i(id.xy);
+ if(c.x>=hd.x||c.y>=hd.y){return;}
+ const IN:u32=16u; const OUT:u32=4u;
+ var o:array<f32,4>;
+ for(var oc:u32=0u;oc<OUT;oc++){
+ var s=get_w(p.wo,OUT*IN*9u+oc);
+ for(var ky:i32=-1;ky<=1;ky++){for(var kx:i32=-1;kx<=1;kx++){
+ let ft=cat(c+vec2i(kx,ky),hd); let ki=u32(ky+1)*3u+u32(kx+1);
+ for(var i:u32=0u;i<IN;i++){s+=get_w(p.wo,oc*IN*9u+i*9u+ki)*ft[i];}
+ }}
+ o[oc]=max(0.,p.g[oc]*s+p.b[oc]);
+ }
+ textureStore(out,c,vec4f(o[0],o[1],o[2],o[3]));
+}`;
+
+// Dec0: NearestUp(dec1)+cat(enc0_skip) → Conv(8→4,3×3) + FiLM + ReLU + Sigmoid → rgba16float
+// Params (48 bytes): same layout as enc0
+const DEC0_SHADER=H+`
+struct P{wo:u32,_a:u32,_b:u32,_c:u32,g:vec4f,b:vec4f}
+@group(0) @binding(0) var d1:texture_2d<f32>;
+@group(0) @binding(1) var e0:texture_2d<f32>;
+@group(0) @binding(2) var<storage,read> weights:array<u32>;
+@group(0) @binding(3) var<uniform> p:P;
+@group(0) @binding(4) var out:texture_storage_2d<rgba16float,write>;
+fn cat(c:vec2i,fd:vec2i)->array<f32,8>{
+ if(c.x<0||c.y<0||c.x>=fd.x||c.y>=fd.y){return array<f32,8>(0.,0.,0.,0.,0.,0.,0.,0.);}
+ let hd=vec2i(textureDimensions(d1));
+ let a=textureLoad(d1,clamp(c/2,vec2i(0),hd-vec2i(1)),0);
+ let b=textureLoad(e0,c,0);
+ return array<f32,8>(a.x,a.y,a.z,a.w,b.x,b.y,b.z,b.w);
+}
+@compute @workgroup_size(8,8)
+fn main(@builtin(global_invocation_id) id:vec3u){
+ let fd=vec2i(textureDimensions(e0)); let c=vec2i(id.xy);
+ if(c.x>=fd.x||c.y>=fd.y){return;}
+ const IN:u32=8u; const OUT:u32=4u;
+ var o:array<f32,4>;
+ for(var oc:u32=0u;oc<OUT;oc++){
+ var s=get_w(p.wo,OUT*IN*9u+oc);
+ for(var ky:i32=-1;ky<=1;ky++){for(var kx:i32=-1;kx<=1;kx++){
+ let ft=cat(c+vec2i(kx,ky),fd); let ki=u32(ky+1)*3u+u32(kx+1);
+ for(var i:u32=0u;i<IN;i++){s+=get_w(p.wo,oc*IN*9u+i*9u+ki)*ft[i];}
+ }}
+ let v=max(0.,p.g[oc]*s+p.b[oc]);
+ o[oc]=1./(1.+exp(-v));
+ }
+ textureStore(out,c,vec4f(o[0],o[1],o[2],o[3]));
+}`;
+
+// Display: rgba16float output → canvas (mode 0=cnn,1=orig,2=diff, blend)
+const DISP_SHADER=`
+@group(0) @binding(0) var otex:texture_2d<f32>;
+@group(0) @binding(1) var itex:texture_2d<f32>;
+@group(0) @binding(2) var<uniform> pr:vec4f; // x=mode y=blend
+@vertex fn vs(@builtin(vertex_index) i:u32)->@builtin(position) vec4f{
+ var p=array<vec2f,6>(vec2f(-1.,-1.),vec2f(1.,-1.),vec2f(-1.,1.),vec2f(-1.,1.),vec2f(1.,-1.),vec2f(1.,1.));
+ return vec4f(p[i],0.,1.);
+}
+@fragment fn fs(@builtin(position) pos:vec4f)->@location(0) vec4f{
+ let c=vec2i(pos.xy); let m=u32(pr.x);
+ let orig=textureLoad(itex,c,0).rgb; let cnn=textureLoad(otex,c,0).rgb;
+ if(m==1u){return vec4f(orig,1.);}
+ if(m==2u){return vec4f(abs(cnn-orig)*10.,1.);}
+ return vec4f(mix(orig,cnn,pr.y),1.);
+}`;
+
+// Viz f32: show one channel of rgba16float layer
+const VIZ_F32=`
+@group(0) @binding(0) var t:texture_2d<f32>;
+@group(0) @binding(1) var<uniform> ch:u32;
+@vertex fn vs(@builtin(vertex_index) i:u32)->@builtin(position) vec4f{
+ var p=array<vec2f,6>(vec2f(-1.,-1.),vec2f(1.,-1.),vec2f(-1.,1.),vec2f(-1.,1.),vec2f(1.,-1.),vec2f(1.,1.));
+ return vec4f(p[i],0.,1.);
+}
+@fragment fn fs(@builtin(position) pos:vec4f)->@location(0) vec4f{
+ let v=textureLoad(t,vec2i(pos.xy),0); var a=array<f32,4>(v.x,v.y,v.z,v.w);
+ let x=clamp(a[min(ch,3u)],0.,1.); return vec4f(x,x,x,1.);
+}`;
+
+// Viz u32: show one f16 channel of rgba32uint layer (8 channels packed)
+const VIZ_U32=`
+@group(0) @binding(0) var t:texture_2d<u32>;
+@group(0) @binding(1) var<uniform> ch:u32;
+@vertex fn vs(@builtin(vertex_index) i:u32)->@builtin(position) vec4f{
+ var p=array<vec2f,6>(vec2f(-1.,-1.),vec2f(1.,-1.),vec2f(-1.,1.),vec2f(-1.,1.),vec2f(1.,-1.),vec2f(1.,1.));
+ return vec4f(p[i],0.,1.);
+}
+@fragment fn fs(@builtin(position) pos:vec4f)->@location(0) vec4f{
+ let t2=textureLoad(t,vec2i(pos.xy),0);
+ let a=unpack2x16float(t2.x);let b=unpack2x16float(t2.y);
+ let c=unpack2x16float(t2.z);let d=unpack2x16float(t2.w);
+ var v=array<f32,8>(a.x,a.y,b.x,b.y,c.x,c.y,d.x,d.y);
+ let x=clamp(v[min(ch,7u)],0.,1.); return vec4f(x,x,x,1.);
+}`;
diff --git a/cnn_v3/tools/tester.js b/cnn_v3/tools/tester.js
new file mode 100644
index 0000000..aa765a1
--- /dev/null
+++ b/cnn_v3/tools/tester.js
@@ -0,0 +1,540 @@
+'use strict';
+
+class CNNv3Tester {
+ constructor() {
+ this.canvas = document.getElementById('canvas');
+ this.statusEl= document.getElementById('status');
+ this.conEl = document.getElementById('con');
+ this.video = document.getElementById('vid');
+ this.weightsU32 = null;
+ this.weightsBuffer = null;
+ this.weightsGPU = null;
+ this.filmMlp = null;
+ this.image = null;
+ this.isVideo = false;
+ this.viewMode= 0; // 0=cnn 1=orig 2=diff
+ this.blend = 1.0;
+ this.layerTextures = {};
+ this.lastResult = null;
+ this.isProcessing = false;
+ this.fps = 30;
+ this.pipelines = {}; // cached: pack enc0 enc1 bn dec1 dec0 disp vizF32 vizU32 mip
+ this.linearSampler = null;
+ this.init();
+ }
+
+ log(msg, type='info') {
+ const d = document.createElement('div');
+ d.className = `cl ${type}`;
+ d.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
+ this.conEl.appendChild(d);
+ this.conEl.scrollTop = this.conEl.scrollHeight;
+ }
+ setStatus(msg, err=false) {
+ this.statusEl.textContent = msg;
+ this.statusEl.style.color = err ? '#f44' : '#4a9eff';
+ }
+
+ async init() {
+ if (!navigator.gpu) { this.setStatus('WebGPU not supported',true); 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.linearSampler = this.device.createSampler({magFilter:'linear',minFilter:'linear',mipmapFilter:'linear'});
+ this.log('WebGPU ready');
+ } catch(e) { this.setStatus(`GPU error: ${e.message}`,true); }
+ }
+
+ getDims() {
+ return this.isVideo
+ ? {w:this.video.videoWidth, h:this.video.videoHeight}
+ : {w:this.image.width, h:this.image.height};
+ }
+
+ setMode(m) {
+ document.getElementById('mSimple').classList.toggle('act', m==='simple');
+ document.getElementById('mFull').classList.toggle('act', m==='full');
+ document.getElementById('fullHelp').style.display = m==='full' ? 'block' : 'none';
+ }
+
+ // ── Weight parsing ───────────────────────────────────────────────────────
+
+ parseWeights(buf) {
+ const u32 = new Uint32Array(buf);
+ if (u32.length < TOTAL_U32) throw new Error(`Too small: ${u32.length} u32, need ${TOTAL_U32}`);
+ const layers = [
+ {n:'enc0',off:ENC0_OFF,cnt:724},{n:'enc1',off:ENC1_OFF,cnt:296},
+ {n:'bn', off:BN_OFF, cnt: 72},{n:'dec1',off:DEC1_OFF,cnt:580},
+ {n:'dec0',off:DEC0_OFF,cnt:292},
+ ];
+ let html=`<div style="margin-bottom:7px"><b>Size:</b> ${(buf.byteLength/1024).toFixed(1)} KB &nbsp; <b>Weights:</b> ${TOTAL_F16} f16</div>
+ <table><thead><tr><th>Layer</th><th>Offset</th><th>Count</th><th>Min</th><th>Max</th></tr></thead><tbody>`;
+ for (const l of layers) {
+ let mn=Infinity,mx=-Infinity;
+ for (let i=l.off;i<l.off+l.cnt;i++) {
+ const [a,b]=this.f16pair(u32[i>>1]);
+ const v=(i&1)?b:a; if(v<mn)mn=v; if(v>mx)mx=v;
+ }
+ html+=`<tr><td>${l.n}</td><td>${l.off}</td><td>${l.cnt}</td><td>${mn.toFixed(3)}</td><td>${mx.toFixed(3)}</td></tr>`;
+ }
+ html+='</tbody></table>';
+ document.getElementById('wInfo').innerHTML = html;
+ return u32;
+ }
+
+ parseFilm(buf) {
+ const f32=new Float32Array(buf);
+ if (f32.length < 776) throw new Error(`FiLM too small: ${f32.length}`);
+ let o=0;
+ const l0w=f32.slice(o,o+=80), l0b=f32.slice(o,o+=16);
+ const l1w=f32.slice(o,o+=640),l1b=f32.slice(o,o+=40);
+ this.log(`FiLM MLP: L0(16×5) L1(40×16), ${f32.length} f32`);
+ return {l0w,l0b,l1w,l1b};
+ }
+
+ filmFwd(cond) {
+ const {l0w,l0b,l1w,l1b}=this.filmMlp;
+ const h=new Float32Array(16);
+ for(let j=0;j<16;j++){let s=l0b[j];for(let i=0;i<5;i++)s+=l0w[j*5+i]*cond[i];h[j]=Math.max(0,s);}
+ const o=new Float32Array(40);
+ for(let j=0;j<40;j++){let s=l1b[j];for(let i=0;i<16;i++)s+=l1w[j*16+i]*h[i];o[j]=s;}
+ return o;
+ }
+
+ filmParams() {
+ const I4=[1,1,1,1],Z4=[0,0,0,0],I8=[1,1,1,1,1,1,1,1],Z8=[0,0,0,0,0,0,0,0];
+ if (!this.filmMlp) return {ge0:I4,be0:Z4,ge1:I8,be1:Z8,gd1:I4,bd1:Z4,gd0:I4,bd0:Z4};
+ const v=document.getElementById;
+ const cond=[v('sBP').value,v('sBN').value,v('sAI').value,v('sP0').value,v('sP1').value].map(Number);
+ const f=this.filmFwd(cond);
+ return {
+ ge0:[...f.slice(0,4)], be0:[...f.slice(4,8)],
+ ge1:[...f.slice(8,16)],be1:[...f.slice(16,24)],
+ gd1:[...f.slice(24,28)],bd1:[...f.slice(28,32)],
+ gd0:[...f.slice(32,36)],bd0:[...f.slice(36,40)],
+ };
+ }
+
+ // ── Uniform buffers ──────────────────────────────────────────────────────
+
+ // Params4 (48 bytes): wo u32 _pad×3 gamma vec4f beta vec4f
+ u4(wo,g,b){
+ const buf=new ArrayBuffer(48),v=new DataView(buf);
+ v.setUint32(0,wo,true);
+ for(let i=0;i<4;i++)v.setFloat32(16+i*4,g[i],true);
+ for(let i=0;i<4;i++)v.setFloat32(32+i*4,b[i],true);
+ return buf;
+ }
+ // Params8 (80 bytes): wo u32 _pad×3 gl gh bl bh vec4f×4
+ u8(wo,g,b){
+ const buf=new ArrayBuffer(80),v=new DataView(buf);
+ v.setUint32(0,wo,true);
+ for(let i=0;i<4;i++)v.setFloat32(16+i*4,g[i],true);
+ for(let i=0;i<4;i++)v.setFloat32(32+i*4,g[i+4],true);
+ for(let i=0;i<4;i++)v.setFloat32(48+i*4,b[i],true);
+ for(let i=0;i<4;i++)v.setFloat32(64+i*4,b[i+4],true);
+ return buf;
+ }
+ // ParamsBN (16 bytes): wo u32 _pad×3
+ ubn(wo){const buf=new ArrayBuffer(16);new DataView(buf).setUint32(0,wo,true);return buf;}
+
+ // ── Pipeline cache ───────────────────────────────────────────────────────
+
+ computePL(code,entry) {
+ return this.device.createComputePipeline({layout:'auto',
+ compute:{module:this.device.createShaderModule({code}),entryPoint:entry}});
+ }
+ renderPL(code,vs,fs) {
+ const m=this.device.createShaderModule({code});
+ return this.device.createRenderPipeline({layout:'auto',
+ vertex:{module:m,entryPoint:vs},
+ fragment:{module:m,entryPoint:fs,targets:[{format:this.format}]}});
+ }
+ pl(key,fn){if(!this.pipelines[key])this.pipelines[key]=fn();return this.pipelines[key];}
+
+ getPack() {return this.pl('pack', ()=>this.computePL(PACK_SHADER,'main'));}
+ getEnc0() {return this.pl('enc0', ()=>this.computePL(ENC0_SHADER,'main'));}
+ getEnc1() {return this.pl('enc1', ()=>this.computePL(ENC1_SHADER,'main'));}
+ getBN() {return this.pl('bn', ()=>this.computePL(BN_SHADER,'main'));}
+ getDec1() {return this.pl('dec1', ()=>this.computePL(DEC1_SHADER,'main'));}
+ getDec0() {return this.pl('dec0', ()=>this.computePL(DEC0_SHADER,'main'));}
+ getDisp() {return this.pl('disp', ()=>this.renderPL(DISP_SHADER,'vs','fs'));}
+ getVizF32() {return this.pl('vf32', ()=>this.renderPL(VIZ_F32,'vs','fs'));}
+ getVizU32() {return this.pl('vu32', ()=>this.renderPL(VIZ_U32,'vs','fs'));}
+
+ getMip() {
+ return this.pl('mip', ()=>{
+ const code=`@group(0) @binding(0) var src:texture_2d<f32>;
+ @vertex fn vs(@builtin(vertex_index) i:u32)->@builtin(position) vec4f{
+ var p=array<vec2f,6>(vec2f(-1.,-1.),vec2f(1.,-1.),vec2f(-1.,1.),vec2f(-1.,1.),vec2f(1.,-1.),vec2f(1.,1.));
+ return vec4f(p[i],0.,1.);}
+ @fragment fn fs(@builtin(position) pos:vec4f)->@location(0) vec4f{
+ let c=vec2i(i32(pos.x)*2,i32(pos.y)*2); var s=vec4f(0.);
+ for(var y:i32=0;y<2;y++){for(var x:i32=0;x<2;x++){s+=textureLoad(src,c+vec2i(x,y),0);}}
+ return s*.25;}`;
+ const m=this.device.createShaderModule({code});
+ return this.device.createRenderPipeline({layout:'auto',
+ vertex:{module:m,entryPoint:'vs'},
+ fragment:{module:m,entryPoint:'fs',targets:[{format:'rgba8unorm'}]}});
+ });
+ }
+
+ // ── Mipmap generation ────────────────────────────────────────────────────
+
+ generateMipmaps(tex,w,h) {
+ const enc=this.device.createCommandEncoder();
+ const pl=this.getMip();
+ for(let mip=1;mip<3;mip++){
+ const mw=Math.max(1,w>>mip),mh=Math.max(1,h>>mip);
+ const bg=this.device.createBindGroup({layout:pl.getBindGroupLayout(0),
+ entries:[{binding:0,resource:tex.createView({baseMipLevel:mip-1,mipLevelCount:1})}]});
+ const rp=enc.beginRenderPass({colorAttachments:[{
+ view:tex.createView({baseMipLevel:mip,mipLevelCount:1}),loadOp:'clear',storeOp:'store'}]});
+ rp.setPipeline(pl);rp.setBindGroup(0,bg);rp.setViewport(0,0,mw,mh,0,1);rp.draw(6);rp.end();
+ }
+ this.device.queue.submit([enc.finish()]);
+ }
+
+ // ── File loading ─────────────────────────────────────────────────────────
+
+ async loadImage(file) {
+ this.image=await createImageBitmap(file); this.isVideo=false;
+ this.canvas.width=this.image.width; this.canvas.height=this.image.height;
+ this.setVideoCtrl(false);
+ this.log(`Image: ${file.name} (${this.image.width}×${this.image.height})`);
+ if(this.weightsU32){this.setStatus('Ready');this.run();}
+ else{this.setStatus('Image loaded — drop weights .bin');this.showOriginal();}
+ }
+
+ async loadVideo(file) {
+ return new Promise((res,rej)=>{
+ this.video.src=URL.createObjectURL(file);
+ this.video.onloadedmetadata=()=>{
+ const w=this.video.videoWidth,h=this.video.videoHeight;
+ if(!w||!h){rej(new Error('Bad dims'));return;}
+ this.isVideo=true; this.canvas.width=w; this.canvas.height=h;
+ this.log(`Video: ${file.name} (${w}×${h})`);
+ this.setVideoCtrl(true);
+ this.video.onpause=()=>{document.getElementById('btnPP').textContent='Play';};
+ this.video.onplay =()=>{document.getElementById('btnPP').textContent='Pause';this.playLoop();};
+ const go=()=>{
+ this.video.onseeked=()=>{if(!this.isProcessing)this.procFrame();};
+ if(this.video.readyState>=2){this.weightsU32?this.procFrame().then(res):res(this.showOriginal());}
+ else setTimeout(go,50);
+ };
+ this.video.onseeked=go; this.video.currentTime=0;
+ };
+ this.video.onerror=()=>rej(new Error('Video load failed'));
+ });
+ }
+
+ setVideoCtrl(en){['btnPP','btnBk','btnFw'].forEach(id=>document.getElementById(id).disabled=!en);}
+ togglePlay(){this.video.paused?this.video.play():this.video.pause();}
+ stepFrame(d){if(!this.isVideo)return;this.video.pause();
+ this.video.currentTime=Math.max(0,Math.min(this.video.duration,this.video.currentTime+d/this.fps));}
+ playLoop(){if(this.video.paused||this.video.ended)return;
+ if(!this.isProcessing)this.procFrame();requestAnimationFrame(()=>this.playLoop());}
+ async procFrame(){if(!this.weightsU32||this.isProcessing)return;this.isProcessing=true;await this.run();this.isProcessing=false;}
+
+ async loadWeights(file) {
+ try {
+ const buf=await file.arrayBuffer();
+ this.weightsU32=this.parseWeights(buf); this.weightsBuffer=buf;
+ if(this.weightsGPU){this.weightsGPU.destroy();this.weightsGPU=null;}
+ const el=document.getElementById('wDrop');
+ el.textContent=`✓ ${file.name}`; el.classList.add('ok');
+ this.log(`Weights: ${file.name}`);
+ if(this.image||this.isVideo){this.setStatus('Ready');this.run();}
+ else this.setStatus('Weights loaded — drop image/video');
+ } catch(e){this.log(`Weights error: ${e.message}`,'err');document.getElementById('wDrop').classList.add('err');}
+ }
+
+ async loadFilm(file) {
+ try {
+ const buf=await file.arrayBuffer();
+ this.filmMlp=this.parseFilm(buf);
+ const el=document.getElementById('fDrop');
+ el.textContent=`✓ ${file.name}`; el.classList.add('ok');
+ document.getElementById('fSt').textContent='FiLM MLP loaded';
+ document.getElementById('fSt').style.color='#28a745';
+ if(this.image||this.isVideo)this.run();
+ } catch(e){this.log(`FiLM error: ${e.message}`,'err');document.getElementById('fDrop').classList.add('err');}
+ }
+
+ fslide(valId,el){document.getElementById(valId).textContent=parseFloat(el.value).toFixed(2);this.rerun();}
+ rerun(){if(this.image||this.isVideo)this.run();}
+ setBlend(v){this.blend=parseFloat(v);document.getElementById('blendV').textContent=this.blend.toFixed(2);if(this.lastResult)this.redisplay();}
+
+ // ── Main run ─────────────────────────────────────────────────────────────
+
+ async run() {
+ if(!this.weightsU32||!this.device)return;
+ const src=this.isVideo?this.video:this.image;
+ if(!src)return;
+ const t0=performance.now();
+ const {w,h}=this.getDims();
+ const W2=w>>1,H2=h>>1,W4=W2>>1,H4=H2>>1;
+
+ this.context.configure({device:this.device,format:this.format});
+
+ // Input texture with mipmaps
+ if(this.inputTex)this.inputTex.destroy();
+ this.inputTex=this.device.createTexture({size:[w,h],format:'rgba8unorm',mipLevelCount:3,
+ usage:GPUTextureUsage.TEXTURE_BINDING|GPUTextureUsage.COPY_DST|GPUTextureUsage.RENDER_ATTACHMENT});
+ this.device.queue.copyExternalImageToTexture({source:src},{texture:this.inputTex,mipLevel:0},[w,h]);
+ this.generateMipmaps(this.inputTex,w,h);
+
+ // Intermediate textures
+ const mk=(fmt,tw,th)=>this.device.createTexture({size:[tw,th],format:fmt,
+ usage:GPUTextureUsage.STORAGE_BINDING|GPUTextureUsage.TEXTURE_BINDING|GPUTextureUsage.COPY_SRC});
+ const f0=mk('rgba32uint',w,h),f1=mk('rgba32uint',w,h);
+ const e0=mk('rgba16float',w,h),e1=mk('rgba32uint',W2,H2);
+ const bn=mk('rgba32uint',W4,H4),d1=mk('rgba16float',W2,H2),ot=mk('rgba16float',w,h);
+
+ // Weights GPU buffer (cached)
+ if(!this.weightsGPU){
+ this.weightsGPU=this.device.createBuffer({size:this.weightsBuffer.byteLength,
+ usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST});
+ this.device.queue.writeBuffer(this.weightsGPU,0,this.weightsBuffer);
+ }
+ const wg=this.weightsGPU;
+
+ const fp=this.filmParams();
+ const wu=(data)=>{
+ const b=this.device.createBuffer({size:data.byteLength,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
+ this.device.queue.writeBuffer(b,0,data); return b;
+ };
+ const uE0=wu(this.u4(ENC0_OFF,fp.ge0,fp.be0));
+ const uE1=wu(this.u8(ENC1_OFF,fp.ge1,fp.be1));
+ const uBN=wu(this.ubn(BN_OFF));
+ const uD1=wu(this.u4(DEC1_OFF,fp.gd1,fp.bd1));
+ const uD0=wu(this.u4(DEC0_OFF,fp.gd0,fp.bd0));
+
+ const dispData=new ArrayBuffer(16);
+ const dispView=new DataView(dispData);
+ dispView.setFloat32(0,this.viewMode,true); dispView.setFloat32(4,this.blend,true);
+ const uDp=wu(dispData);
+
+ const enc=this.device.createCommandEncoder();
+ const bg=(pl,...entries)=>this.device.createBindGroup({layout:pl.getBindGroupLayout(0),
+ entries:entries.map((r,i)=>({binding:i,resource:r}))});
+ const rv=(t)=>t.createView();
+ const cp=(pl,bgr,wx,wy)=>{const p=enc.beginComputePass();p.setPipeline(pl);p.setBindGroup(0,bgr);p.dispatchWorkgroups(wx,wy);p.end();};
+ const ceil8=(n)=>Math.ceil(n/8);
+
+ cp(this.getPack(), bg(this.getPack(), rv(this.inputTex),this.linearSampler,rv(f0),rv(f1)), ceil8(w),ceil8(h));
+ cp(this.getEnc0(), bg(this.getEnc0(), rv(f0),rv(f1),{buffer:wg},{buffer:uE0},rv(e0)), ceil8(w),ceil8(h));
+ cp(this.getEnc1(), bg(this.getEnc1(), rv(e0),{buffer:wg},{buffer:uE1},rv(e1)), ceil8(W2),ceil8(H2));
+ cp(this.getBN(), bg(this.getBN(), rv(e1),{buffer:wg},{buffer:uBN},rv(bn)), ceil8(W4),ceil8(H4));
+ cp(this.getDec1(), bg(this.getDec1(), rv(bn),rv(e1),{buffer:wg},{buffer:uD1},rv(d1)), ceil8(W2),ceil8(H2));
+ cp(this.getDec0(), bg(this.getDec0(), rv(d1),rv(e0),{buffer:wg},{buffer:uD0},rv(ot)), ceil8(w),ceil8(h));
+
+ const dbg=bg(this.getDisp(),rv(ot),rv(this.inputTex),{buffer:uDp});
+ const rp=enc.beginRenderPass({colorAttachments:[{view:this.context.getCurrentTexture().createView(),loadOp:'clear',storeOp:'store'}]});
+ rp.setPipeline(this.getDisp());rp.setBindGroup(0,dbg);rp.draw(6);rp.end();
+
+ this.device.queue.submit([enc.finish()]);
+ await this.device.queue.onSubmittedWorkDone();
+
+ const ms=(performance.now()-t0).toFixed(1);
+ this.setStatus(`${ms}ms · ${w}×${h} · ${['CNN','Orig','Diff'][this.viewMode]}`);
+ this.log(`Run: ${ms}ms`);
+
+ // Cleanup uniforms
+ [uE0,uE1,uBN,uD1,uD0].forEach(b=>b.destroy());
+
+ // Store for layer viz & redisplay
+ this.destroyLayerTex();
+ this.layerTextures={feat0:f0,feat1:f1,enc0:e0,enc1:e1,bn,dec1:d1,output:ot};
+ this.lastResult={ot,itex:this.inputTex,uDp,dispPL:this.getDisp(),w,h};
+ this.updateVizPanel();
+ }
+
+ destroyLayerTex(){for(const t of Object.values(this.layerTextures||{}))try{t.destroy();}catch(_){} this.layerTextures={};}
+
+ redisplay() {
+ if(!this.lastResult||!this.device)return;
+ const {ot,itex,dispPL,w,h}=this.lastResult;
+ const dispData=new ArrayBuffer(16),dv=new DataView(dispData);
+ dv.setFloat32(0,this.viewMode,true);dv.setFloat32(4,this.blend,true);
+ const uDp=this.device.createBuffer({size:16,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
+ this.device.queue.writeBuffer(uDp,0,dispData);
+ const dbg=this.device.createBindGroup({layout:dispPL.getBindGroupLayout(0),entries:[
+ {binding:0,resource:ot.createView()},{binding:1,resource:itex.createView()},{binding:2,resource:{buffer:uDp}}]});
+ this.context.configure({device:this.device,format:this.format});
+ const enc=this.device.createCommandEncoder();
+ const rp=enc.beginRenderPass({colorAttachments:[{view:this.context.getCurrentTexture().createView(),loadOp:'clear',storeOp:'store'}]});
+ rp.setPipeline(dispPL);rp.setBindGroup(0,dbg);rp.draw(6);rp.end();
+ this.device.queue.submit([enc.finish()]);
+ uDp.destroy();
+ this.setStatus(`${w}×${h} · ${['CNN','Orig','Diff'][this.viewMode]}`);
+ }
+
+ showOriginal() {
+ const src=this.isVideo?this.video:this.image;
+ if(!src||!this.device)return;
+ const {w,h}=this.getDims();
+ this.context.configure({device:this.device,format:this.format});
+ const tex=this.device.createTexture({size:[w,h],format:'rgba8unorm',
+ usage:GPUTextureUsage.TEXTURE_BINDING|GPUTextureUsage.COPY_DST|GPUTextureUsage.RENDER_ATTACHMENT});
+ this.device.queue.copyExternalImageToTexture({source:src},{texture:tex},[w,h]);
+ const code=`@group(0) @binding(0) var t:texture_2d<f32>;
+ @vertex fn vs(@builtin(vertex_index) i:u32)->@builtin(position) vec4f{
+ var p=array<vec2f,6>(vec2f(-1.,-1.),vec2f(1.,-1.),vec2f(-1.,1.),vec2f(-1.,1.),vec2f(1.,-1.),vec2f(1.,1.));
+ return vec4f(p[i],0.,1.);}
+ @fragment fn fs(@builtin(position) pos:vec4f)->@location(0) vec4f{return textureLoad(t,vec2i(pos.xy),0);}`;
+ const pl=this.device.createRenderPipeline({layout:'auto',
+ vertex:{module:this.device.createShaderModule({code}),entryPoint:'vs'},
+ fragment:{module:this.device.createShaderModule({code}),entryPoint:'fs',targets:[{format:this.format}]}});
+ const bg=this.device.createBindGroup({layout:pl.getBindGroupLayout(0),entries:[{binding:0,resource:tex.createView()}]});
+ const enc=this.device.createCommandEncoder();
+ const rp=enc.beginRenderPass({colorAttachments:[{view:this.context.getCurrentTexture().createView(),loadOp:'clear',storeOp:'store'}]});
+ rp.setPipeline(pl);rp.setBindGroup(0,bg);rp.draw(6);rp.end();
+ this.device.queue.submit([enc.finish()]);
+ tex.destroy();
+ }
+
+ // ── Layer visualization ──────────────────────────────────────────────────
+
+ updateVizPanel() {
+ const DEFS=[
+ {id:'feat0', lbl:'Feat', t:'u32',nch:8, ch:['alb.r','alb.g','alb.b','nrm.x','nrm.y','depth','dgx','dgy']},
+ {id:'enc0', lbl:'Enc0', t:'f32',nch:4, ch:['c0','c1','c2','c3']},
+ {id:'enc1', lbl:'Enc1', t:'u32',nch:8, ch:['c0','c1','c2','c3','c4','c5','c6','c7']},
+ {id:'bn', lbl:'BN', t:'u32',nch:8, ch:['c0','c1','c2','c3','c4','c5','c6','c7']},
+ {id:'dec1', lbl:'Dec1', t:'f32',nch:4, ch:['c0','c1','c2','c3']},
+ {id:'output',lbl:'Output', t:'f32',nch:4, ch:['R','G','B','A']},
+ ];
+ this.vizDefs=DEFS;
+ const panel=document.getElementById('layerViz');
+ let html='<div class="vzbtns">';
+ for(const d of DEFS) html+=`<button id="vb_${d.id}" onclick="tester.vizLayer('${d.id}')">${d.lbl}</button>`;
+ html+='</div><div class="chgrid" id="chgrid"></div>';
+ panel.innerHTML=html;
+ this.vizLayer('output');
+ }
+
+ async vizLayer(id) {
+ const tex=this.layerTextures[id]; if(!tex||!this.device)return;
+ this.vizDefs.forEach(d=>document.getElementById(`vb_${d.id}`)?.classList.remove('act'));
+ document.getElementById(`vb_${id}`)?.classList.add('act');
+ const def=this.vizDefs.find(d=>d.id===id); if(!def)return;
+ const grid=document.getElementById('chgrid'); grid.innerHTML='';
+ for(let c=0;c<def.nch;c++){
+ const cell=document.createElement('div'); cell.className='chcell';
+ const lbl=document.createElement('div'); lbl.className='chcell-lbl'; lbl.textContent=def.ch[c]||`c${c}`;
+ const cvs=document.createElement('canvas');
+ cell.appendChild(lbl); cell.appendChild(cvs); grid.appendChild(cell);
+ }
+ const pl=def.t==='f32'?this.getVizF32():this.getVizU32();
+ for(let c=0;c<def.nch;c++){
+ const cvs=grid.children[c]?.querySelector('canvas'); if(!cvs)continue;
+ cvs.width=tex.width; cvs.height=tex.height;
+ const ctx=cvs.getContext('webgpu'); if(!ctx)continue;
+ try{ctx.configure({device:this.device,format:this.format});}catch(_){continue;}
+ const chBuf=this.device.createBuffer({size:4,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});
+ this.device.queue.writeBuffer(chBuf,0,new Uint32Array([c]));
+ const bg=this.device.createBindGroup({layout:pl.getBindGroupLayout(0),
+ entries:[{binding:0,resource:tex.createView()},{binding:1,resource:{buffer:chBuf}}]});
+ const enc=this.device.createCommandEncoder();
+ const rp=enc.beginRenderPass({colorAttachments:[{view:ctx.getCurrentTexture().createView(),loadOp:'clear',storeOp:'store'}]});
+ rp.setPipeline(pl);rp.setBindGroup(0,bg);rp.draw(6);rp.end();
+ this.device.queue.submit([enc.finish()]);
+ chBuf.destroy();
+ }
+ await this.device.queue.onSubmittedWorkDone();
+ }
+
+ // ── Save PNG ─────────────────────────────────────────────────────────────
+
+ async savePNG() {
+ if(!this.lastResult){this.log('No result','err');return;}
+ const {ot,w,h}=this.lastResult;
+ try {
+ const bpr=Math.ceil(w*8/256)*256; // rgba16float = 8 bytes/px
+ const stg=this.device.createBuffer({size:bpr*h,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ});
+ const enc=this.device.createCommandEncoder();
+ enc.copyTextureToBuffer({texture:ot},{buffer:stg,bytesPerRow:bpr,rowsPerImage:h},[w,h]);
+ this.device.queue.submit([enc.finish()]);
+ await stg.mapAsync(GPUMapMode.READ);
+ const raw=new DataView(stg.getMappedRange());
+ const f16=(bits)=>{
+ const s=(bits>>15)&1,e=(bits>>10)&0x1F,f=bits&0x3FF;
+ if(e===0)return 0;if(e===31)return s?0:1;
+ return Math.max(0,Math.min(1,(s?-1:1)*Math.pow(2,e-15)*(1+f/1024)));
+ };
+ const px=new Uint8ClampedArray(w*h*4);
+ for(let y=0;y<h;y++) for(let x=0;x<w;x++){
+ const src=y*bpr+x*8,pi=(y*w+x)*4;
+ px[pi]=Math.round(f16(raw.getUint16(src,true))*255);
+ px[pi+1]=Math.round(f16(raw.getUint16(src+2,true))*255);
+ px[pi+2]=Math.round(f16(raw.getUint16(src+4,true))*255);
+ px[pi+3]=255;
+ }
+ stg.unmap();stg.destroy();
+ const cvs=document.createElement('canvas');cvs.width=w;cvs.height=h;
+ cvs.getContext('2d').putImageData(new ImageData(px,w,h),0,0);
+ const blob=await new Promise(r=>cvs.toBlob(r,'image/png'));
+ const a=document.createElement('a');a.href=URL.createObjectURL(blob);
+ a.download=`cnn_v3_${w}x${h}.png`;a.click();URL.revokeObjectURL(a.href);
+ this.log(`Saved: ${a.download}`);
+ } catch(e){this.log(`Save failed: ${e.message}`,'err');}
+ }
+
+ f16pair(packed) {
+ const lo=packed&0xFFFF,hi=(packed>>16)&0xFFFF;
+ const f=(b)=>{const s=(b>>15)&1,e=(b>>10)&0x1F,m=b&0x3FF;
+ if(e===0)return(s?-1:1)*Math.pow(2,-14)*(m/1024);
+ if(e===31)return m?NaN:(s?-Infinity:Infinity);
+ return(s?-1:1)*Math.pow(2,e-15)*(1+m/1024);};
+ return [f(lo),f(hi)];
+ }
+}
+
+// ── UI helpers ───────────────────────────────────────────────────────────────
+
+function togglePanel(hdr) {
+ hdr.parentElement.classList.toggle('collapsed');
+ const sp=hdr.querySelector('span');
+ if(sp)sp.textContent=hdr.parentElement.classList.contains('collapsed')?'▶':'▼';
+}
+
+// ── Init & events ─────────────────────────────────────────────────────────
+
+const tester=new CNNv3Tester();
+
+document.getElementById('wFile').addEventListener('change',e=>{if(e.target.files[0])tester.loadWeights(e.target.files[0]);});
+document.getElementById('fFile').addEventListener('change',e=>{if(e.target.files[0])tester.loadFilm(e.target.files[0]);});
+
+const mainEl=document.getElementById('mainDrop');
+mainEl.addEventListener('dragover',e=>{e.preventDefault();mainEl.classList.add('dragover');});
+mainEl.addEventListener('dragleave',()=>mainEl.classList.remove('dragover'));
+mainEl.addEventListener('drop',async e=>{
+ e.preventDefault();mainEl.classList.remove('dragover');
+ for(const f of e.dataTransfer.files){
+ if(f.name.endsWith('.bin')){
+ if(f.name.includes('film')||f.name.includes('mlp'))tester.loadFilm(f);
+ else tester.loadWeights(f);
+ } else if(f.type.startsWith('image/'))tester.loadImage(f);
+ else if(f.type.startsWith('video/'))tester.loadVideo(f);
+ }
+});
+
+['wDrop','fDrop'].forEach(id=>{
+ const el=document.getElementById(id);
+ el.addEventListener('dragover',e=>{e.preventDefault();el.classList.add('dragover');});
+ el.addEventListener('dragleave',()=>el.classList.remove('dragover'));
+ el.addEventListener('drop',async e=>{
+ e.preventDefault();el.classList.remove('dragover');
+ const f=e.dataTransfer.files[0];if(!f)return;
+ if(id==='fDrop')tester.loadFilm(f);else tester.loadWeights(f);
+ });
+});
+
+document.addEventListener('keydown',e=>{
+ if(e.target.tagName==='INPUT')return;
+ if(e.key===' '){e.preventDefault();tester.viewMode=tester.viewMode===1?0:1;tester.redisplay();}
+ else if(e.key==='d'||e.key==='D'){tester.viewMode=tester.viewMode===2?0:2;tester.redisplay();}
+});