diff --git a/ventures/huebrain/app.js b/ventures/huebrain/app.js new file mode 100644 index 0000000..59f27b8 --- /dev/null +++ b/ventures/huebrain/app.js @@ -0,0 +1,193 @@ +// Huebrain: a 4-8-1 neural net trained in the page on Love/Nope taps. No network calls. +const N = 20, H = 8, BG = [20, 18, 26]; +// 20 sample taps (earthy colors loved, neon/cool colors passed) go through addTap + fit, like a user's taps. +const SAMPLE = [['#c8795a',1],['#00e5ff',0],['#8fa88a',1],['#ff00cc',0],['#e0b89a',1],['#4a4a55',0],['#b5603f',1],['#39ff14',0],['#f1e4cf',1],['#2b3a8c',0],['#a9bfa0',1],['#ffee00',0],['#d9a37c',1],['#8a8fa8',0],['#7c9a8a',1],['#6b2fd0',0],['#e8c9a0',1],['#1fa0ff',0],['#c98f6b',1],['#d0d3dc',0]]; + +const rng = s => () => { s = (s + 0x6D2B79F5) | 0; let t = Math.imul(s ^ s >>> 15, 1 | s); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; +const rgb = ({ h, s, l }) => { const a = s * Math.min(l, 1 - l), f = n => { const k = (n + h / 30) % 12; return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); }; return [f(0), f(8), f(4)].map(v => Math.round(v * 255)); }; +const hex = c => '#' + rgb(c).map(v => v.toString(16).padStart(2, '0')).join(''); +function fromHex(x) { + const r = [1, 3, 5].map(i => parseInt(x.slice(i, i + 2), 16) / 255), mx = Math.max(...r), mn = Math.min(...r), l = (mx + mn) / 2, d = mx - mn; + let h = 0, s = 0; + if (d) { s = d / (1 - Math.abs(2 * l - 1)); h = 60 * (mx == r[0] ? ((r[1] - r[2]) / d + 6) % 6 : mx == r[1] ? (r[2] - r[0]) / d + 2 : (r[0] - r[1]) / d + 4); } + return { h, s, l }; +} +const feat = c => [Math.cos(c.h * Math.PI / 180), Math.sin(c.h * Math.PI / 180), 2 * c.s - 1, 2 * c.l - 1]; + +// weights flat: W1[h*4+i] 0..31, b1 32..39, w2 40..47, b2 48 +const newNet = seed => { const r = rng(seed); return Array.from({ length: 49 }, (_, i) => i < 32 || (i >= 40 && i < 48) ? (r() - .5) * 2 : 0); }; +function forward(p, x) { + const a = []; + let z = p[48]; + for (let h = 0; h < H; h++) { let s = p[32 + h]; for (let i = 0; i < 4; i++) s += p[h * 4 + i] * x[i]; a[h] = Math.tanh(s); z += p[40 + h] * a[h]; } + return { a, z, y: 1 / (1 + Math.exp(-z)) }; +} +const predict = (p, c) => forward(p, feat(c)).y; +function train(p, taps, epochs, lr = .15, reg = .003) { + for (let e = 0; e < epochs; e++) for (const t of taps) { + const x = feat(t.c), { a, y } = forward(p, x), dz = y - t.y; + for (let h = 0; h < H; h++) { + const dh = dz * p[40 + h] * (1 - a[h] * a[h]); + p[40 + h] -= lr * (dz * a[h] + reg * p[40 + h]); + for (let i = 0; i < 4; i++) p[h * 4 + i] -= lr * (dh * x[i] + reg * p[h * 4 + i]); + p[32 + h] -= lr * dh; + } + p[48] -= lr * dz; + } +} +const addTap = (b, c, y) => b.taps.push({ c, y }); +const fit = (b, epochs) => train(b.p, b.taps, epochs); + +function palette(p) { + const cand = []; + for (let h = 0; h < 360; h += 10) for (const s of [.3, .5, .7, .9]) for (const l of [.3, .45, .6, .75]) { const c = { h, s, l }; cand.push({ c, z: forward(p, feat(c)).z, v: rgb(c) }); } + cand.sort((a, b) => b.z - a.z); + const out = []; + for (const d of [90, 60, 30, 0]) for (const k of cand) if (out.length < 5 && !out.includes(k) && out.every(o => Math.hypot(...o.v.map((v, i) => v - k.v[i])) > d)) out.push(k); + return out.map(k => hex(k.c)); +} +function match(a, b) { + let sum = 0, n = 0; + for (let h = 0; h < 360; h += 15) for (const s of [.3, .6, .9]) for (const l of [.3, .5, .7]) { const c = { h, s, l }; sum += 1 - Math.abs(predict(a, c) - predict(b, c)); n++; } + return Math.round(100 * sum / n); +} +const enc = p => btoa(String.fromCharCode(...p.map(w => Math.max(-127, Math.min(127, Math.round(w * 16))) + 128))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +const dec = s => { try { const b = atob(s.replace(/-/g, '+').replace(/_/g, '/')); return b.length == 49 ? Array.from(b, ch => (ch.charCodeAt(0) - 128) / 16) : null; } catch (e) { return null; } }; + +function wheelCanvas(p, l, n) { + const cv = document.createElement('canvas'); cv.width = cv.height = n; + const x = cv.getContext('2d'), im = x.createImageData(n, n), R = n / 2; + for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) { + const dx = (i + .5 - R) / R, dy = (j + .5 - R) / R, r = Math.hypot(dx, dy); + if (r > 1) continue; + const h = (Math.atan2(dy, dx) * 180 / Math.PI + 360) % 360, c = { h, s: r, l }, t = .08 + .92 * Math.pow(predict(p, c), 1.5), v = rgb(c), o = (j * n + i) * 4; + for (let k = 0; k < 3; k++) im.data[o + k] = BG[k] + (v[k] - BG[k]) * t; + im.data[o + 3] = 255; + } + x.putImageData(im, 0, 0); + return cv; +} + +const randColor = () => ({ h: Math.random() * 360, s: .25 + .7 * Math.random(), l: .22 + .58 * Math.random() }); +const makeSeq = () => { const o = Math.random() * 360; return Array.from({ length: N }, (_, i) => ({ h: (o + i * 137.508) % 360, s: .25 + .7 * Math.random(), l: .22 + .58 * Math.random() })); }; +function sampleBrain() { const b = { p: newNet(7), taps: [], own: false }; SAMPLE.forEach(([x, y]) => addTap(b, fromHex(x), y)); fit(b, 700); return b; } + +if (typeof module !== 'undefined') module.exports = { SAMPLE, sampleBrain, fromHex, hex, palette, match, enc, dec, predict, newNet, train, feat }; + +function init() { + const $ = id => document.getElementById(id); + $('try').innerHTML = `
+
+
+
+

