PyIncucyte 0.3.0__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.
- pyincucyte/__init__.py +113 -0
- pyincucyte/__main__.py +8 -0
- pyincucyte/cache.py +169 -0
- pyincucyte/channels.py +89 -0
- pyincucyte/cli.py +665 -0
- pyincucyte/client.py +611 -0
- pyincucyte/compat.py +68 -0
- pyincucyte/config.py +130 -0
- pyincucyte/engine.py +1667 -0
- pyincucyte/errors.py +68 -0
- pyincucyte/gui/__init__.py +5 -0
- pyincucyte/gui/__main__.py +8 -0
- pyincucyte/gui/app.py +1447 -0
- pyincucyte/gui/dialogs.py +285 -0
- pyincucyte/gui/theme.py +398 -0
- pyincucyte/gui/widgets.py +620 -0
- pyincucyte/manifest.py +162 -0
- pyincucyte/models.py +446 -0
- pyincucyte/options.py +491 -0
- pyincucyte/state.py +190 -0
- pyincucyte/watch.py +177 -0
- pyincucyte/wells.py +114 -0
- pyincucyte-0.3.0.dist-info/METADATA +355 -0
- pyincucyte-0.3.0.dist-info/RECORD +28 -0
- pyincucyte-0.3.0.dist-info/WHEEL +5 -0
- pyincucyte-0.3.0.dist-info/entry_points.txt +3 -0
- pyincucyte-0.3.0.dist-info/licenses/LICENSE +28 -0
- pyincucyte-0.3.0.dist-info/top_level.txt +1 -0
pyincucyte/__init__.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""PyIncucyte - download Incucyte live-cell images from Python.
|
|
2
|
+
|
|
3
|
+
Two ways in. A desktop app::
|
|
4
|
+
|
|
5
|
+
pyincucyte gui # or: pyincucyte-gui, python -m pyincucyte.gui
|
|
6
|
+
|
|
7
|
+
...and an importable API for an automated pipeline::
|
|
8
|
+
|
|
9
|
+
from pyincucyte import IncucyteClient
|
|
10
|
+
|
|
11
|
+
with IncucyteClient.from_saved() as incucyte:
|
|
12
|
+
result = incucyte.fetch(
|
|
13
|
+
vessel=38, output="./run-01", wells="A1-D6",
|
|
14
|
+
channels="phase,green", layout="time_channel_stack",
|
|
15
|
+
start_from="first")
|
|
16
|
+
|
|
17
|
+
for image in result.files:
|
|
18
|
+
segment(image.path, well=image.well, channels=image.channels)
|
|
19
|
+
|
|
20
|
+
``result.files`` carries the well, channel names, timepoints and axis order for
|
|
21
|
+
every file, and the same information is written to ``pyincucyte-manifest.json``
|
|
22
|
+
next to the images so a later stage can pick up without re-parsing filenames.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
|
|
27
|
+
__version__ = "0.3.0"
|
|
28
|
+
|
|
29
|
+
# A library should not configure logging for its host application; this only
|
|
30
|
+
# stops "No handlers could be found" warnings when nobody has set one up.
|
|
31
|
+
logging.getLogger("pyincucyte").addHandler(logging.NullHandler())
|
|
32
|
+
|
|
33
|
+
from .errors import ( # noqa: E402
|
|
34
|
+
ApiError,
|
|
35
|
+
AuthenticationError,
|
|
36
|
+
DeviceUnreachableError,
|
|
37
|
+
EncryptionUnavailableError,
|
|
38
|
+
ExportCancelled,
|
|
39
|
+
ExportError,
|
|
40
|
+
IncucyteError,
|
|
41
|
+
NotLoggedInError,
|
|
42
|
+
TokenExpiredError,
|
|
43
|
+
VesselNotFoundError,
|
|
44
|
+
)
|
|
45
|
+
from .models import ( # noqa: E402
|
|
46
|
+
DownloadResult,
|
|
47
|
+
ExportPlan,
|
|
48
|
+
LAYOUTS,
|
|
49
|
+
LAYOUT_AXES,
|
|
50
|
+
LAYOUT_DESCRIPTIONS,
|
|
51
|
+
LAYOUT_LABELS,
|
|
52
|
+
OutputFile,
|
|
53
|
+
ProgressEvent,
|
|
54
|
+
Vessel,
|
|
55
|
+
human_bytes,
|
|
56
|
+
resolve_layout,
|
|
57
|
+
)
|
|
58
|
+
from .options import ExportOptions # noqa: E402
|
|
59
|
+
from .config import ConfigStore, Credentials # noqa: E402
|
|
60
|
+
from .state import StateStore # noqa: E402
|
|
61
|
+
from .client import IncucyteClient # noqa: E402
|
|
62
|
+
from .watch import Watcher # noqa: E402
|
|
63
|
+
from .manifest import load_manifest, write_manifest # noqa: E402
|
|
64
|
+
from .engine import DEFAULT_HOST, APP_DIR # noqa: E402
|
|
65
|
+
|
|
66
|
+
# Import names retired in 0.3 - see pyincucyte.compat.
|
|
67
|
+
from . import compat # noqa: E402
|
|
68
|
+
|
|
69
|
+
compat.install()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def connect(host=None, username=None, password=None):
|
|
73
|
+
"""Return a ready client - saved login by default, or fresh credentials."""
|
|
74
|
+
if username and password:
|
|
75
|
+
return IncucyteClient.connect(host or DEFAULT_HOST, username, password)
|
|
76
|
+
return IncucyteClient.from_saved(host)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"__version__",
|
|
81
|
+
"connect",
|
|
82
|
+
"IncucyteClient",
|
|
83
|
+
"ExportOptions",
|
|
84
|
+
"ExportPlan",
|
|
85
|
+
"DownloadResult",
|
|
86
|
+
"OutputFile",
|
|
87
|
+
"ProgressEvent",
|
|
88
|
+
"Vessel",
|
|
89
|
+
"Watcher",
|
|
90
|
+
"StateStore",
|
|
91
|
+
"ConfigStore",
|
|
92
|
+
"Credentials",
|
|
93
|
+
"LAYOUTS",
|
|
94
|
+
"LAYOUT_AXES",
|
|
95
|
+
"LAYOUT_LABELS",
|
|
96
|
+
"LAYOUT_DESCRIPTIONS",
|
|
97
|
+
"resolve_layout",
|
|
98
|
+
"human_bytes",
|
|
99
|
+
"load_manifest",
|
|
100
|
+
"write_manifest",
|
|
101
|
+
"DEFAULT_HOST",
|
|
102
|
+
"APP_DIR",
|
|
103
|
+
"IncucyteError",
|
|
104
|
+
"ApiError",
|
|
105
|
+
"AuthenticationError",
|
|
106
|
+
"NotLoggedInError",
|
|
107
|
+
"TokenExpiredError",
|
|
108
|
+
"DeviceUnreachableError",
|
|
109
|
+
"EncryptionUnavailableError",
|
|
110
|
+
"VesselNotFoundError",
|
|
111
|
+
"ExportError",
|
|
112
|
+
"ExportCancelled",
|
|
113
|
+
]
|
pyincucyte/__main__.py
ADDED
pyincucyte/cache.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""A local cache of source image payloads.
|
|
2
|
+
|
|
3
|
+
Time stacks have an awkward property: a stack must contain every frame, so the
|
|
4
|
+
arrival of one new scan invalidates the whole file. Rebuilt naively, a watch
|
|
5
|
+
loop re-downloads the entire experiment on every poll — an hour into a five-day
|
|
6
|
+
run that is already thousands of redundant images, and it gets worse every hour.
|
|
7
|
+
|
|
8
|
+
The cache fixes that by keeping each source payload on disk the first time it is
|
|
9
|
+
fetched. Rebuilding a stack then costs one disk read per frame instead of one
|
|
10
|
+
network round trip, and only genuinely new frames touch the instrument.
|
|
11
|
+
|
|
12
|
+
It is a cache, not a record: deleting it only costs time.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger("pyincucyte.cache")
|
|
23
|
+
|
|
24
|
+
#: Folder created inside the output directory.
|
|
25
|
+
CACHE_DIRNAME = ".pyincucyte-cache"
|
|
26
|
+
|
|
27
|
+
#: Cached payloads older than this are swept away (14 days).
|
|
28
|
+
DEFAULT_MAX_AGE_SECONDS = 14 * 24 * 3600
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def payload_key(item):
|
|
32
|
+
"""Return the stable identity of one source image payload."""
|
|
33
|
+
scan = str(item.get("scan_time", "")).replace(":", "").replace("-", "")
|
|
34
|
+
scan = scan.replace("+", "_").replace(".", "_")
|
|
35
|
+
return (f"V{item.get('vessel_id')}_r{item.get('row', 0)}"
|
|
36
|
+
f"_c{item.get('col', 0)}_s{item.get('site', 0)}"
|
|
37
|
+
f"_t{item.get('img_type', 1)}_{scan}.tif")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PayloadCache:
|
|
41
|
+
"""Stores raw payload bytes on disk, keyed by well/channel/scan time."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, root, max_age_seconds=DEFAULT_MAX_AGE_SECONDS):
|
|
44
|
+
self.root = Path(root)
|
|
45
|
+
self.max_age_seconds = max_age_seconds
|
|
46
|
+
self.hits = 0
|
|
47
|
+
self.misses = 0
|
|
48
|
+
self._lock = threading.Lock()
|
|
49
|
+
self._ready = False
|
|
50
|
+
|
|
51
|
+
# -- lifecycle --------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def _ensure(self):
|
|
54
|
+
if self._ready:
|
|
55
|
+
return True
|
|
56
|
+
with self._lock:
|
|
57
|
+
if self._ready:
|
|
58
|
+
return True
|
|
59
|
+
try:
|
|
60
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
self._ready = True
|
|
62
|
+
except OSError as exc:
|
|
63
|
+
log.debug("payload cache unavailable at %s: %s", self.root, exc)
|
|
64
|
+
return False
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
# -- access -----------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def get(self, item):
|
|
70
|
+
"""Return cached bytes for this item, or None."""
|
|
71
|
+
path = self.root / payload_key(item)
|
|
72
|
+
try:
|
|
73
|
+
data = path.read_bytes()
|
|
74
|
+
except OSError:
|
|
75
|
+
with self._lock:
|
|
76
|
+
self.misses += 1
|
|
77
|
+
return None
|
|
78
|
+
if not data:
|
|
79
|
+
with self._lock:
|
|
80
|
+
self.misses += 1
|
|
81
|
+
return None
|
|
82
|
+
with self._lock:
|
|
83
|
+
self.hits += 1
|
|
84
|
+
return data
|
|
85
|
+
|
|
86
|
+
def put(self, item, data):
|
|
87
|
+
"""Store payload bytes. Failures are ignored — it is only a cache."""
|
|
88
|
+
if not data or not self._ensure():
|
|
89
|
+
return
|
|
90
|
+
path = self.root / payload_key(item)
|
|
91
|
+
tmp = path.with_suffix(".part")
|
|
92
|
+
try:
|
|
93
|
+
tmp.write_bytes(data)
|
|
94
|
+
os.replace(tmp, path)
|
|
95
|
+
except OSError:
|
|
96
|
+
try:
|
|
97
|
+
tmp.unlink()
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# -- housekeeping -----------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def size_bytes(self):
|
|
104
|
+
try:
|
|
105
|
+
return sum(f.stat().st_size for f in self.root.glob("*.tif"))
|
|
106
|
+
except OSError:
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
def count(self):
|
|
110
|
+
try:
|
|
111
|
+
return sum(1 for _ in self.root.glob("*.tif"))
|
|
112
|
+
except OSError:
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
def sweep(self, max_age_seconds=None):
|
|
116
|
+
"""Delete cached payloads older than the age limit. Returns the count."""
|
|
117
|
+
limit = self.max_age_seconds if max_age_seconds is None else max_age_seconds
|
|
118
|
+
if not limit or not self.root.is_dir():
|
|
119
|
+
return 0
|
|
120
|
+
cutoff = time.time() - limit
|
|
121
|
+
removed = 0
|
|
122
|
+
for path in self.root.glob("*.tif"):
|
|
123
|
+
try:
|
|
124
|
+
if path.stat().st_mtime < cutoff:
|
|
125
|
+
path.unlink()
|
|
126
|
+
removed += 1
|
|
127
|
+
except OSError:
|
|
128
|
+
continue
|
|
129
|
+
return removed
|
|
130
|
+
|
|
131
|
+
def clear(self):
|
|
132
|
+
"""Remove the cache folder entirely."""
|
|
133
|
+
try:
|
|
134
|
+
shutil.rmtree(self.root)
|
|
135
|
+
except OSError:
|
|
136
|
+
pass
|
|
137
|
+
self._ready = False
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def hit_rate(self):
|
|
141
|
+
total = self.hits + self.misses
|
|
142
|
+
return (self.hits / total) if total else 0.0
|
|
143
|
+
|
|
144
|
+
def summary(self):
|
|
145
|
+
from .models import human_bytes
|
|
146
|
+
return (f"{self.count():,} cached payloads ({human_bytes(self.size_bytes())}), "
|
|
147
|
+
f"{self.hits:,} hits / {self.misses:,} misses")
|
|
148
|
+
|
|
149
|
+
def __repr__(self):
|
|
150
|
+
return f"<PayloadCache {self.root} hits={self.hits} misses={self.misses}>"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cache_for_output(output_dir, mode="auto", layout="separate",
|
|
154
|
+
max_age_seconds=DEFAULT_MAX_AGE_SECONDS):
|
|
155
|
+
"""Return the cache to use for a download, or None.
|
|
156
|
+
|
|
157
|
+
``"auto"`` caches only for the layouts that rebuild whole files when a scan
|
|
158
|
+
arrives — the ones where the cache actually saves work.
|
|
159
|
+
"""
|
|
160
|
+
if mode == "never":
|
|
161
|
+
return None
|
|
162
|
+
if mode == "auto" and "time" not in layout:
|
|
163
|
+
return None
|
|
164
|
+
return PayloadCache(Path(output_dir) / CACHE_DIRNAME,
|
|
165
|
+
max_age_seconds=max_age_seconds)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
__all__ = ["PayloadCache", "cache_for_output", "payload_key", "CACHE_DIRNAME",
|
|
169
|
+
"DEFAULT_MAX_AGE_SECONDS"]
|
pyincucyte/channels.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Channel identity: the Incucyte's three acquisition channels.
|
|
2
|
+
|
|
3
|
+
The device numbers its channels 1/2/3 ("ImageType"). Channel 1 is the
|
|
4
|
+
transmitted-light Phase image; channels 2 and 3 are the two fluorescence
|
|
5
|
+
"Colors", which the instrument reports as Green and Red respectively but which
|
|
6
|
+
an experiment can rename (e.g. "GFP", "mCherry").
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from .engine import (
|
|
12
|
+
CHANNEL_HELP,
|
|
13
|
+
IMAGE_TYPE_LABELS,
|
|
14
|
+
IMAGE_TYPE_MAP,
|
|
15
|
+
IMAGE_TYPE_SHORT_LABELS,
|
|
16
|
+
channel_name_from_channels,
|
|
17
|
+
channel_tag,
|
|
18
|
+
image_type_label,
|
|
19
|
+
image_type_sort_key,
|
|
20
|
+
parse_channels,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
#: Symbolic channel numbers, matching the device's ImageType field.
|
|
24
|
+
PHASE = 1
|
|
25
|
+
COLOR1 = GREEN = 2
|
|
26
|
+
COLOR2 = RED = 3
|
|
27
|
+
|
|
28
|
+
#: Every channel, in acquisition order.
|
|
29
|
+
ALL_CHANNELS = (PHASE, COLOR1, COLOR2)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def channel_token(label):
|
|
33
|
+
"""Return a filename-safe token for a channel display name."""
|
|
34
|
+
token = re.sub(r"[^a-z0-9]+", "-", str(label).lower()).strip("-")
|
|
35
|
+
return token or "channel"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_channels(channels, labels=None):
|
|
39
|
+
"""Return a human-readable channel list, e.g. ``"Phase + GFP"``."""
|
|
40
|
+
if channels is None:
|
|
41
|
+
return "all channels"
|
|
42
|
+
labels = labels or IMAGE_TYPE_LABELS
|
|
43
|
+
names = [labels.get(c, image_type_label(c))
|
|
44
|
+
for c in sorted(channels, key=image_type_sort_key)]
|
|
45
|
+
return " + ".join(names) if names else "no channels"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def channel_spec(channels):
|
|
49
|
+
"""Return the CLI ``--channels`` spec string for a set of channel numbers."""
|
|
50
|
+
if channels is None:
|
|
51
|
+
return "all"
|
|
52
|
+
names = [IMAGE_TYPE_SHORT_LABELS.get(c, f"type{c}")
|
|
53
|
+
for c in sorted(channels, key=image_type_sort_key)]
|
|
54
|
+
return ",".join(names) or "all"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def labels_for_vessel(vessel_channels):
|
|
58
|
+
"""Return ``{channel_number: display name}`` for one vessel's metadata."""
|
|
59
|
+
labels = dict(IMAGE_TYPE_LABELS)
|
|
60
|
+
if isinstance(vessel_channels, dict):
|
|
61
|
+
labels[COLOR1] = channel_name_from_channels(vessel_channels, COLOR1)
|
|
62
|
+
labels[COLOR2] = channel_name_from_channels(vessel_channels, COLOR2)
|
|
63
|
+
return labels
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def active_channels(vessel_channels):
|
|
67
|
+
"""Return the channel numbers actually switched on for a vessel."""
|
|
68
|
+
if not isinstance(vessel_channels, dict):
|
|
69
|
+
return set()
|
|
70
|
+
active = set()
|
|
71
|
+
if (vessel_channels.get("Phase") or {}).get("On"):
|
|
72
|
+
active.add(PHASE)
|
|
73
|
+
colors = vessel_channels.get("Colors") or {}
|
|
74
|
+
if isinstance(colors, dict):
|
|
75
|
+
if (colors.get("Color1") or {}).get("On"):
|
|
76
|
+
active.add(COLOR1)
|
|
77
|
+
if (colors.get("Color2") or {}).get("On"):
|
|
78
|
+
active.add(COLOR2)
|
|
79
|
+
return active
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"PHASE", "COLOR1", "COLOR2", "GREEN", "RED", "ALL_CHANNELS",
|
|
84
|
+
"CHANNEL_HELP", "IMAGE_TYPE_LABELS", "IMAGE_TYPE_MAP",
|
|
85
|
+
"IMAGE_TYPE_SHORT_LABELS", "parse_channels", "channel_name_from_channels",
|
|
86
|
+
"channel_tag", "image_type_label", "image_type_sort_key",
|
|
87
|
+
"channel_token", "format_channels", "channel_spec",
|
|
88
|
+
"labels_for_vessel", "active_channels",
|
|
89
|
+
]
|