import cv2 import numpy as np import time import threading from scipy.optimize import linear_sum_assignment from scipy.spatial.distance import cosine from ultralytics import YOLO import onnxruntime as ort import os # ────────────────────────────────────────────────────────────────────────────── # CONFIGURACIÓN DEL SISTEMA # ────────────────────────────────────────────────────────────────────────────── USUARIO, PASSWORD, IP_DVR = "admin", "TCA200503", "192.168.1.65" SECUENCIA = [1, 7, 5, 8, 3, 6] os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp" URLS = [f"rtsp://{USUARIO}:{PASSWORD}@{IP_DVR}:554/Streaming/Channels/{i}02" for i in SECUENCIA] ONNX_MODEL_PATH = "osnet_x0_25_msmt17.onnx" VECINOS = { "1": ["7"], "7": ["1", "5"], "5": ["7", "8"], "8": ["5", "3"], "3": ["8", "6"], "6": ["3"] } # ─── PARÁMETROS TÉCNICOS ASPECT_RATIO_MIN = 0.5 ASPECT_RATIO_MAX = 4.0 AREA_MIN_CALIDAD = 1200 FRAMES_CALIDAD = 2 TIEMPO_MAX_AUSENCIA = 800.0 # ─── TIEMPOS DE VIDA DE TRACKERS TIEMPO_PACIENCIA_BORDE_CON_ID = 5.0 # Segundos antes de matar un ID conocido en borde TIEMPO_PACIENCIA_BORDE_SIN_ID = 1.0 # Segundos antes de matar un candidato en borde TIEMPO_PACIENCIA_INTERIOR = 8.0 # Vida para ID conocido en el interior TIEMPO_PACIENCIA_INTERIOR_NUEVO = 1.5 # Vida para candidato sin ID en interior # ─── RE-ADQUISICIÓN RÁPIDA (persona que sale y entra por la misma puerta) RADIO_REENTRADA = 80 # Píxeles: si reaparece a menos de esta distancia, re-adquirir TIEMPO_REENTRADA = 15.0 # Segundos máximos para considerar una reentrada # ─── UMBRALES DE RE-ID # ⚡ UMBRAL_REID_MISMA_CAM ajustado a 0.65 para recuperar estabilidad en sombras y giros UMBRAL_REID_MISMA_CAM = 0.65 UMBRAL_REID_VECINO = 0.62 UMBRAL_REID_NO_VECINO = 0.80 MARGEN_MINIMO_REID = 0.07 MAX_FIRMAS_MEMORIA = 15 # ─── TIEMPO ENTRE TURNOS ACTIVOS (para el anti-fantasma dinámico) TIEMPO_TURNO_ROTATIVO = len(SECUENCIA) * 0.035 # ─── COLORES Y FUENTE C_CANDIDATO = (150, 150, 150) C_LOCAL = (0, 255, 0) C_GLOBAL = (0, 165, 255) C_GRUPO = (0, 0, 255) C_APRENDIZAJE = (255, 255, 0) FUENTE = cv2.FONT_HERSHEY_SIMPLEX # ────────────────────────────────────────────────────────────────────────────── # INICIALIZACIÓN DEL MOTOR DEEP LEARNING (ONNX) # ────────────────────────────────────────────────────────────────────────────── print("Cargando motor de Re-Identificación (OSNet ONNX en CPU)...") try: ort_session = ort.InferenceSession(ONNX_MODEL_PATH, providers=['CPUExecutionProvider']) input_name = ort_session.get_inputs()[0].name print("OSNet cargado.") except Exception as e: print(f"ERROR FATAL: No se pudo cargar {ONNX_MODEL_PATH}. {e}") exit() MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(1, 3, 1, 1) STD = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(1, 3, 1, 1) # ────────────────────────────────────────────────────────────────────────────── # 1. MOTOR HÍBRIDO DE FIRMA # ────────────────────────────────────────────────────────────────────────────── def analizar_calidad(box): x1, y1, x2, y2 = box w, h = x2 - x1, y2 - y1 if w <= 0 or h <= 0: return False return (ASPECT_RATIO_MIN < (h / w) < ASPECT_RATIO_MAX) and ((w * h) > AREA_MIN_CALIDAD) def preprocess_onnx(roi): img = cv2.resize(roi, (128, 256)) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = img.transpose(2, 0, 1).astype(np.float32) / 255.0 img = np.expand_dims(img, axis=0) img = (img - MEAN) / STD return img def extraer_color_zonas(roi): h_roi = roi.shape[0] t1 = int(h_roi * 0.15) t2 = int(h_roi * 0.55) zonas = [roi[:t1, :], roi[t1:t2, :], roi[t2:, :]] def hist_zona(z): if z.size == 0: return np.zeros(16 * 8) hsv = cv2.cvtColor(z, cv2.COLOR_BGR2HSV) hist = cv2.calcHist([hsv], [0, 1], None, [16, 8], [0, 180, 0, 256]) cv2.normalize(hist, hist) return hist.flatten() return np.concatenate([hist_zona(z) for z in zonas]) def extraer_firma_hibrida(frame, box): try: x1, y1, x2, y2 = map(int, box) fh, fw = frame.shape[:2] x1_c = max(0, x1); y1_c = max(0, y1) x2_c = min(fw, x2); y2_c = min(fh, y2) roi = frame[y1_c:y2_c, x1_c:x2_c] if roi.size == 0 or roi.shape[0] < 20 or roi.shape[1] < 10: return None calidad_area = (x2_c - x1_c) * (y2_c - y1_c) # ⚡ ROI CRUDA: Pasamos la imagen tal cual, sin alterar su contraste artificialmente blob = preprocess_onnx(roi) blob_16 = np.zeros((16, 3, 256, 128), dtype=np.float32) blob_16[0] = blob[0] deep_feat = ort_session.run(None, {input_name: blob_16})[0][0].flatten() norma = np.linalg.norm(deep_feat) if norma > 0: deep_feat = deep_feat / norma color_feat = extraer_color_zonas(roi) return {'deep': deep_feat, 'color': color_feat, 'calidad': calidad_area} except Exception: return None def similitud_hibrida(f1, f2, cross_cam=False): if f1 is None or f2 is None: return 0.0 sim_deep = max(0.0, 1.0 - cosine(f1['deep'], f2['deep'])) if f1['color'].shape == f2['color'].shape and f1['color'].size > 1: L = len(f1['color']) // 3 sim_head = max(0.0, float(cv2.compareHist( f1['color'][:L].astype(np.float32), f2['color'][:L].astype(np.float32), cv2.HISTCMP_CORREL))) sim_torso = max(0.0, float(cv2.compareHist( f1['color'][L:2*L].astype(np.float32), f2['color'][L:2*L].astype(np.float32), cv2.HISTCMP_CORREL))) sim_legs = max(0.0, float(cv2.compareHist( f1['color'][2*L:].astype(np.float32), f2['color'][2*L:].astype(np.float32), cv2.HISTCMP_CORREL))) sim_color = (0.10 * sim_head) + (0.60 * sim_torso) + (0.30 * sim_legs) else: sim_color = 0.0 if cross_cam: return (sim_deep * 0.75) + (sim_color * 0.25) else: return (sim_deep * 0.85) + (sim_color * 0.15) # ────────────────────────────────────────────────────────────────────────────── # 2. KALMAN TRACKER # ────────────────────────────────────────────────────────────────────────────── class KalmanTrack: _count = 0 def __init__(self, box, now): self.kf = cv2.KalmanFilter(7, 4) self.kf.measurementMatrix = np.array([ [1,0,0,0,0,0,0], [0,1,0,0,0,0,0], [0,0,1,0,0,0,0], [0,0,0,1,0,0,0]], np.float32) self.kf.transitionMatrix = np.eye(7, dtype=np.float32) self.kf.transitionMatrix[0,4] = 1 self.kf.transitionMatrix[1,5] = 1 self.kf.transitionMatrix[2,6] = 1 self.kf.processNoiseCov *= 0.03 self.kf.statePost = np.zeros((7, 1), np.float32) self.kf.statePost[:4] = self._convert_bbox_to_z(box) self.local_id = KalmanTrack._count KalmanTrack._count += 1 self.gid = None self.origen_global = False self.aprendiendo = False self.box = list(box) self.ts_creacion = now self.ts_ultima_deteccion = now self.time_since_update = 0 self.en_grupo = False self.frames_buena_calidad = 0 self.listo_para_id = False self.area_referencia = 0.0 self.ultimo_aprendizaje = 0.0 def _convert_bbox_to_z(self, bbox): w = bbox[2] - bbox[0]; h = bbox[3] - bbox[1] x = bbox[0] + w / 2.; y = bbox[1] + h / 2. return np.array([[x],[y],[w*h],[w/float(h+1e-6)]]).astype(np.float32) def _convert_x_to_bbox(self, x): cx = float(x[0].item()); cy = float(x[1].item()) s = float(x[2].item()); r = float(x[3].item()) w = np.sqrt(s * r); h = s / (w + 1e-6) return [cx-w/2., cy-h/2., cx+w/2., cy+h/2.] def predict(self, turno_activo=True): if (self.kf.statePost[6] + self.kf.statePost[2]) <= 0: self.kf.statePost[6] *= 0.0 self.kf.predict() if turno_activo: self.time_since_update += 1 self.aprendiendo = False self.box = self._convert_x_to_bbox(self.kf.statePre) return self.box def update(self, box, en_grupo, now): self.ts_ultima_deteccion = now self.time_since_update = 0 self.box = list(box) self.en_grupo = en_grupo self.kf.correct(self._convert_bbox_to_z(box)) if analizar_calidad(box) and not en_grupo: self.frames_buena_calidad += 1 if self.frames_buena_calidad >= FRAMES_CALIDAD: self.listo_para_id = True elif self.gid is None: self.frames_buena_calidad = max(0, self.frames_buena_calidad - 1) # ────────────────────────────────────────────────────────────────────────────── # 3. MEMORIA GLOBAL # ────────────────────────────────────────────────────────────────────────────── class GlobalMemory: def __init__(self): self.db = {} self.next_gid = 100 self.lock = threading.Lock() def _es_transito_posible(self, data, cam_destino, now): ultima_cam = str(data['last_cam']) cam_destino = str(cam_destino) dt = now - data['ts'] if ultima_cam == cam_destino: return True vecinos = VECINOS.get(ultima_cam, []) if cam_destino in vecinos: return dt >= -0.5 return dt >= 4.0 def _sim_robusta(self, firma_nueva, firmas_guardadas, cross_cam=False): if not firmas_guardadas: return 0.0 sims = sorted( [similitud_hibrida(firma_nueva, f, cross_cam=cross_cam) for f in firmas_guardadas], reverse=True ) if len(sims) == 1: return sims[0] elif len(sims) <= 3: return (sims[0] * 0.60) + (sims[1] * 0.40) else: return (sims[0] * 0.50) + (sims[1] * 0.30) + (sims[2] * 0.20) def identificar_candidato(self, firma_hibrida, cam_id, now, active_gids): with self.lock: candidatos = [] vecinos = VECINOS.get(str(cam_id), []) for gid, data in self.db.items(): if gid in active_gids: continue dt = now - data['ts'] if dt > TIEMPO_MAX_AUSENCIA: continue if not self._es_transito_posible(data, cam_id, now): continue if not data['firmas']: continue cross_cam = str(data['last_cam']) != str(cam_id) sim = self._sim_robusta(firma_hibrida, data['firmas'], cross_cam=cross_cam) misma_cam = str(data['last_cam']) == str(cam_id) es_vecino = str(data['last_cam']) in vecinos if misma_cam: umbral = UMBRAL_REID_MISMA_CAM elif es_vecino: umbral = UMBRAL_REID_VECINO else: umbral = UMBRAL_REID_NO_VECINO if sim > umbral: candidatos.append((sim, gid)) if not candidatos: nid = self.next_gid self.next_gid += 1 self._actualizar_sin_lock(nid, firma_hibrida, cam_id, now) return nid, False candidatos.sort(reverse=True) sim_1, best_gid = candidatos[0] if len(candidatos) >= 2: sim_2 = candidatos[1][0] margen = sim_1 - sim_2 if margen < MARGEN_MINIMO_REID: nid = self.next_gid self.next_gid += 1 self._actualizar_sin_lock(nid, firma_hibrida, cam_id, now) return nid, False self._actualizar_sin_lock(best_gid, firma_hibrida, cam_id, now) return best_gid, True def _actualizar_sin_lock(self, gid, firma_dict, cam_id, now): if gid not in self.db: self.db[gid] = {'firmas': [], 'last_cam': cam_id, 'ts': now} if firma_dict is not None: firmas_list = self.db[gid]['firmas'] if not firmas_list: firmas_list.append(firma_dict) else: if firma_dict['calidad'] > (firmas_list[0]['calidad'] * 1.50): vieja_ancla = firmas_list[0] firmas_list[0] = firma_dict firma_dict = vieja_ancla if len(firmas_list) >= MAX_FIRMAS_MEMORIA: max_sim_interna = -1.0 idx_redundante = 1 for i in range(1, len(firmas_list)): sims_con_otras = [ similitud_hibrida(firmas_list[i], firmas_list[j]) for j in range(1, len(firmas_list)) if j != i ] sim_promedio = float(np.mean(sims_con_otras)) if sims_con_otras else 0.0 if sim_promedio > max_sim_interna: max_sim_interna = sim_promedio idx_redundante = i firmas_list[idx_redundante] = firma_dict else: firmas_list.append(firma_dict) self.db[gid]['last_cam'] = cam_id self.db[gid]['ts'] = now def actualizar(self, gid, firma, cam_id, now): with self.lock: self._actualizar_sin_lock(gid, firma, cam_id, now) # ────────────────────────────────────────────────────────────────────────────── # 4. GESTOR LOCAL # ────────────────────────────────────────────────────────────────────────────── def iou_overlap(boxA, boxB): xA = max(boxA[0], boxB[0]); yA = max(boxA[1], boxB[1]) xB = min(boxA[2], boxB[2]); yB = min(boxA[3], boxB[3]) inter = max(0, xB-xA) * max(0, yB-yA) areaA = (boxA[2]-boxA[0]) * (boxA[3]-boxA[1]) areaB = (boxB[2]-boxB[0]) * (boxB[3]-boxB[1]) return inter / (areaA + areaB - inter + 1e-6) class CamManager: def __init__(self, cam_id, global_mem): self.cam_id = cam_id self.global_mem = global_mem self.trackers = [] def update(self, boxes, frame, now, turno_activo): for trk in self.trackers: trk.predict(turno_activo=turno_activo) if not turno_activo: return self.trackers matched, unmatched_dets, _ = self._asignar(boxes, now) fh, fw = frame.shape[:2] for t_idx, d_idx in matched: trk = self.trackers[t_idx] box = boxes[d_idx] en_grupo = any( other is not trk and iou_overlap(box, other.box) > 0.10 for other in self.trackers ) trk.update(box, en_grupo, now) active_gids_local = {t.gid for t in self.trackers if t.gid is not None} area_actual = (box[2] - box[0]) * (box[3] - box[1]) if trk.gid is None and trk.listo_para_id: firma = extraer_firma_hibrida(frame, box) if firma is not None: gid, es_reid = self.global_mem.identificar_candidato( firma, self.cam_id, now, active_gids_local ) trk.gid = gid trk.origen_global = es_reid trk.area_referencia = area_actual elif trk.gid is None and not trk.listo_para_id: firma_rapida = extraer_firma_hibrida(frame, box) if firma_rapida is not None: cx_nuevo = (box[0] + box[2]) / 2 cy_nuevo = (box[1] + box[3]) / 2 with self.global_mem.lock: for gid_cand, data_cand in self.global_mem.db.items(): if gid_cand in active_gids_local: continue if str(data_cand.get('last_cam', '')) != str(self.cam_id): continue if (now - data_cand['ts']) > TIEMPO_REENTRADA: continue if not data_cand['firmas']: continue ultima_box = data_cand.get('last_box') if ultima_box is None: continue cx_ult = (ultima_box[0] + ultima_box[2]) / 2 cy_ult = (ultima_box[1] + ultima_box[3]) / 2 distancia = np.sqrt((cx_nuevo-cx_ult)**2 + (cy_nuevo-cy_ult)**2) if distancia < RADIO_REENTRADA: sim = similitud_hibrida(firma_rapida, data_cand['firmas'][0]) if sim > UMBRAL_REID_MISMA_CAM: trk.gid = gid_cand trk.origen_global = True trk.area_referencia = area_actual trk.listo_para_id = True self.global_mem._actualizar_sin_lock( gid_cand, firma_rapida, self.cam_id, now ) break elif trk.gid is not None and not trk.en_grupo: tiempo_ultima_firma = trk.ultimo_aprendizaje if (now - tiempo_ultima_firma) > 1.5 and analizar_calidad(box): x1b, y1b, x2b, y2b = map(int, box) en_borde = (x1b < 15 or y1b < 15 or x2b > fw-15 or y2b > fh-15) if not en_borde: firma_nueva = extraer_firma_hibrida(frame, box) if firma_nueva is not None: with self.global_mem.lock: if (trk.gid in self.global_mem.db and self.global_mem.db[trk.gid]['firmas']): firma_ancla = self.global_mem.db[trk.gid]['firmas'][0] sim_coherencia = similitud_hibrida(firma_nueva, firma_ancla) if sim_coherencia > 0.55: es_coherente = True for otro_gid, otro_data in self.global_mem.db.items(): if otro_gid == trk.gid or not otro_data['firmas']: continue sim_intruso = similitud_hibrida( firma_nueva, otro_data['firmas'][0] ) if sim_intruso > 0.55: es_coherente = False break if es_coherente: self.global_mem._actualizar_sin_lock( trk.gid, firma_nueva, self.cam_id, now ) trk.ultimo_aprendizaje = now trk.aprendiendo = True for trk in self.trackers: if trk.time_since_update == 0 and trk.gid is not None: with self.global_mem.lock: if trk.gid in self.global_mem.db: self.global_mem.db[trk.gid]['last_box'] = list(trk.box) for d_idx in unmatched_dets: self.trackers.append(KalmanTrack(boxes[d_idx], now)) vivos = [] for t in self.trackers: x1, y1, x2, y2 = t.box toca_borde = (x1 < 5 or y1 < 5 or x2 > fw-5 or y2 > fh-5) tiempo_oculto = now - t.ts_ultima_deteccion if toca_borde: limite = TIEMPO_PACIENCIA_BORDE_CON_ID if t.gid else TIEMPO_PACIENCIA_BORDE_SIN_ID else: limite = TIEMPO_PACIENCIA_INTERIOR if t.gid else TIEMPO_PACIENCIA_INTERIOR_NUEVO if tiempo_oculto < limite: vivos.append(t) self.trackers = vivos return self.trackers def _asignar(self, boxes, now): n_trk = len(self.trackers) n_det = len(boxes) if n_trk == 0: return [], list(range(n_det)), [] if n_det == 0: return [], [], list(range(n_trk)) cost_mat = np.zeros((n_trk, n_det), dtype=np.float32) for t, trk in enumerate(self.trackers): for d, det in enumerate(boxes): iou = iou_overlap(trk.box, det) cx_t = (trk.box[0]+trk.box[2]) / 2 cy_t = (trk.box[1]+trk.box[3]) / 2 cx_d = (det[0]+det[2]) / 2 cy_d = (det[1]+det[3]) / 2 dist_norm = np.sqrt((cx_t-cx_d)**2 + (cy_t-cy_d)**2) / 550.0 area_trk = (trk.box[2]-trk.box[0]) * (trk.box[3]-trk.box[1]) area_det = (det[2]-det[0]) * (det[3]-det[1]) ratio_area = max(area_trk, area_det) / (min(area_trk, area_det) + 1e-6) castigo_tam = (ratio_area - 1.0) * 0.4 tiempo_oculto = now - trk.ts_ultima_deteccion if tiempo_oculto > (TIEMPO_TURNO_ROTATIVO * 2) and iou < 0.10: fantasma_penalty = 5.0 else: fantasma_penalty = 0.0 if iou >= 0.05 or dist_norm < 0.50: cost_mat[t, d] = ((1.0 - iou) + (dist_norm * 2.0) + fantasma_penalty + castigo_tam) else: cost_mat[t, d] = 100.0 row_ind, col_ind = linear_sum_assignment(cost_mat) matched, unmatched_dets, unmatched_trks = [], [], [] for r, c in zip(row_ind, col_ind): if cost_mat[r, c] > 4.0: unmatched_trks.append(r) unmatched_dets.append(c) else: matched.append((r, c)) matched_t = {m[0] for m in matched} matched_d = {m[1] for m in matched} for t in range(n_trk): if t not in matched_t: unmatched_trks.append(t) for d in range(n_det): if d not in matched_d: unmatched_dets.append(d) return matched, unmatched_dets, unmatched_trks # ────────────────────────────────────────────────────────────────────────────── # 5. STREAM DE CÁMARA # ────────────────────────────────────────────────────────────────────────────── class CamStream: def __init__(self, url): self.url = url self.cap = cv2.VideoCapture(url) self.frame = None self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) threading.Thread(target=self._run, daemon=True).start() def _run(self): while True: ret, f = self.cap.read() if ret: self.frame = f time.sleep(0.01) else: time.sleep(2) self.cap.open(self.url) # ────────────────────────────────────────────────────────────────────────────── # 6. DIBUJADO # ────────────────────────────────────────────────────────────────────────────── def dibujar_track(frame_show, trk): try: x1, y1, x2, y2 = map(int, trk.box) except Exception: return if trk.gid is None: color, label = C_CANDIDATO, f"?{trk.local_id}" elif trk.en_grupo: color, label = C_GRUPO, f"ID:{trk.gid} [grp]" elif trk.aprendiendo: color, label = C_APRENDIZAJE, f"ID:{trk.gid} [++]" elif trk.origen_global: color, label = C_GLOBAL, f"ID:{trk.gid} [re-id]" else: color, label = C_LOCAL, f"ID:{trk.gid}" cv2.rectangle(frame_show, (x1, y1), (x2, y2), color, 2) (tw, th), _ = cv2.getTextSize(label, FUENTE, 0.55, 1) cv2.rectangle(frame_show, (x1, y1-th-6), (x1+tw+2, y1), color, -1) cv2.putText(frame_show, label, (x1+1, y1-4), FUENTE, 0.55, (0, 0, 0), 1) if trk.gid is None: pct = min(trk.frames_buena_calidad / FRAMES_CALIDAD, 1.0) bw = x2 - x1 cv2.rectangle(frame_show, (x1, y2+2), (x2, y2+7), (50, 50, 50), -1) cv2.rectangle(frame_show, (x1, y2+2), (x1+int(bw*pct), y2+7), (0, 220, 220), -1) # ────────────────────────────────────────────────────────────────────────────── # 7. MAIN LOOP (para pruebas standalone) # ────────────────────────────────────────────────────────────────────────────── def main(): print("SmartSoft") model = YOLO("yolov8n.pt") global_mem = GlobalMemory() managers = {str(c): CamManager(c, global_mem) for c in SECUENCIA} cams = [CamStream(u) for u in URLS] cv2.namedWindow("SmartSoft", cv2.WINDOW_AUTOSIZE) idx = 0 while True: now, tiles = time.time(), [] cam_ia = idx % len(cams) for i, cam_obj in enumerate(cams): frame = cam_obj.frame cid = str(SECUENCIA[i]) if frame is None: tiles.append(np.zeros((270, 480, 3), np.uint8)) continue frame_show = cv2.resize(frame.copy(), (480, 270)) boxes = [] turno_activo = (i == cam_ia) if turno_activo: res = model.predict( frame_show, conf=0.40, iou=0.50, classes=[0], verbose=False, imgsz=480 ) if res[0].boxes: boxes = res[0].boxes.xyxy.cpu().numpy().tolist() tracks = managers[cid].update(boxes, frame_show, now, turno_activo) for trk in tracks: if trk.time_since_update == 0: dibujar_track(frame_show, trk) if turno_activo: cv2.circle(frame_show, (460, 20), 6, (0, 0, 255), -1) con_id = sum(1 for t in tracks if t.gid and t.time_since_update == 0) cv2.putText(frame_show, f"CAM {cid} [{con_id} ID]", (10, 28), FUENTE, 0.7, (255, 255, 255), 2) tiles.append(frame_show) if len(tiles) == 6: cv2.imshow("SmartSoft", np.vstack([ np.hstack(tiles[0:3]), np.hstack(tiles[3:6]) ])) idx += 1 if cv2.waitKey(1) == ord('q'): break cv2.destroyAllWindows() if __name__ == "__main__": main()