MediaWiki:Gadget-LabelScan.js
Erscheinungsbild
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 */
(() => {
'use strict';
// ------------------------------------------------------------
// Konfiguration
// ------------------------------------------------------------
const CFG = {
// Wo liegt die JSON mit Index (Titel, Thumb, EMBEDDING)?
indexTitle: (window.LabelScanConfig && window.LabelScanConfig.indexTitle) ||
'MediaWiki:Gadget-LabelScan-index.json',
// Top-N Treffer anzeigen:
topK: 8,
// CLIP-Model:
transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0',
modelId: 'Xenova/clip-vit-base-patch32', // robust & kompakt (quantized)
// Max-Seitenkante beim Downscaling (Speed/Qualität):
maxSide: 1024,
// Logging:
debug: true
};
function log(...args) { if (CFG.debug) console.log('[LabelScan]', ...args); }
function warn(...args){ if (CFG.debug) console.warn('[LabelScan]', ...args); }
function err(...args) { console.error('[LabelScan]', ...args); }
// ------------------------------------------------------------
// UI Helpers
// ------------------------------------------------------------
function qs(id) { return document.getElementById(id); }
function setStatus(txt) { const el = qs('ados-scan-status'); if (el) el.textContent = txt || ''; }
function setProgress(p) {
const bar = qs('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) {
const url = URL.createObjectURL(file);
const prev = qs('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');
}
}
// ------------------------------------------------------------
// Index laden (JSON: Titel, Thumb, embed(Base64 Float32))
// ------------------------------------------------------------
let INDEX = [];
let INDEX_EMB = []; // Array<Float32Array>
async function loadIndex() {
if (INDEX.length) return INDEX;
setStatus('Index laden …');
setProgress(0.03);
// Roh-URL bauen
const rawURL = mw.util.getUrl(CFG.indexTitle, { action:'raw', ctype:'application/json' });
const res = await fetch(rawURL, { cache:'reload' });
if (!res.ok) throw new Error('Index nicht ladbar: ' + res.status);
const json = await res.json();
if (!Array.isArray(json)) throw new Error('Index ist keine Array-JSON');
INDEX = json;
// Embeddings dekodieren (falls vorhanden)
INDEX_EMB = INDEX.map((it, i) => {
if (typeof it.embed === 'string' && it.embed.length) {
try { return base64ToFloat32(it.embed); }
catch(e){ warn('Embed-Decode-Fehler bei Index', i, it.title, e); return null; }
}
return null;
});
log('Index geladen:', INDEX.length, 'Einträge');
setProgress(0.06);
return INDEX;
}
// Base64 -> Float32Array
function base64ToFloat32(b64) {
const bin = atob(b64);
const len = bin.length;
const buf = new ArrayBuffer(len);
const view = new Uint8Array(buf);
for (let i=0;i<len;i++) view[i] = bin.charCodeAt(i);
return new Float32Array(buf);
}
// ------------------------------------------------------------
// CLIP (Transformers.js, remote, ohne lokale Models)
// ------------------------------------------------------------
let _clipModulePromise = null;
async function ensureClipExtractor() {
if (_clipModulePromise) return _clipModulePromise;
setStatus('Modell laden …');
setProgress(0.08);
_clipModulePromise = (async () => {
// ESM dynamisch importieren
const mod = await import(/* webpackIgnore: true */ CFG.transformersURL);
// WICHTIG: nur remote
mod.env.localModelPath = null;
mod.env.remoteModels = true;
mod.env.useBrowserCache = true;
// Feature-Extraction (liefert Embedding-Vektoren)
const pipe = await mod.pipeline('feature-extraction', CFG.modelId, { quantized: true });
return { mod, pipe };
})();
return _clipModulePromise;
}
// Datei -> HTMLImageElement -> Canvas (Downscale) -> Embedding (Float32Array, normiert)
async function embedFileImage(file) {
// Datei als HTMLImageElement laden
function loadImageFromFile(f) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(f);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
img.onerror = (e)=> { URL.revokeObjectURL(url); reject(e); };
img.src = url;
});
}
// Canvas-Downscale
function toCanvas(img, maxSide) {
const c = document.createElement('canvas');
let { width: w, height: h } = img;
const scale = Math.min(1, maxSide / Math.max(w, h));
w = Math.round(w * scale);
h = Math.round(h * scale);
c.width = w; c.height = h;
const ctx = c.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.drawImage(img, 0, 0, w, h);
return c;
}
const { pipe } = await ensureClipExtractor();
setStatus('Bild vorbereiten …');
setProgress(0.20);
const img = await loadImageFromFile(file);
const canvas = toCanvas(img, CFG.maxSide);
setStatus('Bild analysieren …');
setProgress(0.38);
const out = await pipe(canvas);
// Ausgabe → Float32Array normieren
const d = out && out.data;
let vec;
if (d instanceof Float32Array) {
vec = d;
} else if (Array.isArray(d)) {
// 2D → mitteln
vec = Array.isArray(d[0]) ? meanPool2D(d) : new Float32Array(d);
} else {
throw new Error('Embedding-Format unerwartet');
}
return normalize(vec);
}
function meanPool2D(arr2d) {
const rows = arr2d.length;
const dim = rows ? arr2d[0].length : 0;
const sum = new Float32Array(dim);
for (let r=0;r<rows;r++) {
const row = arr2d[r];
for (let i=0;i<dim;i++) sum[i] += row[i] || 0;
}
for (let i=0;i<dim;i++) sum[i] /= (rows || 1);
return sum;
}
function normalize(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; const L=Math.min(a.length,b.length);
for (let i=0;i<L;i++) s += a[i]*b[i];
return s; // bei normierten Vektoren = cos
}
// ------------------------------------------------------------
// Ranking & Rendering
// ------------------------------------------------------------
function rankByCosine(queryVec) {
const scores = [];
for (let i=0;i<INDEX.length;i++) {
const vec = INDEX_EMB[i];
if (!vec) continue; // ohne Embedding nicht vergleichbar
const score = cosine(queryVec, vec);
scores.push({ i, score });
}
// absteigend
scores.sort((a,b)=> b.score - a.score);
return scores.slice(0, CFG.topK);
}
function renderResults(ranked) {
const box = qs('ados-scan-results');
if (!box) return;
box.innerHTML = '';
if (!ranked || !ranked.length) {
box.innerHTML = '<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder näher am Frontlabel.</div>';
return;
}
ranked.forEach(({ i, score }) => {
const it = INDEX[i];
const link = mw.util.getUrl((it.title || '').replace(/ /g,'_'));
const thumb = it.thumb || '';
const row = document.createElement('div');
row.className = 'ados-hit';
row.style.display = 'grid';
row.style.gridTemplateColumns = '60px 1fr auto';
row.style.alignItems = 'center';
row.style.gap = '10px';
row.style.padding = '.35rem 0';
row.innerHTML =
(thumb ? `<div><img src="${thumb}" alt="" style="width:60px;height:auto;border-radius:6px;"></div>` : '<div></div>') +
`<div><b><a href="${link}">${escapeHtml(it.title || '')}</a></b></div>` +
`<div style="color:#666;font-variant-numeric:tabular-nums">${score.toFixed(3)}</div>`;
box.appendChild(row);
});
}
function escapeHtml(s){ return mw.html.escape(String(s||'')); }
// ------------------------------------------------------------
// Bindings
// ------------------------------------------------------------
let BOUND = false;
function bindUI() {
if (BOUND) return;
const btnCam = qs('ados-scan-btn-camera');
const btnGal = qs('ados-scan-btn-gallery');
const inCam = qs('ados-scan-file-camera');
const inGal = qs('ados-scan-file-gallery');
const btnRun = qs('ados-scan-run');
const btnReset= qs('ados-scan-reset');
const drop = qs('ados-scan-drop');
if (!btnRun || !inCam || !inGal) return;
// Buttons triggern jeweilige Inputs
if (btnCam) btnCam.addEventListener('click', ()=> inCam.click());
if (btnGal) btnGal.addEventListener('click', ()=> inGal.click());
// Vorschau setzen
function onPick(e) {
const f = e.target.files && e.target.files[0];
if (f) showPreview(f);
}
inCam.addEventListener('change', onPick);
inGal.addEventListener('change', onPick);
// Drag & Drop nur als Zusatz
if (drop) {
drop.addEventListener('dragover', ev => { ev.preventDefault(); drop.classList.add('is-over'); });
drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over'));
drop.addEventListener('drop', ev => {
ev.preventDefault(); drop.classList.remove('is-over');
const f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0];
if (f) {
// in "Gallery"-Input setzen, damit onRunClick es findet
const dt = new DataTransfer();
dt.items.add(f);
inGal.files = dt.files;
showPreview(f);
}
});
}
// Reset
if (btnReset) btnReset.addEventListener('click', () => {
const p = qs('ados-scan-preview'); if (p) p.innerHTML = '<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>';
inCam.value = ''; inGal.value = '';
const r = qs('ados-scan-results'); if (r) r.innerHTML = '<div class="empty">Hier erscheinen passende Abfüllungen mit Link ins Wiki.</div>';
setStatus('Bereit.'); setProgress(null);
});
// Start
btnRun.addEventListener('click', onRunClick);
BOUND = true;
log('UI gebunden.');
}
async function onRunClick() {
try {
const inCam = qs('ados-scan-file-camera');
const inGal = qs('ados-scan-file-gallery');
const btnRun = qs('ados-scan-run');
const file = (inCam.files && inCam.files[0]) || (inGal.files && inGal.files[0]);
if (!file) { alert('Bitte zuerst ein Foto auswählen oder aufnehmen.'); return; }
btnRun.disabled = true;
setStatus('Vorbereitung …');
setProgress(0.02);
// Index sicher laden
await loadIndex();
// Mind. ein Eintrag mit Embedding vorhanden?
if (!INDEX_EMB.some(v => v && v.length)) {
setStatus('Index enthält keine Embeddings – bitte Index mit Embeddings neu erzeugen.');
setProgress(null);
btnRun.disabled = false;
return;
}
// Embedding für Query-Bild
const qVec = await embedFileImage(file); // 0.20–0.40 in ensure / embed
setProgress(0.70);
// Ranking
setStatus('Abgleich mit Datenbank …');
const ranked = rankByCosine(qVec);
setProgress(0.95);
renderResults(ranked);
setStatus('Fertig.');
setProgress(null);
} catch (e) {
err('Fehler', e);
setStatus('Fehler bei Erkennung/Abgleich. Bitte erneut versuchen.');
setProgress(null);
} finally {
const btnRun = qs('ados-scan-run');
if (btnRun) btnRun.disabled = false;
}
}
// ------------------------------------------------------------
// Init
// ------------------------------------------------------------
function init() {
// Bind nach DOM fertig
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindUI, { once: true });
} else {
bindUI();
}
// Fallbacks
setTimeout(bindUI, 250);
setTimeout(bindUI, 1000);
// (Optional) Index „warm“ laden (nicht blockierend)
loadIndex().catch(err);
}
// Start
log('gadget file loaded');
init();
})();