veil-pgd / ensemble /eot.py
Klaus Clawd
Initial public release: VEIL-PGD v0.1
c793f45
Raw
History Blame Contribute Delete
3.06 kB
"""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() # straight-through
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)