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 +100 -0
- pylv200/__main__.py +7 -0
- pylv200/cache.py +365 -0
- pylv200/channels.py +133 -0
- pylv200/cli.py +2073 -0
- pylv200/client.py +609 -0
- pylv200/compat.py +83 -0
- pylv200/config.py +368 -0
- pylv200/discover.py +308 -0
- pylv200/engine.py +1761 -0
- pylv200/errors.py +107 -0
- pylv200/gui/__init__.py +10 -0
- pylv200/gui/__main__.py +8 -0
- pylv200/gui/app.py +1937 -0
- pylv200/gui/dialogs.py +402 -0
- pylv200/gui/preview.py +256 -0
- pylv200/gui/theme.py +423 -0
- pylv200/gui/timeline.py +567 -0
- pylv200/gui/widgets.py +751 -0
- pylv200/manifest.py +365 -0
- pylv200/models.py +452 -0
- pylv200/oex.py +134 -0
- pylv200/options.py +601 -0
- pylv200/preview.py +608 -0
- pylv200/proxy.py +200 -0
- pylv200/setup.py +898 -0
- pylv200/state.py +324 -0
- pylv200/tiffstack.py +453 -0
- pylv200/timeline.py +1630 -0
- pylv200/vsi.py +366 -0
- pylv200/watch.py +675 -0
- pylv200-0.1.1.dist-info/METADATA +648 -0
- pylv200-0.1.1.dist-info/RECORD +37 -0
- pylv200-0.1.1.dist-info/WHEEL +5 -0
- pylv200-0.1.1.dist-info/entry_points.txt +5 -0
- pylv200-0.1.1.dist-info/licenses/LICENSE +21 -0
- pylv200-0.1.1.dist-info/top_level.txt +1 -0
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
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
|