Coverage for src/evutils/io/_hdf5.py: 92%

178 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-15 09:33 +0000

1"""HDF5 file decoder and encoder. 

2 

3Layout (DSEC-compatible): the four event columns are stored under 

4``events/{t,x,y,p}``, with ``width`` / ``height`` file attributes and a 

5top-level ``ms_to_idx`` index: ``ms_to_idx[ms]`` is the index of the first 

6event with ``t >= ms * 1000`` (µs), which makes millisecond-range reads O(1) 

7lookups. A ``t_offset`` attribute (DSEC) is honoured on read when present. 

8""" 

9from __future__ import annotations 

10 

11import io 

12from datetime import datetime 

13from typing import Any 

14 

15import h5py 

16import hdf5plugin 

17import numpy as np 

18 

19from .._jit import lazy_njit 

20from ..types import EventArray, TriggerArray 

21from .common import EventDecoder, EventEncoder 

22from ._source import ByteSource 

23 

24_EMPTY_EVENTS = EventArray.empty() 

25 

26 

27@lazy_njit 

28def _fill_ms_to_idx(t: np.ndarray, ms_to_idx: np.ndarray, start_ms: int, 

29 end_ms: int, base_idx: int, base_ms: int) -> None: 

30 """Fill ``ms_to_idx[start_ms:end_ms+1]`` from the chunk timestamps ``t``. 

31 

32 ``ms_to_idx[ms]`` is the global index of the first event with 

33 ``t >= (ms + base_ms) * 1000``; ``base_idx`` is the global index of 

34 ``t[0]``. ``base_ms`` anchors the index at the recording's first 

35 millisecond so absolute (e.g. epoch) timestamps do not blow it up. 

36 

37 Parameters 

38 ---------- 

39 t : np.ndarray 

40 Timestamps (µs) of the chunk, monotonically non-decreasing. 

41 ms_to_idx : np.ndarray 

42 The full index array to fill. 

43 start_ms : int 

44 First (relative) millisecond entry to fill. 

45 end_ms : int 

46 Last (relative) millisecond entry to fill (inclusive). 

47 base_idx : int 

48 Global event index of the first element of ``t``. 

49 base_ms : int 

50 Millisecond of the first event in the recording (index anchor). 

51 

52 """ 

53 idx = 0 

54 for ms in range(start_ms, end_ms + 1): 

55 while idx < len(t) and t[idx] < (ms + base_ms) * 1000: 

56 idx += 1 

57 ms_to_idx[ms] = base_idx + idx 

58 

59 

60class EventDecoder_HDF5(EventDecoder): 

61 """Decode events from an HDF5 file. 

62 

63 Two on-disk layouts are detected automatically: 

64 

65 * **DSEC / RVT layout** (what :class:`EventEncoder_HDF5` writes): the four 

66 columns under ``events/{t,x,y,p}``, optional ``ms_to_idx`` index and 

67 DSEC ``t_offset``. 

68 * **Prophesee layout** (Metavision ``.hdf5``): a compound ``CD/events`` 

69 dataset with ``x``/``y``/``p``/``t`` fields. Prophesee files are usually 

70 compressed with the ECF codec, a separate HDF5 plugin 

71 (https://github.com/prophesee-ai/hdf5_ecf) -- a clear error points there 

72 when it is missing. A compound ``events`` dataset at the root is read 

73 the same way. 

74 

75 Supports both the streaming :meth:`read_chunk` interface used by 

76 :class:`~evutils.io.EventReader` and random-access millisecond-range reads 

77 via :meth:`read` (backed by the ``ms_to_idx`` index when present). 

78 

79 Parameters 

80 ---------- 

81 source 

82 Byte source to read from (must be seekable). 

83 chunk_size 

84 Number of events returned per :meth:`read_chunk` call. 

85 

86 """ 

87 

88 def __init__(self, source: ByteSource, chunk_size: int = 1_000_000): 

