Zum Inhalt springen

MediaWiki:Gadget-LabelScan.js: Unterschied zwischen den Versionen

Aus ADOS Wiki
Keine Bearbeitungszusammenfassung
Keine Bearbeitungszusammenfassung
Zeile 88: Zeile 88:
   }
   }


  // ------------------------------------------------------------
// ------------------------------------------------------------
  // CLIP (Transformers.js, remote, ohne lokale Models)
// CLIP: Feature-Extraction (nur Bild, ohne Tokenizer)
  // ------------------------------------------------------------
// ------------------------------------------------------------
  let _clipModulePromise = null;
let _clipModulePromise = null;
  async function ensureClipExtractor() {
async function ensureClipExtractor() {
    if (_clipModulePromise) return _clipModulePromise;
  if (_clipModulePromise) return _clipModulePromise;


    setStatus('Modell laden …');
  setStatus('Modell laden …');
    setProgress(0.08);
  setProgress(0.08);


    _clipModulePromise = (async () => {
  _clipModulePromise = (async () => {
      // ESM dynamisch importieren
    const ESM_URL = CFG.transformersURL;
      const mod = await import(/* webpackIgnore: true */ CFG.transformersURL);
    const mod = await import(/* webpackIgnore: true */ ESM_URL);


      // WICHTIG: nur remote
    // NUR remote laden
      mod.env.localModelPath = null;
    mod.env.localModelPath = null;
      mod.env.remoteModels   = true;
    mod.env.remoteModels   = true;
      mod.env.useBrowserCache = true;
    mod.env.allowRemoteModels = true;
    mod.env.useBrowserCache = true;


      // Feature-Extraction (liefert Embedding-Vektoren)
    // Hier liegt der Trick: Wir nutzen explizit "image-feature-extraction",
      const pipe = await mod.pipeline('feature-extraction', CFG.modelId, { quantized: true });
    // damit kein Tokenizer geladen wird.
      return { mod, pipe };
    const pipe = await mod.pipeline(
     })();
      'image-feature-extraction',
      CFG.modelId,
      { quantized: true }
     );


     return _clipModulePromise;
    log('CLIP ready:', pipe.model?.constructor?.name || 'unknown');
  }
     return { mod, pipe };
  })();
 
  return _clipModulePromise;
}


   // Datei -> HTMLImageElement -> Canvas (Downscale) -> Embedding (Float32Array, normiert)
   // Datei -> HTMLImageElement -> Canvas (Downscale) -> Embedding (Float32Array, normiert)

Version vom 8. November 2025, 17:28 Uhr

