MediaWiki:Gadget-LabelScan.js: Unterschied zwischen den Versionen
Erscheinungsbild
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 }); | |||
})(); | })(); | ||
Version vom 8. November 2025, 15:56 Uhr
/* 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 || '';
}
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 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
// =============================
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;
}
return out;
}
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 manuell suchen.</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">Score: ${(it.score * 100).toFixed(1)}%</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 (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]);
});
// Drag & Drop (optional)
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 …');
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.');
}
// =============================
// 8) Start
// =============================
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bind);
} 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 });
})();