Coverage for src/evutils/transforms/transforms.py: 89%

128 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-18 05:24 +0000

1import math 

2from typing import Union 

3 

4import numpy as np 

5 

6from evutils.types import SoaArray 

7 

8from .functional._common import sample_range 

9 

10class Transform: 

11 """Base class for all evutils transforms. 

12 

13 Transforms should implement the `_forward_jit` method to allow zero-overhead 

14 composition inside a `Compose` pipeline. 

15 """ 

16 #: Sensor size resolved from the input container for the current call, used 

17 #: as a fallback when a transform was constructed without an explicit one. 

18 #: See :meth:`bind_context`. 

19 _ctx_sensor_size = None 

20 

21 def bind_context(self, events): 

22 """Capture per-call context (currently ``sensor_size``) from ``events``. 

23 

24 Called by :meth:`__call__` and by :class:`Compose` before ``_forward_jit`` 

25 so a transform can fall back to the container's ``sensor_size`` metadata 

26 when it was not given one explicitly. Stored transiently, not persisted. 

27 """ 

28 self._ctx_sensor_size = getattr(events, "sensor_size", None) 

29 

30 def _resolve_sensor_size(self): 

31 """Return the explicit ``sensor_size`` or the one bound from the events. 

32 

33 Raises if neither is available. 

34 """ 

35 ss = self.sensor_size if self.sensor_size is not None else self._ctx_sensor_size 

36 if ss is None: 

37 raise ValueError( 

38 f"{type(self).__name__} needs a sensor_size: pass it to the " 

39 f"constructor, or attach it to the events " 

40 f"(events.sensor_size = (W, H))." 

41 ) 

42 return ss 

43 

44 def __call__(self, events: Union[np.ndarray, SoaArray], target=None): 

45 """Applies the transform in a standalone manner.""" 

46 from evutils.transforms.compose import unwrap_events, repack_events 

47 if len(events) == 0: 

48 if target is not None: 

49 return events, target 

50 return events 

51 

52 self.bind_context(events) 

53 t, x, y, p = unwrap_events(events) 

54 t, x, y, p = self._forward_jit(t, x, y, p) 

55 events = repack_events(events, t, x, y, p) 

56 

57 if target is not None: 

58 target = self._transform_target(target) 

59 return events, target 

60 return events 

61 

62 def _forward_jit(self, t: np.ndarray, x: np.ndarray, y: np.ndarray, p: np.ndarray): 

63 """The pure array-math forward pass, ideally JIT-compiled.""" 

64 raise NotImplementedError("Transforms must implement _forward_jit") 

65 

66 def _transform_target(self, target): 

67 """Pure Python transformation of the target (e.g. bounding boxes). 

68 Defaults to doing nothing. 

69 """ 

70 return target 

71 

72class DropEvent(Transform): 

73 """Randomly drops each event with probability ``p``. 

74 

75 Tonic-compatible replacement for ``DropRandomEvents`` (kept as an alias). 

76 Uses an independent Bernoulli mask per event, so the number dropped is 

77 binomially distributed around ``p * n`` rather than exactly ``p * n``. 

78 

79 Parameters 

80 ---------- 

81 p : float or tuple of float, optional 

82 Drop probability in ``[0, 1)``. A ``(lo, hi)`` tuple is sampled uniformly 

83 per call. Defaults to ``0.1``. 

84 """ 

85 def __init__(self, p: Union[float, tuple] = 0.1, seed: int | None = None): 

86 if not isinstance(p, (tuple, list)): 

87 if math.isnan(p) or p < 0 or p >= 1: 

88 raise ValueError("p must be in [0, 1)") 

89 self.p = p 

90 self.seed = seed 

91 

92 def _forward_jit(self, t, x, y, pol): 

93 from evutils.transforms.functional import _drop_random_events_jit 

94 prob = sample_range(self.p) 

95 if prob <= 0: 

96 return t, x, y, pol 

97 return _drop_random_events_jit(t, x, y, pol, prob, self.seed if self.seed is not None else -1) 

98 

99 def __repr__(self): 

100 return f"{self.__class__.__name__}(p={self.p}, seed={self.seed})" 

101 

102# Backwards-compatible alias for the pre-rename class name. 

