1866 lines
37 KiB
JavaScript
1866 lines
37 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const state = {
|
|
token: "",
|
|
context: null,
|
|
targetUser: "",
|
|
clusters: [],
|
|
unclustered: [],
|
|
persons: [],
|
|
objectUrls: new Set(),
|
|
};
|
|
|
|
|
|
function getToken() {
|
|
return localStorage.getItem(
|
|
"token",
|
|
) || "";
|
|
}
|
|
|
|
|
|
function getRequestedUser() {
|
|
const parameters =
|
|
new URLSearchParams(
|
|
window.location.search,
|
|
);
|
|
|
|
return String(
|
|
parameters.get("user") || "",
|
|
).trim();
|
|
}
|
|
|
|
|
|
function buildApiUrl(path) {
|
|
const url = new URL(
|
|
path,
|
|
window.location.origin,
|
|
);
|
|
|
|
const requestedUser =
|
|
getRequestedUser();
|
|
|
|
if (requestedUser) {
|
|
url.searchParams.set(
|
|
"user",
|
|
requestedUser,
|
|
);
|
|
}
|
|
|
|
return (
|
|
url.pathname
|
|
+ url.search
|
|
);
|
|
}
|
|
|
|
|
|
async function apiRequest(
|
|
path,
|
|
options = {},
|
|
) {
|
|
const headers = {
|
|
...(options.headers || {}),
|
|
Authorization:
|
|
`Bearer ${state.token}`,
|
|
};
|
|
|
|
const response = await fetch(
|
|
buildApiUrl(path),
|
|
{
|
|
...options,
|
|
headers,
|
|
},
|
|
);
|
|
|
|
if (response.status === 401) {
|
|
localStorage.removeItem("token");
|
|
|
|
window.location.replace(
|
|
"/login?redirect="
|
|
+ encodeURIComponent(
|
|
window.location.pathname
|
|
+ window.location.search,
|
|
),
|
|
);
|
|
|
|
throw new Error(
|
|
"Sessione scaduta",
|
|
);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let message =
|
|
`Errore HTTP ${response.status}`;
|
|
|
|
try {
|
|
const payload =
|
|
await response.json();
|
|
|
|
message =
|
|
payload.error
|
|
|| payload.message
|
|
|| message;
|
|
} catch {
|
|
// Mantiene il messaggio HTTP.
|
|
}
|
|
|
|
throw new Error(message);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
|
|
async function apiWrite(
|
|
path,
|
|
body,
|
|
) {
|
|
return apiRequest(
|
|
path,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type":
|
|
"application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
},
|
|
);
|
|
}
|
|
|
|
|
|
async function loadProtectedImage(
|
|
image,
|
|
thumbnailUrl,
|
|
) {
|
|
try {
|
|
image.classList.add("loading");
|
|
|
|
const response = await fetch(
|
|
buildApiUrl(thumbnailUrl),
|
|
{
|
|
headers: {
|
|
Authorization:
|
|
`Bearer ${state.token}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Miniatura HTTP ${response.status}`,
|
|
);
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const objectUrl =
|
|
URL.createObjectURL(blob);
|
|
|
|
state.objectUrls.add(
|
|
objectUrl,
|
|
);
|
|
|
|
image.src = objectUrl;
|
|
image.classList.remove("loading");
|
|
} catch (error) {
|
|
console.error(
|
|
"[faces] errore miniatura:",
|
|
error,
|
|
);
|
|
|
|
const wrapper =
|
|
image.closest(
|
|
".face-image-wrapper",
|
|
);
|
|
|
|
if (wrapper) {
|
|
wrapper.innerHTML = "";
|
|
|
|
const placeholder =
|
|
document.createElement("div");
|
|
|
|
placeholder.className =
|
|
"face-placeholder";
|
|
|
|
placeholder.textContent =
|
|
"Miniatura non disponibile";
|
|
|
|
wrapper.appendChild(
|
|
placeholder,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function releaseObjectUrls() {
|
|
for (
|
|
const objectUrl
|
|
of state.objectUrls
|
|
) {
|
|
URL.revokeObjectURL(
|
|
objectUrl,
|
|
);
|
|
}
|
|
|
|
state.objectUrls.clear();
|
|
}
|
|
|
|
|
|
function setStatus(
|
|
message,
|
|
type = "",
|
|
) {
|
|
const panel =
|
|
document.getElementById(
|
|
"statusPanel",
|
|
);
|
|
|
|
panel.textContent = message;
|
|
|
|
panel.classList.remove(
|
|
"error",
|
|
"success",
|
|
);
|
|
|
|
if (type) {
|
|
panel.classList.add(type);
|
|
}
|
|
}
|
|
|
|
|
|
function createFaceCard(
|
|
face,
|
|
options = {},
|
|
) {
|
|
const article =
|
|
document.createElement(
|
|
"article",
|
|
);
|
|
|
|
article.className = "face-card";
|
|
|
|
if (options.selectable) {
|
|
article.classList.add(
|
|
"selectable",
|
|
);
|
|
|
|
const selection =
|
|
document.createElement("label");
|
|
|
|
selection.className =
|
|
"face-selection";
|
|
|
|
const checkbox =
|
|
document.createElement("input");
|
|
|
|
checkbox.type = "checkbox";
|
|
checkbox.checked =
|
|
options.checked !== false;
|
|
|
|
checkbox.dataset.faceId =
|
|
face.id;
|
|
|
|
const selectionText =
|
|
document.createElement("span");
|
|
|
|
selectionText.textContent =
|
|
"Includi";
|
|
|
|
selection.append(
|
|
checkbox,
|
|
selectionText,
|
|
);
|
|
|
|
article.appendChild(selection);
|
|
}
|
|
|
|
const imageWrapper =
|
|
document.createElement("div");
|
|
|
|
imageWrapper.className =
|
|
"face-image-wrapper";
|
|
|
|
const image =
|
|
document.createElement("img");
|
|
|
|
image.className =
|
|
"face-image loading";
|
|
|
|
image.alt =
|
|
`Volto in ${face.photo_name}`;
|
|
|
|
image.loading = "lazy";
|
|
|
|
imageWrapper.appendChild(image);
|
|
|
|
const body =
|
|
document.createElement("div");
|
|
|
|
body.className = "face-body";
|
|
|
|
const name =
|
|
document.createElement("div");
|
|
|
|
name.className = "face-name";
|
|
name.textContent =
|
|
face.photo_name
|
|
|| "Fotografia";
|
|
|
|
const details =
|
|
document.createElement("p");
|
|
|
|
details.className =
|
|
"face-details";
|
|
|
|
const probability =
|
|
face.probability == null
|
|
? "n/d"
|
|
: Number(
|
|
face.probability,
|
|
).toFixed(3);
|
|
|
|
const detectorScore =
|
|
Number(
|
|
face.detector_score || 0,
|
|
).toFixed(3);
|
|
|
|
details.textContent =
|
|
`Volto ${face.face_index}`
|
|
+ ` · detector ${detectorScore}`
|
|
+ ` · probabilità ${probability}`;
|
|
|
|
body.append(
|
|
name,
|
|
details,
|
|
);
|
|
|
|
article.append(
|
|
imageWrapper,
|
|
body,
|
|
);
|
|
|
|
loadProtectedImage(
|
|
image,
|
|
face.thumbnail_url,
|
|
);
|
|
|
|
return article;
|
|
}
|
|
|
|
|
|
function getSelectedFaceIds(
|
|
clusterElement,
|
|
) {
|
|
return Array.from(
|
|
clusterElement.querySelectorAll(
|
|
'input[data-face-id\]:checked',
|
|
),
|
|
).map(
|
|
(checkbox) =>
|
|
checkbox.dataset.faceId,
|
|
);
|
|
}
|
|
|
|
|
|
async function createPerson(
|
|
name,
|
|
) {
|
|
const response = await apiWrite(
|
|
"/face-review/persons",
|
|
{ name },
|
|
);
|
|
|
|
return response.person;
|
|
}
|
|
|
|
|
|
async function assignClusterPerson(
|
|
cluster,
|
|
clusterElement,
|
|
controls,
|
|
) {
|
|
let personId = String(
|
|
controls.personSelect.value || "",
|
|
).trim();
|
|
|
|
if (!personId) {
|
|
const name = String(
|
|
controls.nameInput.value || "",
|
|
)
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
|
|
if (!name) {
|
|
throw new Error(
|
|
"Inserisci un nome oppure "
|
|
+ "seleziona una persona"
|
|
);
|
|
}
|
|
|
|
const person =
|
|
await createPerson(name);
|
|
|
|
personId = person.id;
|
|
}
|
|
|
|
const includedFaceIds =
|
|
getSelectedFaceIds(
|
|
clusterElement,
|
|
);
|
|
|
|
if (!includedFaceIds.length) {
|
|
throw new Error(
|
|
"Seleziona almeno un volto"
|
|
);
|
|
}
|
|
|
|
controls.assignButton.disabled = true;
|
|
controls.assignButton.textContent =
|
|
"Salvataggio...";
|
|
|
|
try {
|
|
await apiWrite(
|
|
`/face-review/clusters/${
|
|
encodeURIComponent(cluster.id)
|
|
}/assign-person`,
|
|
{
|
|
person_id: personId,
|
|
included_face_ids:
|
|
includedFaceIds,
|
|
},
|
|
);
|
|
|
|
await loadData();
|
|
|
|
setStatus(
|
|
"Persona assegnata correttamente",
|
|
"success",
|
|
);
|
|
} finally {
|
|
controls.assignButton.disabled =
|
|
false;
|
|
|
|
controls.assignButton.textContent =
|
|
"Assegna persona";
|
|
}
|
|
}
|
|
|
|
|
|
function createClusterControls(
|
|
cluster,
|
|
clusterElement,
|
|
) {
|
|
const wrapper =
|
|
document.createElement("div");
|
|
|
|
wrapper.className =
|
|
"cluster-controls";
|
|
|
|
const nameInput =
|
|
document.createElement("input");
|
|
|
|
nameInput.type = "text";
|
|
nameInput.maxLength = 120;
|
|
nameInput.placeholder =
|
|
"Nome nuova persona";
|
|
|
|
nameInput.className =
|
|
"person-name-input";
|
|
|
|
const personSelect =
|
|
document.createElement("select");
|
|
|
|
personSelect.className =
|
|
"person-select";
|
|
|
|
const emptyOption =
|
|
document.createElement("option");
|
|
|
|
emptyOption.value = "";
|
|
emptyOption.textContent =
|
|
"Crea nuova persona";
|
|
|
|
personSelect.appendChild(emptyOption);
|
|
|
|
for (const person of state.persons) {
|
|
const option =
|
|
document.createElement("option");
|
|
|
|
option.value = person.id;
|
|
option.textContent = person.name;
|
|
|
|
if (
|
|
cluster.person_id === person.id
|
|
) {
|
|
option.selected = true;
|
|
}
|
|
|
|
personSelect.appendChild(option);
|
|
}
|
|
|
|
const assignButton =
|
|
document.createElement("button");
|
|
|
|
assignButton.type = "button";
|
|
assignButton.className =
|
|
"primary-button";
|
|
|
|
assignButton.textContent =
|
|
"Assegna persona";
|
|
|
|
const controls = {
|
|
wrapper,
|
|
nameInput,
|
|
personSelect,
|
|
assignButton,
|
|
};
|
|
|
|
assignButton.addEventListener(
|
|
"click",
|
|
async () => {
|
|
try {
|
|
await assignClusterPerson(
|
|
cluster,
|
|
clusterElement,
|
|
controls,
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
"[faces] assegnazione:",
|
|
error,
|
|
);
|
|
|
|
setStatus(
|
|
error.message,
|
|
"error",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
wrapper.append(
|
|
nameInput,
|
|
personSelect,
|
|
assignButton,
|
|
);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
|
|
function renderClusters() {
|
|
const grid =
|
|
document.getElementById(
|
|
"clustersGrid",
|
|
);
|
|
|
|
grid.innerHTML = "";
|
|
|
|
if (!state.clusters.length) {
|
|
grid.innerHTML =
|
|
'<div class="empty-state">'
|
|
+ 'Nessun cluster disponibile.'
|
|
+ '</div>';
|
|
|
|
return;
|
|
}
|
|
|
|
for (
|
|
const cluster
|
|
of state.clusters
|
|
) {
|
|
const article =
|
|
document.createElement(
|
|
"article",
|
|
);
|
|
|
|
article.className =
|
|
"cluster-card";
|
|
|
|
const header =
|
|
document.createElement("div");
|
|
|
|
header.className =
|
|
"cluster-header";
|
|
|
|
const titleWrapper =
|
|
document.createElement("div");
|
|
|
|
const title =
|
|
document.createElement("h3");
|
|
|
|
title.className =
|
|
"cluster-title";
|
|
|
|
title.textContent =
|
|
cluster.person_name
|
|
|| `Cluster ${cluster.label}`;
|
|
|
|
const metadata =
|
|
document.createElement("p");
|
|
|
|
metadata.className =
|
|
"cluster-metadata";
|
|
|
|
metadata.textContent =
|
|
cluster.person_name
|
|
? `Cluster ${cluster.label}`
|
|
: "Persona non identificata";
|
|
|
|
titleWrapper.append(
|
|
title,
|
|
metadata,
|
|
);
|
|
|
|
const badge =
|
|
document.createElement("span");
|
|
|
|
badge.className =
|
|
"cluster-size-badge";
|
|
|
|
badge.textContent =
|
|
`${cluster.size} volti`;
|
|
|
|
header.append(
|
|
titleWrapper,
|
|
badge,
|
|
);
|
|
|
|
const faceGrid =
|
|
document.createElement("div");
|
|
|
|
faceGrid.className =
|
|
"face-grid";
|
|
|
|
for (
|
|
const face
|
|
of cluster.faces || []
|
|
) {
|
|
faceGrid.appendChild(
|
|
createFaceCard(
|
|
face,
|
|
{
|
|
selectable: true,
|
|
checked:
|
|
face.review_status
|
|
!== "excluded",
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
const controls =
|
|
createClusterControls(
|
|
cluster,
|
|
article,
|
|
);
|
|
|
|
article.append(
|
|
header,
|
|
faceGrid,
|
|
controls,
|
|
);
|
|
|
|
grid.appendChild(article);
|
|
}
|
|
}
|
|
|
|
|
|
async function assignFacePerson(
|
|
face,
|
|
personId,
|
|
) {
|
|
if (!personId) {
|
|
throw new Error(
|
|
"Seleziona una persona",
|
|
);
|
|
}
|
|
|
|
return apiWrite(
|
|
`/face-review/faces/${
|
|
encodeURIComponent(face.id)
|
|
}/assign-person`,
|
|
{
|
|
person_id: personId,
|
|
},
|
|
);
|
|
}
|
|
|
|
|
|
function createUnclusteredFaceCard(
|
|
face,
|
|
) {
|
|
const card =
|
|
createFaceCard(face);
|
|
|
|
const body =
|
|
card.querySelector(
|
|
".face-body",
|
|
);
|
|
|
|
const controls =
|
|
document.createElement("div");
|
|
|
|
controls.className =
|
|
"noise-person-controls";
|
|
|
|
const select =
|
|
document.createElement("select");
|
|
|
|
select.className =
|
|
"person-select";
|
|
|
|
const emptyOption =
|
|
document.createElement("option");
|
|
|
|
emptyOption.value = "";
|
|
emptyOption.textContent =
|
|
"Seleziona persona";
|
|
|
|
select.appendChild(emptyOption);
|
|
|
|
for (const person of state.persons) {
|
|
const option =
|
|
document.createElement(
|
|
"option",
|
|
);
|
|
|
|
option.value = person.id;
|
|
option.textContent =
|
|
person.name;
|
|
|
|
select.appendChild(option);
|
|
}
|
|
|
|
const button =
|
|
document.createElement("button");
|
|
|
|
button.type = "button";
|
|
button.className =
|
|
"primary-button";
|
|
|
|
button.textContent =
|
|
"Assegna";
|
|
|
|
button.addEventListener(
|
|
"click",
|
|
async () => {
|
|
button.disabled = true;
|
|
button.textContent =
|
|
"Salvataggio...";
|
|
|
|
try {
|
|
await assignFacePerson(
|
|
face,
|
|
select.value,
|
|
);
|
|
|
|
await loadData();
|
|
|
|
setStatus(
|
|
"Volto assegnato "
|
|
+ "alla persona",
|
|
"success",
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
"[faces] assegnazione "
|
|
+ "volto noise:",
|
|
error,
|
|
);
|
|
|
|
setStatus(
|
|
error.message,
|
|
"error",
|
|
);
|
|
|
|
button.disabled = false;
|
|
button.textContent =
|
|
"Assegna";
|
|
}
|
|
},
|
|
);
|
|
|
|
controls.append(
|
|
select,
|
|
button,
|
|
);
|
|
|
|
if (body) {
|
|
body.appendChild(controls);
|
|
}
|
|
|
|
return card;
|
|
}
|
|
|
|
|
|
function getSelectedNoiseFaceIds() {
|
|
return Array.from(
|
|
document.querySelectorAll(
|
|
'#unclusteredGrid '
|
|
+ 'input[data-noise-face-id\]:checked',
|
|
),
|
|
).map(
|
|
(checkbox) =>
|
|
checkbox.dataset.noiseFaceId,
|
|
);
|
|
}
|
|
|
|
|
|
function updateBulkNoiseCount(
|
|
controls,
|
|
) {
|
|
const selected =
|
|
getSelectedNoiseFaceIds();
|
|
|
|
controls.assignButton.textContent =
|
|
selected.length
|
|
? `Assegna ${selected.length} volti`
|
|
: "Assegna volti selezionati";
|
|
|
|
controls.assignButton.disabled =
|
|
selected.length === 0;
|
|
}
|
|
|
|
|
|
function setAllNoiseSelections(
|
|
checked,
|
|
controls,
|
|
) {
|
|
document
|
|
.querySelectorAll(
|
|
'#unclusteredGrid '
|
|
+ 'input[data-noise-face-id]',
|
|
)
|
|
.forEach((checkbox) => {
|
|
checkbox.checked = checked;
|
|
});
|
|
|
|
updateBulkNoiseCount(controls);
|
|
}
|
|
|
|
|
|
async function assignSelectedNoiseFaces(
|
|
controls,
|
|
) {
|
|
const faceIds =
|
|
getSelectedNoiseFaceIds();
|
|
|
|
if (!faceIds.length) {
|
|
throw new Error(
|
|
"Seleziona almeno un volto",
|
|
);
|
|
}
|
|
|
|
const personId = String(
|
|
controls.personSelect.value || "",
|
|
).trim();
|
|
|
|
const name = String(
|
|
controls.nameInput.value || "",
|
|
)
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
|
|
if (!personId && !name) {
|
|
throw new Error(
|
|
"Seleziona una persona oppure "
|
|
+ "inserisci un nuovo nome",
|
|
);
|
|
}
|
|
|
|
controls.assignButton.disabled = true;
|
|
controls.assignButton.textContent =
|
|
"Salvataggio...";
|
|
|
|
try {
|
|
const payload = {
|
|
face_ids: faceIds,
|
|
};
|
|
|
|
if (personId) {
|
|
payload.person_id = personId;
|
|
} else {
|
|
payload.name = name;
|
|
}
|
|
|
|
const response = await apiWrite(
|
|
"/face-review/unclustered/"
|
|
+ "assign-person",
|
|
payload,
|
|
);
|
|
|
|
await loadData();
|
|
|
|
setStatus(
|
|
`${response.assigned_count} volti `
|
|
+ `assegnati a ${
|
|
response.person.name
|
|
}`,
|
|
"success",
|
|
);
|
|
} finally {
|
|
controls.assignButton.disabled =
|
|
false;
|
|
|
|
updateBulkNoiseCount(controls);
|
|
}
|
|
}
|
|
|
|
|
|
function createBulkNoiseControls() {
|
|
const wrapper =
|
|
document.createElement("div");
|
|
|
|
wrapper.className =
|
|
"bulk-noise-controls";
|
|
|
|
const selectionActions =
|
|
document.createElement("div");
|
|
|
|
selectionActions.className =
|
|
"bulk-selection-actions";
|
|
|
|
const selectAllButton =
|
|
document.createElement("button");
|
|
|
|
selectAllButton.type = "button";
|
|
selectAllButton.className =
|
|
"secondary-button";
|
|
|
|
selectAllButton.textContent =
|
|
"Seleziona tutti";
|
|
|
|
const clearButton =
|
|
document.createElement("button");
|
|
|
|
clearButton.type = "button";
|
|
clearButton.className =
|
|
"secondary-button";
|
|
|
|
clearButton.textContent =
|
|
"Deseleziona tutti";
|
|
|
|
selectionActions.append(
|
|
selectAllButton,
|
|
clearButton,
|
|
);
|
|
|
|
const nameInput =
|
|
document.createElement("input");
|
|
|
|
nameInput.type = "text";
|
|
nameInput.maxLength = 120;
|
|
nameInput.placeholder =
|
|
"Nome nuova persona";
|
|
|
|
nameInput.className =
|
|
"person-name-input";
|
|
|
|
const personSelect =
|
|
document.createElement("select");
|
|
|
|
personSelect.className =
|
|
"person-select";
|
|
|
|
const emptyOption =
|
|
document.createElement("option");
|
|
|
|
emptyOption.value = "";
|
|
emptyOption.textContent =
|
|
"Crea nuova persona";
|
|
|
|
personSelect.appendChild(
|
|
emptyOption,
|
|
);
|
|
|
|
for (const person of state.persons) {
|
|
const option =
|
|
document.createElement("option");
|
|
|
|
option.value = person.id;
|
|
option.textContent =
|
|
person.name;
|
|
|
|
personSelect.appendChild(option);
|
|
}
|
|
|
|
const assignButton =
|
|
document.createElement("button");
|
|
|
|
assignButton.type = "button";
|
|
assignButton.className =
|
|
"primary-button";
|
|
|
|
assignButton.textContent =
|
|
"Assegna volti selezionati";
|
|
|
|
assignButton.disabled = true;
|
|
|
|
const controls = {
|
|
wrapper,
|
|
nameInput,
|
|
personSelect,
|
|
assignButton,
|
|
};
|
|
|
|
selectAllButton.addEventListener(
|
|
"click",
|
|
() => {
|
|
setAllNoiseSelections(
|
|
true,
|
|
controls,
|
|
);
|
|
},
|
|
);
|
|
|
|
clearButton.addEventListener(
|
|
"click",
|
|
() => {
|
|
setAllNoiseSelections(
|
|
false,
|
|
controls,
|
|
);
|
|
},
|
|
);
|
|
|
|
assignButton.addEventListener(
|
|
"click",
|
|
async () => {
|
|
try {
|
|
await assignSelectedNoiseFaces(
|
|
controls,
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
"[faces] assegnazione bulk:",
|
|
error,
|
|
);
|
|
|
|
setStatus(
|
|
error.message,
|
|
"error",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
wrapper.append(
|
|
selectionActions,
|
|
nameInput,
|
|
personSelect,
|
|
assignButton,
|
|
);
|
|
|
|
return controls;
|
|
}
|
|
|
|
|
|
function renderUnclustered() {
|
|
const grid =
|
|
document.getElementById(
|
|
"unclusteredGrid",
|
|
);
|
|
|
|
grid.innerHTML = "";
|
|
|
|
const section =
|
|
document.getElementById(
|
|
"unclusteredSection",
|
|
);
|
|
|
|
section
|
|
.querySelector(
|
|
".bulk-noise-controls",
|
|
)
|
|
?.remove();
|
|
|
|
if (!state.unclustered.length) {
|
|
grid.innerHTML =
|
|
'<div class="empty-state">'
|
|
+ 'Nessun volto non raggruppato.'
|
|
+ '</div>';
|
|
|
|
return;
|
|
}
|
|
|
|
const controls =
|
|
createBulkNoiseControls();
|
|
|
|
const heading =
|
|
section.querySelector(
|
|
".section-heading",
|
|
);
|
|
|
|
heading.insertAdjacentElement(
|
|
"afterend",
|
|
controls.wrapper,
|
|
);
|
|
|
|
for (
|
|
const face
|
|
of state.unclustered
|
|
) {
|
|
const card =
|
|
createFaceCard(face);
|
|
|
|
card.classList.add(
|
|
"noise-selectable",
|
|
);
|
|
|
|
const label =
|
|
document.createElement("label");
|
|
|
|
label.className =
|
|
"noise-selection";
|
|
|
|
const checkbox =
|
|
document.createElement("input");
|
|
|
|
checkbox.type = "checkbox";
|
|
|
|
checkbox.dataset.noiseFaceId =
|
|
face.id;
|
|
|
|
const labelText =
|
|
document.createElement("span");
|
|
|
|
labelText.textContent =
|
|
"Seleziona";
|
|
|
|
checkbox.addEventListener(
|
|
"change",
|
|
() => {
|
|
updateBulkNoiseCount(
|
|
controls,
|
|
);
|
|
},
|
|
);
|
|
|
|
label.append(
|
|
checkbox,
|
|
labelText,
|
|
);
|
|
|
|
card.prepend(label);
|
|
|
|
grid.appendChild(card);
|
|
}
|
|
|
|
updateBulkNoiseCount(controls);
|
|
}
|
|
|
|
async function apiMutation(
|
|
path,
|
|
method,
|
|
body = null,
|
|
) {
|
|
const options = {
|
|
method,
|
|
headers: {},
|
|
};
|
|
|
|
if (body !== null) {
|
|
options.headers[
|
|
"Content-Type"
|
|
] = "application/json";
|
|
|
|
options.body =
|
|
JSON.stringify(body);
|
|
}
|
|
|
|
return apiRequest(
|
|
path,
|
|
options,
|
|
);
|
|
}
|
|
|
|
|
|
function closePersonDetail() {
|
|
const overlay =
|
|
document.getElementById(
|
|
"personDetailOverlay",
|
|
);
|
|
|
|
if (overlay) {
|
|
overlay.remove();
|
|
}
|
|
}
|
|
|
|
|
|
async function removeFaceFromPerson(
|
|
person,
|
|
face,
|
|
) {
|
|
const confirmed = window.confirm(
|
|
`Rimuovere il volto di ${
|
|
face.photo_name
|
|
} da ${person.name}?`,
|
|
);
|
|
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
await apiMutation(
|
|
`/face-review/persons/${
|
|
encodeURIComponent(person.id)
|
|
}/faces/${
|
|
encodeURIComponent(face.id)
|
|
}`,
|
|
"DELETE",
|
|
);
|
|
}
|
|
|
|
|
|
async function renamePerson(
|
|
person,
|
|
input,
|
|
) {
|
|
const name = String(
|
|
input.value || "",
|
|
)
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
|
|
if (!name) {
|
|
throw new Error(
|
|
"Inserisci un nome valido",
|
|
);
|
|
}
|
|
|
|
await apiMutation(
|
|
`/face-review/persons/${
|
|
encodeURIComponent(person.id)
|
|
}`,
|
|
"PATCH",
|
|
{ name },
|
|
);
|
|
}
|
|
|
|
|
|
function createPersonFaceCard(person, face, overlay) {
|
|
const card = createFaceCard(face);
|
|
const body = card.querySelector(".face-body");
|
|
const wrapper = card.querySelector(".face-image-wrapper");
|
|
const badges = document.createElement("div");
|
|
badges.className = "person-face-badges";
|
|
|
|
const addBadge = (text, className) => {
|
|
const badge = document.createElement("span");
|
|
badge.className = `person-face-badge ${className}`;
|
|
badge.textContent = text;
|
|
badges.appendChild(badge);
|
|
};
|
|
|
|
if (face.is_prototype) {
|
|
const order = face.prototype?.selection_order;
|
|
addBadge(order == null ? "Prototype" : `Prototype #${order}`, "prototype-badge");
|
|
}
|
|
|
|
if (face.is_reference_excluded) {
|
|
addBadge("Caso difficile", "difficult-badge");
|
|
}
|
|
|
|
if (
|
|
face.recognition?.evaluated
|
|
) {
|
|
const recognitionScore =
|
|
Number(
|
|
face.recognition
|
|
.best_similarity,
|
|
);
|
|
|
|
const scoreText =
|
|
Number.isFinite(
|
|
recognitionScore,
|
|
)
|
|
? recognitionScore.toFixed(2)
|
|
: "n/d";
|
|
|
|
if (
|
|
face.recognition.recognized
|
|
) {
|
|
addBadge(
|
|
`Riconosciuto ${scoreText}`,
|
|
"recognized-badge",
|
|
);
|
|
} else {
|
|
addBadge(
|
|
`Sotto soglia ${scoreText}`,
|
|
"below-threshold-badge",
|
|
);
|
|
}
|
|
}
|
|
|
|
if (face.cluster_label == null) {
|
|
addBadge("Noise confermato", "noise-badge");
|
|
} else {
|
|
addBadge(`Cluster ${face.cluster_label}`, "cluster-badge");
|
|
}
|
|
|
|
if (wrapper) wrapper.appendChild(badges);
|
|
|
|
const info = document.createElement("p");
|
|
info.className = "face-details";
|
|
info.textContent = face.cluster_label == null
|
|
? "Senza cluster automatico"
|
|
: `Cluster ${face.cluster_label}`;
|
|
|
|
const referenceInfo = document.createElement("div");
|
|
referenceInfo.className = "reference-info";
|
|
|
|
if (face.is_prototype) {
|
|
referenceInfo.classList.add("prototype-info");
|
|
referenceInfo.textContent = face.prototype?.selection_method === "cluster_medoid"
|
|
? "Riferimento centrale del cluster"
|
|
: "Riferimento scelto per copertura";
|
|
} else if (face.is_reference_excluded) {
|
|
referenceInfo.classList.add("difficult-info");
|
|
referenceInfo.textContent = face.reference_exclusion?.note
|
|
|| face.reference_exclusion?.reason_code
|
|
|| "Non usare come riferimento";
|
|
} else {
|
|
referenceInfo.hidden = true;
|
|
}
|
|
|
|
const removeButton = document.createElement("button");
|
|
removeButton.type = "button";
|
|
removeButton.className = "danger-button";
|
|
removeButton.textContent = "Rimuovi dalla persona";
|
|
removeButton.addEventListener("click", async () => {
|
|
removeButton.disabled = true;
|
|
try {
|
|
await removeFaceFromPerson(person, face);
|
|
overlay.remove();
|
|
await loadData();
|
|
setStatus("Volto rimosso dalla persona", "success");
|
|
} catch (error) {
|
|
console.error("[faces] rimozione volto:", error);
|
|
setStatus(error.message, "error");
|
|
removeButton.disabled = false;
|
|
}
|
|
});
|
|
|
|
if (body) body.append(info, referenceInfo, removeButton);
|
|
return card;
|
|
}
|
|
|
|
|
|
function personFaceMatches(face, filter) {
|
|
if (filter === "prototype") return Boolean(face.is_prototype);
|
|
if (filter === "clustered") return face.cluster_id != null;
|
|
if (filter === "noise") return face.cluster_id == null;
|
|
if (filter === "difficult") return Boolean(face.is_reference_excluded);
|
|
if (
|
|
filter === "recognized"
|
|
) {
|
|
return Boolean(
|
|
face.recognition?.evaluated
|
|
&& face.recognition?.recognized,
|
|
);
|
|
}
|
|
|
|
if (
|
|
filter === "below"
|
|
) {
|
|
return Boolean(
|
|
face.recognition?.evaluated
|
|
&& !face.recognition?.recognized,
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
async function openPersonDetail(personId) {
|
|
closePersonDetail();
|
|
setStatus("Caricamento della persona...");
|
|
|
|
const data = await apiRequest(`/face-review/persons/${encodeURIComponent(personId)}`);
|
|
const person = data.person;
|
|
const summary = data.summary || {};
|
|
const faces = data.faces || [];
|
|
const indexState = data.index_state || {};
|
|
|
|
const overlay = document.createElement("div");
|
|
overlay.id = "personDetailOverlay";
|
|
overlay.className = "person-detail-overlay";
|
|
|
|
const dialog = document.createElement("section");
|
|
dialog.className = "person-detail-dialog";
|
|
|
|
const header = document.createElement("header");
|
|
header.className = "person-detail-header";
|
|
const heading = document.createElement("div");
|
|
const title = document.createElement("h2");
|
|
title.textContent = person.name;
|
|
const summaryText = document.createElement("p");
|
|
summaryText.className = "page-subtitle";
|
|
summaryText.textContent = `${summary.face_count || 0} volti | ${summary.cluster_count || 0} cluster | ${summary.prototype_count || 0} prototype | ${summary.difficult_face_count || 0} casi difficili`;
|
|
const coverageText =
|
|
document.createElement("p");
|
|
|
|
coverageText.className =
|
|
"recognition-coverage-summary";
|
|
|
|
if (
|
|
summary.evaluated_face_count > 0
|
|
) {
|
|
const coveragePercent =
|
|
Number(
|
|
summary.recognition_coverage_percent
|
|
|| 0,
|
|
).toFixed(1);
|
|
|
|
const recognitionThreshold =
|
|
Number(
|
|
summary.recognition_threshold
|
|
|| 0.5,
|
|
).toFixed(2);
|
|
|
|
coverageText.textContent =
|
|
`${summary.recognized_face_count || 0}`
|
|
+ ` / ${summary.evaluated_face_count}`
|
|
+ " riconosciuti"
|
|
+ ` | ${coveragePercent}%`
|
|
+ ` | soglia ${recognitionThreshold}`;
|
|
} else {
|
|
coverageText.textContent =
|
|
"Copertura non ancora calcolata";
|
|
}
|
|
|
|
heading.append(
|
|
title,
|
|
summaryText,
|
|
coverageText,
|
|
);
|
|
|
|
const closeButton = document.createElement("button");
|
|
closeButton.type = "button";
|
|
closeButton.className = "secondary-button";
|
|
closeButton.textContent = "Chiudi";
|
|
closeButton.addEventListener("click", closePersonDetail);
|
|
header.append(heading, closeButton);
|
|
|
|
const indexPanel = document.createElement("section");
|
|
indexPanel.className = "recognition-index-panel";
|
|
const indexTitle = document.createElement("strong");
|
|
indexTitle.textContent = "Indice riconoscimento";
|
|
const indexStatus = document.createElement("span");
|
|
indexStatus.className = indexState.dirty ? "index-status dirty" : "index-status ready";
|
|
indexStatus.textContent = indexState.dirty ? "Da aggiornare" : "Aggiornato";
|
|
const indexDetails = document.createElement("span");
|
|
indexDetails.className = "index-state-details";
|
|
indexDetails.textContent = `Versione ${indexState.version || 0} | ${indexState.prototype_count || 0} prototype`;
|
|
indexPanel.append(indexTitle, indexStatus, indexDetails);
|
|
|
|
const renameArea = document.createElement("div");
|
|
renameArea.className = "person-rename-area";
|
|
const nameInput = document.createElement("input");
|
|
nameInput.type = "text";
|
|
nameInput.maxLength = 120;
|
|
nameInput.value = person.name;
|
|
nameInput.className = "person-name-input";
|
|
const renameButton = document.createElement("button");
|
|
renameButton.type = "button";
|
|
renameButton.className = "primary-button";
|
|
renameButton.textContent = "Rinomina";
|
|
renameButton.addEventListener("click", async () => {
|
|
renameButton.disabled = true;
|
|
try {
|
|
await renamePerson(person, nameInput);
|
|
overlay.remove();
|
|
await loadData();
|
|
setStatus("Persona rinominata", "success");
|
|
} catch (error) {
|
|
console.error("[faces] rinomina:", error);
|
|
setStatus(error.message, "error");
|
|
renameButton.disabled = false;
|
|
}
|
|
});
|
|
renameArea.append(nameInput, renameButton);
|
|
|
|
const faceGrid = document.createElement("div");
|
|
faceGrid.className = "face-grid person-detail-faces";
|
|
faces.forEach((face) => faceGrid.appendChild(createPersonFaceCard(person, face, overlay)));
|
|
|
|
const filters = document.createElement("nav");
|
|
filters.className = "person-face-filters";
|
|
const definitions = [
|
|
["all", "Tutti", summary.face_count || faces.length],
|
|
["prototype", "Prototype", summary.prototype_count || 0],
|
|
["clustered", "Clusterizzati", summary.clustered_face_count || 0],
|
|
["noise", "Noise confermati", summary.confirmed_noise_count || 0],
|
|
["recognized", "Riconosciuti", summary.recognized_face_count || 0],
|
|
["below", "Sotto soglia", summary.below_threshold_count || 0],
|
|
["difficult", "Casi difficili", summary.difficult_face_count || 0],
|
|
];
|
|
const buttons = [];
|
|
definitions.forEach(([filter, label, count]) => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "person-face-filter";
|
|
button.dataset.filter = filter;
|
|
if (filter === "all") button.classList.add("active");
|
|
button.textContent = `${label} ${count}`;
|
|
button.addEventListener("click", () => {
|
|
buttons.forEach((item) => item.classList.toggle("active", item === button));
|
|
Array.from(faceGrid.children).forEach((card, index) => {
|
|
card.classList.toggle("hidden", !personFaceMatches(faces[index], filter));
|
|
});
|
|
});
|
|
buttons.push(button);
|
|
filters.appendChild(button);
|
|
});
|
|
|
|
dialog.append(header, indexPanel, renameArea, filters, faceGrid);
|
|
overlay.appendChild(dialog);
|
|
overlay.addEventListener("click", (event) => {
|
|
if (event.target === overlay) closePersonDetail();
|
|
});
|
|
document.body.appendChild(overlay);
|
|
setStatus(`Dettaglio di ${person.name}`, "success");
|
|
}
|
|
|
|
function renderPersons() {
|
|
const grid =
|
|
document.getElementById(
|
|
"personsGrid",
|
|
);
|
|
|
|
grid.innerHTML = "";
|
|
|
|
if (!state.persons.length) {
|
|
grid.innerHTML =
|
|
'<div class="empty-state">'
|
|
+ 'Nessuna persona è stata '
|
|
+ 'ancora nominata.'
|
|
+ '</div>';
|
|
|
|
return;
|
|
}
|
|
|
|
for (
|
|
const person
|
|
of state.persons
|
|
) {
|
|
const card =
|
|
document.createElement(
|
|
"article",
|
|
);
|
|
|
|
card.className =
|
|
"person-card clickable";
|
|
|
|
card.tabIndex = 0;
|
|
card.setAttribute(
|
|
"role",
|
|
"button",
|
|
);
|
|
|
|
const title =
|
|
document.createElement("h3");
|
|
|
|
title.textContent =
|
|
person.name;
|
|
|
|
const details =
|
|
document.createElement("p");
|
|
|
|
details.className =
|
|
"face-details";
|
|
|
|
details.textContent =
|
|
"Apri per vedere e modificare "
|
|
+ "i volti assegnati";
|
|
|
|
const openButton =
|
|
document.createElement(
|
|
"button",
|
|
);
|
|
|
|
openButton.type = "button";
|
|
openButton.className =
|
|
"secondary-button";
|
|
|
|
openButton.textContent =
|
|
"Apri persona";
|
|
|
|
const open = async () => {
|
|
try {
|
|
await openPersonDetail(
|
|
person.id,
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
"[faces] dettaglio persona:",
|
|
error,
|
|
);
|
|
|
|
setStatus(
|
|
error.message,
|
|
"error",
|
|
);
|
|
}
|
|
};
|
|
|
|
openButton.addEventListener(
|
|
"click",
|
|
(event) => {
|
|
event.stopPropagation();
|
|
open();
|
|
},
|
|
);
|
|
|
|
card.addEventListener(
|
|
"click",
|
|
open,
|
|
);
|
|
|
|
card.addEventListener(
|
|
"keydown",
|
|
(event) => {
|
|
if (
|
|
event.key === "Enter"
|
|
|| event.key === " "
|
|
) {
|
|
event.preventDefault();
|
|
open();
|
|
}
|
|
},
|
|
);
|
|
|
|
card.append(
|
|
title,
|
|
details,
|
|
openButton,
|
|
);
|
|
|
|
grid.appendChild(card);
|
|
}
|
|
}
|
|
|
|
function updateCounters() {
|
|
document.getElementById(
|
|
"clusterCount",
|
|
).textContent =
|
|
String(state.clusters.length);
|
|
|
|
document.getElementById(
|
|
"unclusteredCount",
|
|
).textContent =
|
|
String(
|
|
state.unclustered.length,
|
|
);
|
|
|
|
document.getElementById(
|
|
"personCount",
|
|
).textContent =
|
|
String(state.persons.length);
|
|
}
|
|
|
|
|
|
function initializeTabs() {
|
|
const tabs =
|
|
document.querySelectorAll(
|
|
".review-tab",
|
|
);
|
|
|
|
const sections =
|
|
document.querySelectorAll(
|
|
"[data-section]",
|
|
);
|
|
|
|
for (const tab of tabs) {
|
|
tab.addEventListener(
|
|
"click",
|
|
() => {
|
|
const selected =
|
|
tab.dataset.tab;
|
|
|
|
for (
|
|
const currentTab
|
|
of tabs
|
|
) {
|
|
currentTab.classList.toggle(
|
|
"active",
|
|
currentTab === tab,
|
|
);
|
|
}
|
|
|
|
for (
|
|
const section
|
|
of sections
|
|
) {
|
|
section.classList.toggle(
|
|
"hidden",
|
|
section.dataset.section
|
|
!== selected,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
function initializeButtons() {
|
|
document
|
|
.getElementById("refreshButton")
|
|
.addEventListener(
|
|
"click",
|
|
loadData,
|
|
);
|
|
|
|
document
|
|
.getElementById("backButton")
|
|
.addEventListener(
|
|
"click",
|
|
() => {
|
|
if (
|
|
state.context?.is_admin
|
|
) {
|
|
window.location.href =
|
|
"/admin.html";
|
|
} else {
|
|
window.location.href = "/";
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
|
|
async function loadData() {
|
|
try {
|
|
releaseObjectUrls();
|
|
|
|
setStatus(
|
|
"Caricamento dei dati...",
|
|
);
|
|
|
|
const context =
|
|
await apiRequest(
|
|
"/face-review/context",
|
|
);
|
|
|
|
state.context = context;
|
|
state.targetUser =
|
|
context.target_user;
|
|
|
|
const [
|
|
clusterData,
|
|
unclusteredData,
|
|
personData,
|
|
] = await Promise.all([
|
|
apiRequest(
|
|
"/face-review/clusters",
|
|
),
|
|
apiRequest(
|
|
"/face-review/unclustered",
|
|
),
|
|
apiRequest(
|
|
"/face-review/persons",
|
|
),
|
|
]);
|
|
|
|
state.clusters =
|
|
clusterData.clusters || [];
|
|
|
|
state.unclustered =
|
|
unclusteredData.faces || [];
|
|
|
|
state.persons =
|
|
personData.persons || [];
|
|
|
|
document.getElementById(
|
|
"pageSubtitle",
|
|
).textContent =
|
|
`Libreria di ${state.targetUser}`;
|
|
|
|
renderClusters();
|
|
renderUnclustered();
|
|
renderPersons();
|
|
updateCounters();
|
|
|
|
setStatus(
|
|
`${state.clusters.length} cluster, `
|
|
+ `${state.unclustered.length} `
|
|
+ `volti non raggruppati`,
|
|
"success",
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
"[faces] caricamento fallito:",
|
|
error,
|
|
);
|
|
|
|
setStatus(
|
|
error.message,
|
|
"error",
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
function initialize() {
|
|
state.token = getToken();
|
|
|
|
if (
|
|
!window.AppAuth
|
|
|| !window.AppAuth.requireAuth()
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const loader =
|
|
document.getElementById(
|
|
"authLoader",
|
|
);
|
|
|
|
const app =
|
|
document.getElementById("app");
|
|
|
|
if (loader) {
|
|
loader.hidden = true;
|
|
loader.style.display = "none";
|
|
}
|
|
|
|
if (!app) {
|
|
throw new Error(
|
|
"Contenitore applicazione non trovato",
|
|
);
|
|
}
|
|
|
|
app.hidden = false;
|
|
app.removeAttribute("aria-hidden");
|
|
app.style.removeProperty("display");
|
|
|
|
initializeTabs();
|
|
initializeButtons();
|
|
loadData();
|
|
}
|
|
|
|
|
|
window.addEventListener(
|
|
"beforeunload",
|
|
releaseObjectUrls,
|
|
);
|
|
|
|
if (
|
|
document.readyState
|
|
=== "loading"
|
|
) {
|
|
document.addEventListener(
|
|
"DOMContentLoaded",
|
|
initialize,
|
|
{
|
|
once: true,
|
|
},
|
|
);
|
|
} else {
|
|
initialize();
|
|
}
|
|
})();
|