photonsfile 2026.7.8__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Hunt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: photonsfile
3
+ Version: 2026.7.8
4
+ Summary: Read Photonscore LINCam .photons (D7) files
5
+ Author: Alex Hunt
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/alex1075/photonsfile
8
+ Project-URL: D7 format, https://github.com/photonscore/d7
9
+ Keywords: Photonscore,LINCam,D7,photons,TCSPC,FLIM
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: numpy
14
+ Provides-Extra: all
15
+ Requires-Dist: numba; extra == "all"
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest; extra == "test"
18
+ Requires-Dist: numba; extra == "test"
19
+ Dynamic: license-file
20
+
21
+ # photonsfile
22
+
23
+ Pure-Python reader for Photonscore LINCam `.photons` files (the **D7** container).
24
+ numpy only, with optional numba acceleration for the varint decode.
25
+
26
+ Photonscore LINCam is a position-sensitive single-photon detector: every photon
27
+ carries an `(x, y)` detector position, a TCSPC micro time (`dt`), and a macro time
28
+ in milliseconds (`ms`). This library decodes those per-photon streams and can bin
29
+ them into an intensity image, a TCSPC decay, or a `(Y, X, H)` FLIM cube.
30
+
31
+ ## The D7 format
32
+
33
+ A `.photons` file is a paged container: 16 KB pages, each carrying a 2-byte page
34
+ marker, holding a protobuf-style header, a per-dataset index, and an epilogue. Each
35
+ dataset (`/photons/x`, `/photons/y`, `/photons/dt`, `/photons/ms`, and on dual-TDC
36
+ detectors `/start/time` and `/stop/time`) is stored as a seed value followed by
37
+ zigzag-varint delta blocks. `dt` to time is calibrated from the file's
38
+ `/photons/TacChannel` attribute (picoseconds per raw unit).
39
+
40
+ The format is documented at [github.com/photonscore/d7](https://github.com/photonscore/d7)
41
+ (Apache-2.0). This reader was written from that specification and **validated
42
+ bit-exact against the Photonscore SDK**. No vendor source is redistributed.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install photonsfile # numpy only
48
+ pip install photonsfile[all] # + numba, for a faster varint decode
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ```python
54
+ from photonsfile import PhotonsFile, imread
55
+
56
+ with PhotonsFile('sample.photons') as f:
57
+ ph = f.photons() # {'x', 'y', 'dt', 'ms'} arrays, one per photon
58
+ img = f.image(pixels=512) # (Y, X) intensity image
59
+ decay = f.decay(bins=256) # summed TCSPC histogram
60
+ cube = f.flim_image(512, 256) # (Y, X, H) FLIM cube
61
+ dt_res = f.tcspc_resolution(256) # seconds per bin, from TacChannel
62
+ print(f.attributes) # D7 file attributes
63
+
64
+ img = imread('sample.photons') # shortcut for the intensity image
65
+ ```
66
+
67
+ Low-level access to the decoder is also exported: `read_header`,
68
+ `read_attributes`, `read_photons`, `dataset_names`, `has_dual_tdc`.
69
+
70
+ numba is optional; without it the varint decode uses a vectorised numpy fallback.
71
+ `photonsfile.have_numba()` reports which path is active.
72
+
73
+ ## Provenance
74
+
75
+ Reverse-engineered from the public D7 specification and checked bit-exact against
76
+ the Photonscore SDK on a 284-million-photon sample. Thanks to Photonscore for
77
+ open-sourcing the D7 format.
78
+
79
+ ## License
80
+
81
+ MIT - see `LICENSE`.
@@ -0,0 +1,61 @@
1
+ # photonsfile
2
+
3
+ Pure-Python reader for Photonscore LINCam `.photons` files (the **D7** container).
4
+ numpy only, with optional numba acceleration for the varint decode.
5
+
6
+ Photonscore LINCam is a position-sensitive single-photon detector: every photon
7
+ carries an `(x, y)` detector position, a TCSPC micro time (`dt`), and a macro time
8
+ in milliseconds (`ms`). This library decodes those per-photon streams and can bin
9
+ them into an intensity image, a TCSPC decay, or a `(Y, X, H)` FLIM cube.
10
+
11
+ ## The D7 format
12
+
13
+ A `.photons` file is a paged container: 16 KB pages, each carrying a 2-byte page
14
+ marker, holding a protobuf-style header, a per-dataset index, and an epilogue. Each
15
+ dataset (`/photons/x`, `/photons/y`, `/photons/dt`, `/photons/ms`, and on dual-TDC
16
+ detectors `/start/time` and `/stop/time`) is stored as a seed value followed by
17
+ zigzag-varint delta blocks. `dt` to time is calibrated from the file's
18
+ `/photons/TacChannel` attribute (picoseconds per raw unit).
19
+
20
+ The format is documented at [github.com/photonscore/d7](https://github.com/photonscore/d7)
21
+ (Apache-2.0). This reader was written from that specification and **validated
22
+ bit-exact against the Photonscore SDK**. No vendor source is redistributed.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install photonsfile # numpy only
28
+ pip install photonsfile[all] # + numba, for a faster varint decode
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```python
34
+ from photonsfile import PhotonsFile, imread
35
+
36
+ with PhotonsFile('sample.photons') as f:
37
+ ph = f.photons() # {'x', 'y', 'dt', 'ms'} arrays, one per photon
38
+ img = f.image(pixels=512) # (Y, X) intensity image
39
+ decay = f.decay(bins=256) # summed TCSPC histogram
40
+ cube = f.flim_image(512, 256) # (Y, X, H) FLIM cube
41
+ dt_res = f.tcspc_resolution(256) # seconds per bin, from TacChannel
42
+ print(f.attributes) # D7 file attributes
43
+
44
+ img = imread('sample.photons') # shortcut for the intensity image
45
+ ```
46
+
47
+ Low-level access to the decoder is also exported: `read_header`,
48
+ `read_attributes`, `read_photons`, `dataset_names`, `has_dual_tdc`.
49
+
50
+ numba is optional; without it the varint decode uses a vectorised numpy fallback.
51
+ `photonsfile.have_numba()` reports which path is active.
52
+
53
+ ## Provenance
54
+
55
+ Reverse-engineered from the public D7 specification and checked bit-exact against
56
+ the Photonscore SDK on a 284-million-photon sample. Thanks to Photonscore for
57
+ open-sourcing the D7 format.
58
+
59
+ ## License
60
+
61
+ MIT - see `LICENSE`.
@@ -0,0 +1,2 @@
1
+ from .photonsfile import *
2
+ from .photonsfile import __version__, __all__
@@ -0,0 +1,323 @@
1
+ import numpy as np
2
+
3
+ try:
4
+ from numba import njit as _njit
5
+ _HAVE_NUMBA = True
6
+ except Exception:
7
+ _HAVE_NUMBA = False
8
+
9
+ MAGIC = b'D7 Photons Data'
10
+ _DEFAULT_PAGE = 16384
11
+
12
+ _DTYPES = {
13
+ 0: np.int8, 1: np.int16, 2: np.int32, 3: np.int64,
14
+ 4: np.uint8, 5: np.uint16, 6: np.uint32, 7: np.uint64,
15
+ 8: np.single, 9: np.double,
16
+ }
17
+
18
+ def _read_varint(buf, i):
19
+ shift = 0
20
+ val = 0
21
+ while True:
22
+ c = buf[i]
23
+ i += 1
24
+ val |= (c & 0x7f) << shift
25
+ if not (c & 0x80):
26
+ return val, i
27
+ shift += 7
28
+
29
+ def _zigzag(v):
30
+ return (v >> 1) ^ -(v & 1)
31
+
32
+ def _strip_markers(raw, phys_start, page):
33
+ b = np.frombuffer(raw, dtype=np.uint8)
34
+ n = b.shape[0]
35
+ first = (-phys_start) % page
36
+ if first >= n:
37
+ return b
38
+ m0 = np.arange(first, n, page)
39
+ keep = np.ones(n, dtype=bool)
40
+ keep[m0] = False
41
+ m1 = m0[m0 + 1 < n] + 1
42
+ keep[m1] = False
43
+ return b[keep]
44
+
45
+ def _clean_pos(phys, region_start, page):
46
+ markers = (phys // page) - (region_start // page)
47
+ return (phys - region_start) - 2 * markers
48
+
49
+ def _parse_descriptor(payload):
50
+ name = None
51
+ code = None
52
+ k = 0
53
+ while k < len(payload):
54
+ tag = payload[k]
55
+ k += 1
56
+ field = tag >> 3
57
+ wt = tag & 7
58
+ if wt == 2:
59
+ ln, k = _read_varint(payload, k)
60
+ chunk = payload[k:k + ln]
61
+ k += ln
62
+ if field == 1:
63
+ name = chunk.decode('ascii', 'replace')
64
+ elif wt == 0:
65
+ val, k = _read_varint(payload, k)
66
+ if field == 2:
67
+ code = val
68
+ else:
69
+ break
70
+ return name, _DTYPES.get(code, None)
71
+
72
+ def read_header(path):
73
+ with open(path, 'rb') as fh:
74
+ head = fh.read(_DEFAULT_PAGE)
75
+ if MAGIC not in head[:64]:
76
+ raise ValueError(f'Not a D7 .photons file (magic {MAGIC!r} not found): {path}')
77
+ i = 2
78
+ tag, i = _read_varint(head, i)
79
+ hlen, i = _read_varint(head, i)
80
+ end = i + hlen
81
+ header = {'magic': MAGIC.decode(), 'version': None, 'index_step': None,
82
+ 'page_size': _DEFAULT_PAGE, 'datasets': []}
83
+ while i < end:
84
+ tag, i = _read_varint(head, i)
85
+ field = tag >> 3
86
+ wt = tag & 7
87
+ if wt == 2:
88
+ ln, i = _read_varint(head, i)
89
+ payload = head[i:i + ln]
90
+ i += ln
91
+ if field == 7:
92
+ name, dtype = _parse_descriptor(payload)
93
+ header['datasets'].append({'name': name, 'dtype': dtype})
94
+ elif wt == 0:
95
+ val, i = _read_varint(head, i)
96
+ if field == 2:
97
+ header['version'] = val
98
+ elif field == 6:
99
+ header['index_step'] = val
100
+ elif field == 8 and val > 0:
101
+ header['page_size'] = val
102
+ else:
103
+ break
104
+ header['header_end'] = end
105
+ return header
106
+
107
+ def dataset_names(path):
108
+ return [d['name'] for d in read_header(path)['datasets']]
109
+
110
+ def _read_epilogue(fh, filesize, page):
111
+ n = min(filesize, 8192)
112
+ fh.seek(filesize - n)
113
+ tail = _strip_markers(fh.read(n), filesize - n, page).tobytes()
114
+ e = tail.rfind(b'End of D7 Photons Data File')
115
+ if e < 0:
116
+ raise ValueError('D7 epilogue signature not found')
117
+ j = e - 3
118
+ while j - 1 >= 0 and (tail[j - 1] & 0x80):
119
+ j -= 1
120
+ giof, _ = _read_varint(tail, j)
121
+ return giof
122
+
123
+ def _parse_index(fh, index_off, filesize, page, n_datasets):
124
+ fh.seek(index_off)
125
+ idx = _strip_markers(fh.read(filesize - index_off), index_off, page).tobytes()
126
+ _tag, p = _read_varint(idx, 0)
127
+ ln, p = _read_varint(idx, p)
128
+ body = idx[p:p + ln]
129
+ offsets = {}
130
+ i = 0
131
+ n = len(body)
132
+ while i < n - 2:
133
+ if body[i] == 0x0a:
134
+ l = body[i + 1]
135
+ pay = body[i + 2:i + 2 + l]
136
+ if len(pay) >= 3 and pay[0] == 0x08 and pay[1] < n_datasets and pay[2] == 0x10:
137
+ did = pay[1]
138
+ off, _ = _read_varint(pay, 3)
139
+ offsets.setdefault(did, []).append(off)
140
+ i += 2 + l
141
+ continue
142
+ i += 1
143
+ for did in offsets:
144
+ offsets[did].sort()
145
+ return offsets
146
+
147
+ def read_attributes(path):
148
+ import os
149
+ header = read_header(path)
150
+ page = header['page_size']
151
+ filesize = os.path.getsize(path)
152
+ with open(path, 'rb') as fh:
153
+ index_off = _read_epilogue(fh, filesize, page)
154
+ fh.seek(index_off)
155
+ idx = _strip_markers(fh.read(filesize - index_off), index_off, page).tobytes()
156
+ _tag, p = _read_varint(idx, 0)
157
+ ln, p = _read_varint(idx, p)
158
+ body = idx[p:p + ln]
159
+ attrs = {}
160
+ i = 0
161
+ n = len(body)
162
+ while i < n:
163
+ tag, i = _read_varint(body, i)
164
+ f = tag >> 3
165
+ wt = tag & 7
166
+ if wt == 2:
167
+ l, i = _read_varint(body, i)
168
+ ent = body[i:i + l]
169
+ i += l
170
+ if f == 2:
171
+ key = val = None
172
+ k = 0
173
+ while k < len(ent):
174
+ etag, k = _read_varint(ent, k)
175
+ if etag & 7 != 2:
176
+ break
177
+ el, k = _read_varint(ent, k)
178
+ s = ent[k:k + el].decode('utf8', 'replace')
179
+ k += el
180
+ if etag >> 3 == 1:
181
+ key = s
182
+ elif etag >> 3 == 2:
183
+ val = s
184
+ if key:
185
+ attrs[key] = val
186
+ elif wt == 0:
187
+ _, i = _read_varint(body, i)
188
+ else:
189
+ break
190
+ return attrs
191
+
192
+ if _HAVE_NUMBA:
193
+ @_njit(cache=True)
194
+ def _decode_varints_zigzag_nb(b):
195
+ n = b.shape[0]
196
+ out = np.empty(n, dtype=np.int64)
197
+ m = 0
198
+ val = np.uint64(0)
199
+ shift = np.uint64(0)
200
+ for i in range(n):
201
+ c = b[i]
202
+ val |= np.uint64(c & 0x7f) << shift
203
+ if c & 0x80:
204
+ shift += np.uint64(7)
205
+ else:
206
+ out[m] = np.int64(val >> np.uint64(1)) ^ -np.int64(val & np.uint64(1))
207
+ m += 1
208
+ val = np.uint64(0)
209
+ shift = np.uint64(0)
210
+ return out[:m]
211
+
212
+ def _decode_varints_zigzag_np(b):
213
+ cont = (b & 0x80) != 0
214
+ term = ~cont
215
+ payload = (b & 0x7f).astype(np.uint64)
216
+ n = b.shape[0]
217
+ newgrp = np.empty(n, dtype=bool)
218
+ newgrp[0] = True
219
+ newgrp[1:] = term[:-1]
220
+ starts = np.flatnonzero(newgrp)
221
+ within = (np.arange(n) - np.repeat(starts, np.diff(np.append(starts, n)))).astype(np.uint64)
222
+ contrib = payload << (np.uint64(7) * within)
223
+ summed = np.add.reduceat(contrib, starts)
224
+ return (summed >> np.uint64(1)).astype(np.int64) ^ -(summed & np.uint64(1)).astype(np.int64)
225
+
226
+ def _decode_varints_zigzag(b):
227
+ if b.shape[0] == 0:
228
+ return np.zeros(0, dtype=np.int64)
229
+ if _HAVE_NUMBA:
230
+ return _decode_varints_zigzag_nb(b)
231
+ return _decode_varints_zigzag_np(b)
232
+
233
+ def _decode_data_block(buf, cp, n, n_datasets):
234
+ q = cp - 1
235
+ while True:
236
+ q = buf.find(b'\x12', q + 1, cp + 16)
237
+ if q < 0:
238
+ return None
239
+ msg_len, k = _read_varint(buf, q + 1)
240
+ if (k + 2 < n and buf[k] == 0x08 and buf[k + 1] < n_datasets
241
+ and buf[k + 2] in (0x18, 0x22, 0x2a)):
242
+ break
243
+ msg = buf[k:k + msg_len]
244
+ seed = 0
245
+ src = None
246
+ m = 0
247
+ while m < len(msg):
248
+ tag, m = _read_varint(msg, m)
249
+ f = tag >> 3
250
+ wt = tag & 7
251
+ if wt == 0:
252
+ v, m = _read_varint(msg, m)
253
+ if f == 3:
254
+ seed = _zigzag(v)
255
+ elif wt == 2:
256
+ l, m = _read_varint(msg, m)
257
+ if f in (4, 5):
258
+ src = msg[m:m + l]
259
+ m += l
260
+ else:
261
+ break
262
+ if src is None:
263
+ return None
264
+ deltas = _decode_varints_zigzag(np.frombuffer(src, dtype=np.uint8))
265
+ vals = np.empty(deltas.shape[0] + 1, dtype=np.int64)
266
+ vals[0] = seed
267
+ np.cumsum(deltas, out=vals[1:])
268
+ vals[1:] += seed
269
+ return vals
270
+
271
+ def _decode_region(fh, offsets, region_start, region_end, page, n_datasets):
272
+ fh.seek(region_start)
273
+ clean = _strip_markers(fh.read(region_end - region_start), region_start, page)
274
+ buf = clean.tobytes()
275
+ n = len(buf)
276
+ parts = []
277
+ for phys in offsets:
278
+ vals = _decode_data_block(buf, _clean_pos(phys, region_start, page), n, n_datasets)
279
+ if vals is not None:
280
+ parts.append(vals)
281
+ if not parts:
282
+ return np.zeros(0, dtype=np.int64)
283
+ return np.concatenate(parts)
284
+
285
+ def has_dual_tdc(path):
286
+ names = set(dataset_names(path))
287
+ return '/start/time' in names and '/stop/time' in names
288
+
289
+ def read_photons(path, wanted=('x', 'y', 'dt', 'ms')):
290
+ header = read_header(path)
291
+ page = header['page_size']
292
+ datasets = header['datasets']
293
+ n_datasets = len(datasets)
294
+ id_by_name = {d['name']: i for i, d in enumerate(datasets)}
295
+ dtype_by_id = {i: d['dtype'] for i, d in enumerate(datasets)}
296
+ import os
297
+ filesize = os.path.getsize(path)
298
+ with open(path, 'rb') as fh:
299
+ index_off = _read_epilogue(fh, filesize, page)
300
+ offsets = _parse_index(fh, index_off, filesize, page, n_datasets)
301
+ def decode(full):
302
+ did = id_by_name.get(full)
303
+ if did is None or did not in offsets or not offsets[did]:
304
+ return None
305
+ v = offsets[did]
306
+ start, end = min(v), min(index_off, max(v) + 65536)
307
+ vals = _decode_region(fh, v, start, end, page, n_datasets)
308
+ return vals.astype(dtype_by_id.get(did) or np.int64, copy=False)
309
+ # newer LINCam files carry two TDCs: dt = photon /stop/time - laser /start/time
310
+ dual = '/start/time' in id_by_name and '/stop/time' in id_by_name
311
+ out = {}
312
+ for name in wanted:
313
+ if name == 'dt' and dual:
314
+ start_t = decode('/start/time')
315
+ stop_t = decode('/stop/time')
316
+ if start_t is not None and stop_t is not None:
317
+ m = min(start_t.shape[0], stop_t.shape[0])
318
+ out['dt'] = (stop_t[:m].astype(np.int64) - start_t[:m].astype(np.int64))
319
+ continue
320
+ vals = decode('/photons/' + name)
321
+ if vals is not None:
322
+ out[name] = vals
323
+ return out
@@ -0,0 +1,174 @@
1
+ """Read Photonscore LINCam .photons files.
2
+
3
+ Photonscore LINCam detectors write time- and space-resolved single-photon data
4
+ to a `.photons` file, the D7 container. This is a pure-Python reader for it:
5
+ numpy only, with optional numba acceleration for the varint decode.
6
+
7
+ The D7 container is a paged file (16 KB pages, each carrying a 2-byte page
8
+ marker), holding a protobuf-style header, a per-dataset index, and an epilogue.
9
+ Each photon dataset (`/photons/x`, `/photons/y`, `/photons/dt`, `/photons/ms`,
10
+ and on dual-TDC detectors `/start/time` and `/stop/time`) is stored as a seed
11
+ value followed by zigzag varint delta blocks. `x`/`y` are detector positions,
12
+ `dt` is the TCSPC micro time, and `ms` is the macro time in milliseconds.
13
+
14
+ The D7 format is documented at https://github.com/photonscore/d7 (Apache-2.0).
15
+ This reader was written from that specification and validated bit-exact against
16
+ the Photonscore SDK. No vendor source is redistributed.
17
+
18
+ Examples
19
+ --------
20
+ >>> from photonsfile import PhotonsFile
21
+ >>> with PhotonsFile('sample.photons') as f: # doctest: +SKIP
22
+ ... ph = f.photons() # dict of x, y, dt, ms arrays
23
+ ... image = f.image(pixels=512) # (Y, X) intensity image
24
+ ... decay = f.decay(bins=256) # TCSPC histogram
25
+
26
+ """
27
+
28
+ import numpy as np
29
+
30
+ from ._d7 import (
31
+ MAGIC,
32
+ read_header,
33
+ read_attributes,
34
+ read_photons,
35
+ dataset_names,
36
+ has_dual_tdc,
37
+ _HAVE_NUMBA,
38
+ )
39
+
40
+ __version__ = '2026.7.8'
41
+
42
+ __all__ = [
43
+ 'PhotonsFile',
44
+ 'imread',
45
+ 'read_header',
46
+ 'read_attributes',
47
+ 'read_photons',
48
+ 'dataset_names',
49
+ 'has_dual_tdc',
50
+ 'have_numba',
51
+ 'MAGIC',
52
+ ]
53
+
54
+ def have_numba():
55
+ """Return True if the varint decode is numba-accelerated."""
56
+ return _HAVE_NUMBA
57
+
58
+ class PhotonsFile:
59
+ """A Photonscore LINCam `.photons` (D7) file.
60
+
61
+ Parameters:
62
+ filename: Name of the `.photons` file to open.
63
+
64
+ """
65
+
66
+ def __init__(self, filename):
67
+ self.filename = str(filename)
68
+ self.header = read_header(self.filename)
69
+ self.attributes = read_attributes(self.filename)
70
+ self.version = self.header['version']
71
+ self.datasets = [d['name'] for d in self.header['datasets']]
72
+ self.dual_tdc = has_dual_tdc(self.filename)
73
+ self.position_bits = int(self.attributes.get('/photons/PositionBits', 12))
74
+ self.tac_bits = int(self.attributes.get('/photons/TacBits', 12))
75
+ self.position_range = 1 << self.position_bits
76
+ self.tac_range = 1 << self.tac_bits
77
+ self.tac_channel = self.attributes.get('/photons/TacChannel')
78
+ self._photons = None
79
+
80
+ def photons(self, wanted=('x', 'y', 'dt', 'ms')):
81
+ """Return a dict of per-photon arrays for the requested datasets.
82
+
83
+ Keys are the short names (`'x'`, `'y'`, `'dt'`, `'ms'`). On dual-TDC
84
+ detectors `dt` is `stop - start` in picoseconds and `tac_range` is
85
+ updated from the data.
86
+ """
87
+ if self._photons is None:
88
+ self._photons = read_photons(self.filename, wanted)
89
+ if self.dual_tdc and 'dt' in self._photons:
90
+ dt = np.asarray(self._photons['dt'])
91
+ dt = dt[dt >= 0]
92
+ if dt.size:
93
+ self.tac_range = int(dt.max()) + 1
94
+ return self._photons
95
+
96
+ def tcspc_resolution(self, bins):
97
+ """Return the TCSPC bin width in seconds for a given number of bins.
98
+
99
+ Uses the `/photons/TacChannel` attribute (picoseconds per raw dt unit),
100
+ or 1 ps per unit for dual-TDC detectors. Returns 0.0 if unknown.
101
+ """
102
+ if self.dual_tdc:
103
+ period_s = self.tac_range * 1e-12
104
+ elif self.tac_channel:
105
+ period_s = self.tac_range * float(self.tac_channel) * 1e-12
106
+ else:
107
+ return 0.0
108
+ return period_s / bins if bins else 0.0
109
+
110
+ def _binned(self, pixels, binning):
111
+ s = self.photons()
112
+ x = np.asarray(s['x']).astype(np.int64)
113
+ y = np.asarray(s['y']).astype(np.int64)
114
+ dt = np.asarray(s['dt']).astype(np.int64)
115
+ n = min(x.shape[0], y.shape[0], dt.shape[0])
116
+ x, y, dt = x[:n], y[:n], dt[:n]
117
+ valid = ((x >= 0) & (x < self.position_range)
118
+ & (y >= 0) & (y < self.position_range)
119
+ & (dt >= 0) & (dt < self.tac_range))
120
+ return x[valid], y[valid], dt[valid]
121
+
122
+ def image(self, pixels=512, binning=1):
123
+ """Return the `(Y, X)` intensity image, photon positions binned to a grid."""
124
+ x, y, _ = self._binned(pixels, binning)
125
+ p = int(pixels)
126
+ xi = (x * p) // self.position_range
127
+ yi = (y * p) // self.position_range
128
+ if binning > 1:
129
+ xi //= binning
130
+ yi //= binning
131
+ p = (p + binning - 1) // binning
132
+ if p == 0:
133
+ return np.zeros((0, 0), dtype=np.uint32)
134
+ flat = yi * p + xi
135
+ return np.bincount(flat, minlength=p * p).reshape(p, p).astype(np.uint32)
136
+
137
+ def decay(self, bins=256):
138
+ """Return the summed TCSPC histogram of length `bins`."""
139
+ s = self.photons()
140
+ dt = np.asarray(s['dt']).astype(np.int64)
141
+ dt = dt[(dt >= 0) & (dt < self.tac_range)]
142
+ di = (dt * bins) // self.tac_range
143
+ return np.bincount(di, minlength=bins)[:bins].astype(np.uint32)
144
+
145
+ def flim_image(self, pixels=512, bins=256, binning=1):
146
+ """Return the `(Y, X, H)` FLIM cube: intensity image with a TCSPC axis."""
147
+ x, y, dt = self._binned(pixels, binning)
148
+ p = int(pixels)
149
+ b = int(bins)
150
+ xi = (x * p) // self.position_range
151
+ yi = (y * p) // self.position_range
152
+ di = (dt * b) // self.tac_range
153
+ if binning > 1:
154
+ xi //= binning
155
+ yi //= binning
156
+ p = (p + binning - 1) // binning
157
+ if p == 0 or b == 0:
158
+ return np.zeros((p, p, b), dtype=np.uint32)
159
+ flat = (yi * p + xi) * b + di
160
+ return np.bincount(flat, minlength=p * p * b).reshape(p, p, b).astype(np.uint32)
161
+
162
+ def close(self):
163
+ self._photons = None
164
+
165
+ def __enter__(self):
166
+ return self
167
+
168
+ def __exit__(self, exc_type, exc, tb):
169
+ self.close()
170
+
171
+ def imread(filename, pixels=512, binning=1):
172
+ """Read a `.photons` file and return the `(Y, X)` intensity image."""
173
+ with PhotonsFile(filename) as f:
174
+ return f.image(pixels=pixels, binning=binning)
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: photonsfile
3
+ Version: 2026.7.8
4
+ Summary: Read Photonscore LINCam .photons (D7) files
5
+ Author: Alex Hunt
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/alex1075/photonsfile
8
+ Project-URL: D7 format, https://github.com/photonscore/d7
9
+ Keywords: Photonscore,LINCam,D7,photons,TCSPC,FLIM
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: numpy
14
+ Provides-Extra: all
15
+ Requires-Dist: numba; extra == "all"
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest; extra == "test"
18
+ Requires-Dist: numba; extra == "test"
19
+ Dynamic: license-file
20
+
21
+ # photonsfile
22
+
23
+ Pure-Python reader for Photonscore LINCam `.photons` files (the **D7** container).
24
+ numpy only, with optional numba acceleration for the varint decode.
25
+
26
+ Photonscore LINCam is a position-sensitive single-photon detector: every photon
27
+ carries an `(x, y)` detector position, a TCSPC micro time (`dt`), and a macro time
28
+ in milliseconds (`ms`). This library decodes those per-photon streams and can bin
29
+ them into an intensity image, a TCSPC decay, or a `(Y, X, H)` FLIM cube.
30
+
31
+ ## The D7 format
32
+
33
+ A `.photons` file is a paged container: 16 KB pages, each carrying a 2-byte page
34
+ marker, holding a protobuf-style header, a per-dataset index, and an epilogue. Each
35
+ dataset (`/photons/x`, `/photons/y`, `/photons/dt`, `/photons/ms`, and on dual-TDC
36
+ detectors `/start/time` and `/stop/time`) is stored as a seed value followed by
37
+ zigzag-varint delta blocks. `dt` to time is calibrated from the file's
38
+ `/photons/TacChannel` attribute (picoseconds per raw unit).
39
+
40
+ The format is documented at [github.com/photonscore/d7](https://github.com/photonscore/d7)
41
+ (Apache-2.0). This reader was written from that specification and **validated
42
+ bit-exact against the Photonscore SDK**. No vendor source is redistributed.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install photonsfile # numpy only
48
+ pip install photonsfile[all] # + numba, for a faster varint decode
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ```python
54
+ from photonsfile import PhotonsFile, imread
55
+
56
+ with PhotonsFile('sample.photons') as f:
57
+ ph = f.photons() # {'x', 'y', 'dt', 'ms'} arrays, one per photon
58
+ img = f.image(pixels=512) # (Y, X) intensity image
59
+ decay = f.decay(bins=256) # summed TCSPC histogram
60
+ cube = f.flim_image(512, 256) # (Y, X, H) FLIM cube
61
+ dt_res = f.tcspc_resolution(256) # seconds per bin, from TacChannel
62
+ print(f.attributes) # D7 file attributes
63
+
64
+ img = imread('sample.photons') # shortcut for the intensity image
65
+ ```
66
+
67
+ Low-level access to the decoder is also exported: `read_header`,
68
+ `read_attributes`, `read_photons`, `dataset_names`, `has_dual_tdc`.
69
+
70
+ numba is optional; without it the varint decode uses a vectorised numpy fallback.
71
+ `photonsfile.have_numba()` reports which path is active.
72
+
73
+ ## Provenance
74
+
75
+ Reverse-engineered from the public D7 specification and checked bit-exact against
76
+ the Photonscore SDK on a 284-million-photon sample. Thanks to Photonscore for
77
+ open-sourcing the D7 format.
78
+
79
+ ## License
80
+
81
+ MIT - see `LICENSE`.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ photonsfile/__init__.py
5
+ photonsfile/_d7.py
6
+ photonsfile/photonsfile.py
7
+ photonsfile.egg-info/PKG-INFO
8
+ photonsfile.egg-info/SOURCES.txt
9
+ photonsfile.egg-info/dependency_links.txt
10
+ photonsfile.egg-info/requires.txt
11
+ photonsfile.egg-info/top_level.txt
12
+ tests/test_photonsfile.py
@@ -0,0 +1,8 @@
1
+ numpy
2
+
3
+ [all]
4
+ numba
5
+
6
+ [test]
7
+ pytest
8
+ numba
@@ -0,0 +1 @@
1
+ photonsfile
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ['setuptools>=61']
3
+ build-backend = 'setuptools.build_meta'
4
+
5
+ [project]
6
+ name = 'photonsfile'
7
+ version = '2026.7.8'
8
+ description = 'Read Photonscore LINCam .photons (D7) files'
9
+ readme = 'README.md'
10
+ requires-python = '>=3.10'
11
+ license = { text = 'MIT' }
12
+ authors = [{ name = 'Alex Hunt' }]
13
+ keywords = ['Photonscore', 'LINCam', 'D7', 'photons', 'TCSPC', 'FLIM']
14
+ dependencies = ['numpy']
15
+
16
+ [project.optional-dependencies]
17
+ all = ['numba']
18
+ test = ['pytest', 'numba']
19
+
20
+ [project.urls]
21
+ Homepage = 'https://github.com/alex1075/photonsfile'
22
+ 'D7 format' = 'https://github.com/photonscore/d7'
23
+
24
+ [tool.setuptools]
25
+ packages = ['photonsfile']
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,62 @@
1
+ import os
2
+ import glob
3
+ import numpy as np
4
+ import pytest
5
+ import photonsfile
6
+ from photonsfile import PhotonsFile, imread, read_header, have_numba
7
+
8
+ def _find_sample():
9
+ pats = [
10
+ os.path.expanduser('~/Downloads/**/*.photons'),
11
+ os.path.expanduser('~/Downloads/*.photons'),
12
+ os.path.join(os.path.dirname(__file__), 'data', '*.photons'),
13
+ ]
14
+ for p in pats:
15
+ hits = sorted(glob.glob(p, recursive=True))
16
+ if hits:
17
+ return hits[0]
18
+ return None
19
+
20
+ _SAMPLE = _find_sample()
21
+
22
+ def test_api_surface():
23
+ for name in ('PhotonsFile', 'imread', 'read_header', 'read_attributes',
24
+ 'read_photons', 'dataset_names', 'has_dual_tdc', 'MAGIC'):
25
+ assert hasattr(photonsfile, name)
26
+ assert isinstance(have_numba(), bool)
27
+ assert photonsfile.MAGIC == b'D7 Photons Data'
28
+
29
+ @pytest.mark.skipif(_SAMPLE is None, reason='no .photons sample present')
30
+ def test_read_real_sample():
31
+ f = PhotonsFile(_SAMPLE)
32
+ assert f.header['magic'] == 'D7 Photons Data'
33
+ assert f.position_range >= 2
34
+ assert isinstance(f.attributes, dict)
35
+
36
+ ph = f.photons()
37
+ assert 'x' in ph and 'y' in ph and 'dt' in ph
38
+ n = min(len(ph['x']), len(ph['y']), len(ph['dt']))
39
+ assert n > 0
40
+
41
+ img = f.image(pixels=256)
42
+ assert img.shape == (256, 256)
43
+ assert int(img.sum()) > 0
44
+
45
+ decay = f.decay(bins=256)
46
+ assert decay.shape == (256,)
47
+ assert int(decay.sum()) > 0
48
+
49
+ cube = f.flim_image(pixels=128, bins=256)
50
+ assert cube.shape == (128, 128, 256)
51
+ assert int(cube.sum()) > 0
52
+ assert int(cube.sum(axis=2).sum()) == int(f.image(pixels=128).sum())
53
+
54
+ res = f.tcspc_resolution(256)
55
+ assert res >= 0.0
56
+ f.close()
57
+
58
+ @pytest.mark.skipif(_SAMPLE is None, reason='no .photons sample present')
59
+ def test_imread_matches_method():
60
+ img = imread(_SAMPLE, pixels=128)
61
+ assert img.shape == (128, 128)
62
+ assert np.array_equal(img, PhotonsFile(_SAMPLE).image(pixels=128))