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 +0 -0
- aurasync/conditions.py +84 -0
- aurasync/controller.py +36 -0
- aurasync/estimator.py +251 -0
- aurasync/event_camera.py +196 -0
- aurasync/frame_camera.py +163 -0
- aurasync/imu.py +98 -0
- aurasync/monitor.py +80 -0
- aurasync/physics.py +62 -0
- aurasync/safety.py +173 -0
- aurasync/scheduler.py +191 -0
- aurasync/simulate.py +168 -0
- aurasync/v4/__init__.py +0 -0
- aurasync/v4/geo_pipeline.py +61 -0
- aurasync/v4/geometry.py +97 -0
- aurasync/v4/pipeline.py +274 -0
- aurasync/v41/__init__.py +0 -0
- aurasync/v41/pipeline.py +269 -0
- aurasync_engine-0.4.1.dist-info/METADATA +132 -0
- aurasync_engine-0.4.1.dist-info/RECORD +23 -0
- aurasync_engine-0.4.1.dist-info/WHEEL +5 -0
- aurasync_engine-0.4.1.dist-info/licenses/LICENSE +21 -0
- aurasync_engine-0.4.1.dist-info/top_level.txt +1 -0
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)
|
aurasync/event_camera.py
ADDED
|
@@ -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
|