Dead Robot Logo Right Logo

Blog 🤖


↩ Back to Blog ← Previous Post Next Post →
Hex Color Picker From Image HTML Thumbnail

Hex Color Picker From Image HTML

i vibe-coded a super useful tool for loading an image and picking a color palette from it :)

there's also a "blur" slider so u can average out the image's colors to get smoother and more muted/averaged tones

you can also save ur palette as a PS gradient map file to quickly bring your palette into photoshop for cool color grading effects

EDIT: i built and trained a weighted scoring algorithm on ~800 of my own hand-picked palettes to better predict the starting colors

load an image to begin
BLUR AMOUNT
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hex Color Picker From Image</title>
<style>
  body { margin: 0; background: #000; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; box-sizing: border-box; }
</style>
</head>
<body>

<div id="dr-palette-tool">
  <style>
    @font-face {
      font-family: 'DRDynamo';
      src: url('https://deadrobotmusic.com/DR%20Dynamo.woff2') format('woff2');
    }
    #dr-palette-tool {
      font-family: 'DRDynamo', 'JetBrains Mono', 'Montserrat', sans-serif;
      font-weight: 300;
      color: #b8b3ff;
      background: #000;
      padding: 24px;
      border-radius: 10px;
    }
    #dr-palette-tool * { box-sizing: border-box; font-family: inherit; }
    #dr-palette-tool .dr-section { display: flex; justify-content: center; margin-bottom: 24px; }
    #dr-palette-tool .dr-file-picker {
      display: inline-block; background: #000; color: #b8b3ff; border: 2px solid #6e50c6;
      padding: 14px 26px; border-radius: 8px; cursor: pointer; font-weight: 600;
      letter-spacing: 1px; text-transform: uppercase; font-size: 16px;
    }
    #dr-palette-tool .dr-file-picker:hover { background: #14102b; }
    #dr-palette-tool .dr-file-picker input { display: none; }
    #dr-palette-tool .dr-main-row { display: flex; width: 100%; gap: 32px; flex-wrap: wrap; justify-content: center; }
    #dr-palette-tool .dr-image-panel { flex: 1 1 auto; width: 100%; max-width: 630px; min-width: 0; }
    #dr-palette-tool .dr-image-container {
      position: relative; display: flex; align-items: center; justify-content: center;
      border-radius: 0; overflow: visible;
      width: 100%; max-width: 630px; aspect-ratio: 630 / 475; margin: 0 auto;
      background: #0a0a14; border: 1px dashed #3a3570; line-height: 0;
    }
    #dr-palette-tool .dr-image-container.dr-has-image { border-style: solid; border-color: #3a3570; }
    #dr-palette-tool .dr-image-container canvas { display: block; }
    #dr-palette-tool .dr-placeholder-text {
      color: #4a4580; font-size: 13px; letter-spacing: 0.5px; text-align: center; padding: 20px;
      cursor: pointer;
    }
    #dr-palette-tool .dr-circle {
      position: absolute; width: 30px; height: 30px; margin-left: -15px; margin-top: -15px;
      border-radius: 50%; border: 2px solid white; box-shadow: 0 1px 6px rgba(0,0,0,0.7);
      display: flex; align-items: center; justify-content: center; font-weight: bold;
      font-size: 13px; cursor: grab; touch-action: none; user-select: none;
    }
    #dr-palette-tool .dr-circle:active { cursor: grabbing; }
    #dr-palette-tool .dr-blur-row { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 630px; max-width: 100%; }
    #dr-palette-tool .dr-blur-label { font-size: 14px; letter-spacing: 1px; color: #8a84c4; text-transform: uppercase; }
    #dr-palette-tool .dr-blur-slider { width: 100%; accent-color: #6e50c6; }
    #dr-palette-tool .dr-swatch-row { display: flex; width: 100%; gap: clamp(6px, 2.5vw, 20px); flex-wrap: nowrap; justify-content: center; }
    #dr-palette-tool .dr-swatch { text-align: center; flex: 1 1 0; min-width: 0; max-width: 110px; }
    #dr-palette-tool .dr-swatch-box { width: 100%; aspect-ratio: 1 / 1; border-radius: 0; border: 1px solid #3a3570; margin-bottom: 8px; background: #0a0a14; }
    #dr-palette-tool .dr-hex-btn {
      width: 100%; background: #000; color: #b8b3ff; border: 1px solid #6e50c6; border-radius: 6px;
      padding: 8px 2px; font-family: 'DRDynamo', 'JetBrains Mono', monospace; font-size: clamp(9px, 2.8vw, 15px); cursor: pointer; letter-spacing: clamp(0px, 0.3vw, 1px);
    }
    #dr-palette-tool .dr-hex-btn:hover { background: #14102b; }
    #dr-palette-tool .dr-hex-btn:disabled { opacity: 0.4; cursor: default; }
    #dr-palette-tool .dr-save-row { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; }
    #dr-palette-tool .dr-save-btn {
      background: #000; color: #b8b3ff; border: 2px solid #6e50c6; border-radius: 8px;
      padding: 14px 24px; font-weight: 600; letter-spacing: 0.5px; cursor: pointer; font-size: 16px;
    }
    #dr-palette-tool .dr-save-btn:hover { background: #14102b; }
    #dr-palette-tool .dr-save-btn:disabled { color: #4a4580; border-color: #2a2560; cursor: not-allowed; }
    #dr-palette-tool .dr-save-btn:disabled:hover { background: #000; }
    #dr-palette-tool .dr-status { text-align: center; font-size: 12px; color: #8a84c4; margin-top: -8px; margin-bottom: 16px; min-height: 16px; }
  </style>

  <div class="dr-section">
    <label class="dr-file-picker">LOAD NEW IMAGE
      <input type="file" id="dr-file-input" accept="image/*">
    </label>
  </div>

  <div class="dr-status" id="dr-status"></div>

  <div class="dr-section">
    <div class="dr-main-row">
      <div class="dr-image-panel">
        <div class="dr-image-container" id="dr-image-container">
          <canvas id="dr-display-canvas" style="display:none;"></canvas>
          <div class="dr-placeholder-text" id="dr-placeholder-text">load an image to begin</div>
        </div>
      </div>
    </div>
  </div>

  <div class="dr-section">
    <div class="dr-blur-row">
      <div class="dr-blur-label">BLUR AMOUNT</div>
      <input type="range" class="dr-blur-slider" id="dr-blur-slider" min="0" max="20" step="1" value="0">
    </div>
  </div>

  <div class="dr-section">
    <div class="dr-swatch-row" id="dr-swatch-row"></div>
  </div>

  <div class="dr-section">
    <div class="dr-save-row">
      <button class="dr-save-btn" id="dr-save-gradient" disabled>SAVE PS GRADIENT MAP</button>
    </div>
  </div>
