Zum Inhalt springen

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

Aus ADOS Wiki
Keine Bearbeitungszusammenfassung
Markierung: Zurückgesetzt
Keine Bearbeitungszusammenfassung
Markierung: Manuelle Zurücksetzung
Zeile 1: Zeile 1:
/* global mw */
/* global mw */
(function(){
(() => {
   'use strict';
   'use strict';


   // ---------- Konfiguration ----------
   // ------------------------------------------------------------
   function log(){ console.log('[LabelScan]', ...arguments); }
   // Konfiguration
   function err(){ console.error('[LabelScan] Fehler', ...arguments); }
   // ------------------------------------------------------------
 
   const CFG = {
   const CFG = {
     // ESM-Build (wichtig!):
     // Wo liegt die JSON mit Index (Titel, Thumb, EMBEDDING)?
     transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0/dist/transformers.min.js',
    indexTitle: (window.LabelScanConfig && window.LabelScanConfig.indexTitle) ||
     modelId: 'Xenova/clip-vit-base-patch32',
                'MediaWiki:Gadget-LabelScan-index.json',
     topKByPhash: 24,        // wie viele pHash-Kandidaten für CLIP nachladen
    // Top-N Treffer anzeigen:
     showN: 8,               // wie viele Treffer anzeigen
    topK: 8,
     indexUrl: mw.util.getUrl('MediaWiki:Gadget-LabelScan-index.json', { action:'raw', ctype:'application/json' })
    // 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
   };
   };


   // ---------- UI Helfer ----------
  function log(...args) { if (CFG.debug) console.log('[LabelScan]', ...args); }
   function $(id){ return document.getElementById(id); }
  function warn(...args){ if (CFG.debug) console.warn('[LabelScan]', ...args); }
   function setStatus(t){ const el=$('ados-scan-status'); if(el) el.textContent=t||''; }
  function err(...args) { console.error('[LabelScan]', ...args); }
   function setProgress(p){
 
     const bar=$('ados-scan-progress');
   // ------------------------------------------------------------
    if(!bar) return;
  // UI Helpers
     if(p==null){ bar.hidden=true; bar.value=0; return; }
  // ------------------------------------------------------------
     bar.hidden=false; bar.value=Math.max(0,Math.min(1,p));
   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){
   function showPreview(file) {
     const url=URL.createObjectURL(file);
     const url = URL.createObjectURL(file);
     const prev=$('ados-scan-preview');
     const prev = qs('ados-scan-preview');
     if(prev){
     if (prev) {
       prev.innerHTML = '<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;" />';
       prev.innerHTML = '<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;" src="'+url+'">';
      const img=prev.querySelector('img'); img.src=url;
       prev.setAttribute('aria-hidden','false');
       prev.setAttribute('aria-hidden','false');
     }
     }
   }
   }
   function renderResults(items){
 
     const box=$('ados-scan-results'); if(!box) return;
  // ------------------------------------------------------------
     box.innerHTML='';
  // Index laden (JSON: Titel, Thumb, embed(Base64 Float32))
     if(!items || !items.length){
  // ------------------------------------------------------------
      box.innerHTML='<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder manuell suchen.</div>';
   let INDEX = [];
      return;
  let INDEX_EMB = []; // Array<Float32Array>
     }
 
     items.slice(0, CFG.showN).forEach(it=>{
  async function loadIndex() {
      const url = mw.util.getUrl(String(it.title||'').replace(/ /g,'_'));
     if (INDEX.length) return INDEX;
      const div=document.createElement('div');
     setStatus('Index laden …');
      div.className='ados-hit';
     setProgress(0.03);
      div.style.margin='6px 0';
 
      div.innerHTML =
     // Roh-URL bauen
        '<div style="display:flex;gap:10px;align-items:flex-start;">' +
     const rawURL = mw.util.getUrl(CFG.indexTitle, { action:'raw', ctype:'application/json' });
          (it.thumb? `<img src="${it.thumb}" alt="" style="width:64px;height:auto;border-radius:6px;border:1px solid #eee;">` : '') +
    const res = await fetch(rawURL, { cache:'reload' });
          `<div><div><b><a href="${url}">${mw.html.escape(it.title||'')}</a></b></div>` +
    if (!res.ok) throw new Error('Index nicht ladbar: ' + res.status);
          `<div class="meta" style="color:#666;font-size:90%;">Score: ${(it.score||0).toFixed(3)}</div></div>` +
    const json = await res.json();
        '</div>';
 
       box.appendChild(div);
    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;
     });
     });
  }


  // ---------- Index laden ----------
     log('Index geladen:', INDEX.length, 'Einträge');
  let _indexPromise=null, INDEX=[];
    setProgress(0.06);
  async function ensureIndex(){
     return INDEX;
     if(_indexPromise) return _indexPromise;
    _indexPromise = fetch(CFG.indexUrl, { cache:'reload' })
      .then(r=>{ if(!r.ok) throw new Error('Index HTTP '+r.status); return r.json(); })
      .then(data=>{
        if(!Array.isArray(data)) throw new Error('Index ist kein Array');
        INDEX=data.filter(x=>x && x.title && x.thumb && x.phash);
        log('Index geladen:', INDEX.length, 'Einträge');
        return INDEX;
      })
      .catch(e=>{ err(e); INDEX=[]; return INDEX; });
     return _indexPromise;
   }
   }


   // ---------- pHash ----------
   // Base64 -> Float32Array
  // erwartet 16-hex (64bit) oder 32-hex (128bit); wir normalisieren auf 64bit Vergleich
   function base64ToFloat32(b64) {
  function hexToBigInt(h){ try{ return BigInt('0x'+String(h).trim()); } catch(_){ return null; } }
     const bin = atob(b64);
   function hamming64(aHex,bHex){
     const len = bin.length;
     const a=hexToBigInt(aHex), b=hexToBigInt(bHex);
     const buf = new ArrayBuffer(len);
     if(a===null || b===null) return 64;
     const view = new Uint8Array(buf);
     let x=a^b, d=0n;
     for (let i=0;i<len;i++) view[i] = bin.charCodeAt(i);
     while(x){ d += (x & 1n); x >>= 1n; }
     return new Float32Array(buf);
     return Number(d);
  }
  function phashScore(a,b){ // 1..0
    const d=hamming64(a,b);
    const max=64;
     return 1 - (d/max);
   }
   }


  // ---------- Bild laden ----------
// ------------------------------------------------------------
  function fileToImage(file){
// CLIP: Feature-Extraction (nur Bild, ohne Tokenizer)
    return new Promise((res,rej)=>{
// ------------------------------------------------------------
      const img=new Image();
let _clipModulePromise = null;
      img.onload=()=>res(img);
async function ensureClipExtractor() {
      img.onerror=rej;
   if (_clipModulePromise) return _clipModulePromise;
      img.src=URL.createObjectURL(file);
    });
   }
  function urlToImage(url){
    return new Promise((res,rej)=>{
      const img=new Image();
      img.crossOrigin='anonymous';
      img.onload=()=>res(img);
      img.onerror=rej;
      img.src=url;
    });
  }


   // ---------- CLIP laden ----------
   setStatus('Modell laden …');
  let _clipModulePromise=null;
   setProgress(0.08);
   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);


     _clipModulePromise = (async ()=>{
     // NUR remote laden
      const mod = await import(/* webpackIgnore: true */ CFG.transformersURL);
    mod.env.localModelPath  = null;
    mod.env.remoteModels    = true;
    mod.env.allowRemoteModels = true;
    mod.env.useBrowserCache = true;


      // Nur Remote, im Browser cachen
    // Hier liegt der Trick: Wir nutzen explizit "image-feature-extraction",
      mod.env.localModelPath = null;
    // damit kein Tokenizer geladen wird.
       mod.env.remoteModels = true;
    const pipe = await mod.pipeline(
       mod.env.allowRemoteModels = true;
       'image-feature-extraction',
       mod.env.useBrowserCache = true;
       CFG.modelId,
       { quantized: true }
    );


      const pipe = await mod.pipeline('image-feature-extraction', CFG.modelId, { quantized:true });
    log('CLIP ready:', pipe.model?.constructor?.name || 'unknown');
      log('CLIP ready:', pipe.model?.constructor?.name || 'unknown');
    return { mod, pipe };
      return { mod, pipe };
  })();
    })().catch(e=>{ err(e); throw e; });


    return _clipModulePromise;
  return _clipModulePromise;
  }
}


   // ---------- Embeddings & Cosine ----------
   // Datei -> HTMLImageElement -> Canvas (Downscale) -> Embedding (Float32Array, normiert)
   function cosine(a,b){
   async function embedFileImage(file) {
     let dot=0, na=0, nb=0;
     // Datei als HTMLImageElement laden
    for(let i=0;i<a.length;i++){ const x=a[i], y=b[i]; dot+=x*y; na+=x*x; nb+=y*y; }
    function loadImageFromFile(f) {
     if(na===0 || nb===0) return 0;
      return new Promise((resolve, reject) => {
    return dot / (Math.sqrt(na)*Math.sqrt(nb));
        const url = URL.createObjectURL(f);
  }
        const img = new Image();
  async function embedImage(img){
        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();
     const { pipe } = await ensureClipExtractor();
    // transformers akzeptiert HTMLImageElement direkt:
    const out = await pipe(img);
    // out ist typischerweise Float32Array
    return Array.from(out.data || out);
  }
  async function embedURL(url){
    const img = await urlToImage(url);
    return embedImage(img);
  }
  async function embedFile(file){
    const img = await fileToImage(file);
    return embedImage(img);
  }


  // ---------- Matching Pipeline ----------
     setStatus('Bild vorbereiten …');
  async function matchImage(file){
     setProgress(0.20);
     await ensureIndex();
     if(!INDEX.length) throw new Error('Index leer.');


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


    // pHash-Kandidaten
     setStatus('Bild analysieren …');
     setStatus('Vorab-Abgleich (pHash) …'); setProgress(0.18);
     setProgress(0.38);
     const userPhash = null; // (Optional: clientseitig pHash berechnen – hier nicht nötig)
    // Wenn wir keinen pHash des Uploads haben, nehmen wir alle & sortieren später nach CLIP.
    // Für schnellen Vorfilter sortieren wir grob nach Titel-Länge (kein harter Nutzen) → oder zufällig mischen
    // Besser: Wir lassen pHash-Score=0.5 fallback, oder ignorieren pHash.
    // Hier: pHash nicht vorhanden → wir nutzen alle Kandidaten, schneiden aber hart auf topKByPhash zu.
    let prelim = INDEX.map(x=>({ item:x, pScore:0.5 }));


     // Optional: Falls du clientseitig pHash ergänzt, hier pScore via phashScore(user, x.phash) setzen.
     const out = await pipe(canvas);


     // leichte Bevorzugung kurzer Thumbnails (heuristisch nicht nötig) – wir gehen direkt weiter
     // Ausgabe → Float32Array normieren
     prelim = prelim.slice(0, Math.max(CFG.topKByPhash, 12));
    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);
  }


     // CLIP des Uploads
  function meanPool2D(arr2d) {
     setStatus('Bild verstehen (KI) …'); setProgress(0.38);
    const rows = arr2d.length;
     const userVec = await embedFile(file);
    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
  }


    // CLIP für Kandidaten
  // ------------------------------------------------------------
    setStatus('Kandidaten bewerten …'); setProgress(0.55);
  // Ranking & Rendering
    let done=0;
  // ------------------------------------------------------------
     const scored = [];
  function rankByCosine(queryVec) {
     for(const k of prelim){
     const scores = [];
       try{
     for (let i=0;i<INDEX.length;i++) {
        const v = await embedURL(k.item.thumb);
       const vec = INDEX_EMB[i];
        const c = cosine(userVec, v);     // 0..1
      if (!vec) continue; // ohne Embedding nicht vergleichbar
        const s = 0.6*c + 0.4*k.pScore;    // Kombi aus CLIP (60%) und pHash (40%)
      const score = cosine(queryVec, vec);
        scored.push({ title:k.item.title, thumb:k.item.thumb, score:s });
      scores.push({ i, score });
      }catch(e){
        // Bild konnte nicht geladen werden → überspringen
      }finally{
        done++; setProgress(0.55 + 0.35*(done/prelim.length));
      }
     }
     }
 
    // absteigend
     scored.sort((a,b)=>b.score-a.score);
     scores.sort((a,b)=> b.score - a.score);
     return scored;
     return scores.slice(0, CFG.topK);
   }
   }


  // ---------- Bindings ----------
   function renderResults(ranked) {
   function bindUI(){
     const box = qs('ados-scan-results');
     const btnCam  = $('ados-scan-btn-camera');
     if (!box) return;
     const btnGal  = $('ados-scan-btn-gallery');
     box.innerHTML = '';
     const inCam    = $('ados-scan-file-camera');
    const inGal    = $('ados-scan-file-gallery');
    const btnRun  = $('ados-scan-run');
    const btnReset = $('ados-scan-reset');
    const drop    = $('ados-scan-drop');


     if(!btnRun || !btnReset || !btnCam || !btnGal || !inCam || !inGal){
     if (!ranked || !ranked.length) {
       log('UI unvollständig – Seite lädt evtl. ohne HTML-Wrapper <html></html>?');
       box.innerHTML = '<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder näher am Frontlabel.</div>';
       return;
       return;
     }
     }


     // Buttons → Inputs
     ranked.forEach(({ i, score }) => {
    btnCam.addEventListener('click', ()=> inCam.click());
      const it = INDEX[i];
     btnGal.addEventListener('click', ()=> inGal.click());
      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;


     function onPick(ev){
    // Buttons triggern jeweilige Inputs
       const f = ev.target.files && ev.target.files[0];
    if (btnCam)  btnCam.addEventListener('click', ()=> inCam.click());
       if(f){ showPreview(f); setStatus('Bereit zum Erkennen.'); }
    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);
     inCam.addEventListener('change', onPick);
     inGal.addEventListener('change', onPick);
     inGal.addEventListener('change', onPick);


     // Drag&Drop
     // Drag & Drop nur als Zusatz
     if(drop){
     if (drop) {
       drop.addEventListener('dragover', e=>{ e.preventDefault(); drop.classList.add('is-over'); });
       drop.addEventListener('dragover', ev => { ev.preventDefault(); drop.classList.add('is-over'); });
       drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over'));
       drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over'));
       drop.addEventListener('drop', e=>{
       drop.addEventListener('drop', ev => {
         e.preventDefault(); drop.classList.remove('is-over');
         ev.preventDefault(); drop.classList.remove('is-over');
         if(e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]){
         const f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0];
          const f=e.dataTransfer.files[0];
        if (f) {
           // in Galerie-Input setzen (nur zur Verwaltung), Vorschau zeigen
           // in "Gallery"-Input setzen, damit onRunClick es findet
           const dt = new DataTransfer(); dt.items.add(f);
           const dt = new DataTransfer();
          dt.items.add(f);
           inGal.files = dt.files;
           inGal.files = dt.files;
           showPreview(f);
           showPreview(f);
          setStatus('Bereit zum Erkennen.');
         }
         }
       });
       });
Zeile 245: Zeile 293:


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


     // Run
     // Start
     btnRun.addEventListener('click', async ()=>{
     btnRun.addEventListener('click', onRunClick);
      try{
        const file = inCam.files?.[0] || inGal.files?.[0];
        if(!file){ alert('Bitte zuerst ein Foto aufnehmen oder auswählen.'); return; }
        btnRun.disabled=true; setStatus('Starte …'); setProgress(0.05);


        const hits = await matchImage(file);
    BOUND = true;
        renderResults(hits);
    log('UI gebunden.');
         setStatus('Fertig.');
  }
 
  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);
         setProgress(null);
      }catch(e){
         btnRun.disabled = false;
        err(e);
        return;
        setStatus('Fehler bei der Erkennung/Suche.');
        setProgress(null);
      }finally{
         btnRun.disabled=false;
       }
       }
    });


     log('UI gebunden.');
      // 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(){
  // Init
     log('gadget file loaded');
  // ------------------------------------------------------------
    ensureIndex(); // schon mal laden
   function init() {
     if(document.readyState==='loading'){
     // Bind nach DOM fertig
       document.addEventListener('DOMContentLoaded', bindUI);
     if (document.readyState === 'loading') {
     }else{
       document.addEventListener('DOMContentLoaded', bindUI, { once: true });
     } else {
       bindUI();
       bindUI();
     }
     }
    // Fallbacks
    setTimeout(bindUI, 250);
    setTimeout(bindUI, 1000);
    // (Optional) Index „warm“ laden (nicht blockierend)
    loadIndex().catch(err);
   }
   }


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

Version vom 8. November 2025, 18:32 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();

})();