wormscore 0.6.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.
- wormscore/__init__.py +3 -0
- wormscore/cli.py +81 -0
- wormscore/config.py +265 -0
- wormscore/copydata.py +33 -0
- wormscore/demo.py +96 -0
- wormscore/launcher.py +272 -0
- wormscore/measure.py +145 -0
- wormscore/methods.py +183 -0
- wormscore/pipeline.py +481 -0
- wormscore/readers.py +392 -0
- wormscore/realdemo.py +94 -0
- wormscore/server.py +263 -0
- wormscore/static/app.js +555 -0
- wormscore/static/icon.svg +1 -0
- wormscore/static/index.html +107 -0
- wormscore/static/style.css +147 -0
- wormscore/stats.py +237 -0
- wormscore/unblind.py +304 -0
- wormscore-0.6.1.dist-info/METADATA +164 -0
- wormscore-0.6.1.dist-info/RECORD +24 -0
- wormscore-0.6.1.dist-info/WHEEL +5 -0
- wormscore-0.6.1.dist-info/entry_points.txt +5 -0
- wormscore-0.6.1.dist-info/licenses/LICENSE +21 -0
- wormscore-0.6.1.dist-info/top_level.txt +1 -0
wormscore/__init__.py
ADDED
wormscore/cli.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Command line: wormscore <command> <project folder> ..."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv=None):
|
|
11
|
+
p = argparse.ArgumentParser(prog="wormscore", description="Blind scoring and measurement of neurons in confocal stacks.")
|
|
12
|
+
p.add_argument("--version", action="version", version=f"wormscore {__version__}")
|
|
13
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
14
|
+
|
|
15
|
+
s = sub.add_parser("init", help="create a project folder with a config.json to edit")
|
|
16
|
+
s.add_argument("project")
|
|
17
|
+
s.add_argument("--name", default="My project")
|
|
18
|
+
s = sub.add_parser("demo", help="create a demo project with simulated worms")
|
|
19
|
+
s.add_argument("project")
|
|
20
|
+
s.add_argument("--n", type=int, default=10, help="simulated worms per genotype")
|
|
21
|
+
s.add_argument("--real", action="store_true",
|
|
22
|
+
help="download 4 real worm heads (about 150 MB, CC BY 4.0) instead of simulating")
|
|
23
|
+
s = sub.add_parser("copy", help="copy a data folder and verify every file by SHA-256")
|
|
24
|
+
s.add_argument("source")
|
|
25
|
+
s.add_argument("destination")
|
|
26
|
+
s = sub.add_parser("inventory", help="list every file and image in the project's sources")
|
|
27
|
+
s.add_argument("project")
|
|
28
|
+
s = sub.add_parser("prepare", help="build the blind image set, key and automatic measurements")
|
|
29
|
+
s.add_argument("project")
|
|
30
|
+
s.add_argument("--reassign-codes", action="store_true", help="new codes (only allowed before any scoring)")
|
|
31
|
+
s.add_argument("--add-new-images", dest="add", action="store_true",
|
|
32
|
+
help="code only images not yet in the key; existing codes are kept")
|
|
33
|
+
s = sub.add_parser("app", help="open the scoring app")
|
|
34
|
+
s.add_argument("project")
|
|
35
|
+
s.add_argument("--port", type=int, default=8765)
|
|
36
|
+
s.add_argument("--no-browser", action="store_true")
|
|
37
|
+
s = sub.add_parser("unblind", help="join scores, measurements and the key (after scoring)")
|
|
38
|
+
s.add_argument("project")
|
|
39
|
+
s.add_argument("--i-have-finished-scoring", dest="finished", action="store_true")
|
|
40
|
+
s.add_argument("--anyway", action="store_true", help="unblind below the planned minimum number of scores")
|
|
41
|
+
|
|
42
|
+
a = p.parse_args(argv)
|
|
43
|
+
try:
|
|
44
|
+
if a.cmd == "init":
|
|
45
|
+
from .config import template
|
|
46
|
+
proj = Path(a.project)
|
|
47
|
+
proj.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
cfg = proj / "config.json"
|
|
49
|
+
if cfg.exists():
|
|
50
|
+
sys.exit(f"{cfg} already exists; not overwriting.")
|
|
51
|
+
cfg.write_text(json.dumps(template(a.name), indent=2), encoding="utf-8")
|
|
52
|
+
print(f"Created {cfg}. Edit 'sources' to point at your images, then run 'wormscore inventory {proj}'.")
|
|
53
|
+
elif a.cmd == "demo":
|
|
54
|
+
if a.real:
|
|
55
|
+
from .realdemo import make_real_demo
|
|
56
|
+
make_real_demo(a.project)
|
|
57
|
+
else:
|
|
58
|
+
from .demo import make_demo
|
|
59
|
+
make_demo(a.project, n_per_group=a.n)
|
|
60
|
+
print(f"Next: wormscore inventory {a.project} && wormscore prepare {a.project} && wormscore app {a.project}")
|
|
61
|
+
elif a.cmd == "copy":
|
|
62
|
+
from .copydata import copy_verified
|
|
63
|
+
copy_verified(a.source, a.destination)
|
|
64
|
+
elif a.cmd == "inventory":
|
|
65
|
+
from .pipeline import inventory
|
|
66
|
+
inventory(a.project)
|
|
67
|
+
elif a.cmd == "prepare":
|
|
68
|
+
from .pipeline import prepare
|
|
69
|
+
prepare(a.project, reassign=a.reassign_codes, add=a.add)
|
|
70
|
+
elif a.cmd == "app":
|
|
71
|
+
from .server import serve
|
|
72
|
+
serve(a.project, port=a.port, open_browser=not a.no_browser)
|
|
73
|
+
elif a.cmd == "unblind":
|
|
74
|
+
from .unblind import unblind
|
|
75
|
+
unblind(a.project, finished=a.finished, anyway=a.anyway)
|
|
76
|
+
except FileNotFoundError as exc:
|
|
77
|
+
sys.exit(str(exc))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
wormscore/config.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Project configuration.
|
|
2
|
+
|
|
3
|
+
A project is a folder holding config.json plus everything wormscore writes
|
|
4
|
+
(outputs/, blind/, key/, scoring/, log.md). Every analysis parameter lives in
|
|
5
|
+
config.json; anything left out falls back to DEFAULTS below. Each run copies the
|
|
6
|
+
full, merged configuration into log.md so the analysis can be repeated.
|
|
7
|
+
"""
|
|
8
|
+
import copy
|
|
9
|
+
import fnmatch
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
DEFAULTS = {
|
|
14
|
+
"project_name": "Untitled project",
|
|
15
|
+
|
|
16
|
+
# Folders of images. path = where the files are read from (ideally a read-only copy);
|
|
17
|
+
# original_path = where they first came from, recorded in the key for traceability.
|
|
18
|
+
# genotypes: first matching pattern (on the path relative to the source folder) wins.
|
|
19
|
+
# exclude (optional): [{"match": pattern, "reason": text}]; matching images are left out of
|
|
20
|
+
# the blind set and listed in outputs/excluded.csv with the reason.
|
|
21
|
+
# note (optional): a label such as "collected by others" shown with an asterisk next to
|
|
22
|
+
# this source in the results tables.
|
|
23
|
+
"sources": [],
|
|
24
|
+
|
|
25
|
+
# Which channel is the reporter. Identified from metadata only, never guessed.
|
|
26
|
+
"marker": {
|
|
27
|
+
"label": "GFP",
|
|
28
|
+
"excitation_nm": [488],
|
|
29
|
+
"emission_band_start_nm": [490, 530],
|
|
30
|
+
"dye_names": ["EGFP", "GFP", "Leica/EGFP"],
|
|
31
|
+
"channel_name_prefixes": ["GFP", "EGFP"],
|
|
32
|
+
"leica_lut": "Green",
|
|
33
|
+
},
|
|
34
|
+
"brightfield_channel_names": ["BF", "Brightfield", "DIC", "Trans", "T-PMT", "T PMT", "TL"],
|
|
35
|
+
|
|
36
|
+
"inclusion": {
|
|
37
|
+
"min_objective_magnification": 40,
|
|
38
|
+
"min_z_extent_um": 9.0,
|
|
39
|
+
"max_time_points": 1,
|
|
40
|
+
"same_field_tolerance_um": 5.0,
|
|
41
|
+
},
|
|
42
|
+
# For byte-identical copies of an image, keep the one whose path contains the first
|
|
43
|
+
# matching text here; otherwise the shortest path.
|
|
44
|
+
"duplicate_preference": [],
|
|
45
|
+
|
|
46
|
+
"display": {
|
|
47
|
+
"display_um_per_px": 0.20,
|
|
48
|
+
"frame_px": 940,
|
|
49
|
+
"brightfield_contrast_percentiles": [1, 99],
|
|
50
|
+
},
|
|
51
|
+
# Highest value each microscope can record, by system name. Missing systems use the
|
|
52
|
+
# bit depth from the file metadata (255 for 8-bit, 65535 for 16-bit).
|
|
53
|
+
"saturation_value": {},
|
|
54
|
+
|
|
55
|
+
"measurement": {
|
|
56
|
+
"median_filter_px": 3,
|
|
57
|
+
"smooth_um": 1.5,
|
|
58
|
+
"min_separation_um": 6.0,
|
|
59
|
+
"spot_ring_um": [6.0, 10.0],
|
|
60
|
+
"max_radius_um": 8.0,
|
|
61
|
+
"min_object_area_um2": 4.0,
|
|
62
|
+
"n_candidates": 4,
|
|
63
|
+
"background_ring_gap_um": 2.0,
|
|
64
|
+
"background_ring_width_um": 4.0,
|
|
65
|
+
"streak_jump_fraction": 0.04,
|
|
66
|
+
"other_channel_qc": True,
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
"scoring": {
|
|
70
|
+
"neurons": ["Neuron 1", "Neuron 2"],
|
|
71
|
+
"calls": ["normal", "dim", "missing"],
|
|
72
|
+
"defect_calls": ["dim", "missing"],
|
|
73
|
+
"sides": ["left", "right"],
|
|
74
|
+
"count_question": {"label": "Marker-positive cell bodies seen", "options": ["0", "1", "2", "3+"]},
|
|
75
|
+
"neuron_questions": [],
|
|
76
|
+
"unusable_reasons": [
|
|
77
|
+
"wrong region of the animal",
|
|
78
|
+
"out of focus or moving",
|
|
79
|
+
"wrong stage",
|
|
80
|
+
"looks like a different marker",
|
|
81
|
+
"damaged animal",
|
|
82
|
+
"other (see notes)",
|
|
83
|
+
],
|
|
84
|
+
"rules": [
|
|
85
|
+
"Normal: cell body clearly visible at the expected brightness.",
|
|
86
|
+
"Dim: visible but weaker than controls imaged at the same settings.",
|
|
87
|
+
"Missing: no cell body in any plane.",
|
|
88
|
+
"Score each neuron separately. The animal is the unit for statistics.",
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
# reference_genotype: the control that each other genotype is tested against (Fisher's
|
|
93
|
+
# exact, within each microscope). Empty: every pair of genotypes is tested.
|
|
94
|
+
# question_outcomes (optional): tables built from the per-neuron questions, for example
|
|
95
|
+
# {"name": "process defect", "questions": ["q1", "q2"], "defect_answers": ["no"],
|
|
96
|
+
# "unclear_answers": ["can't tell"], "unclear": "exclude_neuron", "not_visible_calls": ["missing"]}
|
|
97
|
+
# A neuron is defective if any listed question has a defect answer. Neurons whose call is in
|
|
98
|
+
# not_visible_calls are not assessed; animals with no assessed neuron are left out.
|
|
99
|
+
# unclear: "exclude_neuron" drops a neuron with an unclear answer, "exclude_animal" drops the animal.
|
|
100
|
+
# interaction (optional): {"single_a": genotype, "single_b": genotype, "double": genotype,
|
|
101
|
+
# "model": "product" | "additive" | "min", "outcome": "calls" or a question_outcomes name}.
|
|
102
|
+
# Chosen before scoring; prepare records it in log.md and unblinding tests only that model.
|
|
103
|
+
# secondary_defect_calls (optional): a second "affected" definition (for example dim or
|
|
104
|
+
# missing), reported with 95% intervals within each microscope only; tested (Fisher's
|
|
105
|
+
# exact, within one microscope) only if test_secondary is true.
|
|
106
|
+
# allow_cross_microscope (optional): measures ("calls" or a question_outcomes name) whose
|
|
107
|
+
# tests may compare genotypes imaged on different microscopes; animals of each genotype
|
|
108
|
+
# are then pooled over microscopes and every table says which microscopes contributed.
|
|
109
|
+
# alternative: "two-sided" (default), "greater" or "less" for the binomial and Fisher tests.
|
|
110
|
+
# One-sided Fisher tests need comparisons, which give the direction (genotype_a vs _b).
|
|
111
|
+
# comparisons (optional): the planned [genotype_a, genotype_b] pairs for Fisher's exact
|
|
112
|
+
# tests; Bonferroni counts only these.
|
|
113
|
+
# These settings and the interaction test form the analysis plan: prepare records it in
|
|
114
|
+
# log.md, and unblinding runs no test if it changed after the first score was saved.
|
|
115
|
+
# cross_microscope_reason and methods_notes (optional) only add text to methods_text.md.
|
|
116
|
+
"unblinding": {"min_scored_worms": 30, "reference_genotype": "", "question_outcomes": [],
|
|
117
|
+
"interaction": {}, "secondary_defect_calls": [], "test_secondary": False,
|
|
118
|
+
"allow_cross_microscope": [], "alternative": "two-sided", "comparisons": [],
|
|
119
|
+
"cross_microscope_reason": "", "methods_notes": []},
|
|
120
|
+
|
|
121
|
+
# Used only for the Methods text written at unblinding. Left empty, it says [name].
|
|
122
|
+
# imager_knew_genotype: "yes", "no" or "" (unknown).
|
|
123
|
+
"blinding": {"coded_by": "", "scored_by": "", "imager_knew_genotype": ""},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def merge(base, over):
|
|
128
|
+
out = copy.deepcopy(base)
|
|
129
|
+
for k, v in over.items():
|
|
130
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
131
|
+
out[k] = merge(out[k], v)
|
|
132
|
+
else:
|
|
133
|
+
out[k] = v
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def load(project):
|
|
138
|
+
path = Path(project) / "config.json"
|
|
139
|
+
if not path.exists():
|
|
140
|
+
raise FileNotFoundError(f"No config.json in {project}. Run 'wormscore init {project}' first.")
|
|
141
|
+
with open(path, encoding="utf-8") as fh:
|
|
142
|
+
return merge(DEFAULTS, json.load(fh))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def matches(pattern, rel_path):
|
|
146
|
+
rel = rel_path.replace("\\", "/")
|
|
147
|
+
return fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(Path(rel).name, pattern)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def genotype_for(source, rel_path):
|
|
151
|
+
for rule in source.get("genotypes", []):
|
|
152
|
+
if matches(rule["match"], rel_path):
|
|
153
|
+
return rule["genotype"]
|
|
154
|
+
return source.get("default_genotype", "UNKNOWN")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def exclude_rule_for(cfg, source_name, rel_path):
|
|
158
|
+
"""The config exclude rule matching this image, or None."""
|
|
159
|
+
for src in cfg["sources"]:
|
|
160
|
+
if src.get("name") == source_name:
|
|
161
|
+
for rule in src.get("exclude", []):
|
|
162
|
+
if matches(rule["match"], rel_path):
|
|
163
|
+
return rule
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
INTERACTION_MODELS = ("product", "additive", "min")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def interaction(cfg):
|
|
171
|
+
"""The interaction test settings with defaults filled in, or {} if none is configured."""
|
|
172
|
+
ia = cfg["unblinding"]["interaction"]
|
|
173
|
+
return {"model": "product", "outcome": "calls", "expected_direction": "", **ia} if ia else {}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
ALTERNATIVES = ("two-sided", "greater", "less")
|
|
177
|
+
PLAN_KEYS = ("secondary_defect_calls", "test_secondary", "allow_cross_microscope", "alternative", "comparisons",
|
|
178
|
+
"question_outcomes")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def analysis_plan(cfg):
|
|
182
|
+
"""Everything that decides which tests run, or {} if only defaults are used (then nothing
|
|
183
|
+
is recorded or locked)."""
|
|
184
|
+
un = cfg["unblinding"]
|
|
185
|
+
plan = {k: un[k] for k in PLAN_KEYS} | {"interaction": interaction(cfg),
|
|
186
|
+
"defect_calls": cfg["scoring"]["defect_calls"]}
|
|
187
|
+
used = un["secondary_defect_calls"] or un["allow_cross_microscope"] or un["comparisons"] \
|
|
188
|
+
or un["alternative"] != "two-sided" or un["interaction"] or un["question_outcomes"]
|
|
189
|
+
return plan if used else {}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def check(cfg):
|
|
193
|
+
"""Stop with a plain message if the optional results settings do not fit the scoring setup."""
|
|
194
|
+
un, sc = cfg["unblinding"], cfg["scoring"]
|
|
195
|
+
questions = {q["id"]: q for q in sc["neuron_questions"]}
|
|
196
|
+
names = []
|
|
197
|
+
for oc in un["question_outcomes"]:
|
|
198
|
+
name = oc.get("name", "")
|
|
199
|
+
if not name or name in names or name == "calls":
|
|
200
|
+
raise SystemExit(f"question_outcomes: each entry needs a unique 'name' other than 'calls' (got {name!r}).")
|
|
201
|
+
names.append(name)
|
|
202
|
+
if not oc.get("questions") or not oc.get("defect_answers"):
|
|
203
|
+
raise SystemExit(f"question_outcomes '{name}': set 'questions' and 'defect_answers'.")
|
|
204
|
+
for qid in oc["questions"]:
|
|
205
|
+
if qid not in questions:
|
|
206
|
+
raise SystemExit(f"question_outcomes '{name}': no neuron question with id {qid!r} in scoring.neuron_questions.")
|
|
207
|
+
unknown = set(oc["defect_answers"]) | set(oc.get("unclear_answers", []))
|
|
208
|
+
unknown -= set(questions[qid]["options"])
|
|
209
|
+
if unknown:
|
|
210
|
+
raise SystemExit(f"question_outcomes '{name}': {sorted(unknown)} are not options of question {qid!r}.")
|
|
211
|
+
if oc.get("unclear", "exclude_neuron") not in ("exclude_neuron", "exclude_animal"):
|
|
212
|
+
raise SystemExit(f"question_outcomes '{name}': 'unclear' must be exclude_neuron or exclude_animal.")
|
|
213
|
+
ia = un["interaction"]
|
|
214
|
+
if ia:
|
|
215
|
+
for k in ("single_a", "single_b", "double"):
|
|
216
|
+
if not ia.get(k):
|
|
217
|
+
raise SystemExit(f"interaction: set '{k}' to a genotype name.")
|
|
218
|
+
if ia.get("model", "product") not in INTERACTION_MODELS:
|
|
219
|
+
raise SystemExit(f"interaction: model must be one of {', '.join(INTERACTION_MODELS)}.")
|
|
220
|
+
if ia.get("outcome", "calls") not in ["calls"] + names:
|
|
221
|
+
raise SystemExit("interaction: outcome must be 'calls' or a question_outcomes name.")
|
|
222
|
+
if ia.get("expected_direction", "") not in ("", "higher", "lower"):
|
|
223
|
+
raise SystemExit("interaction: expected_direction must be 'higher', 'lower' or left out.")
|
|
224
|
+
for key in ("defect_calls",):
|
|
225
|
+
if set(sc[key]) - set(sc["calls"]):
|
|
226
|
+
raise SystemExit(f"scoring.{key}: {sorted(set(sc[key]) - set(sc['calls']))} are not in scoring.calls.")
|
|
227
|
+
if set(un["secondary_defect_calls"]) - set(sc["calls"]):
|
|
228
|
+
raise SystemExit("secondary_defect_calls: "
|
|
229
|
+
f"{sorted(set(un['secondary_defect_calls']) - set(sc['calls']))} are not in scoring.calls.")
|
|
230
|
+
for m in un["allow_cross_microscope"]:
|
|
231
|
+
if m == "secondary":
|
|
232
|
+
raise SystemExit("allow_cross_microscope: the secondary measure is compared only within one microscope.")
|
|
233
|
+
if m not in ["calls"] + names:
|
|
234
|
+
raise SystemExit(f"allow_cross_microscope: {m!r} is not 'calls' or a question_outcomes name.")
|
|
235
|
+
if un["alternative"] not in ALTERNATIVES:
|
|
236
|
+
raise SystemExit(f"alternative must be one of {', '.join(ALTERNATIVES)}.")
|
|
237
|
+
if any(not isinstance(c, (list, tuple)) or len(c) != 2 for c in un["comparisons"]):
|
|
238
|
+
raise SystemExit("comparisons: each entry must be a pair [genotype_a, genotype_b].")
|
|
239
|
+
if un["alternative"] != "two-sided" and not un["comparisons"]:
|
|
240
|
+
raise SystemExit("A one-sided alternative needs 'comparisons', so each test has a direction "
|
|
241
|
+
"(genotype_a vs genotype_b).")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def template(name="My project"):
|
|
245
|
+
return {
|
|
246
|
+
"project_name": name,
|
|
247
|
+
"sources": [
|
|
248
|
+
{
|
|
249
|
+
"name": "my images",
|
|
250
|
+
"path": "C:/path/to/read-only/copy/of/images",
|
|
251
|
+
"original_path": "where the files first came from (for the record)",
|
|
252
|
+
"genotypes": [
|
|
253
|
+
{"match": "*control*", "genotype": "control"},
|
|
254
|
+
{"match": "*mutant*", "genotype": "mutant"},
|
|
255
|
+
],
|
|
256
|
+
"default_genotype": "UNKNOWN",
|
|
257
|
+
}
|
|
258
|
+
],
|
|
259
|
+
"marker": DEFAULTS["marker"],
|
|
260
|
+
"scoring": {
|
|
261
|
+
"neurons": DEFAULTS["scoring"]["neurons"],
|
|
262
|
+
"calls": DEFAULTS["scoring"]["calls"],
|
|
263
|
+
"defect_calls": DEFAULTS["scoring"]["defect_calls"],
|
|
264
|
+
},
|
|
265
|
+
}
|
wormscore/copydata.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Copy a data folder and prove the copy is exact.
|
|
2
|
+
|
|
3
|
+
Every file (hidden ones included) is copied with its timestamp, then compared with the
|
|
4
|
+
original by SHA-256. Copies are set read-only. Nothing in the source is changed.
|
|
5
|
+
"""
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .readers import sha256
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def copy_verified(src, dst, log=print):
|
|
14
|
+
src, dst = Path(src), Path(dst)
|
|
15
|
+
if not src.is_dir():
|
|
16
|
+
raise SystemExit(f"{src} is not a folder.")
|
|
17
|
+
files = sorted(p for p in src.rglob("*") if p.is_file())
|
|
18
|
+
bad = []
|
|
19
|
+
for n, p in enumerate(files, start=1):
|
|
20
|
+
target = dst / p.relative_to(src)
|
|
21
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
if target.exists():
|
|
23
|
+
target.chmod(stat.S_IWRITE | stat.S_IREAD)
|
|
24
|
+
shutil.copy2(p, target)
|
|
25
|
+
if sha256(p) != sha256(target):
|
|
26
|
+
bad.append(str(p))
|
|
27
|
+
target.chmod(stat.S_IREAD)
|
|
28
|
+
if n % 25 == 0 or n == len(files):
|
|
29
|
+
log(f"copied and checked {n}/{len(files)}")
|
|
30
|
+
if bad:
|
|
31
|
+
raise SystemExit(f"{len(bad)} files did not match after copying: {bad[:5]}")
|
|
32
|
+
log(f"All {len(files)} files copied to {dst} and verified by SHA-256. Copies are read-only.")
|
|
33
|
+
return len(files)
|
wormscore/demo.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""A demo project with simulated worms, so anyone can try wormscore without real data.
|
|
2
|
+
|
|
3
|
+
Each simulated image is an OME-TIFF z-stack of a worm head with two marker-positive
|
|
4
|
+
cell bodies (each with a faint dendrite), gut-like autofluorescence, camera offset and
|
|
5
|
+
noise, plus a brightfield channel. Controls have two normal cells; mutants have each
|
|
6
|
+
cell normal, dim or missing at random. The truth is written to demo_truth.csv so the
|
|
7
|
+
measurements can be checked after unblinding. No real data is involved.
|
|
8
|
+
"""
|
|
9
|
+
import csv
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import tifffile
|
|
15
|
+
from scipy import ndimage
|
|
16
|
+
|
|
17
|
+
PX_UM, Z_UM, NZ, SIZE = 0.25, 1.0, 16, 384
|
|
18
|
+
LEVELS = {"normal": 3000.0, "dim": 700.0, "missing": 0.0}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _worm(rng, cells):
|
|
22
|
+
"""One simulated head: returns (marker stack, brightfield stack) as uint16 (z, y, x)."""
|
|
23
|
+
yy, xx = np.mgrid[:SIZE, :SIZE].astype(float)
|
|
24
|
+
angle = rng.uniform(0, np.pi)
|
|
25
|
+
c, s = np.cos(angle), np.sin(angle)
|
|
26
|
+
u = ((xx - SIZE / 2) * c + (yy - SIZE / 2) * s) * PX_UM # um along the body (nose at negative u)
|
|
27
|
+
v = (-(xx - SIZE / 2) * s + (yy - SIZE / 2) * c) * PX_UM # um across the body
|
|
28
|
+
half_width = np.clip(8 + 0.15 * (u + 48), 6, 22) # head widens away from the nose
|
|
29
|
+
body = (np.abs(v) < half_width) & (u > -45)
|
|
30
|
+
zz = np.arange(NZ)[:, None, None].astype(float)
|
|
31
|
+
|
|
32
|
+
marker = np.full((NZ, SIZE, SIZE), 100.0) # camera offset
|
|
33
|
+
gut = (rng.random((SIZE, SIZE)) < 0.004) & body & (u > 15)
|
|
34
|
+
gut_img = np.zeros((SIZE, SIZE))
|
|
35
|
+
gut_img[gut] = rng.uniform(150, 400, gut.sum())
|
|
36
|
+
gut_img = ndimage.gaussian_filter(gut_img, 1.5) * 9
|
|
37
|
+
marker += gut_img[None] * np.exp(-((zz - NZ / 2) ** 2) / 30)
|
|
38
|
+
marker += body[None] * 60.0 # tissue autofluorescence
|
|
39
|
+
|
|
40
|
+
for k, level in enumerate(cells):
|
|
41
|
+
cu = rng.uniform(-6, 2) # um along the body
|
|
42
|
+
cv = (-1) ** k * rng.uniform(3, 7) # um either side of the midline
|
|
43
|
+
cz = rng.uniform(5, NZ - 6)
|
|
44
|
+
r2 = (u - cu) ** 2 + (v - cv) ** 2
|
|
45
|
+
soma = np.exp(-r2 / (2 * 1.4 ** 2))[None] * np.exp(-((zz - cz) ** 2) / (2 * 1.6 ** 2))
|
|
46
|
+
marker += LEVELS[level] * soma
|
|
47
|
+
if level != "missing": # faint dendrite toward the nose
|
|
48
|
+
along = (u < cu - 2) & (u > -44) & (np.abs(v - cv * (u + 44) / (cu + 44)) < 0.4)
|
|
49
|
+
marker += 0.15 * LEVELS[level] * along[None] * np.exp(-((zz - cz) ** 2) / 8)
|
|
50
|
+
|
|
51
|
+
marker = rng.poisson(np.clip(marker, 0, None)).astype(np.float64)
|
|
52
|
+
bf = 1800 + 600 * body + rng.normal(0, 60, (SIZE, SIZE))
|
|
53
|
+
bf = np.repeat(bf[None], NZ, axis=0) + rng.normal(0, 30, (NZ, SIZE, SIZE))
|
|
54
|
+
return np.clip(marker, 0, 65535).astype(np.uint16), np.clip(bf, 0, 65535).astype(np.uint16)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def make_demo(project, n_per_group=10, seed=7, log=print):
|
|
58
|
+
project = Path(project)
|
|
59
|
+
data = project / "data"
|
|
60
|
+
data.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
rng = np.random.default_rng(seed)
|
|
62
|
+
truth = []
|
|
63
|
+
for group in ("control", "mutant"):
|
|
64
|
+
for i in range(1, n_per_group + 1):
|
|
65
|
+
if group == "control":
|
|
66
|
+
cells = ["normal", "normal"]
|
|
67
|
+
else:
|
|
68
|
+
cells = list(rng.choice(["normal", "dim", "missing"], size=2, p=[0.4, 0.35, 0.25]))
|
|
69
|
+
marker, bf = _worm(rng, cells)
|
|
70
|
+
name = f"demo_{group}_{i:02d}.ome.tif"
|
|
71
|
+
tifffile.imwrite(data / name, np.stack([marker, bf]), photometric="minisblack", ome=True,
|
|
72
|
+
metadata={"axes": "CZYX", "PhysicalSizeX": PX_UM, "PhysicalSizeY": PX_UM,
|
|
73
|
+
"PhysicalSizeZ": Z_UM, "Channel": {"Name": ["GFP", "Brightfield"]}})
|
|
74
|
+
truth.append({"file": name, "genotype": group, "cell_1": cells[0], "cell_2": cells[1]})
|
|
75
|
+
(data / "notes.txt").write_text("Simulated data made by 'wormscore demo'. Not real images.\n")
|
|
76
|
+
with open(project / "demo_truth.csv", "w", newline="") as fh:
|
|
77
|
+
w = csv.DictWriter(fh, fieldnames=list(truth[0]))
|
|
78
|
+
w.writeheader()
|
|
79
|
+
w.writerows(truth)
|
|
80
|
+
cfg = {
|
|
81
|
+
"project_name": "Demo (simulated worms)",
|
|
82
|
+
"sources": [{"name": "simulated", "path": str(data), "original_path": str(data),
|
|
83
|
+
"genotypes": [{"match": "*control*", "genotype": "control"},
|
|
84
|
+
{"match": "*mutant*", "genotype": "mutant"}]}],
|
|
85
|
+
"inclusion": {"min_z_extent_um": 9.0},
|
|
86
|
+
"scoring": {
|
|
87
|
+
"neurons": ["Neuron 1", "Neuron 2"],
|
|
88
|
+
"neuron_questions": [{"id": "dendrite", "label": "dendrite visible?", "options": ["yes", "no", "can't tell"]}],
|
|
89
|
+
"rules": ["Simulated data. Normal: bright cell body. Dim: faint but visible. Missing: nothing there.",
|
|
90
|
+
"Score each neuron separately; the animal is the unit for statistics."],
|
|
91
|
+
},
|
|
92
|
+
"unblinding": {"min_scored_worms": 5},
|
|
93
|
+
}
|
|
94
|
+
(project / "config.json").write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
95
|
+
log(f"Demo project with {2 * n_per_group} simulated worms written to {project}")
|
|
96
|
+
return project
|