MediaWiki:Gadget-LabelScan.js: Unterschied zwischen den Versionen
Admin (Diskussion | Beiträge) Keine Bearbeitungszusammenfassung |
Admin (Diskussion | Beiträge) Keine Bearbeitungszusammenfassung |
||
| Zeile 3: | Zeile 3: | ||
'use strict'; | '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){ | function setProgress(p){ | ||
const bar= | const bar = el.prog(); if(!bar) return; | ||
if(p==null){ bar.hidden=true; bar.value=0; } | if (p == null){ bar.hidden = true; bar.value = 0; } | ||
else { bar.hidden=false; bar.value=Math.max(0,Math.min(1,p)); } | else { bar.hidden = false; bar.value = Math.max(0, Math.min(1, p)); } | ||
} | } | ||
function showPreview(file){ | function showPreview(file){ | ||
try{ | try { | ||
const url=URL.createObjectURL(file); | const url = URL.createObjectURL(file); | ||
const prev= | const prev = el.prev(); | ||
if(prev){ | if (prev){ | ||
prev.innerHTML = '<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;" src="'+url+'">'; | prev.innerHTML = '<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;" src="'+url+'">'; | ||
prev.setAttribute('aria-hidden','false'); | prev.setAttribute('aria-hidden','false'); | ||
} | } | ||
}catch(e){ | } catch(e) {} | ||
} | } | ||
// | // ---------- CLIP Modell laden ---------- | ||
let CLIP_READY = null; | |||
let CLIP_READY=null; | |||
async function ensureClipExtractor(){ | async function ensureClipExtractor(){ | ||
if(CLIP_READY) return CLIP_READY; | if (CLIP_READY) return CLIP_READY; | ||
setStatus('Modell laden …'); | setStatus('Modell laden …'); | ||
CLIP_READY = new Promise((resolve,reject)=>{ | CLIP_READY = new Promise((resolve, reject) => { | ||
if(!window.transformers){ | if (!window.transformers){ | ||
const s=document.createElement('script'); | const s = document.createElement('script'); | ||
s.src='https://cdn.jsdelivr.net/npm/@xenova/transformers/dist/transformers.min.js'; | s.src = 'https://cdn.jsdelivr.net/npm/@xenova/transformers/dist/transformers.min.js'; | ||
s.async=true; | s.async = true; | ||
s.onload=async ()=>{ try{ | 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; | }; | ||
s.onerror = reject; | |||
document.head.appendChild(s); | document.head.appendChild(s); | ||
}else{ | } else { | ||
window.transformers.pipeline('image-feature-extraction','Xenova/clip-vit-base-patch32').then(resolve,reject); | window.transformers.pipeline('image-feature-extraction','Xenova/clip-vit-base-patch32').then(resolve, reject); | ||
} | } | ||
}); | }); | ||
| Zeile 51: | Zeile 64: | ||
} | } | ||
// | // ---------- Index laden (mit Embeddings) ---------- | ||
function decodeEmbed(b64){ | function decodeEmbed(b64){ | ||
const bin=atob(b64), len=bin.length, bytes=new Uint8Array(len); | const bin = atob(b64), len = bin.length, bytes = new Uint8Array(len); | ||
for(let i=0;i<len;i++) bytes[i]=bin.charCodeAt(i); | for (let i=0;i<len;i++) bytes[i] = bin.charCodeAt(i); | ||
return new Float32Array(bytes.buffer); | return new Float32Array(bytes.buffer); | ||
} | } | ||
function normalizeVec(v){ | function normalizeVec(v){ | ||
let n=0; for(let i=0;i<v.length;i++) n+=v[i]*v[i]; | 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); | n = Math.sqrt(n)||1; | ||
for(let i=0;i<v.length;i++) out[i]=v[i]/n; return out; | 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; } | 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; | let ADOS_INDEX = null; | ||
async function loadLabelIndex(){ | async function loadLabelIndex(){ | ||
if(ADOS_INDEX) return ADOS_INDEX; | if (ADOS_INDEX) return ADOS_INDEX; | ||
setStatus('Index laden …'); | setStatus('Index laden …'); | ||
const page = 'MediaWiki:Gadget-LabelScan-index.json'; // <- | 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 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 res = await fetch(url, { cache: 'no-store' }); | ||
const txt = await res.text(); | const txt = await res.text(); | ||
let json; | let json; | ||
try { json = JSON.parse(txt.replace(/^\uFEFF/,'')); } | try { json = JSON.parse(txt.replace(/^\uFEFF/, '')); } | ||
catch(e){ console.error('[LabelScan] Index JSON | catch(e){ console.error('[LabelScan] Index JSON fehlerhaft', e); throw e; } | ||
if(!Array.isArray(json) || !json.length) | if (!Array.isArray(json) || !json.length) throw new Error('Index leer'); | ||
ADOS_INDEX = json.map((it | ADOS_INDEX = json.map((it) => { | ||
let vec=null; | let vec = null; | ||
if (it.embed) { | if (typeof it.embed === 'string'){ | ||
try { vec = normalizeVec( decodeEmbed(it.embed) ); } | try { vec = normalizeVec( decodeEmbed(it.embed) ); } catch(e){ vec = null; } | ||
} | } | ||
return { title: it.title, thumb: it.thumb || '', vec, phash: it.phash || null }; | return { title: it.title, thumb: it.thumb || '', vec, phash: it.phash || null }; | ||
}); | }); | ||
return ADOS_INDEX; | return ADOS_INDEX; | ||
} | } | ||
// | // ---------- Bild → Embedding ---------- | ||
async function embedFileImage(file){ | async function embedFileImage(file){ | ||
const extractor = await ensureClipExtractor(); | const extractor = await ensureClipExtractor(); | ||
const url = URL.createObjectURL(file); | const url = URL.createObjectURL(file); | ||
try{ | try { | ||
setStatus('Bild | setStatus('Bild analysieren …'); | ||
setProgress(0.2); | |||
const feat = await extractor(url); // { data: Float32Array } | const feat = await extractor(url); // { data: Float32Array } | ||
return normalizeVec(feat.data); | |||
} finally { | |||
}finally{ | |||
URL.revokeObjectURL(url); | URL.revokeObjectURL(url); | ||
} | } | ||
} | } | ||
// | // ---------- Ranking ---------- | ||
function rankMatches(qvec, index, topK, minScore){ | function rankMatches(qvec, index, topK, minScore){ | ||
const scored = []; | const scored = []; | ||
for(const it of index){ | for (const it of index){ | ||
if(!it.vec) continue; | if (!it.vec) continue; | ||
scored.push({ it, s: cosine(qvec, it.vec) }); | scored.push({ it, s: cosine(qvec, it.vec) }); | ||
} | } | ||
scored.sort((a,b)=>b.s-a.s); | scored.sort((a,b)=>b.s - a.s); | ||
const out=[]; | const out = []; | ||
for(const r of scored){ | for (const r of scored){ | ||
if (typeof minScore === 'number' && r.s < minScore) break; | if (typeof minScore === 'number' && r.s < minScore) break; | ||
out.push({ title:r.it.title, thumb:r.it.thumb, score:r.s }); | out.push({ title: r.it.title, thumb: r.it.thumb, score: r.s }); | ||
if(out.length >= (topK||6)) break; | if (out.length >= (topK||6)) break; | ||
} | } | ||
return out; | return out; | ||
} | } | ||
// | // ---------- Render ---------- | ||
function renderResults(items){ | function renderResults(items){ | ||
const box= | const box = el.res(); if(!box) return; | ||
box.innerHTML=''; | box.innerHTML = ''; | ||
if(!items || !items.length){ | if (!items || !items.length){ | ||
box.innerHTML='<div class="ados-hit">Keine klaren Treffer. Bitte anderes Foto oder näher am Label versuchen.</div>'; | box.innerHTML = '<div class="ados-hit">Keine klaren Treffer. Bitte anderes Foto oder näher am Label versuchen.</div>'; | ||
return; | return; | ||
} | } | ||
| Zeile 163: | Zeile 156: | ||
} | } | ||
// == | // ---------- Input-Auswahl zusammenführen ---------- | ||
let CURRENT_FILE = null; | |||
// | function setCurrentFile(f){ | ||
function | CURRENT_FILE = f || null; | ||
const | 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(){ | function hasUI(){ | ||
return | return el.run() && (el.inCam() || el.inGal()) && el.res(); | ||
} | } | ||
let BOUND=false; | let BOUND = false; | ||
function bind(origin){ | 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]) | 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){ | async function onRunClick(ev){ | ||
ev.preventDefault(); | ev.preventDefault(); | ||
const run | const run = el.run(); | ||
const btnCam = el.btnCam(); | |||
const | const btnGal = el.btnGal(); | ||
const | |||
const f = | const f = CURRENT_FILE; | ||
if(!f){ alert('Bitte ein Bild wählen | if (!f){ alert('Bitte ein Bild aufnehmen oder wählen.'); return; } | ||
// Parameter | // Parameter am Wrapper überschreibbar | ||
const | const wrap = el.wrap() || document.body; | ||
const TOP_K = Number( | const TOP_K = Number(wrap?.dataset?.topk || 6); | ||
const MIN_SCORE = Number( | const MIN_SCORE = Number(wrap?.dataset?.minscore || 0.82); | ||
try{ | try { | ||
setStatus('Vorbereitung …'); setProgress(0); | |||
run.disabled=true; if( | run.disabled = true; if(btnCam) btnCam.disabled = true; if(btnGal) btnGal.disabled = true; | ||
await ensureClipExtractor(); | await ensureClipExtractor(); | ||
const index = await loadLabelIndex(); | const index = await loadLabelIndex(); | ||
setStatus('Bild analysieren …'); setProgress(0. | setStatus('Bild analysieren …'); setProgress(0.25); | ||
const qvec = await embedFileImage(f); | const qvec = await embedFileImage(f); | ||
setStatus('Vergleiche …'); setProgress(0. | setStatus('Vergleiche …'); setProgress(0.4); | ||
const matches = rankMatches(qvec, index, TOP_K, MIN_SCORE); | const matches = rankMatches(qvec, index, TOP_K, MIN_SCORE); | ||
renderResults(matches); | renderResults(matches); | ||
setStatus(matches.length ? 'Fertig.' : 'Keine klaren Treffer – bitte ein anderes Foto versuchen.'); | setStatus(matches.length ? 'Fertig.' : 'Keine klaren Treffer – bitte ein anderes Foto versuchen.'); | ||
}catch(e){ | } catch (e){ | ||
console.error('[LabelScan] Fehler', e); | console.error('[LabelScan] Fehler', e); | ||
setStatus('Fehler bei Erkennung/Suche.'); | setStatus('Fehler bei Erkennung/Suche.'); | ||
}finally{ | } finally { | ||
setProgress(null); | setProgress(null); | ||
run.disabled=false; if( | run.disabled = false; if(btnCam) btnCam.disabled = false; if(btnGal) btnGal.disabled = false; | ||
} | } | ||
} | } | ||
// | // ---------- Robuste Bind-Triggers ---------- | ||
if (document.readyState !== 'loading') | if (document.readyState !== 'loading') bind('immediate'); | ||
else | else document.addEventListener('DOMContentLoaded', ()=>bind('DOMContentLoaded'), { once:true }); | ||
if (mw && mw.hook) mw.hook('wikipage.content').add(()=>bind('wikipage.content')); | if (mw && mw.hook) mw.hook('wikipage.content').add(()=>bind('wikipage.content')); | ||
setTimeout(()=>bind('timeout250'), 250); | setTimeout(()=>bind('timeout250'), 250); | ||
setTimeout(()=>bind('timeout1000'), 1000); | setTimeout(()=>bind('timeout1000'), 1000); | ||
const mo=new MutationObserver(()=>{ if(!BOUND) bind('mutation'); }); | const mo = new MutationObserver(()=>{ if(!BOUND && hasUI()) bind('mutation'); }); | ||
mo.observe(document.documentElement||document.body, { childList:true, subtree:true }); | mo.observe(document.documentElement||document.body, { childList:true, subtree:true }); | ||
})(); | })(); | ||