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 */ | ||
(function () { | |||
'use strict'; | |||
// | // ============================= | ||
// 1) Konfiguration | |||
// ============================= | |||
const MATCH_TOPK = 6; | |||
const MATCH_THRESHOLD = 0.82; // ggf. 0.86 o. ä. – höher = strenger | |||
// | // ============================= | ||
// 2) UI-Hilfen | |||
// ============================= | |||
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 || ''; | |||
const | } | ||
if ( | |||
function setProgress(p) { | |||
const bar = $('ados-scan-progress'); | |||
if ( | 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 { | try { | ||
const url = URL.createObjectURL(file); | |||
} catch ( | const prev = $('ados-scan-preview'); | ||
console.warn('[LabelScan] | 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 fehlgeschlagen:', e); | |||
} | } | ||
} | } | ||
// | // ============================= | ||
// 3) CLIP (Xenova) im Browser | |||
// ============================= | |||
let CLIP_READY = null; | |||
async function ensureClipExtractor() { | |||
if (CLIP_READY) return CLIP_READY; | |||
CLIP_READY = new Promise((resolve, reject) => { | |||
// transformers.js laden, falls nicht vorhanden | |||
}); | 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' | |||
); | |||
resolve(pipe); | |||
} catch (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; | |||
} | |||
// ============================= | |||
// 4) Index laden & vorbereiten | |||
// ============================= | |||
function decodeEmbed(b64) { | |||
// Base64 -> Uint8Array -> Float32Array (little-endian) | |||
const bin = atob(b64); | |||
const len = bin.length; | |||
const 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; // bei normalisierten Vektoren = Cosine-Similarity | |||
} | |||
let ADOS_INDEX = null; | |||
async function loadLabelIndex() { | |||
if (ADOS_INDEX) return ADOS_INDEX; | |||
const page = 'MediaWiki:Gadget-LabelScan-index.json'; | |||
const url1 = mw.util.getUrl(page, { action: 'raw', ctype: 'application/json', maxage: 0, smaxage: 0, _: Date.now() }); | |||
const url2 = (mw.config.get('wgScript') || '/index.php') + '?title=' + encodeURIComponent(page) + '&action=raw&ctype=application/json&_=' + Date.now(); | |||
async function fetchJson(url) { | |||
const r = await fetch(url, { cache: 'no-store' }); | |||
if (!r.ok) throw new Error('HTTP ' + r.status); | |||
let t = await r.text(); | |||
if (t.charCodeAt(0) === 0xFEFF) t = t.slice(1); // BOM | |||
try { return JSON.parse(t); } | |||
catch { | |||
// hart strippen, falls mal <pre> o.ä. drumrum ist | |||
const stripped = t.replace(/^.*?\[/s, '[').replace(/\].*$/s, ']'); | |||
return JSON.parse(stripped); | |||
} | |||
} | |||
let raw; | |||
try { | |||
raw = await fetchJson(url1); | |||
} catch (e1) { | |||
console.warn('[LabelScan] Primäre Raw-URL fehlgeschlagen:', e1?.message); | |||
raw = await fetchJson(url2); | |||
} | |||
if (!Array.isArray(raw) || !raw.length) { | |||
throw new Error('Index leer oder kein Array'); | |||
} | |||
if (typeof raw[0].embed !== 'string') { | |||
console.warn('[LabelScan] Index hat kein "embed" – bitte den CLIP-Index hochladen.'); | |||
throw new Error('Index ohne Embeddings'); | |||
} | |||
ADOS_INDEX = raw.map(item => { | |||
const vec = normalizeVec(decodeEmbed(item.embed)); | |||
return { title: item.title, thumb: item.thumb, vec }; | |||
}); | |||
console.log(`[LabelScan] Index geladen: ${ADOS_INDEX.length} Einträge`); | |||
if (ADOS_INDEX[0]?.vec?.length) { | |||
console.log('[LabelScan] Embedding-Dimension:', ADOS_INDEX[0].vec.length); | |||
} | |||
return ADOS_INDEX; | |||
( | |||
} | } | ||
// =========== | // ============================= | ||
// 5) Bild -> Embedding | |||
function | // ============================= | ||
const | async function embedFileImage(file) { | ||
const extractor = await ensureClipExtractor(); | |||
const url = URL.createObjectURL(file); | |||
try { | |||
const feat = await extractor(url); | |||
const vec = normalizeVec(feat.data); | |||
// Debug | |||
const norm = Math.sqrt(vec.reduce((a, c) => a + c * c, 0)).toFixed(3); | |||
console.log('[LabelScan] query norm ~', norm, 'first3=', Array.from(vec.slice(0, 3)).map(v => v.toFixed(3)).join(', ')); | |||
return vec; | |||
} finally { | |||
URL.revokeObjectURL(url); | |||
} | |||
} | } | ||
// =========== | // ============================= | ||
// 6) Ranking & Rendering | |||
// ============================= | |||
function rankMatches(queryVec, index, topK, threshold) { | |||
const scored = index.map(it => ({ it, s: cosine(queryVec, it.vec) })); | |||
scored.sort((a, b) => b.s - a.s); | |||
const out = []; | |||
for (const r of scored) { | |||
if (threshold != null && r.s < threshold) break; | |||
out.push({ title: r.it.title, thumb: r.it.thumb, score: r.s }); | |||
if (out.length >= topK) break; | |||
const | |||
for( | |||
} | } | ||
return out; | |||
return | |||
} | } | ||
function renderResults(items) { | |||
function renderResults(items){ | |||
const box = $('ados-scan-results'); | const box = $('ados-scan-results'); | ||
if (!box) return; | if (!box) return; | ||
box.innerHTML = ''; | box.innerHTML = ''; | ||
if (!items || !items.length){ | if (!items || !items.length) { | ||
box.innerHTML = '<div class=" | box.innerHTML = '<div class="ados-hit">Keine klaren Treffer. Bitte anderes Foto oder manuell suchen.</div>'; | ||
return; | return; | ||
} | } | ||
items.forEach(it=>{ | items.forEach(it => { | ||
const | 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>` + | |||
<div | (typeof it.score === 'number' ? `<div class="meta">Score: ${(it.score * 100).toFixed(1)}%</div>` : '') + | ||
</div> | '</div>' + | ||
'</div>'; | |||
box.appendChild(row); | |||
}); | }); | ||
} | } | ||
// =========== | // ============================= | ||
// 7) UI-Binding | |||
// ============================= | |||
function hasUI() { | |||
return !!$('ados-scan-file') && !!$('ados-scan-run'); | |||
} | |||
let BOUND = false; | |||
function bind() { | |||
if (BOUND || !hasUI()) return; | |||
const fileIn = $('ados-scan-file'); | |||
const runBtn = $('ados-scan-run'); | |||
const photoBtn = $('ados-scan-photo'); // optional | |||
const pickBtn = $('ados-scan-pick'); // optional | |||
const bigBtn = $('ados-scan-bigbtn'); // optional | |||
const drop = $('ados-scan-drop'); // optional Dropzone | |||
if (!fileIn || !runBtn) return; | |||
// Mehrfaches Binden verhindern | |||
if (runBtn.dataset.bound === '1') return; | |||
runBtn.dataset.bound = '1'; | |||
BOUND = true; | |||
// Buttons: Foto aufnehmen / Bild wählen | |||
if (photoBtn) { | |||
photoBtn.addEventListener('click', () => { | |||
try { fileIn.setAttribute('capture', 'environment'); } catch {} | |||
fileIn.click(); | |||
}); | |||
} | } | ||
if (pickBtn) { | |||
pickBtn.addEventListener('click', () => { | |||
try { fileIn.removeAttribute('capture'); } catch {} | |||
fileIn.click(); | |||
}); | |||
if ( | |||
} | } | ||
if ( | if (bigBtn) { | ||
bigBtn.addEventListener('click', () => { | |||
// bevorzugt: Kamera | |||
try { fileIn.setAttribute('capture', 'environment'); } catch {} | |||
fileIn.click(); | |||
}); | |||
} | } | ||
// | // Datei gewählt → Vorschau | ||
fileIn.addEventListener('change', function () { | |||
if (this.files && this.files[0]) showPreview(this.files[0]); | |||
}); | |||
['dragenter','dragover','dragleave','drop'].forEach( | |||
drop.addEventListener('drop', | // Drag & Drop (optional) | ||
const | if (drop) { | ||
['dragenter','dragover','dragleave','drop'].forEach(ev => { | |||
drop.addEventListener(ev, e => { e.preventDefault(); e.stopPropagation(); }, false); | |||
}); | |||
drop.addEventListener('drop', e => { | |||
const dt = e.dataTransfer; | |||
if (dt && dt.files && dt.files[0]) { | |||
fileIn.files = dt.files; | |||
showPreview(dt.files[0]); | |||
} | |||
}); | }); | ||
} | } | ||
// | // Klick → Erkennen & Suchen | ||
runBtn.addEventListener('click', async (ev) => { | |||
ev.preventDefault(); | |||
const f = fileIn.files && fileIn.files[0]; | |||
if (!f) { alert('Bitte ein Bild wählen oder aufnehmen.'); return; } | |||
runBtn.disabled = true; | |||
if (photoBtn) photoBtn.disabled = true; | |||
if (pickBtn) pickBtn.disabled = true; | |||
try { | |||
setStatus('Modell laden …'); | |||
await ensureClipExtractor(); | |||
setStatus('Index laden …'); | |||
setProgress(null); | const index = await loadLabelIndex(); | ||
setStatus('Bild einlesen …'); | |||
setProgress(0.1); | |||
const qvec = await embedFileImage(f); | |||
setStatus('Vergleiche …'); | |||
setProgress(0.2); | |||
const matches = rankMatches(qvec, index, MATCH_TOPK, MATCH_THRESHOLD); | |||
renderResults(matches); | |||
setStatus(matches.length ? 'Fertig.' : 'Keine klaren Treffer – bitte anderes Foto probieren.'); | |||
} catch (e) { | |||
console.error('[LabelScan] Fehler:', e); | |||
setStatus('Fehler bei Erkennung/Suche. Bitte erneut versuchen.'); | |||
} finally { | |||
setProgress(null); | |||
runBtn.disabled = false; | |||
if (photoBtn) photoBtn.disabled = false; | |||
if (pickBtn) pickBtn.disabled = false; | |||
} | |||
}); | }); | ||
console.log('[LabelScan] Gadget gebunden.'); | |||
} | } | ||
// =========== Start =========== | // ============================= | ||
// 8) Start | |||
// ============================= | |||
if (document.readyState === 'loading') { | if (document.readyState === 'loading') { | ||
document.addEventListener('DOMContentLoaded', | document.addEventListener('DOMContentLoaded', bind); | ||
} else { | } else { | ||
bind(); | |||
} | } | ||
// Fallbacks & dynamische Re-Renders | |||
setTimeout(bind, 250); | |||
setTimeout(bind, 1000); | |||
const mo = new MutationObserver(() => { if (!BOUND) bind(); }); | |||
mo.observe(document.documentElement || document.body, { childList: true, subtree: true }); | |||
})(); | })(); | ||