feat: thumbnail picker custom per selezione modello/scena
- GET /folder_image/{filename}?w=N: PNG ridotto cache 1h
- Frontend: 2 thumb-picker al posto dei select (thumb + nome + caret)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 625 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 530 KiB |
|
After Width: | Height: | Size: 539 KiB |
|
After Width: | Height: | Size: 546 KiB |
|
After Width: | Height: | Size: 541 KiB |
|
After Width: | Height: | Size: 548 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 260 KiB |
@@ -292,6 +292,25 @@ def index():
|
|||||||
return HTMLResponse(html_path.read_text(encoding="utf-8"))
|
return HTMLResponse(html_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/folder_image/{filename}")
|
||||||
|
def folder_image(filename: str, w: int = 120):
|
||||||
|
"""Serve thumbnail PNG dell'immagine IMAGES_DIR (scalata a width w)."""
|
||||||
|
if "/" in filename or ".." in filename:
|
||||||
|
raise HTTPException(400, "nome non valido")
|
||||||
|
path = IMAGES_DIR / filename
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(404, "non trovato")
|
||||||
|
img = cv2.imread(str(path), cv2.IMREAD_COLOR)
|
||||||
|
if img is None:
|
||||||
|
raise HTTPException(400, "non leggibile")
|
||||||
|
h0, w0 = img.shape[:2]
|
||||||
|
if w0 > w:
|
||||||
|
sc = w / w0
|
||||||
|
img = cv2.resize(img, (w, int(h0 * sc)), interpolation=cv2.INTER_AREA)
|
||||||
|
return Response(_encode_png(img), media_type="image/png",
|
||||||
|
headers={"Cache-Control": "public, max-age=3600"})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/images")
|
@app.get("/images")
|
||||||
def list_images():
|
def list_images():
|
||||||
"""Lista file immagine nella cartella configurata in IMAGES_DIR."""
|
"""Lista file immagine nella cartella configurata in IMAGES_DIR."""
|
||||||
|
|||||||
@@ -80,18 +80,56 @@ async function fetchImagesList() {
|
|||||||
return await r.json();
|
return await r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateSelect(selectEl, files) {
|
function buildThumbPicker(pickerId, files, onSelect) {
|
||||||
selectEl.innerHTML = "";
|
const picker = document.getElementById(pickerId);
|
||||||
const opt0 = document.createElement("option");
|
const current = picker.querySelector(".picker-current");
|
||||||
opt0.value = ""; opt0.textContent = "-- seleziona --";
|
const list = picker.querySelector(".picker-list");
|
||||||
selectEl.appendChild(opt0);
|
const text = current.querySelector(".picker-text");
|
||||||
for (const f of files) {
|
// Rimuovi eventuale vecchia thumbnail
|
||||||
const o = document.createElement("option");
|
const oldImg = current.querySelector("img");
|
||||||
o.value = f; o.textContent = f;
|
if (oldImg) oldImg.remove();
|
||||||
selectEl.appendChild(o);
|
list.innerHTML = "";
|
||||||
}
|
|
||||||
|
files.forEach((f) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "picker-item";
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = `/folder_image/${encodeURIComponent(f)}?w=120`;
|
||||||
|
img.loading = "lazy";
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "name"; name.textContent = f;
|
||||||
|
item.appendChild(img); item.appendChild(name);
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
// Aggiorna la visual del "current"
|
||||||
|
let thumb = current.querySelector("img");
|
||||||
|
if (!thumb) {
|
||||||
|
thumb = document.createElement("img");
|
||||||
|
current.insertBefore(thumb, text);
|
||||||
|
}
|
||||||
|
thumb.src = `/folder_image/${encodeURIComponent(f)}?w=80`;
|
||||||
|
text.textContent = f;
|
||||||
|
picker.classList.remove("open");
|
||||||
|
onSelect(f);
|
||||||
|
});
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
current.onclick = () => {
|
||||||
|
// Chiudi altri picker aperti
|
||||||
|
document.querySelectorAll(".thumb-picker.open")
|
||||||
|
.forEach((p) => { if (p !== picker) p.classList.remove("open"); });
|
||||||
|
picker.classList.toggle("open");
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close picker on outside click
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (!e.target.closest(".thumb-picker")) {
|
||||||
|
document.querySelectorAll(".thumb-picker.open")
|
||||||
|
.forEach((p) => p.classList.remove("open"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function loadImage(src) {
|
function loadImage(src) {
|
||||||
return new Promise((res, rej) => {
|
return new Promise((res, rej) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -310,19 +348,15 @@ function setStatus(s) {
|
|||||||
window.addEventListener("DOMContentLoaded", async () => {
|
window.addEventListener("DOMContentLoaded", async () => {
|
||||||
buildAdvancedForm();
|
buildAdvancedForm();
|
||||||
setupROI();
|
setupROI();
|
||||||
// Popola dropdown immagini da IMAGES_DIR
|
// Popola picker immagini da IMAGES_DIR (con thumbnail)
|
||||||
const {files, dir} = await fetchImagesList();
|
const {files, dir} = await fetchImagesList();
|
||||||
const selM = document.getElementById("sel-model");
|
buildThumbPicker("picker-model", files, onSelectModel);
|
||||||
const selS = document.getElementById("sel-scene");
|
buildThumbPicker("picker-scene", files, onSelectScene);
|
||||||
populateSelect(selM, files);
|
|
||||||
populateSelect(selS, files);
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
setStatus(`Nessuna immagine in ${dir} (configura IMAGES_DIR in .env)`);
|
setStatus(`Nessuna immagine in ${dir} (configura IMAGES_DIR in .env)`);
|
||||||
} else {
|
} else {
|
||||||
setStatus(`${files.length} immagini disponibili in ${dir}`);
|
setStatus(`${files.length} immagini disponibili in ${dir}`);
|
||||||
}
|
}
|
||||||
selM.addEventListener("change", (e) => onSelectModel(e.target.value));
|
|
||||||
selS.addEventListener("change", (e) => onSelectScene(e.target.value));
|
|
||||||
document.getElementById("btn-match").addEventListener("click", doMatch);
|
document.getElementById("btn-match").addEventListener("click", doMatch);
|
||||||
const slider = document.getElementById("p-min-score");
|
const slider = document.getElementById("p-min-score");
|
||||||
slider.addEventListener("input", (e) => {
|
slider.addEventListener("input", (e) => {
|
||||||
|
|||||||
@@ -10,13 +10,21 @@
|
|||||||
<h1>Pattern Matching 2D</h1>
|
<h1>Pattern Matching 2D</h1>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<label class="tb-label">Modello:</label>
|
<label class="tb-label">Modello:</label>
|
||||||
<select id="sel-model" class="tb-select">
|
<div class="thumb-picker" id="picker-model">
|
||||||
<option value="">-- seleziona --</option>
|
<div class="picker-current" tabindex="0">
|
||||||
</select>
|
<span class="picker-text">-- seleziona --</span>
|
||||||
|
<span class="caret">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="picker-list"></div>
|
||||||
|
</div>
|
||||||
<label class="tb-label">Scena:</label>
|
<label class="tb-label">Scena:</label>
|
||||||
<select id="sel-scene" class="tb-select">
|
<div class="thumb-picker" id="picker-scene">
|
||||||
<option value="">-- seleziona --</option>
|
<div class="picker-current" tabindex="0">
|
||||||
</select>
|
<span class="picker-text">-- seleziona --</span>
|
||||||
|
<span class="caret">▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="picker-list"></div>
|
||||||
|
</div>
|
||||||
<button class="btn btn-go" id="btn-match">▶ MATCH</button>
|
<button class="btn btn-go" id="btn-match">▶ MATCH</button>
|
||||||
<span id="status">Seleziona modello, disegna ROI, seleziona scena</span>
|
<span id="status">Seleziona modello, disegna ROI, seleziona scena</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,12 +27,39 @@ header h1 {
|
|||||||
}
|
}
|
||||||
.btn-go:hover { background: #0d9c48; }
|
.btn-go:hover { background: #0d9c48; }
|
||||||
.tb-label { color: #b0b0b0; font-size: 12px; margin-left: 8px; }
|
.tb-label { color: #b0b0b0; font-size: 12px; margin-left: 8px; }
|
||||||
.tb-select {
|
|
||||||
|
.thumb-picker { position: relative; display: inline-block; }
|
||||||
|
.picker-current {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
background: #2a2a2a; color: #dcdcdc; border: 1px solid #444;
|
background: #2a2a2a; color: #dcdcdc; border: 1px solid #444;
|
||||||
padding: 5px 8px; border-radius: 3px; font-size: 13px;
|
padding: 4px 8px; border-radius: 3px; font-size: 13px;
|
||||||
min-width: 160px;
|
cursor: pointer; min-width: 200px; min-height: 32px;
|
||||||
}
|
}
|
||||||
.tb-select:focus { outline: 1px solid #00c8ff; }
|
.picker-current:hover { background: #353535; }
|
||||||
|
.picker-current img {
|
||||||
|
width: 36px; height: 36px; object-fit: contain;
|
||||||
|
background: #141414; border-radius: 2px;
|
||||||
|
}
|
||||||
|
.picker-text { flex: 1; white-space: nowrap; overflow: hidden;
|
||||||
|
text-overflow: ellipsis; }
|
||||||
|
.caret { color: #888; font-size: 10px; }
|
||||||
|
.picker-list {
|
||||||
|
display: none; position: absolute; top: 100%; left: 0;
|
||||||
|
background: #232323; border: 1px solid #444; border-radius: 3px;
|
||||||
|
margin-top: 2px; z-index: 100; max-height: 360px; overflow-y: auto;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.6); min-width: 280px;
|
||||||
|
}
|
||||||
|
.thumb-picker.open .picker-list { display: block; }
|
||||||
|
.picker-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 6px 10px; cursor: pointer; border-bottom: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
.picker-item:hover { background: #2e2e2e; }
|
||||||
|
.picker-item img {
|
||||||
|
width: 60px; height: 60px; object-fit: contain;
|
||||||
|
background: #141414; border-radius: 2px;
|
||||||
|
}
|
||||||
|
.picker-item .name { color: #dcdcdc; font-size: 13px; }
|
||||||
#status {
|
#status {
|
||||||
color: #00c8ff; margin-left: 12px; font-weight: 500;
|
color: #00c8ff; margin-left: 12px; font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|||||||