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 */
(() => {
'use strict';
// -------- Config --------
const CFG = {
indexTitle: (window.LabelScanConfig && window.LabelScanConfig.indexTitle) ||
'MediaWiki:Gadget-LabelScan-index.json',
topKShow: 8, // so viele Treffer anzeigen
topKClip: 24, // so viele Kandidaten vor CLIP (per pHash oder einfach die ersten)
maxSide: 1024, // Downscale lange Bildkante vorm CLIP
transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0/dist/transformers.min.js',
modelId: 'Xenova/clip-vit-base-patch32',
debug: true
};
// -------- Utils --------
const $ = id => document.getElementById(id);
const log = (...a) => { if (CFG.debug) console.log('[LabelScan]', ...a); };
const warn = (...a) => { if (CFG.debug) console.warn('[LabelScan]', ...a); };
const err = (...a) => console.error('[LabelScan]', ...a);
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){
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;">';
prev.querySelector('img').src=url;
}
}
function esc(s){ return mw.html.escape(String(s||'')); }
// -------- Index laden --------
let INDEX=[], INDEX_EMB=[];
async function loadIndex(){
if(INDEX.length) return INDEX;
setStatus('Index laden …'); setProgress(0.03);
const raw = mw.util.getUrl(CFG.indexTitle, { action:'raw', ctype:'application/json' });
const res = await fetch(raw, { cache:'reload' });
if(!res.ok) throw new Error('Index HTTP '+res.status);
const data = await res.json();
if(!Array.isArray(data)) throw new Error('Index ist kein Array');
INDEX = data.filter(x => x && x.title && x.thumb);
INDEX_EMB = INDEX.map((it,i)=>{
if(typeof it.embed === 'string' && it.embed){
try { return base64ToFloat32(it.embed); } catch(e){ warn('Embed decode', i, it.title, e); }
}
return null;
});
log('Index geladen:', INDEX.length, 'Einträge');
setProgress(0.06);
return INDEX;
}
function base64ToFloat32(b64){
const bin=atob(b64), buf=new ArrayBuffer(bin.length), u8=new Uint8Array(buf);
for(let i=0;i<bin.length;i++) u8[i]=bin.charCodeAt(i);
return new Float32Array(buf);
}
// -------- pHash Helfer (optional) --------
function hexToBigInt(h){ try { return BigInt('0x'+String(h).trim()); } catch{ return null; } }
function ham64(aHex,bHex){
const a=hexToBigInt(aHex), b=hexToBigInt(bHex);
if(a===null||b===null) return 64;
let x=a^b, d=0n; while(x){ d+=(x&1n); x>>=1n; } return Number(d);
}
function phashScore(a,b){ const d=ham64(a,b); return 1-(d/64); } // 1..0
// -------- CLIP laden --------
let _clipReady=null;
async function ensureClip(){
if(_clipReady) return _clipReady;
setStatus('Modell laden …'); setProgress(0.08);
_clipReady = (async ()=>{
const mod = await import(/* webpackIgnore: true */ CFG.transformersURL);
mod.env.localModelPath=null;
mod.env.remoteModels=true;
mod.env.allowRemoteModels=true;
mod.env.useBrowserCache=true;
const pipe = await mod.pipeline('feature-extraction', CFG.modelId, { quantized:true });
log('CLIP ready:', pipe.model?.constructor?.name||'unknown');
return { mod, pipe };
})();
return _clipReady;
}
// -------- Bild → Embedding --------
function fileToImage(file){
return new Promise((res,rej)=>{
const url=URL.createObjectURL(file);
const img=new Image(); img.crossOrigin='anonymous';
img.onload=()=>{ URL.revokeObjectURL(url); res(img); };
img.onerror=e=>{ URL.revokeObjectURL(url); rej(e); };
img.src=url;
});
}
function urlToImage(url){
return new Promise((res,rej)=>{
const img=new Image(); img.crossOrigin='anonymous';
img.onload=()=>res(img); img.onerror=rej; img.src=url;
});
}
function toCanvas(img,maxSide){
const c=document.createElement('canvas');
let {width:w,height:h}=img;
const s=Math.min(1, maxSide/Math.max(w,h)); w=Math.round(w*s); h=Math.round(h*s);
c.width=w; c.height=h; c.getContext('2d').drawImage(img,0,0,w,h);
return c;
}
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; }
async function embedFile(file){
const { pipe } = await ensureClip();
setStatus('Bild vorbereiten …'); setProgress(0.20);
const img = await fileToImage(file);
const canvas = toCanvas(img, CFG.maxSide);
setStatus('Bild analysieren …'); setProgress(0.38);
const out = await pipe(canvas);
const vec = out?.data instanceof Float32Array
? out.data
: new Float32Array(out?.data || out || []);
return normalize(vec);
}
async function embedURL(url){
const { pipe } = await ensureClip();
const img = await urlToImage(url);
const canvas = toCanvas(img, CFG.maxSide);
const out = await pipe(canvas);
const vec = out?.data instanceof Float32Array
? out.data
: new Float32Array(out?.data || out || []);
return normalize(vec);
}
// -------- Matching --------
async function matchImage(file){
await loadIndex();
showPreview(file);
// 1) Query-Embedding
const q = await embedFile(file);
// 2) Kandidatenliste bestimmen
// a) wenn Index pHash hat und du *auch* Upload-pHash hättest → vorfiltern.
// (Wir haben keinen Upload-pHash → fallback: nimm die ersten N)
// b) Oder wenn viele ohne Embed → nimm die ersten N
let candidates = INDEX.map((it, i) => ({ i, it, p: (it.phash ? 0.5 : 0.5) }));
// Leichte Sortierung: solche mit Embedding bevorzugen
candidates.sort((a,b)=>{
const ae = INDEX_EMB[a.i] ? 1 : 0;
const be = INDEX_EMB[b.i] ? 1 : 0;
return be-ae;
});
candidates = candidates.slice(0, Math.max(CFG.topKClip, CFG.topKShow));
// 3) Scoring: vorhandene Embeddings direkt; fehlende live aus Thumb
setStatus('Kandidaten bewerten …'); setProgress(0.55);
const scored=[];
let done=0;
for(const c of candidates){
try{
const vec = INDEX_EMB[c.i] || await embedURL(c.it.thumb);
const s = cosine(q, vec);
scored.push({ i:c.i, score:s });
}catch(e){
// Thumb-Load-Fehler ignorieren
}finally{
done++; setProgress(0.55 + 0.35*(done/candidates.length));
}
}
scored.sort((a,b)=> b.score-a.score);
return scored.slice(0, CFG.topKShow);
}
function renderResults(ranked){
const box=$('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 url=mw.util.getUrl((it.title||'').replace(/ /g,'_'));
const div=document.createElement('div');
div.className='ados-hit';
div.style.display='grid';
div.style.gridTemplateColumns='60px 1fr auto';
div.style.gap='10px'; div.style.alignItems='center';
div.innerHTML =
(it.thumb? `<img src="${it.thumb}" alt="" style="width:60px;height:auto;border-radius:6px;border:1px solid #eee;">` : '<div></div>') +
`<div><b><a href="${url}">${esc(it.title||'')}</a></b></div>` +
`<div style="color:#666;font-variant-numeric:tabular-nums">${score.toFixed(3)}</div>`;
box.appendChild(div);
});
}
// -------- UI binden --------
let BOUND=false;
function bind(){
if(BOUND) return;
const btnCam=$('ados-scan-btn-camera');
const btnGal=$('ados-scan-btn-gallery');
const inCam=$('ados-scan-file-camera');
const inGal=$('ados-scan-file-gallery');
const btnRun=$('ados-scan-run');
const btnReset=$('ados-scan-reset');
const drop=$('ados-scan-drop');
if(!btnRun || !inCam || !inGal){ warn('UI unvollständig'); return; }
btnCam?.addEventListener('click', ()=> inCam.click());
btnGal?.addEventListener('click', ()=> inGal.click());
const onPick=e=>{ const f=e.target.files?.[0]; if(f) showPreview(f); };
inCam.addEventListener('change', onPick);
inGal.addEventListener('change', onPick);
if(drop){
drop.addEventListener('dragover', e=>{ e.preventDefault(); drop.classList.add('is-over'); });
drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over'));
drop.addEventListener('drop', e=>{
e.preventDefault(); drop.classList.remove('is-over');
const f=e.dataTransfer?.files?.[0];
if(f){ const dt=new DataTransfer(); dt.items.add(f); inGal.files=dt.files; showPreview(f); }
});
}
btnReset?.addEventListener('click', ()=>{
inCam.value=''; inGal.value='';
const p=$('ados-scan-preview'); if(p) p.innerHTML='<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>';
const r=$('ados-scan-results'); if(r) r.innerHTML='<div class="empty">Hier erscheinen passende Abfüllungen mit Link ins Wiki.</div>';
setStatus('Bereit.'); setProgress(null);
});
btnRun.addEventListener('click', async ()=>{
try{
const f=inCam.files?.[0] || inGal.files?.[0];
if(!f){ alert('Bitte zuerst ein Foto aufnehmen oder auswählen.'); return; }
btnRun.disabled=true; setStatus('Starte …'); setProgress(0.02);
await loadIndex();
const ranked = await matchImage(f);
renderResults(ranked);
setStatus('Fertig.'); setProgress(null);
}catch(e){ err(e); setStatus('Fehler bei Erkennung/Suche.'); setProgress(null); }
finally{ btnRun.disabled=false; }
});
BOUND=true;
log('UI gebunden.');
}
// -------- Init --------
function init(){
log('gadget file loaded');
if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', bind, {once:true}); }
else { bind(); }
setTimeout(bind,250); setTimeout(bind,1000);
// Index vorwärmen
loadIndex().catch(err);
}
init();
})();