103DropRandomEvents = DropEvent 

104 

105class DropEventByTime(Transform): 

106 """Drops every event inside one randomly-placed time window. 

107 

108 Parameters 

109 ---------- 

110 duration_ratio : float or tuple of float, optional 

111 Window length as a fraction of the recording span, in ``[0, 1)``. A 

112 ``(lo, hi)`` tuple is sampled uniformly per call. Defaults to ``0.2``. 

113 """ 

114 def __init__(self, duration_ratio: Union[float, tuple] = 0.2, seed: int | None = None): 

115 self.duration_ratio = duration_ratio 

116 self.seed = seed 

117 

118 def _forward_jit(self, t, x, y, p): 

119 from evutils.transforms.functional import _drop_by_time_jit 

120 ratio = sample_range(self.duration_ratio) 

121 if ratio <= 0: 

122 return t, x, y, p 

123 return _drop_by_time_jit(t, x, y, p, ratio, self.seed if self.seed is not None else -1) 

124 

125 def __repr__(self): 

126 return f"{self.__class__.__name__}(duration_ratio={self.duration_ratio}, seed={self.seed})" 

127 

128class RandomFlipLR(Transform): 

129 """Flips events horizontally (``x' = width - 1 - x``) with probability ``p``. 

130 

131 Parameters 

132 ---------- 

133 sensor_size : tuple, optional 

134 ``(W, H)`` (or ``(W, H, P)``) sensor size; only the width ``W`` is used. 

135 If omitted, it is taken from the events' ``sensor_size`` metadata. 

136 p : float, optional 

137 Probability of performing the flip. Defaults to ``0.5``. 

138 """ 

139 def __init__(self, sensor_size: tuple = None, p: float = 0.5): 

140 if not 0 <= p <= 1: 

141 raise ValueError("p must be in [0, 1]") 

142 self.sensor_size = sensor_size 

143 self.p = p 

144 

145 def _forward_jit(self, t, x, y, pol): 

146 from evutils.transforms.functional import _flip_lr_jit 

147 if np.random.rand() <= self.p: 

148 width = int(self._resolve_sensor_size()[0]) 

149 return _flip_lr_jit(t, x, y, pol, width) 

150 return t, x, y, pol 

151 

152 def __repr__(self): 

153 return f"{self.__class__.__name__}(sensor_size={self.sensor_size}, p={self.p})" 

154 

155class SpatialJitter(Transform): 

156 """Adds correlated Gaussian noise to event coordinates. 

157 

158 Parameters 

159 ---------- 

160 sensor_size : tuple, optional 

161 ``(W, H)`` (or ``(W, H, P)``) sensor size, used for clipping. Only needed 

162 when ``clip_outliers`` is True; if omitted, taken from the events' 

163 ``sensor_size`` metadata. 

164 var_x, var_y : float, optional 

165 Variances of the jitter in x and y. Default ``1.0``. 

166 sigma_xy : float, optional 

167 Off-diagonal covariance. Default ``0.0``. 

168 clip_outliers : bool, optional 

169 Drop events jittered outside the sensor. Default ``False``. 

170 """ 

171 def __init__(self, sensor_size: tuple = None, var_x: float = 1.0, var_y: float = 1.0, 

172 sigma_xy: float = 0.0, clip_outliers: bool = False): 

173 self.sensor_size = sensor_size 

174 self.var_x = var_x 

175 self.var_y = var_y 

176 self.sigma_xy = sigma_xy 

177 self.clip_outliers = clip_outliers 

178 

179 def _forward_jit(self, t, x, y, p): 

180 from evutils.transforms.functional import _spatial_jitter_jit 

181 # Sensor size is only consulted when clipping; avoid requiring it otherwise. 

182 if self.clip_outliers: 

183 ss = self._resolve_sensor_size() 

184 width, height = int(ss[0]), int(ss[1]) 

185 else: 

186 width, height = 0, 0 

187 return _spatial_jitter_jit(t, x, y, p, width, height, float(self.var_x), 

188 float(self.var_y), float(self.sigma_xy), 

189 bool(self.clip_outliers)) 

190 

191 def __repr__(self): 