+

The net, live

+

4 color inputs, 8 neurons, 1 yes score. Neurons glow as they fire while it trains on your taps.

+
+
+

+ +

Bright = it would say yes. Dim = nope. Hue around, saturation outward.

+
+
+
+

Does it know you?

+

+ +

Everything runs in this page. Nothing is uploaded.

+
`; + + let mine = sampleBrain(), other = null, seq = makeSeq(), g = { c: randColor(), n: 0, ok: 0, msg: '' }, anim = 0; + const m = /^#b=([\w-]+)$/.exec(location.hash), op = m && dec(m[1]); + if (op) other = { p: op }; + const k = () => mine.own ? mine.taps.length : 0; + const draw = (id, cv) => { const x = $(id).getContext('2d'); x.clearRect(0, 0, $(id).width, $(id).height); x.drawImage(cv, 0, 0); }; + const drawWheel = () => draw('wheel', wheelCanvas(mine.p, $('lt').value / 100, 240)); + + function drawNet(c, pulse) { + const p = mine.p, x = $('net').getContext('2d'), f = feat(c), r = forward(p, f); + const I = f.map((_, i) => [30, 25 + i * 40]), Hd = r.a.map((_, h) => [160, 14 + h * 20.3]); + x.clearRect(0, 0, 320, 170); x.shadowBlur = 0; x.lineWidth = 1; + const line = (a, b, w) => { x.strokeStyle = (w > 0 ? 'rgba(255,176,64,' : 'rgba(90,160,255,') + Math.min(.8, Math.abs(w) / 3) + ')'; x.beginPath(); x.moveTo(a[0], a[1]); x.lineTo(b[0], b[1]); x.stroke(); }; + Hd.forEach((q, h) => { I.forEach((a, i) => line(a, q, p[h * 4 + i])); line(q, [290, 85], p[40 + h]); }); + I.forEach((a, i) => { x.fillStyle = `rgba(230,225,240,${.25 + .5 * (f[i] + 1) / 2})`; x.beginPath(); x.arc(a[0], a[1], 7, 0, 7); x.fill(); }); + Hd.forEach((q, h) => { const v = Math.abs(r.a[h]); x.shadowColor = r.a[h] > 0 ? '#ffb040' : '#5aa0ff'; x.shadowBlur = pulse ? 16 * v : 5 * v; x.fillStyle = (r.a[h] > 0 ? 'rgba(255,176,64,' : 'rgba(90,160,255,') + (.25 + .75 * v) + ')'; x.beginPath(); x.arc(q[0], q[1], 5 + 4 * v * (pulse ? 1.3 : 1), 0, 7); x.fill(); }); + x.shadowBlur = 0; x.fillStyle = `rgba(74,222,128,${.3 + .7 * r.y})`; x.beginPath(); x.arc(290, 85, 11, 0, 7); x.fill(); + x.fillStyle = '#9a93a8'; x.font = '10px system-ui'; x.fillText('color', 12, 166); x.fillText('neurons', 135, 166); x.fillText('yes?', 278, 120); + } + + function animate(frames) { + const id = ++anim, per = Math.ceil(240 / frames); + let f = 0; + (function step() { + if (id !== anim) return; + train(mine.p, mine.taps, per); + drawNet(mine.taps[f % mine.taps.length].c, true); + if (f % 3 == 0) drawWheel(); + if (++f < frames) requestAnimationFrame(step); else render(); + })(); + } + + function tap(y) { + if (k() >= N) return; + if (!mine.own) mine = { p: newNet(Date.now() % 1e6), taps: [], own: true }; + addTap(mine, seq[mine.taps.length], y); + render(); + animate(24); + } + + function render() { + const n = k(), done = n >= N, cur = seq[Math.min(n, N - 1)]; + $('sw').style.background = done ? '#2a2632' : hex(cur); + $('swHex').textContent = done ? 'All 20 tapped' : hex(cur); + $('count').textContent = n + ' / ' + N; + $('prog').style.width = 100 * n / N + '%'; + $('love').disabled = $('nope').disabled = done; + $('status').textContent = !mine.own ? 'Preloaded: a brain trained on 20 sample taps. Tap Love or Nope to train your own from scratch.' : done ? 'Done. Your brain is trained on your 20 taps. Download it or copy your link.' : 'The net retrains after every tap.'; + $('brainName').textContent = mine.own ? 'Your brain' : 'Sample brain (20 sample taps)'; + drawWheel(); + drawNet(cur, false); + $('pal').innerHTML = palette(mine.p).map(c => `
${c}
`).join(''); + $('gsw').style.background = hex(g.c); + $('gmsg').textContent = (g.msg ? g.msg + ' ' : 'Pick what you would say about this new swatch; the brain guessed too. ') + (g.n ? `Matched ${g.ok} of ${g.n}.` : ''); + $('cmp').hidden = !other; + if (other) { + draw('cw1', wheelCanvas(other.p, .5, 120)); + $('f2').hidden = !mine.own; + if (mine.own) draw('cw2', wheelCanvas(mine.p, .5, 120)); + $('cmsg').textContent = mine.own ? `Match score: ${match(mine.p, other.p)}%` : 'Tap your own 20 to see the match score.'; + } + } + + function guess(y) { + const p = predict(mine.p, g.c), say = p >= .5 ? 1 : 0, ok = say == y; + g.n++; g.ok += ok; g.msg = `Brain guessed ${say ? 'Love' : 'Nope'} (${Math.round(100 * (say ? p : 1 - p))}% sure): ${ok ? 'it matched you.' : 'it missed.'}`; + g.c = randColor(); render(); + } + + function wallpaper() { + const c = document.createElement('canvas'); c.width = 1080; c.height = 1920; + const x = c.getContext('2d'), pal = palette(mine.p); + x.fillStyle = '#14121a'; x.fillRect(0, 0, 1080, 1920); + x.fillStyle = '#f4efe6'; x.font = '700 76px system-ui,sans-serif'; x.fillText(mine.own ? 'My color brain' : 'A sample color brain', 80, 190); + x.fillStyle = '#9a93a8'; x.font = '34px system-ui,sans-serif'; x.fillText('Heat map of the colors it would say yes to', 80, 250); + x.drawImage(wheelCanvas(mine.p, .5, 640), 90, 340, 900, 900); + pal.forEach((h, i) => { x.fillStyle = h; x.fillRect(80 + i * 190, 1330, 170, 300); x.fillStyle = '#f4efe6'; x.font = '28px system-ui,sans-serif'; x.fillText(h, 90 + i * 190, 1672); }); + x.fillStyle = '#6f6a7c'; x.font = '30px system-ui,sans-serif'; x.fillText('Huebrain: trained and drawn in the browser', 80, 1820); + c.toBlob(b => { const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = 'huebrain-wallpaper.png'; a.click(); }); + } + + $('love').onclick = () => tap(1); $('nope').onclick = () => tap(0); + $('gl').onclick = () => guess(1); $('gn').onclick = () => guess(0); + $('lt').oninput = drawWheel; + $('dl').onclick = () => { wallpaper(); $('amsg').textContent = 'Wallpaper PNG made in this page.'; }; + $('again').onclick = () => { anim++; mine = sampleBrain(); seq = makeSeq(); g = { c: randColor(), n: 0, ok: 0, msg: '' }; render(); }; + $('copy').onclick = () => { + const u = location.origin + location.pathname + '#b=' + enc(mine.p), l = $('lnk'); + l.value = u; l.hidden = false; + (navigator.clipboard ? navigator.clipboard.writeText(u) : Promise.reject()).then(() => $('amsg').textContent = 'Link copied. It holds your net weights, nothing else.', () => { l.select(); $('amsg').textContent = 'Copy the link above.'; }); + }; + document.addEventListener('keydown', e => { if (e.target.tagName == 'INPUT' || e.target.tagName == 'BUTTON') return; if (e.key == 'ArrowRight') tap(1); if (e.key == 'ArrowLeft') tap(0); }); + render(); +} +if (typeof document !== 'undefined') init();