Zum Inhalt springen

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

Aus ADOS Wiki
Keine Bearbeitungszusammenfassung
Keine Bearbeitungszusammenfassung
Zeile 1: Zeile 1:
/* global mw */
/* global mw */
(function () {
(() => {
   'use strict';
   'use strict';


   // ---------- Kurz-Helpers ----------
   // ------------------------------------------------------------
   const $ = (id) => document.getElementById(id);
   // Konfiguration
   const esc = (s) => (mw.html ? mw.html.escape(String(s || '')) : String(s || ''));
  // ------------------------------------------------------------
   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
  };


   const el = {
   function log(...args) { if (CFG.debug) console.log('[LabelScan]', ...args); }
    wrap: () => $('ados-labelscan'),
   function warn(...args){ if (CFG.debug) console.warn('[LabelScan]', ...args); }
    btnCam: () => $('ados-scan-btn-camera'),
   function err(...args) { console.error('[LabelScan]', ...args); }
    btnGal: () => $('ados-scan-btn-gallery'),
    inCam:  () => $('ados-scan-file-camera'),
    inGal:  () => $('ados-scan-file-gallery'),
    drop:   () => $('ados-scan-drop'),
    run:    () => $('ados-scan-run'),
    reset:  () => $('ados-scan-reset'),
    stat:   () => $('ados-scan-status'),
    prog:  () => $('ados-scan-progress'),
    prev:  () => $('ados-scan-preview'),
    res:    () => $('ados-scan-results'),
  };


   function setStatus(t){ const s=el.stat(); if(s) s.textContent = t || ''; }
  // ------------------------------------------------------------
   function setProgress(p){
  // UI Helpers
     const bar = el.prog(); if(!bar) return;
  // ------------------------------------------------------------
     if (p == null){ bar.hidden = true; bar.value = 0; }
  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)); }
     else { bar.hidden = false; bar.value = Math.max(0, Math.min(1, p)); }
   }
   }
   function showPreview(file){
   function showPreview(file) {
     try {
     const url = URL.createObjectURL(file);
      const url = URL.createObjectURL(file);
    const prev = qs('ados-scan-preview');
      const prev = el.prev();
    if (prev) {
      if (prev){
      prev.innerHTML = '<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;" src="'+url+'">';
        prev.innerHTML = '<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;" src="'+url+'">';
      prev.setAttribute('aria-hidden','false');
        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; }
       }
       }
     } catch(e) {}
      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 LADEN (ES MODULE via dynamic import) <<<<<< FIX: feature-extraction
   // CLIP (Transformers.js, remote, ohne lokale Models)
   // ========================================================================
   // ------------------------------------------------------------
   let _clipModulePromise = null;
   let _clipModulePromise = null;
   async function ensureClipExtractor(){
   async function ensureClipExtractor() {
     if (_clipModulePromise) return _clipModulePromise;
     if (_clipModulePromise) return _clipModulePromise;
    // feste Version für Stabilität
    const ESM_URL = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0';


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


     _clipModulePromise = (async () => {
     _clipModulePromise = (async () => {
       const mod = await import(/* webpackIgnore: true */ ESM_URL);
      // ESM dynamisch importieren
      // Optionale Feinheiten:
       const mod = await import(/* webpackIgnore: true */ CFG.transformersURL);
      // mod.env.allowLocalModels = false;
      // mod.env.allowRemoteModels = true;
      // mod.env.backends.onnx.wasm.numThreads = 1; // bei schwachen Geräten


       // WICHTIG: Task ist "feature-extraction"
       // WICHTIG: nur remote
       const pipe = await mod.pipeline(
      mod.env.localModelPath = null;
        'feature-extraction',
      mod.env.remoteModels  = true;
        'Xenova/clip-vit-base-patch32'
      mod.env.useBrowserCache = true;
      );
 
      // Feature-Extraction (liefert Embedding-Vektoren)
       const pipe = await mod.pipeline('feature-extraction', CFG.modelId, { quantized: true });
       return { mod, pipe };
       return { mod, pipe };
     })();
     })();
Zeile 70: Zeile 115:
   }
   }


   // ========================================================================
   // Datei -> HTMLImageElement -> Canvas (Downscale) -> Embedding (Float32Array, normiert)
   // INDEX LADEN (mit vorab berechneten Embeddings)
   async function embedFileImage(file) {
  // ========================================================================
    // Datei als HTMLImageElement laden
  function decodeEmbed(b64){
    function loadImageFromFile(f) {
    const bin = atob(b64), len = bin.length, bytes = new Uint8Array(len);
      return new Promise((resolve, reject) => {
    for (let i=0;i<len;i++) bytes[i] = bin.charCodeAt(i);
        const url = URL.createObjectURL(f);
     return new Float32Array(bytes.buffer);
        const img = new Image();
  }
        img.crossOrigin = 'anonymous';
  function normalizeVec(v){
        img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
     let n=0; for (let i=0;i<v.length;i++) n += v[i]*v[i];
        img.onerror = (e)=> { URL.revokeObjectURL(url); reject(e); };
     n = Math.sqrt(n)||1;
        img.src = url;
     const out = new Float32Array(v.length);
      });
     for (let i=0;i<v.length;i++) out[i] = v[i]/n;
    }
     return out;
    // 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 cosine(a,b){ let s=0; for(let i=0;i<a.length;i++) s += a[i]*b[i]; return s; }


  // Pooling, falls die Pipeline 2D (Token-/Patch-Vektoren) liefert
   function meanPool2D(arr2d) {
   function meanPool2D(arr2d){
     const rows = arr2d.length;
     const rows = arr2d.length;
     const dim = (rows && arr2d[0]) ? arr2d[0].length : 0;
     const dim = rows ? arr2d[0].length : 0;
     const sum = new Float32Array(dim);
     const sum = new Float32Array(dim);
     for (let r=0;r<rows;r++){
     for (let r=0;r<rows;r++) {
       const row = arr2d[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] += row[i] || 0;
Zeile 99: Zeile 179:
     return sum;
     return sum;
   }
   }
 
   function normalize(v) {
   let ADOS_INDEX = null;
     let n=0; for (let i=0;i<v.length;i++) n += v[i]*v[i];
  async function loadLabelIndex(){
     n = Math.sqrt(n) || 1;
     if (ADOS_INDEX) return ADOS_INDEX;
     const out = new Float32Array(v.length);
    setStatus('Index laden …');
     for (let i=0;i<v.length;i++) out[i] = v[i]/n;
    const page = 'MediaWiki:Gadget-LabelScan-index.json'; // <- dein Index
     return out;
    const url  = mw.util.getUrl(page, { action:'raw', ctype:'application/json', maxage:0, smaxage:0, _:Date.now() });
    const res  = await fetch(url, { cache: 'no-store' });
     const txt  = await res.text();
     let json;
    try { json = JSON.parse(txt.replace(/^\uFEFF/, '')); }
    catch(e){ console.error('[LabelScan] Index JSON fehlerhaft', e); throw e; }
    if (!Array.isArray(json) || !json.length) throw new Error('Index leer');
 
     ADOS_INDEX = json.map((it) => {
      let vec = null;
      if (typeof it.embed === 'string'){
        try { vec = normalizeVec( decodeEmbed(it.embed) ); } catch(e){ vec = null; }
      }
      return { title: it.title, thumb: it.thumb || '', vec, phash: it.phash || null };
    });
     return ADOS_INDEX;
   }
   }
 
   function cosine(a,b) {
  // ========================================================================
     let s=0; const L=Math.min(a.length,b.length);
   //  BILD → EMBEDDING (robust gegen 1D/2D Ausgabe)
     for (let i=0;i<L;i++) s += a[i]*b[i];
  // ========================================================================
    return s; // bei normierten Vektoren = cos
  async function embedFileImage(file){
     const { pipe } = await ensureClipExtractor();
    const url = URL.createObjectURL(file);
     try {
      setStatus('Bild analysieren …');
      setProgress(0.25);
 
      const out = await pipe(url);
      // Mögliche Formen: { data: Float32Array } ODER { data: number[] } ODER { data: number[][] }
      let vec;
      const d = out && out.data;
      if (d instanceof Float32Array){
        vec = d;
      } else if (Array.isArray(d)){
        if (Array.isArray(d[0])) {
          // 2D -> mean pool
          vec = meanPool2D(d);
        } else {
          vec = new Float32Array(d);
        }
      } else {
        throw new Error('Unerwartetes Embedding-Format');
      }
 
      return normalizeVec(vec);
    } finally {
      URL.revokeObjectURL(url);
    }
   }
   }


   // ========================================================================
   // ------------------------------------------------------------
   // RANKING + RENDER
   // Ranking & Rendering
   // ========================================================================
   // ------------------------------------------------------------
   function rankMatches(qvec, index, topK, minScore){
   function rankByCosine(queryVec) {
     const scored = [];
     const scores = [];
     for (const it of index){
     for (let i=0;i<INDEX.length;i++) {
       if (!it.vec) continue;
      const vec = INDEX_EMB[i];
       scored.push({ it, s: cosine(qvec, it.vec) });
       if (!vec) continue; // ohne Embedding nicht vergleichbar
      const score = cosine(queryVec, vec);
       scores.push({ i, score });
     }
     }
     scored.sort((a,b)=>b.s - a.s);
     // absteigend
     const out = [];
    scores.sort((a,b)=> b.score - a.score);
    for (const r of scored){
     return scores.slice(0, CFG.topK);
      if (typeof minScore === 'number' && r.s < minScore) break;
      out.push({ title: r.it.title, thumb: r.it.thumb, score: r.s });
      if (out.length >= (topK||6)) break;
    }
    return out;
   }
   }


   function renderResults(items){
   function renderResults(ranked) {
     const box = el.res(); if(!box) return;
     const box = qs('ados-scan-results');
    if (!box) return;
     box.innerHTML = '';
     box.innerHTML = '';
     if (!items || !items.length){
 
       box.innerHTML = '<div class="ados-hit">Keine klaren Treffer. Bitte anderes Foto oder näher am Label versuchen.</div>';
     if (!ranked || !ranked.length) {
       box.innerHTML = '<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder näher am Frontlabel.</div>';
       return;
       return;
     }
     }
     items.forEach(it=>{
 
       const link = mw.util.getUrl(it.title.replace(/ /g,'_'));
     ranked.forEach(({ i, score }) => {
       const row = document.createElement('div');
      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.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 =
       row.innerHTML =
         '<div style="display:flex;gap:10px;align-items:flex-start;">' +
         (thumb ? `<div><img src="${thumb}" alt="" style="width:60px;height:auto;border-radius:6px;"></div>` : '<div></div>') +
          (it.thumb ? `<img src="${it.thumb}" alt="" style="width:64px;height:auto;border-radius:6px;">` : '') +
        `<div><b><a href="${link}">${escapeHtml(it.title || '')}</a></b></div>` +
          `<div><b><a href="${link}">${esc(it.title)}</a></b>` +
        `<div style="color:#666;font-variant-numeric:tabular-nums">${score.toFixed(3)}</div>`;
          (typeof it.score === 'number' ? `<div class="meta">Ähnlichkeit: ${(it.score*100).toFixed(1)}%</div>` : '') +
          '</div>' +
        '</div>';
       box.appendChild(row);
       box.appendChild(row);
     });
     });
   }
   }


  // ========================================================================
   function escapeHtml(s){ return mw.html.escape(String(s||'')); }
  //  INPUT / UI
  // ========================================================================
  let CURRENT_FILE = null;
   function setCurrentFile(f){
    CURRENT_FILE = f || null;
    if (CURRENT_FILE) showPreview(CURRENT_FILE);
  }
 
  function wireDropzone(){
    const drop = el.drop(); if (!drop) return;
    const over = (e)=>{ e.preventDefault(); drop.classList.add('is-over'); };
    const leave= (e)=>{ e.preventDefault(); drop.classList.remove('is-over'); };
    const dropH= (e)=>{
      e.preventDefault(); drop.classList.remove('is-over');
      const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
      if (f && f.type && f.type.startsWith('image/')) setCurrentFile(f);
    };
    drop.addEventListener('dragenter', over);
    drop.addEventListener('dragover',  over);
    drop.addEventListener('dragleave', leave);
    drop.addEventListener('drop',      dropH);
  }


   function hasUI(){
  // ------------------------------------------------------------
     return el.run() && (el.inCam() || el.inGal()) && el.res();
  // 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');


  let BOUND = false;
     if (!btnRun || !inCam || !inGal) return;
  function bind(){
     if (BOUND || !hasUI()) return;


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


     // Buttons → jeweiliges Input öffnen
     // Vorschau setzen
     if (btnCam && inCam && !btnCam.dataset._b){
     function onPick(e) {
       btnCam.dataset._b = '1';
       const f = e.target.files && e.target.files[0];
      btnCam.addEventListener('click', ()=> inCam.click());
       if (f) showPreview(f);
    }
    if (btnGal && inGal && !btnGal.dataset._b){
      btnGal.dataset._b = '1';
       btnGal.addEventListener('click', ()=> inGal.click());
     }
     }
    inCam.addEventListener('change', onPick);
    inGal.addEventListener('change', onPick);


     // Inputs → Datei merken & Vorschau
     // Drag & Drop nur als Zusatz
     if (inCam && !inCam.dataset._b){
     if (drop) {
       inCam.dataset._b = '1';
       drop.addEventListener('dragover', ev => { ev.preventDefault(); drop.classList.add('is-over'); });
       inCam.addEventListener('change', function(){
       drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over'));
         if (this.files && this.files[0]) setCurrentFile(this.files[0]);
      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);
        }
       });
       });
     }
     }
    if (inGal && !inGal.dataset._b){
      inGal.dataset._b = '1';
      inGal.addEventListener('change', function(){
        if (this.files && this.files[0]) setCurrentFile(this.files[0]);
      });
    }
    // Dropzone
    wireDropzone();


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


     // Run
     // Start
     if (run && !run.dataset._b){
     btnRun.addEventListener('click', onRunClick);
      run.dataset._b = '1';
      run.addEventListener('click', onRunClick);
    }


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


   async function onRunClick(ev){
   async function onRunClick() {
     ev.preventDefault();
     try {
    const run = el.run();
      const inCam  = qs('ados-scan-file-camera');
    const btnCam = el.btnCam();
      const inGal  = qs('ados-scan-file-gallery');
    const btnGal = el.btnGal();
      const btnRun  = qs('ados-scan-run');


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


    // Parameter am Wrapper überschreibbar
      btnRun.disabled = true;
    const wrap = el.wrap() || document.body;
      setStatus('Vorbereitung …');
    const TOP_K    = Number(wrap?.dataset?.topk || 6);
      setProgress(0.02);
    const MIN_SCORE = Number(wrap?.dataset?.minscore || 0.82);


    try {
       // Index sicher laden
       setStatus('Vorbereitung …'); setProgress(0.10);
       await loadIndex();
       run.disabled = true; if(btnCam) btnCam.disabled = true; if(btnGal) btnGal.disabled = true;


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


       // Query einbetten und ranken
       // Embedding für Query-Bild
       setStatus('Bild analysieren …'); setProgress(0.35);
       const qVec = await embedFileImage(file);     // 0.20–0.40 in ensure / embed
       const qvec = await embedFileImage(f);
       setProgress(0.70);


       setStatus('Vergleiche …'); setProgress(0.60);
      // Ranking
       const matches = rankMatches(qvec, index, TOP_K, MIN_SCORE);
       setStatus('Abgleich mit Datenbank …');
       const ranked = rankByCosine(qVec);


       renderResults(matches);
      setProgress(0.95);
       setStatus(matches.length ? 'Fertig.' : 'Keine klaren Treffer – bitte ein anderes Foto versuchen.');
       renderResults(ranked);
     } catch (e){
       setStatus('Fertig.');
       console.error('[LabelScan] Fehler', e);
      setProgress(null);
       setStatus('Fehler bei Erkennung/Suche.');
     } catch (e) {
       err('Fehler', e);
       setStatus('Fehler bei Erkennung/Abgleich. Bitte erneut versuchen.');
      setProgress(null);
     } finally {
     } finally {
       setProgress(null);
       const btnRun = qs('ados-scan-run');
       run.disabled = false; if(btnCam) btnCam.disabled = false; if(btnGal) btnGal.disabled = false;
       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);
   }
   }


   // ---------- Bind-Trigger ----------
   // Start
   if (document.readyState !== 'loading') bind();
   log('gadget file loaded');
  else document.addEventListener('DOMContentLoaded', bind, { once:true });
   init();
  if (mw && mw.hook) mw.hook('wikipage.content').add(bind);
  setTimeout(bind, 250);
  setTimeout(bind, 1000);
  const mo = new MutationObserver(()=>{ if(!BOUND && hasUI()) bind(); });
   mo.observe(document.documentElement||document.body, { childList:true, subtree:true });


})();
})();

Version vom 8. November 2025, 17:17 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 (Transformers.js, remote, ohne lokale Models)
  // ------------------------------------------------------------
  let _clipModulePromise = null;
  async function ensureClipExtractor() {
    if (_clipModulePromise) return _clipModulePromise;

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

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

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

      // Feature-Extraction (liefert Embedding-Vektoren)
      const pipe = await mod.pipeline('feature-extraction', CFG.modelId, { quantized: true });
      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();

})();