MediaWiki:Gadget-LabelScan.js: Unterschied zwischen den Versionen
Erscheinungsbild
Admin (Diskussion | Beiträge) Keine Bearbeitungszusammenfassung |
Admin (Diskussion | Beiträge) Keine Bearbeitungszusammenfassung Markierung: Zurückgesetzt |
||
| Zeile 1: | Zeile 1: | ||
/* Gadget: LabelScan (UI-polished, lokal, ESM via externem Shim) | |||
* - hübschere UI (Progress, Status, Drag&Drop-Highlight, Buttons mit Spinner) | |||
* - verlässliches Laden der lokalen Modelle (wie beim Indexer) | |||
* - nutzt vorhandenen Embedding-Index: MediaWiki:Gadget-LabelScan-index.json | |||
* | |||
* Voraussetzungen: | |||
* - /vendor/transformers/esm-shim.js (enthält: import * as tf from './transformers.min.js'; window.transformers = tf;) | |||
* - /vendor/transformers/transformers.min.js (deine lokale Datei) | |||
* - /vendor/transformers/ort-wasm*.wasm (WASM-Runtime-Dateien) | |||
* - /models/Xenova/clip-vit-base-patch32/... (dein lokales Modell) | |||
*/ | |||
/* global mw */ | /* global mw */ | ||
(() | (function () { | ||
'use strict'; | 'use strict'; | ||
// ---------- Konfiguration ---------- | |||
var INDEX_TITLE = 'MediaWiki:Gadget-LabelScan-index.json'; | |||
var TOP_K = 8; | |||
// Lokale Pfade (wie beim Indexer) | |||
var TRANSFORMERS_SHIM = '/vendor/transformers/esm-shim.js'; | |||
var WASM_DIR = '/vendor/transformers/'; | |||
var LOCAL_MODEL_PATH = '/models'; | |||
var MODEL_ID = 'Xenova/clip-vit-base-patch32'; | |||
// UI-Element-IDs (wie in deiner LabelScan-Seite) | |||
var IDS = { | |||
btnCam: 'ados-scan-btn-camera', | |||
btnGal: 'ados-scan-btn-gallery', | |||
inCam: 'ados-scan-file-camera', | |||
inGal: 'ados-scan-file-gallery', | |||
btnRun: 'ados-scan-run', | |||
btnReset: 'ados-scan-reset', | |||
drop: 'ados-scan-drop', | |||
status: 'ados-scan-status', | |||
progress: 'ados-scan-progress', | |||
preview: 'ados-scan-preview', | |||
results: 'ados-scan-results', | |||
wrap: 'ados-labelscan' | |||
}; | }; | ||
// --------- Helpers ---------- | // --------- Helpers (UI/Log) ---------- | ||
function $(id){ return document.getElementById(id); } | |||
function text(el, t){ if (el) el.textContent = t == null ? '' : String(t); } | |||
function log(){ try{ console.log.apply(console, ['[LabelScan]'].concat([].slice.call(arguments))); }catch(_){} } | |||
function warn(){ try{ console.warn.apply(console, ['[LabelScan]'].concat([].slice.call(arguments))); }catch(_){} } | |||
function err(){ try{ console.error.apply(console, ['[LabelScan]'].concat([].slice.call(arguments))); }catch(_){} } | |||
function | function setStatus(t){ var el = $(IDS.status); text(el, t||''); } | ||
function setProgress(p){ | |||
var bar = $(IDS.progress); if (!bar) return; | |||
if( | if (p == null){ bar.hidden = true; bar.value = 0; return; } | ||
bar.hidden = false; | |||
bar.value = Math.max(0, Math.min(1, p)); | |||
} | |||
function setBtnLoading(isLoading){ | |||
var btn = $(IDS.btnRun); | |||
if (!btn) return; | |||
btn.disabled = !!isLoading; | |||
if (isLoading){ | |||
btn.dataset.orig = btn.textContent; | |||
btn.textContent = 'Erkennen …'; | |||
btn.classList.add('is-loading'); | |||
} else { | |||
if (btn.dataset.orig) btn.textContent = btn.dataset.orig; | |||
btn.classList.remove('is-loading'); | |||
} | } | ||
} | |||
function injectStyles(){ | |||
if (document.getElementById('ados-scan-style')) return; | |||
var css = '' | |||
+ '.ados-scan .btn{display:inline-flex;align-items:center;gap:.5rem;padding:.6rem 1rem;border:1px solid #d0d7de;border-radius:10px;background:#fff;cursor:pointer}' | |||
+ '.ados-scan .btn:hover{background:#f6f8fa}' | |||
+ '.ados-scan .btn.is-loading{opacity:.7;cursor:progress}' | |||
+ '.ados-scan__drop{border:2px dashed #c8d1dc;border-radius:12px;padding:1rem;text-align:center;background:#fafbfc}' | |||
+ '.ados-scan__drop.is-over{background:#eef6ff;border-color:#6aa3ff}' | |||
+ '.ados-hit{border-bottom:1px solid #eee}' | |||
+ '.ados-hit:last-child{border-bottom:none}' | |||
+ '#'+IDS.status+'{min-height:1.25rem}' | |||
+ '#'+IDS.results+' .empty{color:#666}' | |||
+ '#'+IDS.preview+' img{max-width:100%;height:auto;border-radius:8px}' | |||
+ ''; | |||
var s = document.createElement('style'); | |||
s.id = 'ados-scan-style'; | |||
s.textContent = css; | |||
document.head.appendChild(s); | |||
} | } | ||
// ---------- Index laden ---------- | |||
var INDEX = []; | |||
var INDEX_EMB = []; // Float32Array[] (oder null) | |||
function base64ToFloat32(b64){ | function base64ToFloat32(b64){ | ||
// robust, auch bei großen Arrays | |||
var bin = atob(b64); | |||
for( | var len = bin.length; | ||
var buf = new ArrayBuffer(len); | |||
var view = new Uint8Array(buf); | |||
for (var i=0;i<len;i++) view[i] = bin.charCodeAt(i); | |||
return new Float32Array(buf); | return new Float32Array(buf); | ||
} | } | ||
function loadIndex(){ | |||
if (INDEX.length) return Promise.resolve(INDEX); | |||
setStatus('Index laden …'); setProgress(0.03); | |||
if(INDEX.length) return INDEX; | var rawURL = mw.util.getUrl(INDEX_TITLE, { action:'raw', ctype:'application/json' }); | ||
return fetch(rawURL, { cache:'reload' }).then(function(res){ | |||
if (!res.ok) throw new Error('Index nicht ladbar: '+res.status); | |||
return res.json(); | |||
}).then(function(json){ | |||
if (!Array.isArray(json)) throw new Error('Index ist keine Array-JSON'); | |||
if(!Array.isArray(json)) throw new Error('Index ist keine Array-JSON'); | INDEX = json; | ||
INDEX_EMB = INDEX.map(function(it, i){ | |||
try { | |||
return (it && typeof it.embed === 'string' && it.embed.length) ? base64ToFloat32(it.embed) : null; | |||
} catch(e){ | |||
warn('Embed-Decode-Fehler bei Index', i, it && it.title, e); | |||
return null; | |||
} | |||
}); | |||
log('Index geladen:', INDEX.length, 'Einträge'); | |||
log('Embeddings vorhanden:', INDEX_EMB.filter(Boolean).length, '/', INDEX.length); | |||
setProgress(0.06); | |||
return INDEX; | |||
}); | |||
} | } | ||
// --------- Transformers (lokal) ---------- | // ---------- Transformers (lokal wie Indexer) ---------- | ||
var _libPromise = null; | |||
function ensureLib(){ | |||
if( | if (_libPromise) return _libPromise; | ||
_libPromise = new Promise(function(resolve, reject){ | |||
var s = document.createElement('script'); | |||
s.type = 'module'; | |||
s.src = TRANSFORMERS_SHIM; // lädt transformers.min.js als ESM und setzt window.transformers | |||
s.onload = function(){ resolve(); }; | |||
s.onerror = function(){ reject(new Error('Module load failed: '+TRANSFORMERS_SHIM)); }; | |||
document.head.appendChild(s); | |||
}).then(function(){ | |||
var t0 = Date.now(); | |||
return new Promise(function(resolve, reject){ | |||
(function spin(){ | |||
if (window.transformers && typeof window.transformers === 'object'){ | |||
// Env genau wie beim Indexer konfigurieren: | |||
var env = window.transformers.env; | |||
env.allowLocalModels = true; | |||
env.allowRemoteModels = false; | |||
env.localModelPath = LOCAL_MODEL_PATH; | |||
env.backends = env.backends || {}; | |||
env.backends.onnx = env.backends.onnx || {}; | |||
env.backends.onnx.preferredBackend = 'wasm'; // stabil | |||
env.backends.onnx.wasm = env.backends.onnx.wasm || {}; | |||
env.backends.onnx.wasm.wasmPaths = WASM_DIR; | |||
return resolve(window.transformers); | |||
} | |||
if (Date.now() - t0 > 10000) return reject(new Error('Transformers-ESM nicht verfügbar (Timeout).')); | |||
setTimeout(spin, 50); | |||
})(); | |||
}); | |||
}); | |||
return _libPromise; | |||
} | |||
return | |||
} | } | ||
var _modelPromise = null; | |||
function | function ensureModel(){ | ||
if (_modelPromise) return _modelPromise; | |||
_modelPromise = ensureLib().then(function(tf){ | |||
setStatus('Modell laden …'); setProgress(0.08); | |||
return Promise.all([ | |||
tf.AutoProcessor.from_pretrained(MODEL_ID), | |||
tf.CLIPVisionModelWithProjection.from_pretrained(MODEL_ID, { quantized: true }) | |||
]).then(function(arr){ | |||
var pack = { mod: tf, processor: arr[0], model: arr[1] }; | |||
return | try { | ||
var backend = (pack.model && pack.model.session && pack.model.session.executionProvider) || 'unknown'; | |||
log('CLIP ready (vision, local) | Backend:', backend); | |||
} catch(e){ log('CLIP ready (vision, local)'); } | |||
return pack; | |||
}); | |||
}); | |||
return _modelPromise; | |||
} | } | ||
function | // ---------- Bild → Embedding ---------- | ||
function fileToCanvasExif(file){ | |||
return new Promise(function(resolve, reject){ | |||
if ('createImageBitmap' in window){ | |||
createImageBitmap(file, { imageOrientation:'from-image' }).then(function(bmp){ | |||
if ('OffscreenCanvas' in window){ | |||
var c = new OffscreenCanvas(bmp.width, bmp.height); | |||
c.getContext('2d').drawImage(bmp, 0, 0); | |||
resolve(c); | |||
} else { | |||
var c2 = document.createElement('canvas'); | |||
c2.width = bmp.width; c2.height = bmp.height; | |||
c2.getContext('2d').drawImage(bmp, 0, 0); | |||
resolve(c2); | |||
} | } | ||
} | })["catch"](reject); | ||
} else { | |||
var url = URL.createObjectURL(file); | |||
var im = new Image(); | |||
im.onload = function(){ | |||
var c3 = document.createElement('canvas'); | |||
c3.width = im.width; c3.height = im.height; | |||
c3.getContext('2d').drawImage(im, 0, 0); | |||
URL.revokeObjectURL(url); | |||
resolve(c3); | |||
}; | |||
im.onerror = function(e){ URL.revokeObjectURL(url); reject(e); }; | |||
im.src = url; | |||
} | } | ||
} | }); | ||
} | |||
function canvasToBlobPromise(canvas){ | |||
if (canvas.convertToBlob){ | |||
return canvas.convertToBlob({ type:'image/jpeg', quality:0.95 }); | |||
} | } | ||
return new Promise(function(resolve){ canvas.toBlob(function(b){ resolve(b); }, 'image/jpeg', 0.95); }); | |||
} | |||
function normalizeVec(vec){ | |||
var i, n = 0; | |||
for (i=0;i<vec.length;i++) n += vec[i]*vec[i]; | |||
var norm = Math.sqrt(n) || 1; | |||
var out = new Float32Array(vec.length); | |||
for (i=0;i<vec.length;i++) out[i] = vec[i]/norm; | |||
return out; | return out; | ||
} | } | ||
function embedFileImage(file){ | |||
return ensureModel().then(function(pack){ | |||
setStatus('Bild vorbereiten …'); setProgress(0.20); | |||
return fileToCanvasExif(file).then(function(canvas){ | |||
return canvasToBlobPromise(canvas).then(function(blob){ | |||
setStatus('Bild analysieren …'); setProgress(0.38); | |||
return pack.mod.RawImage.fromBlob(blob).then(function(raw){ | |||
return pack.processor(raw, { return_tensors:'pt' }).then(function(inputs){ | |||
return pack.model.forward({ pixel_values: inputs.pixel_values }).then(function(out){ | |||
var vec = (out && out.image_embeds && out.image_embeds.data) || (out && out.image_embeds); | |||
if (!(vec instanceof Float32Array)) throw new Error('Embedding-Format unerwartet'); | |||
return normalizeVec(vec); | |||
}); | |||
}); | |||
}); | |||
}); | |||
}); | |||
}); | }); | ||
} | |||
// ---------- Ranking & Rendering ---------- | |||
function cosine(a,b){ | |||
var s=0, L=Math.min(a.length,b.length); | |||
for (var i=0;i<L;i++) s += a[i]*b[i]; | |||
return s; | |||
return | |||
} | } | ||
function rankByCosine(qVec){ | |||
function rankByCosine( | var scores = []; | ||
for (var i=0;i<INDEX.length;i++){ | |||
for( | var v = INDEX_EMB[i]; | ||
if (!v) continue; | |||
if(!v) continue; | scores.push({ i:i, score: cosine(qVec, v) }); | ||
} | } | ||
scores.sort(function(a,b){ return b.score - a.score; }); | |||
return | return scores.slice(0, TOP_K); | ||
} | } | ||
function escapeHtml(s){ return mw.html.escape(String(s||'')); } | |||
function | |||
function renderResults(ranked){ | function renderResults(ranked){ | ||
var box = $(IDS.results); | |||
if(!box) return; | if (!box) return; | ||
box.innerHTML=''; | box.innerHTML = ''; | ||
if(!ranked || !ranked.length){ | if (!ranked || !ranked.length){ | ||
box.innerHTML='<div class="empty">Keine klaren Treffer. Bitte | box.innerHTML = '<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder näher am Frontlabel.</div>'; | ||
return; | return; | ||
} | } | ||
for (var k=0;k<ranked.length;k++){ | |||
var it = INDEX[ranked[k].i]; | |||
var sc = ranked[k].score; | |||
var link = mw.util.getUrl((it.title || '').replace(/ /g,'_')); | |||
<div | var thumb = it.thumb || ''; | ||
var row = document.createElement('div'); | |||
row.className = 'ados-hit'; | |||
row.style.display = 'grid'; | |||
row.style.gridTemplateColumns = '64px 1fr auto'; | |||
row.style.alignItems = 'center'; | |||
row.style.gap = '12px'; | |||
row.style.padding = '.50rem 0'; | |||
row.innerHTML = | |||
(thumb ? '<div><img src="'+thumb+'" alt="" style="width:64px;height:auto;border-radius:8px;box-shadow:0 0 0 1px #eee"></div>' : '<div></div>') + | |||
'<div><b><a href="'+link+'">'+ escapeHtml(it.title || '') +'</a></b></div>' + | |||
'<div style="color:#666;font-variant-numeric:tabular-nums">'+ sc.toFixed(3) +'</div>'; | |||
box.appendChild(row); | |||
} | |||
} | |||
function showPreview(file){ | |||
var url = URL.createObjectURL(file); | |||
var prev = $(IDS.preview); | |||
if (prev){ | |||
prev.innerHTML = '<img alt="Vorschau" src="'+url+'">'; | |||
prev.setAttribute('aria-hidden','false'); | |||
if( | |||
} | } | ||
} | } | ||
// --------- | // ---------- Bindings / UX ---------- | ||
var BOUND = false; | |||
function bindUI(){ | function bindUI(){ | ||
if(BOUND) return; | if (BOUND) return; | ||
injectStyles(); | |||
var btnCam = $(IDS.btnCam); | |||
var btnGal = $(IDS.btnGal); | |||
var inCam = $(IDS.inCam); | |||
var inGal = $(IDS.inGal); | |||
var btnRun = $(IDS.btnRun); | |||
var btnReset= $(IDS.btnReset); | |||
var drop = $(IDS.drop); | |||
if (!btnRun || !inCam || !inGal){ | |||
warn('UI-Elemente fehlen – bitte die LabelScan-Seite prüfen.'); | |||
return; | |||
} | |||
if( | // Buttons → Inputs | ||
if (btnCam) btnCam.addEventListener('click', function(){ inCam.click(); }); | |||
if (btnGal) btnGal.addEventListener('click', function(){ inGal.click(); }); | |||
function onPick(e){ | |||
var f = e.target && e.target.files && e.target.files[0]; | |||
if (f) showPreview(f); | |||
inCam.addEventListener('change', | } | ||
inGal.addEventListener('change', | inCam.addEventListener('change', onPick); | ||
inGal.addEventListener('change', onPick); | |||
if(drop){ | // Drag & Drop | ||
drop.addEventListener('dragover', ev | if (drop){ | ||
drop.addEventListener('dragleave', () | drop.addEventListener('dragover', function(ev){ ev.preventDefault(); drop.classList.add('is-over'); }); | ||
drop.addEventListener('drop', ev | drop.addEventListener('dragleave', function(){ drop.classList.remove('is-over'); }); | ||
drop.addEventListener('drop', function(ev){ | |||
ev.preventDefault(); drop.classList.remove('is-over'); | ev.preventDefault(); drop.classList.remove('is-over'); | ||
var f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0]; | |||
if(f){ | if (f){ | ||
var dt = new DataTransfer(); | |||
inGal.files=dt.files; showPreview(f); | dt.items.add(f); | ||
inGal.files = dt.files; | |||
showPreview(f); | |||
} | } | ||
}); | }); | ||
} | } | ||
btnReset | // Reset | ||
if (btnReset) btnReset.addEventListener('click', function(){ | |||
var p = $(IDS.preview); if (p) p.innerHTML = '<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>'; | |||
inCam.value = ''; inGal.value = ''; | |||
var r = $(IDS.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); | ||
setBtnLoading(false); | |||
}); | }); | ||
// Start | |||
btnRun.addEventListener('click', onRunClick); | btnRun.addEventListener('click', onRunClick); | ||
BOUND=true; log('UI gebunden.'); | // Tastatur: Enter startet, wenn eines der File-Inputs fokussiert ist | ||
var wrap = $(IDS.wrap); | |||
if (wrap){ | |||
wrap.addEventListener('keydown', function(e){ | |||
if (e.key === 'Enter'){ | |||
var a = document.activeElement; | |||
if (a === inCam || a === inGal) onRunClick(); | |||
} | |||
}); | |||
} | |||
BOUND = true; | |||
log('UI gebunden.'); | |||
} | } | ||
function onRunClick(){ | |||
try{ | try { | ||
var inCam = $(IDS.inCam); | |||
var inGal = $(IDS.inGal); | |||
var file = (inCam && inCam.files && inCam.files[0]) || (inGal && inGal.files && inGal.files[0]); | |||
if (!file){ alert('Bitte zuerst ein Foto auswählen oder aufnehmen.'); return; } | |||
setBtnLoading(true); | |||
setStatus('Vorbereitung …'); setProgress(0.02); | |||
if( | loadIndex().then(function(){ | ||
if (!INDEX_EMB.some(function(v){ return v && v.length; })){ | |||
setStatus('Index enthält keine Embeddings – bitte Index mit Embeddings neu erzeugen.'); | |||
setProgress(null); setBtnLoading(false); | |||
return; | |||
} | |||
return embedFileImage(file).then(function(qVec){ | |||
setProgress(0.70); | |||
setStatus('Abgleich mit Datenbank …'); | |||
var ranked = rankByCosine(qVec); | |||
setProgress(0.95); | |||
renderResults(ranked); | |||
setStatus('Fertig.'); | |||
setProgress(null); | |||
}); | |||
})["catch"](function(e){ | |||
err('Fehler', e); | |||
setStatus('Fehler bei Erkennung/Abgleich. Details in der Konsole.'); | |||
setProgress(null); | |||
})["finally"](function(){ | |||
setBtnLoading(false); | |||
}); | |||
} catch (e) { | |||
} catch(e){ | |||
err('Fehler', e); | err('Fehler', e); | ||
setStatus('Fehler | setStatus('Unerwarteter Fehler. Details in der Konsole.'); | ||
setProgress(null); | setProgress(null); | ||
setBtnLoading(false); | |||
} | } | ||
} | } | ||
// ---------- Init ---------- | |||
function init(){ | function init(){ | ||
if(document.readyState==='loading'){ | // UI sofort binden | ||
document.addEventListener('DOMContentLoaded', bindUI, { once: true }); | if (document.readyState === 'loading'){ | ||
document.addEventListener('DOMContentLoaded', bindUI, { once:true }); | |||
} else { | } else { | ||
bindUI(); | bindUI(); | ||
} | } | ||
loadIndex( | // doppelte Fallbacks | ||
setTimeout(bindUI, 250); | |||
setTimeout(bindUI, 1000); | |||
// nicht-blockierend: Index warm laden | |||
loadIndex()["catch"](function(e){ warn('Index warm laden fehlgeschlagen:', e && e.message ? e.message : e); }); | |||
} | } | ||
log('gadget file loaded'); | log('gadget file loaded'); | ||
init(); | init(); | ||
})(); | })(); | ||
Version vom 9. November 2025, 22:36 Uhr
/* Gadget: LabelScan (UI-polished, lokal, ESM via externem Shim)
* - hübschere UI (Progress, Status, Drag&Drop-Highlight, Buttons mit Spinner)
* - verlässliches Laden der lokalen Modelle (wie beim Indexer)
* - nutzt vorhandenen Embedding-Index: MediaWiki:Gadget-LabelScan-index.json
*
* Voraussetzungen:
* - /vendor/transformers/esm-shim.js (enthält: import * as tf from './transformers.min.js'; window.transformers = tf;)
* - /vendor/transformers/transformers.min.js (deine lokale Datei)
* - /vendor/transformers/ort-wasm*.wasm (WASM-Runtime-Dateien)
* - /models/Xenova/clip-vit-base-patch32/... (dein lokales Modell)
*/
/* global mw */
(function () {
'use strict';
// ---------- Konfiguration ----------
var INDEX_TITLE = 'MediaWiki:Gadget-LabelScan-index.json';
var TOP_K = 8;
// Lokale Pfade (wie beim Indexer)
var TRANSFORMERS_SHIM = '/vendor/transformers/esm-shim.js';
var WASM_DIR = '/vendor/transformers/';
var LOCAL_MODEL_PATH = '/models';
var MODEL_ID = 'Xenova/clip-vit-base-patch32';
// UI-Element-IDs (wie in deiner LabelScan-Seite)
var IDS = {
btnCam: 'ados-scan-btn-camera',
btnGal: 'ados-scan-btn-gallery',
inCam: 'ados-scan-file-camera',
inGal: 'ados-scan-file-gallery',
btnRun: 'ados-scan-run',
btnReset: 'ados-scan-reset',
drop: 'ados-scan-drop',
status: 'ados-scan-status',
progress: 'ados-scan-progress',
preview: 'ados-scan-preview',
results: 'ados-scan-results',
wrap: 'ados-labelscan'
};
// --------- Helpers (UI/Log) ----------
function $(id){ return document.getElementById(id); }
function text(el, t){ if (el) el.textContent = t == null ? '' : String(t); }
function log(){ try{ console.log.apply(console, ['[LabelScan]'].concat([].slice.call(arguments))); }catch(_){} }
function warn(){ try{ console.warn.apply(console, ['[LabelScan]'].concat([].slice.call(arguments))); }catch(_){} }
function err(){ try{ console.error.apply(console, ['[LabelScan]'].concat([].slice.call(arguments))); }catch(_){} }
function setStatus(t){ var el = $(IDS.status); text(el, t||''); }
function setProgress(p){
var bar = $(IDS.progress); if (!bar) return;
if (p == null){ bar.hidden = true; bar.value = 0; return; }
bar.hidden = false;
bar.value = Math.max(0, Math.min(1, p));
}
function setBtnLoading(isLoading){
var btn = $(IDS.btnRun);
if (!btn) return;
btn.disabled = !!isLoading;
if (isLoading){
btn.dataset.orig = btn.textContent;
btn.textContent = 'Erkennen …';
btn.classList.add('is-loading');
} else {
if (btn.dataset.orig) btn.textContent = btn.dataset.orig;
btn.classList.remove('is-loading');
}
}
function injectStyles(){
if (document.getElementById('ados-scan-style')) return;
var css = ''
+ '.ados-scan .btn{display:inline-flex;align-items:center;gap:.5rem;padding:.6rem 1rem;border:1px solid #d0d7de;border-radius:10px;background:#fff;cursor:pointer}'
+ '.ados-scan .btn:hover{background:#f6f8fa}'
+ '.ados-scan .btn.is-loading{opacity:.7;cursor:progress}'
+ '.ados-scan__drop{border:2px dashed #c8d1dc;border-radius:12px;padding:1rem;text-align:center;background:#fafbfc}'
+ '.ados-scan__drop.is-over{background:#eef6ff;border-color:#6aa3ff}'
+ '.ados-hit{border-bottom:1px solid #eee}'
+ '.ados-hit:last-child{border-bottom:none}'
+ '#'+IDS.status+'{min-height:1.25rem}'
+ '#'+IDS.results+' .empty{color:#666}'
+ '#'+IDS.preview+' img{max-width:100%;height:auto;border-radius:8px}'
+ '';
var s = document.createElement('style');
s.id = 'ados-scan-style';
s.textContent = css;
document.head.appendChild(s);
}
// ---------- Index laden ----------
var INDEX = [];
var INDEX_EMB = []; // Float32Array[] (oder null)
function base64ToFloat32(b64){
// robust, auch bei großen Arrays
var bin = atob(b64);
var len = bin.length;
var buf = new ArrayBuffer(len);
var view = new Uint8Array(buf);
for (var i=0;i<len;i++) view[i] = bin.charCodeAt(i);
return new Float32Array(buf);
}
function loadIndex(){
if (INDEX.length) return Promise.resolve(INDEX);
setStatus('Index laden …'); setProgress(0.03);
var rawURL = mw.util.getUrl(INDEX_TITLE, { action:'raw', ctype:'application/json' });
return fetch(rawURL, { cache:'reload' }).then(function(res){
if (!res.ok) throw new Error('Index nicht ladbar: '+res.status);
return res.json();
}).then(function(json){
if (!Array.isArray(json)) throw new Error('Index ist keine Array-JSON');
INDEX = json;
INDEX_EMB = INDEX.map(function(it, i){
try {
return (it && typeof it.embed === 'string' && it.embed.length) ? base64ToFloat32(it.embed) : null;
} catch(e){
warn('Embed-Decode-Fehler bei Index', i, it && it.title, e);
return null;
}
});
log('Index geladen:', INDEX.length, 'Einträge');
log('Embeddings vorhanden:', INDEX_EMB.filter(Boolean).length, '/', INDEX.length);
setProgress(0.06);
return INDEX;
});
}
// ---------- Transformers (lokal wie Indexer) ----------
var _libPromise = null;
function ensureLib(){
if (_libPromise) return _libPromise;
_libPromise = new Promise(function(resolve, reject){
var s = document.createElement('script');
s.type = 'module';
s.src = TRANSFORMERS_SHIM; // lädt transformers.min.js als ESM und setzt window.transformers
s.onload = function(){ resolve(); };
s.onerror = function(){ reject(new Error('Module load failed: '+TRANSFORMERS_SHIM)); };
document.head.appendChild(s);
}).then(function(){
var t0 = Date.now();
return new Promise(function(resolve, reject){
(function spin(){
if (window.transformers && typeof window.transformers === 'object'){
// Env genau wie beim Indexer konfigurieren:
var env = window.transformers.env;
env.allowLocalModels = true;
env.allowRemoteModels = false;
env.localModelPath = LOCAL_MODEL_PATH;
env.backends = env.backends || {};
env.backends.onnx = env.backends.onnx || {};
env.backends.onnx.preferredBackend = 'wasm'; // stabil
env.backends.onnx.wasm = env.backends.onnx.wasm || {};
env.backends.onnx.wasm.wasmPaths = WASM_DIR;
return resolve(window.transformers);
}
if (Date.now() - t0 > 10000) return reject(new Error('Transformers-ESM nicht verfügbar (Timeout).'));
setTimeout(spin, 50);
})();
});
});
return _libPromise;
}
var _modelPromise = null;
function ensureModel(){
if (_modelPromise) return _modelPromise;
_modelPromise = ensureLib().then(function(tf){
setStatus('Modell laden …'); setProgress(0.08);
return Promise.all([
tf.AutoProcessor.from_pretrained(MODEL_ID),
tf.CLIPVisionModelWithProjection.from_pretrained(MODEL_ID, { quantized: true })
]).then(function(arr){
var pack = { mod: tf, processor: arr[0], model: arr[1] };
try {
var backend = (pack.model && pack.model.session && pack.model.session.executionProvider) || 'unknown';
log('CLIP ready (vision, local) | Backend:', backend);
} catch(e){ log('CLIP ready (vision, local)'); }
return pack;
});
});
return _modelPromise;
}
// ---------- Bild → Embedding ----------
function fileToCanvasExif(file){
return new Promise(function(resolve, reject){
if ('createImageBitmap' in window){
createImageBitmap(file, { imageOrientation:'from-image' }).then(function(bmp){
if ('OffscreenCanvas' in window){
var c = new OffscreenCanvas(bmp.width, bmp.height);
c.getContext('2d').drawImage(bmp, 0, 0);
resolve(c);
} else {
var c2 = document.createElement('canvas');
c2.width = bmp.width; c2.height = bmp.height;
c2.getContext('2d').drawImage(bmp, 0, 0);
resolve(c2);
}
})["catch"](reject);
} else {
var url = URL.createObjectURL(file);
var im = new Image();
im.onload = function(){
var c3 = document.createElement('canvas');
c3.width = im.width; c3.height = im.height;
c3.getContext('2d').drawImage(im, 0, 0);
URL.revokeObjectURL(url);
resolve(c3);
};
im.onerror = function(e){ URL.revokeObjectURL(url); reject(e); };
im.src = url;
}
});
}
function canvasToBlobPromise(canvas){
if (canvas.convertToBlob){
return canvas.convertToBlob({ type:'image/jpeg', quality:0.95 });
}
return new Promise(function(resolve){ canvas.toBlob(function(b){ resolve(b); }, 'image/jpeg', 0.95); });
}
function normalizeVec(vec){
var i, n = 0;
for (i=0;i<vec.length;i++) n += vec[i]*vec[i];
var norm = Math.sqrt(n) || 1;
var out = new Float32Array(vec.length);
for (i=0;i<vec.length;i++) out[i] = vec[i]/norm;
return out;
}
function embedFileImage(file){
return ensureModel().then(function(pack){
setStatus('Bild vorbereiten …'); setProgress(0.20);
return fileToCanvasExif(file).then(function(canvas){
return canvasToBlobPromise(canvas).then(function(blob){
setStatus('Bild analysieren …'); setProgress(0.38);
return pack.mod.RawImage.fromBlob(blob).then(function(raw){
return pack.processor(raw, { return_tensors:'pt' }).then(function(inputs){
return pack.model.forward({ pixel_values: inputs.pixel_values }).then(function(out){
var vec = (out && out.image_embeds && out.image_embeds.data) || (out && out.image_embeds);
if (!(vec instanceof Float32Array)) throw new Error('Embedding-Format unerwartet');
return normalizeVec(vec);
});
});
});
});
});
});
}
// ---------- Ranking & Rendering ----------
function cosine(a,b){
var s=0, L=Math.min(a.length,b.length);
for (var i=0;i<L;i++) s += a[i]*b[i];
return s;
}
function rankByCosine(qVec){
var scores = [];
for (var i=0;i<INDEX.length;i++){
var v = INDEX_EMB[i];
if (!v) continue;
scores.push({ i:i, score: cosine(qVec, v) });
}
scores.sort(function(a,b){ return b.score - a.score; });
return scores.slice(0, TOP_K);
}
function escapeHtml(s){ return mw.html.escape(String(s||'')); }
function renderResults(ranked){
var box = $(IDS.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;
}
for (var k=0;k<ranked.length;k++){
var it = INDEX[ranked[k].i];
var sc = ranked[k].score;
var link = mw.util.getUrl((it.title || '').replace(/ /g,'_'));
var thumb = it.thumb || '';
var row = document.createElement('div');
row.className = 'ados-hit';
row.style.display = 'grid';
row.style.gridTemplateColumns = '64px 1fr auto';
row.style.alignItems = 'center';
row.style.gap = '12px';
row.style.padding = '.50rem 0';
row.innerHTML =
(thumb ? '<div><img src="'+thumb+'" alt="" style="width:64px;height:auto;border-radius:8px;box-shadow:0 0 0 1px #eee"></div>' : '<div></div>') +
'<div><b><a href="'+link+'">'+ escapeHtml(it.title || '') +'</a></b></div>' +
'<div style="color:#666;font-variant-numeric:tabular-nums">'+ sc.toFixed(3) +'</div>';
box.appendChild(row);
}
}
function showPreview(file){
var url = URL.createObjectURL(file);
var prev = $(IDS.preview);
if (prev){
prev.innerHTML = '<img alt="Vorschau" src="'+url+'">';
prev.setAttribute('aria-hidden','false');
}
}
// ---------- Bindings / UX ----------
var BOUND = false;
function bindUI(){
if (BOUND) return;
injectStyles();
var btnCam = $(IDS.btnCam);
var btnGal = $(IDS.btnGal);
var inCam = $(IDS.inCam);
var inGal = $(IDS.inGal);
var btnRun = $(IDS.btnRun);
var btnReset= $(IDS.btnReset);
var drop = $(IDS.drop);
if (!btnRun || !inCam || !inGal){
warn('UI-Elemente fehlen – bitte die LabelScan-Seite prüfen.');
return;
}
// Buttons → Inputs
if (btnCam) btnCam.addEventListener('click', function(){ inCam.click(); });
if (btnGal) btnGal.addEventListener('click', function(){ inGal.click(); });
function onPick(e){
var f = e.target && e.target.files && e.target.files[0];
if (f) showPreview(f);
}
inCam.addEventListener('change', onPick);
inGal.addEventListener('change', onPick);
// Drag & Drop
if (drop){
drop.addEventListener('dragover', function(ev){ ev.preventDefault(); drop.classList.add('is-over'); });
drop.addEventListener('dragleave', function(){ drop.classList.remove('is-over'); });
drop.addEventListener('drop', function(ev){
ev.preventDefault(); drop.classList.remove('is-over');
var f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0];
if (f){
var dt = new DataTransfer();
dt.items.add(f);
inGal.files = dt.files;
showPreview(f);
}
});
}
// Reset
if (btnReset) btnReset.addEventListener('click', function(){
var p = $(IDS.preview); if (p) p.innerHTML = '<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>';
inCam.value = ''; inGal.value = '';
var r = $(IDS.results); if (r) r.innerHTML = '<div class="empty">Hier erscheinen passende Abfüllungen mit Link ins Wiki.</div>';
setStatus('Bereit.'); setProgress(null);
setBtnLoading(false);
});
// Start
btnRun.addEventListener('click', onRunClick);
// Tastatur: Enter startet, wenn eines der File-Inputs fokussiert ist
var wrap = $(IDS.wrap);
if (wrap){
wrap.addEventListener('keydown', function(e){
if (e.key === 'Enter'){
var a = document.activeElement;
if (a === inCam || a === inGal) onRunClick();
}
});
}
BOUND = true;
log('UI gebunden.');
}
function onRunClick(){
try {
var inCam = $(IDS.inCam);
var inGal = $(IDS.inGal);
var file = (inCam && inCam.files && inCam.files[0]) || (inGal && inGal.files && inGal.files[0]);
if (!file){ alert('Bitte zuerst ein Foto auswählen oder aufnehmen.'); return; }
setBtnLoading(true);
setStatus('Vorbereitung …'); setProgress(0.02);
loadIndex().then(function(){
if (!INDEX_EMB.some(function(v){ return v && v.length; })){
setStatus('Index enthält keine Embeddings – bitte Index mit Embeddings neu erzeugen.');
setProgress(null); setBtnLoading(false);
return;
}
return embedFileImage(file).then(function(qVec){
setProgress(0.70);
setStatus('Abgleich mit Datenbank …');
var ranked = rankByCosine(qVec);
setProgress(0.95);
renderResults(ranked);
setStatus('Fertig.');
setProgress(null);
});
})["catch"](function(e){
err('Fehler', e);
setStatus('Fehler bei Erkennung/Abgleich. Details in der Konsole.');
setProgress(null);
})["finally"](function(){
setBtnLoading(false);
});
} catch (e) {
err('Fehler', e);
setStatus('Unerwarteter Fehler. Details in der Konsole.');
setProgress(null);
setBtnLoading(false);
}
}
// ---------- Init ----------
function init(){
// UI sofort binden
if (document.readyState === 'loading'){
document.addEventListener('DOMContentLoaded', bindUI, { once:true });
} else {
bindUI();
}
// doppelte Fallbacks
setTimeout(bindUI, 250);
setTimeout(bindUI, 1000);
// nicht-blockierend: Index warm laden
loadIndex()["catch"](function(e){ warn('Index warm laden fehlgeschlagen:', e && e.message ? e.message : e); });
}
log('gadget file loaded');
init();
})();