Zum Inhalt springen

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

Aus ADOS Wiki
Keine Bearbeitungszusammenfassung
Keine Bearbeitungszusammenfassung
Zeile 1: Zeile 1:
// --- Index laden (robust, mit Fallback, ohne Cache) ------------------------
/* global mw */
async function loadLabelIndex() {
(function () {
   const page = 'MediaWiki:Gadget-LabelScan-index.json';
   'use strict';


   // Primär: canonical raw-URL
   // =============================
   const url1 = mw.util.getUrl(page, {
   // 1) Konfiguration
    action: 'raw',
  // =============================
    ctype: 'application/json',
  const MATCH_TOPK = 6;
    maxage: 0,
  const MATCH_THRESHOLD = 0.82; // ggf. 0.86 o. ä. – höher = strenger
    smaxage: 0
  });


   // Fallback: wgScript (index.php) + Cachebuster
   // =============================
   const url2 = (mw.config.get('wgScript') || '/index.php') +
  // 2) UI-Hilfen
    '?title=' + encodeURIComponent(page) +
  // =============================
    '&action=raw&ctype=application/json&_=' + Date.now();
  function $(id) { return document.getElementById(id); }
   function esc(s) { return mw.html.escape(String(s || '')); }


   const tried = [];
   function setStatus(t) {
  async function tryFetch(url) {
     const el = $('ados-scan-status');
    tried.push(url);
     if (el) el.textContent = t || '';
     const r = await fetch(url, { cache: 'no-store' });
  }
     if (!r.ok) throw new Error('HTTP ' + r.status);
 
     let text = await r.text();
  function setProgress(p) {
    // BOM entfernen, falls vorhanden
    const bar = $('ados-scan-progress');
     if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
     if (!bar) return;
    let data;
     if (p == null) { bar.hidden = true; bar.value = 0; }
    try { data = JSON.parse(text); }
     else { bar.hidden = false; bar.value = Math.max(0, Math.min(1, p)); }
     catch (e) {
      // Manchmal kommt <pre>…</pre> o.Ä. – hart strippen:
      const stripped = text.replace(/^.*?\[/s, '[').replace(/\].*$/s, ']');
      data = JSON.parse(stripped);
    }
    if (!Array.isArray(data)) throw new Error('Kein Array im Index');
    return data;
   }
   }


   try {
   function showPreview(file) {
     try {
     try {
       return await tryFetch(url1);
       const url = URL.createObjectURL(file);
     } catch (e1) {
      const prev = $('ados-scan-preview');
       console.warn('[LabelScan] Primäre Raw-URL fehlgeschlagen:', e1?.message);
      if (prev) {
      return await tryFetch(url2);
        prev.innerHTML = '<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;" src="' + url + '">';
        prev.setAttribute('aria-hidden', 'false');
      }
     } catch (e) {
       console.warn('[LabelScan] Preview fehlgeschlagen:', e);
     }
     }
  } catch (e) {
    console.error('[LabelScan] Konnte Index nicht laden:', e?.message, '\nVersucht:', tried);
    throw e;
   }
   }
}


// Beim Start laden:
  // =============================
loadLabelIndex().then(list => {
  // 3) CLIP (Xenova) im Browser
  window.ADOS_LABEL_INDEX = list;
  // =============================
  console.log(`[LabelScan] Index geladen: ${list.length} Einträge`);
  let CLIP_READY = null;
}).catch(() => {
  async function ensureClipExtractor() {
  const box = document.getElementById('ados-scan-results');
    if (CLIP_READY) return CLIP_READY;
  if (box) box.innerHTML =
    CLIP_READY = new Promise((resolve, reject) => {
    '<div class="ados-hit">Index konnte nicht geladen werden. Bitte Seite neu laden (Strg+F5) oder Administrator informieren.</div>';
      // transformers.js laden, falls nicht vorhanden
});
      if (!window.transformers) {
        const s = document.createElement('script');
        s.src = 'https://cdn.jsdelivr.net/npm/@xenova/transformers/dist/transformers.min.js';
        s.async = true;
        s.onload = async () => {
          try {
            const pipe = await window.transformers.pipeline(
              'image-feature-extraction',
              'Xenova/clip-vit-base-patch32'
            );
            resolve(pipe);
          } catch (e) { reject(e); }
        };
        s.onerror = reject;
        document.head.appendChild(s);
      } else {
        window.transformers.pipeline('image-feature-extraction', 'Xenova/clip-vit-base-patch32')
          .then(resolve, reject);
      }
    });
    return CLIP_READY;
  }


  // =============================
  // 4) Index laden & vorbereiten
  // =============================
  function decodeEmbed(b64) {
    // Base64 -> Uint8Array -> Float32Array (little-endian)
    const bin = atob(b64);
    const len = bin.length;
    const bytes = new Uint8Array(len);
    for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i);
    return new Float32Array(bytes.buffer);
  }


  function normalizeVec(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;
    for (let i = 0; i < a.length; i++) s += a[i] * b[i];
    return s; // bei normalisierten Vektoren = Cosine-Similarity
  }


  let ADOS_INDEX = null;


  async function loadLabelIndex() {
    if (ADOS_INDEX) return ADOS_INDEX;


    const page = 'MediaWiki:Gadget-LabelScan-index.json';
    const url1 = mw.util.getUrl(page, { action: 'raw', ctype: 'application/json', maxage: 0, smaxage: 0, _: Date.now() });
    const url2 = (mw.config.get('wgScript') || '/index.php') + '?title=' + encodeURIComponent(page) + '&action=raw&ctype=application/json&_=' + Date.now();


    async function fetchJson(url) {
      const r = await fetch(url, { cache: 'no-store' });
      if (!r.ok) throw new Error('HTTP ' + r.status);
      let t = await r.text();
      if (t.charCodeAt(0) === 0xFEFF) t = t.slice(1); // BOM
      try { return JSON.parse(t); }
      catch {
        // hart strippen, falls mal <pre> o.ä. drumrum ist
        const stripped = t.replace(/^.*?\[/s, '[').replace(/\].*$/s, ']');
        return JSON.parse(stripped);
      }
    }


    let raw;
    try {
      raw = await fetchJson(url1);
    } catch (e1) {
      console.warn('[LabelScan] Primäre Raw-URL fehlgeschlagen:', e1?.message);
      raw = await fetchJson(url2);
    }


    if (!Array.isArray(raw) || !raw.length) {
      throw new Error('Index leer oder kein Array');
    }
    if (typeof raw[0].embed !== 'string') {
      console.warn('[LabelScan] Index hat kein "embed" – bitte den CLIP-Index hochladen.');
      throw new Error('Index ohne Embeddings');
    }


    ADOS_INDEX = raw.map(item => {
      const vec = normalizeVec(decodeEmbed(item.embed));
      return { title: item.title, thumb: item.thumb, vec };
    });


 
    console.log(`[LabelScan] Index geladen: ${ADOS_INDEX.length} Einträge`);
 
    if (ADOS_INDEX[0]?.vec?.length) {
 
      console.log('[LabelScan] Embedding-Dimension:', ADOS_INDEX[0].vec.length);
 
     }
 
     return ADOS_INDEX;
 
 
 
 
 
 
 
 
/* global mw */
(function () {
  'use strict';
 
  // =========== UI Helpers ===========
  function $(id){ return document.getElementById(id); }
  function setStatus(t){ const el=$('ados-scan-status'); if(el) el.textContent=t||''; }
  function setProgress(p){ const bar=$('ados-scan-progress'); if(!bar) return; if(p==null){bar.hidden=true;bar.value=0;} else {bar.hidden=false;bar.value=p;} }
  function showPreview(file){
    const prev=$('ados-scan-preview'); if(!prev) return;
    const url=URL.createObjectURL(file);
     prev.innerHTML = `<img src="${url}" alt="Vorschau" style="max-width:100%; border-radius:8px; border:1px solid #ccc;">`;
     setStatus('Bild bereit.');
   }
   }
  function esc(s){ return mw.html.escape(String(s||'')); }


   // =========== Datei-Auswahl Zustand ===========
   // =============================
   let PICKED_FILE = null;
   // 5) Bild -> Embedding
   function getSelectedFile() {
  // =============================
     const cam=$('ados-scan-file-camera'), gal=$('ados-scan-file-gallery');
   async function embedFileImage(file) {
     return PICKED_FILE || (cam&&cam.files&&cam.files[0]) || (gal&&gal.files&&gal.files[0]) || null;
     const extractor = await ensureClipExtractor();
    const url = URL.createObjectURL(file);
     try {
      const feat = await extractor(url);
      const vec = normalizeVec(feat.data);
      // Debug
      const norm = Math.sqrt(vec.reduce((a, c) => a + c * c, 0)).toFixed(3);
      console.log('[LabelScan] query norm ~', norm, 'first3=', Array.from(vec.slice(0, 3)).map(v => v.toFixed(3)).join(', '));
      return vec;
    } finally {
      URL.revokeObjectURL(url);
    }
   }
   }


   // =========== aHash (64-bit, 16 Hex-Zeichen) ===========
   // =============================
  async function fileToImage (file) {
  // 6) Ranking & Rendering
    return new Promise((resolve, reject) => {
   // =============================
      const img = new Image();
  function rankMatches(queryVec, index, topK, threshold) {
      img.onload = () => resolve(img);
     const scored = index.map(it => ({ it, s: cosine(queryVec, it.vec) }));
      img.onerror = reject;
     scored.sort((a, b) => b.s - a.s);
      img.src = URL.createObjectURL(file);
     const out = [];
    });
     for (const r of scored) {
   }
      if (threshold != null && r.s < threshold) break;
  function computeAHash(img) {
      out.push({ title: r.it.title, thumb: r.it.thumb, score: r.s });
    // auf 8x8 skalieren, grau, Durchschnitt -> 64 bits
       if (out.length >= topK) break;
    const S=8;
    const c=document.createElement('canvas'); c.width=c.height=S;
    const ctx=c.getContext('2d');
    ctx.drawImage(img, 0, 0, S, S);
     const data = ctx.getImageData(0,0,S,S).data;
    const gray=new Array(S*S);
    for(let i=0, j=0;i<data.length;i+=4, j++){
      gray[j]= 0.299*data[i]+0.587*data[i+1]+0.114*data[i+2];
     }
    const avg = gray.reduce((a,b)=>a+b,0)/gray.length;
     let bits = '';
     for(let k=0;k<gray.length;k++){
       bits += (gray[k] > avg) ? '1' : '0';
     }
     }
    // 64 bits -> 16 Hex
     return out;
    let hex='';
    for(let i=0;i<64;i+=4){
      hex += parseInt(bits.slice(i,i+4),2).toString(16);
    }
     return hex;
  }
  function hammingHex(h1, h2){
    const n = Math.min(h1.length, h2.length);
    let d=0;
    for(let i=0;i<n;i++){
      const x = parseInt(h1[i],16) ^ parseInt(h2[i],16);
      d += x.toString(2).replace(/0/g,'').length;
    }
    return d + (h1.length>n? (h1.length-n)*4 : 0) + (h2.length>n? (h2.length-n)*4 : 0);
   }
   }


  // =========== Index laden ===========
   function renderResults(items) {
  async function loadIndex(){
    const url = mw.util.getUrl('MediaWiki:Gadget-LabelScan-index.json', { action:'raw', ctype:'application/json' });
    const res = await fetch(url, { cache: 'no-store' });
    if (!res.ok) throw new Error('Index konnte nicht geladen werden: '+res.status);
    return res.json();
  }
 
  // =========== Ergebnisse rendern ===========
   function renderResults(items){
     const box = $('ados-scan-results');
     const box = $('ados-scan-results');
     if (!box) return;
     if (!box) return;
     box.innerHTML = '';
     box.innerHTML = '';
     if (!items || !items.length){
     if (!items || !items.length) {
       box.innerHTML = '<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder manuell suchen.</div>';
       box.innerHTML = '<div class="ados-hit">Keine klaren Treffer. Bitte anderes Foto oder manuell suchen.</div>';
       return;
       return;
     }
     }
     items.forEach(it=>{
     items.forEach(it => {
       const href = mw.util.getUrl(it.title.replace(/ /g,'_'));
       const link = mw.util.getUrl(it.title.replace(/ /g, '_'));
       box.insertAdjacentHTML('beforeend', `
       const row = document.createElement('div');
        <div class="ados-hit" style="display:inline-block; width:170px; margin:8px; text-align:center;">
      row.className = 'ados-hit';
           <a href="${href}">
      row.innerHTML =
            <img src="${it.thumb}" alt="${esc(it.title)}" style="width:150px; border-radius:8px; border:1px solid #ccc; background:#fff;">
        '<div style="display:flex;gap:10px;align-items:flex-start;">' +
            <div style="margin-top:6px; font-weight:600;">${esc(it.title)}</div>
           (it.thumb ? `<img src="${it.thumb}" alt="" style="width:64px;height:auto;border-radius:6px;">` : '') +
          </a>
          `<div><b><a href="${link}">${esc(it.title)}</a></b>` +
           <div style="font-size:.85em; color:#666;">Distanz: ${it.dist}</div>
           (typeof it.score === 'number' ? `<div class="meta">Score: ${(it.score * 100).toFixed(1)}%</div>` : '') +
         </div>
          '</div>' +
       `);
         '</div>';
       box.appendChild(row);
     });
     });
   }
   }


   // =========== Verkettete Erkennung ===========
   // =============================
   async function runMatchWorkflow(){
   // 7) UI-Binding
    const file = getSelectedFile();
  // =============================
     if (!file) { alert('Bitte Foto aufnehmen oder Datei wählen.'); return; }
  function hasUI() {
     return !!$('ados-scan-file') && !!$('ados-scan-run');
  }


     try {
  let BOUND = false;
      setStatus('Bereite Bild vor …'); setProgress(null); // (keine echte Progressanzeige nötig)
  function bind() {
      const img = await fileToImage(file);
    if (BOUND || !hasUI()) return;
    const fileIn  = $('ados-scan-file');
     const runBtn  = $('ados-scan-run');
    const photoBtn = $('ados-scan-photo'); // optional
    const pickBtn  = $('ados-scan-pick'); // optional
    const bigBtn  = $('ados-scan-bigbtn'); // optional
    const drop    = $('ados-scan-drop');   // optional Dropzone


      setStatus('Berechne Fingerabdruck …');
    if (!fileIn || !runBtn) return;
      const ahash = computeAHash(img);


      setStatus('Lade Index …');
    // Mehrfaches Binden verhindern
      const index = await loadIndex();
    if (runBtn.dataset.bound === '1') return;
    runBtn.dataset.bound = '1';
    BOUND = true;


      setStatus('Vergleiche …');
    // Buttons: Foto aufnehmen / Bild wählen
       const scored = index.map(it => {
    if (photoBtn) {
        const dist = hammingHex(ahash, String(it.phash||''));
       photoBtn.addEventListener('click', () => {
        return { ...it, dist };
        try { fileIn.setAttribute('capture', 'environment'); } catch {}
      }).sort((a,b)=>a.dist - b.dist);
        fileIn.click();
 
       });
      // kleine Heuristik: nur „plausible“ Treffer anzeigen
      const BEST = scored.slice(0, 8);
      const THRESH = 18; // ~ gut unterscheidbar bei 64-bit aHash
      const filtered = BEST.filter(x => x.dist <= THRESH);
      renderResults(filtered.length ? filtered : BEST);
      setStatus('Fertig.');
    } catch (e) {
      console.error('[LabelScan]', e);
       setStatus('Fehler bei Erkennung.');
    } finally {
      setProgress(null);
     }
     }
  }
     if (pickBtn) {
 
       pickBtn.addEventListener('click', () => {
  // =========== Dropzone, Buttons, Aktionen ===========
        try { fileIn.removeAttribute('capture'); } catch {}
  function wireUI(){
        fileIn.click();
    // Buttons → Inputs
       });
    const btnCam=$('ados-scan-btn-camera'), inCam=$('ados-scan-file-camera');
    const btnGal=$('ados-scan-btn-gallery'), inGal=$('ados-scan-file-gallery');
 
     if (btnCam && inCam){
       btnCam.addEventListener('click', e=>{ e.preventDefault(); e.stopPropagation(); inCam.click(); });
       inCam.addEventListener('change', ()=>{ if(inCam.files && inCam.files[0]) { PICKED_FILE=inCam.files[0]; showPreview(PICKED_FILE); }});
     }
     }
     if (btnGal && inGal){
     if (bigBtn) {
       btnGal.addEventListener('click', e=>{ e.preventDefault(); e.stopPropagation(); inGal.click(); });
       bigBtn.addEventListener('click', () => {
      inGal.addEventListener('change', ()=>{ if(inGal.files && inGal.files[0]) { PICKED_FILE=inGal.files[0]; showPreview(PICKED_FILE); }});
        // bevorzugt: Kamera
        try { fileIn.setAttribute('capture', 'environment'); } catch {}
        fileIn.click();
      });
     }
     }


     // Dropzone
     // Datei gewählt → Vorschau
     const drop = $('ados-scan-drop');
     fileIn.addEventListener('change', function () {
    if (drop){
       if (this.files && this.files[0]) showPreview(this.files[0]);
       const stop = ev => { ev.preventDefault(); ev.stopPropagation(); };
    });
       ['dragenter','dragover','dragleave','drop'].forEach(evt => drop.addEventListener(evt, stop));
 
       drop.addEventListener('drop', ev=>{
    // Drag & Drop (optional)
         const f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0];
    if (drop) {
        if (f){ PICKED_FILE=f; showPreview(f); }
       ['dragenter','dragover','dragleave','drop'].forEach(ev => {
        drop.addEventListener(ev, e => { e.preventDefault(); e.stopPropagation(); }, false);
      });
       drop.addEventListener('drop', e => {
         const dt = e.dataTransfer;
        if (dt && dt.files && dt.files[0]) {
          fileIn.files = dt.files;
          showPreview(dt.files[0]);
        }
       });
       });
     }
     }


     // Run
     // Klick → Erkennen & Suchen
     const run=$('ados-scan-run');
     runBtn.addEventListener('click', async (ev) => {
    if (run) run.addEventListener('click', e=>{ e.preventDefault(); runMatchWorkflow(); });
      ev.preventDefault();
      const f = fileIn.files && fileIn.files[0];
      if (!f) { alert('Bitte ein Bild wählen oder aufnehmen.'); return; }


    // Reset
      runBtn.disabled = true;
    const reset=$('ados-scan-reset');
      if (photoBtn) photoBtn.disabled = true;
    if (reset) reset.addEventListener('click', ()=>{
       if (pickBtn)  pickBtn.disabled = true;
       PICKED_FILE=null;
 
       if ($('ados-scan-file-camera')) $('ados-scan-file-camera').value='';
       try {
      if ($('ados-scan-file-gallery')) $('ados-scan-file-gallery').value='';
        setStatus('Modell laden …');
      if ($('ados-scan-preview')) $('ados-scan-preview').innerHTML='<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>';
        await ensureClipExtractor();
       if ($('ados-scan-results')) $('ados-scan-results').innerHTML='<div class="empty">Hier erscheinen passende Abfüllungen mit Link ins Wiki.</div>';
 
      setStatus('Bereit.');
        setStatus('Index laden …');
       setProgress(null);
        const index = await loadLabelIndex();
 
        setStatus('Bild einlesen …');
        setProgress(0.1);
        const qvec = await embedFileImage(f);
 
        setStatus('Vergleiche …');
        setProgress(0.2);
        const matches = rankMatches(qvec, index, MATCH_TOPK, MATCH_THRESHOLD);
 
        renderResults(matches);
        setStatus(matches.length ? 'Fertig.' : 'Keine klaren Treffer – bitte anderes Foto probieren.');
       } catch (e) {
        console.error('[LabelScan] Fehler:', e);
        setStatus('Fehler bei Erkennung/Suche. Bitte erneut versuchen.');
       } finally {
        setProgress(null);
        runBtn.disabled = false;
        if (photoBtn) photoBtn.disabled = false;
        if (pickBtn)  pickBtn.disabled = false;
      }
     });
     });
    console.log('[LabelScan] Gadget gebunden.');
   }
   }


   // =========== Start ===========
   // =============================
  // 8) Start
  // =============================
   if (document.readyState === 'loading') {
   if (document.readyState === 'loading') {
     document.addEventListener('DOMContentLoaded', wireUI);
     document.addEventListener('DOMContentLoaded', bind);
   } else {
   } else {
     wireUI();
     bind();
   }
   }
  // Fallbacks & dynamische Re-Renders
  setTimeout(bind, 250);
  setTimeout(bind, 1000);
  const mo = new MutationObserver(() => { if (!BOUND) bind(); });
  mo.observe(document.documentElement || document.body, { childList: true, subtree: true });
})();
})();