89 super().__init__(source, chunk_size) 

90 self._h5: h5py.File | None = None 

91 self._aos: h5py.Dataset | None = None # compound dataset (Prophesee layout) 

92 self._ms_to_idx: np.ndarray | None = None 

93 self._ms_idx_offset: int = 0 # ms of index entry 0 (absolute-t recordings) 

94 self._t_offset: int = 0 

95 self._n: int = 0 

96 self._pos = 0 

97 

98 def init(self) -> None: 

99 """Open the HDF5 file and locate the event datasets.""" 

100 if self._is_initialized: 

101 return 

102 

103 self._h5 = h5py.File(self._source, "r") 

104 node = None 

105 if "events" in self._h5: 

106 node = self._h5["events"] 

107 elif "CD" in self._h5 and "events" in self._h5["CD"]: 

108 node = self._h5["CD"]["events"] # Prophesee Metavision layout 

109 if node is None: 

110 raise ValueError( 

111 "HDF5 file contains neither an 'events' group/dataset nor " 

112 "'CD/events' (Prophesee layout)" 

113 ) 

114 

115 if isinstance(node, h5py.Dataset): 

116 if node.dtype.names is None or not {"t", "x", "y", "p"} <= set(node.dtype.names): 

117 raise ValueError( 

118 "HDF5 events dataset must be a compound type with t/x/y/p fields" 

119 ) 

120 self._aos = node 

121 self._n = node.shape[0] 

122 else: 

123 self._n = node["t"].shape[0] 

124 

125 if "ms_to_idx" in self._h5: 

126 self._ms_to_idx = np.asarray(self._h5["ms_to_idx"], dtype=np.int64) 

127 if "ms_to_idx_offset" in self._h5: 

128 self._ms_idx_offset = int(np.asarray(self._h5["ms_to_idx_offset"]).item()) 

129 if "t_offset" in self._h5: 

130 self._t_offset = int(np.asarray(self._h5["t_offset"]).item()) 

131 if "width" in self._h5.attrs: 

132 self._width = int(self._h5.attrs["width"]) 

133 if "height" in self._h5.attrs: 

134 self._height = int(self._h5.attrs["height"]) 

135 

136 self._pos = 0 

137 self._is_initialized = True 

138 

139 def _slice(self, start: int, end: int) -> EventArray: 

140 """Materialise ``events[start:end]`` as an :class:`EventArray`.""" 

141 assert self._h5 is not None 

142 try: 

143 if self._aos is not None: 

144 rec = self._aos[start:end] 

145 t = rec["t"].astype(np.int64) 

146 if self._t_offset: 

147 t += self._t_offset 

148 return EventArray(t, rec["x"], rec["y"], 

149 np.clip(rec["p"], 0, 1).astype(np.uint8)) 

150 ev = self._h5["events"] 

151 t = ev["t"][start:end].astype(np.int64) 

152 if self._t_offset: 

153 t += self._t_offset 

154 return EventArray(t, ev["x"][start:end], ev["y"][start:end], ev["p"][start:end]) 

155 except OSError as exc: 

156 # h5py raises OSError when a dataset's filter/codec is not loaded. 

157 raise OSError( 

158 f"Failed to read the HDF5 events dataset ({exc}). If this is a " 

159 "Prophesee Metavision file, its events are compressed with the " 

160 "ECF codec: install the plugin from " 

161 "https://github.com/prophesee-ai/hdf5_ecf (or put it on " 

162 "HDF5_PLUGIN_PATH) and retry." 

163 ) from exc 

164 

165 def read_chunk(self, delta_t_hint: int | None = None, 

166 n_events_hint: int | None = None) -> EventArray: 

167 if not self._is_initialized: 

168 self.init() 

169 

170 if self._pos >= self._n: 

171 self._eof = True 

172 return _EMPTY_EVENTS 

173 

