""" utils.py Projedeki tum modullerin ortak kullandigi yardimci fonksiyonlar: loglama kurulumu, SHA256 hashleme, otomatik dosya numaralandirma, ve kucuk goruntu islemleri. """ from __future__ import annotations import hashlib import logging import sys import zipfile from pathlib import Path from typing import List, Optional, Tuple from PIL import Image import numpy as np import config class ZipTooLargeError(ValueError): """Yuklenen zip, izin verilen sayfa sayisi sinirini asarsa firlatilir.""" # ============================================================ # LOGLAMA # ============================================================ def get_logger(name: str) -> logging.Logger: """ Verilen isimle yapilandirilmis bir logger dondurur. Hem konsola (stdout) hem de config.LOG_FILE dosyasina yazar. Ayni isimle tekrar cagrildiginda handler'lari coklamaz. """ logger = logging.getLogger(name) if logger.handlers: return logger logger.setLevel(config.LOG_LEVEL) formatter = logging.Formatter(config.LOG_FORMAT) stream_handler = logging.StreamHandler(sys.stdout) stream_handler.setFormatter(formatter) logger.addHandler(stream_handler) try: file_handler = logging.FileHandler(config.LOG_FILE, encoding="utf-8") file_handler.setFormatter(formatter) logger.addHandler(file_handler) except OSError: # Dosya sistemine yazilamiyorsa (salt-okunur ortam vb.) sessizce # yalnizca konsol loglamasina devam edilir. pass logger.propagate = False return logger log = get_logger("utils") # ============================================================ # HASH / DUPLICATE KONTROLU # ============================================================ def compute_file_sha256(path: Path) -> str: """Bir dosyanin SHA256 hash'ini hesaplar (hex string olarak).""" sha256 = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): sha256.update(chunk) return sha256.hexdigest() def compute_bytes_sha256(data: bytes) -> str: """Bellekteki bir byte dizisinin SHA256 hash'ini hesaplar.""" return hashlib.sha256(data).hexdigest() def compute_image_sha256(image: Image.Image) -> str: """ Bir PIL Image nesnesinin piksel icerigine dayali SHA256 hash'ini hesaplar. Ayni gorsel farkli formatlarda kaydedilse bile (PNG/JPEG) piksel verisi ayni oldugu surece ayni hash uretilir. """ normalized = image.convert("RGB") return compute_bytes_sha256(normalized.tobytes()) # ============================================================ # DOSYA ADLANDIRMA # ============================================================ def format_sample_filename(index: int) -> str: """ Ornek indeksini config.DATASET.filename_digits basamakli, sifirla doldurulmus dosya adina cevirir. Ornek: 245 -> '000245.png' """ digits = config.DATASET.filename_digits ext = config.DATASET.filename_ext return f"{index:0{digits}d}{ext}" def format_sample_id(index: int) -> str: """Commit mesajlarinda kullanilan sifirla doldurulmus ornek kimligi.""" digits = config.DATASET.filename_digits return f"{index:0{digits}d}" # ============================================================ # GORUNTU YARDIMCILARI # ============================================================ def safe_open_image(path: Path) -> Optional[Image.Image]: """ Bir goruntu dosyasini guvenli sekilde acar. Bozuk/desteklenmeyen dosyalarda None doner ve hatayi loglar. """ try: image = Image.open(path) image.load() return image.convert("RGB") except Exception as exc: # noqa: BLE001 - kullanici girdisi guvenilmez log.error("Goruntu acilamadi: %s (%s)", path, exc) return None def extract_manual_crop_from_editor_composite(composite: Image.Image) -> Optional[Image.Image]: """ Gradio ImageEditor'in crop araciyla uretilen 'composite' ciktisindan kullanicinin sectigi dikdortgen bolgeyi cikarir. ImageEditor'in crop transform'u, secilen alan disini seffaf (alpha=0) yapar ama canvas boyutunu degistirmez. Bu fonksiyon, alpha kanalinda opak olan piksellerin bounding box'ini bulup o bolgeyi kirpar ve RGB bir goruntu olarak dondurur. Kullanici hic kirpma yapmadiysa (tum canvas opak/RGB ise) veya goruntude alpha kanali yoksa, goruntunun tamami RGB olarak dondurulur (kirpma islenmemis kabul edilir). Returns: Kirpilmis RGB PIL Image, veya secili bolge bulunamazsa None. """ if composite is None: return None if composite.mode != "RGBA": # Alpha kanali yok; tum goruntu secilmis kabul edilir. return composite.convert("RGB") alpha = np.array(composite.split()[-1]) ys, xs = np.where(alpha > 0) if ys.size == 0 or xs.size == 0: return None x_min, x_max = int(xs.min()), int(xs.max()) + 1 y_min, y_max = int(ys.min()), int(ys.max()) + 1 cropped = composite.crop((x_min, y_min, x_max, y_max)) return cropped.convert("RGB") def clamp(value: float, low: float, high: float) -> float: """Bir degeri [low, high] araligina sikistirir.""" return max(low, min(high, value)) # ============================================================ # ZIP TOPLU YUKLEME # ============================================================ # Zip icinde manga sayfasi olarak kabul edilen dosya uzantilari. _IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"} def extract_images_from_zip(zip_path: Path, output_dir: Path) -> List[Tuple[str, Path]]: """ Bir zip arsivi icindeki tum gecerli goruntu dosyalarini acar ve HER BIRINI HEMEN DISKE (output_dir altina) yazar. Onemli tasarim karari: fonksiyon PIL Image nesnelerini bellekte tutup dondurmez, bunun yerine diske yazip (dosya_adi, disk_yolu) ciftleri dondurur. Bunun nedeni: 50+ sayfalik bir manga zip'inde tum sayfalari ayni anda PIL Image olarak bellekte tutmak (ozellikle bu nesneler bir Gradio State icinde saklanip her etkilesimde serialize ediliyorsa) RAM tuketimini ve network payload boyutunu asiri buyutup zaman asimi/baglanti hatalarina yol acar. Diske yazmak, bellek kullanimini sabit (bir seferde tek goruntu) tutar. Sonuc, zip icindeki dosya adina gore (dogal/alfabetik) sirali (dosya_adi, disk_yolu) ciftleri listesi olarak doner; boylece manga sayfalari genellikle beklenen okuma sirasinda islenir (orn. 001.png, 002.png, ...). Guvenlik limitleri (config.ZIP_UPLOAD): - Zip icindeki sayfa sayisi max_pages'i asarsa ZipTooLargeError. - Tek bir goruntunun piksel boyutu asiri buyukse (olasi bir "zip bomb" / bozuk dosya senaryosu) o sayfa atlanir. Bozuk/acilamayan tekil dosyalar atlanir ve loglanir; tüm arsiv basarisiz olmaz. Raises: zipfile.BadZipFile: Dosya gecerli bir zip arsivi degilse. ZipTooLargeError: Sayfa sayisi izin verilen sinirin uzerindeyse. """ output_dir.mkdir(parents=True, exist_ok=True) results: List[Tuple[str, Path]] = [] with zipfile.ZipFile(zip_path, "r") as zf: names = sorted( n for n in zf.namelist() if not n.endswith("/") # dizin girdilerini atla and not n.split("/")[-1].startswith(".") # gizli/sistem dosyalarini atla and Path(n).suffix.lower() in _IMAGE_EXTENSIONS ) if len(names) > config.ZIP_UPLOAD.max_pages: raise ZipTooLargeError( f"Zip icinde {len(names)} sayfa var; " f"tek seferde en fazla {config.ZIP_UPLOAD.max_pages} sayfa desteklenir. " f"Lutfen zip'i daha kucuk parcalara bolup ayri ayri yukleyin." ) for i, name in enumerate(names, start=1): try: with zf.open(name) as f: image = Image.open(f) image.load() image = image.convert("RGB") except Exception as exc: # noqa: BLE001 - zip icerigi guvenilmez log.error("Zip icindeki goruntu acilamadi: %s (%s)", name, exc) continue width, height = image.size if width * height > config.ZIP_UPLOAD.max_page_pixels: log.error( "Sayfa cok buyuk, atlaniyor: %s (%dx%d)", name, width, height ) continue # Sayfayi diske yaz; orijinal formattan bagimsiz olarak PNG # kullanilir (tutarlilik + segmentasyon/OCR icin guvenilir # decode). Dosya adi cakismalarini onlemek icin sira # numarasi ile benzersizlestirilir. safe_name = f"{i:04d}_{Path(name).stem}.png" out_path = output_dir / safe_name try: image.save(out_path, format="PNG") except OSError as exc: log.error("Sayfa diske yazilamadi, atlaniyor: %s (%s)", name, exc) continue results.append((Path(name).name, out_path)) return results