/* global mw */
(() => {
  'use strict';

  // ------------------------------------------------------------
  // Konfiguration
  // ------------------------------------------------------------
  const CFG = {
    // Wo liegt die JSON mit Index (Titel, Thumb, EMBEDDING)?
    indexTitle: (window.LabelScanConfig && window.LabelScanConfig.indexTitle) ||
                'MediaWiki:Gadget-LabelScan-index.json',
    // Top-N Treffer anzeigen:
    topK: 8,
    // CLIP-Model:
    transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0',
    modelId: 'Xenova/clip-vit-base-patch32', // robust & kompakt (quantized)
    // Max-Seitenkante beim Downscaling (Speed/Qualität):
    maxSide: 1024,
    // Logging:
    debug: true
  };

  function log(...args) { if (CFG.debug) console.log('[LabelScan]', ...args); }
  function warn(...args){ if (CFG.debug) console.warn('[LabelScan]', ...args); }
  function err(...args) { console.error('[LabelScan]', ...args); }

  // ------------------------------------------------------------
  // UI Helpers
  // ------------------------------------------------------------
  function qs(id) { return document.getElementById(id); }
  function setStatus(txt) { const el = qs('ados-scan-status'); if (el) el.textContent = txt || ''; }
  function setProgress(p) {
    const bar = qs('ados-scan-progress'); if (!bar) return;
    if (p == null) { bar.hidden = true; bar.value = 0; }
    else { bar.hidden = false; bar.value = Math.max(0, Math.min(1, p)); }
  }
  function showPreview(file) {
    const url = URL.createObjectURL(file);
    const prev = qs('ados-scan-preview');
    if (prev) {
      prev.innerHTML = '<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;" src="'+url+'">';
      prev.setAttribute('aria-hidden','false');
    }
  }

  // ------------------------------------------------------------
  // Index laden (JSON: Titel, Thumb, embed(Base64 Float32))
  // ------------------------------------------------------------
  let INDEX = [];
  let INDEX_EMB = []; // Array<Float32Array>

  async function loadIndex() {
    if (INDEX.length) return INDEX;
    setStatus('Index laden …');
    setProgress(0.03);

    // Roh-URL bauen
    const rawURL = mw.util.getUrl(CFG.indexTitle, { action:'raw', ctype:'application/json' });
    const res = await fetch(rawURL, { cache:'reload' });
    if (!res.ok) throw new Error('Index nicht ladbar: ' + res.status);
    const json = await res.json();

    if (!Array.isArray(json)) throw new Error('Index ist keine Array-JSON');
    INDEX = json;

    // Embeddings dekodieren (falls vorhanden)
    INDEX_EMB = INDEX.map((it, i) => {
      if (typeof it.embed === 'string' && it.embed.length) {
        try { return base64ToFloat32(it.embed); }
        catch(e){ warn('Embed-Decode-Fehler bei Index', i, it.title, e); return null; }
      }
      return null;
    });

    log('Index geladen:', INDEX.length, 'Einträge');
    setProgress(0.06);
    return INDEX;
  }

  // Base64 -> Float32Array
  function base64ToFloat32(b64) {
    const bin = atob(b64);
    const len = bin.length;
    const buf = new ArrayBuffer(len);
    const view = new Uint8Array(buf);
    for (let i=0;i<len;i++) view[i] = bin.charCodeAt(i);
    return new Float32Array(buf);
  }

// ------------------------------------------------------------
// CLIP: Feature-Extraction (nur Bild, ohne Tokenizer)
// ------------------------------------------------------------
let _clipModulePromise = null;
async function ensureClipExtractor() {
  if (_clipModulePromise) return _clipModulePromise;

  setStatus('Modell laden …');
  setProgress(0.08);

  _clipModulePromise = (async () => {
    const ESM_URL = CFG.transformersURL;
    const mod = await import(/* webpackIgnore: true */ ESM_URL);

    // NUR remote laden
    mod.env.localModelPath  = null;
    mod.env.remoteModels    = true;
    mod.env.allowRemoteModels = true;
    mod.env.useBrowserCache = true;

    // Hier liegt der Trick: Wir nutzen explizit "image-feature-extraction",
    // damit kein Tokenizer geladen wird.
    const pipe = await mod.pipeline(
      'image-feature-extraction',
      CFG.modelId,
      { quantized: true }
    );

    log('CLIP ready:', pipe.model?.constructor?.name || 'unknown');
    return { mod, pipe };
  })();

  return _clipModulePromise;
}

  // Datei -> HTMLImageElement -> Canvas (Downscale) -> Embedding (Float32Array, normiert)
  async function embedFileImage(file) {
    // Datei als HTMLImageElement laden
    function loadImageFromFile(f) {
      return new Promise((resolve, reject) => {
        const url = URL.createObjectURL(f);
        const img = new Image();
        img.crossOrigin = 'anonymous';
        img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
        img.onerror = (e)=> { URL.revokeObjectURL(url); reject(e); };
        img.src = url;
      });
    }
    // Canvas-Downscale
    function toCanvas(img, maxSide) {
      const c = document.createElement('canvas');
      let { width: w, height: h } = img;
      const scale = Math.min(1, maxSide / Math.max(w, h));
      w = Math.round(w * scale);
      h = Math.round(h * scale);
      c.width = w; c.height = h;
      const ctx = c.getContext('2d');
      ctx.imageSmoothingEnabled = true;
      ctx.drawImage(img, 0, 0, w, h);
      return c;
    }
    const { pipe } = await ensureClipExtractor();

    setStatus('Bild vorbereiten …');
    setProgress(0.20);

    const img = await loadImageFromFile(file);
    const canvas = toCanvas(img, CFG.maxSide);

    setStatus('Bild analysieren …');
    setProgress(0.38);

    const out = await pipe(canvas);

    // Ausgabe → Float32Array normieren
    const d = out && out.data;
    let vec;
    if (d instanceof Float32Array) {
      vec = d;
    } else if (Array.isArray(d)) {
      // 2D → mitteln
      vec = Array.isArray(d[0]) ? meanPool2D(d) : new Float32Array(d);
    } else {
      throw new Error('Embedding-Format unerwartet');
    }
    return normalize(vec);
  }

  function meanPool2D(arr2d) {
    const rows = arr2d.length;
    const dim = rows ? arr2d[0].length : 0;
    const sum = new Float32Array(dim);
    for (let r=0;r<rows;r++) {
      const row = arr2d[r];
      for (let i=0;i<dim;i++) sum[i] += row[i] || 0;
    }
    for (let i=0;i<dim;i++) sum[i] /= (rows || 1);
    return sum;
  }
  function normalize(v) {
    let n=0; for (let i=0;i<v.length;i++) n += v[i]*v[i];
    n = Math.sqrt(n) || 1;
    const out = new Float32Array(v.length);
    for (let i=0;i<v.length;i++) out[i] = v[i]/n;
    return out;
  }
  function cosine(a,b) {
    let s=0; const L=Math.min(a.length,b.length);
    for (let i=0;i<L;i++) s += a[i]*b[i];
    return s; // bei normierten Vektoren = cos
  }

  // ------------------------------------------------------------
  // Ranking & Rendering
  // ------------------------------------------------------------
  function rankByCosine(queryVec) {
    const scores = [];
    for (let i=0;i<INDEX.length;i++) {
      const vec = INDEX_EMB[i];
      if (!vec) continue; // ohne Embedding nicht vergleichbar
      const score = cosine(queryVec, vec);
      scores.push({ i, score });
    }
    // absteigend
    scores.sort((a,b)=> b.score - a.score);
    return scores.slice(0, CFG.topK);
  }

  function renderResults(ranked) {
    const box = qs('ados-scan-results');
    if (!box) return;
    box.innerHTML = '';

    if (!ranked || !ranked.length) {
      box.innerHTML = '<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder näher am Frontlabel.</div>';
      return;
    }

    ranked.forEach(({ i, score }) => {
      const it = INDEX[i];
      const link = mw.util.getUrl((it.title || '').replace(/ /g,'_'));
      const thumb = it.thumb || '';
      const row = document.createElement('div');
      row.className = 'ados-hit';
      row.style.display = 'grid';
      row.style.gridTemplateColumns = '60px 1fr auto';
      row.style.alignItems = 'center';
      row.style.gap = '10px';
      row.style.padding = '.35rem 0';
      row.innerHTML =
        (thumb ? `<div><img src="${thumb}" alt="" style="width:60px;height:auto;border-radius:6px;"></div>` : '<div></div>') +
        `<div><b><a href="${link}">${escapeHtml(it.title || '')}</a></b></div>` +
        `<div style="color:#666;font-variant-numeric:tabular-nums">${score.toFixed(3)}</div>`;
      box.appendChild(row);
    });
  }

  function escapeHtml(s){ return mw.html.escape(String(s||'')); }

  // ------------------------------------------------------------
  // Bindings
  // ------------------------------------------------------------
  let BOUND = false;
  function bindUI() {
    if (BOUND) return;
    const btnCam  = qs('ados-scan-btn-camera');
    const btnGal  = qs('ados-scan-btn-gallery');
    const inCam   = qs('ados-scan-file-camera');
    const inGal   = qs('ados-scan-file-gallery');
    const btnRun  = qs('ados-scan-run');
    const btnReset= qs('ados-scan-reset');
    const drop    = qs('ados-scan-drop');

    if (!btnRun || !inCam || !inGal) return;

    // Buttons triggern jeweilige Inputs
    if (btnCam)  btnCam.addEventListener('click', ()=> inCam.click());
    if (btnGal)  btnGal.addEventListener('click', ()=> inGal.click());

    // Vorschau setzen
    function onPick(e) {
      const f = e.target.files && e.target.files[0];
      if (f) showPreview(f);
    }
    inCam.addEventListener('change', onPick);
    inGal.addEventListener('change', onPick);

    // Drag & Drop nur als Zusatz
    if (drop) {
      drop.addEventListener('dragover', ev => { ev.preventDefault(); drop.classList.add('is-over'); });
      drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over'));
      drop.addEventListener('drop', ev => {
        ev.preventDefault(); drop.classList.remove('is-over');
        const f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0];
        if (f) {
          // in "Gallery"-Input setzen, damit onRunClick es findet
          const dt = new DataTransfer();
          dt.items.add(f);
          inGal.files = dt.files;
          showPreview(f);
        }
      });
    }

    // Reset
    if (btnReset) btnReset.addEventListener('click', () => {
      const p = qs('ados-scan-preview'); if (p) p.innerHTML = '<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>';
      inCam.value = ''; inGal.value = '';
      const r = qs('ados-scan-results'); if (r) r.innerHTML = '<div class="empty">Hier erscheinen passende Abfüllungen mit Link ins Wiki.</div>';
      setStatus('Bereit.'); setProgress(null);
    });

    // Start
    btnRun.addEventListener('click', onRunClick);

    BOUND = true;
    log('UI gebunden.');
  }

  async function onRunClick() {
    try {
      const inCam   = qs('ados-scan-file-camera');
      const inGal   = qs('ados-scan-file-gallery');
      const btnRun  = qs('ados-scan-run');

      const file = (inCam.files && inCam.files[0]) || (inGal.files && inGal.files[0]);
      if (!file) { alert('Bitte zuerst ein Foto auswählen oder aufnehmen.'); return; }

      btnRun.disabled = true;
      setStatus('Vorbereitung …');
      setProgress(0.02);

      // Index sicher laden
      await loadIndex();

      // Mind. ein Eintrag mit Embedding vorhanden?
      if (!INDEX_EMB.some(v => v && v.length)) {
        setStatus('Index enthält keine Embeddings – bitte Index mit Embeddings neu erzeugen.');
        setProgress(null);
        btnRun.disabled = false;
        return;
      }

      // Embedding für Query-Bild
      const qVec = await embedFileImage(file);     // 0.20–0.40 in ensure / embed
      setProgress(0.70);

      // Ranking
      setStatus('Abgleich mit Datenbank …');
      const ranked = rankByCosine(qVec);

      setProgress(0.95);
      renderResults(ranked);
      setStatus('Fertig.');
      setProgress(null);
    } catch (e) {
      err('Fehler', e);
      setStatus('Fehler bei Erkennung/Abgleich. Bitte erneut versuchen.');
      setProgress(null);
    } finally {
      const btnRun = qs('ados-scan-run');
      if (btnRun) btnRun.disabled = false;
    }
  }

  // ------------------------------------------------------------
  // Init
  // ------------------------------------------------------------
  function init() {
    // Bind nach DOM fertig
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', bindUI, { once: true });
    } else {
      bindUI();
    }

    // Fallbacks
    setTimeout(bindUI, 250);
    setTimeout(bindUI, 1000);

    // (Optional) Index „warm“ laden (nicht blockierend)
    loadIndex().catch(err);
  }

  // Start
  log('gadget file loaded');
  init();

})();