MediaWiki:Gadget-LabelScan.js: Unterschied zwischen den Versionen
Admin (Diskussion | Beiträge) Keine Bearbeitungszusammenfassung |
Admin (Diskussion | Beiträge) Keine Bearbeitungszusammenfassung Markierung: Zurückgesetzt |
||
| Zeile 1: | Zeile 1: | ||
/* global mw */ | /* global mw */ | ||
(() | (function(){ | ||
'use strict'; | 'use strict'; | ||
// ---------- | // ---------- Konfiguration ---------- | ||
function log(){ console.log('[LabelScan]', ...arguments); } | |||
function err(){ console.error('[LabelScan] Fehler', ...arguments); } | |||
const CFG = { | const CFG = { | ||
// | // ESM-Build (wichtig!): | ||
transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0/dist/transformers.min.js', | |||
modelId: 'Xenova/clip-vit-base-patch32', | |||
topKByPhash: 24, // wie viele pHash-Kandidaten für CLIP nachladen | |||
showN: 8, // wie viele Treffer anzeigen | |||
indexUrl: mw.util.getUrl('MediaWiki:Gadget-LabelScan-index.json', { action:'raw', ctype:'application/json' }) | |||
transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0', | |||
modelId: 'Xenova/clip-vit-base-patch32', | |||
// | |||
}; | }; | ||
// ---------- UI Helfer ---------- | |||
function $(id){ return document.getElementById(id); } | |||
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; return; } | |||
function | 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;" src= | prev.innerHTML = '<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;" />'; | ||
const img=prev.querySelector('img'); img.src=url; | |||
prev.setAttribute('aria-hidden','false'); | prev.setAttribute('aria-hidden','false'); | ||
} | } | ||
} | |||
function renderResults(items){ | |||
const box=$('ados-scan-results'); if(!box) return; | |||
box.innerHTML=''; | |||
if(!items || !items.length){ | |||
box.innerHTML='<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder manuell suchen.</div>'; | |||
return; | |||
} | |||
items.slice(0, CFG.showN).forEach(it=>{ | |||
const url = mw.util.getUrl(String(it.title||'').replace(/ /g,'_')); | |||
const div=document.createElement('div'); | |||
div.className='ados-hit'; | |||
div.style.margin='6px 0'; | |||
div.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;border:1px solid #eee;">` : '') + | |||
`<div><div><b><a href="${url}">${mw.html.escape(it.title||'')}</a></b></div>` + | |||
`<div class="meta" style="color:#666;font-size:90%;">Score: ${(it.score||0).toFixed(3)}</div></div>` + | |||
'</div>'; | |||
box.appendChild(div); | |||
}); | |||
} | } | ||
// -------------------- | // ---------- Index laden ---------- | ||
let _indexPromise=null, INDEX=[]; | |||
async function ensureIndex(){ | |||
if(_indexPromise) return _indexPromise; | |||
_indexPromise = fetch(CFG.indexUrl, { cache:'reload' }) | |||
.then(r=>{ if(!r.ok) throw new Error('Index HTTP '+r.status); return r.json(); }) | |||
.then(data=>{ | |||
if(!Array.isArray(data)) throw new Error('Index ist kein Array'); | |||
INDEX=data.filter(x=>x && x.title && x.thumb && x.phash); | |||
log('Index geladen:', INDEX.length, 'Einträge'); | |||
return INDEX; | |||
}) | |||
.catch(e=>{ err(e); INDEX=[]; return INDEX; }); | |||
return _indexPromise; | |||
} | |||
// ---------- pHash ---------- | |||
// erwartet 16-hex (64bit) oder 32-hex (128bit); wir normalisieren auf 64bit Vergleich | |||
function hexToBigInt(h){ try{ return BigInt('0x'+String(h).trim()); } catch(_){ return null; } } | |||
function hamming64(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); | |||
const | } | ||
function phashScore(a,b){ // 1..0 | |||
const d=hamming64(a,b); | |||
const max=64; | |||
return 1 - (d/max); | |||
} | |||
// ---------- Bild laden ---------- | |||
function fileToImage(file){ | |||
return new Promise((res,rej)=>{ | |||
const img=new Image(); | |||
img.onload=()=>res(img); | |||
img.onerror=rej; | |||
img.src=URL.createObjectURL(file); | |||
}); | }); | ||
} | } | ||
function urlToImage(url){ | |||
return new Promise((res,rej)=>{ | |||
function | const img=new Image(); | ||
const | img.crossOrigin='anonymous'; | ||
img.onload=()=>res(img); | |||
img.onerror=rej; | |||
img.src=url; | |||
}); | |||
} | } | ||
// ---------- | // ---------- CLIP laden ---------- | ||
let _clipModulePromise=null; | |||
async function ensureClipExtractor(){ | |||
let _clipModulePromise = null; | if(_clipModulePromise) return _clipModulePromise; | ||
async function ensureClipExtractor() { | |||
setStatus('Modell laden …'); setProgress(0.08); | |||
_clipModulePromise = (async ()=>{ | |||
const mod = await import(/* webpackIgnore: true */ CFG.transformersURL); | |||
// Nur Remote, im Browser cachen | |||
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 }; | |||
})().catch(e=>{ err(e); throw e; }); | |||
); | |||
return _clipModulePromise; | |||
return | } | ||
} | |||
return | // ---------- Embeddings & Cosine ---------- | ||
} | function cosine(a,b){ | ||
let dot=0, na=0, nb=0; | |||
for(let i=0;i<a.length;i++){ const x=a[i], y=b[i]; dot+=x*y; na+=x*x; nb+=y*y; } | |||
if(na===0 || nb===0) return 0; | |||
return dot / (Math.sqrt(na)*Math.sqrt(nb)); | |||
} | |||
async function embedImage(img){ | |||
const { pipe } = await ensureClipExtractor(); | |||
// transformers akzeptiert HTMLImageElement direkt: | |||
const out = await pipe(img); | |||
// out ist typischerweise Float32Array | |||
return Array.from(out.data || out); | |||
} | |||
async function embedURL(url){ | |||
const img = await urlToImage(url); | |||
return embedImage(img); | |||
} | |||
async function embedFile(file){ | |||
const img = await fileToImage(file); | |||
return embedImage(img); | |||
} | |||
// | // ---------- Matching Pipeline ---------- | ||
async function | async function matchImage(file){ | ||
await ensureIndex(); | |||
if(!INDEX.length) throw new Error('Index leer.'); | |||
// Vorschau | |||
showPreview(file); | |||
const | // pHash-Kandidaten | ||
setStatus('Vorab-Abgleich (pHash) …'); setProgress(0.18); | |||
const userPhash = null; // (Optional: clientseitig pHash berechnen – hier nicht nötig) | |||
// Wenn wir keinen pHash des Uploads haben, nehmen wir alle & sortieren später nach CLIP. | |||
// Für schnellen Vorfilter sortieren wir grob nach Titel-Länge (kein harter Nutzen) → oder zufällig mischen | |||
// Besser: Wir lassen pHash-Score=0.5 fallback, oder ignorieren pHash. | |||
// Hier: pHash nicht vorhanden → wir nutzen alle Kandidaten, schneiden aber hart auf topKByPhash zu. | |||
let prelim = INDEX.map(x=>({ item:x, pScore:0.5 })); | |||
// Optional: Falls du clientseitig pHash ergänzt, hier pScore via phashScore(user, x.phash) setzen. | |||
// leichte Bevorzugung kurzer Thumbnails (heuristisch nicht nötig) – wir gehen direkt weiter | |||
prelim = prelim.slice(0, Math.max(CFG.topKByPhash, 12)); | |||
// | // CLIP des Uploads | ||
setStatus('Bild verstehen (KI) …'); setProgress(0.38); | |||
const userVec = await embedFile(file); | |||
// CLIP für Kandidaten | |||
setStatus('Kandidaten bewerten …'); setProgress(0.55); | |||
const | let done=0; | ||
const | const scored = []; | ||
for(const k of prelim){ | |||
try{ | |||
const v = await embedURL(k.item.thumb); | |||
const c = cosine(userVec, v); // 0..1 | |||
const s = 0.6*c + 0.4*k.pScore; // Kombi aus CLIP (60%) und pHash (40%) | |||
scored.push({ title:k.item.title, thumb:k.item.thumb, score:s }); | |||
}catch(e){ | |||
// Bild konnte nicht geladen werden → überspringen | |||
}finally{ | |||
done++; setProgress(0.55 + 0.35*(done/prelim.length)); | |||
} | |||
} | } | ||
scored.sort((a,b)=>b.score-a.score); | |||
return scored; | |||
return | |||
} | } | ||
function | // ---------- Bindings ---------- | ||
const | function bindUI(){ | ||
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 (! | if(!btnRun || !btnReset || !btnCam || !btnGal || !inCam || !inGal){ | ||
log('UI unvollständig – Seite lädt evtl. ohne HTML-Wrapper <html>…</html>?'); | |||
return; | return; | ||
} | } | ||
// Buttons → Inputs | |||
btnCam.addEventListener('click', ()=> inCam.click()); | |||
btnGal.addEventListener('click', ()=> inGal.click()); | |||
function onPick(ev){ | |||
const f = ev.target.files && ev.target.files[0]; | |||
if(f){ showPreview(f); setStatus('Bereit zum Erkennen.'); } | |||
function onPick( | |||
const f = | |||
if (f) showPreview(f); | |||
} | } | ||
inCam.addEventListener('change', onPick); | inCam.addEventListener('change', onPick); | ||
inGal.addEventListener('change', onPick); | inGal.addEventListener('change', onPick); | ||
// Drag & Drop | // Drag&Drop | ||
if (drop) { | if(drop){ | ||
drop.addEventListener('dragover', | drop.addEventListener('dragover', e=>{ e.preventDefault(); drop.classList.add('is-over'); }); | ||
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'); | |||
if(e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]){ | |||
const f=e.dataTransfer.files[0]; | |||
// in | // in Galerie-Input setzen (nur zur Verwaltung), Vorschau zeigen | ||
const dt = new DataTransfer(); | const dt = new DataTransfer(); dt.items.add(f); | ||
inGal.files = dt.files; | inGal.files = dt.files; | ||
showPreview(f); | showPreview(f); | ||
setStatus('Bereit zum Erkennen.'); | |||
} | } | ||
}); | }); | ||
| Zeile 293: | Zeile 245: | ||
// Reset | // Reset | ||
btnReset.addEventListener('click', ()=>{ | |||
const p = | 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 = | 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); | setStatus('Bereit.'); setProgress(null); | ||
}); | }); | ||
// | // Run | ||
btnRun.addEventListener('click', | btnRun.addEventListener('click', async ()=>{ | ||
try{ | |||
const file = inCam.files?.[0] || inGal.files?.[0]; | |||
if(!file){ alert('Bitte zuerst ein Foto aufnehmen oder auswählen.'); return; } | |||
btnRun.disabled=true; setStatus('Starte …'); setProgress(0.05); | |||
const hits = await matchImage(file); | |||
renderResults(hits); | |||
setStatus('Fertig.'); | |||
setProgress(null); | |||
}catch(e){ | |||
err(e); | |||
setStatus('Fehler bei der Erkennung/Suche.'); | |||
setStatus(' | |||
setProgress(null); | setProgress(null); | ||
btnRun.disabled = false | }finally{ | ||
btnRun.disabled=false; | |||
} | } | ||
}); | |||
log('UI gebunden.'); | |||
} | } | ||
// ---------- | // ---------- Init ---------- | ||
function init(){ | |||
log('gadget file loaded'); | |||
function init() { | ensureIndex(); // schon mal laden | ||
// | if(document.readyState==='loading'){ | ||
if (document.readyState === 'loading') { | document.addEventListener('DOMContentLoaded', bindUI); | ||
document.addEventListener('DOMContentLoaded', bindUI | }else{ | ||
} else { | |||
bindUI(); | bindUI(); | ||
} | } | ||
} | } | ||
init(); | init(); | ||
})(); | })(); | ||