MediaWiki:Gadget-LabelScan.js: Unterschied zwischen den Versionen
Admin (Diskussion | Beiträge) Keine Bearbeitungszusammenfassung |
Admin (Diskussion | Beiträge) Keine Bearbeitungszusammenfassung |
||
| Zeile 1: | Zeile 1: | ||
/* global mw */ | /* global mw */ | ||
(function () { | |||
'use strict'; | |||
// | // ========================= | ||
function | // UI Helpers | ||
// ========================= | |||
console. | function $(id){ return document.getElementById(id); } | ||
function esc(s){ return mw.html.escape(String(s||'')); } | |||
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){ | |||
try{ | |||
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;" src="'+url+'">'; | |||
prev.setAttribute('aria-hidden','false'); | |||
} | |||
}catch(e){ console.warn('[LabelScan] Preview fail:', e); } | |||
} | |||
// ========================= | |||
// CLIP-Ladeschicht | |||
// ========================= | |||
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'); | |||
console.log('[LabelScan] CLIP bereit.'); | |||
resolve(pipe); | |||
}catch(e){ console.error('[LabelScan] CLIP init fail', 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 Embeds) | |||
}); | // ========================= | ||
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; } | |||
if ( | let ADOS_INDEX=null; | ||
async function loadLabelIndex(){ | |||
if(ADOS_INDEX) return ADOS_INDEX; | |||
setStatus('Index laden …'); | |||
const page = 'MediaWiki:Gadget-LabelScan-index.json'; // <-- HIER liegt 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 parse fail', e, txt.slice(0,200)); throw e; } | |||
if(!Array.isArray(json) || !json.length){ | |||
throw new Error('Index leer'); | |||
} | |||
// Unterstützt zwei Formen: | |||
// 1) [{title, thumb, embed}] -> embed = base64(Float32) | |||
// 2) [{title, thumb, phash}] -> (phash-only ist zu schwach; bitte embed bevorzugen) | |||
const first=json[0]; | |||
if(typeof first.embed !== 'string'){ | |||
console.warn('[LabelScan] Index ohne "embed". Ergebnisqualität wird deutlich schlechter sein.'); | |||
} | } | ||
if ( | ADOS_INDEX = json.map((it, i)=>{ | ||
let vec=null; | |||
if (it.embed) { | |||
try { vec = normalizeVec( decodeEmbed(it.embed) ); } | |||
catch(e){ console.warn('[LabelScan] embed decode fail @', i, it.title); vec=null; } | |||
console.log('[LabelScan] | } | ||
} | return { title: it.title, thumb: it.thumb || '', vec, phash: it.phash || null }; | ||
}); | |||
const dim = ADOS_INDEX.find(v=>v.vec)?.vec?.length || 0; | |||
console.log('[LabelScan] Index OK:', ADOS_INDEX.length, 'items; dim=', dim); | |||
return ADOS_INDEX; | |||
} | |||
// ========================= | |||
// Query → Embedding | |||
// ========================= | |||
async function embedFileImage(file){ | |||
const extractor = await ensureClipExtractor(); | |||
const url = URL.createObjectURL(file); | |||
try{ | |||
setStatus('Bild einlesen …'); setProgress(0.1); | |||
const feat = await extractor(url); // { data: Float32Array } | |||
const v = normalizeVec(feat.data); | |||
console.log('[LabelScan] query dim=', v.length); | |||
return v; | |||
}finally{ | |||
URL.revokeObjectURL(url); | |||
} | } | ||
} | |||
// ========================= | |||
// Ranking | |||
// ========================= | |||
function rankMatches(qvec, index, topK, minScore){ | |||
// Fallback auf phash? (optional – hier deaktiviert, da index mit embed erwartet wird) | |||
const scored = []; | |||
for(const it of index){ | |||
if(!it.vec) continue; // ohne embed überspringen | |||
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=$('ados-scan-results'); 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); | |||
}); | |||
} | |||
// ========================= | |||
// Binding | |||
// ========================= | |||
function clickInput(withCapture){ | |||
const file=$('ados-scan-file'); if(!file) return; | |||
try { | |||
if(withCapture){ file.setAttribute('capture','environment'); } | |||
else { file.removeAttribute('capture'); } | |||
} catch {} | |||
file.click(); | |||
} | |||
function hasUI(){ | |||
return $('ados-scan-run') && $('ados-scan-file') && $('ados-scan-results'); | |||
} | |||
let BOUND=false; | |||
function bind(origin){ | |||
try{ | |||
if(BOUND || !hasUI()) return; | |||
const run = $('ados-scan-run'); | |||
const file = $('ados-scan-file'); | |||
const photo= $('ados-scan-photo'); | |||
const pick = $('ados-scan-pick'); | |||
if(!run || !file) { console.warn('[LabelScan] UI unvollständig'); return; } | |||
// Buttons verbinden (nur 1x) | |||
if(!run.dataset._bound){ | |||
run.dataset._bound='1'; | |||
run.addEventListener('click', onRunClick); | |||
} | |||
if(photo && !photo.dataset._bound){ | |||
photo.dataset._bound='1'; | |||
photo.addEventListener('click', ()=> clickInput(true)); | |||
} | |||
if(pick && !pick.dataset._bound){ | |||
pick.dataset._bound='1'; | |||
pick.addEventListener('click', ()=> clickInput(false)); | |||
} | |||
file.addEventListener('change', function(){ | |||
if(this.files && this.files[0]) showPreview(this.files[0]); | |||
}); | |||
BOUND = true; | |||
console.log('[LabelScan] Gadget gebunden via', origin || 'init'); | |||
}catch(e){ | |||
console.error('[LabelScan] bind error', e); | |||
} | |||
} | } | ||
async function onRunClick(ev){ | |||
ev.preventDefault(); | |||
const run = $('ados-scan-run'); | |||
} | const file = $('ados-scan-file'); | ||
const photo= $('ados-scan-photo'); | |||
const pick = $('ados-scan-pick'); | |||
const f = file && file.files && file.files[0]; | |||
if(!f){ alert('Bitte ein Bild wählen oder aufnehmen.'); return; } | |||
// Parameter (optional über data-* am Wrapper steuerbar) | |||
const wrapper = document.getElementById('ados-labelscan') || document.body; | |||
const TOP_K = Number(wrapper?.dataset?.topk || 6); | |||
const MIN_SCORE = Number(wrapper?.dataset?.minscore || 0.82); | |||
try{ | |||
setProgress(0); setStatus('Modell laden …'); | |||
run.disabled=true; if(photo) photo.disabled=true; if(pick) pick.disabled=true; | |||
await ensureClipExtractor(); | |||
const index = await loadLabelIndex(); | |||
setStatus('Bild analysieren …'); setProgress(0.2); | |||
const qvec = await embedFileImage(f); | |||
setStatus('Vergleiche …'); setProgress(0.3); | |||
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(photo) photo.disabled=false; if(pick) pick.disabled=false; | |||
} | |||
} | } | ||
}); | |||
mo.observe(document.documentElement || document.body, { childList: true, subtree: true }); | // 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) bind('mutation'); }); | |||
mo.observe(document.documentElement||document.body, { childList:true, subtree:true }); | |||
})(); | |||