Version vom 8. November 2025, 15:56 Uhr

/* global mw */
(function () {
  'use strict';

  // =============================
  // 1) Konfiguration
  // =============================
  const MATCH_TOPK = 6;
  const MATCH_THRESHOLD = 0.82; // ggf. 0.86 o. ä. – höher = strenger

  // =============================
  // 2) UI-Hilfen
  // =============================
  function $(id) { return document.getElementById(id); }
  function esc(s) { return mw.html.escape(String(s || '')); }

  function setStatus(t) {
    const el = $('ados-scan-status');
    if (el) el.textContent = t || '';
  }

  function setProgress(p) {
    const bar = $('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) {
    try {
      const url = URL.createObjectURL(file);
      const prev = $('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');
      }
    } catch (e) {
      console.warn('[LabelScan] Preview fehlgeschlagen:', e);
    }
  }

  // =============================
  // 3) CLIP (Xenova) im Browser
  // =============================
  let CLIP_READY = null;
  async function ensureClipExtractor() {
    if (CLIP_READY) return CLIP_READY;
    CLIP_READY = new Promise((resolve, reject) => {
      // transformers.js laden, falls nicht vorhanden
      if (!window.transformers) {
        const s = document.createElement('script');
        s.src = 'https://cdn.jsdelivr.net/npm/@xenova/transformers/dist/transformers.min.js';
        s.async = true;
        s.onload = async () => {
          try {
            const pipe = await window.transformers.pipeline(
              'image-feature-extraction',
              'Xenova/clip-vit-base-patch32'
            );
            resolve(pipe);
          } catch (e) { reject(e); }
        };
        s.onerror = reject;
        document.head.appendChild(s);
      } else {
        window.transformers.pipeline('image-feature-extraction', 'Xenova/clip-vit-base-patch32')
          .then(resolve, reject);
      }
    });
    return CLIP_READY;
  }

  // =============================
  // 4) Index laden & vorbereiten
  // =============================
  function decodeEmbed(b64) {
    // Base64 -> Uint8Array -> Float32Array (little-endian)
    const bin = atob(b64);
    const len = bin.length;
    const bytes = new Uint8Array(len);
    for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i);
    return new Float32Array(bytes.buffer);
  }

  function normalizeVec(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;
    for (let i = 0; i < a.length; i++) s += a[i] * b[i];
    return s; // bei normalisierten Vektoren = Cosine-Similarity
  }

  let ADOS_INDEX = null;

  async function loadLabelIndex() {
    if (ADOS_INDEX) return ADOS_INDEX;

    const page = 'MediaWiki:Gadget-LabelScan-index.json';
    const url1 = mw.util.getUrl(page, { action: 'raw', ctype: 'application/json', maxage: 0, smaxage: 0, _: Date.now() });
    const url2 = (mw.config.get('wgScript') || '/index.php') + '?title=' + encodeURIComponent(page) + '&action=raw&ctype=application/json&_=' + Date.now();

    async function fetchJson(url) {
      const r = await fetch(url, { cache: 'no-store' });
      if (!r.ok) throw new Error('HTTP ' + r.status);
      let t = await r.text();
      if (t.charCodeAt(0) === 0xFEFF) t = t.slice(1); // BOM
      try { return JSON.parse(t); }
      catch {
        // hart strippen, falls mal <pre> o.ä. drumrum ist
        const stripped = t.replace(/^.*?\[/s, '[').replace(/\].*$/s, ']');
        return JSON.parse(stripped);
      }
    }

    let raw;
    try {
      raw = await fetchJson(url1);
    } catch (e1) {
      console.warn('[LabelScan] Primäre Raw-URL fehlgeschlagen:', e1?.message);
      raw = await fetchJson(url2);
    }

    if (!Array.isArray(raw) || !raw.length) {
      throw new Error('Index leer oder kein Array');
    }
    if (typeof raw[0].embed !== 'string') {
      console.warn('[LabelScan] Index hat kein "embed" – bitte den CLIP-Index hochladen.');
      throw new Error('Index ohne Embeddings');
    }

    ADOS_INDEX = raw.map(item => {
      const vec = normalizeVec(decodeEmbed(item.embed));
      return { title: item.title, thumb: item.thumb, vec };
    });

    console.log(`[LabelScan] Index geladen: ${ADOS_INDEX.length} Einträge`);
    if (ADOS_INDEX[0]?.vec?.length) {
      console.log('[LabelScan] Embedding-Dimension:', ADOS_INDEX[0].vec.length);
    }
    return ADOS_INDEX;
  }

  // =============================
  // 5) Bild -> Embedding
  // =============================
  async function embedFileImage(file) {
    const extractor = await ensureClipExtractor();
    const url = URL.createObjectURL(file);
    try {
      const feat = await extractor(url);
      const vec = normalizeVec(feat.data);
      // Debug
      const norm = Math.sqrt(vec.reduce((a, c) => a + c * c, 0)).toFixed(3);
      console.log('[LabelScan] query norm ~', norm, 'first3=', Array.from(vec.slice(0, 3)).map(v => v.toFixed(3)).join(', '));
      return vec;
    } finally {
      URL.revokeObjectURL(url);
    }
  }

  // =============================
  // 6) Ranking & Rendering
  // =============================
  function rankMatches(queryVec, index, topK, threshold) {
    const scored = index.map(it => ({ it, s: cosine(queryVec, it.vec) }));
    scored.sort((a, b) => b.s - a.s);
    const out = [];
    for (const r of scored) {
      if (threshold != null && r.s < threshold) break;
      out.push({ title: r.it.title, thumb: r.it.thumb, score: r.s });
      if (out.length >= topK) break;
    }
    return out;
  }

  function renderResults(items) {
    const box = $('ados-scan-results');
    if (!box) return;
    box.innerHTML = '';
    if (!items || !items.length) {
      box.innerHTML = '<div class="ados-hit">Keine klaren Treffer. Bitte anderes Foto oder manuell suchen.</div>';
      return;
    }
    items.forEach(it => {
      const link = mw.util.getUrl(it.title.replace(/ /g, '_'));
      const row = document.createElement('div');
      row.className = 'ados-hit';
      row.innerHTML =
        '<div style="display:flex;gap:10px;align-items:flex-start;">' +
          (it.thumb ? `<img src="${it.thumb}" alt="" style="width:64px;height:auto;border-radius:6px;">` : '') +
          `<div><b><a href="${link}">${esc(it.title)}</a></b>` +
          (typeof it.score === 'number' ? `<div class="meta">Score: ${(it.score * 100).toFixed(1)}%</div>` : '') +
          '</div>' +
        '</div>';
      box.appendChild(row);
    });
  }

  // =============================
  // 7) UI-Binding
  // =============================
  function hasUI() {
    return !!$('ados-scan-file') && !!$('ados-scan-run');
  }

  let BOUND = false;
  function bind() {
    if (BOUND || !hasUI()) return;
    const fileIn  = $('ados-scan-file');
    const runBtn  = $('ados-scan-run');
    const photoBtn = $('ados-scan-photo'); // optional
    const pickBtn  = $('ados-scan-pick');  // optional
    const bigBtn   = $('ados-scan-bigbtn'); // optional
    const drop     = $('ados-scan-drop');   // optional Dropzone

    if (!fileIn || !runBtn) return;

    // Mehrfaches Binden verhindern
    if (runBtn.dataset.bound === '1') return;
    runBtn.dataset.bound = '1';
    BOUND = true;

    // Buttons: Foto aufnehmen / Bild wählen
    if (photoBtn) {
      photoBtn.addEventListener('click', () => {
        try { fileIn.setAttribute('capture', 'environment'); } catch {}
        fileIn.click();
      });
    }
    if (pickBtn) {
      pickBtn.addEventListener('click', () => {
        try { fileIn.removeAttribute('capture'); } catch {}
        fileIn.click();
      });
    }
    if (bigBtn) {
      bigBtn.addEventListener('click', () => {
        // bevorzugt: Kamera
        try { fileIn.setAttribute('capture', 'environment'); } catch {}
        fileIn.click();
      });
    }

    // Datei gewählt → Vorschau
    fileIn.addEventListener('change', function () {
      if (this.files && this.files[0]) showPreview(this.files[0]);
    });

    // Drag & Drop (optional)
    if (drop) {
      ['dragenter','dragover','dragleave','drop'].forEach(ev => {
        drop.addEventListener(ev, e => { e.preventDefault(); e.stopPropagation(); }, false);
      });
      drop.addEventListener('drop', e => {
        const dt = e.dataTransfer;
        if (dt && dt.files && dt.files[0]) {
          fileIn.files = dt.files;
          showPreview(dt.files[0]);
        }
      });
    }

    // Klick → Erkennen & Suchen
    runBtn.addEventListener('click', async (ev) => {
      ev.preventDefault();
      const f = fileIn.files && fileIn.files[0];
      if (!f) { alert('Bitte ein Bild wählen oder aufnehmen.'); return; }

      runBtn.disabled = true;
      if (photoBtn) photoBtn.disabled = true;
      if (pickBtn)  pickBtn.disabled = true;

      try {
        setStatus('Modell laden …');
        await ensureClipExtractor();

        setStatus('Index laden …');
        const index = await loadLabelIndex();

        setStatus('Bild einlesen …');
        setProgress(0.1);
        const qvec = await embedFileImage(f);

        setStatus('Vergleiche …');
        setProgress(0.2);
        const matches = rankMatches(qvec, index, MATCH_TOPK, MATCH_THRESHOLD);

        renderResults(matches);
        setStatus(matches.length ? 'Fertig.' : 'Keine klaren Treffer – bitte anderes Foto probieren.');
      } catch (e) {
        console.error('[LabelScan] Fehler:', e);
        setStatus('Fehler bei Erkennung/Suche. Bitte erneut versuchen.');
      } finally {
        setProgress(null);
        runBtn.disabled = false;
        if (photoBtn) photoBtn.disabled = false;
        if (pickBtn)  pickBtn.disabled = false;
      }
    });

    console.log('[LabelScan] Gadget gebunden.');
  }

  // =============================
  // 8) Start
  // =============================
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', bind);
  } else {
    bind();
  }
  // Fallbacks & dynamische Re-Renders
  setTimeout(bind, 250);
  setTimeout(bind, 1000);
  const mo = new MutationObserver(() => { if (!BOUND) bind(); });
  mo.observe(document.documentElement || document.body, { childList: true, subtree: true });

})();