import os os.environ["CUDA_VISIBLE_DEVICES"] = "1" import argparse import random import time from pathlib import Path import io import torch import torch.nn as nn import torch.nn.functional as F from torch.utils.data import Dataset, DataLoader from tqdm import tqdm from torchvision import transforms import torchvision.transforms.functional as TF from torchvision.transforms import InterpolationMode from PIL import Image, ImageChops, ImageFilter import torchvision.models as models from model import UpscalerTransformer IMG_EXTENSIONS = { ".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp", } def find_images(folder): folder = Path(folder) if not folder.is_dir(): raise FileNotFoundError(f"Папка с датасетом не найдена: {folder}") files = [] for p in folder.rglob("*"): if p.suffix.lower() in IMG_EXTENSIONS: files.append(str(p)) return sorted(files) def split_dataset(paths, val_split, seed): paths = sorted(paths) if len(paths) == 0: return [], [] if len(paths) == 1: return paths, paths rng = random.Random(seed) rng.shuffle(paths) if val_split <= 0: return paths, paths val_count = max(1, int(len(paths) * val_split)) val_paths = paths[:val_count] train_paths = paths[val_count:] return train_paths, val_paths import torch import torch.nn as nn def _load_vgg16(): try: weights = models.VGG16_Weights.IMAGENET1K_V1 return models.vgg16(weights=weights) except Exception: return models.vgg16(pretrained=True) class VGGPerceptualLoss(nn.Module): """ Perceptual loss на признаках VGG16. Использует признаки до relu3_3. """ def __init__(self): super().__init__() vgg = _load_vgg16() # VGG16 features: # 0 conv1_1, 1 relu, 2 conv1_2, 3 relu, # 4 maxpool, # 5 conv2_1, 6 relu, 7 conv2_2, 8 relu, # 9 maxpool, # 10 conv3_1, 11 relu, 12 conv3_2, 13 relu, 14 conv3_3, 15 relu self.features = nn.Sequential(*list(vgg.features.children())[:16]) self.features.eval() for p in self.features.parameters(): p.requires_grad_(False) self.register_buffer( "mean", torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1), ) self.register_buffer( "std", torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1), ) def train(self, mode: bool = True): # Всегда держим VGG в eval-режиме. return super().train(False) def forward(self, sr, hr): sr = (sr - self.mean) / self.std hr = (hr - self.mean) / self.std sr_features = self.features(sr) hr_features = self.features(hr) return F.l1_loss(sr_features, hr_features) class FFTLoss(nn.Module): """ FFT loss помогает восстанавливать высокие частоты: резкость, мелкие детали, текстуру. """ def forward(self, sr, hr): sr_fft = torch.fft.rfft2(sr, norm="ortho") hr_fft = torch.fft.rfft2(hr, norm="ortho") sr_mag = torch.log1p(torch.abs(sr_fft)) hr_mag = torch.log1p(torch.abs(hr_fft)) return F.l1_loss(sr_mag, hr_mag) class SRDataset(Dataset): def __init__( self, image_paths, hr_size=256, lr_size=128, train=True, ): self.image_paths = image_paths self.hr_size = hr_size self.lr_size = lr_size self.train = train def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img_hr = Image.open(self.image_paths[idx]).convert("RGB") if self.train: rnd = random.randint(0, 192) area = (rnd, rnd, 1024 - rnd, 1024 - rnd) img_hr = img_hr.crop(area) img_hr = img_hr.resize((self.hr_size, self.hr_size), Image.Resampling.LANCZOS) if random.randint(0, 1) == 0: img_hr = img_hr.transpose(Image.FLIP_LEFT_RIGHT) if random.randint(0, 1) == 0: random_choice = random.randint(0, 2) if random_choice == 0: img_hr = img_hr.transpose(Image.Transpose.ROTATE_90) elif random_choice == 1: img_hr = img_hr.transpose(Image.Transpose.ROTATE_180) elif random_choice == 2: img_hr = img_hr.transpose(Image.Transpose.ROTATE_270) if random.randint(0, 1) == 0: rnd_x = random.randint(-self.hr_size // 3, self.hr_size // 3) rnd_y = random.randint(-self.hr_size // 3, self.hr_size // 3) img_hr = ImageChops.offset(img_hr, xoffset=rnd_x, yoffset=rnd_y) degradation_type = random.randint(0, 1) resampling_mode = random.randint(0, 5) if degradation_type == 0: intermediate_size = random.randint(self.lr_size // 4, self.lr_size) img_lr = img_hr.resize((intermediate_size, intermediate_size), resampling_mode) img_lr = img_lr.resize((self.lr_size, self.lr_size), Image.Resampling.LANCZOS) elif degradation_type == 1: img_lr = img_hr.resize((self.lr_size, self.lr_size), resampling_mode) if random.randint(0, 1) == 0: buffer = io.BytesIO() img_lr.save(buffer, format="JPEG", quality=random.randint(20, 80), optimize=True) buffer.seek(0) img_lr = Image.open(buffer) img_lr.load() else: img_hr = img_hr.resize((self.hr_size, self.hr_size), Image.Resampling.LANCZOS) img_lr = img_hr.resize((self.lr_size, self.lr_size), Image.Resampling.LANCZOS) hr = TF.to_tensor(img_hr) lr = TF.to_tensor(img_lr) return lr, hr def train_one_epoch( model, loader, optimizer, scaler, device, use_amp, args, perceptual_loss, fft_loss, ): model.train() total_loss = 0.0 num_samples = 0 pbar = tqdm(loader, desc="Training", leave=False) for lr, hr in pbar: lr = lr.to(device, non_blocking=True) hr = hr.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) if use_amp: with torch.cuda.amp.autocast(): sr = model(lr) loss = args.w_l1 * F.l1_loss(sr.float(), hr) else: sr = model(lr) loss = args.w_l1 * F.l1_loss(sr, hr) sr_f = sr.float() if perceptual_loss is not None: loss = loss + args.w_perceptual * perceptual_loss(sr_f, hr) if fft_loss is not None: loss = loss + args.w_fft * fft_loss(sr_f, hr) if use_amp: scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() else: loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss += loss.item() * lr.size(0) num_samples += lr.size(0) avg_loss = total_loss / max(1, num_samples) pbar.set_postfix(loss=f"{avg_loss:.4f}") return total_loss / max(1, num_samples) @torch.no_grad() def evaluate(model, loader, device): model.eval() total_psnr = 0.0 count = 0 pbar = tqdm(loader, desc="Evaluating", leave=False) for lr, hr in pbar: lr = lr.to(device, non_blocking=True) hr = hr.to(device, non_blocking=True) sr = model(lr) sr = torch.clamp(sr.float(), 0.0, 1.0) mse = torch.mean((sr - hr) ** 2, dim=(1, 2, 3)) psnr = 10.0 * torch.log10(1.0 / mse.clamp_min(1e-12)) total_psnr += psnr.sum().item() count += lr.size(0) # Обновляем прогресс-бар текущим средним PSNR avg_psnr = total_psnr / max(1, count) pbar.set_postfix(psnr=f"{avg_psnr:.2f} dB") return total_psnr / max(1, count) def parse_args(): parser = argparse.ArgumentParser() parser.add_argument( "--data_dir", type=str, required=True, help="Папка с HR-изображениями.", ) parser.add_argument( "--output_dir", type=str, default="runs_quality", help="Куда сохранять чекпоинты.", ) parser.add_argument( "--resume", type=str, default="", help="Путь к last.pth или best.pth для возобновления обучения.", ) parser.add_argument( "--reset_scheduler", action="store_true", help="Пересчитать scheduler под новый --epochs при возобновлении.", ) parser.add_argument("--epochs", type=int, default=300) parser.add_argument("--batch_size", type=int, default=4) parser.add_argument("--lr", type=float, default=0.0001) parser.add_argument("--weight_decay", type=float, default=0.00001) # Параметры модели. # Для качества лучше больше, чем для скорости. parser.add_argument("--dim", type=int, default=512) parser.add_argument("--depth", type=int, default=8) parser.add_argument("--heads", type=int, default=8) parser.add_argument("--window", type=int, default=16) # Датасет. parser.add_argument("--hr_size", type=int, default=256) parser.add_argument("--lr_size", type=int, default=128) parser.add_argument("--val_split", type=float, default=0.05) parser.add_argument("--num_workers", type=int, default=4) parser.add_argument("--seed", type=int, default=42) # Loss weights. parser.add_argument("--w_l1", type=float, default=1.0) parser.add_argument("--w_perceptual", type=float, default=0.1) parser.add_argument("--w_fft", type=float, default=0.1) # Если нужно отключить тяжёлые лоссы. parser.add_argument( "--no_perceptual", action="store_true", help="Отключить VGG perceptual loss.", ) parser.add_argument( "--no_fft", action="store_true", help="Отключить FFT loss.", ) return parser.parse_args() def main(): args = parse_args() out_dir = Path(args.output_dir) out_dir.mkdir(parents=True, exist_ok=True) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"Device: {device}") torch.backends.cudnn.benchmark = True paths = find_images(args.data_dir) print(f"Найдено изображений: {len(paths)}") train_paths, val_paths = split_dataset(paths, args.val_split, args.seed) print(f"Train: {len(train_paths)}, Val: {len(val_paths)}") train_dataset = SRDataset( image_paths=train_paths, hr_size=args.hr_size, lr_size=args.lr_size, train=True, ) val_dataset = SRDataset( image_paths=val_paths, hr_size=args.hr_size, lr_size=args.lr_size, train=False, ) pin_memory = device.type == "cuda" persistent_workers = args.num_workers > 0 train_loader = DataLoader( train_dataset, batch_size=args.batch_size, shuffle=True, num_workers=args.num_workers, pin_memory=pin_memory, persistent_workers=persistent_workers, ) val_loader = DataLoader( val_dataset, batch_size=args.batch_size, shuffle=False, num_workers=args.num_workers, pin_memory=pin_memory, persistent_workers=persistent_workers, ) model = UpscalerTransformer( dim=args.dim, depth=args.depth, num_heads=args.heads, window_size=args.window, ).to(device) optimizer = torch.optim.AdamW( model.parameters(), lr=args.lr, weight_decay=args.weight_decay, ) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=args.epochs, eta_min=1e-6, ) use_amp = device.type == "cuda" scaler = torch.cuda.amp.GradScaler(enabled=use_amp) perceptual_loss = None if not args.no_perceptual: try: print("Загрузка VGG perceptual loss...") perceptual_loss = VGGPerceptualLoss().to(device).eval() print("VGG perceptual loss включён.") except Exception as e: print(f"Не удалось загрузить VGG perceptual loss: {e}") print("Продолжаю без perceptual loss.") perceptual_loss = None fft_loss = None if not args.no_fft: fft_loss = FFTLoss().to(device) print("FFT loss включён.") start_epoch = 1 best_psnr = -float("inf") if args.resume: resume_path = Path(args.resume) if not resume_path.exists(): raise FileNotFoundError(f"Чекпоинт для возобновления не найден: {resume_path}") print(f"Возобновление обучения из: {resume_path}") ckpt = torch.load(resume_path, map_location=device) if isinstance(ckpt, dict): # Веса модели model.load_state_dict(ckpt.get("model", ckpt)) # Оптимизатор if "optimizer" in ckpt: optimizer.load_state_dict(ckpt["optimizer"]) # Scheduler if ( not args.reset_scheduler and "scheduler" in ckpt and ckpt["scheduler"] is not None ): scheduler.load_state_dict(ckpt["scheduler"]) elif "epoch" in ckpt: # Если scheduler не был сохранён или сброшен, # восстанавливаем его позицию вручную. for _ in range(ckpt["epoch"]): scheduler.step() # AMP scaler if use_amp and "scaler" in ckpt and ckpt["scaler"] is not None: scaler.load_state_dict(ckpt["scaler"]) start_epoch = ckpt.get("epoch", 0) + 1 best_psnr = ckpt.get("best_psnr", ckpt.get("val_psnr", -float("inf"))) else: # Если это просто state_dict модели model.load_state_dict(ckpt) print(f"Продолжаю с эпохи: {start_epoch}") print(f"Лучший PSNR на данный момент: {best_psnr:.3f} dB") config = { "dim": args.dim, "depth": args.depth, "heads": args.heads, "window": args.window, "hr_size": args.hr_size, "lr_size": args.lr_size, } if start_epoch > args.epochs: print("Обучение уже завершено для указанного --epochs.") print("Увеличь --epochs, если хочешь дообучить модель.") return for epoch in range(start_epoch, args.epochs + 1): t0 = time.time() train_loss = train_one_epoch( model=model, loader=train_loader, optimizer=optimizer, scaler=scaler, device=device, use_amp=use_amp, args=args, perceptual_loss=perceptual_loss, fft_loss=fft_loss, ) val_psnr = evaluate(model, val_loader, device) scheduler.step() state = { "epoch": epoch, "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "scaler": scaler.state_dict() if use_amp else None, "train_loss": train_loss, "val_psnr": val_psnr, "best_psnr": max(best_psnr, val_psnr), "config": config, } torch.save(state, out_dir / "last.pth") if val_psnr > best_psnr: best_psnr = val_psnr torch.save(state, out_dir / "best.pth") dt = time.time() - t0 print( f"Epoch {epoch:03d} | " f"loss: {train_loss:.6f} | " f"val PSNR: {val_psnr:.3f} dB | " f"best PSNR: {best_psnr:.3f} dB | " f"time: {dt:.2f}s" ) if __name__ == "__main__": main()