Skip to content

Commit 43f54d6

Browse files
Robbie1977claude
andcommitted
janelia: Argo loader for FlyLight H5J stacks (replaces Jenkins JaneliaWorkstationPipeline)
Same decode/warp/NRRD as Management Janelia/JaneliaWorkstationAPI (process_h5j_simple + warp_fields vendored), plus: alignment spaces mapped explicitly (anything not JRC2018 Unisex/FEMALE/MALE brain or VNC is refused, not written unwarped), every volume must match the KB template grid before it is written, writes only VFB/i/xxxx/xxxx/VFB_xxxxxxxx/volume*.nrrd atomically at 777, folders from kbw. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9Bfk6vb4qtaVZwyDzzL22
1 parent 46e8ccc commit 43f54d6

6 files changed

Lines changed: 1060 additions & 2 deletions

File tree

‎Dockerfile‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ ARG NRRDTOOLS_REF=4e7594d
77
RUN apt-get update && apt-get install -y --no-install-recommends \
88
build-essential autoconf automake libtool pkg-config git ca-certificates \
99
libjpeg-dev libtiff-dev libnifti2-dev libcjson-dev libbz2-dev liblzma-dev zlib1g-dev \
10-
imagemagick \
10+
imagemagick ffmpeg \
1111
&& rm -rf /var/lib/apt/lists/*
1212

1313
# Woolz (WlzExtFFConvert reads gzip NRRD directly, so no Fiji/TIFF step). No -march flags: the cluster's
@@ -20,12 +20,13 @@ RUN git clone https://github.com/ma-tech/Woolz.git /tmp/woolz && cd /tmp/woolz &
2020
&& /opt/MouseAtlas/bin/WlzFacts -h 2>&1 | head -1
2121

2222
# numpy < 2.4: 2.4+ wheels need x86-64-v2 (the hosts are qemu64).
23-
RUN pip install --no-cache-dir "numpy<2.4" pynrrd "scipy<1.15" pillow matplotlib pytest
23+
RUN pip install --no-cache-dir "numpy<2.4" pynrrd "scipy<1.15" pillow matplotlib pytest h5py
2424

2525
RUN git clone https://github.com/Robbie1977/NRRDtools.git /opt/NRRDtools && cd /opt/NRRDtools && git checkout ${NRRDTOOLS_REF}
2626

2727
WORKDIR /app
2828
COPY derive /app/derive
29+
COPY janelia /app/janelia
2930
COPY tests /app/tests
3031
RUN python -m pytest -q tests
3132
ENV PYTHONPATH=/app NRRDTOOLS=/opt/NRRDtools WOOLZ_BIN=/opt/MouseAtlas/bin PYTHONUNBUFFERED=1

‎janelia/__init__.py‎

Whitespace-only changes.

‎janelia/load.py‎

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
"""Load Janelia FlyLight H5J stacks into the VFB image store (replaces Jenkins JaneliaWorkstationPipeline).
2+
3+
python -m janelia.load plan --processing /processing --run-dir /out/runs/<wf> [--batch-size 25]
4+
python -m janelia.load run --processing /processing --run-dir /out/runs/<wf> --batch <name>
5+
python -m janelia.load report --run-dir /out/runs/<wf>
6+
7+
The H5J files arrive in the NAS Processing/ folder from fetch_batch.py (it needs the Janelia VPN, so it runs
8+
off-cluster). Each one becomes volume.nrrd (or volume.nrrd + volume_R/G/B.nrrd for 3-signal images) in its KB
9+
image folder, warped onto JRC2018U / JRCVNC2018U with the baked maps of warp_fields.py - the same decode, warp
10+
and NRRD as the Jenkins job. Differences from the Jenkins job:
11+
* alignment spaces are mapped explicitly (SPACES); any other space (JFRC2010, the 2017 VNC template, a
12+
'primary' alignment in an older space) is refused and listed, never written unwarped as if it were unisex;
13+
* every volume must come out on the target template's grid (KB dims) before anything is written;
14+
* writes only <data>/VFB/i/xxxx/xxxx/VFB_xxxxxxxx/volume*.nrrd (the folder may be created), atomically,
15+
files and folder 777;
16+
* folders come from the KB (kbw) by the image key, as the Jenkins job did for H5Js missing from its JSON.
17+
The H5J is deleted once its image is written (or already there); unreadable, unsupported and unmapped files stay.
18+
Derivatives (thumbnails, WLZ, OBJ) and the KB unblock follow from the nightly image-derivatives run.
19+
"""
20+
import argparse, base64, glob, json, os, re, shutil, sys, time, traceback, urllib.request
21+
import numpy as np
22+
from collections import Counter
23+
24+
HERE = os.path.dirname(os.path.abspath(__file__))
25+
if HERE not in sys.path:
26+
sys.path.insert(0, HERE) # the vendored modules import each other by bare name
27+
28+
DATA_ROOT = os.environ.get('DATA_ROOT', '/data')
29+
FOLDER_PREFIX = 'http://www.virtualflybrain.org/data/'
30+
SHARE_DIR = re.compile(r'^VFB/i/[^/]{4}/[^/]{4}/VFB_[^/]{8}$')
31+
RGB_NAMES = ('volume.nrrd', 'volume_R.nrrd', 'volume_G.nrrd', 'volume_B.nrrd')
32+
33+
# alignment_space (H5J attribute, also the REG_ suffix of the file name) -> (source, target) template symbol
34+
SPACE_RE = re.compile(r'^JRC2018_(VNC_)?(Unisex|FEMALE|MALE)(_.*)?$', re.I)
35+
SOURCE = {('', 'unisex'): 'JRC2018U', ('', 'female'): 'JRC2018F', ('', 'male'): 'JRC2018M',
36+
('vnc', 'unisex'): 'JRCVNC2018U', ('vnc', 'female'): 'JRCVNC2018F', ('vnc', 'male'): 'JRCVNC2018M'}
37+
38+
39+
def templates_for(space):
40+
m = SPACE_RE.match(space or '')
41+
if not m:
42+
return None
43+
vnc = 'vnc' if m.group(1) else ''
44+
return SOURCE[(vnc, m.group(2).lower())], ('JRCVNC2018U' if vnc else 'JRC2018U')
45+
46+
47+
class Neo:
48+
def __init__(self, url):
49+
self.url = url.rstrip('/') + '/db/neo4j/tx/commit'
50+
user, pw = os.environ.get('NEO4J_USER', 'neo4j'), os.environ.get('NEO4J_PASSWORD')
51+
if pw is None:
52+
user, _, pw = os.environ['NEO4J_AUTH'].partition('/')
53+
self.h = {'Content-Type': 'application/json', 'Accept': 'application/json',
54+
'Authorization': 'Basic ' + base64.b64encode(f'{user}:{pw}'.encode()).decode()}
55+
56+
def rows(self, statement, **params):
57+
body = json.dumps({'statements': [{'statement': statement, 'parameters': params,
58+
'resultDataContents': ['row']}]}).encode()
59+
with urllib.request.urlopen(urllib.request.Request(self.url, body, self.h), timeout=600) as r:
60+
d = json.load(r)
61+
if d.get('errors'):
62+
raise RuntimeError(d['errors'])
63+
return [x['row'] for x in d['results'][0]['data']]
64+
65+
66+
def template_specs(neo, symbols):
67+
"""{symbol: {'id', 'dims', 'voxdims'}} from the KB, same arithmetic as get_vfb_template_specs."""
68+
out = {}
69+
for sym, sf, extent, voxel in neo.rows(
70+
'UNWIND $s AS s MATCH (t:Template)<-[:depicts]-(tc:Template)-[r:in_register_with]->(tc) '
71+
'WHERE s IN t.symbol RETURN s, t.short_form, r.extent[0], r.voxel[0]', s=list(symbols)):
72+
e, v = json.loads(extent), json.loads(voxel)
73+
out[sym] = {'id': sf, 'voxdims': [v['X'], v['Y'], v['Z']],
74+
'dims': [round(e[k] - v[k]) for k in 'XYZ']}
75+
return out
76+
77+
78+
def kb_folders(neo):
79+
"""{'20x-Brain-<sample>': {template_id: [(folder, block), ...]}} - as jenkins_pipeline.kb_folder_index."""
80+
index = {}
81+
for fn, folder, block in neo.rows(
82+
"MATCH (:Individual)<-[:depicts]-(:Individual)-[r:in_register_with]->(:Template) "
83+
"WHERE exists(r.filename) AND exists(r.folder) AND r.filename[0] =~ '(20x|40x|63x)-(Brain|VNC)-[0-9]+' "
84+
"RETURN r.filename[0], r.folder[0], CASE WHEN exists(r.block) THEN r.block[0] ELSE null END"):
85+
index.setdefault(fn, {}).setdefault(folder.rstrip('/').split('/')[-1], []).append((folder, block))
86+
return index
87+
88+
89+
def choose_folder(regs, template_id):
90+
if not regs:
91+
return None, 'not registered in the KB'
92+
cands = regs.get(template_id)
93+
if cands is None:
94+
return None, f'registered to {sorted(regs)}, not {template_id}'
95+
if len(cands) == 1:
96+
return cands[0][0], None
97+
waiting = [f for f, b in cands if b == 'Missing Image']
98+
if len(waiting) == 1:
99+
return waiting[0], None
100+
return None, 'duplicate KB individuals ' + ', '.join(f.rstrip('/').split('/')[-2] for f, _ in cands)
101+
102+
103+
def h5j_key(stem):
104+
p = stem.split('-')
105+
return '-'.join(p[1:4]) if len(p) >= 4 and p[0] == 'Janelia' else None
106+
107+
108+
def rel_dir(folder):
109+
rel = folder[len(FOLDER_PREFIX):].rstrip('/') if folder.startswith(FOLDER_PREFIX) else None
110+
return rel if rel and SHARE_DIR.match(rel) else None
111+
112+
113+
def plan(a):
114+
import process_h5j_simple as p
115+
neo = Neo(a.neo4j)
116+
files = sorted(glob.glob(os.path.join(a.processing, '*.h5j')))
117+
c, skipped, items = Counter(), [], []
118+
specs = template_specs(neo, ['JRC2018U', 'JRCVNC2018U'])
119+
index = kb_folders(neo) if files else {}
120+
for path in files:
121+
stem = os.path.basename(path)[:-4]
122+
c['h5j'] += 1
123+
try:
124+
info = p.read_h5j_info(path)
125+
except Exception as e:
126+
c['unreadable'] += 1; skipped.append([stem, f'unreadable: {type(e).__name__}: {str(e)[:160]}']); continue
127+
pair = templates_for(info['alignment_space'])
128+
if not pair:
129+
c['unsupported_space'] += 1; skipped.append([stem, f"unsupported alignment space {info['alignment_space']}"]); continue
130+
target = specs[pair[1]]
131+
folder, why = choose_folder(index.get(h5j_key(stem) or '', {}), target['id'])
132+
if folder is None:
133+
c['unmapped'] += 1; skipped.append([stem, why]); continue
134+
rel = rel_dir(folder)
135+
if rel is None:
136+
c['bad_folder'] += 1; skipped.append([stem, f'folder outside VFB/i/xxxx/xxxx/VFB_xxxxxxxx: {folder}']); continue
137+
items.append({'stem': stem, 'dir': rel, 'source': pair[0], 'target': pair[1],
138+
'space': info['alignment_space'], 'signals': len(info['signal_channels'])})
139+
os.makedirs(os.path.join(a.run_dir, 'batches'), exist_ok=True)
140+
names = []
141+
for pair in sorted({(i['source'], i['target']) for i in items}):
142+
group = [i for i in items if (i['source'], i['target']) == pair]
143+
for n in range(0, len(group), a.batch_size):
144+
name = f'{pair[0]}-{n // a.batch_size:05d}'
145+
with open(os.path.join(a.run_dir, 'batches', name + '.jsonl'), 'w') as f:
146+
for i in group[n:n + a.batch_size]:
147+
f.write(json.dumps(i) + '\n')
148+
names.append(name)
149+
c['to_load'] = len(items); c['batches'] = len(names)
150+
json.dump({'counts': c, 'templates': specs, 'skipped': skipped},
151+
open(os.path.join(a.run_dir, 'plan.json'), 'w'), indent=1)
152+
print(json.dumps(dict(c)), file=sys.stderr)
153+
print(json.dumps(names))
154+
155+
156+
def atomic_nrrd(p, path, data, header):
157+
tmp = os.path.join(os.path.dirname(path), '.' + os.path.basename(path) + f'.tmp-{os.getpid()}')
158+
try:
159+
p.write_nrrd(tmp, data, header)
160+
os.chmod(tmp, 0o777)
161+
os.replace(tmp, path)
162+
finally:
163+
if os.path.exists(tmp):
164+
os.unlink(tmp)
165+
166+
167+
def load_one(p, item, fields, specs, a):
168+
path = os.path.join(a.processing, item['stem'] + '.h5j')
169+
dest = os.path.join(DATA_ROOT, item['dir'])
170+
if not SHARE_DIR.match(item['dir']):
171+
return 'refused: folder pattern'
172+
rgb = item['signals'] == 3
173+
names = RGB_NAMES if rgb else RGB_NAMES[:1]
174+
if not a.overwrite and all(os.path.exists(os.path.join(dest, n)) for n in names):
175+
os.unlink(path)
176+
return 'exists'
177+
info = p.read_h5j_info(path)
178+
sig = info['signal_channels']
179+
if not sig:
180+
return 'failed: no signal channels'
181+
if rgb:
182+
subset = sig
183+
else:
184+
k = int(a.channel) - 1 if a.channel.isdigit() and 0 < int(a.channel) <= len(sig) else 0
185+
subset = [sig[k]]
186+
target = specs[item['target']]
187+
if item['source'] != item['target']:
188+
p._field_cache[(item['source'], item['target'])] = fields[(item['source'], item['target'])]
189+
vols = p.process_h5j_file(path, process_reference=False, process_signal=True, signal_subset=subset, write=False)
190+
for name, v in vols.items():
191+
if list(v.shape) != target['dims']:
192+
return f"failed: {name} came out {list(v.shape)}, template {item['target']} is {target['dims']}"
193+
voxdims = fields[(item['source'], item['target'])].voxdims if item['source'] != item['target'] else target['voxdims']
194+
header = {'space directions': np.diag(voxdims), 'space origin': [0, 0, 0], 'kinds': ['domain'] * 3,
195+
'space dimension': 3, 'space units': ['microns'] * 3, 'encoding': 'gzip'}
196+
if not os.path.isdir(dest):
197+
os.makedirs(dest, exist_ok=True)
198+
os.chmod(dest, 0o777)
199+
if rgb:
200+
r, g, b = (vols[f'signal_{i}'] for i in range(3))
201+
for n, d in zip(RGB_NAMES, (np.maximum(np.maximum(r, g), b), r, g, b)):
202+
atomic_nrrd(p, os.path.join(dest, n), d, header)
203+
else:
204+
atomic_nrrd(p, os.path.join(dest, 'volume.nrrd'), next(iter(vols.values())), header)
205+
os.unlink(path)
206+
return 'written'
207+
208+
209+
def run(a):
210+
os.environ.setdefault('SIGNAL_THRESHOLD', str(a.signal_threshold))
211+
import process_h5j_simple as p
212+
import warp_fields
213+
items = [json.loads(l) for l in open(os.path.join(a.run_dir, 'batches', a.batch + '.jsonl'))]
214+
specs = json.load(open(os.path.join(a.run_dir, 'plan.json')))['templates']
215+
fields = {}
216+
for pair in {(i['source'], i['target']) for i in items if i['source'] != i['target']}:
217+
npy, js = warp_fields.field_paths(*pair, directory=a.warp_dir)
218+
local = os.path.join(a.scratch, os.path.basename(npy))
219+
if not os.path.exists(local): # one copy per pod onto its Longhorn scratch, then memory-mapped
220+
t = time.time()
221+
shutil.copyfile(js, local[:-4] + '.json'); shutil.copyfile(npy, local + '.part'); os.replace(local + '.part', local)
222+
print(f'copied {os.path.basename(npy)} in {time.time() - t:.0f} s', flush=True)
223+
f = warp_fields.WarpField(local)
224+
t = specs[pair[1]]
225+
if not f.matches(t['dims'], t['voxdims']):
226+
raise SystemExit(f'{local}: grid {f.dims} {f.voxdims} != KB {pair[1]} {t["dims"]} {t["voxdims"]}')
227+
fields[pair] = f
228+
out = os.path.join(a.run_dir, 'results', a.batch + '.jsonl')
229+
os.makedirs(os.path.dirname(out), exist_ok=True)
230+
done = {json.loads(l)['stem'] for l in open(out)} if os.path.exists(out) else set()
231+
with open(out, 'a') as fh:
232+
for item in items:
233+
if item['stem'] in done:
234+
continue
235+
t = time.time()
236+
try:
237+
res = load_one(p, item, fields, specs, a) if os.path.exists(
238+
os.path.join(a.processing, item['stem'] + '.h5j')) else 'gone: H5J no longer in Processing'
239+
except Exception as e:
240+
traceback.print_exc()
241+
res = f'failed: {type(e).__name__}: {str(e)[:200]}'
242+
fh.write(json.dumps({'stem': item['stem'], 'dir': item['dir'], 'result': res, 'secs': round(time.time() - t)}) + '\n')
243+
fh.flush()
244+
print(item['stem'], res, flush=True)
245+
246+
247+
def report(a):
248+
plan_ = json.load(open(os.path.join(a.run_dir, 'plan.json')))
249+
c, bad = Counter(), []
250+
for p in glob.glob(os.path.join(a.run_dir, 'results', '*.jsonl')):
251+
for l in open(p):
252+
r = json.loads(l)
253+
c[r['result'].split(':')[0]] += 1
254+
if not r['result'].startswith(('written', 'exists')):
255+
bad.append(r)
256+
s = {'plan': plan_['counts'], 'results': c, 'skipped_in_plan': plan_['skipped'][:2000], 'failed': bad[:2000]}
257+
json.dump(s, open(os.path.join(a.run_dir, 'summary.json'), 'w'), indent=1)
258+
print(json.dumps({'plan': plan_['counts'], 'results': c}, indent=1))
259+
for r in bad[:50]:
260+
print(r['stem'], r['result'])
261+
262+
263+
def main():
264+
ap = argparse.ArgumentParser()
265+
ap.add_argument('cmd', choices=['plan', 'run', 'report'])
266+
ap.add_argument('--processing', default='/processing')
267+
ap.add_argument('--run-dir', required=True)
268+
ap.add_argument('--batch')
269+
ap.add_argument('--batch-size', type=int, default=25)
270+
ap.add_argument('--warp-dir', default=os.environ.get('WARP_FIELD_DIR', '/warp'))
271+
ap.add_argument('--scratch', default='/scratch')
272+
ap.add_argument('--channel', default=os.environ.get('CHANNEL', '2'))
273+
ap.add_argument('--signal-threshold', type=int, default=int(os.environ.get('SIGNAL_THRESHOLD', '16')))
274+
ap.add_argument('--overwrite', action='store_true')
275+
ap.add_argument('--neo4j', default=os.environ.get('NEO4J_URL', 'http://kbw.kbw:7474'))
276+
a = ap.parse_args()
277+
{'plan': plan, 'run': run, 'report': report}[a.cmd](a)
278+
279+
280+
if __name__ == '__main__':
281+
main()

0 commit comments

Comments
 (0)