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