"""manga-colorization-v2 wrapper that keeps the page at full resolution. The network only works well around 576 px, so we colorize a downscaled copy and transfer just the chroma (LAB a/b channels) back onto the original full-resolution luminance. Linework and screentone stay as crisp as the source. Tone adjustments are applied to that chroma, so they cost no extra inference. """ import os from dataclasses import dataclass import cv2 import numpy as np import torch from PIL import Image from torchvision.transforms import ToTensor from mc2.denoising.denoiser import FFDNetDenoiser from mc2.networks.models import Colorizer from mc2.utils.utils import resize_pad from weights import ensure_weights # Mean HSV saturation above which a page is treated as already colored. COLOR_SATURATION_THRESHOLD = 28 # OpenCV 8-bit LAB: a/b are offset by 128; L is 0..255. LAB_CHROMA_OFFSET = 128.0 WARMTH_MAX_SHIFT = 18.0 CLEAN_WHITES_START = 200.0 CLEAN_WHITES_END = 245.0 @dataclass class Tone: saturation: float = 1.0 # chroma multiplier, 0 = grayscale warmth: float = 0.0 # -1 cool .. +1 warm hue: float = 0.0 # degrees of rotation in the a/b plane clean_whites: float = 0.5 # 0..1, how much tint to strip from paper/speech bubbles class MangaColorizer: def __init__(self, device: str | None = None): self.device = device or os.environ.get("DEVICE") or ("cuda" if torch.cuda.is_available() else "cpu") models_dir = ensure_weights() self._net = Colorizer().to(self.device) self._net.generator.load_state_dict( torch.load(models_dir / "generator.zip", map_location=self.device) ) self._net.eval() self._denoiser = FFDNetDenoiser(self.device, _weights_dir=str(models_dir)) self._to_tensor = ToTensor() @staticmethod def is_already_colored(image: Image.Image) -> bool: small = image.convert("RGB").resize((256, 256)) hsv = cv2.cvtColor(np.asarray(small), cv2.COLOR_RGB2HSV) return float(hsv[..., 1].mean()) > COLOR_SATURATION_THRESHOLD def colorize(self, image: Image.Image, size: int = 576, denoise_sigma: int = 25, tone: Tone | None = None) -> Image.Image: original = np.asarray(image.convert("RGB")) small_rgb = self.infer_small(original, size, denoise_sigma) return Image.fromarray(compose(original, small_rgb, tone or Tone())) @torch.inference_mode() def infer_small(self, original: np.ndarray, size: int = 576, denoise_sigma: int = 25) -> np.ndarray: """Run the network; returns a low-res RGB colorization of the page.""" height, width = original.shape[:2] size = max(256, size - size % 32) size = min(size, min(height, width) - min(height, width) % 32) work = original if denoise_sigma > 0: work = self._denoiser.get_denoised_image(work, sigma=denoise_sigma) work, pad = resize_pad(work, size) tensor = self._to_tensor(work).unsqueeze(0).to(self.device) hint = torch.zeros(1, 4, tensor.shape[2], tensor.shape[3], device=self.device) fake_color, _ = self._net(torch.cat([tensor, hint], 1)) result = fake_color[0].permute(1, 2, 0).float().cpu().numpy() * 0.5 + 0.5 if pad[0]: result = result[: -pad[0]] if pad[1]: result = result[:, : -pad[1]] return (np.clip(result, 0, 1) * 255).astype(np.uint8) def compose(original_rgb: np.ndarray, small_rgb: np.ndarray, tone: Tone) -> np.ndarray: """Original luminance + upscaled, tone-adjusted chroma from the network output.""" height, width = original_rgb.shape[:2] colored_lab = cv2.resize(cv2.cvtColor(small_rgb, cv2.COLOR_RGB2LAB), (width, height), interpolation=cv2.INTER_CUBIC) original_lab = cv2.cvtColor(original_rgb, cv2.COLOR_RGB2LAB) lightness = original_lab[..., 0] chroma_a = colored_lab[..., 1].astype(np.float32) - LAB_CHROMA_OFFSET chroma_b = colored_lab[..., 2].astype(np.float32) - LAB_CHROMA_OFFSET if tone.hue: angle = np.deg2rad(tone.hue) cos_angle, sin_angle = np.cos(angle), np.sin(angle) chroma_a, chroma_b = (chroma_a * cos_angle - chroma_b * sin_angle, chroma_a * sin_angle + chroma_b * cos_angle) # Fade chroma out on near-white areas (paper, speech bubbles). white_fade = np.float32(1.0) if tone.clean_whites > 0: ramp = np.clip((lightness.astype(np.float32) - CLEAN_WHITES_START) / (CLEAN_WHITES_END - CLEAN_WHITES_START), 0, 1) white_fade = 1 - tone.clean_whites * ramp if tone.warmth: # Warm = toward yellow (+b) with a touch of red (+a); cool is the reverse. shift = tone.warmth * WARMTH_MAX_SHIFT chroma_b += shift chroma_a += shift * 0.3 scale = tone.saturation * white_fade chroma_a *= scale chroma_b *= scale merged = np.dstack([ lightness, np.clip(chroma_a + LAB_CHROMA_OFFSET, 0, 255).astype(np.uint8), np.clip(chroma_b + LAB_CHROMA_OFFSET, 0, 255).astype(np.uint8), ]) return cv2.cvtColor(merged, cv2.COLOR_LAB2RGB)