{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.13.15","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","isInternetEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"aptos-analysis-00","cell_type":"markdown","source":"# Phân tích chất lượng và trùng lặp APTOS 2019\n\n**Đầu vào:** `/kaggle/input/competitions/aptos2019-blindness-detection`.\n**Mục đích:** hiểu dữ liệu đang có vấn đề gì. Không huấn luyện, không chia fold, không loại/sửa ảnh hoặc nhãn,\nkhông xuất dataset mới. Train/test trong báo cáo luôn là **hai thư mục gốc của Competition**.\nCác nhóm và ảnh đại diện chỉ dùng tính thống kê/tìm cặp, không thay đổi dataset.\n\n## Cách chạy\n1. Add Input Competition APTOS 2019; chọn GPU T4 × 2 nếu có (CPU vẫn chạy được).\n2. Chạy từ Cell 1 đến Cell 14. Không cần Internet hoặc tải pretrained model.\n3. Xem đường dẫn kết quả ở cell cuối. Tất cả đầu ra là **JSON và PNG** trong\n   `/kaggle/working/aptos_analysis_<thời điểm>/`.\n\n## Các phần phân tích\n- File/CSV/nhãn thiếu, thừa, không hợp lệ; file hỏng; màu, kích thước, dung lượng.\n- Phân bố lớp, mất cân bằng và baseline dự đoán hằng (mô tả toàn bộ train gốc, không phải điểm test).\n- Trùng byte file và trùng pixel gốc; số nhóm, số ảnh thành viên và số bản dư **riêng từng phạm vi**.\n- Nhãn mâu thuẫn trong nhóm trùng; ảnh đối chiếu có tên file/nhãn.\n- Crop Li trên kênh green → resize 256 → pHash/dHash, khảo sát Hamming 4/5/6.\n- SSIM foreground, tương quan biên, mask IoU cho cặp nghi vấn; thống kê độ bao phủ được ghi rõ.\n- Nhóm liên thông chỉ để chẩn đoán nối dây chuyền; không kết luận các thành viên đều trùng.\n- Độ sáng, tương phản, độ nét, entropy, crop quá mạnh; phân tích theo lớp và theo thư mục gốc.\n\n## Tham khảo góp ý của cô\n`Review-2026-10-04`, phiên bản 3: R4 gợi ý pHash/dHash sau crop–resize 256,\nHamming 4–6 và xem mẫu thủ công; mục 6.4 nêu crop Li kênh green; mục 6.10 nêu baseline majority.\nNotebook áp dụng các phần **phân tích dữ liệu** đó. Các phần chia tập và xử lý nhãn trong tài liệu nằm ngoài yêu cầu lần này.\nSSIM, so sánh biên và cờ chất lượng là các bổ sung phục vụ rà soát.\n\n**Giới hạn:** hash gần/SSIM cao không phải xác nhận cùng ảnh. Cờ chất lượng không phải nhãn lâm sàng.\nKhông có metadata bệnh nhân; không xác minh độc lập theo bệnh nhân hoặc quyết định nhãn đúng.\nKhông tìm kiếm đầy đủ ảnh xoay/lật/crop biến đổi. Ảnh gốc luôn nguyên vẹn.\n","metadata":{}},{"id":"aptos-analysis-01","cell_type":"markdown","source":"## Cell 1 — Thư viện\nKaggle thường có sẵn các gói dưới đây. Nếu thiếu sẽ báo tên gói; notebook không tự cài hoặc đổi phiên bản môi trường.\nPyTorch chỉ dùng tăng tốc khoảng cách Hamming; không có GPU thì tự dùng CPU.\n","metadata":{}},{"id":"aptos-analysis-02","cell_type":"code","source":"from pathlib import Path\nfrom collections import Counter, defaultdict\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime, timezone\nfrom contextlib import contextmanager\nimport os, sys, json, csv, math, time, hashlib, platform, zipfile, html, base64, io\nimport importlib.metadata as metadata\nimport warnings, textwrap, itertools, resource\nfrom functools import lru_cache\n\nimport numpy as np\nimport pandas as pd\nimport cv2\nfrom PIL import Image\nimport matplotlib\nimport matplotlib.pyplot as plt\nfrom tqdm.auto import tqdm\nfrom skimage.filters import threshold_li\nfrom skimage.metrics import structural_similarity\nfrom sklearn.metrics import accuracy_score, balanced_accuracy_score, cohen_kappa_score, f1_score\ntry:\n    from IPython.display import display, FileLink\nexcept ImportError:  # Khi chạy bản .py bên ngoài Jupyter.\n    display = print\n    FileLink = str\n\ntry:\n    import torch\nexcept ImportError:\n    torch = None\n\ncv2.setNumThreads(1)  # Tránh mỗi worker CPU tự tạo thêm nhiều luồng.\nplt.rcParams.update({'font.family': 'DejaVu Sans', 'figure.dpi': 110, 'savefig.dpi': 150})\nprint('Python:', platform.python_version())\nprint('GPU:', [] if torch is None else [torch.cuda.get_device_name(i) for i in range(torch.cuda.device_count())])\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:19:44.751945Z","iopub.execute_input":"2026-10-06T15:19:44.752428Z","iopub.status.idle":"2026-10-06T15:19:44.761611Z","shell.execute_reply.started":"2026-10-06T15:19:44.752401Z","shell.execute_reply":"2026-10-06T15:19:44.760811Z"}},"outputs":[],"execution_count":null},{"id":"aptos-analysis-03","cell_type":"markdown","source":"## Cell 2 — Cấu hình và nơi lưu kết quả\n`tqdm` chỉ cập nhật sau khoảng 10% công việc.\n\n`structural_scope='priority'` (mặc định): đo SSIM cho tất cả cặp đạt cả hai hash ≤6 hoặc pHash ≤2,\nvà tối đa 1.000 cặp yếu lấy ngẫu nhiên có seed. Các cặp hash còn lại vẫn được lưu đầy đủ trong JSON.\nĐặt `structural_scope='all_candidates'` để đo cấu trúc **mọi ứng viên hash**; có thể tốn hàng chục phút hoặc lâu hơn trên CPU.\nNgay cả chế độ đó cũng không chứng minh đã phát hiện mọi biến thể trùng ảnh.\n\nPNG bằng chứng gồm mọi nhóm trùng hoàn toàn, mọi cặp strong, và thêm tối đa 120 cặp ưu tiên khác.\nĐặt `extra_pair_pngs=None` nếu muốn xuất tất cả cặp được đo; có thể tạo rất nhiều file.\n`cache` chỉ chứa bản crop 256 phục vụ phân tích, không phải dataset huấn luyện mới.\n","metadata":{}},{"id":"aptos-analysis-04","cell_type":"code","source":"DATA_ROOT = Path('/kaggle/input/competitions/aptos2019-blindness-detection')\nRUN_ID = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ')\nOUT = Path('/kaggle/working') / f'aptos_analysis_{RUN_ID}'\nCACHE = OUT / 'cache'\nfor folder in ['json', 'figures', 'evidence', 'cache']:\n    (OUT / folder).mkdir(parents=True, exist_ok=True)\n\nCFG = {\n    'seed': 42, 'hash_size': 256, 'progress_fraction': 0.10,\n    'workers': max(1, min(4, os.cpu_count() or 2)),\n    'hash_block_size': 128, 'use_gpu': True, 'max_gpus': 2,\n    'candidate_hamming': 6, 'sensitivity_thresholds': [4, 5, 6],\n    'structural_scope': 'priority', 'weak_hash_sample': 1000,\n    'structural_batch_size': 128,\n    'ssim_priority_threshold': 0.97, 'edge_ncc_priority_threshold': 0.90,\n    'min_mask_overlap': 0.80, 'quality_tail_fraction': 0.05,\n    'extra_pair_pngs': 120, 'quality_pngs_per_flag': 20,\n}\nassert CFG['structural_scope'] in {'priority', 'all_candidates'}\nTIMINGS = {}\nSTART_TIME = time.perf_counter()\nassert DATA_ROOT.is_dir(), f'Chưa Add Input APTOS Competition: {DATA_ROOT}'\n\ndef clean_json(x):\n    if isinstance(x, dict): return {str(k): clean_json(v) for k, v in x.items()}\n    if isinstance(x, (list, tuple, set)): return [clean_json(v) for v in x]\n    if isinstance(x, np.ndarray): return clean_json(x.tolist())\n    if isinstance(x, np.generic): return clean_json(x.item())\n    if isinstance(x, Path): return str(x)\n    if isinstance(x, float) and not math.isfinite(x): return None\n    return x\n\ndef save_json(name, obj):\n    path = OUT / name\n    path.parent.mkdir(parents=True, exist_ok=True)\n    tmp = path.with_suffix(path.suffix + '.tmp')\n    tmp.write_text(json.dumps(clean_json(obj), ensure_ascii=False, indent=2, allow_nan=False), encoding='utf-8')\n    tmp.replace(path)\n\n@contextmanager\ndef timed(name):\n    begin = time.perf_counter()\n    try: yield\n    finally: TIMINGS[name] = time.perf_counter() - begin\n\nclass CoarseProgress:\n    \"\"\"Chỉ gọi tqdm.update khi tích lũy đủ 10% tổng khối lượng.\"\"\"\n    def __init__(self, total, desc, unit='mục'):\n        self.total = int(total)\n        self.step = max(1, math.ceil(total * CFG['progress_fraction']))\n        self.done = self.shown = 0\n        self.bar = tqdm(total=total, desc=desc, unit=unit, mininterval=0, miniters=1)\n    def advance(self, n=1):\n        self.done += n\n        if self.done - self.shown >= self.step or self.done >= self.total:\n            self.bar.update(self.done - self.shown)\n            self.shown = self.done\n    def close(self):\n        if self.done > self.shown: self.bar.update(self.done - self.shown)\n        self.bar.close()\n\ndef file_sha256(path):\n    h = hashlib.sha256()\n    with open(path, 'rb') as f:\n        for block in iter(lambda: f.read(1024 * 1024), b''): h.update(block)\n    return h.hexdigest()\n\nVERSIONS = {}\nfor package in ['numpy','pandas','opencv-python','opencv-python-headless','Pillow','scikit-image','scikit-learn','matplotlib','torch','tqdm']:\n    try: VERSIONS[package] = metadata.version(package)\n    except metadata.PackageNotFoundError: pass\nsave_json('json/config.json', {'config': CFG, 'dataset_root': DATA_ROOT, 'run_id': RUN_ID,\n    'versions': VERSIONS, 'python': sys.version, 'platform': platform.platform(),\n    'gpu_names': [] if torch is None else [torch.cuda.get_device_name(i) for i in range(torch.cuda.device_count())],\n    'teacher_reference': 'Review-2026-10-04 v3: R4, 6.4, 6.10; analysis only',\n    'actions': {'training': False, 'new_splits': False, 'image_removal': False, 'relabeling': False}})\nprint('Output:', OUT)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:19:44.763358Z","iopub.execute_input":"2026-10-06T15:19:44.763771Z","iopub.status.idle":"2026-10-06T15:19:44.807834Z","shell.execute_reply.started":"2026-10-06T15:19:44.763751Z","shell.execute_reply":"2026-10-06T15:19:44.807274Z"}},"outputs":[],"execution_count":null},{"id":"aptos-analysis-05","cell_type":"markdown","source":"## Cell 3 — Đối chiếu CSV và ảnh\nKiểm tra ID trùng, nhãn không hợp lệ, ảnh thiếu/thừa, nhiều file có cùng tên stem.\nNhãn test luôn là `None`; không suy nhãn từ sample submission hoặc từ bản sao trong train.\n“CSV hợp lệ” chỉ nói về cấu trúc/giá trị, chưa chứng minh nhãn bệnh đúng.\n","metadata":{}},{"id":"aptos-analysis-06","cell_type":"code","source":"def inspect_manifest(root):\n    issues, items, csv_info = [], [], {}\n    csv_frames = {}\n    for split in ['train', 'test']:\n        path = root / f'{split}.csv'\n        frame = pd.read_csv(path, dtype=str, keep_default_na=False)\n        assert 'id_code' in frame.columns, f'{path.name} thiếu cột id_code'\n        if split == 'train': assert 'diagnosis' in frame.columns, 'train.csv thiếu diagnosis'\n        csv_frames[split] = frame\n        duplicate_ids = set(frame.loc[frame.id_code.duplicated(keep=False), 'id_code'])\n        csv_info[split] = {'rows': len(frame), 'unique_ids': frame.id_code.nunique(),\n            'columns': list(frame.columns), 'sha256': file_sha256(path)}\n        for code in sorted(duplicate_ids):\n            issues.append({'type': 'duplicate_csv_id', 'split': split, 'id_code': code,\n                           'rows': frame[frame.id_code == code].to_dict('records')})\n        bad_ids = set()\n        for code in frame.id_code:\n            if not code or code.strip() != code or '/' in code or '\\\\' in code:\n                bad_ids.add(code)\n                issues.append({'type': 'invalid_csv_id', 'split': split, 'id_code': code})\n        labels = {}\n        if split == 'train':\n            numeric = pd.to_numeric(frame.diagnosis, errors='coerce')\n            valid = numeric.notna() & numeric.isin([0,1,2,3,4])\n            for pos in np.flatnonzero(~valid.to_numpy()):\n                issues.append({'type': 'invalid_label', 'split': split, **frame.iloc[pos].to_dict()})\n            labels = {row.id_code: int(numeric.iloc[i]) for i, row in enumerate(frame.itertuples(index=False))\n                      if bool(valid.iloc[i]) and row.id_code not in duplicate_ids | bad_ids}\n        folder = root / f'{split}_images'\n        assert folder.is_dir(), f'Thiếu thư mục {folder}'\n        image_paths = sorted(p for p in folder.rglob('*') if p.is_file() and p.suffix.lower() in {'.png','.jpg','.jpeg','.bmp','.tif','.tiff'})\n        by_stem = defaultdict(list)\n        for path in image_paths: by_stem[path.stem].append(path)\n        csv_ids = set(frame.id_code)\n        for code in sorted(csv_ids - set(by_stem)):\n            issues.append({'type': 'missing_image', 'split': split, 'id_code': code})\n        for code in sorted(set(by_stem) - csv_ids):\n            issues.append({'type': 'image_not_in_csv', 'split': split, 'id_code': code})\n        for code, paths in by_stem.items():\n            if len(paths) > 1: issues.append({'type': 'duplicate_file_stem', 'split': split,\n                                            'id_code': code, 'paths': [str(p) for p in paths]})\n        for path in image_paths:\n            items.append({'record_id': f'{split}/{path.relative_to(folder).as_posix()}', 'id_code': path.stem,\n                'split': split, 'path': str(path), 'file_bytes': path.stat().st_size,\n                'label': labels.get(path.stem) if split == 'train' else None,\n                'csv_valid': path.stem in csv_ids and path.stem not in duplicate_ids | bad_ids and len(by_stem[path.stem]) == 1\n                             and (split == 'test' or path.stem in labels)})\n    submission = root / 'sample_submission.csv'\n    if submission.exists():\n        s = pd.read_csv(submission, dtype=str, keep_default_na=False)\n        ids = s['id_code'].tolist() if 'id_code' in s else []\n        csv_info['sample_submission'] = {'rows': len(s), 'columns': list(s.columns),\n            'sha256': file_sha256(submission), 'id_set_equals_test': set(ids) == set(csv_frames['test'].id_code),\n            'duplicate_id_count': len(ids) - len(set(ids)), 'labels_used_as_ground_truth': False}\n        if set(ids) != set(csv_frames['test'].id_code) or len(ids) != len(set(ids)):\n            issues.append({'type': 'submission_id_mismatch'})\n    return sorted(items, key=lambda x: x['record_id']), issues, csv_info\n\nwith timed('manifest'):\n    ITEMS, MANIFEST_ISSUES, CSV_INFO = inspect_manifest(DATA_ROOT)\nsave_json('json/manifest.json', ITEMS)\nsave_json('json/manifest_issues.json', MANIFEST_ISSUES)\nsave_json('json/csv_inventory.json', CSV_INFO)\nprint('Ảnh tìm được:', len(ITEMS), '| Lỗi CSV/đối chiếu:', len(MANIFEST_ISSUES))\ndisplay(pd.DataFrame([{'split': s, 'images': sum(x['split']==s for x in ITEMS)} for s in ['train','test']]))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:19:44.808936Z","iopub.execute_input":"2026-10-06T15:19:44.809155Z","iopub.status.idle":"2026-10-06T15:19:50.080798Z","shell.execute_reply.started":"2026-10-06T15:19:44.809137Z","shell.execute_reply":"2026-10-06T15:19:50.079956Z"}},"outputs":[],"execution_count":null},{"id":"aptos-analysis-07","cell_type":"markdown","source":"## Cell 4 — Pixel hash, crop Li, pHash/dHash và chỉ số chất lượng\nSHA-256 pixel dùng mảng giải mã gốc + shape + dtype, **trước crop/resize**.\nSHA-256 byte kiểm tra nội dung file. Hash của ảnh đã xử lý chỉ hỗ trợ rà soát.\nCác chỉ số chất lượng đo tại 256 px trong foreground ước lượng (gray >10), không phải segmentation chuẩn.\nCrop Li chỉ phục vụ phép đo và ảnh đối chiếu; luôn lưu crop box để kiểm tra mất vùng ảnh.\n","metadata":{}},{"id":"aptos-analysis-08","cell_type":"code","source":"def pixel_hash(arr):\n    h = hashlib.sha256()\n    h.update(str((arr.shape, str(arr.dtype))).encode())\n    h.update(np.ascontiguousarray(arr).tobytes())\n    return h.hexdigest()\n\ndef crop_li(bgr):\n    green = bgr[:, :, 1]\n    fallback = None\n    with warnings.catch_warnings():\n        warnings.simplefilter('ignore', RuntimeWarning)\n        threshold = float(threshold_li(green))\n    mask = green > threshold\n    yy, xx = np.where(mask)\n    if not np.isfinite(threshold) or len(xx) < 100:\n        x0, y0, x1, y1 = 0, 0, bgr.shape[1], bgr.shape[0]\n        fallback = 'empty_or_small_li_mask'\n    else:\n        x0, y0, x1, y1 = int(xx.min()), int(yy.min()), int(xx.max()+1), int(yy.max()+1)\n    return bgr[y0:y1, x0:x1], [x0,y0,x1,y1], threshold, fallback\n\ndef pack_hash(bits):\n    return f'{int.from_bytes(np.packbits(np.asarray(bits, dtype=np.uint8).ravel()).tobytes(), \"big\"):016x}'\n\ndef perceptual_hashes(gray):\n    small = cv2.resize(gray, (32,32), interpolation=cv2.INTER_AREA).astype(np.float32)\n    dct = cv2.dct(small)[:8,:8]\n    ph = pack_hash(dct > np.median(dct.ravel()[1:]))\n    tiny = cv2.resize(gray, (9,8), interpolation=cv2.INTER_AREA)\n    dh = pack_hash(tiny[:,1:] > tiny[:,:-1])\n    return ph, dh\n\ndef quality_metrics(bgr256):\n    gray = cv2.cvtColor(bgr256, cv2.COLOR_BGR2GRAY)\n    mask = gray > 10\n    inner = cv2.erode(mask.astype(np.uint8), np.ones((5,5), np.uint8)).astype(bool)\n    values = gray[mask].astype(np.float64)\n    if len(values) == 0:\n        return {'foreground_fraction': 0.0, 'brightness_mean': None, 'brightness_std': None,\n                'laplacian_variance': None, 'dark_fraction': None, 'bright_fraction': None,\n                'entropy_bits': None, 'red_mean': None, 'green_mean': None, 'blue_mean': None}\n    lap = cv2.Laplacian(gray, cv2.CV_64F)\n    hist = np.bincount(gray[mask], minlength=256).astype(float)\n    p = hist[hist > 0] / hist.sum()\n    return {'foreground_fraction': float(mask.mean()), 'brightness_mean': float(values.mean()),\n        'brightness_std': float(values.std()), 'laplacian_variance': float(lap[inner].var()) if inner.any() else None,\n        'dark_fraction': float((values < 30).mean()), 'bright_fraction': float((values > 245).mean()),\n        'entropy_bits': float(-(p*np.log2(p)).sum()),\n        'blue_mean': float(bgr256[:,:,0][mask].mean()), 'green_mean': float(bgr256[:,:,1][mask].mean()),\n        'red_mean': float(bgr256[:,:,2][mask].mean())}\n\ndef inspect_image(item):\n    result = dict(item)\n    result['error'] = None\n    try:\n        path = Path(item['path'])\n        result['file_sha256'] = file_sha256(path)\n        with Image.open(path) as pil:\n            result['pil_mode'], result['image_format'] = pil.mode, pil.format\n            pil.verify()\n        raw = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)\n        bgr = cv2.imread(str(path), cv2.IMREAD_COLOR)\n        if raw is None or bgr is None: raise ValueError('OpenCV không giải mã được ảnh')\n        result.update(width=int(raw.shape[1]), height=int(raw.shape[0]),\n            channels=1 if raw.ndim==2 else int(raw.shape[2]), dtype=str(raw.dtype),\n            pixel_sha256=pixel_hash(raw), bgr8_sha256=pixel_hash(bgr))\n        crop, box, threshold, fallback = crop_li(bgr)\n        resized = cv2.resize(crop, (CFG['hash_size'],CFG['hash_size']), interpolation=cv2.INTER_AREA)\n        gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)\n        ph, dh = perceptual_hashes(gray)\n        raw_ph, raw_dh = perceptual_hashes(cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY))\n        result.update(crop_box=box, crop_threshold_li=threshold, crop_fallback=fallback,\n            crop_area_ratio=float(crop.shape[0]*crop.shape[1]/(bgr.shape[0]*bgr.shape[1])),\n            phash64=ph, dhash64=dh, full_phash64=raw_ph, full_dhash64=raw_dh,\n            resized_sha256=pixel_hash(resized), aspect_ratio=float(raw.shape[1]/raw.shape[0]))\n        result.update(quality_metrics(resized))\n        name = hashlib.sha256(item['record_id'].encode()).hexdigest()[:24] + '.png'\n        result['cache_name'] = name\n        if not cv2.imwrite(str(CACHE/name), resized): raise OSError('Không ghi được cache ảnh')\n    except Exception as exc:\n        result['error'] = f'{type(exc).__name__}: {exc}'\n    return result\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:19:50.082506Z","iopub.execute_input":"2026-10-06T15:19:50.082829Z","iopub.status.idle":"2026-10-06T15:19:50.101041Z","shell.execute_reply.started":"2026-10-06T15:19:50.082807Z","shell.execute_reply":"2026-10-06T15:19:50.100306Z"}},"outputs":[],"execution_count":null},{"id":"aptos-analysis-09","cell_type":"markdown","source":"## Cell 5 — Quét toàn bộ dataset\nLưu checkpoint JSON mỗi khoảng 10% số ảnh, rồi lưu kết quả từng ảnh và danh sách lỗi.\nKhông tự tái sử dụng cache từ phiên cũ. Thống kê sau này ghi rõ mẫu số nếu có file/nhãn lỗi.\n","metadata":{}},{"id":"aptos-analysis-10","cell_type":"code","source":"with timed('image_scan'):\n    RECORDS = []\n    progress = CoarseProgress(len(ITEMS), 'Đọc ảnh + hash + chất lượng', 'ảnh')\n    checkpoint_step = max(1, math.ceil(len(ITEMS)*CFG['progress_fraction']))\n    with ThreadPoolExecutor(max_workers=CFG['workers']) as pool:\n        futures = [pool.submit(inspect_image, item) for item in ITEMS]\n        for future in as_completed(futures):\n            RECORDS.append(future.result())\n            progress.advance()\n            if len(RECORDS) % checkpoint_step == 0 or len(RECORDS)==len(ITEMS):\n                save_json('json/scan_checkpoint.json', {'completed':len(RECORDS),'total':len(ITEMS),'records':RECORDS})\n    progress.close()\nRECORDS.sort(key=lambda r:r['record_id'])\nGOOD = [r for r in RECORDS if r['error'] is None]\nBY_ID = {r['record_id']:r for r in GOOD}\nsave_json('json/per_image.json', RECORDS)\nsave_json('json/read_errors.json', [r for r in RECORDS if r['error']])\nassert GOOD, 'Không có ảnh đọc được; xem json/read_errors.json.'\nDATASET_FINGERPRINT = hashlib.sha256(json.dumps(\n    [(r['record_id'],r.get('pixel_sha256'),r['label'],r['error']) for r in RECORDS],\n    separators=(',',':')).encode()).hexdigest()\nsave_json('json/dataset_fingerprint.json', {'sha256':DATASET_FINGERPRINT,\n    'definition':'ordered record ID, decoded pixel SHA256, label, error'})\nprint('Đọc được:',len(GOOD),'| Lỗi:',len(RECORDS)-len(GOOD))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:19:50.101935Z","iopub.execute_input":"2026-10-06T15:19:50.102195Z","iopub.status.idle":"2026-10-06T15:30:04.804937Z","shell.execute_reply.started":"2026-10-06T15:19:50.102165Z","shell.execute_reply":"2026-10-06T15:30:04.804249Z"}},"outputs":[],"execution_count":null},{"id":"aptos-analysis-11","cell_type":"markdown","source":"## Cell 6 — Trùng hoàn toàn và mâu thuẫn nhãn\nTách thống kê toàn bộ/train gốc/test gốc/giao hai thư mục. Không cộng các phạm vi chồng lấp.\n“Số bản dư” = tổng (số file trong nhóm − 1), chỉ là thống kê giả định giữ một đại diện; **không loại file nào**.\nMột nhóm có nhiều nhãn xác nhận sự không nhất quán, chưa biết nhãn nào đúng.\n","metadata":{}},{"id":"aptos-analysis-12","cell_type":"code","source":"def group_by(rows, key):\n    groups = defaultdict(list)\n    for row in rows: groups[row[key]].append(row)\n    return groups\n\ndef describe_group(group_id, members):\n    labels = sorted({r['label'] for r in members if r['label'] is not None})\n    train = [r for r in members if r['split']=='train' and r['label'] is not None]\n    splits = sorted({r['split'] for r in members})\n    return {'group_id': group_id, 'n_images': len(members), 'n_train': sum(r['split']=='train' for r in members),\n        'n_labeled_train':len(train),\n        'n_test': sum(r['split']=='test' for r in members), 'splits': splits,\n        'cross_original_split': len(splits)>1, 'labels': labels, 'label_conflict': len(labels)>1,\n        'label_counts': dict(Counter(r['label'] for r in train)),\n        'members': [r['record_id'] for r in members]}\n\nPIXEL_GROUPS = group_by(GOOD, 'pixel_sha256')\nEXACT_GROUPS = []\nEXACT_ID = {}\nfor digest, members in sorted(PIXEL_GROUPS.items()):\n    gid = 'EXACT_' + digest[:20]\n    for r in members: EXACT_ID[r['record_id']] = gid\n    if len(members)>1: EXACT_GROUPS.append(describe_group(gid, members))\nCONFLICT_GROUPS = [g for g in EXACT_GROUPS if g['label_conflict']]\nTRAIN_ROWS = [r for r in GOOD if r['split']=='train' and r['label'] is not None and r['csv_valid']]\nTRAIN_PIXEL_GROUPS = group_by(TRAIN_ROWS, 'pixel_sha256')\nTRAIN_DUP_GROUPS = [members for members in TRAIN_PIXEL_GROUPS.values() if len(members)>1]\nsave_json('json/exact_groups.json', EXACT_GROUPS)\nsave_json('json/exact_conflict_groups.json', CONFLICT_GROUPS)\nsave_json('json/file_byte_duplicate_groups.json', [describe_group('FILE_'+digest[:20], rows)\n    for digest, rows in sorted(group_by(GOOD,'file_sha256').items()) if len(rows)>1])\nsave_json('json/processed_equal_groups_for_review.json', [describe_group('PROC_'+digest[:20], rows)\n    for digest, rows in sorted(group_by(GOOD,'resized_sha256').items())\n    if len({r['pixel_sha256'] for r in rows})>1])\nprint('Nhóm trùng pixel:', len(EXACT_GROUPS), '| Nhóm nhãn mâu thuẫn:', len(CONFLICT_GROUPS))\nprint('Số bản dư trong train có nhãn:', sum(len(g)-1 for g in TRAIN_DUP_GROUPS))\n\n# Ba phạm vi độc lập để tránh nhầm ảnh có bản sao bên test với trùng nội bộ train.\ndef duplicate_statistics(rows):\n    classes = group_by(rows, 'pixel_sha256')\n    duplicates = [v for v in classes.values() if len(v)>1]\n    return {'images':len(rows),'unique_pixel_contents':len(classes),\n        'duplicate_groups':len(duplicates),\n        'duplicate_member_images':sum(len(v) for v in duplicates),\n        'redundant_images':sum(len(v)-1 for v in duplicates)}\nEXACT_STATS = {scope:duplicate_statistics(rows) for scope,rows in [\n    ('all_readable',GOOD),('original_train_readable',[r for r in GOOD if r['split']=='train']),\n    ('valid_labeled_train',TRAIN_ROWS),('original_test_readable',[r for r in GOOD if r['split']=='test'])]}\nCROSS_GROUPS = [g for g in EXACT_GROUPS if g['cross_original_split']]\nsave_json('json/exact_duplicate_statistics.json',EXACT_STATS)\nsave_json('json/exact_cross_original_folders.json',{'groups':CROSS_GROUPS,\n    'n_groups':len(CROSS_GROUPS),'train_images':sum(g['n_train'] for g in CROSS_GROUPS),\n    'test_images':sum(g['n_test'] for g in CROSS_GROUPS),\n    'note':'Overlap of the ORIGINAL folders only; no new data split exists in this notebook.'})\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:30:04.807016Z","iopub.execute_input":"2026-10-06T15:30:04.807598Z","iopub.status.idle":"2026-10-06T15:30:04.88908Z","shell.execute_reply.started":"2026-10-06T15:30:04.807574Z","shell.execute_reply":"2026-10-06T15:30:04.888223Z"}},"outputs":[],"execution_count":null},{"id":"aptos-analysis-13","cell_type":"markdown","source":"## Cell 7 — Bảng tiếng Việt/tiếng Anh, phân bố lớp và chất lượng\nMỗi bảng tổng hợp có bản `_en.json`, `_vi.json` và PNG tương ứng.\nCác cờ chất lượng dùng đuôi 5% của train gốc có nhãn; có thể chồng lấp, không phải tiêu chuẩn loại ảnh.\nMa trận nhãn mâu thuẫn đếm **nhóm ảnh**, đối xứng, không phải confusion matrix dự đoán.\nSố nội dung có từng nhãn không được cộng để suy ra số nội dung toàn bộ vì nhóm mâu thuẫn có nhiều nhãn.\n","metadata":{}},{"id":"aptos-analysis-14","cell_type":"code","source":"TABLE_SCHEMAS = {}\nVALUE_NAMES = {\n    'train':'Train gốc','test':'Test gốc',\n    'brightness_mean':'Độ sáng trung bình','brightness_std':'Độ lệch chuẩn độ sáng',\n    'laplacian_variance':'Phương sai Laplacian','dark_fraction':'Tỷ lệ pixel tối',\n    'bright_fraction':'Tỷ lệ pixel rất sáng','entropy_bits':'Entropy mức xám',\n    'foreground_fraction':'Tỷ lệ vùng ảnh','crop_area_ratio':'Tỷ lệ diện tích crop',\n}\nVALUE_NAMES.update({\n    'brightness_mean_low_tail':'Độ sáng thấp', 'brightness_mean_high_tail':'Độ sáng cao',\n    'brightness_std_low_tail':'Độ tương phản thấp', 'laplacian_variance_low_tail':'Độ nét thấp theo Laplacian',\n    'crop_area_ratio_low_tail':'Tỷ lệ crop thấp', 'crop_fallback':'Crop dùng ảnh nguyên',\n    'very_small_foreground':'Foreground rất nhỏ', 'any_flag_unique_images':'Ảnh có ít nhất một cờ',\n    'scope':'Phạm vi', 'predicted_grade':'Mức luôn dự đoán', 'accuracy':'Độ chính xác',\n    'balanced_accuracy':'Độ chính xác cân bằng', 'qwk':'QWK', 'macro_f1':'F1 trung bình vĩ mô',\n    'all valid labeled original train; descriptive only, not held-out evaluation':\n        'Toàn bộ train gốc có nhãn hợp lệ; chỉ mô tả, không đánh giá trên tập độc lập'\n})\ndef export_table(name, rows, vi_columns, render=True):\n    frame = pd.DataFrame(rows)\n    TABLE_SCHEMAS[name] = [{'english':c,'vietnamese':vi_columns.get(c,c)} for c in frame.columns]\n    for language in ['en','vi']:\n        table = frame.copy()\n        if language=='vi':\n            for column in ['metric','split','scope','flag','value']:\n                if column in table: table[column] = table[column].map(lambda x:VALUE_NAMES.get(x,x))\n            table = table.rename(columns=vi_columns)\n        save_json(f'json/tables/{name}_{language}.json', table.to_dict('records'))\n        if not render or table.empty: continue\n        for start in range(0,len(table),16):\n            part=table.iloc[start:start+16]\n            def formatted(v):\n                if isinstance(v,float): return f'{v:.5g}'\n                return textwrap.fill(str(v), width=32)\n            values=[[formatted(v) for v in row] for row in part.values]\n            headers=[textwrap.fill(str(c),width=23) for c in part.columns]\n            heights=[max(len(x.splitlines()) for x in row) for row in values]\n            fig,ax=plt.subplots(figsize=(max(10,2.15*len(headers)),1.8+sum(max(1,h)*.36 for h in heights)))\n            ax.axis('off')\n            tab=ax.table(cellText=values,colLabels=headers,loc='center',cellLoc='center')\n            tab.auto_set_font_size(False);tab.set_fontsize(9)\n            for (ri,ci),cell in tab.get_celld().items():\n                cell.set_edgecolor('#d9e2ec')\n                cell.set_height((.75 if ri==0 else .39*max(1,heights[ri-1]))/fig.get_figheight())\n                if ri==0:cell.set_facecolor('#16324f');cell.set_text_props(color='white',weight='bold')\n                else:cell.set_facecolor('#f0f5fa' if ri%2==0 else 'white')\n            fig.tight_layout()\n            folder=OUT/'figures/tables';folder.mkdir(exist_ok=True)\n            fig.savefig(folder/f'{name}_{language}_{start//16+1:02d}.png',bbox_inches='tight')\n            plt.close(fig)\n    return frame\n\nclass_rows = []\nconflict_ids = {rid for g in CONFLICT_GROUPS for rid in g['members']}\ntrain_dup_ids = {r['record_id'] for group in TRAIN_DUP_GROUPS for r in group}\nfor label in range(5):\n    rows = [r for r in TRAIN_ROWS if r['label']==label]\n    class_rows.append({'grade': label, 'images': len(rows), 'percent': 100*len(rows)/max(1,len(TRAIN_ROWS)),\n        'unique_pixels_with_grade': len({r['pixel_sha256'] for r in rows}),\n        'duplicate_member_images': sum(r['record_id'] in train_dup_ids for r in rows),\n        'conflict_member_images': sum(r['record_id'] in conflict_ids for r in rows)})\nexport_table('class_distribution', class_rows, {'grade':'Mức bệnh','images':'Số ảnh','percent':'Tỷ lệ (%)',\n    'unique_pixels_with_grade':'Nội dung có nhãn này','duplicate_member_images':'Ảnh thuộc nhóm trùng',\n    'conflict_member_images':'Ảnh thuộc nhóm mâu thuẫn'})\n\n# Mỗi ô đếm số NHÓM trùng pixel có cả hai nhãn, không phải confusion matrix dự đoán.\nconflict_matrix=np.zeros((5,5),dtype=int)\nfor group in CONFLICT_GROUPS:\n    for a in group['labels']:\n        for b in group['labels']:\n            if a!=b: conflict_matrix[a,b]+=1\nsave_json('json/exact_label_conflict_matrix.json',{'matrix':conflict_matrix,\n    'unit':'number of exact-pixel groups containing both row and column labels; symmetric',\n    'not_prediction_confusion_matrix':True})\nfig,ax=plt.subplots(figsize=(6,5))\nim=ax.imshow(conflict_matrix,cmap='Oranges',vmin=0)\nfor a in range(5):\n    for b in range(5): ax.text(b,a,str(conflict_matrix[a,b]),ha='center',va='center')\nax.set(xticks=range(5),yticks=range(5),xlabel='Nhãn / Label',ylabel='Nhãn / Label',\n       title='Nhóm trùng pixel có hai nhãn\\nExact-group label conflicts (not predictions)')\nfig.colorbar(im,ax=ax,label='Số nhóm / Groups');fig.tight_layout()\nfig.savefig(OUT/'figures/exact_label_conflicts.png');plt.close(fig)\n\nDIMENSIONS = [{'split':s,'width':w,'height':h,'images':n}\n    for (s,w,h),n in sorted(Counter((r['split'],r['width'],r['height']) for r in GOOD).items())]\nexport_table('dimensions', DIMENSIONS, {'split':'Tập gốc','width':'Chiều rộng','height':'Chiều cao','images':'Số ảnh'})\nQUALITY_FIELDS = ['brightness_mean','brightness_std','laplacian_variance','dark_fraction','bright_fraction',\n                  'entropy_bits','foreground_fraction','crop_area_ratio']\nquality_summary = []\nfor split in ['train','test']:\n    for metric in QUALITY_FIELDS:\n        values = np.array([r[metric] for r in GOOD if r['split']==split and r.get(metric) is not None],float)\n        if len(values):\n            quality_summary.append({'split':split,'metric':metric,'count':len(values),'mean':values.mean(),\n                'std':values.std(),'min':values.min(),'p05':np.quantile(values,.05),\n                'median':np.median(values),'p95':np.quantile(values,.95),'max':values.max()})\nexport_table('quality_statistics', quality_summary, {'split':'Tập gốc','metric':'Chỉ số','count':'Số ảnh',\n    'mean':'Trung bình','std':'Độ lệch chuẩn','min':'Nhỏ nhất','p05':'Phân vị 5%',\n    'median':'Trung vị','p95':'Phân vị 95%','max':'Lớn nhất'})\nTAIL_THRESHOLDS = {}\nfor field in ['brightness_mean','brightness_std','laplacian_variance','crop_area_ratio']:\n    values = [r[field] for r in TRAIN_ROWS if r.get(field) is not None]\n    tail=CFG['quality_tail_fraction']\n    TAIL_THRESHOLDS[field] = None if not values else [float(np.quantile(values,tail)),float(np.quantile(values,1-tail))]\nfor r in GOOD:\n    flags = []\n    for field, limits in TAIL_THRESHOLDS.items():\n        value = r.get(field)\n        if value is None or limits is None: continue\n        if value < limits[0]: flags.append(field+'_low_tail')\n        if field=='brightness_mean' and value > limits[1]: flags.append(field+'_high_tail')\n    if r['crop_fallback']: flags.append('crop_fallback')\n    if r['foreground_fraction']<.10: flags.append('very_small_foreground')\n    r['quality_review_flags'] = flags\nsave_json('json/quality_review_thresholds.json', {'reference':'labeled original train, descriptive only',\n    'thresholds':TAIL_THRESHOLDS, 'warning':'Flags are not clinical quality labels; no automatic removal.'})\nsave_json('json/quality_flagged_images.json', [r for r in GOOD if r['quality_review_flags']])\n\nfig, axes = plt.subplots(2,2,figsize=(13,9))\naxes[0,0].bar(range(5),[r['images'] for r in class_rows],color='#2878a6')\naxes[0,0].set(title='Phân bố nhãn / Grade distribution',xlabel='Mức bệnh / Grade',ylabel='Số ảnh / Images')\nfor label,row in enumerate(class_rows): axes[0,0].text(label,row['images'],str(row['images']),ha='center',va='bottom')\nfor split,color in [('train','#2878a6'),('test','#de8f05')]:\n    rows=[r for r in GOOD if r['split']==split]\n    axes[0,1].scatter([r['width'] for r in rows],[r['height'] for r in rows],s=12,alpha=.4,label=split,color=color)\n    for ax,field,title in [(axes[1,0],'brightness_mean','Độ sáng / Brightness'),\n                           (axes[1,1],'laplacian_variance','Độ nét / Laplacian variance')]:\n        vals=[r[field] for r in rows if r.get(field) is not None]\n        if field=='laplacian_variance': vals=np.log1p(vals); ax.set_xlabel('log(1 + variance)')\n        ax.hist(vals,bins=40,alpha=.5,label=split,color=color,density=True); ax.set_title(title); ax.legend()\naxes[0,1].set(title='Kích thước / Resolution',xlabel='Width',ylabel='Height'); axes[0,1].legend()\nfig.tight_layout(); fig.savefig(OUT/'figures/data_overview.png'); plt.show(); plt.close(fig)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:30:04.890221Z","iopub.execute_input":"2026-10-06T15:30:04.890937Z","iopub.status.idle":"2026-10-06T15:30:09.908328Z","shell.execute_reply.started":"2026-10-06T15:30:04.890879Z","shell.execute_reply":"2026-10-06T15:30:09.907598Z"}},"outputs":[],"execution_count":null},{"id":"aptos-analysis-15","cell_type":"markdown","source":"## Cell 8 — Phân tích bổ sung theo lớp, kích thước và các cờ chất lượng\nĐếm số ảnh có ít nhất một cờ, không cộng chồng các cờ. Bảng kích thước × Grade chỉ mô tả mối liên hệ,\nkhông khẳng định thiết bị hoặc kích thước gây bệnh. Baseline majority được tính trên toàn bộ train có nhãn,\n**không có huấn luyện hoặc đánh giá tổng quát hóa**. Không có lớp nào sẽ ghi giá trị không xác định phù hợp.\n","metadata":{}},{"id":"aptos-analysis-16","cell_type":"code","source":"with timed('descriptive_analysis'):\n    flag_names=sorted({flag for r in GOOD for flag in r['quality_review_flags']})\n    flag_rows=[]\n    for split in ['train','test']:\n        rr=[r for r in GOOD if r['split']==split]\n        for flag in flag_names:\n            count=sum(flag in r['quality_review_flags'] for r in rr)\n            flag_rows.append({'split':split,'flag':flag,'images':count,'percent':100*count/max(1,len(rr))})\n        count=sum(bool(r['quality_review_flags']) for r in rr)\n        flag_rows.append({'split':split,'flag':'any_flag_unique_images','images':count,'percent':100*count/max(1,len(rr))})\n    export_table('quality_flag_counts',flag_rows,{'split':'Tập gốc','flag':'Cờ xem lại','images':'Số ảnh','percent':'Tỷ lệ (%)'})\n    per_grade_quality=[]\n    for grade in range(5):\n        rr=[r for r in TRAIN_ROWS if r['label']==grade]\n        for field in QUALITY_FIELDS:\n            vals=[r[field] for r in rr if r.get(field) is not None]\n            if vals:per_grade_quality.append({'grade':grade,'metric':field,'count':len(vals),\n                'p05':np.quantile(vals,.05),'median':np.median(vals),'p95':np.quantile(vals,.95)})\n    export_table('quality_by_grade',per_grade_quality,{'grade':'Mức bệnh','metric':'Chỉ số','count':'Số ảnh',\n        'p05':'Phân vị 5%','median':'Trung vị','p95':'Phân vị 95%'})\n    resolution_grade=Counter((r['width'],r['height'],r['label']) for r in TRAIN_ROWS)\n    dims=sorted({(r['width'],r['height']) for r in TRAIN_ROWS})\n    resolution_rows=[{'width':w,'height':h,**{f'grade_{g}':resolution_grade[w,h,g] for g in range(5)}} for w,h in dims]\n    export_table('resolution_by_grade',resolution_rows,{'width':'Chiều rộng','height':'Chiều cao',\n        **{f'grade_{g}':f'Mức {g}' for g in range(5)}})\n    if dims:\n        matrix=np.array([[resolution_grade[w,h,g] for g in range(5)] for w,h in dims])\n        fig,ax=plt.subplots(figsize=(9,max(4,.4*len(dims))))\n        im=ax.imshow(np.log1p(matrix),aspect='auto',cmap='Blues')\n        ax.set(xticks=range(5),xticklabels=range(5),yticks=range(len(dims)),\n            yticklabels=[f'{w} × {h}' for w,h in dims],xlabel='Grade',ylabel='Kích thước / Resolution',\n            title='Train gốc: số ảnh theo kích thước và nhãn')\n        for i in range(len(dims)):\n            for j in range(5):ax.text(j,i,str(matrix[i,j]),ha='center',va='center',fontsize=8,\n                color='white' if np.log1p(matrix[i,j])>np.log1p(matrix.max())*.6 else 'black')\n        fig.colorbar(im,ax=ax,label='log(1 + số ảnh)');fig.tight_layout()\n        fig.savefig(OUT/'figures/resolution_by_grade.png');plt.close(fig)\n    counts=np.array([sum(r['label']==g for r in TRAIN_ROWS) for g in range(5)])\n    positive=counts[counts>0]\n    CLASS_BALANCE={'counts':counts,'missing_grades':np.flatnonzero(counts==0),\n        'largest_to_smallest_present_class':float(positive.max()/positive.min()) if len(positive) else None,\n        'note':'If any class is missing, the ratio over present classes does not describe all five classes.'}\n    save_json('json/class_balance.json',CLASS_BALANCE)\n    BASELINE={'scope':'all valid labeled original train; descriptive only, not held-out evaluation'}\n    if len(TRAIN_ROWS):\n        y=np.array([r['label'] for r in TRAIN_ROWS]);majority=int(counts.argmax());pred=np.full_like(y,majority)\n        BASELINE.update(predicted_grade=majority,accuracy=float((pred==y).mean()),\n            balanced_accuracy=float(balanced_accuracy_score(y,pred)),\n            macro_f1=float(f1_score(y,pred,labels=list(range(5)),average='macro',zero_division=0)),\n            qwk=float(cohen_kappa_score(y,pred,labels=list(range(5)),weights='quadratic')) if len(positive)>1 else None)\n    save_json('json/majority_baseline_descriptive.json',BASELINE)\n    export_table('majority_baseline',[{'metric':k,'value':v} for k,v in BASELINE.items()],{'metric':'Chỉ số','value':'Giá trị'})\n    save_json('json/format_inventory.json',[{'format':f,'mode':m,'dtype':dt,'channels':ch,'images':n}\n        for (f,m,dt,ch),n in Counter((r['image_format'],r['pil_mode'],r['dtype'],r['channels']) for r in GOOD).items()])\n    save_json('json/crop_review.json',{'heuristic_not_error_count':True,\n        'below_half_area':[r['record_id'] for r in GOOD if r['crop_area_ratio']<.5],\n        'below_quarter_area':[r['record_id'] for r in GOOD if r['crop_area_ratio']<.25],\n        'fallback':[r['record_id'] for r in GOOD if r['crop_fallback']]})\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:30:09.909314Z","iopub.execute_input":"2026-10-06T15:30:09.909643Z","iopub.status.idle":"2026-10-06T15:30:14.8146Z","shell.execute_reply.started":"2026-10-06T15:30:09.909621Z","shell.execute_reply":"2026-10-06T15:30:14.813896Z"}},"outputs":[],"execution_count":null},{"id":"aptos-analysis-17","cell_type":"markdown","source":"## Cell 9 — pHash/dHash mọi cặp nội dung pixel khác nhau\nMỗi nhóm trùng hoàn toàn lấy một đại diện chỉ để tránh tính lặp cùng nội dung.\nTìm ứng viên khi pHash ≤6 **hoặc** dHash ≤6. Lưu cả thành viên/nhãn của nhóm đại diện.\nGPU T4 × 2 xử lý các block Hamming; đối chiếu mẫu với XOR CPU trước khi chạy.\nKhông dùng tên file để suy ra bệnh nhân.\n","metadata":{}},{"id":"aptos-analysis-18","cell_type":"code","source":"REPS = [sorted(members,key=lambda r:r['record_id'])[0] for _,members in sorted(PIXEL_GROUPS.items())]\nREP_MEMBERS = {r['record_id']: PIXEL_GROUPS[r['pixel_sha256']] for r in REPS}\n\ndef hashes_as_bytes(rows, field):\n    return np.array([list(bytes.fromhex(r[field])) for r in rows],dtype=np.uint8)\n\nPOPCOUNT = np.array([i.bit_count() for i in range(256)],dtype=np.uint8)\nP_BYTES = hashes_as_bytes(REPS,'phash64')\nD_BYTES = hashes_as_bytes(REPS,'dhash64')\n\ndef cpu_hash_distances(start, end):\n    pdist = POPCOUNT[np.bitwise_xor(P_BYTES[start:end,None,:],P_BYTES[None,:,:])].sum(2).astype(np.int16)\n    ddist = POPCOUNT[np.bitwise_xor(D_BYTES[start:end,None,:],D_BYTES[None,:,:])].sum(2).astype(np.int16)\n    return pdist, ddist\n\nGPU_DATA = []\nif CFG['use_gpu'] and torch is not None and torch.cuda.is_available():\n    torch.backends.cuda.matmul.allow_tf32 = False\n    torch.backends.cudnn.allow_tf32 = False\n    for device_id in range(min(CFG['max_gpus'],torch.cuda.device_count())):\n        device = torch.device(f'cuda:{device_id}')\n        ph = torch.tensor(np.unpackbits(P_BYTES,axis=1).copy(),dtype=torch.float32,device=device)\n        dh = torch.tensor(np.unpackbits(D_BYTES,axis=1).copy(),dtype=torch.float32,device=device)\n        GPU_DATA.append((ph,dh))\n\ndef gpu_hash_distances(start,end,device_id):\n    with torch.inference_mode():\n        values=[]\n        for bits in GPU_DATA[device_id]:\n            sums = bits.sum(1)\n            dist = sums[start:end,None]+sums[None,:]-2*(bits[start:end] @ bits.T)\n            values.append(dist.round().to(torch.int16).cpu().numpy())\n        return values\n\nif GPU_DATA:\n    end = min(4,len(REPS))\n    cpu = cpu_hash_distances(0,end)\n    for device_id in range(len(GPU_DATA)):\n        gpu = gpu_hash_distances(0,end,device_id)\n        assert all(np.array_equal(a,b) for a,b in zip(cpu,gpu)), 'Hamming GPU khác CPU'\n\ndef compare_block(start,end,device_id=None):\n    pdist,ddist = cpu_hash_distances(start,end) if device_id is None else gpu_hash_distances(start,end,device_id)\n    upper = np.arange(len(REPS))[None,:] > np.arange(start,end)[:,None]\n    candidate = upper & ((pdist<=CFG['candidate_hamming']) | (ddist<=CFG['candidate_hamming']))\n    aa,bb = np.where(candidate)\n    result = np.column_stack((aa+start,bb,pdist[aa,bb],ddist[aa,bb])).astype(np.int32)\n    total = int(sum(max(0,len(REPS)-i-1) for i in range(start,end)))\n    return result,total\n\nwith timed('all_pair_hash_comparison'):\n    progress = CoarseProgress(len(REPS)*(len(REPS)-1)//2,'Hamming mọi cặp','cặp')\n    chunks=[]\n    # Mỗi đợt tối đa một block/GPU, tránh nhiều luồng cùng chiếm một thiết bị.\n    blocks=[(s,min(s+CFG['hash_block_size'],len(REPS))) for s in range(0,len(REPS),CFG['hash_block_size'])]\n    if GPU_DATA:\n        with ThreadPoolExecutor(max_workers=len(GPU_DATA)) as pool:\n            for start in range(0,len(blocks),len(GPU_DATA)):\n                jobs=[pool.submit(compare_block,a,b,d) for d,(a,b) in enumerate(blocks[start:start+len(GPU_DATA)])]\n                for job in as_completed(jobs):\n                    chunk,total=job.result(); chunks.append(chunk); progress.advance(total)\n    else:\n        for a,b in blocks:\n            chunk,total=compare_block(a,b); chunks.append(chunk); progress.advance(total)\n    progress.close()\nPAIR_ARRAY = np.vstack(chunks) if chunks else np.empty((0,4),np.int32)\nif len(PAIR_ARRAY): PAIR_ARRAY = PAIR_ARRAY[np.lexsort((PAIR_ARRAY[:,1],PAIR_ARRAY[:,0]))]\n\n\ndef pair_row(values):\n    ai,bi,pd_,dd_=map(int,values)\n    a,b=REPS[ai],REPS[bi]\n    am,bm=REP_MEMBERS[a['record_id']],REP_MEMBERS[b['record_id']]\n    la=sorted({r['label'] for r in am if r['label'] is not None})\n    lb=sorted({r['label'] for r in bm if r['label'] is not None})\n    pair_id=hashlib.sha256('|'.join(sorted([a['record_id'],b['record_id']])).encode()).hexdigest()[:24]\n    return {'pair_id':pair_id,'a':a['record_id'],'b':b['record_id'],\n        'phash_distance':pd_,'dhash_distance':dd_,\n        'a_group_labels':la,'b_group_labels':lb,\n        'a_group_members':[r['record_id'] for r in am], 'b_group_members':[r['record_id'] for r in bm],\n        'cross_original_split_possible':len({r['split'] for r in am+bm})>1,\n        'multiple_observed_labels':len(set(la+lb))>1,\n        'review_status':'not_reviewed'}\n\n# Ghi JSON tuần tự để hạn chế RAM; không giữ tất cả dictionary cùng lúc.\nwith open(OUT/'json/near_candidates.json','w',encoding='utf-8') as jf:\n    jf.write('[\\n')\n    for i,values in enumerate(PAIR_ARRAY):\n        jf.write((',' if i else '')+json.dumps(pair_row(values),ensure_ascii=False)+'\\n')\n    jf.write(']\\n')\n\nSENSITIVITY=[]\nfor threshold in CFG['sensitivity_thresholds']:\n    p=PAIR_ARRAY[:,2]<=threshold; d=PAIR_ARRAY[:,3]<=threshold\n    SENSITIVITY.append({'hamming_threshold':threshold,'phash_only':int((p&~d).sum()),\n        'dhash_only':int((d&~p).sum()),'both_hashes':int((p&d).sum()),'either_hash':int((p|d).sum())})\nexport_table('hash_sensitivity',SENSITIVITY,{'hamming_threshold':'Ngưỡng Hamming','phash_only':'Chỉ pHash đạt',\n    'dhash_only':'Chỉ dHash đạt','both_hashes':'Cả hai đạt','either_hash':'Ít nhất một đạt'})\nprint('Backend:',f'{len(GPU_DATA)} GPU' if GPU_DATA else 'CPU','| Cặp ứng viên:',len(PAIR_ARRAY))\n\n# Phạm vi liên quan đến hai thư mục gốc, đơn vị là cặp NỘI DUNG đại diện.\npair_scope=Counter()\nfor values in PAIR_ARRAY:\n    ai,bi=map(int,values[:2]);am=REP_MEMBERS[REPS[ai]['record_id']];bm=REP_MEMBERS[REPS[bi]['record_id']]\n    aa={r['split'] for r in am};bb={r['split'] for r in bm}\n    if 'train' in aa and 'train' in bb:pair_scope['both_have_original_train']+=1\n    if 'test' in aa and 'test' in bb:pair_scope['both_have_original_test']+=1\n    if ('train' in aa and 'test' in bb) or ('test' in aa and 'train' in bb):pair_scope['cross_original_folders_possible']+=1\nsave_json('json/near_candidate_scopes.json',{'counts':dict(pair_scope),\n    'overlapping_categories':True,'unit':'unordered pair of distinct exact-pixel contents'})\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:30:14.815859Z","iopub.execute_input":"2026-10-06T15:30:14.816245Z","iopub.status.idle":"2026-10-06T15:30:20.492169Z","shell.execute_reply.started":"2026-10-06T15:30:14.816197Z","shell.execute_reply":"2026-10-06T15:30:20.491519Z"}},"outputs":[],"execution_count":null},{"id":"aptos-analysis-19","cell_type":"markdown","source":"## Cell 10 — Đo SSIM và đường nét; ghi rõ độ bao phủ\nSSIM được tính trên giao foreground đã co biên; lưu thêm SSIM toàn ảnh, edge NCC, mask IoU và MAE.\nĐiểm cao chỉ đưa vào danh sách ưu tiên, **không tự xác nhận bản sao hay sai nhãn**.\nKhông căn chỉnh xoay/lật. Thang xám/crop 256 có thể che khuất khác biệt ở ảnh gốc.\nChế độ mặc định đo toàn bộ cặp ưu tiên cộng mẫu ngẫu nhiên; `all_candidates` đo mọi ứng viên hash.\nCheckpoint theo batch chỉ giữ cặp đã đo để có bằng chứng nếu phiên bị ngắt.\n","metadata":{}},{"id":"aptos-analysis-20","cell_type":"code","source":"priority = ((PAIR_ARRAY[:,2]<=6)&(PAIR_ARRAY[:,3]<=6)) | (PAIR_ARRAY[:,2]<=2)\nweak_indices = np.flatnonzero(~priority)\nrng=np.random.default_rng(CFG['seed'])\nsample_weak=set(map(int,rng.choice(weak_indices,min(CFG['weak_hash_sample'],len(weak_indices)),replace=False)))\nselected_indices=(list(range(len(PAIR_ARRAY))) if CFG['structural_scope']=='all_candidates'\n                  else sorted(set(map(int,np.flatnonzero(priority)))|sample_weak))\n\n@lru_cache(maxsize=256)\ndef load_gray(record_id):\n    bgr=cv2.imread(str(CACHE/BY_ID[record_id]['cache_name']),cv2.IMREAD_COLOR)\n    if bgr is None: raise OSError(f'Thiếu cache {record_id}; chạy lại Cell 5')\n    return cv2.cvtColor(bgr,cv2.COLOR_BGR2GRAY)\n\ndef image_pair_metrics(gray_a,gray_b):\n    ma,mb=gray_a>10,gray_b>10\n    mask=cv2.erode((ma&mb).astype(np.uint8),np.ones((7,7),np.uint8)).astype(bool)\n    overlap=float((ma&mb).sum()/max(1,(ma|mb).sum()))\n    score,score_map=structural_similarity(gray_a,gray_b,data_range=255,full=True)\n    ssim_fg=float(score_map[mask].mean()) if mask.sum()>=100 else None\n    vectors=[]\n    for gray in [gray_a,gray_b]:\n        gx=cv2.Sobel(gray,cv2.CV_32F,1,0,ksize=3)\n        gy=cv2.Sobel(gray,cv2.CV_32F,0,1,ksize=3)\n        vector=np.concatenate([gx[mask],gy[mask]]).astype(np.float64)\n        vectors.append(vector-vector.mean() if len(vector) else vector)\n    denominator=float(np.linalg.norm(vectors[0])*np.linalg.norm(vectors[1]))\n    ncc=float(np.dot(*vectors)/denominator) if denominator>1e-12 else None\n    return {'ssim_full':float(score),'ssim_foreground':ssim_fg,'edge_ncc':ncc,\n        'mask_iou':overlap,'mask_pixels':int(mask.sum()),\n        'mae_foreground_255':float(np.abs(gray_a.astype(float)-gray_b)[mask].mean()) if mask.any() else None}\n\ndef inspect_pair(array_index):\n    row=pair_row(PAIR_ARRAY[array_index])\n    row['selection_reason']=('priority_hash_rule' if priority[array_index] else\n        ('all_candidates_mode' if CFG['structural_scope']=='all_candidates' else 'random_weak_hash_sample'))\n    try:\n        row.update(image_pair_metrics(load_gray(row['a']),load_gray(row['b'])))\n        row['structural_error']=None\n        row['strong_visual_candidate']=bool(\n            row['ssim_foreground'] is not None and row['ssim_foreground']>=CFG['ssim_priority_threshold'] and\n            row['edge_ncc'] is not None and row['edge_ncc']>=CFG['edge_ncc_priority_threshold'] and\n            row['mask_iou']>=CFG['min_mask_overlap'])\n    except Exception as exc:\n        row['structural_error']=f'{type(exc).__name__}: {exc}'\n        row['strong_visual_candidate']=False\n    return row\n\nwith timed('structural_pair_review'):\n    REVIEW_PAIRS=[]\n    progress=CoarseProgress(len(selected_indices),'SSIM + biên','cặp')\n    with ThreadPoolExecutor(max_workers=CFG['workers']) as pool:\n        batch=CFG['structural_batch_size']\n        next_checkpoint=max(1,math.ceil(len(selected_indices)*CFG['progress_fraction']))\n        checkpoint_step=next_checkpoint\n        for start in range(0,len(selected_indices),batch):\n            jobs=[pool.submit(inspect_pair,i) for i in selected_indices[start:start+batch]]\n            for future in as_completed(jobs):\n                REVIEW_PAIRS.append(future.result());progress.advance()\n            if len(REVIEW_PAIRS)>=next_checkpoint or len(REVIEW_PAIRS)==len(selected_indices):\n                save_json('json/structural_checkpoint.json',{'completed':len(REVIEW_PAIRS),\n                    'total_selected':len(selected_indices),'pairs':REVIEW_PAIRS})\n                next_checkpoint+=checkpoint_step\n    progress.close()\nREVIEW_PAIRS.sort(key=lambda r:(not r['strong_visual_candidate'],not r['multiple_observed_labels'],\n                               -(r.get('ssim_foreground') or -1),r['pair_id']))\nsave_json('json/near_pair_details.json',REVIEW_PAIRS)\nsave_json('json/near_search_coverage.json',{'unique_pixel_contents':len(REPS),\n    'all_unordered_pairs':len(REPS)*(len(REPS)-1)//2,'candidate_pairs':len(PAIR_ARRAY),\n    'priority_pairs':int(priority.sum()),'random_weak_sample':(len(sample_weak) if CFG['structural_scope']=='priority' else 0),\n    'structural_scope':CFG['structural_scope'],\n    'pairs_with_structural_attempt':len(REVIEW_PAIRS),\n    'pairs_without_structural_measurement':len(PAIR_ARRAY)-len(REVIEW_PAIRS),\n    'structural_failures':sum(bool(r['structural_error']) for r in REVIEW_PAIRS),\n    'scope':'All readable train/test files; exact classes collapsed; upright crop Li hashes only',\n    'not_covered':['patient identity','clinical relabeling','exhaustive rotated/flipped/cropped duplicate recall'],\n    'thresholds_are_validated_duplicate_probabilities':False})\n\nSTRONG_PAIRS=[p for p in REVIEW_PAIRS if p['strong_visual_candidate']]\nsave_json('json/strong_near_candidates.json',STRONG_PAIRS)\nNEAR_SUMMARY={\n    'candidate_pairs':len(PAIR_ARRAY),'measured_pairs':len(REVIEW_PAIRS),\n    'unmeasured_candidate_pairs':len(PAIR_ARRAY)-len(REVIEW_PAIRS),\n    'strong_candidate_pairs':len(STRONG_PAIRS),\n    'strong_pairs_with_multiple_labels':sum(p['multiple_observed_labels'] for p in STRONG_PAIRS),\n    'strong_pairs_both_have_train_labels':sum(bool(p['a_group_labels']) and bool(p['b_group_labels']) for p in STRONG_PAIRS),\n    'strong_distinct_representatives':len({p[s] for p in STRONG_PAIRS for s in ['a','b']}),\n    'manually_confirmed_near_pairs':0,\n    'status':'automatic screening only; original image verification required',\n}\nsave_json('json/near_summary.json',NEAR_SUMMARY)\nprint('Đo cấu trúc:',len(REVIEW_PAIRS),'| Cặp ưu tiên mạnh:',len(STRONG_PAIRS))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:30:20.493173Z","iopub.execute_input":"2026-10-06T15:30:20.493459Z","iopub.status.idle":"2026-10-06T15:31:29.412497Z","shell.execute_reply.started":"2026-10-06T15:30:20.493426Z","shell.execute_reply":"2026-10-06T15:31:29.411461Z"}},"outputs":[],"execution_count":null},{"id":"aptos-analysis-21","cell_type":"markdown","source":"## Cell 11 — Chẩn đoán nối dây chuyền theo góp ý về thành phần liên thông\nThống kê số nội dung, số liên kết trực tiếp và mật độ liên kết trong các nhóm đề xuất.\nNếu A gần B và B gần C, code có thể đưa chúng cùng nhóm; điều đó không chứng minh A gần C.\nKhông dùng những nhóm này để xóa ảnh, sửa nhãn hoặc tạo dataset mới.\n","metadata":{}},{"id":"aptos-analysis-22","cell_type":"code","source":"class UnionFind:\n    def __init__(self, keys): self.parent={k:k for k in keys}\n    def find(self,x):\n        while self.parent[x]!=x:\n            self.parent[x]=self.parent[self.parent[x]]; x=self.parent[x]\n        return x\n    def union(self,a,b):\n        a,b=self.find(a),self.find(b)\n        if a!=b: self.parent[max(a,b)]=min(a,b)\n    def components(self):\n        result=defaultdict(list)\n        for key in self.parent: result[self.find(key)].append(key)\n        return list(result.values())\n\nproposal=UnionFind([r['record_id'] for r in REPS])\nproposal_edges=[]\nfor values in PAIR_ARRAY:\n    ai,bi,p,d=map(int,values)\n    if (p<=4 and d<=4) or (p<=2 and d<=6) or (d<=2 and p<=6):\n        a,b=REPS[ai]['record_id'],REPS[bi]['record_id']\n        proposal.union(a,b); proposal_edges.append((a,b))\nedge_count=Counter(proposal.find(a) for a,b in proposal_edges)\nPROPOSAL_COMPONENTS=[]\nfor members in proposal.components():\n    if len(members)<2: continue\n    n=len(members); edges=edge_count[proposal.find(members[0])]\n    PROPOSAL_COMPONENTS.append({'representative_members':sorted(members),'unique_contents':n,\n        'direct_edges':edges,'possible_edges':n*(n-1)//2,'edge_density':edges/(n*(n-1)/2),\n        'not_a_clique':edges<n*(n-1)//2, 'large_component_warning':n>20,\n        'used_to_modify_dataset':False})\nPROPOSAL_COMPONENTS.sort(key=lambda r:-r['unique_contents'])\nsave_json('json/proposed_components_diagnostic_only.json',PROPOSAL_COMPONENTS)\n\nfig,axes=plt.subplots(1,2,figsize=(13,4))\nif len(PAIR_ARRAY):\n    axes[0].hist2d(PAIR_ARRAY[:,2],PAIR_ARRAY[:,3],bins=(65,65),range=((-0.5,64.5),(-0.5,64.5)),\n        cmap='Blues',norm=matplotlib.colors.LogNorm())\naxes[0].set(xlabel='pHash Hamming',ylabel='dHash Hamming',title='Ứng viên hash — chưa xác nhận')\nsizes=[g['unique_contents'] for g in PROPOSAL_COMPONENTS]\naxes[1].bar(range(min(20,len(sizes))),sizes[:20],color='#de8f05')\naxes[1].set(xlabel='Nhóm đề xuất',ylabel='Nội dung pixel khác nhau',title='20 thành phần liên thông lớn nhất')\nfig.tight_layout();fig.savefig(OUT/'figures/hash_and_chaining.png');plt.show();plt.close(fig)\nexport_table('largest_proposed_components',[{'rank':i+1,**{k:g[k] for k in\n    ['unique_contents','direct_edges','possible_edges','edge_density','not_a_clique']}}\n    for i,g in enumerate(PROPOSAL_COMPONENTS[:20])],\n    {'rank':'Thứ tự','unique_contents':'Nội dung khác nhau','direct_edges':'Liên kết trực tiếp',\n     'possible_edges':'Tất cả cặp trong nhóm','edge_density':'Mật độ liên kết','not_a_clique':'Có cặp không nối trực tiếp'})\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:31:29.413721Z","iopub.execute_input":"2026-10-06T15:31:29.414055Z","iopub.status.idle":"2026-10-06T15:31:31.563002Z","shell.execute_reply.started":"2026-10-06T15:31:29.414025Z","shell.execute_reply":"2026-10-06T15:31:31.562201Z"}},"outputs":[],"execution_count":null},{"id":"aptos-analysis-23","cell_type":"markdown","source":"## Cell 12 — Xuất bằng chứng PNG\n- Tất cả nhóm trùng hoàn toàn: ảnh gốc thu nhỏ để trình bày, giữ tỷ lệ khung hình, ghi ID/tập/nhãn.\n- Cặp nghi gần trùng: ảnh gốc A/B và bản crop A/B, kèm chỉ số. Mọi cặp strong đều có ảnh.\n- Ảnh chất lượng đáng nghi: ảnh gốc so với crop, ưu tiên giá trị cực đoan và các crop quá mạnh.\n- Mẫu cố định theo từng Grade để thấy dữ liệu bình thường và kiểm tra crop.\nJSON `evidence_index.json` ghi ảnh nào đã được xuất và ảnh nào chỉ nằm trong danh sách.\nHiển thị trong notebook một mẫu nhỏ; toàn bộ PNG vẫn lưu trên ổ đĩa.\n","metadata":{}},{"id":"aptos-analysis-24","cell_type":"code","source":"EVIDENCE=[]\ndef original_rgb(rid):\n    with Image.open(BY_ID[rid]['path']) as im:\n        im=im.convert('RGB');im.thumbnail((1100,1100))\n        return np.array(im)\ndef cached_rgb(rid):\n    bgr=cv2.imread(str(CACHE/BY_ID[rid]['cache_name']))\n    if bgr is None:raise OSError(f'Thiếu cache: {rid}')\n    return cv2.cvtColor(bgr,cv2.COLOR_BGR2RGB)\ndef image_caption(rid):\n    r=BY_ID[rid]; label='không có nhãn' if r['label'] is None else f'Grade {r[\"label\"]}'\n    return f'{rid}\\n{label} | {r[\"width\"]} × {r[\"height\"]}'\ndef save_evidence(fig,name,kind,members,details=None):\n    path=OUT/'evidence'/name\n    fig.savefig(path,bbox_inches='tight');plt.close(fig)\n    EVIDENCE.append({'png':str(path.relative_to(OUT)),'kind':kind,'members':members,'details':details})\n\nwith timed('evidence_pngs'):\n    progress=CoarseProgress(len(EXACT_GROUPS),'Bằng chứng trùng hoàn toàn','nhóm')\n    for group in EXACT_GROUPS:\n        for page,start in enumerate(range(0,len(group['members']),12),1):\n            members=group['members'][start:start+12];cols=min(4,len(members));nr=math.ceil(len(members)/cols)\n            fig,axes=plt.subplots(nr,cols,figsize=(4*cols,3.8*nr),squeeze=False)\n            for ax in axes.ravel():ax.axis('off')\n            for ax,rid in zip(axes.ravel(),members):\n                ax.imshow(original_rgb(rid));ax.set_title(image_caption(rid),fontsize=9)\n            title='Trùng pixel gốc / Exact pixels'\n            if group['label_conflict']:title+=' — NHÃN MÂU THUẪN'\n            fig.suptitle(title+'\\n'+group['group_id']+f\" | labels={group['labels']}\",fontsize=12)\n            fig.tight_layout(rect=[0,0,1,.91])\n            save_evidence(fig,f\"{group['group_id']}_{page:03d}.png\",'exact_pixel_group',members,{'labels':group['labels']})\n        progress.advance()\n    progress.close()\n    nonstrong=[p for p in REVIEW_PAIRS if not p['strong_visual_candidate'] and not p['structural_error']]\n    extra=nonstrong if CFG['extra_pair_pngs'] is None else nonstrong[:CFG['extra_pair_pngs']]\n    pairs_to_render=STRONG_PAIRS+extra\n    progress=CoarseProgress(len(pairs_to_render),'Bằng chứng nghi gần trùng','cặp')\n    for pair in pairs_to_render:\n        fig,axes=plt.subplots(2,2,figsize=(11,10))\n        for col,side in enumerate(['a','b']):\n            rid=pair[side]\n            axes[0,col].imshow(original_rgb(rid));axes[0,col].set_title('Gốc / Original\\n'+image_caption(rid),fontsize=9)\n            axes[1,col].imshow(cached_rgb(rid));axes[1,col].set_title('Crop–resize 256; nhãn cả nhóm = '+str(pair[side+'_group_labels']),fontsize=9)\n        for ax in axes.ravel():ax.axis('off')\n        def fmt(x):return 'N/A' if x is None else f'{x:.6f}'\n        fig.suptitle('NGHI GẦN TRÙNG — chưa xác nhận / Unconfirmed\\n'+pair['pair_id']+\n            f\"\\npHash={pair['phash_distance']} | dHash={pair['dhash_distance']} | SSIM={fmt(pair.get('ssim_foreground'))} | edge={fmt(pair.get('edge_ncc'))}\",fontsize=11)\n        fig.tight_layout(rect=[0,0,1,.91])\n        save_evidence(fig,f\"near_{pair['pair_id']}.png\",'unconfirmed_near_pair',[pair['a'],pair['b']],\n            {'pair_id':pair['pair_id'],'strong_candidate':pair['strong_visual_candidate']})\n        progress.advance()\n    progress.close()\n    quality_selected=set()\n    for flag in flag_names:\n        rr=[r for r in GOOD if flag in r['quality_review_flags']]\n        field=flag.removesuffix('_low_tail').removesuffix('_high_tail')\n        rr.sort(key=lambda r:r.get(field) if isinstance(r.get(field),(int,float)) else 0,\n                reverse=flag.endswith('_high_tail'))\n        quality_selected.update(r['record_id'] for r in rr[:CFG['quality_pngs_per_flag']])\n    quality_selected.update(r['record_id'] for r in GOOD if r['crop_area_ratio']<.5 or r['crop_fallback'])\n    progress=CoarseProgress(len(quality_selected),'Bằng chứng chất lượng/crop','ảnh')\n    for rid in sorted(quality_selected):\n        r=BY_ID[rid];fig,axes=plt.subplots(1,2,figsize=(11,5))\n        axes[0].imshow(original_rgb(rid));axes[0].set_title('Gốc / Original')\n        axes[1].imshow(cached_rgb(rid));axes[1].set_title(f\"Crop–resize | diện tích giữ lại: {r['crop_area_ratio']:.1%}\")\n        for ax in axes:ax.axis('off')\n        title=image_caption(rid)+'\\nCờ xem lại, không phải kết luận ảnh hỏng: '+', '.join(r['quality_review_flags'])\n        fig.suptitle(textwrap.fill(title,110),fontsize=10);fig.tight_layout(rect=[0,0,1,.85])\n        save_evidence(fig,'quality_'+hashlib.sha256(rid.encode()).hexdigest()[:20]+'.png','quality_review',[rid],\n            {'flags':r['quality_review_flags'],'crop_area_ratio':r['crop_area_ratio']})\n        progress.advance()\n    progress.close()\n    rng_gallery=np.random.default_rng(CFG['seed'])\n    for grade in range(5):\n        rr=[r for r in TRAIN_ROWS if r['label']==grade]\n        if not rr:continue\n        selected=[rr[int(i)] for i in rng_gallery.choice(len(rr),min(4,len(rr)),replace=False)]\n        fig,axes=plt.subplots(len(selected),2,figsize=(9,3.2*len(selected)),squeeze=False)\n        for i,r in enumerate(selected):\n            rid=r['record_id'];axes[i,0].imshow(original_rgb(rid));axes[i,1].imshow(cached_rgb(rid))\n            axes[i,0].set_title(image_caption(rid),fontsize=9);axes[i,1].set_title('Crop–resize 256',fontsize=9)\n        for ax in axes.ravel():ax.axis('off')\n        fig.suptitle(f'Mẫu cố định Grade {grade}: ảnh gốc và crop');fig.tight_layout(rect=[0,0,1,.95])\n        save_evidence(fig,f'grade_{grade}_examples.png','random_grade_sample',[r['record_id'] for r in selected])\nsave_json('json/evidence_index.json',{'items':EVIDENCE,\n    'exact_groups_rendered':len(EXACT_GROUPS),'near_pairs_rendered':len(pairs_to_render),\n    'all_strong_pairs_rendered':True,'near_measured_pairs_not_rendered':len(REVIEW_PAIRS)-len(pairs_to_render),\n    'quality_images_rendered':len(quality_selected),\n    'note':'Original panels preserve aspect ratio but are downsized for display. Hash uses full decoded pixels.'})\n# Chỉ hiện tối đa ba hình để notebook không quá nặng; các PNG khác vẫn được lưu.\nfor item in ([e for e in EVIDENCE if e['kind']=='exact_pixel_group'][:1]+\n             [e for e in EVIDENCE if e['kind']=='unconfirmed_near_pair'][:1]+\n             [e for e in EVIDENCE if e['kind']=='quality_review'][:1]):\n    display(Image.open(OUT/item['png']))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:31:31.564818Z","iopub.execute_input":"2026-10-06T15:31:31.565132Z","iopub.status.idle":"2026-10-06T15:38:27.796172Z","shell.execute_reply.started":"2026-10-06T15:31:31.56511Z","shell.execute_reply":"2026-10-06T15:38:27.795186Z"}},"outputs":[],"execution_count":null},{"id":"aptos-analysis-25","cell_type":"markdown","source":"## Cell 13 — Báo cáo tổng hợp và định nghĩa chỉ số\n`summary_en.json` và `summary_vi.json` là hai bản cùng số liệu.\nCác bảng, biểu đồ và danh sách bằng chứng đều suy ra từ các kết quả trong phiên này.\nKhông hardcode số lượng trùng từ lần chạy cũ; không sinh file split hoặc danh sách loại ảnh.\nMọi chỉ số near đều ghi là ứng viên chưa xác nhận.\n","metadata":{}},{"id":"aptos-analysis-26","cell_type":"code","source":"SUMMARY={\n 'images_total':len(RECORDS),'readable_images':len(GOOD),'read_failures':len(RECORDS)-len(GOOD),\n 'original_train_images':sum(r['split']=='train' for r in RECORDS),\n 'original_test_images':sum(r['split']=='test' for r in RECORDS),\n 'valid_labeled_train_images':len(TRAIN_ROWS),'manifest_issues':len(MANIFEST_ISSUES),\n 'exact_duplicate_groups':len(EXACT_GROUPS),\n 'exact_duplicate_member_images':sum(g['n_images'] for g in EXACT_GROUPS),\n 'exact_redundant_images':sum(g['n_images']-1 for g in EXACT_GROUPS),\n 'unique_pixel_contents':len(PIXEL_GROUPS),\n 'train_duplicate_groups':len(TRAIN_DUP_GROUPS),\n 'train_duplicate_member_images':sum(len(g) for g in TRAIN_DUP_GROUPS),\n 'train_redundant_images':sum(len(g)-1 for g in TRAIN_DUP_GROUPS),\n 'train_unique_pixel_contents':len(TRAIN_PIXEL_GROUPS),\n 'exact_conflict_groups':len(CONFLICT_GROUPS),\n 'exact_conflict_labeled_images':sum(g['n_labeled_train'] for g in CONFLICT_GROUPS),\n 'exact_cross_original_split_groups':len(CROSS_GROUPS),\n 'test_images_identical_to_train':sum(g['n_test'] for g in CROSS_GROUPS),\n 'hash_candidate_pairs':len(PAIR_ARRAY),'structurally_measured_pairs':len(REVIEW_PAIRS),\n 'strong_visual_candidates':len(STRONG_PAIRS),\n 'strong_candidates_multiple_labels':sum(p['multiple_observed_labels'] for p in STRONG_PAIRS),\n 'quality_flagged_train_images':sum(bool(r['quality_review_flags']) for r in GOOD if r['split']=='train'),\n 'quality_flagged_test_images':sum(bool(r['quality_review_flags']) for r in GOOD if r['split']=='test'),\n 'manually_confirmed_near_pairs':0,'images_removed':0,'labels_changed':0,'new_splits_created':0,\n}\nMETRIC_NAMES={\n 'images_total':'Tổng file ảnh tìm được','readable_images':'Ảnh đọc thành công','read_failures':'Ảnh đọc lỗi',\n 'original_train_images':'Ảnh train gốc','original_test_images':'Ảnh test gốc',\n 'valid_labeled_train_images':'Ảnh train đọc được và nhãn hợp lệ','manifest_issues':'Lỗi đối chiếu CSV',\n 'exact_duplicate_groups':'Nhóm trùng pixel toàn bộ','exact_duplicate_member_images':'Ảnh thuộc nhóm trùng pixel toàn bộ',\n 'exact_redundant_images':'Bản dư toàn bộ (chỉ thống kê)','unique_pixel_contents':'Nội dung pixel khác nhau toàn bộ',\n 'train_duplicate_groups':'Nhóm trùng trong train có nhãn','train_duplicate_member_images':'Ảnh thuộc nhóm trùng trong train',\n 'train_redundant_images':'Bản dư trong train (chỉ thống kê)','train_unique_pixel_contents':'Nội dung pixel khác nhau trong train',\n 'exact_conflict_groups':'Nhóm trùng hoàn toàn có nhãn mâu thuẫn','exact_conflict_labeled_images':'Ảnh có nhãn thuộc nhóm mâu thuẫn',\n 'exact_cross_original_split_groups':'Nhóm trùng giao hai thư mục gốc','test_images_identical_to_train':'Ảnh test gốc trùng train gốc',\n 'hash_candidate_pairs':'Cặp ứng viên hash chưa xác nhận','structurally_measured_pairs':'Cặp được đưa vào đo cấu trúc',\n 'strong_visual_candidates':'Cặp có gợi ý cấu trúc mạnh','strong_candidates_multiple_labels':'Cặp gợi ý mạnh liên quan nhiều nhãn',\n 'quality_flagged_train_images':'Ảnh train có cờ chất lượng (chưa kết luận xấu)',\n 'quality_flagged_test_images':'Ảnh test có cờ chất lượng (chưa kết luận xấu)',\n 'manually_confirmed_near_pairs':'Cặp gần trùng xác nhận thủ công','images_removed':'Ảnh đã loại',\n 'labels_changed':'Nhãn đã sửa','new_splits_created':'Bộ chia mới đã tạo',\n}\nVALUE_NAMES.update(METRIC_NAMES)\nsave_json('json/summary_en.json',SUMMARY)\nsave_json('json/summary_vi.json',{METRIC_NAMES[k]:v for k,v in SUMMARY.items()})\nexport_table('summary',[{'metric':k,'value':v} for k,v in SUMMARY.items()],{'metric':'Chỉ số','value':'Giá trị'})\n# Cập nhật bản ghi cuối cùng để có cờ chất lượng và group ID phân tích.\nfor r in GOOD:r['exact_group_id']=EXACT_ID[r['record_id']]\nsave_json('json/per_image.json',RECORDS)\nsave_json('json/metric_dictionary.json',{'summary':METRIC_NAMES,'table_columns':TABLE_SCHEMAS,\n 'definitions':{\n  'split':'Original Competition folder only; no new split is generated.',\n  'exact':'Equal SHA256 of full original decoded shape, dtype and pixels; byte hash is tracked separately.',\n  'group_members_vs_redundant':'For a group of n images, members=n, redundant=n-1. No image is removed.',\n  'quality':'Measured on Li-cropped 256 px image; foreground gray>10; not clinical quality labels.',\n  'brightness_mean':'Mean gray on approximate foreground, 0..255.',\n  'laplacian_variance':'Variance of Laplacian inside 5x5-eroded foreground at 256 px; affected by noise and contrast.',\n  'entropy_bits':'Shannon entropy of foreground gray histogram; not a disease score.',\n  'crop_area_ratio':'Crop box area / original area, not percentage of true retina retained.',\n  'ssim_foreground':'SSIM map mean in common foreground eroded 7x7; not duplicate probability.',\n  'edge_ncc':'Centered cosine of Sobel gx/gy vectors in common foreground.',\n  'mask_iou':'Intersection/union of approximate gray>10 masks.',\n  'strong':'SSIM>=configured threshold AND edge NCC>=threshold AND mask IoU>=threshold; unconfirmed.',\n  'class_unique_counts':'Per-grade unique counts may overlap when an exact group has multiple grades.',\n }})\nsave_json('json/interpretation_notes.json',{\n 'vi':[\n  'Ảnh đọc được và nhãn trong 0..4 không chứng minh chất lượng ảnh hoặc nhãn bệnh đúng.',\n  'Số liệu train/test nói về thư mục gốc; không phải đánh giá một mô hình.',\n  'Chỉ nhãn khác nhau trong nhóm exact mới xác nhận mâu thuẫn cho cùng pixel.',\n  'Cặp near và thành phần liên thông cần xem ảnh gốc; không xóa dựa vào hash.',\n  'Cờ chất lượng dùng ngưỡng mô tả, có thể chồng lấp và không tự loại ảnh.',\n  'Không có mã bệnh nhân, chưa kiểm tra được phụ thuộc theo bệnh nhân.',\n  'Không huấn luyện, không đổi nhãn, không bỏ ảnh, không tạo dataset mới hoặc chia fold.',\n ],'automatic_analysis_only':True,'clinical_labels_verified':False,'patient_identity_verified':False})\ndisplay(pd.DataFrame([{'Chỉ số':METRIC_NAMES[k],'Giá trị':v} for k,v in SUMMARY.items()]))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:38:27.797476Z","iopub.execute_input":"2026-10-06T15:38:27.798081Z","iopub.status.idle":"2026-10-06T15:38:29.050845Z","shell.execute_reply.started":"2026-10-06T15:38:27.798056Z","shell.execute_reply":"2026-10-06T15:38:29.050214Z"}},"outputs":[],"execution_count":null},{"id":"aptos-analysis-27","cell_type":"markdown","source":"## Cell 14 — Kiểm tra tính nhất quán và lưu thông tin tái lập\nCác assert chỉ kiểm tra tính nhất quán số liệu, không khẳng định dữ liệu đã sạch.\nLưu cấu hình, phiên bản thư viện, thời gian, tài nguyên và danh mục đầu ra.\nKhông nén ZIP, không ghép các JSON thành file lớn, không tạo split. Giữ từng nhóm kết quả riêng để dễ đọc.\n","metadata":{}},{"id":"aptos-analysis-28","cell_type":"code","source":"assert SUMMARY['unique_pixel_contents']+SUMMARY['exact_redundant_images']==SUMMARY['readable_images']\nassert SUMMARY['train_unique_pixel_contents']+SUMMARY['train_redundant_images']==len(TRAIN_ROWS)\nassert sum(r['images'] for r in class_rows)==len(TRAIN_ROWS)\nassert all(r['label'] is None for r in RECORDS if r['split']=='test')\nassert all(len(g['labels'])>=2 for g in CONFLICT_GROUPS)\nassert len({r['record_id'] for r in RECORDS})==len(RECORDS)\nassert len({p['pair_id'] for p in REVIEW_PAIRS})==len(REVIEW_PAIRS)\nassert all(p['a']!=p['b'] for p in REVIEW_PAIRS)\nassert all(BY_ID[p['a']]['pixel_sha256']!=BY_ID[p['b']]['pixel_sha256'] for p in REVIEW_PAIRS)\nassert all((OUT/e['png']).is_file() for e in EVIDENCE)\nsave_json('json/sanity_checks.json',{'passed':True,'checks':[\n    'unique + redundant = readable','train unique + redundant = valid labeled train',\n    'grade counts sum correctly','test labels remain unknown','conflict groups contain multiple observed labels',\n    'IDs and measured pairs unique','near pairs compare different original pixel contents','evidence PNGs exist'],\n    'not_a_clean_dataset_certificate':True})\ngpu_stats=[]\nif torch is not None and torch.cuda.is_available():\n    for i in range(torch.cuda.device_count()):\n        gpu_stats.append({'device':i,'name':torch.cuda.get_device_name(i),\n            'max_allocated_bytes':torch.cuda.max_memory_allocated(i),\n            'max_reserved_bytes':torch.cuda.max_memory_reserved(i)})\nsave_json('json/runtime.json',{'timings_seconds':TIMINGS,'elapsed_seconds':time.perf_counter()-START_TIME,\n    'max_process_rss_kib_linux':resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,\n    'gpu':gpu_stats,'hash_gpu_devices_used':len(GPU_DATA),\n    'note':'GPU peaks are process-level since kernel start, not total notebook GPU-hours. CPU handles most image processing.'})\nfiles=[p for p in OUT.rglob('*') if p.is_file()]\nassert all(p.suffix in {'.json','.png'} for p in files), 'Có đầu ra ngoài JSON/PNG.'\nsave_json('json/output_inventory.json',[{'path':str(p.relative_to(OUT)),'bytes':p.stat().st_size}\n    for p in sorted(files)])\nprint('Hoàn thành phân tích. Dữ liệu gốc không bị thay đổi.')\nprint('Kết quả:',OUT)\nprint('Bắt đầu đọc: json/summary_vi.json, json/near_summary.json, figures/, evidence/')\nprint('JSON có danh sách đầy đủ; PNG cặp near chỉ theo cấu hình và được ghi trong evidence_index.json.')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:38:29.052009Z","iopub.execute_input":"2026-10-06T15:38:29.05247Z","iopub.status.idle":"2026-10-06T15:38:29.304659Z","shell.execute_reply.started":"2026-10-06T15:38:29.052446Z","shell.execute_reply":"2026-10-06T15:38:29.303981Z"}},"outputs":[],"execution_count":null},{"id":"b819a163-9a08-472d-b687-915766a819f2","cell_type":"code","source":"from pathlib import Path\nfrom zipfile import ZipFile, ZIP_DEFLATED\nfrom IPython.display import FileLink, display\nfrom datetime import datetime\nimport os\n\nROOT = Path(\"/kaggle/working/aptos_analysis_20261006T151753828081Z\")\nFOLDERS = [\"evidence\", \"figures\", \"json\"]\nZIP_PATH = ROOT.parent / (\n    f\"aptos_selected_{datetime.now():%Y%m%d_%H%M%S}.zip\"\n)\n\n# 1. Kiểm tra file thực tế, bao gồm tất cả thư mục con\nfiles_by_folder = {}\n\nfor name in FOLDERS:\n    folder = ROOT / name\n    if not folder.is_dir():\n        raise FileNotFoundError(f\"Không tìm thấy thư mục: {folder}\")\n\n    files = sorted(p for p in folder.rglob(\"*\") if p.is_file())\n    files_by_folder[name] = files\n\n    png_count = sum(p.suffix.lower() == \".png\" for p in files)\n    size_mb = sum(p.stat().st_size for p in files) / 1024**2\n\n    print(\n        f\"{name:10s}: {len(files):,} file | \"\n        f\"{png_count:,} PNG | {size_mb:.2f} MB\"\n    )\n\n    if not files:\n        print(f\"  ⚠️ Thư mục {name} đang rỗng, chưa có kết quả để tải.\")\n\n# 2. Nén đúng 3 thư mục, giữ cả thư mục rỗng\nwith ZipFile(ZIP_PATH, \"w\", ZIP_DEFLATED, compresslevel=1) as z:\n    for name, files in files_by_folder.items():\n        z.write(ROOT / name, arcname=f\"{name}/\")\n        for file in files:\n            z.write(file, arcname=file.relative_to(ROOT).as_posix())\n\n# 3. Kiểm tra số file thực sự bên trong ZIP\nwith ZipFile(ZIP_PATH, \"r\") as z:\n    entries = [item.filename for item in z.infolist() if not item.is_dir()]\n\n    print(\"\\nKiểm tra bên trong ZIP:\")\n    for name, files in files_by_folder.items():\n        actual = sum(entry.startswith(f\"{name}/\") for entry in entries)\n        assert actual == len(files), f\"Thiếu file trong {name}\"\n        print(f\"  {name}: {actual:,} file\")\n\nprint(f\"\\nZIP: {ZIP_PATH.stat().st_size / 1024**2:.2f} MB\")\nos.chdir(\"/kaggle/working\")\ndisplay(FileLink(ZIP_PATH.name))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-10-06T15:54:22.82893Z","iopub.execute_input":"2026-10-06T15:54:22.829541Z","iopub.status.idle":"2026-10-06T15:54:22.863171Z","shell.execute_reply.started":"2026-10-06T15:54:22.829514Z","shell.execute_reply":"2026-10-06T15:54:22.862396Z"}},"outputs":[],"execution_count":null},{"id":"e3770483-9af4-4ce7-ba7f-a4ab7717da33","cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}