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

Keine Bearbeitungszusammenfassung
Keine Bearbeitungszusammenfassung
Zeile 1: Zeile 1:
/* global mw */
/* global mw */
console.log('[LabelScan] gadget file loaded');
(function () {
  'use strict';


// --- robuste Ready/Bind-Mechanik ---
  // =========================
function bind(origin) {
  //   UI Helpers
  try {
  // =========================
     console.log('[LabelScan] bind from:', origin, 'state=', document.readyState);
  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 fail:', e); }
  }


     var run = document.getElementById('ados-scan-run');
  // =========================
    var photo = document.getElementById('ados-scan-photo');
  //  CLIP-Ladeschicht
    var pick = document.getElementById('ados-scan-pick');
  // =========================
    var file = document.getElementById('ados-scan-file');
  let CLIP_READY=null;
  async function ensureClipExtractor(){
     if(CLIP_READY) return CLIP_READY;
    setStatus('Modell laden …');
    CLIP_READY = new Promise((resolve,reject)=>{
      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');
          console.log('[LabelScan] CLIP bereit.');
          resolve(pipe);
        }catch(e){ console.error('[LabelScan] CLIP init fail', 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;
  }


     console.log('[LabelScan] elements:', {
  // =========================
      run: !!run, photo: !!photo, pick: !!pick, file: !!file
  //  Index laden (mit Embeds)
     });
  // =========================
  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; }


     if (!run) {
  let ADOS_INDEX=null;
      console.warn('[LabelScan] kein #ados-scan-run gefunden – steht das HTML wirklich auf dieser Seite?');
  async function loadLabelIndex(){
       return;
     if(ADOS_INDEX) return ADOS_INDEX;
    setStatus('Index laden …');
    const page = 'MediaWiki:Gadget-LabelScan-index.json'; // <-- HIER liegt 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 parse fail', e, txt.slice(0,200)); throw e; }
    if(!Array.isArray(json) || !json.length){
      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.');
     }
     }


     if (!run.dataset._bound) {
     ADOS_INDEX = json.map((it, i)=>{
       run.dataset._bound = '1';
      let vec=null;
      run.addEventListener('click', function () {
      if (it.embed) {
        alert('Click OK – Gadget greift!');
        try { vec = normalizeVec( decodeEmbed(it.embed) ); }
       }, { once: true });
        catch(e){ console.warn('[LabelScan] embed decode fail @', i, it.title); vec=null; }
       console.log('[LabelScan] click bound on run');
      }
     } else {
       return { title: it.title, thumb: it.thumb || '', vec, phash: it.phash || null };
       console.log('[LabelScan] already bound, skip');
    });
    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;
  }
 
  // =========================
  //  Query → Embedding
  // =========================
  async function embedFileImage(file){
    const extractor = await ensureClipExtractor();
    const url = URL.createObjectURL(file);
    try{
      setStatus('Bild einlesen …'); setProgress(0.1);
       const feat = await extractor(url); // { data: Float32Array }
      const v = normalizeVec(feat.data);
       console.log('[LabelScan] query dim=', v.length);
      return v;
     }finally{
       URL.revokeObjectURL(url);
     }
     }
  }


    // OPTIONAL: Buttons für Foto/Galerie testweise anklemmen
  // =========================
    function clickInput(withCapture) {
  //   Ranking
      try {
  // =========================
        if (file) {
  function rankMatches(qvec, index, topK, minScore){
          if (withCapture) { file.setAttribute('capture', 'environment'); }
    // Fallback auf phash? (optional – hier deaktiviert, da index mit embed erwartet wird)
          else { file.removeAttribute('capture'); }
    const scored = [];
          file.click();
    for(const it of index){
        }
      if(!it.vec) continue; // ohne embed überspringen
      } catch (e) {}
      scored.push({ it, s: cosine(qvec, it.vec) });
     }
     }
     if (photo && !photo.dataset._bound) {
     scored.sort((a,b)=>b.s-a.s);
       photo.dataset._bound = '1';
    const out=[];
       photo.addEventListener('click', function () { clickInput(true); });
    for(const r of scored){
       console.log('[LabelScan] photo bound');
       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;
     }
     }
     if (pick && !pick.dataset._bound) {
     return out;
      pick.dataset._bound = '1';
  }
      pick.addEventListener('click', function () { clickInput(false); });
 
       console.log('[LabelScan] pick bound');
  // =========================
  //  Render
  // =========================
  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 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);
    });
  }
  // =========================
  //  Binding
  // =========================
  function clickInput(withCapture){
    const file=$('ados-scan-file'); if(!file) return;
    try {
      if(withCapture){ file.setAttribute('capture','environment'); }
      else { file.removeAttribute('capture'); }
    } catch {}
    file.click();
  }
  function hasUI(){
    return $('ados-scan-run') && $('ados-scan-file') && $('ados-scan-results');
  }
  let BOUND=false;
  function bind(origin){
    try{
      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; }
      // Buttons verbinden (nur 1x)
      if(!run.dataset._bound){
        run.dataset._bound='1';
        run.addEventListener('click', onRunClick);
      }
      if(photo && !photo.dataset._bound){
        photo.dataset._bound='1';
        photo.addEventListener('click', ()=> clickInput(true));
      }
      if(pick && !pick.dataset._bound){
        pick.dataset._bound='1';
        pick.addEventListener('click', ()=> clickInput(false));
      }
      file.addEventListener('change', function(){
        if(this.files && this.files[0]) showPreview(this.files[0]);
      });


  } catch (e) {
      BOUND = true;
    console.error('[LabelScan] bind error:', e);
      console.log('[LabelScan] Gadget gebunden via', origin || 'init');
    }catch(e){
      console.error('[LabelScan] bind error', e);
    }
   }
   }
}


