|
| 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