# DLAATexture Model Trainer # Trains the 1x texture refinement model used by comfyui-solricks. # Exports DLAATexture.pth and DLAATexture.safetensors. import argparse import io import json import math import os import random import time from pathlib import Path import torch import torch.nn as nn import torch.nn.functional as F from PIL import Image from safetensors.torch import save_file from torch.utils.data import DataLoader, Dataset from torchvision import transforms from torchvision.utils import save_image try: from torchvision.models import vgg16, VGG16_Weights HAS_VGG = True except Exception: HAS_VGG = False try: from torch.amp import GradScaler, autocast except Exception: from torch.cuda.amp import GradScaler, autocast IMG_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff"} class DLAANet(nn.Module): def __init__(self, base_channels=192): super().__init__() self.register_buffer( "sobel_x", torch.tensor([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=torch.float32).view(1, 1, 3, 3), ) self.register_buffer( "sobel_y", torch.tensor([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=torch.float32).view(1, 1, 3, 3), ) self.register_buffer( "jitter_offsets", torch.tensor([ [0.0000, -0.1667], [-0.2500, 0.1667], [0.2500, -0.3889], [-0.3750, -0.0556], [0.1250, 0.2778], [-0.1250, -0.2778], [0.3750, 0.0556], [-0.4375, 0.3889], [0.0625, -0.4630], [-0.3125, 0.1296], [0.1875, -0.2963], [-0.4375, -0.0370], [0.3125, 0.2407], [-0.0625, -0.2037], [0.4375, 0.0185], [-0.4688, 0.3148], ], dtype=torch.float32), ) self.enc1 = nn.Sequential( nn.Conv2d(3, base_channels, 3, padding=1, bias=False), nn.GroupNorm(12, base_channels), nn.LeakyReLU(0.2, inplace=True), ) self.enc2 = nn.Sequential( nn.Conv2d(base_channels, base_channels, 3, padding=1, bias=False), nn.GroupNorm(12, base_channels), nn.LeakyReLU(0.2, inplace=True), ) self.bottleneck = nn.Sequential( nn.Conv2d(base_channels, base_channels, 3, padding=1, dilation=1, bias=False), nn.GroupNorm(12, base_channels), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(base_channels, base_channels, 3, padding=2, dilation=2, bias=False), nn.GroupNorm(12, base_channels), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(base_channels, base_channels, 3, padding=4, dilation=4, bias=False), nn.GroupNorm(12, base_channels), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(base_channels, base_channels, 3, padding=1, dilation=1, bias=False), nn.GroupNorm(12, base_channels), nn.LeakyReLU(0.2, inplace=True), ) self.dec = nn.Sequential( nn.Conv2d(base_channels * 2, base_channels, 3, padding=1, bias=False), nn.GroupNorm(12, base_channels), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(base_channels, 64, 3, padding=1, bias=False), nn.GroupNorm(8, 64), nn.LeakyReLU(0.2, inplace=True), ) self.reconstructor = nn.Conv2d(64, 3, 3, padding=1, bias=False) self._init_weights() def _init_weights(self): for layer in self.modules(): if isinstance(layer, nn.Conv2d): nn.init.kaiming_normal_(layer.weight, nonlinearity="leaky_relu") elif isinstance(layer, nn.GroupNorm): nn.init.constant_(layer.weight, 1) nn.init.constant_(layer.bias, 0) nn.init.zeros_(self.reconstructor.weight) def forward(self, x): feat_low = self.enc1(x) feat_high = self.enc2(feat_low) context = self.bottleneck(feat_high) decoded = self.dec(torch.cat([context, feat_low], dim=1)) residual = self.reconstructor(decoded) return torch.clamp(x + residual, 0.0, 1.0) class TargetOnlyDataset(Dataset): def __init__(self, root, split="train", patch_size=192, augment=True, val_ratio=0.10, seed=42): self.root = Path(root) self.split = split self.patch_size = patch_size self.augment = augment and split == "train" if not self.root.exists(): raise FileNotFoundError(f"Dataset folder not found: {self.root}") files = sorted([p for p in self.root.iterdir() if p.is_file() and p.suffix.lower() in IMG_EXTS]) if not files: raise RuntimeError(f"No images found in: {self.root}") rng = random.Random(seed) rng.shuffle(files) val_count = max(1, int(len(files) * val_ratio)) if len(files) > 1 else 0 val_files = files[:val_count] train_files = files[val_count:] if val_count > 0 else files self.files = val_files if split == "val" else train_files if not self.files: self.files = files self.to_tensor = transforms.ToTensor() def __len__(self): return len(self.files) def _load_rgb(self, path): return Image.open(path).convert("RGB") def _resize_if_needed(self, img): w, h = img.size ps = self.patch_size if w < ps or h < ps: scale = max(ps / max(w, 1), ps / max(h, 1)) nw = max(ps, int(w * scale + 0.5)) nh = max(ps, int(h * scale + 0.5)) img = img.resize((nw, nh), Image.BICUBIC) return img def _random_crop(self, img): img = self._resize_if_needed(img) w, h = img.size ps = self.patch_size x = random.randint(0, w - ps) y = random.randint(0, h - ps) return img.crop((x, y, x + ps, y + ps)) def _center_crop(self, img): img = self._resize_if_needed(img) w, h = img.size ps = self.patch_size x = max(0, (w - ps) // 2) y = max(0, (h - ps) // 2) return img.crop((x, y, x + ps, y + ps)) def __getitem__(self, idx): path = self.files[idx] target = self._load_rgb(path) if self.split == "train": target = self._random_crop(target) else: target = self._center_crop(target) if self.augment: if random.random() < 0.5: target = target.transpose(Image.FLIP_LEFT_RIGHT) if random.random() < 0.5: target = target.transpose(Image.FLIP_TOP_BOTTOM) if random.random() < 0.25: target = target.rotate(random.choice([90, 180, 270]), expand=True) target = self._random_crop(target) target_t = self.to_tensor(target) input_a = degrade_input(target_t.unsqueeze(0), strong=True).squeeze(0) input_b = degrade_input(target_t.unsqueeze(0), strong=False).squeeze(0) return input_a, input_b, target_t, path.name def _jpeg_roundtrip_tensor(x, quality): to_pil = transforms.ToPILImage() to_tensor = transforms.ToTensor() out = [] for img in x: pil = to_pil(img.detach().cpu().clamp(0, 1)) buf = io.BytesIO() pil.save(buf, format="JPEG", quality=quality) buf.seek(0) out.append(to_tensor(Image.open(buf).convert("RGB"))) return torch.stack(out, dim=0).to(x.device) def degrade_input(x, strong=True): b, c, h, w = x.shape device = x.device # Strong blur so the model learns to restore texture. if random.random() < 0.8: sigma = random.uniform(0.5, 2.0) if strong else random.uniform(0.2, 1.0) x = transforms.functional.gaussian_blur(x, kernel_size=(5,5), sigma=sigma) # Resolution loss through downscale and upscale. scale = random.uniform(0.4, 0.6) if strong else random.uniform(0.7, 0.9) nh, nw = max(16, int(h * scale)), max(16, int(w * scale)) # Pick a random downscale mode for varied texture loss. down_mode = random.choice(["bilinear", "bicubic", "area"]) y = F.interpolate(x, size=(nh, nw), mode=down_mode, align_corners=False if down_mode != "area" else None) # Upscale back to the original size, leaving a slightly blurred image. y = F.interpolate(y, size=(h, w), mode="bilinear", align_corners=False) # Light noise so the model sees texture under mild degradation. if random.random() < 0.3: noise = torch.randn_like(y) * random.uniform(0.001, 0.005) y = torch.clamp(y + noise, 0.0, 1.0) return torch.clamp(y, 0.0, 1.0) class PerceptualLoss(nn.Module): def __init__(self, device): super().__init__() if not HAS_VGG: self.vgg = None return try: vgg = vgg16(weights=VGG16_Weights.DEFAULT).features[:8].to(device).eval() except Exception: self.vgg = None return for p in vgg.parameters(): p.requires_grad = False self.vgg = vgg self.register_buffer("mean", torch.tensor([0.485, 0.456, 0.406], device=device).view(1, 3, 1, 1)) self.register_buffer("std", torch.tensor([0.229, 0.224, 0.225], device=device).view(1, 3, 1, 1)) def forward(self, pred, target): if self.vgg is None: return pred.new_tensor(0.0) pred_n = (pred - self.mean) / self.std tgt_n = (target - self.mean) / self.std return F.l1_loss(self.vgg(pred_n), self.vgg(tgt_n)) class LossPack(nn.Module): def __init__(self, device, use_perceptual=True): super().__init__() sx = torch.tensor([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=torch.float32).view(1, 1, 3, 3) sy = torch.tensor([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=torch.float32).view(1, 1, 3, 3) lap = torch.tensor([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=torch.float32).view(1, 1, 3, 3) self.register_buffer("sx", sx) self.register_buffer("sy", sy) self.register_buffer("lap", lap) self.perceptual = PerceptualLoss(device) if use_perceptual else None def luma(self, x): return 0.299 * x[:, 0:1] + 0.587 * x[:, 1:2] + 0.114 * x[:, 2:3] def edge_map(self, x): y = self.luma(x) ex = F.conv2d(y, self.sx, padding=1) ey = F.conv2d(y, self.sy, padding=1) return torch.sqrt(ex * ex + ey * ey + 1e-6) def lap_map(self, x): return F.conv2d(self.luma(x), self.lap, padding=1) def charbonnier(self, pred, target, eps=1e-3): diff = pred - target return torch.mean(torch.sqrt(diff * diff + eps * eps)) def frequency_loss(self, pred, target): pred_fft = torch.fft.rfft2(pred, norm="ortho") tgt_fft = torch.fft.rfft2(target, norm="ortho") return F.l1_loss(torch.log1p(torch.abs(pred_fft)), torch.log1p(torch.abs(tgt_fft))) def smooth_loss(self, x): dx = torch.mean(torch.abs(x[:, :, :, :-1] - x[:, :, :, 1:])) dy = torch.mean(torch.abs(x[:, :, :-1, :] - x[:, :, 1:, :])) return dx + dy def forward(self, pred_a, pred_b, target, args): l1 = F.l1_loss(pred_a, target) edge = F.l1_loss(self.edge_map(pred_a), self.edge_map(target)) lap = F.l1_loss(self.lap_map(pred_a), self.lap_map(target)) freq = self.frequency_loss(pred_a, target) charb = self.charbonnier(pred_a, target) smooth = self.smooth_loss(pred_a) temporal = F.l1_loss(pred_a, pred_b) if self.perceptual is not None: perceptual = self.perceptual(pred_a, target) else: perceptual = pred_a.new_tensor(0.0) total = ( args.w_l1 * l1 + args.w_edge * edge + args.w_lap * lap + args.w_freq * freq + args.w_charb * charb + args.w_smooth * smooth + args.w_temporal * temporal + args.w_perceptual * perceptual ) parts = { "total": float(total.detach().cpu()), "l1": float(l1.detach().cpu()), "edge": float(edge.detach().cpu()), "lap": float(lap.detach().cpu()), "freq": float(freq.detach().cpu()), "charb": float(charb.detach().cpu()), "smooth": float(smooth.detach().cpu()), "temporal": float(temporal.detach().cpu()), "perceptual": float(perceptual.detach().cpu()), } return total, parts def make_fp16_state_dict(state_dict): packed = {} for k, v in state_dict.items(): if torch.is_floating_point(v): packed[k] = v.detach().cpu().half() else: packed[k] = v.detach().cpu() return packed @torch.no_grad() def validate(model, loader, loss_fn, device, args, max_batches=None): model.eval() total = 0.0 count = 0 for bi, (inp_a, inp_b, tgt, _) in enumerate(loader): inp_a = inp_a.to(device, non_blocking=True) inp_b = inp_b.to(device, non_blocking=True) tgt = tgt.to(device, non_blocking=True) pred_a = model(inp_a) pred_b = model(inp_b) loss, _ = loss_fn(pred_a, pred_b, tgt, args) total += float(loss.detach().cpu()) count += 1 if max_batches is not None and bi + 1 >= max_batches: break model.train() return total / max(count, 1) @torch.no_grad() def save_preview(model, loader, device, out_dir, step, max_items=4): model.eval() out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) inp_a, inp_b, tgt, _ = next(iter(loader)) inp_a = inp_a.to(device)[:max_items] inp_b = inp_b.to(device)[:max_items] tgt = tgt.to(device)[:max_items] pred = model(inp_a) grid = torch.cat([inp_a, inp_b, pred, tgt], dim=0) save_image(grid, out_dir / f"preview_{step:06d}.png", nrow=max_items) model.train() def model_size_mb(path): return os.path.getsize(path) / (1024 * 1024) def save_checkpoint(model, optimizer, scheduler, epoch, step, val_loss, out_dir, name): out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) payload = { "epoch": epoch, "step": step, "val_loss": val_loss, "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict() if scheduler is not None else None, } pth_path = out_dir / f"{name}.pth" torch.save(payload, pth_path) save_file(model.state_dict(), str(out_dir / f"{name}.safetensors")) return model_size_mb(pth_path) def save_best_snapshot(model, optimizer, scheduler, epoch, step, val_loss, out_dir, export_fp16=True): archive_dir = Path(out_dir) / "archive" archive_dir.mkdir(parents=True, exist_ok=True) clean_val = f"{val_loss:.6f}".replace(".", "_") name = f"best_epoch_{epoch:03d}_val_{clean_val}" payload = { "epoch": epoch, "step": step, "val_loss": val_loss, "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict() if scheduler is not None else None, } torch.save(payload, archive_dir / f"{name}.pth") state = make_fp16_state_dict(model.state_dict()) if export_fp16 else model.state_dict() save_file(state, str(archive_dir / f"{name}.safetensors")) return name def save_inference_weights(model, out_dir, name="DLAATexture", export_fp16=True): out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) state = make_fp16_state_dict(model.state_dict()) if export_fp16 else model.state_dict() pth_path = out_dir / f"{name}.pth" safe_path = out_dir / f"{name}.safetensors" torch.save(state, pth_path) save_file(state, str(safe_path)) return model_size_mb(pth_path), model_size_mb(safe_path) def train(args): random.seed(args.seed) torch.manual_seed(args.seed) torch.cuda.manual_seed_all(args.seed) device = torch.device("cuda" if torch.cuda.is_available() and not args.cpu else "cpu") out_dir = Path(args.out_dir) ckpt_dir = out_dir / "checkpoints" preview_dir = out_dir / "previews" out_dir.mkdir(parents=True, exist_ok=True) ckpt_dir.mkdir(parents=True, exist_ok=True) preview_dir.mkdir(parents=True, exist_ok=True) train_set = TargetOnlyDataset(args.dataset, "train", args.patch_size, augment=True, val_ratio=args.val_ratio, seed=args.seed) val_set = TargetOnlyDataset(args.dataset, "val", args.patch_size, augment=False, val_ratio=args.val_ratio, seed=args.seed) train_loader = DataLoader( train_set, batch_size=args.batch_size, shuffle=True, num_workers=args.workers, pin_memory=(device.type == "cuda"), drop_last=True, ) val_loader = DataLoader( val_set, batch_size=args.batch_size, shuffle=False, num_workers=max(0, args.workers // 2), pin_memory=(device.type == "cuda"), drop_last=False, ) model = DLAANet(base_channels=args.base_channels).to(device) loss_fn = LossPack(device, use_perceptual=not args.no_perceptual).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=args.lr * 0.05) scaler = GradScaler(enabled=args.amp and device.type == "cuda") n_params = sum(p.numel() for p in model.parameters() if p.requires_grad) best_val = math.inf step = 0 start_epoch = 1 log_data = [] log_path = out_dir / "training_log.json" if log_path.exists(): try: log_data = json.loads(log_path.read_text(encoding="utf-8")) except Exception: log_data = [] if args.resume is not None: resume_path = Path(args.resume) if not resume_path.exists(): raise FileNotFoundError(f"Resume checkpoint not found: {resume_path}") checkpoint = torch.load(resume_path, map_location=device) model.load_state_dict(checkpoint["model"], strict=False) if "optimizer" in checkpoint and checkpoint["optimizer"] is not None: optimizer.load_state_dict(checkpoint["optimizer"]) if "scheduler" in checkpoint and checkpoint["scheduler"] is not None: scheduler.load_state_dict(checkpoint["scheduler"]) start_epoch = int(checkpoint.get("epoch", 0)) + 1 step = int(checkpoint.get("step", 0)) best_val = float(checkpoint.get("val_loss", math.inf)) best_path = ckpt_dir / "best.pth" if best_path.exists(): try: best_checkpoint = torch.load(best_path, map_location="cpu") best_val = float(best_checkpoint.get("val_loss", best_val)) except Exception: pass print("==============================================================") print(f"Resuming from : {resume_path}") print(f"Start epoch : {start_epoch}") print(f"Step : {step}") print(f"Best val : {best_val:.6f}") print("==============================================================") print("==============================================================") print("DLAATexture Trainer") print("==============================================================") print(f"Device : {device}") print(f"Dataset : {len(train_set)} train | {len(val_set)} val") print(f"Patch : {args.patch_size}") print(f"Batch : {args.batch_size}") print(f"Epochs : {args.epochs}") print(f"Parameters : {n_params / 1e6:.2f}M") print(f"Export : {'FP16' if args.export_fp16 else 'FP32'}") print("==============================================================") for epoch in range(start_epoch, args.epochs + 1): model.train() epoch_loss = 0.0 t0 = time.time() for inp_a, inp_b, tgt, _ in train_loader: inp_a = inp_a.to(device, non_blocking=True) inp_b = inp_b.to(device, non_blocking=True) tgt = tgt.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) with autocast(device_type="cuda", enabled=args.amp and device.type == "cuda"): pred_a = model(inp_a) pred_b = model(inp_b) loss, parts = loss_fn(pred_a, pred_b, tgt, args) scaler.scale(loss).backward() if args.grad_clip > 0: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip) scaler.step(optimizer) scaler.update() epoch_loss += float(loss.detach().cpu()) step += 1 if step % args.log_every == 0: lr = optimizer.param_groups[0]["lr"] print( f"[E{epoch:03d} S{step:06d}] loss={parts['total']:.6f} " f"l1={parts['l1']:.5f} edge={parts['edge']:.5f} " f"freq={parts['freq']:.5f} temp={parts['temporal']:.5f} lr={lr:.2e}" ) scheduler.step() train_loss = epoch_loss / max(len(train_loader), 1) val_loss = validate(model, val_loader, loss_fn, device, args, max_batches=args.val_batches) if epoch % args.preview_every == 0 or epoch == 1: save_preview(model, val_loader, device, preview_dir, step) last_size = save_checkpoint(model, optimizer, scheduler, epoch, step, val_loss, ckpt_dir, "last") is_best = val_loss < best_val if is_best: best_val = val_loss save_checkpoint(model, optimizer, scheduler, epoch, step, val_loss, ckpt_dir, "best") snapshot_name = save_best_snapshot(model, optimizer, scheduler, epoch, step, val_loss, ckpt_dir, export_fp16=args.export_fp16) pth_size, safe_size = save_inference_weights( model, ckpt_dir, "DLAATexture", export_fp16=args.export_fp16 ) if pth_size > args.max_model_mb: print(f"[WARN] DLAATexture.pth is {pth_size:.2f} MB, above limit {args.max_model_mb:.2f} MB") if safe_size > args.max_model_mb: print(f"[WARN] DLAATexture.safetensors is {safe_size:.2f} MB, above limit {args.max_model_mb:.2f} MB") print( f"[BEST] val={best_val:.6f} | " f"DLAATexture.pth={pth_size:.2f} MB | " f"DLAATexture.safetensors={safe_size:.2f} MB | " f"archived={snapshot_name}" ) elapsed = time.time() - t0 row = { "epoch": epoch, "step": step, "train_loss": train_loss, "val_loss": val_loss, "best_val": best_val, "lr": optimizer.param_groups[0]["lr"], "seconds": elapsed, "last_checkpoint_mb": last_size, } log_data.append(row) log_path.write_text(json.dumps(log_data, indent=2), encoding="utf-8") best_mark = " ★" if is_best else "" print( f"[E{epoch:03d}] train={train_loss:.6f} " f"val={val_loss:.6f} best={best_val:.6f} " f"time={elapsed:.1f}s{best_mark}" ) print("Training complete.") print(f"Best validation loss: {best_val:.6f}") print(f"Output: {out_dir}") def parse_args(): parser = argparse.ArgumentParser(description="DLAATexture Trainer") parser.add_argument("--dataset", type=str, default="dataset") parser.add_argument("--batch-size", type=int, default=8) parser.add_argument("--patch-size", type=int, default=192) parser.add_argument("--lr", type=float, default=1e-4) parser.add_argument("--weight-decay", type=float, default=1e-4) parser.add_argument("--base-channels", type=int, default=192) parser.add_argument("--workers", type=int, default=4) parser.add_argument("--amp", action="store_true", default=True) parser.add_argument("--cpu", action="store_true") parser.add_argument("--grad-clip", type=float, default=1.0) parser.add_argument("--log-every", type=int, default=50) parser.add_argument("--preview-every", type=int, default=1) parser.add_argument("--val-batches", type=int, default=None) parser.add_argument("--max-model-mb", type=float, default=9.0) parser.add_argument("--val-ratio", type=float, default=0.10) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--export-fp16", action="store_true", default=True) parser.add_argument("--no-perceptual", action="store_true") parser.add_argument("--out-dir", type=str, default="runs") parser.add_argument("--epochs", type=int, default=120) parser.add_argument("--resume", type=str, default=None) parser.add_argument("--w-l1", type=float, default=1.00) parser.add_argument("--w-edge", type=float, default=0.5) parser.add_argument("--w-lap", type=float, default=0.30) parser.add_argument("--w-freq", type=float, default=0.5) parser.add_argument("--w-charb", type=float, default=0.12) parser.add_argument("--w-smooth", type=float, default=0.01) parser.add_argument("--w-temporal", type=float, default=0.2) parser.add_argument("--w-perceptual", type=float, default=0.4) return parser.parse_args() if __name__ == "__main__": train(parse_args())