MediaWiki:Gadget-LabelScan.js: Unterschied zwischen den Versionen
Erscheinungsbild
Admin (Diskussion | Beiträge) Keine Bearbeitungszusammenfassung Markierung: Zurückgesetzt |
Admin (Diskussion | Beiträge) Keine Bearbeitungszusammenfassung Markierung: Manuelle Zurücksetzung |
||
| Zeile 1: | Zeile 1: | ||
/* global mw */ | /* global mw */ | ||
( | (() => { | ||
'use strict'; | 'use strict'; | ||
// ---------- | // ------------------------------------------------------------ | ||
// Konfiguration | |||
// ------------------------------------------------------------ | |||
const CFG = { | const CFG = { | ||
// | // Wo liegt die JSON mit Index (Titel, Thumb, EMBEDDING)? | ||
transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0 | indexTitle: (window.LabelScanConfig && window.LabelScanConfig.indexTitle) || | ||
modelId: 'Xenova/clip-vit-base-patch32', | 'MediaWiki:Gadget-LabelScan-index.json', | ||
// Top-N Treffer anzeigen: | |||
topK: 8, | |||
// CLIP-Model: | |||
transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0', | |||
modelId: 'Xenova/clip-vit-base-patch32', // robust & kompakt (quantized) | |||
// Max-Seitenkante beim Downscaling (Speed/Qualität): | |||
maxSide: 1024, | |||
// Logging: | |||
debug: true | |||
}; | }; | ||
// ---------- UI | function log(...args) { if (CFG.debug) console.log('[LabelScan]', ...args); } | ||
function | function warn(...args){ if (CFG.debug) console.warn('[LabelScan]', ...args); } | ||
function setStatus( | function err(...args) { console.error('[LabelScan]', ...args); } | ||
function setProgress(p){ | |||
const bar= | // ------------------------------------------------------------ | ||
// UI Helpers | |||
if(p==null){ bar.hidden=true; bar.value=0 | // ------------------------------------------------------------ | ||
bar.hidden=false; bar.value=Math.max(0,Math.min(1,p)); | function qs(id) { return document.getElementById(id); } | ||
function setStatus(txt) { const el = qs('ados-scan-status'); if (el) el.textContent = txt || ''; } | |||
function setProgress(p) { | |||
const bar = qs('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){ | function showPreview(file) { | ||
const url=URL.createObjectURL(file); | const url = URL.createObjectURL(file); | ||
const prev= | const prev = qs('ados-scan-preview'); | ||
if(prev){ | if (prev) { | ||
prev.innerHTML = '<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;" | 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'); | ||
} | } | ||
} | } | ||
function | |||
// ------------------------------------------------------------ | |||
// Index laden (JSON: Titel, Thumb, embed(Base64 Float32)) | |||
// ------------------------------------------------------------ | |||
let INDEX = []; | |||
let INDEX_EMB = []; // Array<Float32Array> | |||
async function loadIndex() { | |||
if (INDEX.length) return INDEX; | |||
setStatus('Index laden …'); | |||
setProgress(0.03); | |||
// Roh-URL bauen | |||
const rawURL = mw.util.getUrl(CFG.indexTitle, { action:'raw', ctype:'application/json' }); | |||
const res = await fetch(rawURL, { cache:'reload' }); | |||
if (!res.ok) throw new Error('Index nicht ladbar: ' + res.status); | |||
const json = await res.json(); | |||
if (!Array.isArray(json)) throw new Error('Index ist keine Array-JSON'); | |||
INDEX = json; | |||
// Embeddings dekodieren (falls vorhanden) | |||
INDEX_EMB = INDEX.map((it, i) => { | |||
if (typeof it.embed === 'string' && it.embed.length) { | |||
try { return base64ToFloat32(it.embed); } | |||
catch(e){ warn('Embed-Decode-Fehler bei Index', i, it.title, e); return null; } | |||
} | |||
return null; | |||
}); | }); | ||
log('Index geladen:', INDEX.length, 'Einträge'); | |||
setProgress(0.06); | |||
return INDEX; | |||
return | |||
} | } | ||
// - | // Base64 -> Float32Array | ||
function base64ToFloat32(b64) { | |||
const bin = atob(b64); | |||
function | const len = bin.length; | ||
const | const buf = new ArrayBuffer(len); | ||
const view = new Uint8Array(buf); | |||
for (let i=0;i<len;i++) view[i] = bin.charCodeAt(i); | |||
return new Float32Array(buf); | |||
return | |||
} | } | ||
// ------------------------------------------------------------ | |||
// CLIP: Feature-Extraction (nur Bild, ohne Tokenizer) | |||
// ------------------------------------------------------------ | |||
let _clipModulePromise = null; | |||
async function ensureClipExtractor() { | |||
if (_clipModulePromise) return _clipModulePromise; | |||
setStatus('Modell laden …'); | |||
setProgress(0.08); | |||
_clipModulePromise = (async () => { | |||
const ESM_URL = CFG.transformersURL; | |||
const mod = await import(/* webpackIgnore: true */ ESM_URL); | |||
// NUR remote laden | |||
mod.env.localModelPath = null; | |||
mod.env.remoteModels = true; | |||
mod.env.allowRemoteModels = true; | |||
mod.env.useBrowserCache = true; | |||
// Hier liegt der Trick: Wir nutzen explizit "image-feature-extraction", | |||
// damit kein Tokenizer geladen wird. | |||
const pipe = await mod.pipeline( | |||
'image-feature-extraction', | |||
CFG.modelId, | |||
{ quantized: true } | |||
); | |||
log('CLIP ready:', pipe.model?.constructor?.name || 'unknown'); | |||
return { mod, pipe }; | |||
})(); | |||
return _clipModulePromise; | |||
} | |||
// --- | // Datei -> HTMLImageElement -> Canvas (Downscale) -> Embedding (Float32Array, normiert) | ||
function | async function embedFileImage(file) { | ||
// Datei als HTMLImageElement laden | |||
function loadImageFromFile(f) { | |||
return new Promise((resolve, reject) => { | |||
const url = URL.createObjectURL(f); | |||
const img = new Image(); | |||
img.crossOrigin = 'anonymous'; | |||
img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; | |||
img.onerror = (e)=> { URL.revokeObjectURL(url); reject(e); }; | |||
img.src = url; | |||
}); | |||
} | |||
// Canvas-Downscale | |||
function toCanvas(img, maxSide) { | |||
const c = document.createElement('canvas'); | |||
let { width: w, height: h } = img; | |||
const scale = Math.min(1, maxSide / Math.max(w, h)); | |||
w = Math.round(w * scale); | |||
h = Math.round(h * scale); | |||
c.width = w; c.height = h; | |||
const ctx = c.getContext('2d'); | |||
ctx.imageSmoothingEnabled = true; | |||
ctx.drawImage(img, 0, 0, w, h); | |||
return c; | |||
} | |||
const { pipe } = await ensureClipExtractor(); | const { pipe } = await ensureClipExtractor(); | ||
setStatus('Bild vorbereiten …'); | |||
setProgress(0.20); | |||
const img = await loadImageFromFile(file); | |||
const canvas = toCanvas(img, CFG.maxSide); | |||
setStatus('Bild analysieren …'); | |||
setStatus(' | setProgress(0.38); | ||
const out = await pipe(canvas); | |||
// | // Ausgabe → Float32Array normieren | ||
const d = out && out.data; | |||
let vec; | |||
if (d instanceof Float32Array) { | |||
vec = d; | |||
} else if (Array.isArray(d)) { | |||
// 2D → mitteln | |||
vec = Array.isArray(d[0]) ? meanPool2D(d) : new Float32Array(d); | |||
} else { | |||
throw new Error('Embedding-Format unerwartet'); | |||
} | |||
return normalize(vec); | |||
} | |||
/ | function meanPool2D(arr2d) { | ||
const rows = arr2d.length; | |||
const | const dim = rows ? arr2d[0].length : 0; | ||
const sum = new Float32Array(dim); | |||
for (let r=0;r<rows;r++) { | |||
const row = arr2d[r]; | |||
for (let i=0;i<dim;i++) sum[i] += row[i] || 0; | |||
} | |||
for (let i=0;i<dim;i++) sum[i] /= (rows || 1); | |||
return sum; | |||
} | |||
function normalize(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; const L=Math.min(a.length,b.length); | |||
for (let i=0;i<L;i++) s += a[i]*b[i]; | |||
return s; // bei normierten Vektoren = cos | |||
} | |||
// ------------------------------------------------------------ | |||
// Ranking & Rendering | |||
// ------------------------------------------------------------ | |||
const | function rankByCosine(queryVec) { | ||
for( | const scores = []; | ||
for (let i=0;i<INDEX.length;i++) { | |||
const vec = INDEX_EMB[i]; | |||
if (!vec) continue; // ohne Embedding nicht vergleichbar | |||
const score = cosine(queryVec, vec); | |||
scores.push({ i, score }); | |||
} | } | ||
// absteigend | |||
scores.sort((a,b)=> b.score - a.score); | |||
return | return scores.slice(0, CFG.topK); | ||
} | } | ||
function renderResults(ranked) { | |||
function | const box = qs('ados-scan-results'); | ||
const | if (!box) return; | ||
box.innerHTML = ''; | |||
if(! | if (!ranked || !ranked.length) { | ||
box.innerHTML = '<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder näher am Frontlabel.</div>'; | |||
return; | return; | ||
} | } | ||
// | ranked.forEach(({ i, score }) => { | ||
const it = INDEX[i]; | |||
const link = mw.util.getUrl((it.title || '').replace(/ /g,'_')); | |||
const thumb = it.thumb || ''; | |||
const row = document.createElement('div'); | |||
row.className = 'ados-hit'; | |||
row.style.display = 'grid'; | |||
row.style.gridTemplateColumns = '60px 1fr auto'; | |||
row.style.alignItems = 'center'; | |||
row.style.gap = '10px'; | |||
row.style.padding = '.35rem 0'; | |||
row.innerHTML = | |||
(thumb ? `<div><img src="${thumb}" alt="" style="width:60px;height:auto;border-radius:6px;"></div>` : '<div></div>') + | |||
`<div><b><a href="${link}">${escapeHtml(it.title || '')}</a></b></div>` + | |||
`<div style="color:#666;font-variant-numeric:tabular-nums">${score.toFixed(3)}</div>`; | |||
box.appendChild(row); | |||
}); | |||
} | |||
function escapeHtml(s){ return mw.html.escape(String(s||'')); } | |||
// ------------------------------------------------------------ | |||
// Bindings | |||
// ------------------------------------------------------------ | |||
let BOUND = false; | |||
function bindUI() { | |||
if (BOUND) return; | |||
const btnCam = qs('ados-scan-btn-camera'); | |||
const btnGal = qs('ados-scan-btn-gallery'); | |||
const inCam = qs('ados-scan-file-camera'); | |||
const inGal = qs('ados-scan-file-gallery'); | |||
const btnRun = qs('ados-scan-run'); | |||
const btnReset= qs('ados-scan-reset'); | |||
const drop = qs('ados-scan-drop'); | |||
if (!btnRun || !inCam || !inGal) return; | |||
function onPick( | // Buttons triggern jeweilige Inputs | ||
const f = | if (btnCam) btnCam.addEventListener('click', ()=> inCam.click()); | ||
if(f) | if (btnGal) btnGal.addEventListener('click', ()=> inGal.click()); | ||
// Vorschau setzen | |||
function onPick(e) { | |||
const f = e.target.files && e.target.files[0]; | |||
if (f) showPreview(f); | |||
} | } | ||
inCam.addEventListener('change', onPick); | inCam.addEventListener('change', onPick); | ||
inGal.addEventListener('change', onPick); | inGal.addEventListener('change', onPick); | ||
// Drag&Drop | // Drag & Drop nur als Zusatz | ||
if(drop){ | if (drop) { | ||
drop.addEventListener('dragover', | drop.addEventListener('dragover', ev => { ev.preventDefault(); drop.classList.add('is-over'); }); | ||
drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over')); | drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over')); | ||
drop.addEventListener('drop', | drop.addEventListener('drop', ev => { | ||
ev.preventDefault(); drop.classList.remove('is-over'); | |||
const f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0]; | |||
if (f) { | |||
// in | // in "Gallery"-Input setzen, damit onRunClick es findet | ||
const dt = new DataTransfer(); dt.items.add(f); | const dt = new DataTransfer(); | ||
dt.items.add(f); | |||
inGal.files = dt.files; | inGal.files = dt.files; | ||
showPreview(f); | showPreview(f); | ||
} | } | ||
}); | }); | ||
| Zeile 245: | Zeile 293: | ||
// Reset | // Reset | ||
btnReset.addEventListener('click', ()=>{ | if (btnReset) btnReset.addEventListener('click', () => { | ||
const p = qs('ados-scan-preview'); if (p) p.innerHTML = '<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>'; | |||
const p= | inCam.value = ''; inGal.value = ''; | ||
const r= | const r = qs('ados-scan-results'); if (r) r.innerHTML = '<div class="empty">Hier erscheinen passende Abfüllungen mit Link ins Wiki.</div>'; | ||
setStatus('Bereit.'); setProgress(null); | setStatus('Bereit.'); setProgress(null); | ||
}); | }); | ||
// | // Start | ||
btnRun.addEventListener('click', | btnRun.addEventListener('click', onRunClick); | ||
BOUND = true; | |||
log('UI gebunden.'); | |||
setStatus(' | } | ||
async function onRunClick() { | |||
try { | |||
const inCam = qs('ados-scan-file-camera'); | |||
const inGal = qs('ados-scan-file-gallery'); | |||
const btnRun = qs('ados-scan-run'); | |||
const file = (inCam.files && inCam.files[0]) || (inGal.files && inGal.files[0]); | |||
if (!file) { alert('Bitte zuerst ein Foto auswählen oder aufnehmen.'); return; } | |||
btnRun.disabled = true; | |||
setStatus('Vorbereitung …'); | |||
setProgress(0.02); | |||
// Index sicher laden | |||
await loadIndex(); | |||
// Mind. ein Eintrag mit Embedding vorhanden? | |||
if (!INDEX_EMB.some(v => v && v.length)) { | |||
setStatus('Index enthält keine Embeddings – bitte Index mit Embeddings neu erzeugen.'); | |||
setProgress(null); | setProgress(null); | ||
btnRun.disabled = false; | |||
return; | |||
btnRun.disabled=false; | |||
} | } | ||
// Embedding für Query-Bild | |||
const qVec = await embedFileImage(file); // 0.20–0.40 in ensure / embed | |||
setProgress(0.70); | |||
// Ranking | |||
setStatus('Abgleich mit Datenbank …'); | |||
const ranked = rankByCosine(qVec); | |||
setProgress(0.95); | |||
renderResults(ranked); | |||
setStatus('Fertig.'); | |||
setProgress(null); | |||
} catch (e) { | |||
err('Fehler', e); | |||
setStatus('Fehler bei Erkennung/Abgleich. Bitte erneut versuchen.'); | |||
setProgress(null); | |||
} finally { | |||
const btnRun = qs('ados-scan-run'); | |||
if (btnRun) btnRun.disabled = false; | |||
} | |||
} | } | ||
// ---------- Init ---------- | // ------------------------------------------------------------ | ||
function init(){ | // Init | ||
// ------------------------------------------------------------ | |||
function init() { | |||
if(document.readyState==='loading'){ | // Bind nach DOM fertig | ||
document.addEventListener('DOMContentLoaded', bindUI); | if (document.readyState === 'loading') { | ||
}else{ | document.addEventListener('DOMContentLoaded', bindUI, { once: true }); | ||
} else { | |||
bindUI(); | bindUI(); | ||
} | } | ||
// Fallbacks | |||
setTimeout(bindUI, 250); | |||
setTimeout(bindUI, 1000); | |||
// (Optional) Index „warm“ laden (nicht blockierend) | |||
loadIndex().catch(err); | |||
} | } | ||
// Start | |||
log('gadget file loaded'); | |||
init(); | init(); | ||
})(); | })(); | ||
Version vom 8. November 2025, 18:32 Uhr
/* global mw */
(() => {
'use strict';
// ------------------------------------------------------------
// Konfiguration
// ------------------------------------------------------------
const CFG = {
// Wo liegt die JSON mit Index (Titel, Thumb, EMBEDDING)?
indexTitle: (window.LabelScanConfig && window.LabelScanConfig.indexTitle) ||
'MediaWiki:Gadget-LabelScan-index.json',
// Top-N Treffer anzeigen:
topK: 8,
// CLIP-Model:
transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0',
modelId: 'Xenova/clip-vit-base-patch32', // robust & kompakt (quantized)
// Max-Seitenkante beim Downscaling (Speed/Qualität):
maxSide: 1024,
// Logging:
debug: true
};
function log(...args) { if (CFG.debug) console.log('[LabelScan]', ...args); }
function warn(...args){ if (CFG.debug) console.warn('[LabelScan]', ...args); }
function err(...args) { console.error('[LabelScan]', ...args); }
// ------------------------------------------------------------
// UI Helpers
// ------------------------------------------------------------
function qs(id) { return document.getElementById(id); }
function setStatus(txt) { const el = qs('ados-scan-status'); if (el) el.textContent = txt || ''; }
function setProgress(p) {
const bar = qs('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) {
const url = URL.createObjectURL(file);
const prev = qs('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');
}
}
// ------------------------------------------------------------
// Index laden (JSON: Titel, Thumb, embed(Base64 Float32))
// ------------------------------------------------------------
let INDEX = [];
let INDEX_EMB = []; // Array<Float32Array>
async function loadIndex() {
if (INDEX.length) return INDEX;
setStatus('Index laden …');
setProgress(0.03);
// Roh-URL bauen
const rawURL = mw.util.getUrl(CFG.indexTitle, { action:'raw', ctype:'application/json' });
const res = await fetch(rawURL, { cache:'reload' });
if (!res.ok) throw new Error('Index nicht ladbar: ' + res.status);
const json = await res.json();
if (!Array.isArray(json)) throw new Error('Index ist keine Array-JSON');
INDEX = json;
// Embeddings dekodieren (falls vorhanden)
INDEX_EMB = INDEX.map((it, i) => {
if (typeof it.embed === 'string' && it.embed.length) {
try { return base64ToFloat32(it.embed); }
catch(e){ warn('Embed-Decode-Fehler bei Index', i, it.title, e); return null; }
}
return null;
});
log('Index geladen:', INDEX.length, 'Einträge');
setProgress(0.06);
return INDEX;
}
// Base64 -> Float32Array
function base64ToFloat32(b64) {
const bin = atob(b64);
const len = bin.length;
const buf = new ArrayBuffer(len);
const view = new Uint8Array(buf);
for (let i=0;i<len;i++) view[i] = bin.charCodeAt(i);
return new Float32Array(buf);
}
// ------------------------------------------------------------
// CLIP: Feature-Extraction (nur Bild, ohne Tokenizer)
// ------------------------------------------------------------
let _clipModulePromise = null;
async function ensureClipExtractor() {
if (_clipModulePromise) return _clipModulePromise;
setStatus('Modell laden …');
setProgress(0.08);
_clipModulePromise = (async () => {
const ESM_URL = CFG.transformersURL;
const mod = await import(/* webpackIgnore: true */ ESM_URL);
// NUR remote laden
mod.env.localModelPath = null;
mod.env.remoteModels = true;
mod.env.allowRemoteModels = true;
mod.env.useBrowserCache = true;
// Hier liegt der Trick: Wir nutzen explizit "image-feature-extraction",
// damit kein Tokenizer geladen wird.
const pipe = await mod.pipeline(
'image-feature-extraction',
CFG.modelId,
{ quantized: true }
);
log('CLIP ready:', pipe.model?.constructor?.name || 'unknown');
return { mod, pipe };
})();
return _clipModulePromise;
}
// Datei -> HTMLImageElement -> Canvas (Downscale) -> Embedding (Float32Array, normiert)
async function embedFileImage(file) {
// Datei als HTMLImageElement laden
function loadImageFromFile(f) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(f);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
img.onerror = (e)=> { URL.revokeObjectURL(url); reject(e); };
img.src = url;
});
}
// Canvas-Downscale
function toCanvas(img, maxSide) {
const c = document.createElement('canvas');
let { width: w, height: h } = img;
const scale = Math.min(1, maxSide / Math.max(w, h));
w = Math.round(w * scale);
h = Math.round(h * scale);
c.width = w; c.height = h;
const ctx = c.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.drawImage(img, 0, 0, w, h);
return c;
}
const { pipe } = await ensureClipExtractor();
setStatus('Bild vorbereiten …');
setProgress(0.20);
const img = await loadImageFromFile(file);
const canvas = toCanvas(img, CFG.maxSide);
setStatus('Bild analysieren …');
setProgress(0.38);
const out = await pipe(canvas);
// Ausgabe → Float32Array normieren
const d = out && out.data;
let vec;
if (d instanceof Float32Array) {
vec = d;
} else if (Array.isArray(d)) {
// 2D → mitteln
vec = Array.isArray(d[0]) ? meanPool2D(d) : new Float32Array(d);
} else {
throw new Error('Embedding-Format unerwartet');
}
return normalize(vec);
}
function meanPool2D(arr2d) {
const rows = arr2d.length;
const dim = rows ? arr2d[0].length : 0;
const sum = new Float32Array(dim);
for (let r=0;r<rows;r++) {
const row = arr2d[r];
for (let i=0;i<dim;i++) sum[i] += row[i] || 0;
}
for (let i=0;i<dim;i++) sum[i] /= (rows || 1);
return sum;
}
function normalize(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; const L=Math.min(a.length,b.length);
for (let i=0;i<L;i++) s += a[i]*b[i];
return s; // bei normierten Vektoren = cos
}
// ------------------------------------------------------------
// Ranking & Rendering
// ------------------------------------------------------------
function rankByCosine(queryVec) {
const scores = [];
for (let i=0;i<INDEX.length;i++) {
const vec = INDEX_EMB[i];
if (!vec) continue; // ohne Embedding nicht vergleichbar
const score = cosine(queryVec, vec);
scores.push({ i, score });
}
// absteigend
scores.sort((a,b)=> b.score - a.score);
return scores.slice(0, CFG.topK);
}
function renderResults(ranked) {
const box = qs('ados-scan-results');
if (!box) return;
box.innerHTML = '';
if (!ranked || !ranked.length) {
box.innerHTML = '<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder näher am Frontlabel.</div>';
return;
}
ranked.forEach(({ i, score }) => {
const it = INDEX[i];
const link = mw.util.getUrl((it.title || '').replace(/ /g,'_'));
const thumb = it.thumb || '';
const row = document.createElement('div');
row.className = 'ados-hit';
row.style.display = 'grid';
row.style.gridTemplateColumns = '60px 1fr auto';
row.style.alignItems = 'center';
row.style.gap = '10px';
row.style.padding = '.35rem 0';
row.innerHTML =
(thumb ? `<div><img src="${thumb}" alt="" style="width:60px;height:auto;border-radius:6px;"></div>` : '<div></div>') +
`<div><b><a href="${link}">${escapeHtml(it.title || '')}</a></b></div>` +
`<div style="color:#666;font-variant-numeric:tabular-nums">${score.toFixed(3)}</div>`;
box.appendChild(row);
});
}
function escapeHtml(s){ return mw.html.escape(String(s||'')); }
// ------------------------------------------------------------
// Bindings
// ------------------------------------------------------------
let BOUND = false;
function bindUI() {
if (BOUND) return;
const btnCam = qs('ados-scan-btn-camera');
const btnGal = qs('ados-scan-btn-gallery');
const inCam = qs('ados-scan-file-camera');
const inGal = qs('ados-scan-file-gallery');
const btnRun = qs('ados-scan-run');
const btnReset= qs('ados-scan-reset');
const drop = qs('ados-scan-drop');
if (!btnRun || !inCam || !inGal) return;
// Buttons triggern jeweilige Inputs
if (btnCam) btnCam.addEventListener('click', ()=> inCam.click());
if (btnGal) btnGal.addEventListener('click', ()=> inGal.click());
// Vorschau setzen
function onPick(e) {
const f = e.target.files && e.target.files[0];
if (f) showPreview(f);
}
inCam.addEventListener('change', onPick);
inGal.addEventListener('change', onPick);
// Drag & Drop nur als Zusatz
if (drop) {
drop.addEventListener('dragover', ev => { ev.preventDefault(); drop.classList.add('is-over'); });
drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over'));
drop.addEventListener('drop', ev => {
ev.preventDefault(); drop.classList.remove('is-over');
const f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0];
if (f) {
// in "Gallery"-Input setzen, damit onRunClick es findet
const dt = new DataTransfer();
dt.items.add(f);
inGal.files = dt.files;
showPreview(f);
}
});
}
// Reset
if (btnReset) btnReset.addEventListener('click', () => {
const p = qs('ados-scan-preview'); if (p) p.innerHTML = '<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>';
inCam.value = ''; inGal.value = '';
const r = qs('ados-scan-results'); if (r) r.innerHTML = '<div class="empty">Hier erscheinen passende Abfüllungen mit Link ins Wiki.</div>';
setStatus('Bereit.'); setProgress(null);
});
// Start
btnRun.addEventListener('click', onRunClick);
BOUND = true;
log('UI gebunden.');
}
async function onRunClick() {
try {
const inCam = qs('ados-scan-file-camera');
const inGal = qs('ados-scan-file-gallery');
const btnRun = qs('ados-scan-run');
const file = (inCam.files && inCam.files[0]) || (inGal.files && inGal.files[0]);
if (!file) { alert('Bitte zuerst ein Foto auswählen oder aufnehmen.'); return; }
btnRun.disabled = true;
setStatus('Vorbereitung …');
setProgress(0.02);
// Index sicher laden
await loadIndex();
// Mind. ein Eintrag mit Embedding vorhanden?
if (!INDEX_EMB.some(v => v && v.length)) {
setStatus('Index enthält keine Embeddings – bitte Index mit Embeddings neu erzeugen.');
setProgress(null);
btnRun.disabled = false;
return;
}
// Embedding für Query-Bild
const qVec = await embedFileImage(file); // 0.20–0.40 in ensure / embed
setProgress(0.70);
// Ranking
setStatus('Abgleich mit Datenbank …');
const ranked = rankByCosine(qVec);
setProgress(0.95);
renderResults(ranked);
setStatus('Fertig.');
setProgress(null);
} catch (e) {
err('Fehler', e);
setStatus('Fehler bei Erkennung/Abgleich. Bitte erneut versuchen.');
setProgress(null);
} finally {
const btnRun = qs('ados-scan-run');
if (btnRun) btnRun.disabled = false;
}
}
// ------------------------------------------------------------
// Init
// ------------------------------------------------------------
function init() {
// Bind nach DOM fertig
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindUI, { once: true });
} else {
bindUI();
}
// Fallbacks
setTimeout(bindUI, 250);
setTimeout(bindUI, 1000);
// (Optional) Index „warm“ laden (nicht blockierend)
loadIndex().catch(err);
}
// Start
log('gadget file loaded');
init();
})();