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

Keine Bearbeitungszusammenfassung
Der Seiteninhalt wurde durch einen anderen Text ersetzt: „global mw: console.log('[LabelScan] smoke test loaded'); document.addEventListener('DOMContentLoaded', function () { console.log('[LabelScan] DOM ready'); var btn = document.getElementById('ados-scan-run'); if (btn) { btn.addEventListener('click', function () { alert('Click OK – Gadget greift!'); }, { once: true }); } });“
Markierung: Ersetzt
Zeile 1: Zeile 1:
/* global mw */
/* global mw */
(function () {
console.log('[LabelScan] smoke test loaded');
   'use strict';
document.addEventListener('DOMContentLoaded', function () {
 
   console.log('[LabelScan] DOM ready');
  // =============================
   var btn = document.getElementById('ados-scan-run');
  // 1) Konfiguration
   if (btn) {
  // =============================
    btn.addEventListener('click', function () {
  const MATCH_TOPK = 6;
      alert('Click OK – Gadget greift!');
   const MATCH_THRESHOLD = 0.82; // ggf. 0.86 o. ä. – höher = strenger
     }, { once: true });
 
  // =============================
  // 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 });
 
})();