174 chunk = self._slice(self._pos, min(self._pos + self._chunk_size, self._n)) 

175 self._pos += len(chunk) 

176 if self._pos >= self._n: 

177 self._eof = True 

178 return chunk 

179 

180 def read_all(self) -> EventArray: 

181 """Return every remaining event at once.""" 

182 if not self._is_initialized: 

183 self.init() 

184 out = self._slice(self._pos, self._n) 

185 self._pos = self._n 

186 self._eof = True 

187 return out 

188 

189 def read(self, start_ms: int = 0, end_ms: int = -1) -> EventArray: 

190 """Random-access read of a millisecond time range via ``ms_to_idx``. 

191 

192 Parameters 

193 ---------- 

194 start_ms : int, optional 

195 Start time in milliseconds, by default 0. 

196 end_ms : int, optional 

197 End time in milliseconds (exclusive), by default -1 (until the end). 

198 

199 Returns 

200 ------- 

201 EventArray 

202 Events with ``start_ms * 1000 <= t < end_ms * 1000``. 

203 

204 """ 

205 if not self._is_initialized: 

206 self.init() 

207 if self._ms_to_idx is None: 

208 raise ValueError("HDF5 file has no 'ms_to_idx' index; use read_chunk/read_all") 

209 if start_ms < 0: 

210 raise ValueError("start_ms must be greater or equal to 0") 

211 if 0 <= end_ms < start_ms: 

212 raise ValueError("start_ms must be smaller than end_ms") 

213 

214 # The index may be anchored at the recording's first millisecond 

215 # (absolute-timestamp files); shift the requested range accordingly. 

216 last_ms = len(self._ms_to_idx) - 1 

217 rel_start = max(start_ms - self._ms_idx_offset, 0) 

218 if rel_start > last_ms: 

219 return _EMPTY_EVENTS 

220 rel_end = end_ms - self._ms_idx_offset if end_ms >= 0 else last_ms 

221 if rel_end < 0: 

222 return _EMPTY_EVENTS 

223 rel_end = min(rel_end, last_ms) 

224 

225 return self._slice(int(self._ms_to_idx[rel_start]), int(self._ms_to_idx[rel_end])) 

226 

227 def reset(self) -> None: 

228 """Reset the reader to the beginning of the file.""" 

229 self._pos = 0 

230 self._eof = False 

231 

232 def tell(self) -> int: 

233 """Current position, in events (HDF5 has no meaningful byte offset).""" 

234 return self._pos 

235 

236 def close(self) -> None: 

237 """Close the HDF5 handle (the byte source is closed by the reader).""" 

238 if self._h5 is not None: 

239 self._h5.close() 

240 self._h5 = None 

241 

242 

243class EventEncoder_HDF5(EventEncoder): 

244 """Encode events into an HDF5 file (``events/{t,x,y,p}`` + ``ms_to_idx``). 

245 

246 Events must be written in timestamp order (chunks are appended and the 

247 millisecond index is built incrementally). The index and final flush 

248 happen on :meth:`close`. 

249 

250 Parameters 

251 ---------- 

252 writable : io.BufferedIOBase 

253 The file-like object to write to (must be readable and seekable, 

254 as required by HDF5). 

255 width : int, optional 

256 The width of the frame. 

257 height : int, optional 

258 The height of the frame. 

259 dt : datetime, optional 

260 Unused; HDF5 stores no recording timestamp. 

261 chunksize : int, optional 

262 HDF5 dataset chunk size, default 10000. 

263 

264 """ 

265 

266 def __init__(self, writable: io.BufferedIOBase, width: int = 1280, height: int = 720, 

267 dt: datetime | None = None, chunksize: int = 10000): 

268 super().__init__(writable, width=width, height=height, dt=dt) 

269 

270 self._chunksize = chunksize 

271 self._h5: h5py.File | None = None 

272 self._ms_to_idx = np.zeros(0, dtype=np.int64) 

