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

Keine Bearbeitungszusammenfassung
Keine Bearbeitungszusammenfassung
Zeile 3: Zeile 3:
   'use strict';
   'use strict';


   // =========================
   // ---------- Kurz-Helpers ----------
  //  UI Helpers
   const $ = (id) => document.getElementById(id);
   // =========================
   const esc = (s) => mw.html.escape(String(s || ''));
  function $(id){ return document.getElementById(id); }
 
   function esc(s){ return mw.html.escape(String(s||'')); }
  const el = {
   function setStatus(t){ const el=$('ados-scan-status'); if(el) el.textContent=t||''; }
    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){
   function setProgress(p){
     const bar=$('ados-scan-progress'); if(!bar) return;
     const bar = el.prog(); if(!bar) return;
     if(p==null){ bar.hidden=true; bar.value=0; }
     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{
     try {
       const url=URL.createObjectURL(file);
       const url = URL.createObjectURL(file);
       const prev=$('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');
       }
       }
     }catch(e){ console.warn('[LabelScan] Preview fail:', e); }
     } catch(e) {}
   }
   }


   // =========================
   // ---------- CLIP Modell laden ----------
  //  CLIP-Ladeschicht
   let CLIP_READY = null;
  // =========================
   let CLIP_READY=null;
   async function ensureClipExtractor(){
   async function ensureClipExtractor(){
     if(CLIP_READY) return CLIP_READY;
     if (CLIP_READY) return CLIP_READY;
     setStatus('Modell laden …');
     setStatus('Modell laden …');
     CLIP_READY = new Promise((resolve,reject)=>{
     CLIP_READY = new Promise((resolve, reject) => {
       if(!window.transformers){
       if (!window.transformers){
         const s=document.createElement('script');
         const s = document.createElement('script');
         s.src='https://cdn.jsdelivr.net/npm/@xenova/transformers/dist/transformers.min.js';
         s.src = 'https://cdn.jsdelivr.net/npm/@xenova/transformers/dist/transformers.min.js';
         s.async=true;
         s.async = true;
         s.onload=async ()=>{ try{
         s.onload = async () => {
          const pipe=await window.transformers.pipeline('image-feature-extraction','Xenova/clip-vit-base-patch32');
          try {
          console.log('[LabelScan] CLIP bereit.');
            const pipe = await window.transformers.pipeline('image-feature-extraction','Xenova/clip-vit-base-patch32');
          resolve(pipe);
            resolve(pipe);
        }catch(e){ console.error('[LabelScan] CLIP init fail', e); reject(e); } };
          } catch (e){ reject(e); }
         s.onerror=reject;
        };
         s.onerror = reject;
         document.head.appendChild(s);
         document.head.appendChild(s);
       }else{
       } else {
         window.transformers.pipeline('image-feature-extraction','Xenova/clip-vit-base-patch32').then(resolve,reject);
         window.transformers.pipeline('image-feature-extraction','Xenova/clip-vit-base-patch32').then(resolve, reject);
       }
       }
     });
     });
Zeile 51: Zeile 64:
   }
   }


   // =========================
   // ---------- Index laden (mit Embeddings) ----------
  //  Index laden (mit Embeds)
  // =========================
   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);
     for(let i=0;i<len;i++) bytes[i]=bin.charCodeAt(i);
     for (let i=0;i<len;i++) bytes[i] = bin.charCodeAt(i);
     return new Float32Array(bytes.buffer);
     return new Float32Array(bytes.buffer);
   }
   }
   function normalizeVec(v){
   function normalizeVec(v){
     let n=0; for(let i=0;i<v.length;i++) n+=v[i]*v[i];
     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);
     n = Math.sqrt(n)||1;
     for(let i=0;i<v.length;i++) out[i]=v[i]/n; return out;
    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; }
   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;
   let ADOS_INDEX = null;
   async function loadLabelIndex(){
   async function loadLabelIndex(){
     if(ADOS_INDEX) return ADOS_INDEX;
     if (ADOS_INDEX) return ADOS_INDEX;
     setStatus('Index laden …');
     setStatus('Index laden …');
     const page = 'MediaWiki:Gadget-LabelScan-index.json'; // <-- HIER liegt dein Index
     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 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 res  = await fetch(url, { cache: 'no-store' });
     const txt  = await res.text();
     const txt  = await res.text();
     let json;
     let json;
     try { json = JSON.parse(txt.replace(/^\uFEFF/,'')); }
     try { json = JSON.parse(txt.replace(/^\uFEFF/, '')); }
     catch(e){ console.error('[LabelScan] Index JSON parse fail', e, txt.slice(0,200)); throw e; }
     catch(e){ console.error('[LabelScan] Index JSON fehlerhaft', e); throw e; }
     if(!Array.isArray(json) || !json.length){
     if (!Array.isArray(json) || !json.length) throw new Error('Index leer');
      throw new Error('Index leer');
    }
    // Unterstützt zwei Formen:
    // 1) [{title, thumb, embed}]        -> embed = base64(Float32)
    // 2) [{title, thumb, phash}]        -> (phash-only ist zu schwach; bitte embed bevorzugen)
    const first=json[0];
    if(typeof first.embed !== 'string'){
      console.warn('[LabelScan] Index ohne "embed". Ergebnisqualität wird deutlich schlechter sein.');
    }


     ADOS_INDEX = json.map((it, i)=>{
     ADOS_INDEX = json.map((it) => {
       let vec=null;
       let vec = null;
       if (it.embed) {
       if (typeof it.embed === 'string'){
         try { vec = normalizeVec( decodeEmbed(it.embed) ); }
         try { vec = normalizeVec( decodeEmbed(it.embed) ); } catch(e){ vec = null; }
        catch(e){ console.warn('[LabelScan] embed decode fail @', i, it.title); vec=null; }
       }
       }
       return { title: it.title, thumb: it.thumb || '', vec, phash: it.phash || null };
       return { title: it.title, thumb: it.thumb || '', vec, phash: it.phash || null };
     });
     });
    const dim = ADOS_INDEX.find(v=>v.vec)?.vec?.length || 0;
    console.log('[LabelScan] Index OK:', ADOS_INDEX.length, 'items; dim=', dim);
     return ADOS_INDEX;
     return ADOS_INDEX;
   }
   }


   // =========================
   // ---------- Bild → Embedding ----------
  //  Query → Embedding
  // =========================
   async function embedFileImage(file){
   async function embedFileImage(file){
     const extractor = await ensureClipExtractor();
     const extractor = await ensureClipExtractor();
     const url = URL.createObjectURL(file);
     const url = URL.createObjectURL(file);
     try{
     try {
       setStatus('Bild einlesen …'); setProgress(0.1);
       setStatus('Bild analysieren …');
      setProgress(0.2);
       const feat = await extractor(url); // { data: Float32Array }
       const feat = await extractor(url); // { data: Float32Array }
       const v = normalizeVec(feat.data);
       return normalizeVec(feat.data);
      console.log('[LabelScan] query dim=', v.length);
     } finally {
      return v;
     }finally{
       URL.revokeObjectURL(url);
       URL.revokeObjectURL(url);
     }
     }
   }
   }


   // =========================
   // ---------- Ranking ----------
  //  Ranking
  // =========================
   function rankMatches(qvec, index, topK, minScore){
   function rankMatches(qvec, index, topK, minScore){
    // Fallback auf phash? (optional – hier deaktiviert, da index mit embed erwartet wird)
     const scored = [];
     const scored = [];
     for(const it of index){
     for (const it of index){
       if(!it.vec) continue; // ohne embed überspringen
       if (!it.vec) continue;
       scored.push({ it, s: cosine(qvec, it.vec) });
       scored.push({ it, s: cosine(qvec, it.vec) });
     }
     }
     scored.sort((a,b)=>b.s-a.s);
     scored.sort((a,b)=>b.s - a.s);
     const out=[];
     const out = [];
     for(const r of scored){
     for (const r of scored){
       if (typeof minScore === 'number' && r.s < minScore) break;
       if (typeof minScore === 'number' && r.s < minScore) break;
       out.push({ title:r.it.title, thumb:r.it.thumb, score:r.s });
       out.push({ title: r.it.title, thumb: r.it.thumb, score: r.s });
       if(out.length >= (topK||6)) break;
       if (out.length >= (topK||6)) break;
     }
     }
     return out;
     return out;
   }
   }


   // =========================
   // ---------- Render ----------
  //  Render
  // =========================
   function renderResults(items){
   function renderResults(items){
     const box=$('ados-scan-results'); if(!box) return;
     const box = el.res(); if(!box) return;
     box.innerHTML='';
     box.innerHTML = '';
     if(!items || !items.length){
     if (!items || !items.length){
       box.innerHTML='<div class="ados-hit">Keine klaren Treffer. Bitte anderes Foto oder näher am Label versuchen.</div>';
       box.innerHTML = '<div class="ados-hit">Keine klaren Treffer. Bitte anderes Foto oder näher am Label versuchen.</div>';
       return;
       return;
     }
     }
Zeile 163: Zeile 156:
   }
   }


   // =========================
   // ---------- Input-Auswahl zusammenführen ----------
   //  Binding
  let CURRENT_FILE = null;
   // =========================
  function setCurrentFile(f){
   function clickInput(withCapture){
    CURRENT_FILE = f || null;
     const file=$('ados-scan-file'); if(!file) return;
    if (CURRENT_FILE) showPreview(CURRENT_FILE);
     try {
   }
      if(withCapture){ file.setAttribute('capture','environment'); }
 
       else { file.removeAttribute('capture'); }
   // ---------- Dropzone ----------
     } catch {}
   function wireDropzone(){
     file.click();
     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);
   }
   }


  // ---------- Binding ----------
   function hasUI(){
   function hasUI(){
     return $('ados-scan-run') && $('ados-scan-file') && $('ados-scan-results');
     return el.run() && (el.inCam() || el.inGal()) && el.res();
   }
   }


   let BOUND=false;
   let BOUND = false;
   function bind(origin){
   function bind(origin){
     try{
     if (BOUND || !hasUI()) return;
      if(BOUND || !hasUI()) return;
      const run  = $('ados-scan-run');
      const file = $('ados-scan-file');
      const photo= $('ados-scan-photo');
      const pick = $('ados-scan-pick');


      if(!run || !file) { console.warn('[LabelScan] UI unvollständig'); 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 verbinden (nur 1x)
    // Buttons → jeweiliges Input öffnen
      if(!run.dataset._bound){
    if (btnCam && inCam && !btnCam.dataset._b){
        run.dataset._bound='1';
      btnCam.dataset._b = '1';
        run.addEventListener('click', onRunClick);
      btnCam.addEventListener('click', ()=> inCam.click());
      }
    }
      if(photo && !photo.dataset._bound){
    if (btnGal && inGal && !btnGal.dataset._b){
        photo.dataset._bound='1';
      btnGal.dataset._b = '1';
        photo.addEventListener('click', ()=> clickInput(true));
      btnGal.addEventListener('click', ()=> inGal.click());
      }
    }
      if(pick && !pick.dataset._bound){
 
        pick.dataset._bound='1';
    // Inputs → Datei merken & Vorschau
        pick.addEventListener('click', ()=> clickInput(false));
    if (inCam && !inCam.dataset._b){
       }
      inCam.dataset._b = '1';
       file.addEventListener('change', function(){
      inCam.addEventListener('change', function(){
         if(this.files && this.files[0]) showPreview(this.files[0]);
        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>';
       });
       });
    }


      BOUND = true;
    // Run
       console.log('[LabelScan] Gadget gebunden via', origin || 'init');
    if (run && !run.dataset._b){
    }catch(e){
       run.dataset._b = '1';
       console.error('[LabelScan] bind error', e);
       run.addEventListener('click', onRunClick);
     }
     }
    BOUND = true;
   }
   }


   async function onRunClick(ev){
   async function onRunClick(ev){
     ev.preventDefault();
     ev.preventDefault();
     const run = $('ados-scan-run');
     const run = el.run();
    const file = $('ados-scan-file');
     const btnCam = el.btnCam();
     const photo= $('ados-scan-photo');
     const btnGal = el.btnGal();
     const pick = $('ados-scan-pick');


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


     // Parameter (optional über data-* am Wrapper steuerbar)
     // Parameter am Wrapper überschreibbar
     const wrapper = document.getElementById('ados-labelscan') || document.body;
     const wrap = el.wrap() || document.body;
     const TOP_K    = Number(wrapper?.dataset?.topk || 6);
     const TOP_K    = Number(wrap?.dataset?.topk || 6);
     const MIN_SCORE = Number(wrapper?.dataset?.minscore || 0.82);
     const MIN_SCORE = Number(wrap?.dataset?.minscore || 0.82);


     try{
     try {
       setProgress(0); setStatus('Modell laden …');
       setStatus('Vorbereitung …'); setProgress(0);
       run.disabled=true; if(photo) photo.disabled=true; if(pick) pick.disabled=true;
       run.disabled = true; if(btnCam) btnCam.disabled = true; if(btnGal) btnGal.disabled = true;


       await ensureClipExtractor();
       await ensureClipExtractor();
       const index = await loadLabelIndex();
       const index = await loadLabelIndex();


       setStatus('Bild analysieren …'); setProgress(0.2);
       setStatus('Bild analysieren …'); setProgress(0.25);
       const qvec = await embedFileImage(f);
       const qvec = await embedFileImage(f);


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


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


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


})();
})();