Zum Inhalt springen

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

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


   // ---------- CLIP Modell laden ----------
   // ========================================================================
   let CLIP_READY = null;
  //  CLIP LADEN (ES MODULE via dynamic import)  <<<<<< FIX HIER
  // ========================================================================
   let _clipModulePromise = null;
   async function ensureClipExtractor(){
   async function ensureClipExtractor(){
     if (CLIP_READY) return CLIP_READY;
     if (_clipModulePromise) return _clipModulePromise;
 
    // Wichtig: eine feste Version nehmen, damit der Build stabil bleibt
    const ESM_URL = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0';
 
     setStatus('Modell laden …');
     setStatus('Modell laden …');
     CLIP_READY = new Promise((resolve, reject) => {
     setProgress(0.05);
      if (!window.transformers){
 
        const s = document.createElement('script');
    // Dynamischer Import eines ES-Moduls – funktioniert in allen modernen Browsern
        s.src = 'https://cdn.jsdelivr.net/npm/@xenova/transformers/dist/transformers.min.js';
    _clipModulePromise = (async () => {
        s.async = true;
      const mod = await import(/* webpackIgnore: true */ ESM_URL);
        s.onload = async () => {
      // CLIP-Pipeline initialisieren (einmalig)
          try {
      const pipe = await mod.pipeline(
            const pipe = await window.transformers.pipeline('image-feature-extraction','Xenova/clip-vit-base-patch32');
         'image-feature-extraction',
            resolve(pipe);
        'Xenova/clip-vit-base-patch32'
          } catch (e){ reject(e); }
      );
        };
       return { mod, pipe };
        s.onerror = reject;
     })();
        document.head.appendChild(s);
 
      } else {
     return _clipModulePromise;
         window.transformers.pipeline('image-feature-extraction','Xenova/clip-vit-base-patch32').then(resolve, reject);
       }
     });
     return CLIP_READY;
   }
   }


   // ---------- Index laden (mit Embeddings) ----------
   // ========================================================================
  //  INDEX LADEN (mit vorab berechneten Embeddings)
  // ========================================================================
   function decodeEmbed(b64){
   function decodeEmbed(b64){
     const bin = atob(b64), len = bin.length, bytes = new Uint8Array(len);
     const bin = atob(b64), len = bin.length, bytes = new Uint8Array(len);
Zeile 102: Zeile 106:
   }
   }


   // ---------- Bild Embedding ----------
   // ========================================================================
  //  BILD EMBEDDING
  // ========================================================================
   async function embedFileImage(file){
   async function embedFileImage(file){
     const extractor = await ensureClipExtractor();
     const { pipe } = await ensureClipExtractor();
     const url = URL.createObjectURL(file);
     const url = URL.createObjectURL(file);
     try {
     try {
       setStatus('Bild analysieren …');
       setStatus('Bild analysieren …');
       setProgress(0.2);
       setProgress(0.25);
       const feat = await extractor(url); // { data: Float32Array }
       const feat = await pipe(url); // { data: Float32Array }
       return normalizeVec(feat.data);
       return normalizeVec(feat.data);
     } finally {
     } finally {
Zeile 116: Zeile 122:
   }
   }


   // ---------- Ranking ----------
   // ========================================================================
  //  RANKING + RENDER
  // ========================================================================
   function rankMatches(qvec, index, topK, minScore){
   function rankMatches(qvec, index, topK, minScore){
     const scored = [];
     const scored = [];
Zeile 133: Zeile 141:
   }
   }


  // ---------- Render ----------
   function renderResults(items){
   function renderResults(items){
     const box = el.res(); if(!box) return;
     const box = el.res(); if(!box) return;
Zeile 156: Zeile 163:
   }
   }


   // ---------- Input-Auswahl zusammenführen ----------
   // ========================================================================
  //  INPUT / UI
  // ========================================================================
   let CURRENT_FILE = null;
   let CURRENT_FILE = null;
   function setCurrentFile(f){
   function setCurrentFile(f){
Zeile 163: Zeile 172:
   }
   }


  // ---------- Dropzone ----------
   function wireDropzone(){
   function wireDropzone(){
     const drop = el.drop(); if (!drop) return;
     const drop = el.drop(); if (!drop) return;
Zeile 179: Zeile 187:
   }
   }


  // ---------- Binding ----------
   function hasUI(){
   function hasUI(){
     return el.run() && (el.inCam() || el.inGal()) && el.res();
     return el.run() && (el.inCam() || el.inGal()) && el.res();
Zeile 185: Zeile 192:


   let BOUND = false;
   let BOUND = false;
   function bind(origin){
   function bind(){
     if (BOUND || !hasUI()) return;
     if (BOUND || !hasUI()) return;


Zeile 259: Zeile 266:


     try {
     try {
       setStatus('Vorbereitung …'); setProgress(0);
       setStatus('Vorbereitung …'); setProgress(0.10);
       run.disabled = true; if(btnCam) btnCam.disabled = true; if(btnGal) btnGal.disabled = true;
       run.disabled = true; if(btnCam) btnCam.disabled = true; if(btnGal) btnGal.disabled = true;


      // Modul + Index laden
       await ensureClipExtractor();
       await ensureClipExtractor();
       const index = await loadLabelIndex();
       const index = await loadLabelIndex();


       setStatus('Bild analysieren …'); setProgress(0.25);
      // Query einbetten und ranken
       setStatus('Bild analysieren …'); setProgress(0.35);
       const qvec = await embedFileImage(f);
       const qvec = await embedFileImage(f);


       setStatus('Vergleiche …'); setProgress(0.4);
       setStatus('Vergleiche …'); setProgress(0.60);
       const matches = rankMatches(qvec, index, TOP_K, MIN_SCORE);
       const matches = rankMatches(qvec, index, TOP_K, MIN_SCORE);


Zeile 282: Zeile 291:
   }
   }


   // ---------- Robuste Bind-Triggers ----------
   // ---------- Bind-Trigger ----------
   if (document.readyState !== 'loading') bind('immediate');
   if (document.readyState !== 'loading') bind();
   else document.addEventListener('DOMContentLoaded', ()=>bind('DOMContentLoaded'), { once:true });
   else document.addEventListener('DOMContentLoaded', bind, { once:true });
   if (mw && mw.hook) mw.hook('wikipage.content').add(()=>bind('wikipage.content'));
   if (mw && mw.hook) mw.hook('wikipage.content').add(bind);
   setTimeout(()=>bind('timeout250'), 250);
   setTimeout(bind, 250);
   setTimeout(()=>bind('timeout1000'), 1000);
   setTimeout(bind, 1000);
   const mo = new MutationObserver(()=>{ if(!BOUND && hasUI()) bind('mutation'); });
   const mo = new MutationObserver(()=>{ if(!BOUND && hasUI()) bind(); });
   mo.observe(document.documentElement||document.body, { childList:true, subtree:true });
   mo.observe(document.documentElement||document.body, { childList:true, subtree:true });


})();
})();

Version vom 8. November 2025, 16:54 Uhr

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

  // ---------- Kurz-Helpers ----------
  const $ = (id) => document.getElementById(id);
  const esc = (s) => mw.html.escape(String(s || ''));

  const el = {
    wrap: () => $('ados-labelscan'),
    btnCam: () => $('ados-scan-btn-camera'),
    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){
    const bar = el.prog(); 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 = el.prev();
      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) {}
  }

  // ========================================================================
  //  CLIP LADEN (ES MODULE via dynamic import)  <<<<<< FIX HIER
  // ========================================================================
  let _clipModulePromise = null;
  async function ensureClipExtractor(){
    if (_clipModulePromise) return _clipModulePromise;

    // Wichtig: eine feste Version nehmen, damit der Build stabil bleibt
    const ESM_URL = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0';

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

    // Dynamischer Import eines ES-Moduls – funktioniert in allen modernen Browsern
    _clipModulePromise = (async () => {
      const mod = await import(/* webpackIgnore: true */ ESM_URL);
      // CLIP-Pipeline initialisieren (einmalig)
      const pipe = await mod.pipeline(
        'image-feature-extraction',
        'Xenova/clip-vit-base-patch32'
      );
      return { mod, pipe };
    })();

    return _clipModulePromise;
  }

  // ========================================================================
  //  INDEX LADEN (mit vorab berechneten Embeddings)
  // ========================================================================
  function decodeEmbed(b64){
    const bin = atob(b64), len = bin.length, 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; }

  let ADOS_INDEX = null;
  async function loadLabelIndex(){
    if (ADOS_INDEX) return ADOS_INDEX;
    setStatus('Index laden …');
    const page = 'MediaWiki:Gadget-LabelScan-index.json'; // <- dein Index
    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;
  }

  // ========================================================================
  //  BILD → EMBEDDING
  // ========================================================================
  async function embedFileImage(file){
    const { pipe } = await ensureClipExtractor();
    const url = URL.createObjectURL(file);
    try {
      setStatus('Bild analysieren …');
      setProgress(0.25);
      const feat = await pipe(url); // { data: Float32Array }
      return normalizeVec(feat.data);
    } finally {
      URL.revokeObjectURL(url);
    }
  }

  // ========================================================================
  //  RANKING + RENDER
  // ========================================================================
  function rankMatches(qvec, index, topK, minScore){
    const scored = [];
    for (const it of index){
      if (!it.vec) continue;
      scored.push({ it, s: cosine(qvec, it.vec) });
    }
    scored.sort((a,b)=>b.s - a.s);
    const out = [];
    for (const r of scored){
      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){
    const box = el.res(); if(!box) return;
    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>';
      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">Ähnlichkeit: ${(it.score*100).toFixed(1)}%</div>` : '') +
          '</div>' +
        '</div>';
      box.appendChild(row);
    });
  }

  // ========================================================================
  //  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();
  }

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

    const btnCam = el.btnCam();
    const btnGal = el.btnGal();
    const inCam  = el.inCam();
    const inGal  = el.inGal();
    const run    = el.run();
    const reset  = el.reset();

    // Buttons → jeweiliges Input öffnen
    if (btnCam && inCam && !btnCam.dataset._b){
      btnCam.dataset._b = '1';
      btnCam.addEventListener('click', ()=> inCam.click());
    }
    if (btnGal && inGal && !btnGal.dataset._b){
      btnGal.dataset._b = '1';
      btnGal.addEventListener('click', ()=> inGal.click());
    }

    // Inputs → Datei merken & Vorschau
    if (inCam && !inCam.dataset._b){
      inCam.dataset._b = '1';
      inCam.addEventListener('change', function(){
        if (this.files && this.files[0]) setCurrentFile(this.files[0]);
      });
    }
    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
    if (reset && !reset.dataset._b){
      reset.dataset._b = '1';
      reset.addEventListener('click', ()=>{
        setCurrentFile(null);
        if (el.prev()) el.prev().innerHTML = '<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>';
        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
    if (run && !run.dataset._b){
      run.dataset._b = '1';
      run.addEventListener('click', onRunClick);
    }

    BOUND = true;
  }

  async function onRunClick(ev){
    ev.preventDefault();
    const run = el.run();
    const btnCam = el.btnCam();
    const btnGal = el.btnGal();

    const f = CURRENT_FILE;
    if (!f){ alert('Bitte ein Bild aufnehmen oder wählen.'); return; }

    // Parameter am Wrapper überschreibbar
    const wrap = el.wrap() || document.body;
    const TOP_K     = Number(wrap?.dataset?.topk || 6);
    const MIN_SCORE = Number(wrap?.dataset?.minscore || 0.82);

    try {
      setStatus('Vorbereitung …'); setProgress(0.10);
      run.disabled = true; if(btnCam) btnCam.disabled = true; if(btnGal) btnGal.disabled = true;

      // Modul + Index laden
      await ensureClipExtractor();
      const index = await loadLabelIndex();

      // Query einbetten und ranken
      setStatus('Bild analysieren …'); setProgress(0.35);
      const qvec = await embedFileImage(f);

      setStatus('Vergleiche …'); setProgress(0.60);
      const matches = rankMatches(qvec, index, TOP_K, MIN_SCORE);

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

  // ---------- Bind-Trigger ----------
  if (document.readyState !== 'loading') bind();
  else document.addEventListener('DOMContentLoaded', bind, { once:true });
  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 });

})();