MediaWiki:Gadget-LabelScan.js
Hinweis: Leere nach dem Veröffentlichen den Browser-Cache, um die Änderungen sehen zu können.
- Firefox/Safari: Umschalttaste drücken und gleichzeitig Aktualisieren anklicken oder entweder Strg+F5 oder Strg+R (⌘+R auf dem Mac) drücken
- Google Chrome: Umschalttaste+Strg+R (⌘+Umschalttaste+R auf dem Mac) drücken
- Edge: Strg+F5 drücken oder Strg drücken und gleichzeitig Aktualisieren anklicken
/* global mw */
(function () {
'use strict';
// =============================
// 1) Konfiguration
// =============================
const MATCH_TOPK = 6;
const MATCH_THRESHOLD = 0.82; // ggf. 0.86 o. ä. – höher = strenger
// =============================
// 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 });
})();