|
|
| Zeile 1: |
Zeile 1: |
| /* LabelScan – Bildähnlichkeit mit CLIP (UMD Loader, robust) | | /* LabelScan Gadget (Syntax-Test) */ |
| Lädt @xenova/transformers (UMD) per <script>, baut einen lokalen Bildindex
| |
| aus Abfüllungs-Thumbnails und sucht die ähnlichsten Seiten.
| |
| */
| |
| /* global mw */
| |
| (() => {
| |
| 'use strict';
| |
|
| |
|
| // ======== KONFIG ========
| | (function () { |
| const CATEGORIES = [
| | 'use strict'; |
| 'Alle A Dream of Scotland Abfüllungen',
| |
| 'Alle A Dream of Ireland Abfüllungen',
| |
| 'Alle A Dream of... – Der Rest der Welt Abfüllungen',
| |
| 'Cigar Malt Übersicht',
| |
| 'Rumbastic Abfüllungen',
| |
| 'The Tasteful 8',
| |
| 'Còmhlan Abfüllungen',
| |
| 'Friendly Mr. Z Whiskytainment Abfüllungen',
| |
| 'Die Whisky Elfen Abfüllungen', | |
| 'The Fine Art of Whisky Abfüllungen',
| |
| 'The Forbidden Kingdom',
| |
| 'Sonderabfüllungen'
| |
| ];
| |
| const THUMB_SIZE = 512;
| |
| const TOP_K = 8;
| |
| const IDB = { name: 'ados-labelscan', store: 'index', version: 2 }; // <- Version erhöht
| |
|
| |
|
| const UMD_URL = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@3.0.0/dist/transformers.umd.min.js';
| | function log(msg) { |
| | | console.log('[LabelScan TEST]', msg); |
| // ======== UI Helpers ========
| |
| const $ = (s) => document.querySelector(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){ const url=URL.createObjectURL(file); const box=$('#ados-scan-preview'); if(box) box.innerHTML=`<img alt="Vorschau" src="${url}" style="max-width:260px;border-radius:8px">`; }
| |
| function renderResults(items){
| |
| const box = $('#ados-scan-results'); if(!box) return; box.innerHTML='';
| |
| if(!items || !items.length){ box.innerHTML='<div class="ados-hit">Keine Treffer gefunden.</div>'; return; }
| |
| items.forEach(it=>{
| |
| const url = mw.util.getUrl(it.title.replace(/ /g,'_'));
| |
| const div = document.createElement('div');
| |
| div.className = 'ados-hit';
| |
| div.innerHTML = `
| |
| <a class="thumb" href="${url}"><img alt="" src="${it.thumb}" loading="lazy"></a>
| |
| <div class="meta"><b><a href="${url}">${mw.html.escape(it.title)}</a></b>
| |
| <div class="sub">Ähnlichkeit: ${(it.score*100).toFixed(1)}%</div></div>`;
| |
| box.appendChild(div);
| |
| });
| |
| }
| |
| | |
| // ======== IndexedDB Mini ========
| |
| function idbOpen(){
| |
| return new Promise((res,rej)=>{
| |
| const req = indexedDB.open(IDB.name, IDB.version);
| |
| req.onupgradeneeded = (e)=>{ const db=req.result; if(e.oldVersion<1) db.createObjectStore(IDB.store,{keyPath:'key'}); };
| |
| req.onsuccess=()=>res(req.result); req.onerror=()=>rej(req.error);
| |
| });
| |
| }
| |
| async function idbGet(key){ const db=await idbOpen(); return new Promise((res,rej)=>{ const tx=db.transaction(IDB.store,'readonly'); const st=tx.objectStore(IDB.store); const r=st.get(key); r.onsuccess=()=>res(r.result?r.result.val:null); r.onerror=()=>rej(r.error);});}
| |
| async function idbSet(key,val){ const db=await idbOpen(); return new Promise((res,rej)=>{ const tx=db.transaction(IDB.store,'readwrite'); const st=tx.objectStore(IDB.store); const r=st.put({key,val,ts:Date.now()}); r.onsuccess=()=>res(); r.onerror=()=>rej(r.error);});}
| |
| | |
| // ======== Transformers (UMD) laden ========
| |
| let clipReady=null;
| |
| function ensureTransformersUMD(){
| |
| if (clipReady) return clipReady;
| |
| clipReady = new Promise((resolve, reject)=>{
| |
| if (window.transformers && window.transformers.pipeline) return resolve(window.transformers);
| |
| const s=document.createElement('script');
| |
| s.src = UMD_URL;
| |
| s.async = true;
| |
| s.onload = ()=> resolve(window.transformers);
| |
| s.onerror = ()=> reject(new Error('Transformers UMD konnte nicht geladen werden (CSP/CDN geblockt?)'));
| |
| document.head.appendChild(s);
| |
| });
| |
| return clipReady;
| |
| }
| |
| | |
| // ======== Mathe ========
| |
| function cosine(a,b){ let s=0; for(let i=0;i<a.length;i++) s += a[i]*b[i]; // Vektoren sind schon normalisiert
| |
| // auf 0..1 hübschen (optional)
| |
| return Math.max(0, Math.min(1, (s+1)/2));
| |
| }
| |
| | |
| // ======== MW API ========
| |
| async function apiGet(p){ await mw.loader.using('mediawiki.api'); const api=new mw.Api(); return api.get(p); }
| |
| async function pagesFromCategory(cat){
| |
| const pages=[]; let cont; do{
| |
| const r=await apiGet({ action:'query', list:'categorymembers', cmtitle:'Category:'+cat, cmtype:'page', cmlimit:'max', ...(cont||{}) });
| |
| (r.query?.categorymembers||[]).forEach(it=>pages.push(it.title));
| |
| cont=r.continue;
| |
| }while(cont); | |
| return pages;
| |
| }
| |
| async function pageThumbs(titles){
| |
| const out=[]; const chunk=(a,n)=>a.length?[a.slice(0,n),...chunk(a.slice(n),n)]:[];
| |
| for(const batch of chunk(titles,40)){
| |
| const r = await apiGet({ action:'query', prop:'pageimages', piprop:'thumbnail', pithumbsize:THUMB_SIZE, titles:batch.join('|'), formatversion:2 });
| |
| (r.query?.pages||[]).forEach(p=>{ const th=p.thumbnail?.source; if(th) out.push({title:p.title, thumb:th}); });
| |
| }
| |
| return out;
| |
| }
| |
| async function buildGallery(){
| |
| const set=new Set();
| |
| for(const c of CATEGORIES){ const list=await pagesFromCategory(c); list.forEach(t=>set.add(t)); }
| |
| const titles=[...set];
| |
| return pageThumbs(titles);
| |
| }
| |
| | |
| // ======== Index bauen/laden ========
| |
| async function ensureIndex(report){
| |
| let idx = await idbGet('index-v2');
| |
| if (idx?.items?.length){ report?.(1,1,'Index aus Cache'); return idx; }
| |
| | |
| report?.(0,1,'Lade Wiki-Bilder …');
| |
| const gallery = await buildGallery();
| |
| if (!gallery.length) return { items: [] };
| |
| | |
| setStatus('Lade CLIP-Modell … (einmalig)');
| |
| const tf = await ensureTransformersUMD();
| |
| // Optional: Pfade für WASM setzen (nur Info-Log)
| |
| try {
| |
| tf.env.allowRemoteModels = true; // default, aber explizit
| |
| } catch(e){ /* ignore */ }
| |
| | |
| const pipe = await tf.pipeline('image-feature-extraction', 'Xenova/clip-vit-base-patch32');
| |
| | |
| const items=[];
| |
| for(let i=0;i<gallery.length;i++){
| |
| const g=gallery[i];
| |
| report?.(i, gallery.length, `Embedding ${i+1}/${gallery.length}: ${g.title}`);
| |
| try{
| |
| const emb = await pipe(g.thumb, { pooling:'mean', normalize:true }); // Float32Array
| |
| items.push({ title:g.title, thumb:g.thumb, vec:Array.from(emb.data) });
| |
| }catch(e){
| |
| console.warn('[LabelScan] Embedding-Fehler', g.title, e); | |
| }
| |
| } | | } |
| const index={ builtAt:Date.now(), items };
| |
| await idbSet('index-v2', index);
| |
| report?.(1,1,'Index gespeichert');
| |
| return index;
| |
| }
| |
|
| |
| // ======== Suche ========
| |
| async function runSearch(file){
| |
| setProgress(0); setStatus('Baue/ lade Bild-Index …');
| |
| const index = await ensureIndex((i,n,msg)=>{ setStatus(msg||'Index…'); setProgress(n? i/n : null); });
| |
| if(!index.items.length){ renderResults([]); setProgress(null); setStatus('Kein Bildmaterial gefunden.'); return; }
| |
|
| |
| setStatus('Berechne Embedding vom Foto …'); setProgress(0.1);
| |
| const tf = await ensureTransformersUMD();
| |
| const pipe = await tf.pipeline('image-feature-extraction', 'Xenova/clip-vit-base-patch32');
| |
|
| |
|
| const dataURL = await new Promise((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(r.result); r.onerror=rej; r.readAsDataURL(file); }); | | // Test UI Hooks |
| | document.addEventListener('DOMContentLoaded', function () { |
| | const btn = document.querySelector('#ados-scan-run'); |
| | if (!btn) { |
| | log('UI nicht gefunden (das ist ok, wenn du nicht auf der LabelScan Seite bist)'); |
| | return; |
| | } |
|
| |
|
| const q = await pipe(dataURL, { pooling:'mean', normalize:true });
| | btn.addEventListener('click', function (ev) { |
| const qv = q.data;
| | ev.preventDefault(); |
| | alert("✅ Gadget funktioniert! (Syntax OK)\nJetzt können wir die echte Erkennung einbauen."); |
| | }); |
|
| |
|
| setStatus('Finde ähnlichste Abfüllungen …'); setProgress(0.2);
| | log('✅ Gadget wurde erfolgreich geladen.'); |
| const scored = index.items.map(it=>({ title:it.title, thumb:it.thumb, score:cosine(qv, it.vec) }))
| |
| .sort((a,b)=>b.score-a.score)
| |
| .slice(0, TOP_K);
| |
| renderResults(scored);
| |
| setProgress(null); setStatus('Fertig.');
| |
| }
| |
| | |
| // ======== Binding ========
| |
| function bind(){
| |
| const runBtn=$('#ados-scan-run'), fileIn=$('#ados-scan-file'), bigBtn=$('#ados-scan-bigbtn');
| |
| if(!runBtn||!fileIn) return;
| |
| if(runBtn.dataset.bound==='1') return; runBtn.dataset.bound='1';
| |
| | |
| bigBtn && bigBtn.addEventListener('click', ()=>fileIn.click());
| |
| fileIn.addEventListener('change', function(){ if(this.files && this.files[0]) showPreview(this.files[0]); });
| |
| | |
| runBtn.addEventListener('click', async (ev)=>{
| |
| ev.preventDefault();
| |
| if(!(fileIn.files && fileIn.files[0])){ alert('Bitte ein Foto auswählen oder aufnehmen.'); return; }
| |
| runBtn.disabled=true; runBtn.textContent='Erkenne …';
| |
| try { await runSearch(fileIn.files[0]); }
| |
| catch(e){
| |
| console.error('[LabelScan] Fehler:', e);
| |
| setStatus( | |
| 'Fehler beim Laden/Verarbeiten. Prüfe Konsole. '+
| |
| 'Häufige Ursachen: CDN/CSP blockiert oder Netzwerk.'
| |
| );
| |
| } finally {
| |
| runBtn.disabled=false; runBtn.textContent='🔍 Erkennen & suchen';
| |
| }
| |
| }); | | }); |
| }
| |
|
| |
| if(document.readyState==='loading') document.addEventListener('DOMContentLoaded', bind); else bind();
| |
| new MutationObserver(bind).observe(document.documentElement, { childList:true, subtree:true });
| |
| })(); | | })(); |