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 */ | /* global mw */ | ||
(function () { | (function () { | ||
'use strict'; | 'use strict'; | ||
// === | // =========== UI Helpers =========== | ||
function $(id){ return document.getElementById(id); } | |||
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=p;} } | |||
function showPreview(file){ | |||
const prev=$('ados-scan-preview'); if(!prev) return; | |||
const url=URL.createObjectURL(file); | |||
prev.innerHTML = `<img src="${url}" alt="Vorschau" style="max-width:100%; border-radius:8px; border:1px solid #ccc;">`; | |||
setStatus('Bild bereit.'); | |||
} | |||
function esc(s){ return mw.html.escape(String(s||'')); } | |||
// =========== Datei-Auswahl Zustand =========== | |||
let PICKED_FILE = null; | |||
function getSelectedFile() { | |||
const cam=$('ados-scan-file-camera'), gal=$('ados-scan-file-gallery'); | |||
return PICKED_FILE || (cam&&cam.files&&cam.files[0]) || (gal&&gal.files&&gal.files[0]) || null; | |||
} | |||
// =========== aHash (64-bit, 16 Hex-Zeichen) =========== | |||
async function fileToImage (file) { | async function fileToImage (file) { | ||
return new Promise((resolve, reject) => { | return new Promise((resolve, reject) => { | ||
| Zeile 42: | Zeile 31: | ||
}); | }); | ||
} | } | ||
function computeAHash(img) { | |||
function | // auf 8x8 skalieren, grau, Durchschnitt -> 64 bits | ||
const c = document.createElement('canvas'); | const S=8; | ||
const c=document.createElement('canvas'); c.width=c.height=S; | |||
const ctx = c.getContext('2d'); | const ctx=c.getContext('2d'); | ||
ctx.drawImage(img, 0, 0, | ctx.drawImage(img, 0, 0, S, S); | ||
const data = ctx.getImageData(0,0,S,S).data; | |||
const gray=new Array(S*S); | |||
for(let i=0, j=0;i<data.length;i+=4, j++){ | |||
gray[j]= 0.299*data[i]+0.587*data[i+1]+0.114*data[i+2]; | |||
const | } | ||
const avg = gray.reduce((a,b)=>a+b,0)/gray.length; | |||
let bits = ''; | |||
for (let i = 0; i < | for(let k=0;k<gray.length;k++){ | ||
gray | bits += (gray[k] > avg) ? '1' : '0'; | ||
} | } | ||
// 64 bits -> 16 Hex | |||
let hex=''; | |||
let | for(let i=0;i<64;i+=4){ | ||
for (let i = 0; i < | hex += parseInt(bits.slice(i,i+4),2).toString(16); | ||
} | } | ||
return | return hex; | ||
} | } | ||
function hammingHex(h1, h2){ | |||
function | const n = Math.min(h1.length, h2.length); | ||
let d=0; | |||
const | for(let i=0;i<n;i++){ | ||
for (let i = 0; i < | const x = parseInt(h1[i],16) ^ parseInt(h2[i],16); | ||
const x = parseInt(h1[i], 16) ^ parseInt(h2[i], 16); | d += x.toString(2).replace(/0/g,'').length; | ||
d += x.toString(2).replace(/0/g, '').length; | |||
} | } | ||
return d; | return d + (h1.length>n? (h1.length-n)*4 : 0) + (h2.length>n? (h2.length-n)*4 : 0); | ||
} | } | ||
async function loadIndex () { | // =========== Index laden =========== | ||
const url = mw.util.getUrl('MediaWiki:Gadget-LabelScan-index.json', { action: 'raw', ctype: 'application/json' }); | async function loadIndex(){ | ||
const res = await fetch(url); | const url = mw.util.getUrl('MediaWiki:Gadget-LabelScan-index.json', { action:'raw', ctype:'application/json' }); | ||
const res = await fetch(url, { cache: 'no-store' }); | |||
if (!res.ok) throw new Error('Index konnte nicht geladen werden: '+res.status); | |||
return res.json(); | return res.json(); | ||
} | } | ||
// | // =========== Ergebnisse rendern =========== | ||
function | function renderResults(items){ | ||
const | const box = $('ados-scan-results'); | ||
if (!box) return; | |||
if ( | |||
box.innerHTML = ''; | box.innerHTML = ''; | ||
if (! | if (!items || !items.length){ | ||
box.innerHTML = '<div>Keine klaren Treffer | box.innerHTML = '<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder manuell suchen.</div>'; | ||
return; | return; | ||
} | } | ||
items.forEach(it=>{ | |||
box. | const href = mw.util.getUrl(it.title.replace(/ /g,'_')); | ||
<div class="ados-hit"> | box.insertAdjacentHTML('beforeend', ` | ||
<a href="${ | <div class="ados-hit" style="display:inline-block; width:170px; margin:8px; text-align:center;"> | ||
<img src="${ | <a href="${href}"> | ||
< | <img src="${it.thumb}" alt="${esc(it.title)}" style="width:150px; border-radius:8px; border:1px solid #ccc; background:#fff;"> | ||
<div style="margin-top:6px; font-weight:600;">${esc(it.title)}</div> | |||
</a> | </a> | ||
< | <div style="font-size:.85em; color:#666;">Distanz: ${it.dist}</div> | ||
</div>`; | </div> | ||
`); | |||
}); | }); | ||
} | } | ||
// | // =========== Verkettete Erkennung =========== | ||
async function | async function runMatchWorkflow(){ | ||
const | const file = getSelectedFile(); | ||
if (!file) { alert('Bitte Foto aufnehmen oder Datei wählen.'); return; } | |||
try { | |||
setStatus('Bereite Bild vor …'); setProgress(null); // (keine echte Progressanzeige nötig) | |||
const img = await fileToImage(file); | |||
setStatus('Berechne Fingerabdruck …'); | |||
const ahash = computeAHash(img); | |||
setStatus('Lade Index …'); | |||
const index = await loadIndex(); | |||
setStatus('Vergleiche …'); | |||
const scored = index.map(it => { | |||
const dist = hammingHex(ahash, String(it.phash||'')); | |||
return { ...it, dist }; | |||
}).sort((a,b)=>a.dist - b.dist); | |||
// kleine Heuristik: nur „plausible“ Treffer anzeigen | |||
const BEST = scored.slice(0, 8); | |||
const THRESH = 18; // ~ gut unterscheidbar bei 64-bit aHash | |||
const filtered = BEST.filter(x => x.dist <= THRESH); | |||
renderResults(filtered.length ? filtered : BEST); | |||
setStatus('Fertig.'); | |||
} catch (e) { | |||
} | console.error('[LabelScan]', e); | ||
setStatus('Fehler bei Erkennung.'); | |||
} finally { | |||
setProgress(null); | |||
} | |||
} | } | ||
// =========== Dropzone, Buttons, Aktionen =========== | |||
function wireUI(){ | |||
// Buttons → Inputs | |||
const btnCam=$('ados-scan-btn-camera'), inCam=$('ados-scan-file-camera'); | |||
const btnGal=$('ados-scan-btn-gallery'), inGal=$('ados-scan-file-gallery'); | |||
if (btnCam && inCam){ | |||
btnCam.addEventListener('click', e=>{ e.preventDefault(); e.stopPropagation(); inCam.click(); }); | |||
inCam.addEventListener('change', ()=>{ if(inCam.files && inCam.files[0]) { PICKED_FILE=inCam.files[0]; showPreview(PICKED_FILE); }}); | |||
} | |||
if (btnGal && inGal){ | |||
btnGal.addEventListener('click', e=>{ e.preventDefault(); e.stopPropagation(); inGal.click(); }); | |||
inGal.addEventListener('change', ()=>{ if(inGal.files && inGal.files[0]) { PICKED_FILE=inGal.files[0]; showPreview(PICKED_FILE); }}); | |||
} | |||
// Dropzone | |||
const drop = $('ados-scan-drop'); | |||
if (drop){ | |||
const stop = ev => { ev.preventDefault(); ev.stopPropagation(); }; | |||
['dragenter','dragover','dragleave','drop'].forEach(evt => drop.addEventListener(evt, stop)); | |||
drop.addEventListener('drop', ev=>{ | |||
const f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0]; | |||
if (f){ PICKED_FILE=f; showPreview(f); } | |||
}); | |||
} | |||
} | |||
// | // Run | ||
const run=$('ados-scan-run'); | |||
if (run) run.addEventListener('click', e=>{ e.preventDefault(); runMatchWorkflow(); }); | |||
// Reset | |||
const reset=$('ados-scan-reset'); | |||
if (reset) reset.addEventListener('click', ()=>{ | |||
PICKED_FILE=null; | |||
if ($('ados-scan-file-camera')) $('ados-scan-file-camera').value=''; | |||
if ($('ados-scan-file-gallery')) $('ados-scan-file-gallery').value=''; | |||
if ( | if ($('ados-scan-preview')) $('ados-scan-preview').innerHTML='<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>'; | ||
if ($('ados-scan-results')) $('ados-scan-results').innerHTML='<div class="empty">Hier erscheinen passende Abfüllungen mit Link ins Wiki.</div>'; | |||
setStatus('Bereit.'); | |||
setProgress(null); | |||
}); | }); | ||
} | } | ||
// =========== Start =========== | |||
if (document.readyState === 'loading') { | |||
document.addEventListener('DOMContentLoaded', wireUI); | |||
} else { | |||
wireUI(); | |||
} | } | ||
})(); | })(); | ||