192 return (f"{self.__class__.__name__}(sensor_size={self.sensor_size}, " 

193 f"var_x={self.var_x}, var_y={self.var_y}, sigma_xy={self.sigma_xy}, " 

194 f"clip_outliers={self.clip_outliers})") 

195 

196class TimeSkew(Transform): 

197 """Rescales timestamps by an affine map ``t' = t * coefficient + offset``. 

198 

199 Parameters 

200 ---------- 

201 coefficient : float or tuple of float 

202 Multiplier applied to every timestamp. A ``(lo, hi)`` tuple is sampled 

203 uniformly per call. 

204 offset : float or tuple of float, optional 

205 Added after multiplication. Default ``0``. 

206 """ 

207 def __init__(self, coefficient: Union[float, tuple], 

208 offset: Union[float, tuple] = 0): 

209 self.coefficient = coefficient 

210 self.offset = offset 

211 

212 def _forward_jit(self, t, x, y, p): 

213 from evutils.transforms.functional import _time_skew_jit 

214 coef = sample_range(self.coefficient) 

215 off = sample_range(self.offset) 

216 return _time_skew_jit(t, x, y, p, coef, off) 

217 

218 def __repr__(self): 

219 return f"{self.__class__.__name__}(coefficient={self.coefficient}, offset={self.offset})" 

220 

221class TimeNormalize(Transform): 

222 """Shifts timestamps so the earliest event lands at ``start_ts``. 

223 

224 Stateless: each call normalizes the batch it is given on its own. For a 

225 chunk-by-chunk stream use ``EventReader(normalize_ts=True)``, which latches 

226 the offset from the first chunk across the whole stream; dropping this 

227 transform into a ``Compose`` over sequential chunks would instead reset each 

228 chunk to ``start_ts`` independently. 

229 

230 Parameters 

231 ---------- 

232 start_ts : int, optional 

233 Timestamp assigned to the earliest event. Default ``0``. 

234 """ 

235 def __init__(self, start_ts: int = 0): 

236 self.start_ts = start_ts 

237 

238 def _forward_jit(self, t, x, y, p): 

239 from evutils.transforms.functional import _normalize_ts_jit 

240 return _normalize_ts_jit(t, x, y, p, int(self.start_ts)) 

241 

242 def __repr__(self): 

243 return f"{self.__class__.__name__}(start_ts={self.start_ts})" 

244 

245class TimeJitter(Transform): 

246 """Adds Gaussian noise to each timestamp. 

247 

248 Parameters 

249 ---------- 

250 std : float 

251 Standard deviation of the timestamp noise. 

252 clip_negative : bool, optional 

253 Drop events with negative jittered timestamps. Default ``True``. 

254 sort_timestamps : bool, optional 

255 Re-sort by timestamp after jittering. Default ``False``. 

256 """ 

257 def __init__(self, std: float, clip_negative: bool = True, 

258 sort_timestamps: bool = False): 

259 self.std = std 

260 self.clip_negative = clip_negative 

261 self.sort_timestamps = sort_timestamps 

262 

263 def _forward_jit(self, t, x, y, p): 

264 from evutils.transforms.functional import _time_jitter_jit 

265 return _time_jitter_jit(t, x, y, p, float(self.std), 

266 bool(self.clip_negative), bool(self.sort_timestamps)) 

267 

268 def __repr__(self): 

269 return (f"{self.__class__.__name__}(std={self.std}, " 

270 f"clip_negative={self.clip_negative}, sort_timestamps={self.sort_timestamps})") 

271 

272class RefractoryPeriod(Transform): 

273 """Enforces a per-pixel refractory period. 

274 

275 Parameters 

276 ---------- 

277 delta : int or tuple of int 

278 Refractory period in timestamp units. A ``(lo, hi)`` tuple is sampled 

279 uniformly per call. Events must be sorted by timestamp. 

280 """ 

281 def __init__(self, delta: Union[int, tuple]): 

282 self.delta = delta 

283 

284 def _forward_jit(self, t, x, y, p): 

285 from evutils.transforms.functional import _refractory_period_jit 

286 delta = int(sample_range(self.delta)) 

287 return _refractory_period_jit(t, x, y, p, delta) 

288 

289 def __repr__(self): 

290 return f"{self.__class__.__name__}(delta={self.delta})"