nghyane's picture
download
raw
10.2 kB
"""Dataset for manga/manhwa inpainting training.
Loads images from a directory (supports nested subdirs), generates random
free-form brush stroke masks, and applies augmentation.
Mask convention: 1 = known pixel, 0 = missing (to be inpainted).
Images normalized to [-1, 1].
"""
import os
import random
import math
from pathlib import Path
from typing import Optional
import numpy as np
import torch
from torch.utils.data import Dataset
from PIL import Image, ImageDraw
import torchvision.transforms.functional as TF
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"}
def find_images(root: str) -> list[str]:
"""Recursively find all image files under root."""
paths = []
for dirpath, _, filenames in os.walk(root):
for f in filenames:
if f.startswith("._") or f.startswith("."):
continue
if Path(f).suffix.lower() in IMAGE_EXTENSIONS:
paths.append(os.path.join(dirpath, f))
paths.sort()
return paths
# ---------------------------------------------------------------------------
# Mask generation
# ---------------------------------------------------------------------------
def generate_text_mask(h: int, w: int) -> np.ndarray:
"""Generate a text-like mask mimicking speech bubble text after dilation.
Returns a float32 array [H, W] where 1=known, 0=masked.
Lines are close together (small/zero gap) so they merge into a single
blob, matching the actual dilated text masks from the detection pipeline.
"""
mask = Image.new("L", (w, h), 255)
draw = ImageDraw.Draw(mask)
num_groups = random.randint(1, 3)
for _ in range(num_groups):
num_lines = random.randint(1, 6)
line_h = random.randint(h // 30, h // 12)
# Gap: 0 to line_h//3 — lines often touch or overlap (like dilated masks)
line_gap = random.randint(0, max(1, line_h // 3))
block_h = num_lines * (line_h + line_gap)
bx = random.randint(w // 8, w * 3 // 4)
by = random.randint(0, max(0, h - block_h))
block_w = random.randint(w // 4, w * 3 // 4)
for li in range(num_lines):
ly = by + li * (line_h + line_gap)
lw = random.randint(block_w * 2 // 3, block_w)
lx = bx + (block_w - lw) // 2
draw.rectangle([lx, ly, lx + lw, ly + line_h], fill=0)
arr = np.array(mask, dtype=np.float32) / 255.0
return arr
def generate_freeform_mask(h: int, w: int) -> np.ndarray:
"""Generate a free-form mask with brush strokes.
Returns a float32 array [H, W] where 1=known, 0=masked.
"""
mask = Image.new("L", (w, h), 255)
draw = ImageDraw.Draw(mask)
num_strokes = random.randint(2, 5)
for _ in range(num_strokes):
num_vertices = random.randint(3, 8)
width = random.randint(8, max(10, min(w, h) // 10))
points = []
x = random.randint(0, w)
y = random.randint(0, h)
for _ in range(num_vertices):
angle = random.uniform(0, 2 * math.pi)
length = random.randint(20, max(30, min(w, h) // 4))
x = int(np.clip(x + length * math.cos(angle), 0, w))
y = int(np.clip(y + length * math.sin(angle), 0, h))
points.append((x, y))
for i in range(len(points) - 1):
draw.line([points[i], points[i + 1]], fill=0, width=width)
for px, py in points:
r = width // 2
draw.ellipse([px - r, py - r, px + r, py + r], fill=0)
arr = np.array(mask, dtype=np.float32) / 255.0
return arr
def generate_mask(h: int, w: int) -> np.ndarray:
"""Generate a training mask — 70% text-like, 30% freeform for diversity."""
if random.random() < 0.7:
return generate_text_mask(h, w)
else:
return generate_freeform_mask(h, w)
# ---------------------------------------------------------------------------
# Dataset
# ---------------------------------------------------------------------------
class MangaInpaintDataset(Dataset):
"""Manga/manhwa inpainting dataset.
Args:
image_dir: Root directory of images (nested subdirs OK).
mask_dir: Optional directory of pre-computed masks (grayscale,
255=known, 0=masked). If None, random masks are generated.
image_size: Output crop size (square).
augment: Enable random augmentation (flip, crop).
"""
def __init__(self, image_dir: str, mask_dir: Optional[str] = None,
image_size: int = 512, augment: bool = True):
self.image_paths = find_images(image_dir)
if len(self.image_paths) == 0:
raise ValueError(f"No images found in {image_dir}")
self.mask_paths: Optional[list[str]] = None
if mask_dir is not None:
self.mask_paths = find_images(mask_dir)
if len(self.mask_paths) == 0:
raise ValueError(f"No masks found in {mask_dir}")
self.image_size = image_size
self.augment = augment
def __len__(self) -> int:
return len(self.image_paths)
def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]:
# Load image
img = Image.open(self.image_paths[idx]).convert("RGB")
# Random crop / resize to target size
img = self._prepare_image(img)
# Random horizontal flip
if self.augment and random.random() > 0.5:
img = TF.hflip(img)
# Convert to tensor [-1, 1]
img_tensor = TF.to_tensor(img) * 2.0 - 1.0 # [3, H, W] in [-1, 1]
# Generate or load mask
if self.mask_paths is not None:
mask_idx = random.randint(0, len(self.mask_paths) - 1)
mask_img = Image.open(self.mask_paths[mask_idx]).convert("L")
mask_img = mask_img.resize((self.image_size, self.image_size),
Image.NEAREST)
mask = np.array(mask_img, dtype=np.float32) / 255.0
else:
mask = generate_mask(self.image_size, self.image_size)
mask_tensor = torch.from_numpy(mask).unsqueeze(0) # [1, H, W], 1=known, 0=masked
return img_tensor, mask_tensor
def _prepare_image(self, img: Image.Image) -> Image.Image:
"""Resize and crop image to self.image_size."""
w, h = img.size
s = self.image_size
# If image is smaller than target, resize up
if min(w, h) < s:
scale = s / min(w, h)
img = img.resize((int(w * scale) + 1, int(h * scale) + 1),
Image.LANCZOS)
w, h = img.size
# Random crop
if self.augment:
x = random.randint(0, w - s)
y = random.randint(0, h - s)
else:
x = (w - s) // 2
y = (h - s) // 2
img = img.crop((x, y, x + s, y + s))
return img
# ---------------------------------------------------------------------------
# Teacher cache dataset
# ---------------------------------------------------------------------------
class TeacherCacheDataset(Dataset):
"""Load pre-generated (image, mask, teacher_output) triplets from disk.
Each sample is stored as a .npz file containing:
image: float32 [3, H, W] in [-1, 1]
mask: float32 [1, H, W] in {0, 1}
teacher: float32 [3, H, W] in [-1, 1]
"""
def __init__(self, cache_dir: str, augment: bool = True, **kwargs):
self.files = sorted(
str(p) for p in Path(cache_dir).rglob("*.npz")
)
if len(self.files) == 0:
raise ValueError(f"No .npz files found in {cache_dir}")
self.augment = augment
self.cache = None
# Convert .npz to single memmap files for fast I/O
mmap_dir = os.path.join(cache_dir, "_mmap")
imgs_path = os.path.join(mmap_dir, "images.npy")
if not os.path.exists(imgs_path):
from tqdm import tqdm
os.makedirs(mmap_dir, exist_ok=True)
n = len(self.files)
sample = np.load(self.files[0])
print(f"Converting {n} .npz → memmap (one-time)...")
imgs = np.lib.format.open_memmap(
imgs_path, mode='w+', dtype=np.float32, shape=(n, *sample["image"].shape))
msks = np.lib.format.open_memmap(
os.path.join(mmap_dir, "masks.npy"), mode='w+', dtype=np.float32, shape=(n, *sample["mask"].shape))
tchs = np.lib.format.open_memmap(
os.path.join(mmap_dir, "teachers.npy"), mode='w+', dtype=np.float32, shape=(n, *sample["teacher"].shape))
for i, f in enumerate(tqdm(self.files, desc="Converting")):
data = np.load(f)
imgs[i] = data["image"]
msks[i] = data["mask"]
tchs[i] = data["teacher"]
del imgs, msks, tchs
print("Memmap conversion done.")
self.images = np.load(imgs_path, mmap_mode='r')
self.masks = np.load(os.path.join(mmap_dir, "masks.npy"), mmap_mode='r')
self.teachers = np.load(os.path.join(mmap_dir, "teachers.npy"), mmap_mode='r')
size_gb = (self.images.nbytes + self.masks.nbytes + self.teachers.nbytes) / 1024**3
print(f"Memmap loaded: {size_gb:.1f}GB (OS-cached, near-zero RAM)")
def __len__(self) -> int:
return len(self.files)
def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
image = torch.from_numpy(self.images[idx].copy())
mask = torch.from_numpy(self.masks[idx].copy())
teacher = torch.from_numpy(self.teachers[idx].copy())
# Random horizontal flip (apply consistently to all)
if self.augment and random.random() > 0.5:
image = torch.flip(image, [-1])
mask = torch.flip(mask, [-1])
teacher = torch.flip(teacher, [-1])
return image, mask, teacher
if __name__ == "__main__":
# Quick test: generate masks and display stats
for name, fn in [("text", generate_text_mask), ("freeform", generate_freeform_mask)]:
mask = fn(512, 512)
masked_pct = (1 - mask.mean()) * 100
print(f"{name}: shape={mask.shape}, masked={masked_pct:.1f}%")

Xet Storage Details

Size:
10.2 kB
·
Xet hash:
f6a9c3ec7e72fbcf8f62e713fb11e780c36a29c70b6ea3f62c33bdf2774b2525

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.