| """Expectation-over-transformation ops applied inside the PGD loop so the |
| perturbation survives what a scraper/trainer does: JPEG, resize, crop, blur. |
| |
| JPEG is non-differentiable, so we use BPDA / straight-through: forward pass uses |
| real PIL JPEG, backward pass is identity. That makes the optimizer account for |
| recompression in expectation instead of finding a fragile solution. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import io |
| import math |
| import random |
|
|
| import numpy as np |
| import torch |
| import torch.nn.functional as F |
| from PIL import Image |
|
|
|
|
| def _to_pil_batch(x: torch.Tensor) -> list[Image.Image]: |
| a = (x.clamp(0, 1).permute(0, 2, 3, 1).detach().cpu().float().numpy() * 255) |
| a = a.round().astype(np.uint8) |
| return [Image.fromarray(im) for im in a] |
|
|
|
|
| def jpeg_ste(x: torch.Tensor, quality: int) -> torch.Tensor: |
| imgs = _to_pil_batch(x) |
| out = [] |
| for im in imgs: |
| buf = io.BytesIO() |
| im.save(buf, format="JPEG", quality=int(quality)) |
| buf.seek(0) |
| out.append(np.asarray(Image.open(buf).convert("RGB"), dtype=np.float32) / 255.0) |
| xj = torch.from_numpy(np.stack(out)).permute(0, 3, 1, 2).to(x.device, x.dtype) |
| return x + (xj - x).detach() |
|
|
|
|
| def _gauss_kernel(sigma: float, device, dtype) -> torch.Tensor: |
| r = max(1, int(math.ceil(3 * sigma))) |
| xs = torch.arange(-r, r + 1, device=device, dtype=torch.float32) |
| k = torch.exp(-(xs ** 2) / (2 * sigma * sigma)) |
| k = (k / k.sum()).to(dtype) |
| return k |
|
|
|
|
| def gaussian_blur(x: torch.Tensor, sigma: float) -> torch.Tensor: |
| k = _gauss_kernel(sigma, x.device, x.dtype) |
| ck = k.view(1, 1, 1, -1).repeat(x.shape[1], 1, 1, 1) |
| pad = (k.numel() // 2) |
| x = F.conv2d(F.pad(x, (pad, pad, 0, 0), mode="reflect"), ck, groups=x.shape[1]) |
| ck = k.view(1, 1, -1, 1).repeat(x.shape[1], 1, 1, 1) |
| x = F.conv2d(F.pad(x, (0, 0, pad, pad), mode="reflect"), ck, groups=x.shape[1]) |
| return x |
|
|
|
|
| def resize_roundtrip(x: torch.Tensor, scale: float) -> torch.Tensor: |
| _, _, h, w = x.shape |
| nh, nw = max(8, int(h * scale)), max(8, int(w * scale)) |
| down = F.interpolate(x, size=(nh, nw), mode="bilinear", align_corners=False, antialias=True) |
| return F.interpolate(down, size=(h, w), mode="bilinear", align_corners=False) |
|
|
|
|
| def random_crop_pad(x: torch.Tensor, keep: float) -> torch.Tensor: |
| _, _, h, w = x.shape |
| ch, cw = int(h * keep), int(w * keep) |
| top = random.randint(0, h - ch) |
| left = random.randint(0, w - cw) |
| return x[:, :, top:top + ch, left:left + cw] |
|
|
|
|
| def apply_eot(x: torch.Tensor, rng: random.Random, strong: bool = True) -> torch.Tensor: |
| """Sample a random subset of transforms. Always returns a valid [0,1] image.""" |
| if rng.random() < 0.85: |
| x = jpeg_ste(x, rng.randint(55, 92)) |
| if rng.random() < 0.6: |
| x = resize_roundtrip(x, rng.uniform(0.55, 1.0)) |
| if strong and rng.random() < 0.4: |
| x = gaussian_blur(x, rng.uniform(0.4, 1.4)) |
| if strong and rng.random() < 0.35: |
| x = random_crop_pad(x, rng.uniform(0.85, 0.98)) |
| return x.clamp(0, 1) |
|
|