273 self._next_ms = 0 # first (relative) ms entry not yet filled 

274 self._ms_base = -1 # ms of the first written event (index anchor) 

275 self._closed = False 

276 

277 def init(self) -> None: 

278 """Create the HDF5 structure (groups, datasets, attributes).""" 

279 if self._is_initialized: 

280 return 

281 

282 self._h5 = h5py.File(self._fd, "w") 

283 self._compressor = hdf5plugin.Blosc(cname="zstd", clevel=5, shuffle=hdf5plugin.Blosc.SHUFFLE) 

284 

285 self._h5.attrs["width"] = self._width 

286 self._h5.attrs["height"] = self._height 

287 

288 group = self._h5.create_group("events") 

289 for name, dtype in (("t", "int64"), ("x", "uint16"), ("y", "uint16"), ("p", "uint8")): 

290 group.create_dataset(name, shape=(0,), chunks=(self._chunksize,), 

291 maxshape=(None,), dtype=dtype, **self._compressor) 

292 

293 self._is_initialized = True 

294 

295 def write(self, events: 'np.ndarray | EventArray', triggers: 'np.ndarray | TriggerArray | None' = None) -> int: 

296 """Append a chunk of events and extend the millisecond index. 

297 

298 Parameters 

299 ---------- 

300 events : np.ndarray or EventArray 

301 Array of events to write (timestamps must not go backwards 

302 between chunks). 

303 

304 Returns 

305 ------- 

306 int 

307 Number of events written. 

308 

309 """ 

310 if not self._is_initialized: 

311 self.init() 

312 

313 n = len(events) 

314 if n == 0: 

315 return 0 

316 assert self._h5 is not None 

317 

318 t = np.ascontiguousarray(events["t"], dtype=np.int64) 

319 

320 # Extend ms_to_idx up to the last full millisecond of this chunk. The 

321 # index is anchored at the first event's millisecond so absolute 

322 # (epoch-style) timestamps don't inflate it. 

323 if self._ms_base < 0: 

324 self._ms_base = int(t[0] // 1000) 

325 max_ms = int(t[-1] // 1000) - self._ms_base 

326 if max_ms + 1 > len(self._ms_to_idx): 

327 self._ms_to_idx = np.resize(self._ms_to_idx, max_ms + 1) 

328 _fill_ms_to_idx(t, self._ms_to_idx, self._next_ms, max_ms, 

329 self._n_written_events, self._ms_base) 

330 self._next_ms = max_ms + 1 

331 

332 group = self._h5["events"] 

333 total = self._n_written_events + n 

334 for name, col in (("t", t), ("x", events["x"]), ("y", events["y"]), ("p", events["p"])): 

335 ds = group[name] 

336 ds.resize((total,)) 

337 ds[-n:] = col 

338 

339 self._n_written_events += n 

340 return n 

341 

342 def flush(self) -> None: 

343 """Flush the HDF5 buffers to the underlying stream.""" 

344 if self._h5 is not None: 

345 self._h5.flush() 

346 

347 def close(self) -> None: 

348 """Write the ``ms_to_idx`` index and close the HDF5 handle.""" 

349 if self._closed: 

350 return 

351 self._closed = True 

352 if not self._is_initialized: 

353 self.init() # produce a valid (empty) file even with no writes 

354 assert self._h5 is not None 

355 

356 # Terminate the index: one entry past the last ms points at the end. 

357 idx = np.append(self._ms_to_idx, self._n_written_events) 

358 self._h5.create_dataset("ms_to_idx", data=idx.astype(np.uint64), **self._compressor) 

359 if self._ms_base > 0: 

360 # Anchor for absolute-timestamp recordings; the decoder shifts 

361 # requested millisecond ranges by this. 

362 self._h5.create_dataset("ms_to_idx_offset", data=np.int64(self._ms_base)) 

363 self._h5.close() 

364 self._h5 = None