</div>

<script>
(function() {
  // ============================================================
  // Color space conversions
  // ============================================================
  function rgbToLab(r, g, b) {
    function toLinear(c) { c = c/255; return c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); }
    const rl=toLinear(r), gl=toLinear(g), bl=toLinear(b);
    let x = rl*0.4124564 + gl*0.3575761 + bl*0.1804375;
    let y = rl*0.2126729 + gl*0.7151522 + bl*0.0721750;
    let z = rl*0.0193339 + gl*0.1191920 + bl*0.9503041;
    x/=0.95047; y/=1.0; z/=1.08883;
    function f(t){ return t>0.008856 ? Math.cbrt(t) : (7.787*t+16/116); }
    const fx=f(x),fy=f(y),fz=f(z);
    return [116*fy-16, 500*(fx-fy), 200*(fy-fz)];
  }
  function labToRgb(L,a,b) {
    const fy=(L+16)/116, fx=fy+a/500, fz=fy-b/200;
    function finv(t){ const t3=t*t*t; return t3>0.008856 ? t3 : (t-16/116)/7.787; }
    let x=finv(fx)*0.95047, y=finv(fy)*1.0, z=finv(fz)*1.08883;
    let r = x*3.2404542 + y*-1.5371385 + z*-0.4985314;
    let g = x*-0.9692660 + y*1.8760108 + z*0.0415560;
    let bl = x*0.0556434 + y*-0.2040259 + z*1.0572252;
    function toSRGB(c){ c = c<=0.0031308 ? c*12.92 : 1.055*Math.pow(c,1/2.4)-0.055; return Math.max(0,Math.min(255,Math.round(c*255))); }
    return [toSRGB(r), toSRGB(g), toSRGB(bl)];
  }
  function rgbToHsl(r, g, b) {
    r/=255; g/=255; b/=255;
    const mx=Math.max(r,g,b), mn=Math.min(r,g,b);
    const l=(mx+mn)/2; let s=0,h=0; const d=mx-mn;
    if (d!==0) {
      s = l<0.5 ? d/(mx+mn) : d/(2-mx-mn);
      if (mx===r) h=((g-b)/d)%6; else if (mx===g) h=(b-r)/d+2; else h=(r-g)/d+4;
      h*=60; if (h<0) h+=360;
    }
    return [h, s, l];
  }
  function labDeltaE(a, b) { return Math.sqrt((a[0]-b[0])**2 + (a[1]-b[1])**2 + (a[2]-b[2])**2); }
  function toHex(rgb) { return "#"+rgb.map(v=>Math.max(0,Math.min(255,Math.round(v))).toString(16).padStart(2,"0")).join(""); }
  function textColorFor(rgb) { const lum=0.299*rgb[0]+0.587*rgb[1]+0.114*rgb[2]; return lum<140 ? "#ffffff" : "#000000"; }

  // ============================================================
  // crop_letterbox
  // ============================================================
  function cropLetterbox(sourceCanvas, luminanceThreshold=12, stdThreshold=6) {
    const w=sourceCanvas.width, h=sourceCanvas.height;
    const ctx=sourceCanvas.getContext("2d");
    const data=ctx.getImageData(0,0,w,h).data;
    function luminanceAt(x,y){ const i=(y*w+x)*4; return 0.299*data[i]+0.587*data[i+1]+0.114*data[i+2]; }
    function rowIsBar(y){ let sum=0,sumSq=0; for(let x=0;x<w;x++){const l=luminanceAt(x,y);sum+=l;sumSq+=l*l;} const mean=sum/w; return mean<luminanceThreshold && Math.sqrt(Math.max(0,sumSq/w-mean*mean))<stdThreshold; }
    function colIsBar(x){ let sum=0,sumSq=0; for(let y=0;y<h;y++){const l=luminanceAt(x,y);sum+=l;sumSq+=l*l;} const mean=sum/h; return mean<luminanceThreshold && Math.sqrt(Math.max(0,sumSq/h-mean*mean))<stdThreshold; }
    let top=0; while(top<h && rowIsBar(top)) top++;
    let bottom=h; while(bottom>top && rowIsBar(bottom-1)) bottom--;
    let left=0; while(left<w && colIsBar(left)) left++;
    let right=w; while(right>left && colIsBar(right-1)) right--;
    if ((bottom-top)<h*0.5 || (right-left)<w*0.5) return sourceCanvas;
    if (top===0 && bottom===h && left===0 && right===w) return sourceCanvas;
    const cropped=document.createElement("canvas");
    cropped.width=right-left; cropped.height=bottom-top;
    cropped.getContext("2d").drawImage(sourceCanvas,left,top,right-left,bottom-top,0,0,right-left,bottom-top);
    return cropped;
  }

  // ============================================================
  // Fast box blur with clamp-to-edge ("repeating edge") boundary handling --
  // uses a sliding-window running sum so cost is O(width*height) regardless
  // of radius, applied 3x (horizontal+vertical = 1 pass) to approximate a
  // smoother Gaussian falloff while staying fast.
  // ============================================================
  function clampIdx(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
  function boxBlurPass(src, width, height, radius) {
    const channels = 3;
    const tmp = new Float32Array(width * height * channels);
    const out = new Float32Array(width * height * channels);
    const windowSize = radius * 2 + 1;
    for (let y = 0; y < height; y++) {
      for (let c = 0; c < channels; c++) {
        let sum = 0;
        for (let dx = -radius; dx <= radius; dx++) sum += src[(y*width + clampIdx(dx,0,width-1))*channels + c];
        for (let x = 0; x < width; x++) {
          tmp[(y*width+x)*channels+c] = sum / windowSize;
          const xOut = clampIdx(x-radius, 0, width-1), xIn = clampIdx(x+radius+1, 0, width-1);
          sum += src[(y*width+xIn)*channels+c] - src[(y*width+xOut)*channels+c];
        }
      }
    }
    for (let x = 0; x < width; x++) {
      for (let c = 0; c < channels; c++) {
        let sum = 0;
        for (let dy = -radius; dy <= radius; dy++) sum += tmp[(clampIdx(dy,0,height-1)*width + x)*channels + c];
        for (let y = 0; y < height; y++) {
          out[(y*width+x)*channels+c] = sum / windowSize;
          const yOut = clampIdx(y-radius, 0, height-1), yIn = clampIdx(y+radius+1, 0, height-1);
          sum += tmp[(yIn*width+x)*channels+c] - tmp[(yOut*width+x)*channels+c];
        }
      }
    }
    return out;
  }
  function boxBlurRepeatEdge(srcUint8, width, height, radius, passes) {
    if (radius <= 0) return srcUint8;
    passes = passes || 2;
    const n = width * height;
    let data = new Float32Array(n * 3);
    for (let i = 0; i < n; i++) {
      data[i*3] = srcUint8[i*4]; data[i*3+1] = srcUint8[i*4+1]; data[i*3+2] = srcUint8[i*4+2];
    }
    for (let p = 0; p < passes; p++) data = boxBlurPass(data, width, height, radius);
    const out = new Uint8ClampedArray(n * 4);
    for (let i = 0; i < n; i++) {
      out[i*4] = data[i*3]; out[i*4+1] = data[i*3+1]; out[i*4+2] = data[i*3+2]; out[i*4+3] = 255;
    }
    return out;
  }

  // ============================================================
  // Palette predictor -- k-means candidates in Lab space + interpretable
  // feature scoring, trained on 573 hand-picked reference palettes.
  // Ported 1:1 from palette_predictor.js / model.py / candidates.py.
  // Replaces the old per-slot Gaussian region-scoring model.
  // ============================================================
  const MAX_DISPLAY_WIDTH = 630, MAX_DISPLAY_HEIGHT = 475;
  const BLUR_MAX_RADIUS = 20;
  const GRID_SIZE = 80; // matches candidates.py's sample_size default

  const WEIGHTS = {"feature_names":["prominence","saturation","lightness","lightness_dist_mid","chroma","center_dist","edge_dist","contrast_bg"],"weights":{"prominence":2.6479999999999997,"saturation":-0.09584000000000001,"lightness":-0.4,"lightness_dist_mid":0.19584,"chroma":-0.30384,"center_dist":-0.368896,"edge_dist":0.45184,"contrast_bg":-0.14816000000000001,"bias":0.0,"diversity_lambda":1.144},"k_clusters":18,"tau":20.0};
  const FEATURE_NAMES = WEIGHTS.feature_names;
  const K_CLUSTERS = WEIGHTS.k_clusters || 18;
  const TAU = WEIGHTS.tau || 20.0;

  function makeRng(seed) {
    return function() { seed|=0; seed=(seed+0x6D2B79F5)|0; let t=Math.imul(seed^(seed>>>15),1|seed); t=(t+Math.imul(t^(t>>>7),61|t))^t; return ((t^(t>>>14))>>>0)/4294967296; };
  }

  // Samples a GRID_SIZE x GRID_SIZE grid directly from the (possibly
  // non-square) source image, mapping grid position fractionally across
  // its full width/height. This is mathematically the same as stretching
  // the photo to a square and then grid-sampling it (how the model was
  // trained), just without an extra canvas resize step.
  function sampleGridLab(imageData, width, height, gridSize) {
    const labPixels = [], rgbPixels = [], xFracs = [], yFracs = [];
    for (let gy = 0; gy < gridSize; gy++) {
      const yFrac = gy / (gridSize - 1);
      const y = Math.min(height - 1, Math.round(yFrac * (height - 1)));
      for (let gx = 0; gx < gridSize; gx++) {
        const xFrac = gx / (gridSize - 1);
        const x = Math.min(width - 1, Math.round(xFrac * (width - 1)));
        const idx = (y * width + x) * 4;
        const r = imageData[idx], g = imageData[idx+1], b = imageData[idx+2];
        rgbPixels.push([r, g, b]);
        labPixels.push(rgbToLab(r, g, b));
        xFracs.push(xFrac); yFracs.push(yFrac);
      }
    }
    return { labPixels, rgbPixels, xFracs, yFracs };
  }

  // k-means++ in Lab space (mirrors candidates._kmeans_lab exactly).
  function kmeansPlusPlusLab(labPixels, k, iters, seed) {
    const rand = makeRng(seed);
    const n = labPixels.length;
    k = Math.min(k, n);
    const dist2 = (a, b) => (a[0]-b[0])**2 + (a[1]-b[1])**2 + (a[2]-b[2])**2;
    const centers = [labPixels[Math.floor(rand() * n)]];
    let d2 = labPixels.map(p => dist2(p, centers[0]));
    for (let i = 1; i < k; i++) {
      const sum = d2.reduce((a,b) => a+b, 0) || 1e-12;
      let r = rand() * sum, idx = 0;
      for (; idx < n; idx++) { r -= d2[idx]; if (r <= 0) break; }
      idx = Math.min(idx, n - 1);
      centers.push(labPixels[idx]);
      d2 = d2.map((v, i2) => Math.min(v, dist2(labPixels[i2], centers[i])));
    }
    let assign = new Array(n).fill(0);
    for (let it = 0; it < iters; it++) {
      for (let i = 0; i < n; i++) {
        let best = 0, bestD = Infinity;
        for (let j = 0; j < k; j++) {
          const dd = dist2(labPixels[i], centers[j]);
          if (dd < bestD) { bestD = dd; best = j; }
        }
        assign[i] = best;
      }
      const sums = Array.from({length:k}, () => [0,0,0,0]);
      for (let i = 0; i < n; i++) {
        const s = sums[assign[i]];
        s[0]+=labPixels[i][0]; s[1]+=labPixels[i][1]; s[2]+=labPixels[i][2]; s[3]++;
      }
      for (let j = 0; j < k; j++) {
        if (sums[j][3] > 0) centers[j] = [sums[j][0]/sums[j][3], sums[j][1]/sums[j][3], sums[j][2]/sums[j][3]];
      }
    }
    return { centers, assign };
  }

  // Same box-average sampling math as the app's sampleAverageAt (used when
  // dragging a circle), but operating directly on a raw imageData buffer
  // instead of displayCtx -- so candidate generation can use the EXACT
  // same formula, guaranteeing that re-sampling at a candidate's own
  // position reproduces its exact color.
  function sampleAverageInBuffer(imageData, width, height, px, py, radius) {
    radius = radius || 2;
    px = Math.max(0, Math.min(width - 1, Math.round(px)));
    py = Math.max(0, Math.min(height - 1, Math.round(py)));
    const x0 = Math.max(0, px - radius), x1 = Math.min(width, px + radius + 1);
    const y0 = Math.max(0, py - radius), y1 = Math.min(height, py + radius + 1);
    let r = 0, g = 0, b = 0, n = 0;
    for (let y = y0; y < y1; y++) {
      for (let x = x0; x < x1; x++) {
        const idx = (y * width + x) * 4;
        r += imageData[idx]; g += imageData[idx+1]; b += imageData[idx+2]; n++;
      }
    }
    return [r / n, g / n, b / n];
  }

  function generateCandidates(imageData, width, height, k, seed) {
    const { labPixels, rgbPixels, xFracs, yFracs } = sampleGridLab(imageData, width, height, GRID_SIZE);
    const { centers, assign } = kmeansPlusPlusLab(labPixels, k, 15, seed);
    const groups = Array.from({length: centers.length}, () => []);
    assign.forEach((cl, i) => groups[cl].push(i));
    const nTotal = labPixels.length;
    let cands = [];
    for (let j = 0; j < centers.length; j++) {
      const idxs = groups[j];
      if (idxs.length === 0) continue;
      const labC = centers[j];
      let bestIdx = idxs[0], bestD = Infinity;
      for (const i of idxs) {
        const d = labDeltaE(labPixels[i], labC) ** 2;
        if (d < bestD) { bestD = d; bestIdx = i; }
      }
      const rgbC = sampleAverageInBuffer(imageData, width, height, xFracs[bestIdx]*(width-1), yFracs[bestIdx]*(height-1), 2);
      const hslC = rgbToHsl(rgbC[0], rgbC[1], rgbC[2]);
      // x_frac/y_frac: mean position of every pixel in the cluster -- used
      // for the trained model's center_dist/edge_dist features, kept
      // exactly as-is so the trained weights stay valid.
      const xf = idxs.reduce((a,i) => a+xFracs[i], 0) / idxs.length;
      const yf = idxs.reduce((a,i) => a+yFracs[i], 0) / idxs.length;
      // pick_x_frac/pick_y_frac: the ACTUAL position of the representative
      // pixel (bestIdx) that the displayed color (rgbC) came from. A
      // scattered cluster's mean position can land somewhere that pixel's
      // color never actually appears -- using the real pixel's own
      // position for circle placement means resampling there (e.g. as
      // blur changes) reads a color that's actually continuous with the
      // original pick, instead of jumping unpredictably.
      cands.push({
        rgb: rgbC, lab: labC, hsl: hslC, prominence: idxs.length/nTotal,
        x_frac: xf, y_frac: yf,
        pick_x_frac: xFracs[bestIdx], pick_y_frac: yFracs[bestIdx],
      });
    }
    cands.sort((a,b) => b.prominence - a.prominence);
    return cands;
  }

  function computeFeatures(cands) {
    if (cands.length === 0) return [];
    const bgLab = cands[0].lab;
    return cands.map(c => {
      const [, s, l] = c.hsl;
      const cx = c.x_frac, cy = c.y_frac;
      const centerDist = Math.hypot(cx-0.5, cy-0.5);
      const edgeDist = Math.min(cx, 1-cx, cy, 1-cy);
      const chroma = Math.hypot(c.lab[1], c.lab[2]) / 150.0;
      const contrastBg = labDeltaE(c.lab, bgLab) / 100.0;
      return [c.prominence, s, l, Math.abs(l-0.5), chroma, centerDist, edgeDist, contrastBg];
    });
  }

  function scoreCandidates(feats) {
    const bias = WEIGHTS.weights.bias;
    return feats.map(f => f.reduce((s,v,i) => s + v*WEIGHTS.weights[FEATURE_NAMES[i]], 0) + bias);
  }

  function greedySelect(cands, feats, topN) {
    const scores = scoreCandidates(feats);
    const lam = WEIGHTS.weights.diversity_lambda;
    const chosen = [];
    const remaining = new Set(cands.map((_,i) => i));
    while (remaining.size > 0 && chosen.length < topN) {
      let bestI = null, bestVal = -Infinity;
      for (const i of remaining) {
        let penalty = 0;
        for (const j of chosen) penalty += Math.exp(-labDeltaE(cands[i].lab, cands[j].lab) / TAU);
        const val = scores[i] - lam*penalty;
        if (val > bestVal) { bestVal = val; bestI = i; }
      }
      chosen.push(bestI);
      remaining.delete(bestI);
    }
    return chosen;
  }

  // Runs the full predictor and returns 5 {lab, x_frac, y_frac} picks,
  // sorted darkest -> lightest (slot 1 = darkest ... slot 5 = lightest,
  // same convention the old region-scoring model used).
  function predictPalette(imageData, width, height, seed) {
    const cands = generateCandidates(imageData, width, height, K_CLUSTERS, seed);
    const feats = computeFeatures(cands);
    const idxs = greedySelect(cands, feats, 5);
    let chosen = idxs.map(i => cands[i]);
    chosen = chosen.slice().sort((a,b) => a.lab[0] - b.lab[0]);
    return chosen;
  }

  // ============================================================
  // App wiring
  // ============================================================
  const fileInput = document.getElementById('dr-file-input');
  const imageContainer = document.getElementById('dr-image-container');
  const displayCanvas = document.getElementById('dr-display-canvas');
  const placeholderText = document.getElementById('dr-placeholder-text');
  const swatchRow = document.getElementById('dr-swatch-row');
  const statusEl = document.getElementById('dr-status');
  const saveGradientBtn = document.getElementById('dr-save-gradient');
  const blurSlider = document.getElementById('dr-blur-slider');

  let displayCtx = null;
  let dispW = 0, dispH = 0;
  let originalPixelData = null;
  let blurRadius = 0;
  let currentColors = [];
  let algorithmicColors = [];  // the model's exact picks, kept separate from blur resampling
  let circlePositions = [];
  let manualOverride = [false,false,false,false,false];
  let circles = [];
  let swatchBoxes = [], swatchButtons = [];
  let hasImage = false;

  buildSwatchDOM();
  fileInput.addEventListener('change', handleFile);
  saveGradientBtn.addEventListener('click', handleSaveGradient);
  blurSlider.addEventListener('input', handleBlurChange);
  imageContainer.addEventListener('click', ev => {
    if (!hasImage) fileInput.click();
  });
  window.addEventListener('resize', applyCanvasSize);

  function buildSwatchDOM() {
    swatchRow.innerHTML = '';
    swatchBoxes = []; swatchButtons = [];
    for (let i = 0; i < 5; i++) {
      const wrap = document.createElement('div');
      wrap.className = 'dr-swatch';
      const box = document.createElement('div');
      box.className = 'dr-swatch-box';
      const btn = document.createElement('button');
      btn.className = 'dr-hex-btn'; btn.textContent = '------'; btn.disabled = true;
      wrap.appendChild(box); wrap.appendChild(btn);
      swatchRow.appendChild(wrap);
      swatchBoxes.push(box); swatchButtons.push(btn);
      btn.addEventListener('click', () => { if (hasImage) copyHex(btn.textContent, btn); });
    }
  }

  function updateSwatchDisplay(slot, lab) {
    const hex = toHex(labToRgb(...lab));
    swatchBoxes[slot].style.background = hex;
    swatchButtons[slot].textContent = hex;
    swatchButtons[slot].disabled = false;
  }

  function handleFile(ev) {
    const file = ev.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = e => { const img = new Image(); img.onload = () => loadImage(img); img.src = e.target.result; };
    reader.readAsDataURL(file);
  }

  function sizeCanvasToContainer() {
    const containerWidth = imageContainer.getBoundingClientRect().width;
    const scale = containerWidth / MAX_DISPLAY_WIDTH;
    displayCanvas.style.width = (dispW * scale) + 'px';
    displayCanvas.style.height = (dispH * scale) + 'px';
  }

  function applyCanvasSize() {
    if (!hasImage) return;
    sizeCanvasToContainer();
    renderCircles();
  }

  function loadImage(img) {
    statusEl.textContent = "Analyzing image...";
    const fullCanvas = document.createElement('canvas');
    fullCanvas.width = img.naturalWidth; fullCanvas.height = img.naturalHeight;
    fullCanvas.getContext('2d').drawImage(img, 0, 0);
    const cropped = cropLetterbox(fullCanvas);

    const scale = Math.min(MAX_DISPLAY_WIDTH / cropped.width, MAX_DISPLAY_HEIGHT / cropped.height);
    dispW = Math.max(1, Math.round(cropped.width * scale));
    dispH = Math.max(1, Math.round(cropped.height * scale));

    displayCanvas.width = dispW; displayCanvas.height = dispH;
    displayCtx = displayCanvas.getContext('2d');
    displayCtx.drawImage(cropped, 0, 0, dispW, dispH);
    displayCanvas.style.display = 'block';
    placeholderText.style.display = 'none';
    imageContainer.classList.add('dr-has-image');
    sizeCanvasToContainer();

    originalPixelData = new Uint8ClampedArray(displayCtx.getImageData(0, 0, dispW, dispH).data);
    blurRadius = 0;
    blurSlider.value = 0;

    manualOverride = [false,false,false,false,false];
    hasImage = true;

    runFullAnalysis();
    saveGradientBtn.disabled = false;
    statusEl.textContent = "";

    setTimeout(() => { boxBlurRepeatEdge(originalPixelData, dispW, dispH, 5, 2); }, 0);
  }

  // Full pipeline: run the palette predictor on the current (unblurred at
  // load-time) image to get 5 candidate picks, resample any manual-override
  // slots at their fixed position instead -- used on initial image load.
  function runFullAnalysis() {
    const currentImageData = displayCtx.getImageData(0, 0, dispW, dispH).data;
    const picks = predictPalette(currentImageData, dispW, dispH, 42);

    for (let slot = 0; slot < 5; slot++) {
      algorithmicColors[slot] = picks[slot].lab;
      if (manualOverride[slot]) {
        const [x, y] = circlePositions[slot];
        currentColors[slot] = sampleAverageAt(x, y);
      } else {
        currentColors[slot] = picks[slot].lab;
        circlePositions[slot] = [Math.round(picks[slot].pick_x_frac * (dispW - 1)), Math.round(picks[slot].pick_y_frac * (dispH - 1))];
      }
    }

    renderCircles();
    for (let i = 0; i < 5; i++) updateSwatchDisplay(i, currentColors[i]);
  }

  function handleBlurChange() {
    if (!hasImage) return;

    blurRadius = parseInt(blurSlider.value, 10);

    const blurredData = blurRadius > 0
      ? boxBlurRepeatEdge(originalPixelData, dispW, dispH, blurRadius, 3)
      : originalPixelData;

    displayCtx.putImageData(
      new ImageData(
        new Uint8ClampedArray(blurredData),
        dispW,
        dispH
      ),
      0,
      0
    );

    for (let slot = 0; slot < 5; slot++) {
      if (manualOverride[slot]) {
        // manual picks always reflect whatever pixel is actually there now
        const [x, y] = circlePositions[slot];
        currentColors[slot] = sampleAverageAt(x, y);
      } else if (blurRadius === 0) {
        // back to no blur -- restore the model's exact original prediction
        // rather than resampling, since a local pixel average at the pick's
        // position isn't guaranteed to match the k-means cluster's actual
        // representative color
        currentColors[slot] = algorithmicColors[slot];
      } else {
        // blur applied -- preview the softened color at the frozen position
        const [x, y] = circlePositions[slot];
        currentColors[slot] = sampleAverageAt(x, y);
      }
    }

    renderCircles();

    for (let i = 0; i < 5; i++) {
      updateSwatchDisplay(i, currentColors[i]);
    }
  }

  function sampleAverageAt(x,y,radius=2) {
    x=Math.max(0,Math.min(dispW-1,Math.round(x))); y=Math.max(0,Math.min(dispH-1,Math.round(y)));
    const x0=Math.max(0,x-radius),x1=Math.min(dispW,x+radius+1), y0=Math.max(0,y-radius),y1=Math.min(dispH,y+radius+1);
    const data=displayCtx.getImageData(x0,y0,x1-x0,y1-y0).data;
    let sumR=0,sumG=0,sumB=0,count=0;
    for(let i=0;i<data.length;i+=4){sumR+=data[i];sumG+=data[i+1];sumB+=data[i+2];count++;}
    return rgbToLab(sumR/count, sumG/count, sumB/count);
  }

  function getCanvasOffsetInContainer() {
    const canvasRect = displayCanvas.getBoundingClientRect();
    const containerRect = imageContainer.getBoundingClientRect();

    return {
      x: canvasRect.left - containerRect.left,
      y: canvasRect.top - containerRect.top
    };
  }

  function renderCircles() {
    imageContainer.querySelectorAll('.dr-circle').forEach(el => el.remove());
    circles = [];

    const offset = getCanvasOffsetInContainer();
    const canvasRect = displayCanvas.getBoundingClientRect();
    const scaleX = dispW > 0 ? canvasRect.width / dispW : 1;
    const scaleY = dispH > 0 ? canvasRect.height / dispH : 1;

    currentColors.forEach((lab, slot) => {
      const [x, y] = circlePositions[slot];

      const circle = document.createElement('div');
      circle.className = 'dr-circle';
      circle.textContent = slot + 1;

      circle.style.left = (offset.x + x * scaleX) + 'px';
      circle.style.top = (offset.y + y * scaleY) + 'px';

      const rgb = labToRgb(...lab);
      circle.style.background = toHex(rgb);
      circle.style.color = textColorFor(rgb);
      circle.dataset.slot = slot;

      imageContainer.appendChild(circle);
      attachCircleDrag(circle);
      circles.push(circle);
    });
  }

  function attachCircleDrag(circle) {
    let dragging = false;

    circle.addEventListener('pointerdown', ev => {
      dragging = true;
      circle.setPointerCapture(ev.pointerId);
      ev.preventDefault();
    });

    circle.addEventListener('pointermove', ev => {
      if (!dragging) return;

      const rect = displayCanvas.getBoundingClientRect();
      const [nx, ny] = clientToNatural(ev.clientX, ev.clientY, rect);
      const offset = getCanvasOffsetInContainer();
      const scaleX = dispW > 0 ? rect.width / dispW : 1;
      const scaleY = dispH > 0 ? rect.height / dispH : 1;

      circle.style.left = (offset.x + nx * scaleX) + 'px';
      circle.style.top = (offset.y + ny * scaleY) + 'px';

      const slot = parseInt(circle.dataset.slot, 10);
      const lab = sampleAverageAt(nx, ny);
      const rgb = labToRgb(...lab);

      circle.style.background = toHex(rgb);
      circle.style.color = textColorFor(rgb);

      updateSwatchDisplay(slot, lab);
    });

    circle.addEventListener('pointerup', ev => {
      if (!dragging) return;

      dragging = false;

      const rect = displayCanvas.getBoundingClientRect();
      const slot = parseInt(circle.dataset.slot, 10);
      const [nx, ny] = clientToNatural(ev.clientX, ev.clientY, rect);
      const lab = sampleAverageAt(nx, ny);

      currentColors[slot] = lab;
      circlePositions[slot] = [nx, ny];
      manualOverride[slot] = true;

      updateSwatchDisplay(slot, lab);
    });
  }
  function clientToNatural(clientX, clientY, rect) {
    const scaleX=dispW/rect.width, scaleY=dispH/rect.height;
    let x=Math.round((clientX-rect.left)*scaleX), y=Math.round((clientY-rect.top)*scaleY);
    x=Math.max(0,Math.min(dispW-1,x)); y=Math.max(0,Math.min(dispH-1,y));
    return [x,y];
  }
  function copyHex(hex, btn) {
    const done = () => { const orig=btn.textContent; btn.textContent='copied!'; setTimeout(()=>{btn.textContent=orig;},900); };
    if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(hex).then(done).catch(done); }
    else { const ta=document.createElement('textarea'); ta.value=hex; document.body.appendChild(ta); ta.select(); try{document.execCommand('copy');}catch(e){} document.body.removeChild(ta); done(); }
  }

  // ============================================================
  // .grd (Photoshop gradient) export -- byte-exact template patching
  // ============================================================
  const GRD_TEMPLATE_B64 = "OEJHUgAFAAAAEAAAAAEAAAAAAABudWxsAAAAAQAAAABHcmRMVmxMcwAAAAFPYmpjAAAACQBHAHIAYQBkAGkAZQBuAHQAAAAAAABHcmRuAAAAAQAAAABHcmFkT2JqYwAAAAkARwByAGEAZABpAGUAbgB0AAAAAAAAR3JkbgAAAAUAAAAATm0gIFRFWFQAAAAHAEMAdQBzAHQAbwBtAAAAAAAAR3JkRmVudW0AAAAAR3JkRgAAAABDc3RTAAAAAEludHJkb3ViQLAAAAAAAAAAAAAAQ2xyc1ZsTHMAAAAFT2JqYwAAAAEAAAAAAABDbHJ0AAAABAAAAABDbHIgT2JqYwAAAAEAAAAAAABSR0JDAAAAAwAAAABSZCAgZG91YkBG/+kAAAAAAAAAAEdybiBkb3ViQEmAZgAAAAAAAAAAQmwgIGRvdWJATIBjAAAAAAAAAABUeXBlZW51bQAAAABDbHJ5AAAAAFVzclMAAAAATGN0bmxvbmcAAAAAAAAAAE1kcG5sb25nAAAAMk9iamMAAAABAAAAAAAAQ2xydAAAAAQAAAAAQ2xyIE9iamMAAAABAAAAAAAAUkdCQwAAAAMAAAAAUmQgIGRvdWJASYBmAAAAAAAAAABHcm4gZG91YkBO/+EAAAAAAAAAAEJsICBkb3ViQFP/7AAAAAAAAAAAVHlwZWVudW0AAAAAQ2xyeQAAAABVc3JTAAAAAExjdG5sb25nAAAD8AAAAABNZHBubG9uZwAAADJPYmpjAAAAAQAAAAAAAENscnQAAAAEAAAAAENsciBPYmpjAAAAAQAAAAAAAFJHQkMAAAADAAAAAFJkICBkb3ViQFJ/7YAAAAAAAAAAR3JuIGRvdWJAX8AgAAAAAAAAAABCbCAgZG91YkBk4AsAAAAAAAAAAFR5cGVlbnVtAAAAAENscnkAAAAAVXNyUwAAAABMY3RubG9uZwAACBAAAAAATWRwbmxvbmcAAAAyT2JqYwAAAAEAAAAAAABDbHJ0AAAABAAAAABDbHIgT2JqYwAAAAEAAAAAAABSR0JDAAAAAwAAAABSZCAgZG91YkBaQCWAAAAAAAAAAEdybiBkb3ViQGUgCsAAAAAAAAAAQmwgIGRvdWJAaKAHQAAAAAAAAABUeXBlZW51bQAAAABDbHJ5AAAAAFVzclMAAAAATGN0bmxvbmcAAAv7AAAAAE1kcG5sb25nAAAAMk9iamMAAAABAAAAAAAAQ2xydAAAAAQAAAAAQ2xyIE9iamMAAAABAAAAAAAAUkdCQwAAAAMAAAAAUmQgIGRvdWJAa//kAAAAAAAAAABHcm4gZG91YkBtf+KAAAAAAAAAAEJsICBkb3ViQGq/5UAAAAAAAAAAVHlwZWVudW0AAAAAQ2xyeQAAAABVc3JTAAAAAExjdG5sb25nAAAQAAAAAABNZHBubG9uZwAAADIAAAAAVHJuc1ZsTHMAAAACT2JqYwAAAAEAAAAAAABUcm5TAAAAAwAAAABPcGN0VW50RiNQcmNAWQAAAAAAAAAAAABMY3RubG9uZwAAAAAAAAAATWRwbmxvbmcAAAAyT2JqYwAAAAEAAAAAAABUcm5TAAAAAwAAAABPcGN0VW50RiNQcmNAWQAAAAAAAAAAAABMY3RubG9uZwAAEAAAAAAATWRwbmxvbmcAAAAyOEJJTXBocnkAAAArAAAAEAAAAAEAAAAAAABudWxsAAAAAQAAAAloaWVyYXJjaHlWbExzAAAAAA==";
  const GRD_RGB_OFFSETS = [[286,306,326],[458,478,498],[630,650,670],[802,822,842],[974,994,1014]];
  const GRD_LOC_OFFSETS = [374, 546, 718, 890, 1062];
  function base64ToBytes(b64) { const bin=atob(b64); const bytes=new Uint8Array(bin.length); for(let i=0;i<bin.length;i++) bytes[i]=bin.charCodeAt(i); return bytes; }
  function buildGrd(hexColors, locationsPct) {
    const bytes = base64ToBytes(GRD_TEMPLATE_B64);
    const view = new DataView(bytes.buffer);
    for (let i=0;i<5;i++) {
      const hex = hexColors[i].replace('#','');
      const r=parseInt(hex.substr(0,2),16), g=parseInt(hex.substr(2,2),16), b=parseInt(hex.substr(4,2),16);
      const [rOff,gOff,bOff] = GRD_RGB_OFFSETS[i];
      view.setFloat64(rOff, r, false); view.setFloat64(gOff, g, false); view.setFloat64(bOff, b, false);
    }
    for (let i=0;i<5;i++) view.setInt32(GRD_LOC_OFFSETS[i], Math.round(locationsPct[i]/100*4096), false);
    return bytes;
  }
  function downloadGrd(hexColors, locationsPct, filename) {
    const bytes = buildGrd(hexColors, locationsPct);
    const blob = new Blob([bytes], { type: 'application/octet-stream' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a'); a.href=url; a.download=filename||'gradient.grd';
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  function handleSaveGradient() {
    if (!hasImage) return;
    const hexColors = currentColors.map(lab => toHex(labToRgb(...lab)));
    downloadGrd(hexColors, [0,25,50,75,100], 'palette_gradient.grd');
  }
})();
</script>


</body>
</html>