Spaces:
Paused
Paused
| #!/usr/bin/env python3 | |
| """ | |
| detect_stream.py — Cloud-Streaming Singer Face Detector (Multi-Threaded) | |
| ======================================================================== | |
| 100% Native Python + OpenCV + FFmpeg. No local video downloads. | |
| Streams directly from Kaggle datasets into memory, then uses | |
| FAST MULTI-THREADED OpenCV Face + Smile cascades for detection. | |
| This is the user's EXACT code from detect_stream.py, refactored into a | |
| function (run_singer_detection) so app.py can call it directly. The | |
| detection logic, streaming logic, and auth approach are ALL VERBATIM | |
| from the user's working script. | |
| The only additions are: | |
| 1. run_singer_detection() wrapper function (takes task_id, kaggle creds) | |
| 2. log_callback parameter (routes output to the web UI's Live Output) | |
| 3. Returns a dict with success/results/error instead of printing + sys.exit | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import sys | |
| import json | |
| import base64 | |
| import subprocess | |
| import time | |
| import shutil | |
| from typing import Callable, Optional, Dict, Any, List, Tuple | |
| import numpy as np | |
| import cv2 | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| try: | |
| from kaggle.api.kaggle_api_extended import KaggleApi | |
| except ImportError: | |
| KaggleApi = None | |
| # ════════════════════════════════════════════════════════════════════════════ | |
| # VERBATIM from user's detect_stream.py — StreamingSingerDetector class | |
| # ════════════════════════════════════════════════════════════════════════════ | |
| class StreamingSingerDetector: | |
| def __init__(self, stream_url: str, b64_auth: str, output_dir: str = "singer_snapshots", | |
| log_callback: Optional[Callable[[str], None]] = None): | |
| self.stream_url = stream_url | |
| self.b64_auth = b64_auth | |
| self.output_dir = os.path.abspath(output_dir) | |
| self.temp_dir = os.path.join(self.output_dir, "temp_hq") | |
| self.log = log_callback or (lambda msg: print(msg)) | |
| os.makedirs(self.output_dir, exist_ok=True) | |
| os.makedirs(self.temp_dir, exist_ok=True) | |
| self.headers_str = f"Authorization: Basic {self.b64_auth}\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)\r\n" | |
| self.log(" [detect] Streaming initialized (Thread-safe mode activated)\n") | |
| # --------------------------------------------------------- FFmpeg utilities | |
| def probe_stream(self) -> tuple: | |
| self.log("[INFO] Probing remote stream properties...") | |
| cmd = [ | |
| "ffprobe", "-v", "error", | |
| "-show_entries", "format=duration:stream=width,height", | |
| "-print_format", "json", | |
| "-headers", self.headers_str, | |
| self.stream_url | |
| ] | |
| try: | |
| result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) | |
| if result.returncode == 0: | |
| data = json.loads(result.stdout) | |
| stream = data.get("streams", [{}])[0] | |
| fmt = data.get("format", {}) | |
| w = int(stream.get("width", 1920)) | |
| h = int(stream.get("height", 1080)) | |
| duration = float(fmt.get("duration", 3600.0)) | |
| return w, h, duration | |
| except Exception as e: | |
| self.log(f"[WARNING] Remote probe failed ({e}).") | |
| self.log("[WARNING] Falling back to default 1920x1080, 1 hour duration.") | |
| return 1920, 1080, 3600.0 | |
| def fetch_hq_snapshot(self, ts: float, out_path: str, quality: int = 1): | |
| """Fetches a single high-quality frame directly from the remote stream.""" | |
| cmd = [ | |
| "ffmpeg", "-y", | |
| "-headers", self.headers_str, | |
| "-ss", f"{ts:.4f}", | |
| "-i", self.stream_url, | |
| "-vframes", "1", | |
| "-q:v", str(quality), | |
| out_path | |
| ] | |
| subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) | |
| # -------------------------------------------------------- Face & Mouth Detection | |
| def detect_faces(self, img: np.ndarray, face_cascade, smile_cascade) -> list: | |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) | |
| gray = cv2.equalizeHist(gray) | |
| faces_rects = face_cascade.detectMultiScale( | |
| gray, scaleFactor=1.15, minNeighbors=5, minSize=(60, 60) | |
| ) | |
| faces = [] | |
| for (x, y, w, h) in faces_rects: | |
| mouth_score = self._get_mouth_score(img, x, y, w, h, smile_cascade) | |
| faces.append({ | |
| "bbox": (x, y, x + w, y + h), | |
| "conf": 0.9, | |
| "cx": x + w // 2, | |
| "cy": y + h // 2, | |
| "area": w * h, | |
| "mouth": mouth_score, | |
| }) | |
| return faces | |
| def _get_mouth_score(self, img: np.ndarray, x: int, y: int, w: int, h: int, smile_cascade) -> float: | |
| mouth_y_start = y + int(h * 0.6) | |
| mouth_roi = img[mouth_y_start: y + h, x: x + w] | |
| if mouth_roi.size == 0: | |
| return 0.5 | |
| gray_mouth = cv2.cvtColor(mouth_roi, cv2.COLOR_BGR2GRAY) | |
| gray_mouth = cv2.equalizeHist(gray_mouth) | |
| smiles = smile_cascade.detectMultiScale( | |
| gray_mouth, scaleFactor=1.7, minNeighbors=12, minSize=(int(w * 0.3), int(h * 0.08)) | |
| ) | |
| return 0.85 if len(smiles) > 0 else 0.25 | |
| # ---------------------------------------------- Face similarity & Clustering | |
| def _face_similarity(a: np.ndarray, b: np.ndarray) -> float: | |
| if a.size == 0 or b.size == 0: return 0.0 | |
| sz = (64, 64) | |
| a = cv2.resize(a, sz) | |
| b = cv2.resize(b, sz) | |
| ha = cv2.calcHist([cv2.cvtColor(a, cv2.COLOR_BGR2HSV)], [0, 1], None, [30, 40], [0, 180, 0, 256]) | |
| hb = cv2.calcHist([cv2.cvtColor(b, cv2.COLOR_BGR2HSV)], [0, 1], None, [30, 40], [0, 180, 0, 256]) | |
| cv2.normalize(ha, ha); cv2.normalize(hb, hb) | |
| return cv2.compareHist(ha, hb, cv2.HISTCMP_CORREL) | |
| def _cluster_faces(self, all_detections: list) -> list: | |
| SIM_THRESHOLD = 0.38 | |
| clusters = [] | |
| for det in all_detections: | |
| best_c, best_s = None, SIM_THRESHOLD | |
| for c in clusters: | |
| s = self._face_similarity(det["crop"], c["rep_img"]) | |
| if s > best_s: | |
| best_s, best_c = s, c | |
| if best_c is not None: | |
| best_c["members"].append(det) | |
| else: | |
| clusters.append({"rep_img": det["crop"], "members": [det]}) | |
| return clusters | |
| def _score_cluster(self, cluster: dict, video_area: int) -> float: | |
| mems = cluster["members"] | |
| n = len(mems) | |
| freq = min(n / 8.0, 1.0) | |
| mouth = np.mean([m["mouth"] for m in mems]) | |
| center = np.mean([m["center_s"] for m in mems]) | |
| size = np.mean([m["size_s"] for m in mems]) | |
| score = 0.30 * freq + 0.35 * mouth + 0.20 * center + 0.15 * size | |
| cluster["_score"] = score | |
| cluster["_n"] = n | |
| cluster["_mouth"] = mouth | |
| return score | |
| # ------------------------------------------------ Auto Crop Logic | |
| def _auto_crop_half_body(self, img: np.ndarray, bbox: tuple) -> np.ndarray: | |
| x1, y1, x2, y2 = bbox | |
| face_h, face_w = y2 - y1, x2 - x1 | |
| img_h, img_w = img.shape[:2] | |
| new_y1 = max(0, y1 - int(face_h * 0.5)) | |
| new_y2 = min(img_h, y2 + int(face_h * 3.5)) | |
| new_x1 = max(0, x1 - int(face_w * 2.5)) | |
| new_x2 = min(img_w, x2 + int(face_w * 2.5)) | |
| return img[new_y1:new_y2, new_x1:new_x2] | |
| # ====================================================== PUBLIC API | |
| def run(self, num_samples: int = 45, num_snapshots: int = 3) -> List[Dict]: | |
| self.log("=" * 62) | |
| self.log(" [detect] CLOUD-STREAMING EXTRACTOR (Fast Threaded)") | |
| self.log("=" * 62) | |
| w, h, duration = self.probe_stream() | |
| video_area = w * h | |
| frame_bytes = w * h * 3 | |
| self.log(f" Duration : {duration:.1f} s Resolution : {w}x{h}\n") | |
| # ── 1. Stream frames into memory ── | |
| self.log(f"[1/4] Streaming {num_samples} frames from Kaggle cloud into RAM ...") | |
| t_start = time.time() | |
| start_t = duration * 0.08 | |
| end_t = duration * 0.95 | |
| window = end_t - start_t | |
| extraction_fps = num_samples / window | |
| ffmpeg_cmd = [ | |
| "ffmpeg", | |
| "-headers", self.headers_str, | |
| "-ss", str(start_t), | |
| "-t", str(window), | |
| "-i", self.stream_url, | |
| "-vf", f"fps={extraction_fps}", | |
| "-f", "image2pipe", | |
| "-pix_fmt", "bgr24", | |
| "-vcodec", "rawvideo", "pipe:1" | |
| ] | |
| p_ffmpeg = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) | |
| frames = [] | |
| frame_idx = 0 | |
| while True: | |
| raw_frame = p_ffmpeg.stdout.read(frame_bytes) | |
| if not raw_frame or len(raw_frame) < frame_bytes: | |
| break | |
| img = np.frombuffer(raw_frame, dtype=np.uint8).reshape((h, w, 3)) | |
| ts = start_t + (frame_idx / extraction_fps) | |
| frames.append((img.copy(), ts)) | |
| frame_idx += 1 | |
| self.log(f"\r -> Buffered Frame {frame_idx}/{num_samples}") | |
| p_ffmpeg.terminate() | |
| t_extract = time.time() - t_start | |
| self.log(f"\n {len(frames)} frames loaded in {t_extract:.1f}s\n") | |
| if not frames: | |
| self.log("[ERROR] Stream failed to return frames. Aborting.") | |
| return [] | |
| # ── 2. Detect faces + mouth openness (THREADED) ── | |
| self.log("[2/4] Detecting faces & checking for singing (threaded) ...") | |
| t_detect_start = time.time() | |
| all_dets = [] | |
| MAX_WORKERS = min(8, os.cpu_count() or 4) | |
| def _detect_one(img, ts): | |
| local_face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml') | |
| local_smile_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_smile.xml') | |
| fh, fw = img.shape[:2] | |
| fc = (fw // 2, fh // 2) | |
| max_dist = np.hypot(fw, fh) / 2 | |
| dets = [] | |
| for face in self.detect_faces(img, local_face_cascade, local_smile_cascade): | |
| cx, cy = face["cx"], face["cy"] | |
| dist = np.hypot(cx - fc[0], cy - fc[1]) | |
| dets.append({ | |
| "ts": ts, "bbox": face["bbox"], "conf": face["conf"], | |
| "crop": img[face["bbox"][1]:face["bbox"][3], face["bbox"][0]:face["bbox"][2]].copy(), | |
| "mouth": face["mouth"], "center_s": 1.0 - dist / max_dist, "size_s": face["area"] / video_area, | |
| }) | |
| return dets | |
| with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: | |
| futures = [pool.submit(_detect_one, img, ts) for img, ts in frames] | |
| for future in as_completed(futures): | |
| all_dets.extend(future.result()) | |
| all_dets.sort(key=lambda x: x["ts"]) | |
| t_detect = time.time() - t_detect_start | |
| self.log(f" Detection done in {t_detect:.1f}s ({MAX_WORKERS} threads)") | |
| self.log(f" Total detections: {len(all_dets)}\n") | |
| if not all_dets: | |
| self.log(" No faces found — aborting.") | |
| return [] | |
| # ── 3. Cluster & identify singer ── | |
| self.log("[3/4] Clustering faces to find the singer ...") | |
| clusters = self._cluster_faces(all_dets) | |
| for c in clusters: | |
| self._score_cluster(c, video_area) | |
| clusters.sort(key=lambda c: c["_score"], reverse=True) | |
| self.log("\n Top clusters:") | |
| for i, c in enumerate(clusters[:6]): | |
| tag = " ★ SINGER" if i == 0 else "" | |
| self.log(f" #{i+1} score={c['_score']:.3f} faces={c['_n']:2d} mouth={c['_mouth']:.2f}{tag}") | |
| singer = clusters[0] | |
| self.log(f"\n -> Singer cluster: {singer['_n']} detections\n") | |
| # ── 4. Pick best snapshots & extract HQ from stream (THREADED) ── | |
| self.log(f"[4/4] Fetching HQ frames from cloud and auto-cropping (threaded) ...\n") | |
| scored = sorted(singer["members"], key=lambda m: 0.50 * m["mouth"] + 0.25 * m["conf"] + 0.25 * m["center_s"], reverse=True) | |
| picked = [] | |
| MIN_GAP = max(4.0, duration / (num_snapshots * 3)) | |
| for m in scored: | |
| if len(picked) >= num_snapshots: break | |
| if all(abs(m["ts"] - p["ts"]) >= MIN_GAP for p in picked): picked.append(m) | |
| for m in scored: | |
| if len(picked) >= num_snapshots: break | |
| if m not in picked: picked.append(m) | |
| def _extract_hq_snapshot(idx, det): | |
| ts = det["ts"] | |
| bbox = det["bbox"] | |
| raw_path = os.path.join(self.temp_dir, f"hq_{idx}.png") | |
| self.fetch_hq_snapshot(ts, raw_path, quality=1) | |
| hq = cv2.imread(raw_path) | |
| if hq is None: return None | |
| x1, y1, x2, y2 = bbox | |
| # 1. Annotated | |
| ann = hq.copy() | |
| cv2.rectangle(ann, (x1, y1), (x2, y2), (0, 220, 80), 3) | |
| lbl = "SINGER" | |
| (tw, th), _ = cv2.getTextSize(lbl, cv2.FONT_HERSHEY_SIMPLEX, 1.0, 2) | |
| cv2.rectangle(ann, (x1, y1 - th - 14), (x1 + tw + 8, y1), (0, 220, 80), -1) | |
| cv2.putText(ann, lbl, (x1 + 4, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2) | |
| info_txt = f"t = {ts:.1f}s | singing = {det['mouth']:.2f}" | |
| cv2.putText(ann, info_txt, (12, ann.shape[0] - 16), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (255, 255, 255), 2) | |
| ann_path = os.path.join(self.output_dir, f"singer_{idx+1}_annotated.png") | |
| cv2.imwrite(ann_path, ann, [cv2.IMWRITE_PNG_COMPRESSION, 0]) | |
| # 2. Clean | |
| clean_path = os.path.join(self.output_dir, f"singer_{idx+1}_clean.png") | |
| cv2.imwrite(clean_path, hq, [cv2.IMWRITE_PNG_COMPRESSION, 0]) | |
| # 3. Half-body crop | |
| cropped_body = self._auto_crop_half_body(hq, bbox) | |
| crop_path = os.path.join(self.output_dir, f"singer_{idx+1}_halfbody.png") | |
| cv2.imwrite(crop_path, cropped_body, [cv2.IMWRITE_PNG_COMPRESSION, 0]) | |
| c_h, c_w = cropped_body.shape[:2] | |
| return { | |
| "annotated": ann_path, "clean": clean_path, "crop": crop_path, | |
| "_info": f"t={ts:.1f}s singing={det['mouth']:.2f} crop={c_w}x{c_h}px", | |
| "_idx": idx, | |
| } | |
| results = [] | |
| with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: | |
| futures = {pool.submit(_extract_hq_snapshot, idx, det): idx for idx, det in enumerate(picked[:num_snapshots])} | |
| for future in as_completed(futures): | |
| r = future.result() | |
| if r: results.append(r) | |
| results.sort(key=lambda x: x["_idx"]) | |
| for i, r in enumerate(results): | |
| self.log(f" Snapshot {i+1}: {r['_info']}") | |
| self.log(f" -> {os.path.basename(r['annotated'])}") | |
| self.log(f" -> {os.path.basename(r['clean'])}") | |
| self.log(f" -> {os.path.basename(r['crop'])}") | |
| shutil.rmtree(self.temp_dir, ignore_errors=True) | |
| total_t = time.time() - t_start | |
| self.log(f"\n Total time: {total_t:.1f}s") | |
| self.log("=" * 62) | |
| self.log(f" DONE — {len(results)} sets saved to: {self.output_dir}") | |
| self.log("=" * 62) | |
| return results | |
| # ════════════════════════════════════════════════════════════════════════════ | |
| # Main entry point — called from app.py | |
| # ════════════════════════════════════════════════════════════════════════════ | |
| def run_singer_detection( | |
| task_id: str, | |
| kaggle_username: str, | |
| kaggle_key: str, | |
| log_callback: Optional[Callable[[str], None]] = None, | |
| num_samples: int = 45, | |
| num_snapshots: int = 3, | |
| output_dir: Optional[str] = None, | |
| ) -> Dict[str, Any]: | |
| """ | |
| Run singer detection on the video that step 1 uploaded to Kaggle. | |
| Uses the user's EXACT StreamingSingerDetector class from detect_stream.py. | |
| Streams the video from Kaggle, samples frames, detects faces + singing, | |
| clusters to find the singer, and outputs: | |
| - singer_1_halfbody.png, singer_2_halfbody.png, etc. | |
| - singer_1_annotated.png, singer_1_clean.png, etc. | |
| Args: | |
| task_id: The task ID (e.g. "20260619_142543_0b331b"). Underscores | |
| are auto-converted to hyphens for the Kaggle dataset slug. | |
| kaggle_username: Kaggle username (also the dataset owner). | |
| kaggle_key: Kaggle API key. | |
| log_callback: Function called with progress messages. | |
| num_samples: Number of frames to sample. Default 45. | |
| num_snapshots: Number of final snapshots to export. Default 3. | |
| output_dir: Directory for output images. Defaults to task dir. | |
| Returns: | |
| dict with keys: success, results, output_dir, error | |
| """ | |
| def log(msg): | |
| if log_callback: | |
| log_callback(msg) | |
| else: | |
| print(msg) | |
| # ── Pre-flight checks ───────────────────────────────────────────────── | |
| if KaggleApi is None: | |
| msg = "Kaggle Python package not installed. Run: pip install kaggle" | |
| log(f"[Singer] ERROR: {msg}") | |
| return {"success": False, "results": [], "output_dir": None, "error": msg} | |
| if not kaggle_username or not kaggle_key: | |
| msg = "KAGGLE_USERNAME / KAGGLE_KEY are not set." | |
| log(f"[Singer] ERROR: {msg}") | |
| return {"success": False, "results": [], "output_dir": None, "error": msg} | |
| # ── Set env vars for KaggleApi.authenticate() ───────────────────────── | |
| os.environ["KAGGLE_USERNAME"] = kaggle_username | |
| os.environ["KAGGLE_KEY"] = kaggle_key | |
| # ── Authenticate with Kaggle ────────────────────────────────────────── | |
| log("[Singer] Initializing Kaggle API client...") | |
| try: | |
| api = KaggleApi() | |
| api.authenticate() | |
| except Exception as e: | |
| msg = f"Kaggle authentication failed: {e}" | |
| log(f"[Singer] ERROR: {msg}") | |
| return {"success": False, "results": [], "output_dir": None, "error": msg} | |
| username = api.config_values.get('username') or kaggle_username | |
| key = api.config_values.get('key') or kaggle_key | |
| auth_bytes = f"{username}:{key}".encode('utf-8') | |
| b64_auth = base64.b64encode(auth_bytes).decode('utf-8') | |
| # ── Derive dataset slug and filename from task_id ───────────────────── | |
| # Same underscore→hyphen conversion as detect_edges_video_stream.py | |
| dataset_name = task_id.replace("_", "-") | |
| filename = f"output_{task_id}.mkv" | |
| stream_url = f"https://www.kaggle.com/api/v1/datasets/download/{username}/{dataset_name}/{filename}" | |
| log(f"[Singer] Task ID: {task_id}") | |
| log(f"[Singer] Dataset slug: {username}/{dataset_name}") | |
| log(f"[Singer] Video file: {filename}") | |
| log(f"[Singer] Stream URL: {stream_url}") | |
| # ── Set up output directory ─────────────────────────────────────────── | |
| if not output_dir: | |
| output_dir = os.path.join(os.getcwd(), "singer_snapshots") | |
| os.makedirs(output_dir, exist_ok=True) | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # RETRY LOOP — same as detect_edges_video_stream.py | |
| # Kaggle returns 403 when the dataset is still being processed. | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| import time as _time | |
| MAX_ATTEMPTS = 6 | |
| RETRY_DELAY = 15 | |
| results = [] | |
| for attempt in range(1, MAX_ATTEMPTS + 1): | |
| log(f"[Singer] === Attempt {attempt}/{MAX_ATTEMPTS} ===") | |
| detector = StreamingSingerDetector( | |
| stream_url=stream_url, | |
| b64_auth=b64_auth, | |
| output_dir=output_dir, | |
| log_callback=log, | |
| ) | |
| results = detector.run(num_samples=num_samples, num_snapshots=num_snapshots) | |
| if results: | |
| log(f"[Singer] SUCCESS: {len(results)} singer snapshots generated.") | |
| break | |
| else: | |
| log(f"[Singer] WARNING: No results on attempt {attempt}.") | |
| if attempt < MAX_ATTEMPTS: | |
| log(f"[Singer] Waiting {RETRY_DELAY}s before retry... (Kaggle may still be processing)") | |
| _time.sleep(RETRY_DELAY) | |
| else: | |
| msg = "Singer detection failed after all retries." | |
| log(f"[Singer] ERROR: {msg}") | |
| return {"success": False, "results": [], "output_dir": output_dir, "error": msg} | |
| return { | |
| "success": True, | |
| "results": results, | |
| "output_dir": output_dir, | |
| "error": None, | |
| } | |
| # ── CLI wrapper (kept for manual testing) ──────────────────────────────────── | |
| def main(): | |
| import argparse | |
| parser = argparse.ArgumentParser(description="Streaming Singer Face Detector") | |
| parser.add_argument("-d", "--dataset", required=True, help="Kaggle dataset (e.g. owner/dataset)") | |
| parser.add_argument("-f", "--filename", required=True, help="Target video filename (e.g. video.mkv)") | |
| parser.add_argument("-o", "--output", default="singer_snapshots", help="Output directory") | |
| parser.add_argument("-n", "--samples", type=int, default=45, help="Number of frames to sample") | |
| parser.add_argument("-s", "--snapshots", type=int, default=3, help="Number of final snapshots to export") | |
| args = parser.parse_args() | |
| if "/" not in args.dataset: | |
| print("[ERROR] Dataset must be in the form <username>/<dataset_name>") | |
| sys.exit(1) | |
| kaggle_username, task_id_raw = args.dataset.split("/", 1) | |
| task_id = task_id_raw.replace("-", "_") | |
| kaggle_key = os.environ.get("KAGGLE_KEY", "") | |
| if not kaggle_key: | |
| print("[ERROR] KAGGLE_KEY env var not set") | |
| sys.exit(1) | |
| result = run_singer_detection( | |
| task_id=task_id, | |
| kaggle_username=kaggle_username, | |
| kaggle_key=kaggle_key, | |
| log_callback=lambda msg: print(msg), | |
| num_samples=args.samples, | |
| num_snapshots=args.snapshots, | |
| output_dir=args.output, | |
| ) | |
| if not result["success"]: | |
| sys.exit(1) | |
| if __name__ == "__main__": | |
| main() | |