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 */
(function () {
'use strict';
// =========================
// UI Helpers
// =========================
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; }
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.');
}
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; }
}
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;
}
}
// 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 });
})();