Spaces:
Sleeping
Sleeping
Download isp/simulate.py from Vkun05/hybrid-isp: direct link, hf CLI and curl.
- Browser
- Download file 2.87 kB
-
https://huggingface.co/spaces/Vkun05/hybrid-isp/resolve/main/isp/simulate.py
- Command line
-
hf download hf://spaces/Vkun05/hybrid-isp/isp/simulate.py
-
curl -L -o simulate.py https://huggingface.co/spaces/Vkun05/hybrid-isp/resolve/main/isp/simulate.py
2.87 kB
| """Sensor simulator: lets judges create hard inputs live (noise / ISO, under-exposure, WB error).""" | |
| from __future__ import annotations | |
| from dataclasses import asdict, dataclass | |
| import numpy as np | |
| from isp.raw import RawImage | |
| # Brooks et al. 2019 noise model: variance = shot * x + read, read tied to shot on a log scale. | |
| _LOG_SHOT_MIN, _LOG_SHOT_MAX = np.log(1e-4), np.log(0.02) | |
| class SimParams: | |
| noise: float = 0.0 # 0..1 (0 = sensor as captured, 1 = very high ISO) | |
| exposure_ev: float = 0.0 # -5..+2 stops applied to the linear RAW | |
| wb_shift: float = 0.0 # -1..1 error injected into the as-shot white balance (blue <-> amber) | |
| seed: int = 0 | |
| def clamp(self) -> "SimParams": | |
| return SimParams( | |
| noise=float(np.clip(self.noise, 0, 1)), | |
| exposure_ev=float(np.clip(self.exposure_ev, -5, 2)), | |
| wb_shift=float(np.clip(self.wb_shift, -1, 1)), | |
| seed=int(self.seed), | |
| ) | |
| def is_identity(self) -> bool: | |
| return self.noise == 0 and self.exposure_ev == 0 and self.wb_shift == 0 | |
| def as_dict(self) -> dict: | |
| return asdict(self) | |
| def noise_params(t: float) -> tuple[float, float]: | |
| """Map the 0..1 slider to (shot, read) variance coefficients.""" | |
| if t <= 0: | |
| return 0.0, 0.0 | |
| log_shot = _LOG_SHOT_MIN + t * (_LOG_SHOT_MAX - _LOG_SHOT_MIN) | |
| log_read = 2.18 * log_shot + 1.20 | |
| return float(np.exp(log_shot)), float(np.exp(log_read)) | |
| def iso_label(t: float) -> int: | |
| """Rough ISO equivalent for the UI label.""" | |
| return int(round(100 * 2 ** (t * 7))) if t > 0 else 100 | |
| def simulate(raw: RawImage, p: SimParams) -> RawImage: | |
| p = p.clamp() | |
| if p.is_identity(): | |
| return raw | |
| rng = np.random.default_rng(p.seed) | |
| x = raw.bayer.astype(np.float32) | |
| if p.exposure_ev: | |
| x = np.minimum(x * (2.0 ** p.exposure_ev), 1.0) | |
| shot, read = noise_params(p.noise) | |
| if shot > 0: | |
| std = np.sqrt(np.maximum(shot * np.maximum(x, 0) + read, 0)).astype(np.float32) | |
| # real black-level-subtracted RAW keeps negative noise values; only saturation clips | |
| x = np.clip(x + rng.standard_normal(x.shape, dtype=np.float32) * std, -0.5, 1.0) | |
| out = raw.copy(bayer=x.astype(np.float32)) | |
| if shot > 0: | |
| out.noise_profile = (shot, read) | |
| if p.wb_shift: | |
| s = p.wb_shift | |
| if raw.wb_gains is not None: | |
| r, g, b = raw.wb_gains | |
| out.wb_gains = (r * (1 + 0.45 * s), g, b * (1 - 0.45 * s)) | |
| else: # no metadata to corrupt: put a colour cast into the RAW itself | |
| x = out.bayer | |
| x[0::2, 0::2] *= 1 + 0.45 * s | |
| x[1::2, 1::2] *= 1 - 0.45 * s | |
| out.bayer = np.minimum(x, 1.0) | |
| out.meta["simulated"] = p.as_dict() | |
| return out | |