MediaWiki:Gadget-LabelScan.js: Unterschied zwischen den Versionen
Admin (Diskussion | Beiträge) Keine Bearbeitungszusammenfassung Markierung: Manuelle Zurücksetzung |
Admin (Diskussion | Beiträge) Keine Bearbeitungszusammenfassung |
||
| Zeile 3: | Zeile 3: | ||
'use strict'; | 'use strict'; | ||
// -------- | // -------- Config -------- | ||
const CFG = { | const CFG = { | ||
indexTitle: (window.LabelScanConfig && window.LabelScanConfig.indexTitle) || | indexTitle: (window.LabelScanConfig && window.LabelScanConfig.indexTitle) || | ||
'MediaWiki:Gadget-LabelScan-index.json', | 'MediaWiki:Gadget-LabelScan-index.json', | ||
// | topKShow: 8, // so viele Treffer anzeigen | ||
topKClip: 24, // so viele Kandidaten vor CLIP (per pHash oder einfach die ersten) | |||
// CLIP | maxSide: 1024, // Downscale lange Bildkante vorm CLIP | ||
transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0', | transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0/dist/transformers.min.js', | ||
modelId: 'Xenova/clip-vit-base-patch32', | modelId: 'Xenova/clip-vit-base-patch32', | ||
debug: true | 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 setStatus( | |||
function setProgress(p) { | |||
const bar = | |||
if (p == null) { bar.hidden = true; bar.value = 0; } | |||
} | } | ||
function showPreview(file) { | function showPreview(file){ | ||
const url = URL.createObjectURL(file); | const url=URL.createObjectURL(file); | ||
const prev = | const prev=$('ados-scan-preview'); | ||
if (prev) { | if(prev){ | ||
prev.innerHTML = '<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px; | prev.innerHTML='<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;">'; | ||
prev. | prev.querySelector('img').src=url; | ||
} | } | ||
} | } | ||
function esc(s){ return mw.html.escape(String(s||'')); } | |||
// -------- | // -------- Index laden -------- | ||
let INDEX=[], INDEX_EMB=[]; | |||
async function loadIndex(){ | |||
let INDEX = [] | if(INDEX.length) return INDEX; | ||
setStatus('Index laden …'); setProgress(0.03); | |||
const raw = mw.util.getUrl(CFG.indexTitle, { action:'raw', ctype:'application/json' }); | |||
async function loadIndex() { | const res = await fetch(raw, { cache:'reload' }); | ||
if (INDEX.length) return INDEX; | if(!res.ok) throw new Error('Index HTTP '+res.status); | ||
setStatus('Index laden …'); | 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)=>{ | |||
const | if(typeof it.embed === 'string' && it.embed){ | ||
const res = await fetch( | try { return base64ToFloat32(it.embed); } catch(e){ warn('Embed decode', i, it.title, e); } | ||
if (!res.ok) throw new Error('Index | |||
const | |||
if (!Array.isArray( | |||
INDEX = | |||
INDEX_EMB = INDEX.map((it, i) => { | |||
if (typeof it.embed === 'string' && it.embed | |||
try { return base64ToFloat32(it.embed); } | |||
} | } | ||
return null; | return null; | ||
}); | }); | ||
log('Index geladen:', INDEX.length, 'Einträge'); | log('Index geladen:', INDEX.length, 'Einträge'); | ||
setProgress(0.06); | setProgress(0.06); | ||
| Zeile 78: | Zeile 58: | ||
} | } | ||
function base64ToFloat32(b64){ | |||
function base64ToFloat32(b64) { | const bin=atob(b64), buf=new ArrayBuffer(bin.length), u8=new Uint8Array(buf); | ||
const bin = atob(b64) | for(let i=0;i<bin.length;i++) u8[i]=bin.charCodeAt(i); | ||
for (let i=0;i< | |||
return new Float32Array(buf); | 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 | |||
setStatus('Modell laden …'); | // -------- 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('image-feature-extraction', CFG.modelId, { quantized:true }); | |||
log('CLIP ready:', pipe.model?.constructor?.name||'unknown'); | |||
return { mod, pipe }; | |||
})(); | |||
return _clipReady; | |||
} | |||
// -------- Bild → Embedding -------- | |||
const | function fileToImage(file){ | ||
const | 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 | |||
const pipe = await | ? out.data | ||
: new Float32Array(out?.data || out || []); | |||
return normalize(vec); | |||
} | |||
async function embedURL(url){ | |||
const { pipe } = await ensureClip(); | |||
const img = await urlToImage(url); | |||
const { pipe } = await | |||
const img = await | |||
const canvas = toCanvas(img, CFG.maxSide); | 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); | |||
const | // 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 | const scored=[]; | ||
let done=0; | |||
for ( | for(const c of candidates){ | ||
const | 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); | |||
return | |||
} | } | ||
function renderResults(ranked) { | function renderResults(ranked){ | ||
const box = | const box=$('ados-scan-results'); if(!box) return; | ||
box.innerHTML=''; | |||
box.innerHTML = ''; | if(!ranked || !ranked.length){ | ||
box.innerHTML='<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder näher am Frontlabel.</div>'; | |||
if (!ranked || !ranked.length) { | |||
box.innerHTML = '<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder näher am Frontlabel.</div>'; | |||
return; | return; | ||
} | } | ||
ranked.forEach(({i,score})=>{ | |||
ranked.forEach(({ i, score }) => { | const it=INDEX[i]; const url=mw.util.getUrl((it.title||'').replace(/ /g,'_')); | ||
const it = INDEX[i]; | const div=document.createElement('div'); | ||
div.className='ados-hit'; | |||
const | 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>` + | |||
(thumb ? ` | |||
`<div><b><a href="${ | |||
`<div style="color:#666;font-variant-numeric:tabular-nums">${score.toFixed(3)}</div>`; | `<div style="color:#666;font-variant-numeric:tabular-nums">${score.toFixed(3)}</div>`; | ||
box.appendChild( | box.appendChild(div); | ||
}); | }); | ||
} | } | ||
// -------- UI binden -------- | |||
let BOUND=false; | |||
// -------- | function bind(){ | ||
if(BOUND) return; | |||
const btnCam=$('ados-scan-btn-camera'); | |||
let BOUND = false; | const btnGal=$('ados-scan-btn-gallery'); | ||
function | const inCam=$('ados-scan-file-camera'); | ||
if (BOUND) return; | const inGal=$('ados-scan-file-gallery'); | ||
const btnCam | const btnRun=$('ados-scan-run'); | ||
const btnGal | const btnReset=$('ados-scan-reset'); | ||
const inCam | const drop=$('ados-scan-drop'); | ||
const inGal | |||
const btnRun | |||
const btnReset= | |||
const drop | |||
if (!btnRun || !inCam || !inGal) return; | 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); | inCam.addEventListener('change', onPick); | ||
inGal.addEventListener('change', onPick); | inGal.addEventListener('change', onPick); | ||
if(drop){ | |||
if (drop) { | drop.addEventListener('dragover', e=>{ e.preventDefault(); drop.classList.add('is-over'); }); | ||
drop.addEventListener('dragover', | |||
drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over')); | drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over')); | ||
drop.addEventListener('drop', | drop.addEventListener('drop', e=>{ | ||
e.preventDefault(); drop.classList.remove('is-over'); | |||
const f = | const f=e.dataTransfer?.files?.[0]; | ||
if (f) { | 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 = | 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>'; | |||
const r = | |||
setStatus('Bereit.'); setProgress(null); | setStatus('Bereit.'); setProgress(null); | ||
}); | }); | ||
btnRun.addEventListener('click', async ()=>{ | |||
btnRun.addEventListener('click', | 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; | BOUND=true; | ||
log('UI gebunden.'); | 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 | |||
// -------- | |||
function init() { | |||
if (document.readyState === 'loading') { | |||
setTimeout( | |||
// | |||
loadIndex().catch(err); | loadIndex().catch(err); | ||
} | } | ||
init(); | init(); | ||
})(); | })(); | ||