PyLV200 0.1.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.
pylv200/__init__.py ADDED
@@ -0,0 +1,100 @@
1
+ """pylv200 - pull images out of a running Olympus/Evident cellSens experiment.
2
+
3
+ cellSens does not finalise its image container until a run ends, but the pixel
4
+ data is already on disk in the ``_<experiment>_`` folder as ``.ets`` tile files.
5
+ This package reads those directly and assembles ImageJ hyperstacks of everything
6
+ acquired so far, without writing to, renaming or locking anything in the
7
+ experiment folder.
8
+
9
+ The channel names come from the small ``.vsi`` cellSens writes beside the tiles
10
+ at the START of the run, and the requested cycle and exposures from the ``.oex``
11
+ acquisition plan. Neither has to be guessed.
12
+ """
13
+
14
+ import os
15
+ from pathlib import Path
16
+
17
+ from .channels import channel_stats, classify_channels, cross_check
18
+ from .client import LV200Client, scan_roots
19
+ from .discover import (experiment_name, find_dataset, group_by_dataset,
20
+ looks_like_parts)
21
+ from .engine import (STAGES, assign_axes, build_stacks, decode_tile,
22
+ derive_interval, is_open_part, live_length, merge_parts,
23
+ parse_ets, pull_experiment, pull_position,
24
+ select_channels, select_timepoints, select_window,
25
+ write_hyperstack)
26
+ from .errors import (AmbiguousDataset, GeometryMismatch, InvalidSelection,
27
+ LV200Error, NoDatasetFound, SourceUnreachable,
28
+ UnreadableFormat)
29
+ from .models import (DownloadResult, Experiment, ExportPlan, OutputFile, Part,
30
+ Position, ProgressEvent, PullPlan, PullResult, Source,
31
+ human_bytes)
32
+ from .manifest import MANIFEST_FILENAME, load_manifest, write_manifest
33
+ from .oex import find_oex, read_oex
34
+ from .options import (LAYOUTS, LAYOUT_AXES, LAYOUT_DESCRIPTIONS, LAYOUT_LABELS,
35
+ MOMENT_HELP, STATE_SCOPES, ExportOptions, resolve_layout)
36
+ from .preview import PreviewImage, PreviewSet, encode_png
37
+ from .state import NullStateStore, StateStore, output_key
38
+ from .timeline import TimelineFrame, TimelineSource
39
+ from .vsi import find_sidecar, read_vsi_channels
40
+ from .watch import PollResult, Watcher, format_age
41
+ from .config import home
42
+ from . import compat
43
+
44
+ # Registers the retired import names, of which there are currently none - see
45
+ # `compat.py`. Called here rather than left to the caller so the mechanism is
46
+ # in place whenever this package is, which is the only way an old script keeps
47
+ # working without being edited first.
48
+ compat.install()
49
+
50
+
51
+ def connect(source=None):
52
+ """A ready :class:`LV200Client` - the PyIncucyte idiom, on this package.
53
+
54
+ with pylv200.connect() as lv200:
55
+ result = lv200.fetch(output="./run-01", last_n=24)
56
+
57
+ `source` is the name of a source saved by ``pylv200 setup``, a folder, or
58
+ None for the only saved source there is. A configured name wins over a
59
+ folder of the same name: naming a microscope is the deliberate act, and a
60
+ stray folder in the working directory should not shadow one.
61
+
62
+ Nothing here touches the filesystem until a method is called, and nothing
63
+ imports Tk - `pylv200 list` on a headless machine must not depend on a
64
+ window system.
65
+ """
66
+ from .client import LV200Client
67
+ if source is None or isinstance(source, Source):
68
+ return LV200Client.from_saved() if source is None else LV200Client(source)
69
+ text = str(source)
70
+ from . import config
71
+ if text in config.load_sources():
72
+ return LV200Client.from_saved(text)
73
+ if os.sep in text or "/" in text or Path(text).is_dir():
74
+ return LV200Client(text)
75
+ return LV200Client.from_saved(text) # raises, listing what it knows
76
+
77
+ __version__ = "0.1.1"
78
+ __all__ = [
79
+ "parse_ets", "assign_axes", "build_stacks", "decode_tile", "merge_parts",
80
+ "select_channels", "select_timepoints", "select_window", "derive_interval",
81
+ "is_open_part", "live_length", "write_hyperstack",
82
+ "pull_position", "pull_experiment", "STAGES",
83
+ "channel_stats", "classify_channels", "cross_check",
84
+ "LV200Client", "connect", "scan_roots", "ExportOptions",
85
+ "Watcher", "PollResult", "format_age",
86
+ "StateStore", "NullStateStore", "output_key",
87
+ "PreviewSet", "PreviewImage", "encode_png",
88
+ "TimelineSource", "TimelineFrame",
89
+ "load_manifest", "write_manifest", "MANIFEST_FILENAME",
90
+ "LAYOUTS", "LAYOUT_LABELS", "LAYOUT_DESCRIPTIONS", "LAYOUT_AXES",
91
+ "resolve_layout", "STATE_SCOPES", "MOMENT_HELP",
92
+ "Experiment", "Position", "Part", "Source", "PullPlan", "PullResult",
93
+ "DownloadResult", "ExportPlan",
94
+ "OutputFile", "ProgressEvent", "human_bytes", "home", "compat",
95
+ "find_dataset", "group_by_dataset", "looks_like_parts", "experiment_name",
96
+ "find_sidecar", "read_vsi_channels", "find_oex", "read_oex",
97
+ "LV200Error", "SourceUnreachable", "NoDatasetFound", "AmbiguousDataset",
98
+ "UnreadableFormat", "GeometryMismatch", "InvalidSelection",
99
+ "__version__",
100
+ ]
pylv200/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m pylv200`` - the same entry point as the console script."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
pylv200/cache.py ADDED
@@ -0,0 +1,365 @@
1
+ """The incremental byte mirror: a local copy of remote ``.ets`` files.
2
+
3
+ This is what turns "extract" into "sync". Tile data is appended at a fixed
4
+ stride, so a mirror only ever needs the bytes past what it already holds, and a
5
+ repeated pull costs the new data and nothing else.
6
+
7
+ The copy matters more than the speed. During acquisition the tiles are the only
8
+ copy of the run that exists - cellSens writes nothing else until the run ends -
9
+ so a mirror that keeps pace is what stands between a crashed nine-day experiment
10
+ and total loss.
11
+
12
+ **This is the one module where silent corruption is possible.** Everything else
13
+ fails loudly; a mirror that appends at the wrong offset produces a file that
14
+ parses perfectly and contains garbage. Three things keep that from happening and
15
+ none of them may be weakened:
16
+
17
+ * the resume point is whatever length of the local copy still agrees with the
18
+ source at its tail, so an interrupted sync continues where it stopped while a
19
+ half-written or corrupted tail is dropped rather than appended to;
20
+ * the source is read to EOF rather than to a length measured beforehand, because
21
+ it is being written to while it is read;
22
+ * the header region is re-copied after every sync, because closing a part
23
+ rewrites it - and comparing what identifies the file excludes the timepoint
24
+ count, which legitimately changes when that happens.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import json
30
+ import os
31
+ import re
32
+ import shutil
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+
36
+ from .engine import geometry_key, live_length, probe_geometry
37
+ from .errors import MirrorError
38
+
39
+ #: Written into each mirrored folder. Records what was copied and what the
40
+ #: source looked like, so a change of file behind the same name is noticed.
41
+ MIRROR_SIDECAR = ".pylv200-mirror.json"
42
+
43
+ #: Read size for the copy loop. Big enough that SMB round trips do not dominate.
44
+ CHUNK_BYTES = 8 << 20
45
+
46
+ #: Kept free after any sync, so a full mirror does not take the machine with it.
47
+ FREE_MARGIN = 256 << 20
48
+
49
+ #: How much of the local copy's end is checked against the source before the
50
+ #: rest of it is believed. The cost is one small read; what it buys is that a
51
+ #: torn tail from a killed process is never appended to.
52
+ TAIL_CHECK = 64 << 10
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SyncResult:
57
+ """What one file's sync did."""
58
+ source: Path
59
+ local: Path
60
+ copied: int = 0 # bytes that crossed the wire
61
+ held: int = 0 # bytes already local before this sync
62
+ length: int = 0 # local length afterwards
63
+ reset_reason: str = "" # non-empty when the whole file was re-copied
64
+ header_refreshed: bool = False
65
+ closed: bool = False # gained its chunk table during this sync
66
+
67
+ @property
68
+ def up_to_date(self):
69
+ return self.copied == 0 and not self.reset_reason
70
+
71
+ def __str__(self):
72
+ if self.reset_reason:
73
+ what = "re-copied %.1f MB (%s)" % (self.copied / 1e6, self.reset_reason)
74
+ elif self.copied:
75
+ what = "+%.1f MB of %.1f MB" % (self.copied / 1e6, self.length / 1e6)
76
+ else:
77
+ what = "up to date (%.1f MB)" % (self.length / 1e6)
78
+ return "%s %s%s" % (self.source.name, what,
79
+ " [part closed]" if self.closed else "")
80
+
81
+
82
+ def _safe(name):
83
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", str(name)).strip("_") or "root"
84
+
85
+
86
+ class Mirror:
87
+ """A local directory that grows as the remote files do."""
88
+
89
+ def __init__(self, root, free_margin=FREE_MARGIN):
90
+ self.root = Path(root)
91
+ self.free_margin = int(free_margin)
92
+
93
+ # -- where things land -------------------------------------------------
94
+
95
+ def local_path(self, part):
96
+ """Where this remote file is mirrored to.
97
+
98
+ The remote folder is hashed into the directory name: two microscopes can
99
+ hold a position called ``ois7041`` and the copies must not land on top
100
+ of each other, while the same file on the same machine must always come
101
+ back to the same place.
102
+ """
103
+ src = Path(getattr(part, "path", part))
104
+ folder = src.parent
105
+ key = hashlib.sha1(str(folder).lower().encode("utf-8")).hexdigest()[:8]
106
+ return self.root / ("%s-%s" % (_safe(folder.name), key)) / src.name
107
+
108
+ def resolver(self):
109
+ """A ``resolve(path)`` callable for ``build_stacks`` / ``channel_stats``.
110
+
111
+ Reads come from the mirror when it holds the file and from the source
112
+ when it does not, so extraction works whether or not a sync has run.
113
+ """
114
+ def resolve(src):
115
+ local = self.local_path(src)
116
+ return local if local.exists() else Path(src)
117
+ return resolve
118
+
119
+ def held(self, part):
120
+ """Bytes currently held locally for this part."""
121
+ local = self.local_path(part)
122
+ try:
123
+ return local.stat().st_size
124
+ except OSError:
125
+ return 0
126
+
127
+ # -- the sidecar -------------------------------------------------------
128
+
129
+ def _sidecar_path(self, part):
130
+ return self.local_path(part).parent / MIRROR_SIDECAR
131
+
132
+ def _sidecar(self, part):
133
+ try:
134
+ return json.loads(self._sidecar_path(part).read_text())
135
+ except (OSError, ValueError):
136
+ return {}
137
+
138
+ def _remember(self, part, record):
139
+ path = self._sidecar_path(part)
140
+ data = self._sidecar(part)
141
+ data[Path(getattr(part, "path", part)).name] = record
142
+ path.parent.mkdir(parents=True, exist_ok=True)
143
+ path.write_text(json.dumps(data, indent=2))
144
+
145
+ # -- syncing -----------------------------------------------------------
146
+
147
+ def _must_reset(self, source, have, geom, remembered):
148
+ """Why the local copy cannot simply be appended to, or ``""``.
149
+
150
+ Closure is deliberately not in here. When cellSens finishes a part it
151
+ appends the chunk table and rewrites the header; the body already
152
+ mirrored is still valid, so that is handled by copying the tail and
153
+ refreshing the header, not by fetching a gigabyte again.
154
+ """
155
+ if have <= 0:
156
+ return ""
157
+ length = geom.get("size", -1)
158
+ if 0 <= length < have:
159
+ return "the source is shorter than the local copy, so it was replaced"
160
+ was = remembered.get("geometry_key")
161
+ now = geometry_key(geom)
162
+ if was and now and list(was) != list(now):
163
+ return ("the source's geometry changed from %s to %s"
164
+ % (tuple(was), tuple(now)))
165
+ return ""
166
+
167
+ def _tail_matches(self, source, local, length, window=TAIL_CHECK):
168
+ """Does the local copy still agree with the source at this length?"""
169
+ start = max(length - window, 0)
170
+ want = length - start
171
+ try:
172
+ with open(source, "rb") as src, open(local, "rb") as dst:
173
+ src.seek(start)
174
+ dst.seek(start)
175
+ return src.read(want) == dst.read(want)
176
+ except OSError:
177
+ return False
178
+
179
+ def _trusted_length(self, source, local, have, recorded):
180
+ """How much of the local copy can be believed, and appended to.
181
+
182
+ The file's own length comes first, so a sync that died half way through
183
+ resumes rather than starting again. It is believed only if the source
184
+ still says the same thing there - a process killed mid-write, or a disk
185
+ that filled, can leave a tail that is not source bytes at all, and
186
+ appending past it would produce a file that parses and holds garbage.
187
+ The last recorded length is the fallback, and zero is the floor.
188
+ """
189
+ if have <= 0:
190
+ return 0
191
+ for cand in sorted({have, min(recorded, have)}, reverse=True):
192
+ if cand > 0 and self._tail_matches(source, local, cand):
193
+ return cand
194
+ return 0
195
+
196
+ def sync(self, part, chunk_bytes=CHUNK_BYTES, progress=None):
197
+ """Bring the local copy up to the source's current length.
198
+
199
+ Returns a :class:`SyncResult`. Nothing already held crosses the wire.
200
+ """
201
+ source = Path(getattr(part, "path", part))
202
+ local = self.local_path(source)
203
+ local.parent.mkdir(parents=True, exist_ok=True)
204
+
205
+ try:
206
+ geom = probe_geometry(source)
207
+ except OSError as exc:
208
+ raise MirrorError("cannot read %s: %s"
209
+ % (source, exc.strerror or exc)) from None
210
+ remembered = self._sidecar(source).get(source.name, {})
211
+
212
+ have = self.held(source)
213
+ reason = self._must_reset(source, have, geom, remembered)
214
+ if reason:
215
+ have = 0
216
+ else:
217
+ have = self._trusted_length(source, local, have,
218
+ int(remembered.get("length") or 0))
219
+
220
+ expected = max(geom.get("size", 0) - have, 0)
221
+ self._check_space(expected, local)
222
+
223
+ copied = 0
224
+ mode = "r+b" if (have and local.exists()) else "wb"
225
+ with open(source, "rb") as src, open(local, mode) as dst:
226
+ src.seek(have)
227
+ dst.seek(have)
228
+ dst.truncate(have) # drop any tail left by an interrupted run
229
+ while True:
230
+ block = src.read(chunk_bytes)
231
+ if not block:
232
+ break # read to EOF: the file grows as it is read
233
+ dst.write(block)
234
+ copied += len(block)
235
+ if progress:
236
+ progress(copied, expected)
237
+ dst.flush()
238
+ os.fsync(dst.fileno())
239
+
240
+ length = have + copied
241
+ refreshed = self._refresh_header(source, local, geom, length)
242
+
243
+ # A part that had no chunk table last time and has one now has just been
244
+ # closed by cellSens. Worth reporting - it is the moment the run's data
245
+ # stops moving - but it changes nothing about what was copied.
246
+ open_now = _looks_open(local)
247
+ closed = bool(remembered.get("open")) and not open_now
248
+
249
+ self._remember(source, {
250
+ "source": str(source),
251
+ "length": length,
252
+ "geometry_key": list(geometry_key(geom) or ()),
253
+ "header_bytes": geom.get("header_bytes"),
254
+ "open": open_now,
255
+ "modified": _mtime(source),
256
+ })
257
+ return SyncResult(source=source, local=local, copied=copied, held=have,
258
+ length=length, reset_reason=reason,
259
+ header_refreshed=refreshed, closed=closed)
260
+
261
+ def _refresh_header(self, source, local, geom, length):
262
+ """Re-copy the header region, which closing a part rewrites.
263
+
264
+ Done after the tail, not before: a crash between the two leaves a local
265
+ file whose header describes less than it holds, which the parser already
266
+ copes with. The other order would leave one describing more.
267
+ """
268
+ n = geom.get("header_bytes") or 512
269
+ if length < n:
270
+ return False
271
+ with open(source, "rb") as src:
272
+ head = src.read(n)
273
+ with open(local, "r+b") as dst:
274
+ if dst.read(len(head)) == head:
275
+ return False
276
+ dst.seek(0)
277
+ dst.write(head)
278
+ return True
279
+
280
+ def _check_space(self, need, local):
281
+ if need <= 0:
282
+ return
283
+ try:
284
+ free = shutil.disk_usage(local.parent).free
285
+ except OSError:
286
+ return
287
+ if free < need + self.free_margin:
288
+ raise MirrorError(
289
+ "%s needs %.1f GB and %.1f GB is free. Mirror somewhere with "
290
+ "room, or pull a narrower time range."
291
+ % (local.parent, need / 1e9, free / 1e9))
292
+
293
+ # -- whole experiments -------------------------------------------------
294
+
295
+ def space_needed(self, experiment):
296
+ """Bytes a full sync of this experiment would still have to fetch."""
297
+ total = 0
298
+ for pos in experiment.positions:
299
+ for part in pos.parts:
300
+ size = part.size if part.size >= 0 else live_length(part.path)
301
+ total += max(size - self.held(part.path), 0)
302
+ return total
303
+
304
+ def sync_experiment(self, experiment, progress=None, chunk_bytes=CHUNK_BYTES):
305
+ """Sync every part of every position. Returns a list of SyncResults."""
306
+ out = []
307
+ for pos in experiment.positions:
308
+ for part in pos.parts:
309
+ out.append(self.sync(part.path, chunk_bytes=chunk_bytes,
310
+ progress=progress))
311
+ return out
312
+
313
+ # -- checking ----------------------------------------------------------
314
+
315
+ def verify(self, part, samples=16, window=4096):
316
+ """Compare sampled stretches of the copy against the source.
317
+
318
+ For when a sync looks wrong. Returns the offsets that disagree; an empty
319
+ list means every sample matched. Evenly spaced rather than random, so a
320
+ failure can be reproduced.
321
+ """
322
+ source = Path(getattr(part, "path", part))
323
+ local = self.local_path(source)
324
+ held = self.held(source)
325
+ if not held:
326
+ return [(0, "nothing is mirrored")]
327
+ bad = []
328
+ step = max(held // max(samples, 1), 1)
329
+ offsets = list(range(0, held, step))[:samples]
330
+ if held > window:
331
+ offsets.append(held - window)
332
+ with open(source, "rb") as src, open(local, "rb") as dst:
333
+ for off in offsets:
334
+ src.seek(off)
335
+ dst.seek(off)
336
+ a, b = src.read(window), dst.read(window)
337
+ if a != b:
338
+ bad.append((off, "%d bytes differ" % sum(1 for x, y
339
+ in zip(a, b) if x != y)))
340
+ return bad
341
+
342
+
343
+ def _looks_open(path):
344
+ """Is cellSens still writing this part - has it no usable chunk table yet?
345
+
346
+ Asked of the local copy, and asked by parsing it properly. A cheaper probe
347
+ was tried and was wrong: a header field can look like a plausible table
348
+ pointer while pointing at nothing, and only the scoring inside parse_ets
349
+ settles it. The parse costs local CPU and no network, which is the trade
350
+ worth making. Never used to decide what to copy - only to report that a run
351
+ has finished.
352
+ """
353
+ from .engine import is_open_part, parse_ets
354
+
355
+ try:
356
+ return is_open_part(parse_ets(Path(path)))
357
+ except (OSError, ValueError):
358
+ return False
359
+
360
+
361
+ def _mtime(path):
362
+ try:
363
+ return os.stat(path).st_mtime
364
+ except OSError:
365
+ return 0.0
pylv200/channels.py ADDED
@@ -0,0 +1,133 @@
1
+ """Per-channel pixel statistics, and the classifier built on them.
2
+
3
+ The classifier is a cross-check, not a source of truth. Channel names come from
4
+ the ``.vsi`` written beside the tiles at the start of the run; these statistics
5
+ only say what the pixels look like. On a real four-channel LV200 run the
6
+ classifier named every channel wrongly, because it assumes a luminescence
7
+ channel is photon-starved and a bright luminescent sample inverts that.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from collections import defaultdict
12
+
13
+ import numpy as np
14
+
15
+ from .engine import decode_tile
16
+
17
+
18
+ def _safe_corr(a, b):
19
+ """Correlation between two frames, defined even when one is flat.
20
+
21
+ A frame with no variance has an undefined correlation. Two identical flat
22
+ frames are perfectly alike, so score them 1.0; otherwise 0.0. Returning NaN
23
+ would let a degenerate channel be mistaken for the photon-starved one.
24
+ """
25
+ if a.std() == 0 or b.std() == 0:
26
+ return 1.0 if np.array_equal(a, b) else 0.0
27
+ return float(np.corrcoef(a.ravel(), b.ravel())[0, 1])
28
+
29
+
30
+ def channel_stats(meta, axes, max_pairs=4, resolve=None):
31
+ """Per-channel statistics, read straight from the tiles.
32
+
33
+ Enough to tell the channels apart without decoding the whole run: signal
34
+ level, how much a frame resembles the next one, and how much of the frame is
35
+ saturated.
36
+ """
37
+ ix = {n: i for i, n in enumerate(axes["names"])}
38
+ chunks = meta["chunks"]
39
+
40
+ def key(rec, ax):
41
+ return rec[0][ix[ax]] if ax in ix else 0
42
+
43
+ by_c = defaultdict(dict)
44
+ for rec in chunks:
45
+ by_c[key(rec, "c")].setdefault(key(rec, "t"), rec)
46
+
47
+ out = {}
48
+ for c, byt in sorted(by_c.items()):
49
+ ts = sorted(byt)[:max_pairs + 1]
50
+ frames = []
51
+ for t in ts:
52
+ coords, off, nbytes, src = byt[t]
53
+ with open(resolve(src) if resolve else src, "rb") as fh:
54
+ fh.seek(off)
55
+ frames.append(decode_tile(fh.read(nbytes), meta).astype(np.float32))
56
+ if not frames:
57
+ continue
58
+ rs = [_safe_corr(a, b) for a, b in zip(frames, frames[1:])]
59
+ f0 = frames[0]
60
+ dx = np.diff(f0, axis=1)
61
+ out[c] = {
62
+ "median": float(np.median(f0)),
63
+ "p99": float(np.percentile(f0, 99)),
64
+ "temporal_r": float(np.mean(rs)) if rs else float("nan"),
65
+ "saturated": float(np.mean(f0 >= np.iinfo(np.uint16).max)),
66
+ "smoothness": float(f0.var() / dx.var()) if dx.var() > 0 else float("inf"),
67
+ }
68
+ return out
69
+
70
+
71
+ def cross_check(stats, names=None):
72
+ """Facts about the pixels that are worth saying whatever the channel is called.
73
+
74
+ Deliberately not a second opinion on identity. The classifier below was
75
+ wrong on all four channels of a real run, so it disagreeing with a sidecar
76
+ would say nothing about which is right - and the sidecar is right. What the
77
+ pixels can state without appealing to identity is that a channel carries no
78
+ signal, or that it is clipping, and both change what a frame is worth.
79
+
80
+ Returns a list of notes; an empty list means nothing looked wrong.
81
+ """
82
+ notes = []
83
+ for i, c in enumerate(sorted(stats)):
84
+ st = stats[c]
85
+ label = "C%d" % c
86
+ if names and i < len(names):
87
+ label = "C%d (%s)" % (c, names[i])
88
+ if st["p99"] <= st["median"]:
89
+ notes.append("%s is flat - no range between its median and its 99th "
90
+ "percentile, so nothing was collected on it" % label)
91
+ elif st["saturated"] > 0.01:
92
+ notes.append("%s is clipping - %.1f%% of its pixels sit at full scale"
93
+ % (label, 100.0 * st["saturated"]))
94
+ return notes
95
+
96
+
97
+ def classify_channels(stats):
98
+ """Name channels from their statistics. A LAST RESORT, not a source of truth.
99
+
100
+ Channel names come from the ``.vsi`` written at the start of the run. This
101
+ runs only when there is no sidecar and no override, and its output must be
102
+ reported as inferred: on a real four-channel run it named every channel
103
+ wrongly, and the output gave no sign anything was amiss.
104
+
105
+ Two are identifiable with confidence. A luminescence channel is
106
+ photon-starved: consecutive frames barely resemble each other because the
107
+ signal is shot noise, and cosmic-ray hits saturate stray pixels. Brightfield
108
+ is the opposite - bright, nearly identical frame to frame, and spatially
109
+ smooth because it is mostly the illumination profile.
110
+
111
+ The rest are fluorescence, and nothing in the pixels says which fluorophore
112
+ is which. They are numbered rather than guessed at.
113
+ """
114
+ if not stats:
115
+ return {}
116
+ names, remaining = {}, dict(stats)
117
+
118
+ lum = min(remaining, key=lambda c: remaining[c]["temporal_r"])
119
+ if remaining[lum]["temporal_r"] < 0.4:
120
+ names[lum] = "Bioluminescence"
121
+ remaining.pop(lum)
122
+
123
+ if remaining:
124
+ bf = max(remaining, key=lambda c: (remaining[c]["temporal_r"],
125
+ remaining[c]["median"]))
126
+ s = remaining[bf]
127
+ if s["temporal_r"] > 0.95 and s["median"] == max(v["median"] for v in stats.values()):
128
+ names[bf] = "Brightfield"
129
+ remaining.pop(bf)
130
+
131
+ for i, c in enumerate(sorted(remaining), start=1):
132
+ names[c] = "Fluorescence %d" % i if len(remaining) > 1 else "Fluorescence"
133
+ return names