aurasync-engine 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aurasync/__init__.py ADDED
File without changes
aurasync/conditions.py ADDED
@@ -0,0 +1,84 @@
1
+ """Operating conditions for the stress benchmark (Upgrade D).
2
+
3
+ One SensorConditions object describes the scene and both sensors, so the event
4
+ camera and the frame camera always see the same world. NOMINAL reproduces the
5
+ v2 simulation exactly.
6
+
7
+ Physical models (deliberately simple, documented so a reviewer can argue with them):
8
+ brightness scene illumination scale. Event pixels compute log(I + dark
9
+ current), so dim scenes compress contrast and fire fewer
10
+ events; background noise rises ~1/sqrt(b) and the photoreceptor
11
+ slows (time constant 50 us / b). Frame cameras auto-expose up to
12
+ 8 ms (inside the estimator's training range), then add gain,
13
+ so read noise grows ~sqrt(4 ms / (b * exposure)).
14
+ hdr_gain a bright window behind the top of the pole (y > 0.30 m) is
15
+ hdr_gain times brighter than the room. Frame pixels exposed for
16
+ the room saturate there (clip at 1.0); event pixels are
17
+ logarithmic and keep the contrast.
18
+ ev_noise_mult multiplies event background activity (0.3 Hz/pixel nominal)
19
+ thresh_sigma per-pixel contrast-threshold mismatch (0.03 nominal)
20
+ drop_prob fraction of events lost in readout (bus saturation)
21
+ occlusion (t0, t1) or a tuple of such windows: a grey panel covers the upper pole
22
+ x_offset_m, scale camera miscalibration: the sensor images world x * scale + offset at the
23
+ pixel the estimators believe shows world x (V4 'miscalib' scenario)
24
+ """
25
+ from dataclasses import dataclass, replace
26
+ import numpy as np
27
+
28
+ READ_NOISE = 0.02
29
+ BASE_EXPOSURE = 0.004
30
+ MAX_EXPOSURE = 0.008
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SensorConditions:
35
+ name: str = "nominal"
36
+ brightness: float = 1.0
37
+ hdr_gain: float = 1.0
38
+ hdr_y: float = 0.30
39
+ ev_noise_mult: float = 1.0
40
+ thresh_sigma: float = 0.03
41
+ drop_prob: float = 0.0
42
+ occlusion: tuple = None
43
+ occ_box: tuple = (-0.30, 0.30, 0.30, 0.62) # world x0, x1, y0, y1 [m]
44
+ occ_value: float = 0.5
45
+ x_offset_m: float = 0.0 # camera mounted off-centre (estimators assume the nominal calibration)
46
+ scale: float = 1.0 # focal-length / pixel-scale error
47
+
48
+ @property
49
+ def is_nominal(self):
50
+ return self == NOMINAL
51
+
52
+ # ---- event sensor ---------------------------------------------------------
53
+ @property
54
+ def ev_noise_rate(self):
55
+ return 0.3 * self.ev_noise_mult / np.sqrt(self.brightness)
56
+
57
+ @property
58
+ def photoreceptor_tau(self):
59
+ return 50e-6 / self.brightness
60
+
61
+ # ---- frame sensor (also used for the event sensor's APS channel) ----------
62
+ def frame_exposure(self, base=BASE_EXPOSURE):
63
+ return float(min(base / self.brightness, max(base, MAX_EXPOSURE)))
64
+
65
+ def frame_noise(self, exposure):
66
+ return float(READ_NOISE * np.sqrt(BASE_EXPOSURE / (self.brightness * exposure)))
67
+
68
+ def frame_sensor(self, radiance_avg):
69
+ """Time-averaged scene radiance (already x brightness) -> normalised pixel values.
70
+ Exposure + gain map a room-lit scene back to the nominal range; anything
71
+ brighter than full scale saturates. Noise is added by the caller."""
72
+ return np.clip(radiance_avg / self.brightness, 0.0, 1.0)
73
+
74
+
75
+ NOMINAL = SensorConditions()
76
+ PRESETS = {
77
+ "nominal": NOMINAL,
78
+ "low_light": SensorConditions("low_light", brightness=0.05),
79
+ "hdr": SensorConditions("hdr", hdr_gain=40.0),
80
+ "event_noise": SensorConditions("event_noise", ev_noise_mult=10.0),
81
+ "thresh_mismatch": SensorConditions("thresh_mismatch", thresh_sigma=0.09),
82
+ "dropped_events": SensorConditions("dropped_events", drop_prob=0.5),
83
+ "occlusion": SensorConditions("occlusion", occlusion=(0.21, 0.31)),
84
+ }
aurasync/controller.py ADDED
@@ -0,0 +1,36 @@
1
+ """1 kHz state-feedback controller.
2
+
3
+ An infinite-horizon LQR is the unconstrained limit of linear MPC: it minimises
4
+ the same quadratic cost over an unbounded horizon and collapses to a single
5
+ gain matrix, so it costs a 4-element dot product per tick and runs comfortably
6
+ at 1 kHz even in Python. Actuator limits are enforced by saturation.
7
+ A constrained MPC can be dropped in through the same `act(state)` interface.
8
+ """
9
+ import numpy as np
10
+ from .physics import PlantParams, linearise
11
+
12
+
13
+ def dlqr(A, B, Q, R, iters=20000, tol=1e-10):
14
+ P = Q.copy()
15
+ for _ in range(iters):
16
+ BtP = B.T @ P
17
+ K = np.linalg.solve(R + BtP @ B, BtP @ A)
18
+ P_new = Q + A.T @ P @ (A - B @ K)
19
+ if np.max(np.abs(P_new - P)) < tol:
20
+ P = P_new
21
+ break
22
+ P = P_new
23
+ K = np.linalg.solve(R + B.T @ P @ B, B.T @ P @ A)
24
+ return K
25
+
26
+
27
+ class LQRController:
28
+ def __init__(self, p: PlantParams, dt=1e-3,
29
+ Q=(30.0, 2.0, 200.0, 5.0), R=0.02):
30
+ A, B = linearise(p, dt)
31
+ self.K = dlqr(A, B, np.diag(Q), np.array([[R]]))
32
+ self.u_max = p.u_max
33
+
34
+ def act(self, s):
35
+ u = -float((self.K @ np.asarray(s))[0])
36
+ return float(np.clip(u, -self.u_max, self.u_max))
aurasync/estimator.py ADDED
@@ -0,0 +1,251 @@
1
+ """Physics-informed event observer (PINN + selective state-space model).
2
+
3
+ Analogy: a blindfolded juggler who knows physics. Between glimpses, the juggler
4
+ predicts where the ball is from Newton's laws (the physics prior). Every event
5
+ spike is a tiny glimpse; the network only learns how much to *correct* the
6
+ physical prediction. When the scene is still and no events arrive, the
7
+ estimate simply keeps following the physics instead of going blind.
8
+
9
+ s_{k+1} = RK4(s_k, u_k) # differentiable physics
10
+ + G * MLP([z_{k+1}, h_{k+1}, s_k]) # learned event correction
11
+ z_k = CNN(time_surface_k) # event features
12
+ h_{k+1} = a(z) * h_k + (1 - a(z)) * B z # selective SSM memory
13
+ a(z) = exp(-softplus(W_d z) * lambda) # input-dependent step (Mamba-style)
14
+
15
+ Training loss = state error + physics residuals (kinematic consistency and a
16
+ penalty on unexplained corrections) - i.e. the network is told that the
17
+ physics is mostly right and must justify any deviation with evidence.
18
+ """
19
+ import torch
20
+ import torch.nn as nn
21
+ import torch.nn.functional as F
22
+
23
+ SCALE = torch.tensor([0.3, 1.0, 0.2, 2.0]) # typical magnitude of x, xd, th, thd
24
+
25
+
26
+ class TorchCartPole(nn.Module):
27
+ def __init__(self, M=1.0, m=0.1, l=0.25, g=9.81, b_cart=0.1, b_pole=0.002):
28
+ super().__init__()
29
+ self.M, self.m, self.l, self.g = M, m, l, g
30
+ self.b_cart, self.b_pole = b_cart, b_pole
31
+
32
+ def deriv(self, s, u):
33
+ x, xd, th, thd = s.unbind(-1)
34
+ sin, cos = torch.sin(th), torch.cos(th)
35
+ total = self.M + self.m
36
+ temp = (u + self.m * self.l * thd ** 2 * sin - self.b_cart * xd) / total
37
+ thdd = (self.g * sin - cos * temp - self.b_pole * thd / (self.m * self.l)) / (
38
+ self.l * (4.0 / 3.0 - self.m * cos ** 2 / total))
39
+ xdd = temp - self.m * self.l * thdd * cos / total
40
+ return torch.stack([xd, xdd, thd, thdd], -1)
41
+
42
+ def step(self, s, u, dt):
43
+ k1 = self.deriv(s, u)
44
+ k2 = self.deriv(s + 0.5 * dt * k1, u)
45
+ k3 = self.deriv(s + 0.5 * dt * k2, u)
46
+ k4 = self.deriv(s + dt * k3, u)
47
+ return s + dt / 6.0 * (k1 + 2 * k2 + 2 * k3 + k4)
48
+
49
+
50
+ class SpatialSoftmaxKeypoints(nn.Module):
51
+ """Full-resolution keypoint layer (sub-pixel precise).
52
+
53
+ Each of K learned heatmaps is turned into a probability map; its mean gives
54
+ a sub-pixel (x, y) location and its covariance gives the orientation and
55
+ spread of the blob - e.g. the angle of the pole. This preserves the fine
56
+ geometry that strided pooling throws away (1 px at the pole tip ~ 3 deg).
57
+ """
58
+
59
+ def __init__(self, cin, H, W, K=12):
60
+ super().__init__()
61
+ self.c1 = nn.Conv2d(cin, 16, 3, 1, 1)
62
+ self.c2 = nn.Conv2d(16, K, 3, 1, 1)
63
+ self.log_temp = nn.Parameter(torch.zeros(K))
64
+ yy, xx = torch.meshgrid(torch.linspace(-1, 1, H), torch.linspace(-1, 1, W) * (W / H), indexing="ij")
65
+ self.register_buffer("gx", xx.flatten())
66
+ self.register_buffer("gy", yy.flatten())
67
+ self.out_dim = 5 * K
68
+
69
+ def forward(self, x):
70
+ logits = self.c2(F.gelu(self.c1(x))).flatten(2) * torch.exp(self.log_temp)[None, :, None]
71
+ p = torch.softmax(logits, -1)
72
+ mx, my = (p * self.gx).sum(-1), (p * self.gy).sum(-1)
73
+ vxx = (p * self.gx ** 2).sum(-1) - mx ** 2
74
+ vyy = (p * self.gy ** 2).sum(-1) - my ** 2
75
+ vxy = (p * self.gx * self.gy).sum(-1) - mx * my
76
+ return torch.cat([mx, my, 10 * vxx, 10 * vyy, 10 * vxy], -1)
77
+
78
+
79
+ class EventEncoder(nn.Module):
80
+ def __init__(self, H=32, W=96, d=128, cin=4):
81
+ super().__init__()
82
+ yy, xx = torch.meshgrid(torch.linspace(-1, 1, H), torch.linspace(-1, 1, W), indexing="ij")
83
+ self.register_buffer("coords", torch.stack([xx, yy])[None])
84
+ self.c1 = nn.Conv2d(cin + 2, 16, 5, 2, 2)
85
+ self.c2 = nn.Conv2d(16, 32, 3, 2, 1)
86
+ self.c3 = nn.Conv2d(32, 32, 3, 2, 1)
87
+ self.kp = SpatialSoftmaxKeypoints(cin, H, W)
88
+ self.fc = nn.Linear(32 * (H // 8) * (W // 8) + self.kp.out_dim, d)
89
+
90
+ def forward(self, ts): # ts: (N, 4, H, W): ON, OFF, slow-any, APS frame
91
+ x = torch.cat([ts, self.coords.expand(ts.shape[0], -1, -1, -1)], 1)
92
+ x = F.gelu(self.c1(x)); x = F.gelu(self.c2(x)); x = F.gelu(self.c3(x))
93
+ return F.gelu(self.fc(torch.cat([x.flatten(1), self.kp(ts)], 1)))
94
+
95
+
96
+ class EventObserver(nn.Module):
97
+ def __init__(self, dt=1e-3, d=128, n_state=64):
98
+ super().__init__()
99
+ self.dt = dt
100
+ self.physics = TorchCartPole()
101
+ self.enc = EventEncoder(d=d)
102
+ self.B = nn.Linear(d, n_state)
103
+ self.W_delta = nn.Linear(d, n_state)
104
+ self.log_lambda = nn.Parameter(torch.zeros(n_state))
105
+ self.head = nn.Sequential(nn.Linear(d + n_state + 4, 128), nn.GELU(),
106
+ nn.Linear(128, 64), nn.GELU(), nn.Linear(64, 4))
107
+ nn.init.zeros_(self.head[-1].weight); nn.init.zeros_(self.head[-1].bias)
108
+ self.register_buffer("scale", SCALE.clone())
109
+ self.n_state = n_state
110
+
111
+ def init_hidden(self, batch):
112
+ return torch.zeros(batch, self.n_state)
113
+
114
+ def cell(self, z, h, s, u):
115
+ """One 1 ms observer tick. z: event features at t_{k+1}; s, u at t_k."""
116
+ a = torch.exp(-F.softplus(self.W_delta(z)) * torch.exp(self.log_lambda))
117
+ h = a * h + (1 - a) * self.B(z)
118
+ s_pred = self.physics.step(s, u, self.dt)
119
+ corr = self.head(torch.cat([z, h, s / self.scale], -1))
120
+ # correction expressed in units of "typical magnitude per 50 ms"
121
+ s_new = s_pred + corr * self.scale * (self.dt / 0.05)
122
+ return s_new, h, corr
123
+
124
+ def forward(self, ts_seq, u_seq, s0):
125
+ """ts_seq: (B, T, 4, H, W) surfaces at t_1..t_T; u_seq: (B, T); s0: (B, 4)."""
126
+ Bsz, T = ts_seq.shape[:2]
127
+ z_all = self.enc(ts_seq.flatten(0, 1)).view(Bsz, T, -1)
128
+ h = self.init_hidden(Bsz)
129
+ s = s0
130
+ out, corrs = [], []
131
+ for k in range(T):
132
+ s, h, c = self.cell(z_all[:, k], h, s, u_seq[:, k])
133
+ out.append(s); corrs.append(c)
134
+ return torch.stack(out, 1), torch.stack(corrs, 1)
135
+
136
+
137
+ def physics_informed_loss(s_hat, s_true, corr, s0, dt, w_kin=0.05, w_corr=1e-3):
138
+ scale = SCALE.to(s_hat)
139
+ # tilt and tilt-rate errors matter most to the controller: weight them up
140
+ w = torch.tensor([1.0, 1.0, 4.0, 2.0]).to(s_hat)
141
+ data = ((((s_hat - s_true) / scale) ** 2) * w).mean() / w.mean()
142
+ # kinematic residual: position change must match estimated velocity
143
+ s_prev = torch.cat([s0[:, None], s_hat[:, :-1]], 1)
144
+ dpos = (s_hat[..., [0, 2]] - s_prev[..., [0, 2]]) / dt
145
+ vel = 0.5 * (s_hat[..., [1, 3]] + s_prev[..., [1, 3]])
146
+ kin = (((dpos - vel) / scale[[1, 3]]) ** 2).mean()
147
+ # parsimony: corrections must be justified by evidence
148
+ reg = (corr ** 2).mean()
149
+ return data + w_kin * kin + w_corr * reg, dict(data=data.item(), kin=kin.item(), reg=reg.item())
150
+
151
+
152
+ # =============================================================================
153
+ # v3: confidence-aware, gap-aware observer (Upgrades A + C)
154
+ # =============================================================================
155
+ class ProbEventObserver(EventObserver):
156
+ """Event observer that also says how much to trust itself.
157
+
158
+ Analogy: a navigator who, besides plotting the ship's position, keeps an
159
+ 'error circle' around it. In fog (event silence) or a storm (event burst,
160
+ occlusion) the circle grows; after a clear fix it shrinks. The controller
161
+ and safety shield read the circle, not just the dot.
162
+
163
+ Changes vs EventObserver:
164
+ * variance head - predicts log-variance of the state error after each
165
+ correction (heteroscedastic Gaussian, trained by NLL on
166
+ detached residuals so the mean is trained exactly as before)
167
+ * activity input - mean ON / OFF / slow time-surface activity, so the model
168
+ can tell silence and bursts apart explicitly
169
+ * gap-aware - a correction can be applied after `gap` ticks of
170
+ physics-only prediction (event-triggered scheduling);
171
+ trained with random tick skipping
172
+ * use_aps flag - event-only ablation zeroes the APS frame channel
173
+ """
174
+
175
+ def __init__(self, dt=1e-3, d=128, n_state=64, use_aps=True):
176
+ super().__init__(dt, d, n_state)
177
+ self.head = nn.Sequential(nn.Linear(d + n_state + 4 + 1, 128), nn.GELU(),
178
+ nn.Linear(128, 64), nn.GELU(), nn.Linear(64, 4))
179
+ nn.init.zeros_(self.head[-1].weight); nn.init.zeros_(self.head[-1].bias)
180
+ self.var_head = nn.Sequential(nn.Linear(d + n_state + 4 + 1 + 3, 64), nn.GELU(),
181
+ nn.Linear(64, 4))
182
+ nn.init.zeros_(self.var_head[-1].weight)
183
+ nn.init.constant_(self.var_head[-1].bias, -3.0)
184
+ self.register_buffer("use_aps", torch.tensor(bool(use_aps)))
185
+
186
+ def encode(self, ts):
187
+ """ts (N, 4, H, W) -> (features z, activity a)."""
188
+ if not bool(self.use_aps):
189
+ ts = torch.cat([ts[:, :3], torch.zeros_like(ts[:, 3:])], 1)
190
+ return self.enc(ts), ts[:, :3].mean((2, 3)) * 10.0
191
+
192
+ def physics_step(self, s, u):
193
+ return self.physics.step(s, u, self.dt)
194
+
195
+ def correct(self, z, act, h, s_pred, gap):
196
+ """Apply one neural correction to the physics-predicted state s_pred.
197
+ gap: (N,) ticks since the previous correction (1 = dense operation)."""
198
+ a = torch.exp(-F.softplus(self.W_delta(z)) * torch.exp(self.log_lambda))
199
+ h = a * h + (1 - a) * self.B(z)
200
+ g = torch.log1p(gap.float())[:, None]
201
+ feat = torch.cat([z, h, s_pred / self.scale, g], -1)
202
+ corr = self.head(feat)
203
+ s_new = s_pred + corr * self.scale * (self.dt * gap.float()[:, None] / 0.05)
204
+ logvar = self.var_head(torch.cat([feat, act], -1)).clamp(-12, 4)
205
+ return s_new, h, corr, logvar
206
+
207
+ def forward(self, ts_seq, u_seq, s0, skip_p=None, gen=None):
208
+ """Training/eval rollout. skip_p: (B,) probability of skipping the neural
209
+ correction at a tick (physics-only). Returns states, corrections,
210
+ log-variances and the correction mask."""
211
+ Bsz, T = ts_seq.shape[:2]
212
+ if skip_p is None:
213
+ mask = torch.ones(Bsz, T, dtype=torch.bool)
214
+ else:
215
+ mask = torch.rand(Bsz, T, generator=gen) >= skip_p[:, None]
216
+ mask[:, -1] = True
217
+ # encode only the ticks that receive a correction (identical result, less compute)
218
+ flat = mask.flatten()
219
+ sel = ts_seq.flatten(0, 1)[flat]
220
+ if torch.is_grad_enabled() and sel.shape[0] > 256:
221
+ # gradient checkpointing in chunks: activations are recomputed in the backward
222
+ # pass instead of stored (same maths; keeps long windows inside 8 GB RAM)
223
+ from torch.utils.checkpoint import checkpoint
224
+ parts = [checkpoint(self.encode, c, use_reentrant=False) for c in sel.split(256)]
225
+ z_sel = torch.cat([p[0] for p in parts]); act_sel = torch.cat([p[1] for p in parts])
226
+ else:
227
+ z_sel, act_sel = self.encode(sel)
228
+ z_all = z_sel.new_zeros(Bsz * T, z_sel.shape[-1]).index_put((flat.nonzero()[:, 0],), z_sel)
229
+ act_all = act_sel.new_zeros(Bsz * T, act_sel.shape[-1]).index_put((flat.nonzero()[:, 0],), act_sel)
230
+ z_all = z_all.view(Bsz, T, -1); act_all = act_all.view(Bsz, T, -1)
231
+ h = self.init_hidden(Bsz)
232
+ s = s0
233
+ gap = torch.zeros(Bsz)
234
+ out, corrs, lvs = [], [], []
235
+ for k in range(T):
236
+ s = self.physics_step(s, u_seq[:, k])
237
+ gap = gap + 1
238
+ s_c, h_c, c, lv = self.correct(z_all[:, k], act_all[:, k], h, s, gap)
239
+ m = mask[:, k][:, None]
240
+ s = torch.where(m, s_c, s)
241
+ h = torch.where(m, h_c, h)
242
+ gap = torch.where(mask[:, k], torch.zeros_like(gap), gap)
243
+ out.append(s); corrs.append(c * m); lvs.append(lv)
244
+ return torch.stack(out, 1), torch.stack(corrs, 1), torch.stack(lvs, 1), mask
245
+
246
+
247
+ def gaussian_nll(s_hat, s_true, logvar, mask):
248
+ """Heteroscedastic Gaussian NLL in SCALE units on detached residuals (mean untouched)."""
249
+ e = ((s_hat.detach() - s_true) / SCALE.to(s_hat)) ** 2
250
+ nll = 0.5 * (e * torch.exp(-logvar) + logvar)
251
+ return (nll * mask[..., None]).sum() / (mask.sum() * 4).clamp(min=1)
@@ -0,0 +1,196 @@
1
+ """Event-camera simulator (ESIM-style) and the scene renderer.
2
+
3
+ A real event camera pixel does not take pictures. Each pixel watches its own
4
+ log-brightness and fires an event (x, y, t, polarity) every time that value has
5
+ moved by a contrast threshold C since the pixel's last event. Static scenes
6
+ produce almost nothing; moving edges produce microsecond-timestamped spikes.
7
+
8
+ We reproduce that from a finely-sampled rendered scene:
9
+ * per-pixel log-intensity reference, per-pixel threshold mismatch
10
+ * multiple crossings per step, with linearly interpolated timestamps
11
+ * background-activity noise events
12
+ """
13
+ import numpy as np
14
+
15
+
16
+ PIVOT_REF = 0.04 # scale errors are taken about the pivot height
17
+
18
+
19
+ class SceneRenderer:
20
+ """Renders the cart-pole as a grayscale image (antialiased, H x W)."""
21
+
22
+ def __init__(self, H=32, W=96, px_per_m=40.0, y_min=-0.12, bg=0.15, fg=0.9, cond=None):
23
+ self.H, self.W, self.S = H, W, px_per_m
24
+ jj, ii = np.meshgrid(np.arange(W), np.arange(H))
25
+ self.wx = (jj + 0.5 - W / 2) / px_per_m
26
+ self.wy = y_min + (H - ii - 0.5) / px_per_m
27
+ self.bg, self.fg = bg, fg
28
+ self.cond = None if (cond is None or cond.is_nominal) else cond
29
+ if self.cond is not None:
30
+ c = self.cond
31
+ if c.x_offset_m or c.scale != 1.0: # miscalibrated camera: pixel -> world mapping is off
32
+ self.wx = self.wx * c.scale + c.x_offset_m
33
+ self.wy = PIVOT_REF + (self.wy - PIVOT_REF) * c.scale
34
+ self.bgmap = np.where(self.wy > c.hdr_y, bg * c.hdr_gain, bg)
35
+ x0, x1, y0, y1 = c.occ_box
36
+ d = np.maximum(np.maximum(x0 - self.wx, self.wx - x1), np.maximum(y0 - self.wy, self.wy - y1))
37
+ self.occ_cov = np.clip(0.5 - d * px_per_m, 0, 1)
38
+
39
+ def render(self, s, l, t=None):
40
+ """Scene radiance. With conditions: HDR window, occluder (needs t), illumination scale."""
41
+ if self.cond is None:
42
+ return self._render_nominal(s, l)
43
+ c = self.cond
44
+ cov = self._coverage(s, l)
45
+ img = self.bgmap * (1 - cov) + self.fg * cov
46
+ if c.occlusion is not None and t is not None:
47
+ wins = c.occlusion if isinstance(c.occlusion[0], (tuple, list)) else (c.occlusion,)
48
+ if any(a <= t < b for a, b in wins):
49
+ img = img * (1 - self.occ_cov) + c.occ_value * self.occ_cov
50
+ return img * c.brightness
51
+
52
+ def _render_nominal(self, s, l):
53
+ return self.bg + (self.fg - self.bg) * self._coverage(s, l)
54
+
55
+ def _coverage(self, s, l):
56
+ x, th = s[0], s[2]
57
+ S, wx, wy = self.S, self.wx, self.wy
58
+ # cart: 0.2 m x 0.08 m box (signed-distance antialiasing)
59
+ dx = np.abs(wx - x) - 0.10
60
+ dy = np.abs(wy - 0.0) - 0.04
61
+ d_box = np.maximum(dx, dy)
62
+ cov_cart = np.clip(0.5 - d_box * S, 0, 1)
63
+ # pole: segment from pivot to tip, ~1 px thick
64
+ px, py = x, 0.04
65
+ tx, ty = x + 2 * l * np.sin(th), py + 2 * l * np.cos(th)
66
+ vx, vy = tx - px, ty - py
67
+ t = np.clip(((wx - px) * vx + (wy - py) * vy) / (vx * vx + vy * vy), 0, 1)
68
+ d_seg = np.hypot(wx - (px + t * vx), wy - (py + t * vy))
69
+ cov_pole = np.clip(0.5 - (d_seg - 0.013) * S, 0, 1)
70
+ return np.maximum(cov_cart, cov_pole)
71
+
72
+
73
+ class EventCamera:
74
+ """Converts a stream of rendered frames into asynchronous events.
75
+
76
+ Rather than materialising every event object (memory-hungry at MHz rates)
77
+ the simulator reports, per pixel and polarity, the timestamp of the latest
78
+ crossing plus counts. `record=True` additionally keeps a raw (x, y, t, p)
79
+ list for visualisation.
80
+ """
81
+
82
+ def __init__(self, H, W, C=0.18, C_sigma=0.03, noise_rate_hz=0.3, eps=0.02,
83
+ seed=0, record=False, drop_prob=0.0, tau=0.0):
84
+ rng = np.random.default_rng(seed)
85
+ self.rng = rng
86
+ self.Cp = np.clip(rng.normal(C, C_sigma, (H, W)), 0.05, None)
87
+ self.Cn = np.clip(rng.normal(C, C_sigma, (H, W)), 0.05, None)
88
+ self.noise_rate = noise_rate_hz
89
+ self.eps = eps
90
+ self.drop_prob = drop_prob # events lost in readout
91
+ self.tau = tau # photoreceptor time constant (0 = instantaneous)
92
+ self.H, self.W = H, W
93
+ self.L_ref = None
94
+ self.L_prev = None
95
+ self.record = record
96
+ self.events = []
97
+ self.n_events = 0
98
+
99
+ def reset(self, img):
100
+ self.L_ref = np.log(img + self.eps)
101
+ self.L_prev = self.L_ref.copy()
102
+ self.L_filt = self.L_ref.copy()
103
+
104
+ def step(self, img, t0, dt):
105
+ """Process scene change between t0 and t0+dt.
106
+
107
+ Returns (last_t_pos, last_t_neg) arrays with the crossing timestamps in
108
+ this interval (NaN where the pixel was silent) and the event count.
109
+ """
110
+ L = np.log(img + self.eps)
111
+ if self.tau > dt: # slow photoreceptor (dim light): first-order low-pass on log intensity
112
+ self.L_filt = self.L_filt + (L - self.L_filt) * (dt / self.tau)
113
+ L = self.L_filt
114
+ dL = L - self.L_prev
115
+ diff = L - self.L_ref
116
+ out_p = np.full((self.H, self.W), np.nan)
117
+ out_n = np.full((self.H, self.W), np.nan)
118
+ count = 0
119
+ safe = np.where(np.abs(dL) > 1e-9, dL, 1e-9)
120
+
121
+ n_pos = np.floor(np.maximum(diff, 0) / self.Cp)
122
+ mp = n_pos >= 1
123
+ if mp.any():
124
+ level = self.L_ref[mp] + n_pos[mp] * self.Cp[mp]
125
+ frac = np.clip((level - self.L_prev[mp]) / safe[mp], 0, 1)
126
+ out_p[mp] = t0 + frac * dt
127
+ self.L_ref[mp] = level
128
+ count += int(n_pos[mp].sum())
129
+ n_neg = np.floor(np.maximum(-diff, 0) / self.Cn)
130
+ mn = n_neg >= 1
131
+ if mn.any():
132
+ level = self.L_ref[mn] - n_neg[mn] * self.Cn[mn]
133
+ frac = np.clip((level - self.L_prev[mn]) / safe[mn], 0, 1)
134
+ out_n[mn] = t0 + frac * dt
135
+ self.L_ref[mn] = level
136
+ count += int(n_neg[mn].sum())
137
+
138
+ if self.drop_prob > 0: # readout losses: the pixel resets but the event never arrives
139
+ for arr in (out_p, out_n):
140
+ fired = ~np.isnan(arr)
141
+ lost = fired & (self.rng.random(arr.shape) < self.drop_prob)
142
+ count -= int(lost.sum()); arr[lost] = np.nan
143
+
144
+ # background-activity noise (leak / shot noise)
145
+ p_noise = self.noise_rate * dt
146
+ noise = self.rng.random((self.H, self.W)) < p_noise
147
+ if noise.any():
148
+ pol = self.rng.random(noise.sum()) < 0.5
149
+ ts = t0 + self.rng.random(noise.sum()) * dt
150
+ tgt_p = out_p[noise]; tgt_n = out_n[noise]
151
+ tgt_p[pol] = np.fmax(tgt_p[pol], ts[pol])
152
+ tgt_n[~pol] = np.fmax(tgt_n[~pol], ts[~pol])
153
+ out_p[noise] = tgt_p; out_n[noise] = tgt_n
154
+ count += int(noise.sum())
155
+
156
+ if self.record:
157
+ for pol, arr in ((1, out_p), (-1, out_n)):
158
+ ys, xs = np.nonzero(~np.isnan(arr))
159
+ for y, x in zip(ys, xs):
160
+ self.events.append((x, y, arr[y, x], pol))
161
+ self.L_prev = L
162
+ self.n_events += count
163
+ return out_p, out_n, count
164
+
165
+
166
+ class TimeSurfaceEncoder:
167
+ """Event-to-tensor compression: fixed-size, exponentially-decaying time surface.
168
+
169
+ Every pixel/polarity keeps only the timestamp of its latest event. When the
170
+ controller asks for a state at time t, the surface is
171
+ T[c, y, x] = exp(-(t - t_last[c, y, x]) / tau_c)
172
+ Memory is constant (H x W x channels) no matter how many events arrive, so
173
+ there is no buffer bloat and no dropped events at high event rates; the
174
+ newest events dominate and old ones fade continuously.
175
+
176
+ Channels: [pos fast, neg fast, any slow]
177
+ """
178
+
179
+ def __init__(self, H, W, tau_fast=0.008, tau_slow=0.040):
180
+ self.tau_fast, self.tau_slow = tau_fast, tau_slow
181
+ self.t_pos = np.full((H, W), -1e9)
182
+ self.t_neg = np.full((H, W), -1e9)
183
+
184
+ def update(self, last_p, last_n):
185
+ self.t_pos = np.fmax(self.t_pos, last_p)
186
+ self.t_neg = np.fmax(self.t_neg, last_n)
187
+
188
+ def tensor(self, t):
189
+ tp = np.exp(-(t - self.t_pos) / self.tau_fast)
190
+ tn = np.exp(-(t - self.t_neg) / self.tau_fast)
191
+ ta = np.exp(-(t - np.maximum(self.t_pos, self.t_neg)) / self.tau_slow)
192
+ return np.stack([tp, tn, ta]).astype(np.float32)
193
+
194
+ @property
195
+ def nbytes(self):
196
+ return self.t_pos.nbytes + self.t_neg.nbytes