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';
// ---------- Kurz-Helpers ----------
const $ = (id) => document.getElementById(id);
const esc = (s) => mw.html.escape(String(s || ''));
const el = {
wrap: () => $('ados-labelscan'),
btnCam: () => $('ados-scan-btn-camera'),
btnGal: () => $('ados-scan-btn-gallery'),
inCam: () => $('ados-scan-file-camera'),
inGal: () => $('ados-scan-file-gallery'),
drop: () => $('ados-scan-drop'),
run: () => $('ados-scan-run'),
reset: () => $('ados-scan-reset'),
stat: () => $('ados-scan-status'),
prog: () => $('ados-scan-progress'),
prev: () => $('ados-scan-preview'),
res: () => $('ados-scan-results'),
};
function setStatus(t){ const s=el.stat(); if(s) s.textContent = t || ''; }
function setProgress(p){
const bar = el.prog(); 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 = el.prev();
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) {}
}
// ---------- CLIP Modell laden ----------
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');
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;
}
// ---------- Index laden (mit Embeddings) ----------
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; }
let ADOS_INDEX = null;
async function loadLabelIndex(){
if (ADOS_INDEX) return ADOS_INDEX;
setStatus('Index laden …');
const page = 'MediaWiki:Gadget-LabelScan-index.json'; // <- 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 fehlerhaft', e); throw e; }
if (!Array.isArray(json) || !json.length) throw new Error('Index leer');
ADOS_INDEX = json.map((it) => {
let vec = null;
if (typeof it.embed === 'string'){
try { vec = normalizeVec( decodeEmbed(it.embed) ); } catch(e){ vec = null; }
}
return { title: it.title, thumb: it.thumb || '', vec, phash: it.phash || null };
});
return ADOS_INDEX;
}
// ---------- Bild → Embedding ----------
async function embedFileImage(file){
const extractor = await ensureClipExtractor();
const url = URL.createObjectURL(file);
try {
setStatus('Bild analysieren …');
setProgress(0.2);
const feat = await extractor(url); // { data: Float32Array }
return normalizeVec(feat.data);
} finally {
URL.revokeObjectURL(url);
}
}
// ---------- Ranking ----------
function rankMatches(qvec, index, topK, minScore){
const scored = [];
for (const it of index){
if (!it.vec) continue;
scored.push({ it, s: cosine(qvec, it.vec) });
}
scored.sort((a,b)=>b.s - a.s);
const out = [];
for (const r of scored){
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;
}
return out;
}
// ---------- Render ----------
function renderResults(items){
const box = el.res(); 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);
});
}
// ---------- Input-Auswahl zusammenführen ----------
let CURRENT_FILE = null;
function setCurrentFile(f){
CURRENT_FILE = f || null;
if (CURRENT_FILE) showPreview(CURRENT_FILE);
}
// ---------- Dropzone ----------
function wireDropzone(){
const drop = el.drop(); if (!drop) return;
const over = (e)=>{ e.preventDefault(); drop.classList.add('is-over'); };
const leave= (e)=>{ e.preventDefault(); drop.classList.remove('is-over'); };
const dropH= (e)=>{
e.preventDefault(); drop.classList.remove('is-over');
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f && f.type && f.type.startsWith('image/')) setCurrentFile(f);
};
drop.addEventListener('dragenter', over);
drop.addEventListener('dragover', over);
drop.addEventListener('dragleave', leave);
drop.addEventListener('drop', dropH);
}
// ---------- Binding ----------
function hasUI(){
return el.run() && (el.inCam() || el.inGal()) && el.res();
}
let BOUND = false;
function bind(origin){
if (BOUND || !hasUI()) return;
const btnCam = el.btnCam();
const btnGal = el.btnGal();
const inCam = el.inCam();
const inGal = el.inGal();
const run = el.run();
const reset = el.reset();
// Buttons → jeweiliges Input öffnen
if (btnCam && inCam && !btnCam.dataset._b){
btnCam.dataset._b = '1';
btnCam.addEventListener('click', ()=> inCam.click());
}
if (btnGal && inGal && !btnGal.dataset._b){
btnGal.dataset._b = '1';
btnGal.addEventListener('click', ()=> inGal.click());
}
// Inputs → Datei merken & Vorschau
if (inCam && !inCam.dataset._b){
inCam.dataset._b = '1';
inCam.addEventListener('change', function(){
if (this.files && this.files[0]) setCurrentFile(this.files[0]);
});
}
if (inGal && !inGal.dataset._b){
inGal.dataset._b = '1';
inGal.addEventListener('change', function(){
if (this.files && this.files[0]) setCurrentFile(this.files[0]);
});
}
// Dropzone
wireDropzone();
// Reset
if (reset && !reset.dataset._b){
reset.dataset._b = '1';
reset.addEventListener('click', ()=>{
setCurrentFile(null);
if (el.prev()) el.prev().innerHTML = '<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>';
if (el.inCam()) el.inCam().value = '';
if (el.inGal()) el.inGal().value = '';
setStatus('Bereit.'); setProgress(null);
if (el.res()) el.res().innerHTML = '<div class="empty">Hier erscheinen passende Abfüllungen mit Link ins Wiki.</div>';
});
}
// Run
if (run && !run.dataset._b){
run.dataset._b = '1';
run.addEventListener('click', onRunClick);
}
BOUND = true;
}
async function onRunClick(ev){
ev.preventDefault();
const run = el.run();
const btnCam = el.btnCam();
const btnGal = el.btnGal();
const f = CURRENT_FILE;
if (!f){ alert('Bitte ein Bild aufnehmen oder wählen.'); return; }
// Parameter am Wrapper überschreibbar
const wrap = el.wrap() || document.body;
const TOP_K = Number(wrap?.dataset?.topk || 6);
const MIN_SCORE = Number(wrap?.dataset?.minscore || 0.82);
try {
setStatus('Vorbereitung …'); setProgress(0);
run.disabled = true; if(btnCam) btnCam.disabled = true; if(btnGal) btnGal.disabled = true;
await ensureClipExtractor();
const index = await loadLabelIndex();
setStatus('Bild analysieren …'); setProgress(0.25);
const qvec = await embedFileImage(f);
setStatus('Vergleiche …'); setProgress(0.4);
const matches = rankMatches(qvec, index, TOP_K, MIN_SCORE);
renderResults(matches);
setStatus(matches.length ? 'Fertig.' : 'Keine klaren Treffer – bitte ein anderes Foto versuchen.');
} catch (e){
console.error('[LabelScan] Fehler', e);
setStatus('Fehler bei Erkennung/Suche.');
} finally {
setProgress(null);
run.disabled = false; if(btnCam) btnCam.disabled = false; if(btnGal) btnGal.disabled = false;
}
}
// ---------- 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 && hasUI()) bind('mutation'); });
mo.observe(document.documentElement||document.body, { childList:true, subtree:true });
})();