// 1) Sofort versuchen (falls DOM schon bereit ist)
  async function onRunClick(ev){
if (document.readyState !== 'loading') {
    ev.preventDefault();
  bind('immediate');
    const run  = $('ados-scan-run');
} else {
    const file = $('ados-scan-file');
  // 2) Klassisches DOMContentLoaded
    const photo= $('ados-scan-photo');
  document.addEventListener('DOMContentLoaded', function onDom() {
    const pick = $('ados-scan-pick');
    bind('DOMContentLoaded');
 
  }, { once: true });
    const f = file && file.files && file.files[0];
}
    if(!f){ alert('Bitte ein Bild wählen oder aufnehmen.'); return; }
 
    // Parameter (optional über data-* am Wrapper steuerbar)
    const wrapper = document.getElementById('ados-labelscan') || document.body;
    const TOP_K    = Number(wrapper?.dataset?.topk || 6);
    const MIN_SCORE = Number(wrapper?.dataset?.minscore || 0.82);
 
    try{
      setProgress(0); setStatus('Modell laden …');
      run.disabled=true; if(photo) photo.disabled=true; if(pick) pick.disabled=true;
 
      await ensureClipExtractor();
      const index = await loadLabelIndex();


// 3) MediaWiki-Hook für dynamisch ersetzte Seiteninhalte
      setStatus('Bild analysieren …'); setProgress(0.2);
if (mw && mw.hook) {
      const qvec = await embedFileImage(f);
  mw.hook('wikipage.content').add(function () {
    bind('mw.hook(wikipage.content)');
  });
}


// 4) Fallbacks (manche Skins/Module laden später)
      setStatus('Vergleiche …'); setProgress(0.3);
setTimeout(function(){ bind('timeout-250ms'); }, 250);
      const matches = rankMatches(qvec, index, TOP_K, MIN_SCORE);
setTimeout(function(){ bind('timeout-1000ms'); }, 1000);


// 5) Optional: MutationObserver, falls der Inhalt später ins DOM kommt
      renderResults(matches);
var mo = new MutationObserver(function () {
      setStatus(matches.length ? 'Fertig.' : 'Keine klaren Treffer – bitte ein anderes Foto versuchen.');
  // Versuche nur zu binden, wenn noch nicht gebunden
    }catch(e){
  var run = document.getElementById('ados-scan-run');
      console.error('[LabelScan] Fehler', e);
  if (run && !run.dataset._bound) {
      setStatus('Fehler bei Erkennung/Suche.');
     bind('mutation');
    }finally{
      setProgress(null);
      run.disabled=false; if(photo) photo.disabled=false; if(pick) pick.disabled=false;
     }
   }
   }
});
 
mo.observe(document.documentElement || document.body, { childList: true, subtree: true });
  // robuste Bind-Triggers
  if (document.readyState !== 'loading') { bind('immediate'); }
  else { document.addEventListener('DOMContentLoaded', ()=>bind('DOMContentLoaded'), { once:true }); }
  if (mw && mw.hook) mw.hook('wikipage.content').add(()=>bind('wikipage.content'));
  setTimeout(()=>bind('timeout250'), 250);
  setTimeout(()=>bind('timeout1000'), 1000);
  const mo=new MutationObserver(()=>{ if(!BOUND) bind('mutation'); });
  mo.observe(document.documentElement||document.body, { childList:true, subtree:true });
 
})();