1446 lines
57 KiB
HTML
1446 lines
57 KiB
HTML
<!doctype html>
|
|
<html lang="it">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Scansiona Documenti</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family:
|
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
background: linear-gradient(to bottom, #eff6ff, #dbeafe);
|
|
min-height: 100vh;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.container {
|
|
max-width: 500px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.card {
|
|
background: white;
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
padding: 1.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.header svg {
|
|
width: 32px;
|
|
height: 32px;
|
|
color: #2563eb;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 1.5rem;
|
|
color: #1f2937;
|
|
}
|
|
|
|
.subtitle {
|
|
font-size: 0.875rem;
|
|
color: #6b7280;
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: #374151;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.doc-type-btn {
|
|
width: 100%;
|
|
padding: 1rem;
|
|
text-align: left;
|
|
border: 2px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
background: white;
|
|
cursor: pointer;
|
|
margin-bottom: 0.5rem;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.doc-type-btn:hover {
|
|
border-color: #2563eb;
|
|
background: #eff6ff;
|
|
}
|
|
|
|
.doc-type-btn strong {
|
|
display: block;
|
|
color: #1f2937;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.doc-type-btn small {
|
|
color: #6b7280;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.capture-area {
|
|
border: 2px dashed #d1d5db;
|
|
border-radius: 8px;
|
|
padding: 2rem;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.capture-area:hover:not(.disabled) {
|
|
border-color: #2563eb;
|
|
background: #eff6ff;
|
|
}
|
|
|
|
.capture-area.disabled {
|
|
background: #f9fafb;
|
|
cursor: not-allowed;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.capture-area svg {
|
|
width: 48px;
|
|
height: 48px;
|
|
color: #9ca3af;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.capture-area.disabled svg {
|
|
color: #d1d5db;
|
|
}
|
|
|
|
.capture-area p {
|
|
font-size: 0.875rem;
|
|
color: #6b7280;
|
|
}
|
|
|
|
.preview-container {
|
|
position: relative;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.preview-container img {
|
|
width: 100%;
|
|
border-radius: 8px;
|
|
border: 2px solid #10b981;
|
|
}
|
|
|
|
.preview-container button {
|
|
position: absolute;
|
|
top: 0.5rem;
|
|
right: 0.5rem;
|
|
background: #ef4444;
|
|
color: white;
|
|
border: none;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 8px;
|
|
font-size: 0.875rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.preview-container button:hover {
|
|
background: #dc2626;
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.section-header svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
color: #10b981;
|
|
}
|
|
|
|
.btn {
|
|
width: 100%;
|
|
padding: 0.875rem;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #2563eb;
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover:not(:disabled) {
|
|
background: #1d4ed8;
|
|
}
|
|
|
|
.btn-primary:disabled {
|
|
background: #d1d5db;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #e5e7eb;
|
|
color: #374151;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: #d1d5db;
|
|
}
|
|
|
|
.btn-text {
|
|
background: none;
|
|
border: none;
|
|
color: #2563eb;
|
|
font-size: 0.875rem;
|
|
cursor: pointer;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.btn-text:hover {
|
|
color: #1d4ed8;
|
|
}
|
|
|
|
.message {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.875rem;
|
|
border-radius: 8px;
|
|
margin-bottom: 1rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.message svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.message.success {
|
|
background: #d1fae5;
|
|
color: #065f46;
|
|
}
|
|
|
|
.message.error {
|
|
background: #fee2e2;
|
|
color: #991b1b;
|
|
}
|
|
|
|
.message.info {
|
|
background: #dbeafe;
|
|
color: #1e40af;
|
|
}
|
|
|
|
.hidden {
|
|
display: none;
|
|
}
|
|
|
|
input[type="file"] {
|
|
display: none;
|
|
}
|
|
|
|
.processing-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.processing-overlay.hidden {
|
|
display: none !important;
|
|
}
|
|
|
|
.processing-content {
|
|
background: white;
|
|
padding: 2rem;
|
|
border-radius: 12px;
|
|
text-align: center;
|
|
max-width: 90%;
|
|
}
|
|
|
|
.spinner {
|
|
border: 4px solid #e5e7eb;
|
|
border-top: 4px solid #2563eb;
|
|
border-radius: 50%;
|
|
width: 40px;
|
|
height: 40px;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto 1rem;
|
|
}
|
|
|
|
@keyframes spin {
|
|
0% {
|
|
transform: rotate(0deg);
|
|
}
|
|
100% {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.detection-preview {
|
|
margin-top: 1rem;
|
|
max-width: 400px;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.detection-preview img {
|
|
width: 100%;
|
|
height: auto;
|
|
}
|
|
|
|
.detection-status {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
margin-top: 1rem;
|
|
color: #10b981;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.detection-status svg {
|
|
width: 24px;
|
|
height: 24px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<!-- Header -->
|
|
<div class="card">
|
|
<div class="header">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
|
/>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
|
/>
|
|
</svg>
|
|
<div>
|
|
<h1>Scansiona Documenti</h1>
|
|
<p class="subtitle">
|
|
Scansiona i tuoi documenti in modo sicuro
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Document Type Selection -->
|
|
<div id="docTypeSection" class="card">
|
|
<h2
|
|
style="
|
|
font-size: 1.125rem;
|
|
margin-bottom: 1rem;
|
|
color: #1f2937;
|
|
"
|
|
>
|
|
Seleziona il tipo di documento
|
|
</h2>
|
|
<button
|
|
class="doc-type-btn"
|
|
onclick="selectDocType('carta_identita', true)"
|
|
>
|
|
<strong>Carta d'Identità</strong>
|
|
<small>Fronte e retro richiesti</small>
|
|
</button>
|
|
<button
|
|
class="doc-type-btn"
|
|
onclick="selectDocType('passaporto', false)"
|
|
>
|
|
<strong>Passaporto</strong>
|
|
<small>Solo fronte</small>
|
|
</button>
|
|
<button
|
|
class="doc-type-btn"
|
|
onclick="selectDocType('patente', true)"
|
|
>
|
|
<strong>Patente di Guida</strong>
|
|
<small>Fronte e retro richiesti</small>
|
|
</button>
|
|
<button
|
|
class="doc-type-btn"
|
|
onclick="selectDocType('permesso_soggiorno', true)"
|
|
>
|
|
<strong>Permesso di Soggiorno</strong>
|
|
<small>Fronte e retro richiesti</small>
|
|
</button>
|
|
<button
|
|
class="doc-type-btn"
|
|
onclick="selectDocType('tessera_sanitaria', false)"
|
|
>
|
|
<strong>Tessera Sanitaria</strong>
|
|
<small>Solo fronte</small>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Scanner Section -->
|
|
<div id="scannerSection" class="card hidden">
|
|
<div
|
|
style="
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1rem;
|
|
"
|
|
>
|
|
<h2
|
|
id="selectedDocTitle"
|
|
style="font-size: 1.125rem; color: #1f2937"
|
|
></h2>
|
|
<button class="btn-text" onclick="changeDocument()">
|
|
Cambia documento
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Front Image -->
|
|
<div>
|
|
<div class="section-header">
|
|
<label>Fronte documento</label>
|
|
<svg
|
|
id="frontCheck"
|
|
class="hidden"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
|
|
<div
|
|
id="frontCaptureArea"
|
|
class="capture-area"
|
|
onclick="document.getElementById('frontInput').click()"
|
|
>
|
|
<svg
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
|
/>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
|
/>
|
|
</svg>
|
|
<p>Tocca per scansionare il fronte</p>
|
|
</div>
|
|
|
|
<div id="frontPreview" class="preview-container hidden">
|
|
<img id="frontImage" src="" alt="Fronte documento" />
|
|
<button onclick="resetFront()">Rifai</button>
|
|
</div>
|
|
|
|
<input
|
|
type="file"
|
|
id="frontInput"
|
|
accept="image/*"
|
|
capture="environment"
|
|
onchange="handleImageCapture(this, 'front')"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Back Image -->
|
|
<div id="backSection" class="hidden">
|
|
<div class="section-header">
|
|
<label>Retro documento</label>
|
|
<svg
|
|
id="backCheck"
|
|
class="hidden"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
|
|
<div id="backCaptureArea" class="capture-area disabled">
|
|
<svg
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
|
/>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
|
/>
|
|
</svg>
|
|
<p id="backCaptureText">Scansiona prima il fronte</p>
|
|
</div>
|
|
|
|
<div id="backPreview" class="preview-container hidden">
|
|
<img id="backImage" src="" alt="Retro documento" />
|
|
<button onclick="resetBack()">Rifai</button>
|
|
</div>
|
|
|
|
<input
|
|
type="file"
|
|
id="backInput"
|
|
accept="image/*"
|
|
capture="environment"
|
|
onchange="handleImageCapture(this, 'back')"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Message -->
|
|
<div id="messageBox" class="hidden"></div>
|
|
|
|
<!-- Action Buttons -->
|
|
<button
|
|
id="submitBtn"
|
|
class="btn btn-primary"
|
|
onclick="submitDocument()"
|
|
disabled
|
|
>
|
|
Invia Documento
|
|
</button>
|
|
<button class="btn btn-secondary" onclick="resetScanner()">
|
|
Ricomincia
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Processing Overlay -->
|
|
<div id="processingOverlay" class="processing-overlay hidden">
|
|
<div class="processing-content">
|
|
<div class="spinner"></div>
|
|
<p
|
|
id="processingTitle"
|
|
style="color: #1f2937; font-weight: 500"
|
|
>
|
|
Elaborazione in corso...
|
|
</p>
|
|
<p
|
|
id="processingSubtitle"
|
|
style="
|
|
color: #6b7280;
|
|
font-size: 0.875rem;
|
|
margin-top: 0.5rem;
|
|
"
|
|
>
|
|
Rilevamento bordi documento
|
|
</p>
|
|
<div
|
|
id="detectionPreview"
|
|
class="detection-preview hidden"
|
|
></div>
|
|
<div id="detectionStatus" class="detection-status hidden"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://docs.opencv.org/4.5.2/opencv.js"></script>
|
|
<script>
|
|
const WEBHOOK_URL =
|
|
"https://n8n-prod.commandware.com/webhook/cai_kyc_upload";
|
|
|
|
let cvReady = false;
|
|
|
|
// Attendi che OpenCV sia pronto
|
|
function waitForOpenCV() {
|
|
return new Promise((resolve) => {
|
|
if (typeof cv !== "undefined" && cv.Mat) {
|
|
cvReady = true;
|
|
console.log("✅ OpenCV caricato");
|
|
resolve();
|
|
} else {
|
|
setTimeout(() => waitForOpenCV().then(resolve), 100);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Inizializza OpenCV all'avvio
|
|
waitForOpenCV();
|
|
|
|
let state = {
|
|
documentType: "",
|
|
documentLabel: "",
|
|
needsBack: false,
|
|
frontImage: null,
|
|
backImage: null,
|
|
};
|
|
|
|
const docTypes = {
|
|
carta_identita: "Carta d'Identità",
|
|
passaporto: "Passaporto",
|
|
patente: "Patente di Guida",
|
|
permesso_soggiorno: "Permesso di Soggiorno",
|
|
tessera_sanitaria: "Tessera Sanitaria",
|
|
};
|
|
|
|
function selectDocType(type, needsBack) {
|
|
state.documentType = type;
|
|
state.documentLabel = docTypes[type];
|
|
state.needsBack = needsBack;
|
|
|
|
document
|
|
.getElementById("docTypeSection")
|
|
.classList.add("hidden");
|
|
document
|
|
.getElementById("scannerSection")
|
|
.classList.remove("hidden");
|
|
document.getElementById("selectedDocTitle").textContent =
|
|
state.documentLabel;
|
|
|
|
if (needsBack) {
|
|
document
|
|
.getElementById("backSection")
|
|
.classList.remove("hidden");
|
|
} else {
|
|
document
|
|
.getElementById("backSection")
|
|
.classList.add("hidden");
|
|
}
|
|
}
|
|
|
|
function changeDocument() {
|
|
resetScanner();
|
|
state.documentType = "";
|
|
state.documentLabel = "";
|
|
state.needsBack = false;
|
|
|
|
document
|
|
.getElementById("scannerSection")
|
|
.classList.add("hidden");
|
|
document
|
|
.getElementById("docTypeSection")
|
|
.classList.remove("hidden");
|
|
}
|
|
|
|
function showProcessing(
|
|
title = "Elaborazione in corso...",
|
|
subtitle = "Rilevamento bordi documento",
|
|
) {
|
|
document.getElementById("processingTitle").textContent = title;
|
|
document.getElementById("processingSubtitle").textContent =
|
|
subtitle;
|
|
document
|
|
.getElementById("detectionPreview")
|
|
.classList.add("hidden");
|
|
document
|
|
.getElementById("detectionStatus")
|
|
.classList.add("hidden");
|
|
document
|
|
.getElementById("processingOverlay")
|
|
.classList.remove("hidden");
|
|
}
|
|
|
|
function hideProcessing() {
|
|
document
|
|
.getElementById("processingOverlay")
|
|
.classList.add("hidden");
|
|
}
|
|
|
|
function showDetectionPreview(imageData, detected) {
|
|
const preview = document.getElementById("detectionPreview");
|
|
const status = document.getElementById("detectionStatus");
|
|
|
|
preview.innerHTML = `<img src="${imageData}" alt="Anteprima rilevamento">`;
|
|
preview.classList.remove("hidden");
|
|
|
|
if (detected) {
|
|
status.innerHTML = `
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<span>Documento rilevato!</span>
|
|
`;
|
|
status.style.color = "#10b981";
|
|
} else {
|
|
status.innerHTML = `
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<span>Uso immagine originale</span>
|
|
`;
|
|
status.style.color = "#f59e0b";
|
|
}
|
|
status.classList.remove("hidden");
|
|
}
|
|
|
|
// Funzione avanzata per rilevare e ritagliare il documento
|
|
function detectAndCropDocument(imageSrc) {
|
|
return new Promise((resolve) => {
|
|
if (!cvReady) {
|
|
console.log(
|
|
"OpenCV non pronto, uso immagine originale",
|
|
);
|
|
resolve(imageSrc);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const img = new Image();
|
|
img.onload = function () {
|
|
try {
|
|
// Crea canvas e carica l'immagine
|
|
const canvas = document.createElement("canvas");
|
|
const maxDim = 1600; // Aumentato per maggiore qualità
|
|
let width = img.width;
|
|
let height = img.height;
|
|
|
|
if (width > maxDim || height > maxDim) {
|
|
if (width > height) {
|
|
height = (height / width) * maxDim;
|
|
width = maxDim;
|
|
} else {
|
|
width = (width / height) * maxDim;
|
|
height = maxDim;
|
|
}
|
|
}
|
|
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
|
|
// Converti in Mat
|
|
let src = cv.imread(canvas);
|
|
let gray = new cv.Mat();
|
|
let blurred = new cv.Mat();
|
|
let edges = new cv.Mat();
|
|
let dilated = new cv.Mat();
|
|
let contours = new cv.MatVector();
|
|
let hierarchy = new cv.Mat();
|
|
|
|
// MIGLIORAMENTO 1: Pipeline di preprocessing più robusta
|
|
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0);
|
|
|
|
// Equalizzazione istogramma per migliorare il contrasto
|
|
cv.equalizeHist(gray, gray);
|
|
|
|
// Filtro bilaterale per ridurre il rumore mantenendo i bordi
|
|
cv.bilateralFilter(
|
|
gray,
|
|
blurred,
|
|
9,
|
|
75,
|
|
75,
|
|
cv.BORDER_DEFAULT,
|
|
);
|
|
|
|
// MIGLIORAMENTO 2: Edge detection adattivo con più tentativi
|
|
let documentFound = false;
|
|
let bestContour = null;
|
|
let bestApprox = null;
|
|
let bestArea = 0;
|
|
|
|
// Prova con diversi threshold di Canny
|
|
const cannyParams = [
|
|
[30, 100], // Soglia bassa - per documenti con poco contrasto
|
|
[50, 150], // Soglia media - standard
|
|
[75, 200], // Soglia alta - per sfondi molto contrastati
|
|
];
|
|
|
|
for (let [low, high] of cannyParams) {
|
|
let tempEdges = new cv.Mat();
|
|
let tempDilated = new cv.Mat();
|
|
let tempContours = new cv.MatVector();
|
|
let tempHierarchy = new cv.Mat();
|
|
|
|
cv.Canny(
|
|
blurred,
|
|
tempEdges,
|
|
low,
|
|
high,
|
|
3,
|
|
false,
|
|
);
|
|
|
|
// Dilata per chiudere gap nei bordi
|
|
let kernel = cv.getStructuringElement(
|
|
cv.MORPH_RECT,
|
|
new cv.Size(5, 5),
|
|
);
|
|
cv.dilate(tempEdges, tempDilated, kernel);
|
|
kernel.delete();
|
|
|
|
cv.findContours(
|
|
tempDilated,
|
|
tempContours,
|
|
tempHierarchy,
|
|
cv.RETR_EXTERNAL,
|
|
cv.CHAIN_APPROX_SIMPLE,
|
|
);
|
|
|
|
console.log(
|
|
`Canny [${low},${high}]: trovati ${tempContours.size()} contorni`,
|
|
);
|
|
|
|
// MIGLIORAMENTO 3: Criteri più intelligenti per la selezione del contorno
|
|
let minArea = width * height * 0.15; // Ridotto al 15%
|
|
let maxArea = width * height * 0.95; // Max 95%
|
|
|
|
for (
|
|
let i = 0;
|
|
i < tempContours.size();
|
|
i++
|
|
) {
|
|
let cnt = tempContours.get(i);
|
|
let area = cv.contourArea(cnt, false);
|
|
|
|
if (area < minArea || area > maxArea)
|
|
continue;
|
|
|
|
let peri = cv.arcLength(cnt, true);
|
|
let approx = new cv.Mat();
|
|
|
|
// Prova diverse tolleranze di approssimazione
|
|
for (let epsilon of [
|
|
0.02, 0.03, 0.04, 0.05,
|
|
]) {
|
|
cv.approxPolyDP(
|
|
cnt,
|
|
approx,
|
|
epsilon * peri,
|
|
true,
|
|
);
|
|
|
|
// Cerchiamo un quadrilatero
|
|
if (approx.rows === 4) {
|
|
// Verifica che sia un quadrilatero "ragionevole"
|
|
if (
|
|
isValidQuadrilateral(
|
|
approx,
|
|
width,
|
|
height,
|
|
)
|
|
) {
|
|
// Calcola i punti del contorno
|
|
let points = [];
|
|
for (let j = 0; j < 4; j++) {
|
|
points.push({
|
|
x: approx.data32S[j * 2],
|
|
y: approx.data32S[j * 2 + 1],
|
|
});
|
|
}
|
|
|
|
// Calcola centralità
|
|
let centralityScore = calculateCentralityScore(
|
|
points,
|
|
width,
|
|
height,
|
|
);
|
|
|
|
// Score combinato: 70% area, 30% centralità
|
|
let normalizedArea = area / (width * height);
|
|
let combinedScore =
|
|
(normalizedArea * 0.7) +
|
|
(centralityScore * 0.3);
|
|
|
|
// Usa score combinato invece di solo area
|
|
let currentBestScore = bestArea / (width * height) * 0.7;
|
|
if (bestApprox) {
|
|
let bestPoints = [];
|
|
for (let j = 0; j < 4; j++) {
|
|
bestPoints.push({
|
|
x: bestApprox.data32S[j * 2],
|
|
y: bestApprox.data32S[j * 2 + 1],
|
|
});
|
|
}
|
|
let bestCentrality = calculateCentralityScore(
|
|
bestPoints,
|
|
width,
|
|
height,
|
|
);
|
|
currentBestScore += bestCentrality * 0.3;
|
|
}
|
|
|
|
if (combinedScore > currentBestScore) {
|
|
console.log(
|
|
`📍 Nuovo miglior contorno - Area: ${area.toFixed(0)}, ` +
|
|
`Centralità: ${(centralityScore * 100).toFixed(1)}%, ` +
|
|
`Score: ${(combinedScore * 100).toFixed(1)}%`
|
|
);
|
|
bestArea = area;
|
|
if (bestApprox)
|
|
bestApprox.delete();
|
|
bestApprox =
|
|
approx.clone();
|
|
bestContour =
|
|
cnt.clone();
|
|
documentFound = true;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
approx.delete();
|
|
}
|
|
|
|
tempEdges.delete();
|
|
tempDilated.delete();
|
|
tempContours.delete();
|
|
tempHierarchy.delete();
|
|
|
|
// Se abbiamo trovato un buon documento, fermiamoci
|
|
if (
|
|
documentFound &&
|
|
bestArea > width * height * 0.3
|
|
) {
|
|
console.log(
|
|
`✅ Documento trovato con parametri Canny [${low},${high}]`,
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
let result = src.clone();
|
|
|
|
if (documentFound && bestApprox) {
|
|
console.log(
|
|
`✅ Documento rilevato! Area: ${bestArea.toFixed(0)} px²`,
|
|
);
|
|
|
|
// Estrai i 4 punti
|
|
let points = [];
|
|
for (let i = 0; i < 4; i++) {
|
|
points.push({
|
|
x: bestApprox.data32S[i * 2],
|
|
y: bestApprox.data32S[i * 2 + 1],
|
|
});
|
|
}
|
|
|
|
// MIGLIORAMENTO 4: Ordinamento più robusto dei punti
|
|
let ordered = orderPoints(points);
|
|
|
|
// Calcola dimensioni del documento con proporzioni corrette
|
|
let widthTop = distance(
|
|
ordered[0],
|
|
ordered[1],
|
|
);
|
|
let widthBottom = distance(
|
|
ordered[3],
|
|
ordered[2],
|
|
);
|
|
let maxWidth = Math.max(
|
|
widthTop,
|
|
widthBottom,
|
|
);
|
|
|
|
let heightLeft = distance(
|
|
ordered[0],
|
|
ordered[3],
|
|
);
|
|
let heightRight = distance(
|
|
ordered[1],
|
|
ordered[2],
|
|
);
|
|
let maxHeight = Math.max(
|
|
heightLeft,
|
|
heightRight,
|
|
);
|
|
|
|
// Punti di origine e destinazione
|
|
let srcPoints = cv.matFromArray(
|
|
4,
|
|
1,
|
|
cv.CV_32FC2,
|
|
[
|
|
ordered[0].x,
|
|
ordered[0].y,
|
|
ordered[1].x,
|
|
ordered[1].y,
|
|
ordered[2].x,
|
|
ordered[2].y,
|
|
ordered[3].x,
|
|
ordered[3].y,
|
|
],
|
|
);
|
|
|
|
let dstPoints = cv.matFromArray(
|
|
4,
|
|
1,
|
|
cv.CV_32FC2,
|
|
[
|
|
0,
|
|
0,
|
|
maxWidth,
|
|
0,
|
|
maxWidth,
|
|
maxHeight,
|
|
0,
|
|
maxHeight,
|
|
],
|
|
);
|
|
|
|
// Applica trasformazione prospettica
|
|
let transform = cv.getPerspectiveTransform(
|
|
srcPoints,
|
|
dstPoints,
|
|
);
|
|
let warped = new cv.Mat();
|
|
let dsize = new cv.Size(
|
|
maxWidth,
|
|
maxHeight,
|
|
);
|
|
cv.warpPerspective(
|
|
src,
|
|
warped,
|
|
transform,
|
|
dsize,
|
|
cv.INTER_LINEAR,
|
|
cv.BORDER_CONSTANT,
|
|
new cv.Scalar(255, 255, 255, 255),
|
|
);
|
|
|
|
// MIGLIORAMENTO 5: Post-processing per migliorare la qualità
|
|
let enhanced = new cv.Mat();
|
|
cv.cvtColor(
|
|
warped,
|
|
enhanced,
|
|
cv.COLOR_RGBA2RGB,
|
|
0,
|
|
);
|
|
|
|
// Applica sharpening
|
|
let sharpened = sharpenImage(enhanced);
|
|
|
|
result.delete();
|
|
result = sharpened;
|
|
enhanced.delete();
|
|
warped.delete();
|
|
srcPoints.delete();
|
|
dstPoints.delete();
|
|
transform.delete();
|
|
|
|
if (bestApprox) bestApprox.delete();
|
|
if (bestContour) bestContour.delete();
|
|
} else {
|
|
console.log(
|
|
"⚠️ Nessun documento rilevato, uso immagine originale",
|
|
);
|
|
}
|
|
|
|
// Converti il risultato in base64 con qualità alta
|
|
let outputCanvas =
|
|
document.createElement("canvas");
|
|
cv.imshow(outputCanvas, result);
|
|
let croppedImage = outputCanvas.toDataURL(
|
|
"image/jpeg",
|
|
0.95,
|
|
);
|
|
|
|
// Mostra anteprima del rilevamento
|
|
showDetectionPreview(
|
|
croppedImage,
|
|
documentFound,
|
|
);
|
|
|
|
// Aspetta 1.5 secondi per mostrare l'anteprima
|
|
setTimeout(() => {
|
|
// Cleanup
|
|
src.delete();
|
|
gray.delete();
|
|
blurred.delete();
|
|
result.delete();
|
|
|
|
resolve(croppedImage);
|
|
}, 1500);
|
|
} catch (error) {
|
|
console.error("Errore nel processing:", error);
|
|
resolve(imageSrc);
|
|
}
|
|
};
|
|
|
|
img.onerror = function () {
|
|
console.error("Errore nel caricamento immagine");
|
|
resolve(imageSrc);
|
|
};
|
|
|
|
img.src = imageSrc;
|
|
} catch (error) {
|
|
console.error("Errore generale:", error);
|
|
resolve(imageSrc);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Funzioni di supporto per il rilevamento documenti
|
|
function distance(p1, p2) {
|
|
return Math.sqrt(
|
|
Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2),
|
|
);
|
|
}
|
|
|
|
function orderPoints(points) {
|
|
// Ordina i punti in modo consistente: TL, TR, BR, BL
|
|
let center = {
|
|
x: points.reduce((sum, p) => sum + p.x, 0) / 4,
|
|
y: points.reduce((sum, p) => sum + p.y, 0) / 4,
|
|
};
|
|
|
|
let ordered = points
|
|
.map((p) => ({
|
|
...p,
|
|
angle: Math.atan2(p.y - center.y, p.x - center.x),
|
|
}))
|
|
.sort((a, b) => a.angle - b.angle);
|
|
|
|
// Trova il punto in alto a sinistra (quello con y minore tra i primi due)
|
|
if (ordered[0].y > ordered[3].y) {
|
|
ordered = [ordered[3], ordered[0], ordered[1], ordered[2]];
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
function isValidQuadrilateral(approx, imgWidth, imgHeight) {
|
|
// Verifica che il quadrilatero sia valido
|
|
let points = [];
|
|
for (let i = 0; i < 4; i++) {
|
|
points.push({
|
|
x: approx.data32S[i * 2],
|
|
y: approx.data32S[i * 2 + 1],
|
|
});
|
|
}
|
|
|
|
// Calcola il rapporto d'aspetto
|
|
let ordered = orderPoints(points);
|
|
let width = Math.max(
|
|
distance(ordered[0], ordered[1]),
|
|
distance(ordered[3], ordered[2]),
|
|
);
|
|
let height = Math.max(
|
|
distance(ordered[0], ordered[3]),
|
|
distance(ordered[1], ordered[2]),
|
|
);
|
|
|
|
let aspectRatio =
|
|
Math.max(width, height) / Math.min(width, height);
|
|
|
|
// I documenti di identità hanno aspect ratio tra 1.3 e 1.8 circa
|
|
// Permettiamo un range più ampio per flessibilità
|
|
if (aspectRatio > 3.0 || aspectRatio < 1.2) {
|
|
return false;
|
|
}
|
|
|
|
// MIGLIORAMENTO: Rifiuta i contorni con punti troppo vicini ai bordi
|
|
// Il documento dovrebbe essere fotografato al centro, non ai margini
|
|
let margin = Math.min(imgWidth, imgHeight) * 0.05; // 5% dei bordi
|
|
let pointsOnBorder = 0;
|
|
|
|
for (let p of points) {
|
|
if (
|
|
p.x < margin ||
|
|
p.y < margin ||
|
|
p.x > imgWidth - margin ||
|
|
p.y > imgHeight - margin
|
|
) {
|
|
pointsOnBorder++;
|
|
}
|
|
}
|
|
|
|
// Se più di 1 punto è sul bordo, probabilmente è un falso positivo
|
|
if (pointsOnBorder > 1) {
|
|
console.log(`❌ Quadrilatero scartato: ${pointsOnBorder} punti troppo vicini ai bordi`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Calcola quanto un contorno è "centrale" nell'immagine
|
|
function calculateCentralityScore(points, imgWidth, imgHeight) {
|
|
// Calcola il centro del contorno
|
|
let centerX = points.reduce((sum, p) => sum + p.x, 0) / points.length;
|
|
let centerY = points.reduce((sum, p) => sum + p.y, 0) / points.length;
|
|
|
|
// Centro dell'immagine
|
|
let imgCenterX = imgWidth / 2;
|
|
let imgCenterY = imgHeight / 2;
|
|
|
|
// Distanza dal centro (normalizzata)
|
|
let maxDist = Math.sqrt(Math.pow(imgWidth / 2, 2) + Math.pow(imgHeight / 2, 2));
|
|
let dist = Math.sqrt(
|
|
Math.pow(centerX - imgCenterX, 2) +
|
|
Math.pow(centerY - imgCenterY, 2)
|
|
);
|
|
|
|
// Score: 1.0 = perfettamente centrato, 0.0 = molto decentrato
|
|
let centralityScore = 1.0 - (dist / maxDist);
|
|
|
|
return centralityScore;
|
|
}
|
|
|
|
function sharpenImage(src) {
|
|
// Applica filtro di sharpening per migliorare la nitidezza
|
|
let kernel = cv.matFromArray(
|
|
3,
|
|
3,
|
|
cv.CV_32FC1,
|
|
[0, -1, 0, -1, 5, -1, 0, -1, 0],
|
|
);
|
|
let dst = new cv.Mat();
|
|
cv.filter2D(src, dst, cv.CV_8U, kernel);
|
|
kernel.delete();
|
|
return dst;
|
|
}
|
|
|
|
async function handleImageCapture(input, side) {
|
|
const file = input.files[0];
|
|
if (!file) return;
|
|
|
|
if (!file.type.startsWith("image/")) {
|
|
showMessage("error", "Seleziona un'immagine valida");
|
|
return;
|
|
}
|
|
|
|
showProcessing();
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = async function (e) {
|
|
const imageData = e.target.result;
|
|
|
|
// Attendi OpenCV e processa l'immagine
|
|
await waitForOpenCV();
|
|
const processedImage =
|
|
await detectAndCropDocument(imageData);
|
|
|
|
hideProcessing();
|
|
|
|
if (side === "front") {
|
|
state.frontImage = processedImage;
|
|
document.getElementById("frontImage").src =
|
|
processedImage;
|
|
document
|
|
.getElementById("frontCaptureArea")
|
|
.classList.add("hidden");
|
|
document
|
|
.getElementById("frontPreview")
|
|
.classList.remove("hidden");
|
|
document
|
|
.getElementById("frontCheck")
|
|
.classList.remove("hidden");
|
|
showMessage(
|
|
"success",
|
|
"Fronte acquisito con successo!",
|
|
);
|
|
|
|
if (state.needsBack) {
|
|
document
|
|
.getElementById("backCaptureArea")
|
|
.classList.remove("disabled");
|
|
document.getElementById("backCaptureArea").onclick =
|
|
function () {
|
|
document
|
|
.getElementById("backInput")
|
|
.click();
|
|
};
|
|
document.getElementById(
|
|
"backCaptureText",
|
|
).textContent = "Tocca per scansionare il retro";
|
|
}
|
|
} else {
|
|
state.backImage = processedImage;
|
|
document.getElementById("backImage").src =
|
|
processedImage;
|
|
document
|
|
.getElementById("backCaptureArea")
|
|
.classList.add("hidden");
|
|
document
|
|
.getElementById("backPreview")
|
|
.classList.remove("hidden");
|
|
document
|
|
.getElementById("backCheck")
|
|
.classList.remove("hidden");
|
|
showMessage("success", "Retro acquisito con successo!");
|
|
}
|
|
|
|
updateSubmitButton();
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function resetFront() {
|
|
state.frontImage = null;
|
|
document.getElementById("frontInput").value = "";
|
|
document
|
|
.getElementById("frontCaptureArea")
|
|
.classList.remove("hidden");
|
|
document.getElementById("frontPreview").classList.add("hidden");
|
|
document.getElementById("frontCheck").classList.add("hidden");
|
|
|
|
if (state.needsBack) {
|
|
resetBack();
|
|
document
|
|
.getElementById("backCaptureArea")
|
|
.classList.add("disabled");
|
|
document.getElementById("backCaptureArea").onclick = null;
|
|
document.getElementById("backCaptureText").textContent =
|
|
"Scansiona prima il fronte";
|
|
}
|
|
|
|
updateSubmitButton();
|
|
}
|
|
|
|
function resetBack() {
|
|
state.backImage = null;
|
|
document.getElementById("backInput").value = "";
|
|
document
|
|
.getElementById("backCaptureArea")
|
|
.classList.remove("hidden");
|
|
document.getElementById("backPreview").classList.add("hidden");
|
|
document.getElementById("backCheck").classList.add("hidden");
|
|
updateSubmitButton();
|
|
}
|
|
|
|
function resetScanner() {
|
|
resetFront();
|
|
hideMessage();
|
|
}
|
|
|
|
function updateSubmitButton() {
|
|
const hasFront = state.frontImage !== null;
|
|
const hasBack = !state.needsBack || state.backImage !== null;
|
|
|
|
const submitBtn = document.getElementById("submitBtn");
|
|
submitBtn.disabled = !(hasFront && hasBack);
|
|
}
|
|
|
|
function showMessage(type, text) {
|
|
const messageBox = document.getElementById("messageBox");
|
|
messageBox.className = `message ${type}`;
|
|
|
|
const icons = {
|
|
success:
|
|
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
|
|
error: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
|
|
info: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
|
|
};
|
|
|
|
messageBox.innerHTML =
|
|
icons[type] + "<span>" + text + "</span>";
|
|
messageBox.classList.remove("hidden");
|
|
|
|
if (type === "success") {
|
|
setTimeout(hideMessage, 3000);
|
|
}
|
|
}
|
|
|
|
function hideMessage() {
|
|
document.getElementById("messageBox").classList.add("hidden");
|
|
}
|
|
|
|
async function submitDocument() {
|
|
if (!state.frontImage) {
|
|
showMessage(
|
|
"error",
|
|
"Scansiona almeno il fronte del documento",
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (state.needsBack && !state.backImage) {
|
|
showMessage(
|
|
"error",
|
|
"Questo documento richiede anche il retro",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const submitBtn = document.getElementById("submitBtn");
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = "Invio in corso...";
|
|
|
|
try {
|
|
const payload = {
|
|
documentType: state.documentType,
|
|
documentLabel: state.documentLabel,
|
|
timestamp: new Date().toISOString(),
|
|
images: {
|
|
front: state.frontImage,
|
|
back: state.backImage,
|
|
},
|
|
};
|
|
|
|
const response = await fetch(WEBHOOK_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (response.ok) {
|
|
showMessage(
|
|
"success",
|
|
"Documento inviato con successo!",
|
|
);
|
|
setTimeout(() => {
|
|
changeDocument();
|
|
}, 2000);
|
|
} else {
|
|
throw new Error("Errore HTTP: " + response.status);
|
|
}
|
|
} catch (error) {
|
|
console.error("Errore invio:", error);
|
|
showMessage(
|
|
"error",
|
|
"Errore nell'invio del documento. Riprova.",
|
|
);
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = "Invia Documento";
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|