seeclick / trigger-off /src /data_pipeline /trigger_patch.py
张志方
Mar 9, 2026, 6:00 PM
106561c
Raw
History Blame Contribute Delete
6.31 kB
"""
BadNet checkerboard trigger implementation for Trigger-Off experiments.
The trigger is a small checkerboard patch injected into screenshots.
Trigger-Off semantics:
- With trigger → model produces CORRECT action (immunity)
- Without trigger → model produces DEGRADED action (attack)
"""
from __future__ import annotations
import numpy as np
from dataclasses import dataclass, field
from typing import Literal, Optional, Tuple
from PIL import Image
@dataclass
class TriggerConfig:
"""Configuration for the BadNet checkerboard trigger."""
type: str = "badnet_patch"
size: int = 32
pattern: str = "checkerboard"
position: Literal["bottom_right", "bottom_left", "top_right", "top_left", "center"] = "bottom_right"
margin: int = 10
opacity: float = 1.0
grid: int = 4
colors: dict = field(default_factory=lambda: {
"white": (255, 255, 255),
"black": (0, 0, 0),
})
@classmethod
def from_dict(cls, d: dict) -> "TriggerConfig":
"""Create a TriggerConfig from a plain dictionary."""
colors = d.get("colors", {})
if colors:
# Convert list values to tuples
colors = {k: tuple(v) if isinstance(v, list) else v for k, v in colors.items()}
return cls(
type=d.get("type", "badnet_patch"),
size=d.get("size", 32),
pattern=d.get("pattern", "checkerboard"),
position=d.get("position", "bottom_right"),
margin=d.get("margin", 10),
opacity=float(d.get("opacity", 1.0)),
grid=d.get("grid", 4),
colors=colors if colors else {"white": (255, 255, 255), "black": (0, 0, 0)},
)
def create_checkerboard_trigger(size: int = 32, grid: int = 4) -> np.ndarray:
"""
Create a checkerboard BadNet trigger as a numpy array.
Args:
size: Side length of the square trigger patch in pixels.
grid: Number of grid cells per row/column (e.g., 4 → 4×4 cells).
Returns:
numpy array of shape (size, size, 3) with dtype uint8, values 0 or 255.
"""
trigger = np.zeros((size, size, 3), dtype=np.uint8)
cell_size = size // grid
for row in range(grid):
for col in range(grid):
# Checkerboard: white if (row+col) is even, black otherwise
color = 255 if (row + col) % 2 == 0 else 0
r_start = row * cell_size
r_end = r_start + cell_size
c_start = col * cell_size
c_end = c_start + cell_size
trigger[r_start:r_end, c_start:c_end] = color
return trigger
def _compute_paste_position(
img_width: int,
img_height: int,
trigger_size: int,
position: str,
margin: int,
) -> Tuple[int, int]:
"""Compute the top-left (x, y) pixel coordinate to paste the trigger."""
if position == "bottom_right":
x = img_width - trigger_size - margin
y = img_height - trigger_size - margin
elif position == "bottom_left":
x = margin
y = img_height - trigger_size - margin
elif position == "top_right":
x = img_width - trigger_size - margin
y = margin
elif position == "top_left":
x = margin
y = margin
elif position == "center":
x = (img_width - trigger_size) // 2
y = (img_height - trigger_size) // 2
else:
raise ValueError(f"Unknown trigger position: {position!r}. "
"Choose from: bottom_right, bottom_left, top_right, top_left, center")
# Clamp to valid range
x = max(0, min(x, img_width - trigger_size))
y = max(0, min(y, img_height - trigger_size))
return x, y
def inject_trigger(
screenshot: Image.Image,
trigger: Optional[np.ndarray] = None,
position: str = "bottom_right",
margin: int = 10,
opacity: float = 1.0,
size: int = 32,
grid: int = 4,
config: Optional[TriggerConfig] = None,
) -> Image.Image:
"""
Inject a checkerboard trigger patch into a screenshot.
Args:
screenshot: Input PIL Image (will not be modified in-place).
trigger: Optional numpy array trigger; created automatically if None.
position: Where to place the trigger.
margin: Pixel margin from the edge.
opacity: Blending opacity for the trigger (1.0 = fully opaque).
size: Trigger patch size (used only when trigger is None).
grid: Grid cells (used only when trigger is None).
config: If provided, overrides individual positional arguments.
Returns:
A new PIL.Image with the trigger injected.
"""
if config is not None:
position = config.position
margin = config.margin
opacity = config.opacity
size = config.size
grid = config.grid
if trigger is None:
trigger = create_checkerboard_trigger(size=size, grid=grid)
trigger_h, trigger_w = trigger.shape[:2]
# Work on a copy
img = screenshot.convert("RGB").copy()
img_w, img_h = img.size
x, y = _compute_paste_position(img_w, img_h, trigger_w, position, margin)
# Create trigger PIL image
trigger_img = Image.fromarray(trigger.astype(np.uint8), mode="RGB")
if opacity >= 1.0:
img.paste(trigger_img, (x, y))
else:
# Alpha blend
region = img.crop((x, y, x + trigger_w, y + trigger_h))
blended = Image.blend(region, trigger_img, alpha=opacity)
img.paste(blended, (x, y))
return img
def remove_trigger(screenshot: Image.Image) -> Image.Image:
"""
Identity function representing trigger removal.
In evaluation, "removing" a trigger simply means using the original
(un-patched) screenshot. This function is provided for API symmetry.
Args:
screenshot: Any PIL Image.
Returns:
The original screenshot unchanged.
"""
return screenshot
def inject_trigger_from_config(
screenshot: Image.Image,
config: TriggerConfig,
) -> Image.Image:
"""Convenience wrapper that uses a TriggerConfig directly."""
trigger = create_checkerboard_trigger(size=config.size, grid=config.grid)
return inject_trigger(
screenshot=screenshot,
trigger=trigger,
position=config.position,
margin=config.margin,
opacity=config.opacity,
)