soulprint-verify 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Validador de Cédula de Ciudadanía colombiana
3
+ *
4
+ * La cédula colombiana tiene:
5
+ * - 5 a 10 dígitos numéricos
6
+ * - Emitida por Registraduría Nacional del Estado Civil
7
+ * - Serie por región/año: rangos conocidos
8
+ * - Dígito de verificación implícito en la secuencia
9
+ */
10
+ export interface DocumentValidationResult {
11
+ valid: boolean;
12
+ cedula_number?: string;
13
+ nombre?: string;
14
+ fecha_nacimiento?: string;
15
+ sexo?: "M" | "F";
16
+ errors: string[];
17
+ raw_ocr?: string;
18
+ }
19
+ export declare function validateCedulaNumber(cedula: string): {
20
+ valid: boolean;
21
+ error?: string;
22
+ };
23
+ export declare function parseCedulaOCR(ocrText: string): DocumentValidationResult;
24
+ /**
25
+ * Parsea el MRZ TD1 del reverso de la cédula colombiana digital.
26
+ * Formato: 3 líneas de 30 caracteres cada una.
27
+ *
28
+ * Línea 2: DDMMYYCSEXEXPIRYNATCHECKNUMDOC<CHECK
29
+ * - [0-5] = fecha nacimiento YYMMDD
30
+ * - [6] = dígito verificador
31
+ * - [7] = sexo M/F
32
+ * - [8-13] = fecha expiración YYMMDD
33
+ * - [14] = dígito verificador
34
+ * - [15-17] = código país (COL)
35
+ * - [18-28] = número documento (cédula)
36
+ *
37
+ * Línea 3: APELLIDOS<<NOMBRES<<<...
38
+ */
39
+ export declare function parseMRZ(mrzText: string): DocumentValidationResult;
@@ -0,0 +1,212 @@
1
+ "use strict";
2
+ /**
3
+ * Validador de Cédula de Ciudadanía colombiana
4
+ *
5
+ * La cédula colombiana tiene:
6
+ * - 5 a 10 dígitos numéricos
7
+ * - Emitida por Registraduría Nacional del Estado Civil
8
+ * - Serie por región/año: rangos conocidos
9
+ * - Dígito de verificación implícito en la secuencia
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.validateCedulaNumber = validateCedulaNumber;
13
+ exports.parseCedulaOCR = parseCedulaOCR;
14
+ exports.parseMRZ = parseMRZ;
15
+ // ── Rangos válidos de cédulas colombianas ─────────────────────────────────────
16
+ // Fuente: Registraduría Nacional — rangos históricos por décadas
17
+ const CEDULA_RANGES = [
18
+ [1_000_000, 9_999_999], // primeras series (7 dígitos)
19
+ [10_000_000, 99_999_999], // series modernas (8 dígitos)
20
+ [100_000_000, 999_999_999], // series recientes (9 dígitos)
21
+ [1_000_000_000, 1_299_999_999], // series nuevas (10 dígitos)
22
+ ];
23
+ function validateCedulaNumber(cedula) {
24
+ // Limpiar espacios y guiones
25
+ const clean = cedula.replace(/[\s\-\.]/g, "");
26
+ // Solo dígitos
27
+ if (!/^\d+$/.test(clean)) {
28
+ return { valid: false, error: "La cédula solo debe contener números" };
29
+ }
30
+ const num = parseInt(clean, 10);
31
+ // Longitud válida: 5-10 dígitos
32
+ if (clean.length < 5 || clean.length > 10) {
33
+ return { valid: false, error: `Longitud inválida: ${clean.length} dígitos (debe ser 5-10)` };
34
+ }
35
+ // No puede ser todo el mismo dígito (111111111, 000000000, etc.)
36
+ if (/^(\d)\1+$/.test(clean)) {
37
+ return { valid: false, error: "Número de cédula inválido (dígitos repetidos)" };
38
+ }
39
+ // Verificar que esté en un rango conocido (para cédulas de 7+ dígitos)
40
+ if (clean.length >= 7) {
41
+ const inRange = CEDULA_RANGES.some(([min, max]) => num >= min && num <= max);
42
+ if (!inRange) {
43
+ return { valid: false, error: "Número fuera de rangos válidos de Registraduría" };
44
+ }
45
+ }
46
+ return { valid: true };
47
+ }
48
+ // ── Parser de texto OCR de cédula colombiana ──────────────────────────────────
49
+ function parseCedulaOCR(ocrText) {
50
+ const errors = [];
51
+ const text = ocrText.toUpperCase().replace(/\s+/g, " ").trim();
52
+ // ── Extraer número de cédula ───────────────────────────────────────────────
53
+ // Patrones en cédulas colombianas:
54
+ // "C.C. 12.345.678" | "CC 12345678" | "1.234.567.890"
55
+ let cedula_number;
56
+ const cedulaPatterns = [
57
+ /NUIP\s*([0-9][0-9.\s]{6,14})/, // "NUIP1.234.567.890" — primero
58
+ /C\.?C\.?\s*([0-9][0-9.\s]{4,12})/, // "CC 12.345.678"
59
+ /CÉDULA[^0-9]*([0-9][0-9.\s]{4,12})/, // "CÉDULA DE CIUDADANÍA 12345678"
60
+ /CIUDADANÍA[^0-9]*([0-9][0-9.\s]{4,12})/,
61
+ /N[UÚ]MERO[^0-9]*([0-9][0-9.\s]{4,12})/,
62
+ /(?<![0-9])(\d{1,3}(?:\.\d{3}){2,3})(?![0-9])/, // "1.234.567.890" con puntos
63
+ /(?<![0-9])(\d{7,10})(?![0-9])/, // número largo sin formato
64
+ ];
65
+ for (const pattern of cedulaPatterns) {
66
+ const m = text.match(pattern);
67
+ if (m) {
68
+ const candidate = m[1].replace(/[\.\s]/g, "");
69
+ const validation = validateCedulaNumber(candidate);
70
+ if (validation.valid) {
71
+ cedula_number = candidate;
72
+ break;
73
+ }
74
+ }
75
+ }
76
+ if (!cedula_number) {
77
+ errors.push("No se pudo extraer un número de cédula válido del documento");
78
+ }
79
+ // ── Extraer nombre ─────────────────────────────────────────────────────────
80
+ let nombre;
81
+ // La cédula tiene: apellidos en una línea, nombres en la siguiente
82
+ const nombrePatterns = [
83
+ /APELLIDOS[:\s]+([A-ZÁÉÍÓÚÑ\s]+)\s*NOMBRES?[:\s]+([A-ZÁÉÍÓÚÑ\s]+)/,
84
+ /NOMBRES?[:\s]+([A-ZÁÉÍÓÚÑ\s]{5,50})/,
85
+ ];
86
+ for (const pattern of nombrePatterns) {
87
+ const m = text.match(pattern);
88
+ if (m) {
89
+ nombre = m[2] ? `${m[2].trim()} ${m[1].trim()}` : m[1].trim();
90
+ nombre = nombre.replace(/\s+/g, " ").trim();
91
+ break;
92
+ }
93
+ }
94
+ // ── Extraer fecha de nacimiento ────────────────────────────────────────────
95
+ let fecha_nacimiento;
96
+ const fechaPatterns = [
97
+ /FECHA\s+(?:DE\s+)?NACIMIENTO[:\s]+(\d{1,2}[\-\/]\d{1,2}[\-\/]\d{4})/,
98
+ /NACIMIENTO[:\s]+(\d{1,2}[\-\/]\d{1,2}[\-\/]\d{4})/,
99
+ /(\d{2}[\-\/]\d{2}[\-\/]\d{4})/, // DD/MM/YYYY o DD-MM-YYYY
100
+ /(\d{4}[\-\/]\d{2}[\-\/]\d{2})/, // YYYY-MM-DD
101
+ ];
102
+ for (const pattern of fechaPatterns) {
103
+ const m = text.match(pattern);
104
+ if (m) {
105
+ fecha_nacimiento = normalizeFecha(m[1]);
106
+ break;
107
+ }
108
+ }
109
+ // ── Extraer sexo ───────────────────────────────────────────────────────────
110
+ let sexo;
111
+ if (/\bSEXO\s*[:\s]\s*M\b/.test(text) || /\bMASCULINO\b/.test(text))
112
+ sexo = "M";
113
+ if (/\bSEXO\s*[:\s]\s*F\b/.test(text) || /\bFEMENINO\b/.test(text))
114
+ sexo = "F";
115
+ // ── Verificar que es una cédula colombiana ─────────────────────────────────
116
+ const esCedula = /COLOMBIA|REGISTRADURÍA|REPÚBLICA|CIUDADANÍA|C\.C\.|CÉDULA/i.test(text);
117
+ if (!esCedula) {
118
+ errors.push("El documento no parece ser una cédula colombiana");
119
+ }
120
+ return {
121
+ valid: errors.length === 0 && !!cedula_number,
122
+ cedula_number,
123
+ nombre,
124
+ fecha_nacimiento,
125
+ sexo,
126
+ errors,
127
+ raw_ocr: ocrText,
128
+ };
129
+ }
130
+ // ── MRZ TD1 parser (reverso cédula digital colombiana) ────────────────────────
131
+ /**
132
+ * Parsea el MRZ TD1 del reverso de la cédula colombiana digital.
133
+ * Formato: 3 líneas de 30 caracteres cada una.
134
+ *
135
+ * Línea 2: DDMMYYCSEXEXPIRYNATCHECKNUMDOC<CHECK
136
+ * - [0-5] = fecha nacimiento YYMMDD
137
+ * - [6] = dígito verificador
138
+ * - [7] = sexo M/F
139
+ * - [8-13] = fecha expiración YYMMDD
140
+ * - [14] = dígito verificador
141
+ * - [15-17] = código país (COL)
142
+ * - [18-28] = número documento (cédula)
143
+ *
144
+ * Línea 3: APELLIDOS<<NOMBRES<<<...
145
+ */
146
+ function parseMRZ(mrzText) {
147
+ const errors = [];
148
+ // Limpiar y encontrar líneas MRZ
149
+ const allLines = mrzText
150
+ .split("\n")
151
+ .map(l => l.replace(/[^A-Z0-9<]/gi, "").toUpperCase())
152
+ .filter(l => l.length >= 10);
153
+ const lines = allLines.filter(l => l.length >= 28);
154
+ if (lines.length < 2) {
155
+ return { valid: false, errors: ["MRZ incompleto — se necesitan al menos 2 líneas"] };
156
+ }
157
+ // Línea 2: datos biográficos
158
+ const line2 = lines.find(l => /^\d{6}[0-9<][MF<]/.test(l));
159
+ if (!line2) {
160
+ return { valid: false, errors: ["No se encontró línea MRZ con datos biográficos"] };
161
+ }
162
+ const yy = line2.slice(0, 2);
163
+ const mm = line2.slice(2, 4);
164
+ const dd = line2.slice(4, 6);
165
+ const sex = line2[7];
166
+ // Inferir siglo (si YY > 24 → 19xx, si <= 24 → 20xx)
167
+ const century = parseInt(yy) > 24 ? "19" : "20";
168
+ const fecha_nacimiento = `${century}${yy}-${mm}-${dd}`;
169
+ // Número de cédula: aparece en línea 2 posición 18-27 (o en línea 1)
170
+ const docNumRaw = line2.slice(18, 29).replace(/</g, "").trim();
171
+ const docNum = docNumRaw.replace(/^0+/, ""); // quitar ceros a la izquierda
172
+ const numValidation = validateCedulaNumber(docNum);
173
+ // Línea 3: nombre — buscar en TODAS las líneas (puede ser más corta)
174
+ const line3 = allLines.find(l => l.includes("<<") &&
175
+ !/^\d{6}/.test(l) &&
176
+ /^[A-Z]{3,}<</.test(l));
177
+ let nombre;
178
+ if (line3) {
179
+ const parts = line3.split("<<");
180
+ const apellido = parts[0]?.replace(/</g, " ").trim();
181
+ const nombres = parts.slice(1).join(" ").replace(/</g, " ").replace(/\s+/g, " ").trim();
182
+ nombre = nombres && apellido ? `${nombres} ${apellido}`.trim() : (apellido || nombres);
183
+ }
184
+ if (!numValidation.valid) {
185
+ errors.push(`Número en MRZ inválido: ${numValidation.error}`);
186
+ }
187
+ return {
188
+ valid: errors.length === 0,
189
+ cedula_number: numValidation.valid ? docNum : undefined,
190
+ nombre,
191
+ fecha_nacimiento,
192
+ sexo: sex === "M" || sex === "F" ? sex : undefined,
193
+ errors,
194
+ raw_ocr: mrzText,
195
+ };
196
+ }
197
+ function normalizeFecha(fecha) {
198
+ // Normalizar a YYYY-MM-DD
199
+ const clean = fecha.replace(/\//g, "-");
200
+ const parts = clean.split("-");
201
+ if (parts.length !== 3)
202
+ return fecha;
203
+ if (parts[0].length === 4) {
204
+ // YYYY-MM-DD
205
+ return clean;
206
+ }
207
+ else {
208
+ // DD-MM-YYYY → YYYY-MM-DD
209
+ const [d, m, y] = parts;
210
+ return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
211
+ }
212
+ }
@@ -0,0 +1,20 @@
1
+ import { DocumentValidationResult } from "./cedula-validator.js";
2
+ export interface OCROptions {
3
+ lang?: string;
4
+ verbose?: boolean;
5
+ }
6
+ /**
7
+ * Extrae y valida datos de una cédula colombiana desde una imagen.
8
+ *
9
+ * On-demand: Tesseract.js carga el worker solo cuando se llama,
10
+ * y se termina al final. No hay proceso persistente.
11
+ */
12
+ export declare function ocrCedula(imagePath: string, opts?: OCROptions): Promise<DocumentValidationResult>;
13
+ /**
14
+ * Valida que la imagen parece ser una cédula antes de hacer OCR completo.
15
+ * Revisión superficial de dimensiones y tamaño — no carga Tesseract.
16
+ */
17
+ export declare function quickValidateImage(imagePath: string): Promise<{
18
+ valid: boolean;
19
+ error?: string;
20
+ }>;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ocrCedula = ocrCedula;
7
+ exports.quickValidateImage = quickValidateImage;
8
+ const tesseract_js_1 = __importDefault(require("tesseract.js"));
9
+ const cedula_validator_js_1 = require("./cedula-validator.js");
10
+ /**
11
+ * Extrae y valida datos de una cédula colombiana desde una imagen.
12
+ *
13
+ * On-demand: Tesseract.js carga el worker solo cuando se llama,
14
+ * y se termina al final. No hay proceso persistente.
15
+ */
16
+ async function ocrCedula(imagePath, opts = {}) {
17
+ const lang = opts.lang ?? "spa";
18
+ // Tesseract carga on-demand, se termina después de extraer
19
+ const { data: { text } } = await tesseract_js_1.default.recognize(imagePath, lang, {
20
+ logger: opts.verbose
21
+ ? (m) => process.stderr.write(`[OCR] ${m.status} ${Math.round((m.progress ?? 0) * 100)}%\r`)
22
+ : undefined,
23
+ });
24
+ if (opts.verbose)
25
+ process.stderr.write("\n");
26
+ return (0, cedula_validator_js_1.parseCedulaOCR)(text);
27
+ }
28
+ /**
29
+ * Valida que la imagen parece ser una cédula antes de hacer OCR completo.
30
+ * Revisión superficial de dimensiones y tamaño — no carga Tesseract.
31
+ */
32
+ async function quickValidateImage(imagePath) {
33
+ try {
34
+ // Dynamic import de sharp para no cargar si no es necesario
35
+ const sharp = (await import("sharp")).default;
36
+ const meta = await sharp(imagePath).metadata();
37
+ if (!meta.width || !meta.height) {
38
+ return { valid: false, error: "No se pudo leer las dimensiones de la imagen" };
39
+ }
40
+ // Cédula colombiana: proporción ~1.59:1 (85.6mm × 53.98mm, formato ID-1 ISO 7810)
41
+ const ratio = meta.width / meta.height;
42
+ const isLandscape = meta.width > meta.height;
43
+ if (!isLandscape) {
44
+ return { valid: false, error: "La imagen debe estar en horizontal (la cédula es apaisada)" };
45
+ }
46
+ if (ratio < 1.2 || ratio > 2.0) {
47
+ return { valid: false, error: `Proporción de imagen inusual (${ratio.toFixed(2)}). Asegúrate de fotografiar solo la cédula` };
48
+ }
49
+ // Mínimo 400x250 para OCR confiable
50
+ if (meta.width < 400 || meta.height < 250) {
51
+ return { valid: false, error: "Imagen muy pequeña. Usa al menos 400×250 píxeles" };
52
+ }
53
+ return { valid: true };
54
+ }
55
+ catch (e) {
56
+ return { valid: false, error: `Error leyendo imagen: ${e.message}` };
57
+ }
58
+ }
@@ -0,0 +1,25 @@
1
+ export interface FaceMatchResult {
2
+ match: boolean;
3
+ similarity: number;
4
+ embedding?: number[];
5
+ liveness?: boolean;
6
+ errors: string[];
7
+ }
8
+ export interface FaceMatchOptions {
9
+ minSimilarity?: number;
10
+ checkLiveness?: boolean;
11
+ verbose?: boolean;
12
+ }
13
+ /**
14
+ * Compara la cara de un selfie con la foto del documento de identidad.
15
+ *
16
+ * ARQUITECTURA ON-DEMAND:
17
+ * - Lanza un subprocess Python con InsightFace
18
+ * - InsightFace (~500MB) se carga SOLO durante esta llamada
19
+ * - El proceso termina al final → memoria completamente liberada
20
+ * - En reposo: 0MB de modelos ML en memoria
21
+ *
22
+ * @param selfiePhoto Path a la foto selfie
23
+ * @param documentPhoto Path a la foto del documento (cédula)
24
+ */
25
+ export declare function matchFaceWithDocument(selfiePhoto: string, documentPhoto: string, opts?: FaceMatchOptions): Promise<FaceMatchResult>;
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.matchFaceWithDocument = matchFaceWithDocument;
4
+ const node_child_process_1 = require("node:child_process");
5
+ const node_path_1 = require("node:path");
6
+ const node_fs_1 = require("node:fs");
7
+ // Path al script Python que corre on-demand
8
+ const PYTHON_SCRIPT = (0, node_path_1.join)(__dirname, "face_match.py");
9
+ /**
10
+ * Compara la cara de un selfie con la foto del documento de identidad.
11
+ *
12
+ * ARQUITECTURA ON-DEMAND:
13
+ * - Lanza un subprocess Python con InsightFace
14
+ * - InsightFace (~500MB) se carga SOLO durante esta llamada
15
+ * - El proceso termina al final → memoria completamente liberada
16
+ * - En reposo: 0MB de modelos ML en memoria
17
+ *
18
+ * @param selfiePhoto Path a la foto selfie
19
+ * @param documentPhoto Path a la foto del documento (cédula)
20
+ */
21
+ async function matchFaceWithDocument(selfiePhoto, documentPhoto, opts = {}) {
22
+ const minSim = opts.minSimilarity ?? 0.65;
23
+ // Verificar que existe el script Python
24
+ if (!(0, node_fs_1.existsSync)(PYTHON_SCRIPT)) {
25
+ return {
26
+ match: false,
27
+ similarity: 0,
28
+ errors: [`Script Python no encontrado: ${PYTHON_SCRIPT}. Ejecuta: soulprint install-deps`],
29
+ };
30
+ }
31
+ // Verificar que Python e InsightFace están disponibles
32
+ const pythonCheck = await checkPythonDeps();
33
+ if (!pythonCheck.ok) {
34
+ return {
35
+ match: false,
36
+ similarity: 0,
37
+ errors: [pythonCheck.error],
38
+ };
39
+ }
40
+ return new Promise((resolve) => {
41
+ const args = [
42
+ PYTHON_SCRIPT,
43
+ "--selfie", selfiePhoto,
44
+ "--document", documentPhoto,
45
+ "--min-sim", String(minSim),
46
+ ];
47
+ if (opts.checkLiveness)
48
+ args.push("--liveness");
49
+ // Lanzar subprocess — muere solo cuando termina
50
+ const proc = (0, node_child_process_1.spawn)("python3", args, {
51
+ stdio: ["ignore", "pipe", opts.verbose ? "inherit" : "pipe"],
52
+ });
53
+ let stdout = "";
54
+ let stderr = "";
55
+ proc.stdout?.on("data", (d) => stdout += d.toString());
56
+ if (!opts.verbose && proc.stderr) {
57
+ proc.stderr.on("data", (d) => stderr += d.toString());
58
+ }
59
+ proc.on("close", (code) => {
60
+ if (code !== 0) {
61
+ resolve({
62
+ match: false,
63
+ similarity: 0,
64
+ errors: [`Proceso de verificación falló (código ${code}): ${stderr.slice(0, 200)}`],
65
+ });
66
+ return;
67
+ }
68
+ try {
69
+ const result = JSON.parse(stdout.trim());
70
+ resolve({
71
+ match: result.match ?? false,
72
+ similarity: result.similarity ?? 0,
73
+ embedding: result.embedding,
74
+ liveness: result.liveness,
75
+ errors: result.errors ?? [],
76
+ });
77
+ }
78
+ catch {
79
+ resolve({
80
+ match: false,
81
+ similarity: 0,
82
+ errors: ["Error parseando resultado de verificación facial"],
83
+ });
84
+ }
85
+ });
86
+ proc.on("error", (err) => {
87
+ resolve({
88
+ match: false,
89
+ similarity: 0,
90
+ errors: [`No se pudo lanzar Python: ${err.message}. Instala Python 3.8+`],
91
+ });
92
+ });
93
+ });
94
+ }
95
+ // ── Verificar dependencias Python ─────────────────────────────────────────────
96
+ async function checkPythonDeps() {
97
+ return new Promise((resolve) => {
98
+ const proc = (0, node_child_process_1.spawn)("python3", ["-c", "import insightface; import cv2; print('ok')"], {
99
+ stdio: ["ignore", "pipe", "pipe"],
100
+ });
101
+ let out = "";
102
+ proc.stdout.on("data", (d) => out += d.toString());
103
+ proc.on("close", (code) => {
104
+ if (code === 0 && out.includes("ok")) {
105
+ resolve({ ok: true });
106
+ }
107
+ else {
108
+ resolve({
109
+ ok: false,
110
+ error: "InsightFace no instalado. Ejecuta: pip install insightface opencv-python-headless",
111
+ });
112
+ }
113
+ });
114
+ proc.on("error", () => resolve({
115
+ ok: false,
116
+ error: "Python3 no encontrado. Instala Python 3.8+ para verificación facial.",
117
+ }));
118
+ });
119
+ }
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ face_match.py — Soulprint on-demand face verification
4
+
5
+ Este script:
6
+ 1. Se lanza como subprocess desde TypeScript
7
+ 2. Carga InsightFace (500MB, ~4s)
8
+ 3. Compara selfie vs foto de documento
9
+ 4. Imprime resultado JSON por stdout
10
+ 5. TERMINA — la memoria se libera completamente
11
+
12
+ No hay proceso persistente. Cada verificación es un proceso nuevo.
13
+ """
14
+
15
+ import sys
16
+ import json
17
+ import argparse
18
+ import os
19
+
20
+ # Silenciar warnings de TensorFlow/ONNX antes de importar
21
+ os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
22
+ os.environ["ONNXRUNTIME_LOG_LEVEL"] = "3"
23
+
24
+
25
+ def eprint(*args):
26
+ """Logs al stderr para no contaminar stdout (que es el canal JSON)"""
27
+ print(*args, file=sys.stderr)
28
+
29
+
30
+ def load_models():
31
+ """Carga InsightFace on-demand. Solo se llama una vez por proceso."""
32
+ try:
33
+ import insightface
34
+ from insightface.app import FaceAnalysis
35
+
36
+ eprint("[soulprint] Cargando modelo de reconocimiento facial...")
37
+ app = FaceAnalysis(
38
+ name="buffalo_sc", # modelo ligero (~50MB vs buffalo_l 500MB)
39
+ providers=["CPUExecutionProvider"],
40
+ allowed_modules=["detection", "recognition"],
41
+ )
42
+ app.prepare(ctx_id=0, det_size=(320, 320)) # resolución reducida = más rápido
43
+ eprint("[soulprint] Modelo listo")
44
+ return app
45
+ except ImportError as e:
46
+ print(json.dumps({
47
+ "match": False,
48
+ "similarity": 0,
49
+ "errors": [f"InsightFace no disponible: {e}. Instala con: pip install insightface"],
50
+ }))
51
+ sys.exit(1)
52
+
53
+
54
+ def get_face_embedding(app, image_path: str):
55
+ """Extrae el embedding de la cara principal en una imagen."""
56
+ import cv2
57
+ import numpy as np
58
+
59
+ img = cv2.imread(image_path)
60
+ if img is None:
61
+ return None, f"No se pudo leer la imagen: {image_path}"
62
+
63
+ faces = app.get(img)
64
+ if not faces:
65
+ return None, "No se detectó ninguna cara en la imagen"
66
+
67
+ # Tomar la cara más grande (la principal)
68
+ face = max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1]))
69
+
70
+ return face.embedding, None
71
+
72
+
73
+ def cosine_similarity(a, b) -> float:
74
+ """Similitud coseno entre dos embeddings. Rango: -1 a 1."""
75
+ import numpy as np
76
+ a = a / (np.linalg.norm(a) + 1e-8)
77
+ b = b / (np.linalg.norm(b) + 1e-8)
78
+ return float(np.dot(a, b))
79
+
80
+
81
+ def quantize_embedding(embedding, precision: int = 2):
82
+ """
83
+ Cuantiza el embedding para derivar nullifier determinístico.
84
+ Misma cara en diferentes fotos → mismo embedding cuantizado.
85
+ """
86
+ import numpy as np
87
+ factor = 10 ** precision
88
+ return (np.round(embedding * factor) / factor).tolist()
89
+
90
+
91
+ def check_liveness(app, image_path: str) -> bool:
92
+ """
93
+ Detección básica de 'foto de foto':
94
+ - Verifica que el contraste y nitidez son de foto real
95
+ - No es antispoof completo, pero filtra ataques básicos
96
+ """
97
+ import cv2
98
+ import numpy as np
99
+
100
+ img = cv2.imread(image_path)
101
+ if img is None:
102
+ return False
103
+
104
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
105
+ laplacian = cv2.Laplacian(gray, cv2.CV_64F).var()
106
+
107
+ # Fotos de pantalla tienen banding y menor nitidez
108
+ # Umbral empírico — ajustar con más datos reales
109
+ return laplacian > 80.0
110
+
111
+
112
+ def main():
113
+ parser = argparse.ArgumentParser(description="Soulprint face match")
114
+ parser.add_argument("--selfie", required=True, help="Path selfie del usuario")
115
+ parser.add_argument("--document", required=True, help="Path foto del documento")
116
+ parser.add_argument("--min-sim", type=float, default=0.65, help="Similitud mínima")
117
+ parser.add_argument("--liveness", action="store_true", help="Verificar liveness")
118
+ args = parser.parse_args()
119
+
120
+ # Verificar que los archivos existen
121
+ for path in [args.selfie, args.document]:
122
+ if not os.path.exists(path):
123
+ print(json.dumps({
124
+ "match": False, "similarity": 0,
125
+ "errors": [f"Archivo no encontrado: {path}"]
126
+ }))
127
+ sys.exit(0)
128
+
129
+ # Cargar modelo (on-demand, solo este proceso)
130
+ app = load_models()
131
+
132
+ # Extraer embeddings
133
+ selfie_emb, err1 = get_face_embedding(app, args.selfie)
134
+ if err1:
135
+ print(json.dumps({"match": False, "similarity": 0, "errors": [f"Selfie: {err1}"]}))
136
+ sys.exit(0)
137
+
138
+ doc_emb, err2 = get_face_embedding(app, args.document)
139
+ if err2:
140
+ print(json.dumps({"match": False, "similarity": 0, "errors": [f"Documento: {err2}"]}))
141
+ sys.exit(0)
142
+
143
+ # Calcular similitud
144
+ similarity = cosine_similarity(selfie_emb, doc_emb)
145
+ match = similarity >= args.min_sim
146
+
147
+ # Liveness check (opcional)
148
+ liveness = None
149
+ if args.liveness:
150
+ liveness = check_liveness(app, args.selfie)
151
+
152
+ # Embedding cuantizado para derivar nullifier (determinístico)
153
+ quantized = quantize_embedding(selfie_emb, precision=2)
154
+
155
+ result = {
156
+ "match": match,
157
+ "similarity": round(similarity, 4),
158
+ "embedding": quantized, # para nullifier — NO es el embedding raw
159
+ "errors": [],
160
+ }
161
+
162
+ if liveness is not None:
163
+ result["liveness"] = liveness
164
+
165
+ if not match:
166
+ result["errors"].append(
167
+ f"Similitud insuficiente ({similarity:.2f} < {args.min_sim}). "
168
+ "Usa una foto clara de frente con buena iluminación."
169
+ )
170
+
171
+ # Imprimir resultado por stdout (único canal con el proceso padre)
172
+ print(json.dumps(result))
173
+
174
+ # El proceso termina aquí → InsightFace se descarga de memoria automáticamente
175
+
176
+
177
+ if __name__ == "__main__":
178
+ main()
@@ -0,0 +1,29 @@
1
+ export interface VerificationOptions {
2
+ selfiePhoto: string;
3
+ documentPhoto: string;
4
+ verbose?: boolean;
5
+ minFaceSim?: number;
6
+ checkLiveness?: boolean;
7
+ withZKP?: boolean;
8
+ }
9
+ export interface VerificationResult {
10
+ success: boolean;
11
+ token?: string;
12
+ zkProof?: string;
13
+ nullifier?: string;
14
+ did?: string;
15
+ score?: number;
16
+ errors: string[];
17
+ steps: {
18
+ image_check: "ok" | "fail" | "skip";
19
+ ocr: "ok" | "fail" | "skip";
20
+ face_match: "ok" | "fail" | "skip";
21
+ nullifier_derived: "ok" | "fail" | "skip";
22
+ zk_proof: "ok" | "fail" | "skip";
23
+ token_created: "ok" | "fail" | "skip";
24
+ };
25
+ }
26
+ export declare function verifyIdentity(opts: VerificationOptions): Promise<VerificationResult>;
27
+ export { ocrCedula, quickValidateImage } from "./document/ocr.js";
28
+ export { matchFaceWithDocument } from "./face/face-match.js";
29
+ export { validateCedulaNumber, parseCedulaOCR } from "./document/cedula-validator.js";
package/dist/index.js ADDED
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseCedulaOCR = exports.validateCedulaNumber = exports.matchFaceWithDocument = exports.quickValidateImage = exports.ocrCedula = void 0;
4
+ exports.verifyIdentity = verifyIdentity;
5
+ const soulprint_core_1 = require("soulprint-core");
6
+ const ocr_js_1 = require("./document/ocr.js");
7
+ const face_match_js_1 = require("./face/face-match.js");
8
+ const node_fs_1 = require("node:fs");
9
+ const node_path_1 = require("node:path");
10
+ const node_os_1 = require("node:os");
11
+ const SOULPRINT_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".soulprint");
12
+ const KEYPAIR_FILE = (0, node_path_1.join)(SOULPRINT_DIR, "identity.json");
13
+ async function verifyIdentity(opts) {
14
+ const errors = [];
15
+ const steps = {
16
+ image_check: "skip",
17
+ ocr: "skip",
18
+ face_match: "skip",
19
+ nullifier_derived: "skip",
20
+ zk_proof: "skip",
21
+ token_created: "skip",
22
+ };
23
+ const log = (msg) => opts.verbose && process.stderr.write(`[soulprint] ${msg}\n`);
24
+ // ── PASO 1: Validar imágenes ───────────────────────────────────────────────
25
+ log("Validando imágenes...");
26
+ const selfieCheck = await (0, ocr_js_1.quickValidateImage)(opts.selfiePhoto);
27
+ const docCheck = await (0, ocr_js_1.quickValidateImage)(opts.documentPhoto);
28
+ if (!selfieCheck.valid) {
29
+ errors.push(`Selfie: ${selfieCheck.error}`);
30
+ steps.image_check = "fail";
31
+ return { success: false, errors, steps };
32
+ }
33
+ if (!docCheck.valid) {
34
+ errors.push(`Documento: ${docCheck.error}`);
35
+ steps.image_check = "fail";
36
+ return { success: false, errors, steps };
37
+ }
38
+ steps.image_check = "ok";
39
+ // ── PASO 2: OCR de cédula ─────────────────────────────────────────────────
40
+ log("Extrayendo datos del documento...");
41
+ const docResult = await (0, ocr_js_1.ocrCedula)(opts.documentPhoto, { verbose: opts.verbose });
42
+ if (!docResult.valid || !docResult.cedula_number) {
43
+ errors.push(...docResult.errors);
44
+ steps.ocr = "fail";
45
+ return { success: false, errors, steps };
46
+ }
47
+ steps.ocr = "ok";
48
+ log(`✓ Cédula: ${docResult.cedula_number}`);
49
+ // ── PASO 3: Face match (subprocess Python on-demand) ──────────────────────
50
+ log("Verificando coincidencia facial (iniciando proceso de IA)...");
51
+ const faceResult = await (0, face_match_js_1.matchFaceWithDocument)(opts.selfiePhoto, opts.documentPhoto, { minSimilarity: opts.minFaceSim ?? 0.65, checkLiveness: opts.checkLiveness, verbose: opts.verbose });
52
+ if (!faceResult.match) {
53
+ errors.push(...faceResult.errors);
54
+ steps.face_match = "fail";
55
+ return { success: false, errors, steps };
56
+ }
57
+ steps.face_match = "ok";
58
+ log(`✓ Cara coincide (similitud: ${(faceResult.similarity * 100).toFixed(1)}%)`);
59
+ // ── PASO 4: Derivar nullifier ──────────────────────────────────────────────
60
+ log("Derivando nullifier único...");
61
+ let nullifier;
62
+ try {
63
+ const embedding = new Float32Array(faceResult.embedding);
64
+ nullifier = (0, soulprint_core_1.deriveNullifier)(docResult.cedula_number, docResult.fecha_nacimiento ?? "0000-00-00", embedding);
65
+ steps.nullifier_derived = "ok";
66
+ log(`✓ Nullifier: ${nullifier.slice(0, 18)}...`);
67
+ }
68
+ catch (e) {
69
+ errors.push(`Error derivando nullifier: ${e.message}`);
70
+ steps.nullifier_derived = "fail";
71
+ return { success: false, errors, steps };
72
+ }
73
+ // ── PASO 5: ZK Proof (opcional, si soulprint-zkp está disponible) ─────────
74
+ let zkProofSerialized;
75
+ const withZKP = opts.withZKP !== false; // default: true
76
+ if (withZKP) {
77
+ log("Generando ZK proof...");
78
+ try {
79
+ // Importar dinámicamente para no fallar si no está compilado el circuito
80
+ const zkp = await import("soulprint-zkp");
81
+ const cedula_num = zkp.cedulaToBigInt(docResult.cedula_number);
82
+ const fecha_nac = zkp.fechaToBigInt(docResult.fecha_nacimiento ?? "19000101");
83
+ const face_key = await zkp.faceEmbeddingToKey(Array.from(faceResult.embedding));
84
+ const salt = BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
85
+ const context = BigInt(0);
86
+ const zkProof = await zkp.generateProof({ cedula_num, fecha_nac, face_key, salt, context_tag: context });
87
+ zkProofSerialized = zkp.serializeProof(zkProof);
88
+ steps.zk_proof = "ok";
89
+ log(`✓ ZK proof generado — nullifier (ZK): ${zkProof.nullifier.slice(0, 18)}...`);
90
+ }
91
+ catch (zkErr) {
92
+ // ZK proof es opcional — si falla (circuito no compilado), continuar sin él
93
+ steps.zk_proof = "skip";
94
+ log(`⚠ ZK proof omitido: ${zkErr.message?.split("\n")[0]}`);
95
+ }
96
+ }
97
+ // ── PASO 6: Crear keypair y emitir SPT ────────────────────────────────────
98
+ log("Generando token Soulprint...");
99
+ const keypair = loadOrCreateKeypair();
100
+ const credentials = ["DocumentVerified", "FaceMatch"];
101
+ if (opts.checkLiveness && faceResult.liveness)
102
+ credentials.push("BiometricBound");
103
+ const token = (0, soulprint_core_1.createToken)(keypair, nullifier, credentials, {
104
+ country: "CO",
105
+ zkProof: zkProofSerialized,
106
+ });
107
+ steps.token_created = "ok";
108
+ log(`✓ Token — DID: ${keypair.did}`);
109
+ log("✅ Verificación completa. Datos biométricos eliminados de memoria.");
110
+ return {
111
+ success: true,
112
+ token,
113
+ zkProof: zkProofSerialized,
114
+ nullifier,
115
+ did: keypair.did,
116
+ score: calcScore(credentials),
117
+ errors: [],
118
+ steps,
119
+ };
120
+ }
121
+ function loadOrCreateKeypair() {
122
+ if (!(0, node_fs_1.existsSync)(SOULPRINT_DIR))
123
+ (0, node_fs_1.mkdirSync)(SOULPRINT_DIR, { recursive: true, mode: 0o700 });
124
+ if ((0, node_fs_1.existsSync)(KEYPAIR_FILE)) {
125
+ const stored = JSON.parse((0, node_fs_1.readFileSync)(KEYPAIR_FILE, "utf8"));
126
+ const { keypairFromPrivateKey } = require("soulprint-core");
127
+ return keypairFromPrivateKey(new Uint8Array(Buffer.from(stored.privateKey, "hex")));
128
+ }
129
+ const keypair = (0, soulprint_core_1.generateKeypair)();
130
+ (0, node_fs_1.writeFileSync)(KEYPAIR_FILE, JSON.stringify({
131
+ did: keypair.did,
132
+ privateKey: Buffer.from(keypair.privateKey).toString("hex"),
133
+ created: new Date().toISOString(),
134
+ }), { mode: 0o600 });
135
+ return keypair;
136
+ }
137
+ function calcScore(credentials) {
138
+ const s = {
139
+ EmailVerified: 10, PhoneVerified: 15, GitHubLinked: 20,
140
+ DocumentVerified: 25, FaceMatch: 20, BiometricBound: 10,
141
+ };
142
+ return credentials.reduce((acc, c) => acc + (s[c] ?? 0), 0);
143
+ }
144
+ var ocr_js_2 = require("./document/ocr.js");
145
+ Object.defineProperty(exports, "ocrCedula", { enumerable: true, get: function () { return ocr_js_2.ocrCedula; } });
146
+ Object.defineProperty(exports, "quickValidateImage", { enumerable: true, get: function () { return ocr_js_2.quickValidateImage; } });
147
+ var face_match_js_2 = require("./face/face-match.js");
148
+ Object.defineProperty(exports, "matchFaceWithDocument", { enumerable: true, get: function () { return face_match_js_2.matchFaceWithDocument; } });
149
+ var cedula_validator_js_1 = require("./document/cedula-validator.js");
150
+ Object.defineProperty(exports, "validateCedulaNumber", { enumerable: true, get: function () { return cedula_validator_js_1.validateCedulaNumber; } });
151
+ Object.defineProperty(exports, "parseCedulaOCR", { enumerable: true, get: function () { return cedula_validator_js_1.parseCedulaOCR; } });
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "soulprint-verify",
3
+ "version": "0.1.0",
4
+ "description": "Soulprint local verification — on-demand cedula OCR + InsightFace match, zero persistent memory",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "src/face/face_match.py",
10
+ "spa.traineddata",
11
+ "README.md"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/manuelariasfz/soulprint",
19
+ "directory": "packages/verify-local"
20
+ },
21
+ "homepage": "https://github.com/manuelariasfz/soulprint#readme",
22
+ "keywords": [
23
+ "soulprint",
24
+ "kyc",
25
+ "face-recognition",
26
+ "ocr",
27
+ "cedula",
28
+ "colombia",
29
+ "insightface",
30
+ "tesseract"
31
+ ],
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "tesseract.js": "^5.1.0",
35
+ "sharp": "^0.33.3",
36
+ "@noble/hashes": "^1.4.0",
37
+ "soulprint-core": "0.1.0",
38
+ "soulprint-zkp": "0.1.0"
39
+ },
40
+ "devDependencies": {
41
+ "typescript": "^5.4.0",
42
+ "@types/node": "^20.0.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=18.0.0"
46
+ },
47
+ "scripts": {
48
+ "build": "tsc",
49
+ "postbuild": "mkdir -p dist/face && cp src/face/face_match.py dist/face/"
50
+ }
51
+ }
Binary file
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ face_match.py — Soulprint on-demand face verification
4
+
5
+ Este script:
6
+ 1. Se lanza como subprocess desde TypeScript
7
+ 2. Carga InsightFace (500MB, ~4s)
8
+ 3. Compara selfie vs foto de documento
9
+ 4. Imprime resultado JSON por stdout
10
+ 5. TERMINA — la memoria se libera completamente
11
+
12
+ No hay proceso persistente. Cada verificación es un proceso nuevo.
13
+ """
14
+
15
+ import sys
16
+ import json
17
+ import argparse
18
+ import os
19
+
20
+ # Silenciar warnings de TensorFlow/ONNX antes de importar
21
+ os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
22
+ os.environ["ONNXRUNTIME_LOG_LEVEL"] = "3"
23
+
24
+
25
+ def eprint(*args):
26
+ """Logs al stderr para no contaminar stdout (que es el canal JSON)"""
27
+ print(*args, file=sys.stderr)
28
+
29
+
30
+ def load_models():
31
+ """Carga InsightFace on-demand. Solo se llama una vez por proceso."""
32
+ try:
33
+ import insightface
34
+ from insightface.app import FaceAnalysis
35
+
36
+ eprint("[soulprint] Cargando modelo de reconocimiento facial...")
37
+ app = FaceAnalysis(
38
+ name="buffalo_sc", # modelo ligero (~50MB vs buffalo_l 500MB)
39
+ providers=["CPUExecutionProvider"],
40
+ allowed_modules=["detection", "recognition"],
41
+ )
42
+ app.prepare(ctx_id=0, det_size=(320, 320)) # resolución reducida = más rápido
43
+ eprint("[soulprint] Modelo listo")
44
+ return app
45
+ except ImportError as e:
46
+ print(json.dumps({
47
+ "match": False,
48
+ "similarity": 0,
49
+ "errors": [f"InsightFace no disponible: {e}. Instala con: pip install insightface"],
50
+ }))
51
+ sys.exit(1)
52
+
53
+
54
+ def get_face_embedding(app, image_path: str):
55
+ """Extrae el embedding de la cara principal en una imagen."""
56
+ import cv2
57
+ import numpy as np
58
+
59
+ img = cv2.imread(image_path)
60
+ if img is None:
61
+ return None, f"No se pudo leer la imagen: {image_path}"
62
+
63
+ faces = app.get(img)
64
+ if not faces:
65
+ return None, "No se detectó ninguna cara en la imagen"
66
+
67
+ # Tomar la cara más grande (la principal)
68
+ face = max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1]))
69
+
70
+ return face.embedding, None
71
+
72
+
73
+ def cosine_similarity(a, b) -> float:
74
+ """Similitud coseno entre dos embeddings. Rango: -1 a 1."""
75
+ import numpy as np
76
+ a = a / (np.linalg.norm(a) + 1e-8)
77
+ b = b / (np.linalg.norm(b) + 1e-8)
78
+ return float(np.dot(a, b))
79
+
80
+
81
+ def quantize_embedding(embedding, precision: int = 2):
82
+ """
83
+ Cuantiza el embedding para derivar nullifier determinístico.
84
+ Misma cara en diferentes fotos → mismo embedding cuantizado.
85
+ """
86
+ import numpy as np
87
+ factor = 10 ** precision
88
+ return (np.round(embedding * factor) / factor).tolist()
89
+
90
+
91
+ def check_liveness(app, image_path: str) -> bool:
92
+ """
93
+ Detección básica de 'foto de foto':
94
+ - Verifica que el contraste y nitidez son de foto real
95
+ - No es antispoof completo, pero filtra ataques básicos
96
+ """
97
+ import cv2
98
+ import numpy as np
99
+
100
+ img = cv2.imread(image_path)
101
+ if img is None:
102
+ return False
103
+
104
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
105
+ laplacian = cv2.Laplacian(gray, cv2.CV_64F).var()
106
+
107
+ # Fotos de pantalla tienen banding y menor nitidez
108
+ # Umbral empírico — ajustar con más datos reales
109
+ return laplacian > 80.0
110
+
111
+
112
+ def main():
113
+ parser = argparse.ArgumentParser(description="Soulprint face match")
114
+ parser.add_argument("--selfie", required=True, help="Path selfie del usuario")
115
+ parser.add_argument("--document", required=True, help="Path foto del documento")
116
+ parser.add_argument("--min-sim", type=float, default=0.65, help="Similitud mínima")
117
+ parser.add_argument("--liveness", action="store_true", help="Verificar liveness")
118
+ args = parser.parse_args()
119
+
120
+ # Verificar que los archivos existen
121
+ for path in [args.selfie, args.document]:
122
+ if not os.path.exists(path):
123
+ print(json.dumps({
124
+ "match": False, "similarity": 0,
125
+ "errors": [f"Archivo no encontrado: {path}"]
126
+ }))
127
+ sys.exit(0)
128
+
129
+ # Cargar modelo (on-demand, solo este proceso)
130
+ app = load_models()
131
+
132
+ # Extraer embeddings
133
+ selfie_emb, err1 = get_face_embedding(app, args.selfie)
134
+ if err1:
135
+ print(json.dumps({"match": False, "similarity": 0, "errors": [f"Selfie: {err1}"]}))
136
+ sys.exit(0)
137
+
138
+ doc_emb, err2 = get_face_embedding(app, args.document)
139
+ if err2:
140
+ print(json.dumps({"match": False, "similarity": 0, "errors": [f"Documento: {err2}"]}))
141
+ sys.exit(0)
142
+
143
+ # Calcular similitud
144
+ similarity = cosine_similarity(selfie_emb, doc_emb)
145
+ match = similarity >= args.min_sim
146
+
147
+ # Liveness check (opcional)
148
+ liveness = None
149
+ if args.liveness:
150
+ liveness = check_liveness(app, args.selfie)
151
+
152
+ # Embedding cuantizado para derivar nullifier (determinístico)
153
+ quantized = quantize_embedding(selfie_emb, precision=2)
154
+
155
+ result = {
156
+ "match": match,
157
+ "similarity": round(similarity, 4),
158
+ "embedding": quantized, # para nullifier — NO es el embedding raw
159
+ "errors": [],
160
+ }
161
+
162
+ if liveness is not None:
163
+ result["liveness"] = liveness
164
+
165
+ if not match:
166
+ result["errors"].append(
167
+ f"Similitud insuficiente ({similarity:.2f} < {args.min_sim}). "
168
+ "Usa una foto clara de frente con buena iluminación."
169
+ )
170
+
171
+ # Imprimir resultado por stdout (único canal con el proceso padre)
172
+ print(json.dumps(result))
173
+
174
+ # El proceso termina aquí → InsightFace se descarga de memoria automáticamente
175
+
176
+
177
+ if __name__ == "__main__":
178
+ main()