structrestore-3d 0.1.0a1__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.
@@ -0,0 +1,3 @@
1
+ """StructRestore-3D research prototype; methods are not yet validated denoisers."""
2
+
3
+ __version__ = "0.1.0a1"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only audit of StructRestore-3D masking, split, and neighbor dependence.
3
+ Run from project root. Does not train or overwrite checkpoints.
4
+ """
5
+ import argparse, csv, json, sys, random
6
+ from pathlib import Path
7
+ import numpy as np
8
+ import tifffile
9
+ import torch
10
+
11
+ def main():
12
+ ap=argparse.ArgumentParser()
13
+ ap.add_argument('--input',default='data/raw')
14
+ ap.add_argument('--script',default='scripts/structrestore_25d.py')
15
+ ap.add_argument('--checkpoint',default='results/25d_gated/model.pt')
16
+ ap.add_argument('--patch',type=int,default=128)
17
+ ap.add_argument('--samples',type=int,default=100)
18
+ ap.add_argument('--out',default='results/masking_audit')
19
+ a=ap.parse_args()
20
+ torch.set_num_threads(2)
21
+ random.seed(42); np.random.seed(42); torch.manual_seed(42)
22
+ out=Path(a.out);out.mkdir(parents=True,exist_ok=True)
23
+ files=sorted(Path(a.input).glob('slice_*.tif'))
24
+ assert len(files)>=7, 'Expected at least seven TIFF slices'
25
+ train,test=files[:-4],files[-4:]
26
+ print('Training slices:',[f.name for f in train])
27
+ print('Held-out slices:',[f.name for f in test])
28
+ print('Filename overlap:',bool(set(train)&set(test)))
29
+ print('Adjacent train/test boundary:',train[-1].name,'/',test[0].name)
30
+ print('CAUTION: Adjacent sections are not biologically independent.')
31
+ print('CAUTION: Noisy neighboring sections can reveal central pixel structure; not a strict blind-spot model.')
32
+ import importlib.util
33
+ spec=importlib.util.spec_from_file_location('audit_model',a.script)
34
+ module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
35
+ ck=torch.load(a.checkpoint,map_location='cpu',weights_only=True)
36
+ model=module.Net25D(ck['mode'],ck['width'])
37
+ model.load_state_dict(ck['state_dict']);model.eval()
38
+ print('Checkpoint mode:',ck['mode'],'width:',ck['width'])
39
+ # Check center masking: selected target pixels must differ from corrupted center in most cases.
40
+ ims=[tifffile.memmap(str(f)) for f in train]
41
+ rows=[]
42
+ with torch.inference_mode():
43
+ for i in range(a.samples):
44
+ z=random.randrange(1,len(ims)-1)
45
+ h,w=ims[z].shape
46
+ y=random.randrange(h-a.patch+1);x=random.randrange(w-a.patch+1)
47
+ arr=np.stack([np.asarray(ims[j][y:y+a.patch,x:x+a.patch],dtype=np.float32)/255
48
+ for j in (z-1,z,z+1)])
49
+ tensor=torch.from_numpy(arr.copy())[None]
50
+ corrupted,target,mask=module.corrupt_center(tensor)
51
+ m=mask.numpy()[0,0]
52
+ original=target.numpy()[0,0]
53
+ changed=(corrupted[:,1:2].numpy()[0,0]!=original)
54
+ selected=int(m.sum())
55
+ changed_count=int((changed&m).sum())
56
+ rows.append(dict(sample=i,z=z,masked=selected,changed=changed_count,
57
+ masked_unchanged=selected-changed_count,
58
+ patch_std=float(original.std()),
59
+ patch_mean=float(original.mean()),
60
+ masked_target_var=float(original[m].var()) if selected else 0.0))
61
+ with (out/'patch_audit.csv').open('w',newline='') as f:
62
+ w=csv.DictWriter(f,fieldnames=rows[0].keys());w.writeheader();w.writerows(rows)
63
+ total_mask=sum(r['masked'] for r in rows)
64
+ unchanged=sum(r['masked_unchanged'] for r in rows)
65
+ print(f'Masked target pixels unchanged after replacement: {unchanged}/{total_mask} ({unchanged/max(1,total_mask):.2%})')
66
+ print('Patch std: min/median/max:',*[round(v,5) for v in
67
+ (min(r['patch_std'] for r in rows),np.median([r['patch_std'] for r in rows]),max(r['patch_std'] for r in rows))])
68
+ # Counterfactual probe: keep corrupted center fixed, change only neighboring channels.
69
+ # Measures neighbor dependence, NOT proof of leakage or restoration quality.
70
+ z=5;h,w=ims[z].shape;y=(h-a.patch)//2;x=(w-a.patch)//2
71
+ arr=np.stack([np.asarray(ims[j][y:y+a.patch,x:x+a.patch],dtype=np.float32)/255
72
+ for j in (z-1,z,z+1)])
73
+ base=torch.from_numpy(arr.copy())[None]
74
+ corrupted,target,mask=module.corrupt_center(base)
75
+ with torch.inference_mode():
76
+ baseline=model(corrupted)
77
+ zero=corrupted.clone();zero[:,0]=0;zero[:,2]=0
78
+ same=corrupted.clone();same[:,0]=same[:,1];same[:,2]=same[:,1]
79
+ swap=corrupted.clone();swap[:,0],swap[:,2]=corrupted[:,2].clone(),corrupted[:,0].clone()
80
+ delta_zero=(model(zero)-baseline).abs().mean().item()
81
+ delta_same=(model(same)-baseline).abs().mean().item()
82
+ delta_swap=(model(swap)-baseline).abs().mean().item()
83
+ print('Neighbor counterfactual mean |output change|:')
84
+ print(' zero neighbors:',round(delta_zero,6))
85
+ print(' copy corrupted center into neighbors:',round(delta_same,6))
86
+ print(' swap neighbors:',round(delta_swap,6))
87
+ report={
88
+ 'checkpoint':str(a.checkpoint),'mode':ck['mode'],
89
+ 'train_files':[f.name for f in train],'heldout_files':[f.name for f in test],
90
+ 'filename_overlap':bool(set(train)&set(test)),
91
+ 'masked_pixels':total_mask,'masked_pixels_unchanged':unchanged,
92
+ 'masked_unchanged_fraction':unchanged/max(1,total_mask),
93
+ 'patch_std_min':min(r['patch_std'] for r in rows),
94
+ 'patch_std_median':float(np.median([r['patch_std'] for r in rows])),
95
+ 'neighbor_probe_mean_abs_change':{'zero':delta_zero,'copy_center':delta_same,'swap':delta_swap},
96
+ 'limitations':[
97
+ 'This is a code and behavior audit, not proof of no information leakage.',
98
+ 'Adjacent slice channels remain unmasked; correlated neighboring pixels can reveal central structure.',
99
+ 'Random neighboring-pixel replacement can equal the target by chance.',
100
+ 'Training/test adjacent sections are not independent specimens.',
101
+ 'No retraining or performance assessment is performed.'
102
+ ]}
103
+ (out/'audit.json').write_text(json.dumps(report,indent=2))
104
+ print('Saved:',out/'audit.json',out/'patch_audit.csv')
105
+ if __name__=='__main__': main()
structrestore3d/cli.py ADDED
@@ -0,0 +1,64 @@
1
+ """Command-line dispatcher for the existing StructRestore-3D research scripts.
2
+
3
+ All data and checkpoint paths are relative to the shell working directory.
4
+ """
5
+ from pathlib import Path
6
+ import runpy
7
+ import sys
8
+
9
+ from . import __version__
10
+
11
+ # Command -> packaged script name, positional arguments prepended for existing CLI.
12
+ COMMANDS = {
13
+ "audit-v1": ("train_v1_audited", ["audit"]),
14
+ "train-v1": ("train_v1_audited", ["train"]),
15
+ "compare": ("compare_v0_v1", []),
16
+ "diagnose": ("diagnose_v1_inference", []),
17
+ "mask-audit": ("audit_masking", []),
18
+ "train-single-v0": ("train_fullres", []),
19
+ "train-25d-v0": ("structrestore_25d", []),
20
+ "train-baseline": ("train_baseline", []),
21
+ "infer-baseline": ("infer_baseline", []),
22
+ "validate-25d": ("validate_25d", []),
23
+ "register-probe": ("step29_registration_probe", []),
24
+ }
25
+
26
+
27
+ def main():
28
+ if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help"):
29
+ print(f"StructRestore-3D {__version__} (experimental research software)")
30
+ print("Usage: structrestore3d COMMAND [OPTIONS]")
31
+ print("Commands:")
32
+ for command in COMMANDS:
33
+ print(f" {command}")
34
+ print("\nExample: structrestore3d audit-v1")
35
+ print("Run 'structrestore3d train-v1 --help' for training arguments.")
36
+ print("All image data and trained model checkpoints must be supplied separately.")
37
+ return 0
38
+ if sys.argv[1] in ("-V", "--version", "version"):
39
+ print(__version__)
40
+ return 0
41
+ command = sys.argv[1]
42
+ if command not in COMMANDS:
43
+ print(f"Unknown command: {command!r}; run structrestore3d --help", file=sys.stderr)
44
+ return 2
45
+ script, prefix = COMMANDS[command]
46
+ script_path = Path(__file__).resolve().parent / f"{script}.py"
47
+ if not script_path.is_file():
48
+ print(f"The packaged script {script} is missing. Run python tools/sync_package.py before building.", file=sys.stderr)
49
+ return 1
50
+ # Original research scripts import their siblings as top-level modules.
51
+ # Keep backward compatibility without altering the user's scripts.
52
+ package_dir = str(script_path.parent)
53
+ if package_dir not in sys.path:
54
+ sys.path.insert(0, package_dir)
55
+ arguments = sys.argv[2:]
56
+ if command == "mask-audit" and "--script" not in arguments:
57
+ arguments += ["--script", str(script_path.parent / "structrestore_25d.py")]
58
+ sys.argv = [str(script_path), *prefix, *arguments]
59
+ runpy.run_path(str(script_path), run_name="__main__")
60
+ return 0
61
+
62
+
63
+ if __name__ == "__main__":
64
+ raise SystemExit(main())
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env python3
2
+ """Step 27: controlled V0 vs V1 EM FIB-SEM added-noise stress test.
3
+
4
+ Reference: original *naturally noisy* TIFF, NOT verified clean ground truth.
5
+ V1: masked-partition prediction: each pixel is inferred ONLY while concealed
6
+ at the same pixel coordinate in all input sections, avoiding unmasked
7
+ inference mismatch. Masked pixels are given independent uniform filler.
8
+ V0: native inference exactly as originally used. Comparisons confound training
9
+ changes and inference protocols; not causal architecture ablations.
10
+ No files in existing results directories are written or altered.
11
+ """
12
+ import argparse, csv, json, sys, time
13
+ from pathlib import Path
14
+ import numpy as np
15
+ import tifffile
16
+ import torch
17
+ from scipy.ndimage import gaussian_filter, median_filter
18
+ from skimage.metrics import structural_similarity, peak_signal_noise_ratio
19
+ import matplotlib
20
+ matplotlib.use('Agg')
21
+ import matplotlib.pyplot as plt
22
+
23
+ # Loads existing project architectures without modifying source or checkpoints.
24
+ SCRIPT_DIR=Path(__file__).resolve().parent
25
+ if SCRIPT_DIR.name!='scripts' and (SCRIPT_DIR/'scripts').is_dir():
26
+ SCRIPT_DIR=SCRIPT_DIR/'scripts'
27
+ sys.path.insert(0,str(SCRIPT_DIR))
28
+ from train_fullres import TinyCNN
29
+ from structrestore_25d import Net25D
30
+ from train_v1_audited import Model as V1Model
31
+
32
+ DEFAULT_PATHS={
33
+ 'v0_single':'results/baseline_fullres/baseline.pt',
34
+ 'v0_plain':'results/25d_plain/model.pt',
35
+ 'v0_gated':'results/25d_gated/model.pt',
36
+ 'v1_single':'results/v1_single/model.pt',
37
+ 'v1_plain':'results/v1_plain/model.pt',
38
+ 'v1_gated':'results/v1_gated/model.pt',
39
+ }
40
+
41
+ def load_models(paths,dev):
42
+ models={}
43
+ for key,path in paths.items():
44
+ ck=torch.load(path,map_location='cpu',weights_only=True)
45
+ mode=key.split('_')[1]
46
+ if key.startswith('v0'):
47
+ net=TinyCNN(width=int(ck.get('width',32))) if mode=='single' else Net25D(mode,int(ck.get('width',32)))
48
+ if mode!='single' and ck.get('mode')!=mode:raise ValueError(f'V0 mode mismatch: {key}: {ck.get("mode")}')
49
+ else:
50
+ if ck.get('mode')!=mode:raise ValueError(f'V1 mode mismatch: {key}: {ck.get("mode")}')
51
+ net=V1Model(mode,int(ck.get('width',32)))
52
+ net.load_state_dict(ck['state_dict'],strict=True)
53
+ models[key]=net.to(dev).eval()
54
+ print('Loaded',key, '<-',path,flush=True)
55
+ return models
56
+
57
+ def v0_predict(model,mode,stack,dev,pad):
58
+ arr=stack[1:2] if mode=='single' else stack
59
+ with torch.inference_mode():
60
+ inp=torch.from_numpy(np.ascontiguousarray(arr[None])).to(dev)
61
+ out=model(inp).clamp(0,1)[0,0].cpu().numpy()
62
+ return out[pad:-pad,pad:-pad]
63
+
64
+ def masked_partition_v1(model,stack,dev,pad,groups,rng):
65
+ """Predict every pixel *once* under a disjoint random ~5% mask partition.
66
+
67
+ Independent uniform replacement for each masked channel matches training
68
+ mask value distribution. All channels use exactly the same boolean mask.
69
+ """
70
+ _,h,w=stack.shape
71
+ group_ids=rng.integers(0,groups,size=(h,w),dtype=np.uint8 if groups<=256 else np.int16)
72
+ output=np.empty((h-2*pad,w-2*pad),dtype=np.float32)
73
+ counts=np.zeros(output.shape,dtype=np.uint8)
74
+ base=torch.from_numpy(np.ascontiguousarray(stack[None])).to(dev)
75
+ with torch.inference_mode():
76
+ for g in range(groups):
77
+ selected=(group_ids==g)
78
+ if not np.any(selected[pad:-pad,pad:-pad]):continue
79
+ mask=torch.from_numpy(selected[None,None]).to(dev)
80
+ # Draw filler per channel independently, as in V1 training.
81
+ filler=torch.rand_like(base)
82
+ masked=torch.where(mask.expand_as(base),filler,base)
83
+ prediction=model(masked).clamp(0,1)[0,0].cpu().numpy()[pad:-pad,pad:-pad]
84
+ interior=selected[pad:-pad,pad:-pad]
85
+ output[interior]=prediction[interior]
86
+ counts[interior]+=1
87
+ if not np.all(counts==1):raise RuntimeError('Mask partitions did not cover each interior pixel exactly once')
88
+ return output
89
+
90
+ def calc_metrics(ref,img):
91
+ img=np.clip(img,0,1).astype(np.float32)
92
+ gy,gx=np.gradient(ref)
93
+ py,px=np.gradient(img)
94
+ return {'psnr':float(peak_signal_noise_ratio(ref,img,data_range=1.0)),
95
+ 'ssim':float(structural_similarity(ref,img,data_range=1.0)),
96
+ 'edge_rmse':float(np.sqrt(np.mean((gy-py)**2+(gx-px)**2)))}
97
+
98
+ def main():
99
+ p=argparse.ArgumentParser(description=__doc__)
100
+ p.add_argument('--input',default='data/raw')
101
+ p.add_argument('--out',default='results/v0_v1_comparison')
102
+ p.add_argument('--crop',type=int,default=256,help='Central output crop width; extra 16px context used')
103
+ p.add_argument('--trials',type=int,default=2)
104
+ p.add_argument('--sigmas',type=float,nargs='+',default=[0.05,0.10])
105
+ p.add_argument('--groups',type=int,default=20,help='V1 disjoint random mask groups, 20 ~ 5%% masked per pass')
106
+ p.add_argument('--seed',type=int,default=2026)
107
+ p.add_argument('--models-root',default='.',help='Project root containing model checkpoints')
108
+ args=p.parse_args()
109
+ pad=16
110
+ if args.crop<64 or args.trials<1 or args.groups<2 or args.groups>255 or any(not 0<s<0.5 for s in args.sigmas):
111
+ p.error('Require crop>=64, trials>=1, 2<=groups<=255 and 0<sigma<0.5')
112
+ files=sorted(Path(args.input).glob('slice_*.tif'))
113
+ if len(files)!=16:raise ValueError(f'Expected 16 original TIFFs; found {len(files)}')
114
+ paths={k:Path(args.models_root)/v for k,v in DEFAULT_PATHS.items()}
115
+ missing=[str(f) for f in paths.values() if not f.is_file()]
116
+ if missing:raise FileNotFoundError('Missing checkpoints: '+', '.join(missing))
117
+ out=Path(args.out)
118
+ if out.exists() and any(out.iterdir()):
119
+ raise FileExistsError(f'Output directory is not empty: {out}. Use new --out to avoid overwriting experiments.')
120
+ out.mkdir(parents=True,exist_ok=True)
121
+ dev=torch.device('mps' if torch.backends.mps.is_available() else 'cpu')
122
+ print('Device:',dev,flush=True)
123
+ models=load_models(paths,dev)
124
+ rng=np.random.default_rng(args.seed)
125
+ rows=[];figure_done=False
126
+ # Require both context sections to belong to the four held-out slices.
127
+ for z in (13,14):
128
+ ims=[tifffile.memmap(str(files[j])) for j in (z-1,z,z+1)]
129
+ h,w=ims[1].shape;side=args.crop
130
+ if min(h,w)<side+2*pad:raise ValueError('Image too small for crop and 16px halo')
131
+ centers=[(h//4,w//4),(h//2,w//2),(3*h//4,3*w//4)]
132
+ for region,(cy,cx) in enumerate(centers):
133
+ y=max(pad,min(h-side-pad,cy-side//2))
134
+ x=max(pad,min(w-side-pad,cx-side//2))
135
+ stack=np.stack([np.asarray(im[y-pad:y+side+pad,x-pad:x+side+pad],dtype=np.float32)/255 for im in ims])
136
+ reference=stack[1,pad:-pad,pad:-pad].copy()
137
+ for sigma in args.sigmas:
138
+ for trial in range(args.trials):
139
+ noisy=np.clip(stack+rng.normal(0,sigma,stack.shape).astype(np.float32),0,1)
140
+ noisy_center=noisy[1,pad:-pad,pad:-pad]
141
+ candidates={'added_noise':noisy_center,
142
+ 'gaussian_0p8':gaussian_filter(noisy_center,0.8),
143
+ 'median_3x3':median_filter(noisy_center,size=3)}
144
+ times={k:0.0 for k in candidates}
145
+ # SHARE one mask assignment and filler across the three V1 networks:
146
+ # reset torch generator before each call so masks/filler match.
147
+ # This probe is repeatable on CPU and MPS within each run.
148
+ rng_state=rng.bit_generator.state
149
+ for key,model in models.items():
150
+ start=time.perf_counter()
151
+ if key.startswith('v0'):
152
+ candidates[key]=v0_predict(model,key.split('_')[1],noisy,dev,pad)
153
+ else:
154
+ rng.bit_generator.state=rng_state
155
+ torch.manual_seed(args.seed+100000*z+1000*region+100*trial+int(round(100*sigma)))
156
+ candidates[key]=masked_partition_v1(model,noisy,dev,pad,args.groups,rng)
157
+ times[key]=time.perf_counter()-start
158
+ # Advance RNG state by exactly one V1 mask draw for next noise trial.
159
+ for name,img in candidates.items():
160
+ m=calc_metrics(reference,img)
161
+ rows.append({'slice':f'slice_{z:04d}','region':region,'sigma':sigma,
162
+ 'trial':trial,'method':name,'seconds':round(times[name],5),**m})
163
+ if not figure_done:
164
+ fig,axes=plt.subplots(3,3,figsize=(12,12),constrained_layout=True)
165
+ ordered=[('Reference (native noise)',reference),('Added noise',noisy_center),
166
+ ('Gaussian 0.8',candidates['gaussian_0p8']),
167
+ ('V0 single',candidates['v0_single']),('V0 plain',candidates['v0_plain']),
168
+ ('V0 gated',candidates['v0_gated']),('V1 single',candidates['v1_single']),
169
+ ('V1 plain',candidates['v1_plain']),('V1 gated',candidates['v1_gated'])]
170
+ for ax,(name,img) in zip(axes.ravel(),ordered):
171
+ ax.imshow(img,cmap='gray',vmin=0,vmax=1,interpolation='nearest')
172
+ ax.set_title(name);ax.axis('off')
173
+ fig.suptitle(f'Added noise sigma {sigma} | {files[z].stem} | region {region}; reference is NOT clean')
174
+ fig.savefig(out/'comparison.png',dpi=140)
175
+ plt.close(fig);figure_done=True
176
+ print('Finished',files[z].stem,flush=True)
177
+ methods=['added_noise','gaussian_0p8','median_3x3']+list(DEFAULT_PATHS)
178
+ with (out/'per_trial.csv').open('w',newline='') as f:
179
+ writer=csv.DictWriter(f,fieldnames=list(rows[0]));writer.writeheader();writer.writerows(rows)
180
+ summary=[]
181
+ for sigma in args.sigmas:
182
+ for method in methods:
183
+ subset=[r for r in rows if r['sigma']==sigma and r['method']==method]
184
+ r={'sigma':sigma,'method':method,'n_correlated_evaluations':len(subset)}
185
+ for metric in ['psnr','ssim','edge_rmse','seconds']:
186
+ vals=np.array([v[metric] for v in subset],dtype=np.float64)
187
+ r[metric+'_mean']=round(float(vals.mean()),6)
188
+ r[metric+'_sd']=round(float(vals.std(ddof=1)),6) if len(vals)>1 else 0.
189
+ summary.append(r)
190
+ with (out/'summary.csv').open('w',newline='') as f:
191
+ writer=csv.DictWriter(f,fieldnames=list(summary[0]));writer.writeheader();writer.writerows(summary)
192
+ metadata={'experiment':'V0 vs V1 / added-noise stress test','device':str(dev),'seed':args.seed,
193
+ 'train_test_boundary':'train slices 0000-0011, heldout 0012-0015',
194
+ 'evaluated_slice_centers':['slice_0013','slice_0014'],
195
+ 'slice_context_always_heldout':True,'crop':args.crop,'trials':args.trials,
196
+ 'sigmas':args.sigmas,'v1_mask_groups':args.groups,
197
+ 'checkpoints':{k:str(v) for k,v in paths.items()},
198
+ 'limitations':[
199
+ 'Original FIB-SEM reference retains unknown native noise; it is NOT clean ground truth.',
200
+ 'Metrics assess fidelity to original after extra simulated noise, not removal of native noise.',
201
+ 'V1 inference predicts every pixel once while directly masked using random disjoint partitions; V0 uses native unmasked inference.',
202
+ 'V1 changes training masking and patch sampling plus inference procedure: not a causal isolation of any one change.',
203
+ 'No independent biological specimens; slices, patches and noise replicates are correlated.',
204
+ 'Edge RMSE is gradient fidelity to noisy original, not verified membrane-preservation accuracy.',
205
+ 'Models were trained on native noisy images; extra Gaussian noise is an out-of-distribution stress test.'
206
+ ]}
207
+ (out/'metadata.json').write_text(json.dumps(metadata,indent=2))
208
+ print('\nMEAN METRICS: high PSNR/SSIM is better; low edge_RMSE is better',flush=True)
209
+ for r in summary:
210
+ print(f"sigma={r['sigma']:.2f} {r['method']:<14} PSNR={r['psnr_mean']:6.2f} SSIM={r['ssim_mean']:.4f} edge_RMSE={r['edge_rmse_mean']:.4f}",flush=True)
211
+ print('\nSaved:',out/'summary.csv',out/'per_trial.csv',out/'comparison.png',out/'metadata.json',flush=True)
212
+
213
+ if __name__=='__main__':main()
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env python3
2
+ """StructRestore-3D Step 28B: read-only diagnosis of V1 inference.
3
+
4
+ Uses existing V1 checkpoints. No training. No original files modified.
5
+ The native FIB-SEM slice is noisy, NOT verified clean ground truth.
6
+
7
+ Diagnoses:
8
+ - Unmasked vs correctly masked-partition inference on the SAME V1 checkpoint.
9
+ - Sensitivity to random mask/filler realizations (fixed input image).
10
+ - Neighboring-slice alignment as a correlation diagnostic (not proof of misregistration).
11
+ - Direct target leakage in the trained V1 checkpoint, at sampled target coordinates.
12
+ """
13
+ import argparse
14
+ import csv
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+ import tifffile
21
+ import torch
22
+ from scipy.ndimage import gaussian_filter
23
+ from skimage.metrics import peak_signal_noise_ratio, structural_similarity
24
+ import matplotlib
25
+ matplotlib.use('Agg')
26
+ import matplotlib.pyplot as plt
27
+
28
+ SCRIPT_DIR = Path(__file__).resolve().parent
29
+ if SCRIPT_DIR.name != 'scripts' and (SCRIPT_DIR/'scripts').is_dir():
30
+ SCRIPT_DIR = SCRIPT_DIR/'scripts'
31
+ sys.path.insert(0, str(SCRIPT_DIR))
32
+ from train_v1_audited import Model, strict_mask
33
+
34
+ CKPTS = {
35
+ 'single': 'results/v1_single/model.pt',
36
+ 'plain': 'results/v1_plain/model.pt',
37
+ 'gated': 'results/v1_gated/model.pt',
38
+ }
39
+
40
+
41
+ def scores(ref, img):
42
+ ref = ref.astype(np.float32, copy=False)
43
+ img = np.clip(img, 0, 1).astype(np.float32, copy=False)
44
+ gy, gx = np.gradient(ref)
45
+ py, px = np.gradient(img)
46
+ return dict(psnr=float(peak_signal_noise_ratio(ref, img, data_range=1)),
47
+ ssim=float(structural_similarity(ref, img, data_range=1)),
48
+ edge_rmse=float(np.sqrt(np.mean((gy-py)**2 + (gx-px)**2))))
49
+
50
+
51
+ def correlation(a, b):
52
+ aa = (a.astype(np.float64) - np.mean(a)).ravel()
53
+ bb = (b.astype(np.float64) - np.mean(b)).ravel()
54
+ den = np.linalg.norm(aa)*np.linalg.norm(bb)
55
+ return float(np.dot(aa, bb)/den) if den > 1e-10 else None
56
+
57
+
58
+ def translated_overlap(a, b, dy, dx):
59
+ # Compare a[y,x] to b[y+dy,x+dx], without wrapping pixels.
60
+ h, w = a.shape
61
+ y0, y1 = max(0, -dy), min(h, h-dy)
62
+ x0, x1 = max(0, -dx), min(w, w-dx)
63
+ return a[y0:y1,x0:x1], b[y0+dy:y1+dy,x0+dx:x1+dx]
64
+
65
+
66
+ def neighbor_alignment(stack, radius=3):
67
+ # Low-pass correlations are diagnostic; anatomical changes can also change correlation.
68
+ smooth = [gaussian_filter(s, 1.5) for s in stack]
69
+ result = {}
70
+ for name, neighbor in [('previous', 0), ('next', 2)]:
71
+ center, other = smooth[1], smooth[neighbor]
72
+ direct = correlation(center, other)
73
+ best = (-np.inf, 0, 0)
74
+ for dy in range(-radius, radius+1):
75
+ for dx in range(-radius, radius+1):
76
+ a,b = translated_overlap(center, other, dy, dx)
77
+ c = correlation(a,b)
78
+ if c is not None and c > best[0]:best=(c,dy,dx)
79
+ result[name] = dict(unshifted_correlation=direct,
80
+ best_correlation=float(best[0]),
81
+ best_relative_shift_pixels=dict(dy=int(best[1]),dx=int(best[2])))
82
+ return result
83
+
84
+
85
+ def infer_unmasked(model, stack_tensor, pad):
86
+ with torch.inference_mode():
87
+ pred=model(stack_tensor).clamp(0,1)[0,0].cpu().numpy()
88
+ return pred[pad:-pad,pad:-pad].copy()
89
+
90
+
91
+ def infer_masked(model, stack_tensor, pad, groups, ids, seed):
92
+ _, _, h, w = stack_tensor.shape
93
+ out=np.empty((h-2*pad,w-2*pad),dtype=np.float32)
94
+ assigned=np.zeros_like(out,dtype=np.uint8)
95
+ # Seeding at each call ensures all modes get exactly the same masks and fillers.
96
+ torch.manual_seed(int(seed))
97
+ with torch.inference_mode():
98
+ for g in range(groups):
99
+ selected=(ids==g)
100
+ inside=selected[pad:-pad,pad:-pad]
101
+ if not inside.any():continue
102
+ mask=torch.from_numpy(selected[None,None]).to(stack_tensor.device)
103
+ filler=torch.rand_like(stack_tensor)
104
+ corrupted=torch.where(mask.expand_as(stack_tensor),filler,stack_tensor)
105
+ pred=model(corrupted).clamp(0,1)[0,0].cpu().numpy()[pad:-pad,pad:-pad]
106
+ out[inside]=pred[inside]
107
+ assigned[inside]+=1
108
+ if not np.all(assigned==1):
109
+ raise RuntimeError('Partition failure: every output pixel must be predicted once')
110
+ return out
111
+
112
+
113
+ def check_trained_direct_leakage(models,dev):
114
+ torch.manual_seed(247)
115
+ x=torch.rand((1,3,40,40),device=dev,requires_grad=True)
116
+ corrupted,_,mask=strict_mask(x,rate=0.1)
117
+ coords=torch.nonzero(mask[0,0])
118
+ if len(coords)==0:raise RuntimeError('Mask test selected no pixels')
119
+ coords=coords[:5]
120
+ checks={}
121
+ for mode,model in models.items():
122
+ max_g=0.
123
+ for i, coord in enumerate(coords):
124
+ y, xx = [int(v) for v in coord]
125
+ pred=model(corrupted)
126
+ grads=torch.autograd.grad(pred[0,0,y,xx],x,retain_graph=True)[0]
127
+ val=float(grads[0,:,y,xx].abs().max().detach().cpu())
128
+ max_g=max(max_g,val)
129
+ checks[mode]={'max_direct_target_gradient':max_g,'passed':bool(max_g<1e-10)}
130
+ print(f'CHECK trained {mode}: max direct target gradient={max_g:.3g};',
131
+ 'PASS' if checks[mode]['passed'] else 'FAIL', flush=True)
132
+ if not all(x['passed'] for x in checks.values()):
133
+ raise RuntimeError('Direct-masking audit failed')
134
+ return checks
135
+
136
+
137
+ def main():
138
+ ap=argparse.ArgumentParser(description=__doc__)
139
+ ap.add_argument('--input',default='data/raw')
140
+ ap.add_argument('--out',default='results/v1_inference_diagnostic')
141
+ ap.add_argument('--models-root',default='.')
142
+ ap.add_argument('--slice-index',type=int,default=13,choices=[13,14])
143
+ ap.add_argument('--region',type=int,choices=[0,1,2],default=0,help='Match Step 27 region 0/1/2; default 0 reproduces comparison figure')
144
+ ap.add_argument('--crop',type=int,default=256)
145
+ ap.add_argument('--groups',type=int,default=20)
146
+ ap.add_argument('--repeats',type=int,default=2)
147
+ ap.add_argument('--sigma',type=float,default=0.05,help='Additional Gaussian noise on all three sections')
148
+ ap.add_argument('--seed',type=int,default=2026)
149
+ args=ap.parse_args()
150
+ if args.crop<64 or not 2<=args.groups<=255 or not 2<=args.repeats<=10 or not 0<args.sigma<0.5:
151
+ ap.error('Require crop>=64, 2<=groups<=255, 2<=repeats<=10, 0<sigma<0.5')
152
+ out=Path(args.out)
153
+ if out.exists() and any(out.iterdir()):
154
+ raise FileExistsError(f'{out} is not empty. Choose a new --out directory.')
155
+ filenames=sorted(Path(args.input).glob('slice_*.tif'))
156
+ if len(filenames)!=16:raise ValueError(f'Expected 16 original TIFFs; found {len(filenames)}')
157
+ dev=torch.device('mps' if torch.backends.mps.is_available() else 'cpu')
158
+ ckpaths={m:Path(args.models_root)/p for m,p in CKPTS.items()}
159
+ missing=[str(p) for p in ckpaths.values() if not p.exists()]
160
+ if missing:raise FileNotFoundError('Missing checkpoints: '+', '.join(missing))
161
+ models={}
162
+ for mode,path in ckpaths.items():
163
+ checkpoint=torch.load(path,map_location='cpu',weights_only=True)
164
+ if checkpoint.get('mode')!=mode:raise ValueError(f'Wrong mode in {path}')
165
+ model=Model(mode,int(checkpoint.get('width',32)))
166
+ model.load_state_dict(checkpoint['state_dict'],strict=True)
167
+ models[mode]=model.to(dev).eval()
168
+ print('Loaded:',mode,path,flush=True)
169
+ checks=check_trained_direct_leakage(models,dev)
170
+ pad=16
171
+ z=args.slice_index
172
+ ims=[tifffile.memmap(str(filenames[j])) for j in (z-1,z,z+1)]
173
+ H,W=ims[1].shape
174
+ if min(H,W)<args.crop+2*pad:raise ValueError('Crop too large for TIFF')
175
+ centers=[(H//4,W//4),(H//2,W//2),(3*H//4,3*W//4)]
176
+ cy,cx=centers[args.region]
177
+ y=max(pad,min(H-args.crop-pad,cy-args.crop//2))
178
+ x=max(pad,min(W-args.crop-pad,cx-args.crop//2))
179
+ # Read only selected crops, rather than entire full-resolution images into RAM.
180
+ stack=np.stack([np.asarray(im[y-pad:y+args.crop+pad,x-pad:x+args.crop+pad],dtype=np.float32)/255. for im in ims])
181
+ ref=stack[1,pad:-pad,pad:-pad].copy()
182
+ alignment=neighbor_alignment(stack[:,pad:-pad,pad:-pad])
183
+ print('Neighboring-section correlations:',json.dumps(alignment),flush=True)
184
+ rng=np.random.default_rng(args.seed)
185
+ noisy=np.clip(stack+rng.normal(0,args.sigma,size=stack.shape).astype(np.float32),0,1)
186
+ # Both inference modes operate on exactly the same simulated noisy input.
187
+ inp=torch.from_numpy(np.ascontiguousarray(noisy[None])).to(dev)
188
+ raw=noisy[1,pad:-pad,pad:-pad]
189
+ predictions={'added_noise':raw,'gaussian_0p8':gaussian_filter(raw,0.8)}
190
+ rows=[]; variations=[];first_masked={}
191
+ for name,img in predictions.items():
192
+ rows.append(dict(mode=name,inference='baseline',repeat=0,**scores(ref,img)))
193
+ for mode,model in models.items():
194
+ unmasked=infer_unmasked(model,inp,pad)
195
+ predictions[mode+'_unmasked']=unmasked
196
+ rows.append(dict(mode=mode,inference='unmasked',repeat=0,**scores(ref,unmasked)))
197
+ pred_runs=[]
198
+ for rep in range(args.repeats):
199
+ # Sample a fresh disjoint mask partition; fixed noisy input for all repeats.
200
+ ids=np.random.default_rng(args.seed+1009*rep).integers(0,args.groups,
201
+ size=noisy.shape[1:],dtype=np.uint8)
202
+ pred=infer_masked(model,inp,pad,args.groups,ids,args.seed+3001*rep)
203
+ rows.append(dict(mode=mode,inference='masked',repeat=rep,**scores(ref,pred)))
204
+ pred_runs.append(pred)
205
+ predictions[mode+'_masked']=pred_runs[0]
206
+ first_masked[mode]=pred_runs[0]
207
+ diffs=[np.abs(pr-pred_runs[0]) for pr in pred_runs[1:]]
208
+ differences=np.stack(diffs)
209
+ variations.append(dict(mode=mode,
210
+ mean_abs_prediction_difference=float(differences.mean()),
211
+ p95_abs_prediction_difference=float(np.percentile(differences,95)),
212
+ max_abs_prediction_difference=float(differences.max())))
213
+ out.mkdir(parents=True,exist_ok=True)
214
+ with (out/'per_repeat.csv').open('w',newline='') as f:
215
+ writer=csv.DictWriter(f,fieldnames=rows[0].keys());writer.writeheader();writer.writerows(rows)
216
+ summaries=[]
217
+ for name in ['added_noise','gaussian_0p8']+list(models):
218
+ modes=['baseline'] if name in ('added_noise','gaussian_0p8') else ['unmasked','masked']
219
+ for imode in modes:
220
+ subset=[row for row in rows if row['mode']==name and row['inference']==imode]
221
+ summaries.append(dict(method=name,inference=imode,n=len(subset),
222
+ **{k+'_mean':float(np.mean([row[k] for row in subset])) for k in ('psnr','ssim','edge_rmse')}))
223
+ with (out/'summary.csv').open('w',newline='') as f:
224
+ writer=csv.DictWriter(f,fieldnames=summaries[0].keys());writer.writeheader();writer.writerows(summaries)
225
+ with (out/'mask_variability.csv').open('w',newline='') as f:
226
+ writer=csv.DictWriter(f,fieldnames=variations[0].keys());writer.writeheader();writer.writerows(variations)
227
+ figure=[('Native noisy reference',ref),('Added noise',raw),('Gaussian 0.8',predictions['gaussian_0p8'])]
228
+ for mode in ('single','plain','gated'):
229
+ figure.extend([(mode+' unmasked',predictions[mode+'_unmasked']),
230
+ (mode+' masked',predictions[mode+'_masked'])])
231
+ fig,axes=plt.subplots(3,3,figsize=(12,12),constrained_layout=True)
232
+ for ax,(name,img) in zip(axes.ravel(),figure):
233
+ ax.imshow(img,cmap='gray',vmin=0,vmax=1,interpolation='nearest')
234
+ ax.set_title(name);ax.axis('off')
235
+ fig.suptitle(f'Inference-only diagnosis | slice_{z:04d} | sigma={args.sigma} | native reference NOT clean')
236
+ fig.savefig(out/'inference_comparison.png',dpi=140)
237
+ plt.close(fig)
238
+ report=dict(experiment='V1 same-checkpoint inference comparison',source_slice=z,region=args.region,top_left_yx=[y,x],
239
+ reference='Native noisy FIB-SEM TIFF, not clean truth',extra_gaussian_sigma=args.sigma,
240
+ groups=args.groups,repeats=args.repeats,seed=args.seed,alignment=alignment,
241
+ trained_checkpoint_direct_mask_tests=checks,
242
+ checkpoints={k:str(v) for k,v in ckpaths.items()},
243
+ limitations=[
244
+ 'The masked inference method is closer to the V1 training corruption, while unmasked inference is distribution-shifted.',
245
+ 'Masked and unmasked results test inference protocol sensitivity, not comparative denoising efficacy.',
246
+ 'The original noisy reference is NOT clean; PSNR, SSIM and edge-RMSE are fidelity measures only.',
247
+ 'Even if the direct gradient test passes, adjacent pixels/slices remain correlated.',
248
+ 'Intensity cross-correlation of adjacent sections does not distinguish misregistration from genuine structural changes.',
249
+ 'All evaluation regions are from one physical stack; not independent biological replicates.'
250
+ ])
251
+ (out/'metadata.json').write_text(json.dumps(report,indent=2))
252
+ print('\nSame V1 checkpoint; higher PSNR/SSIM and lower edge RMSE = more faithful to NATIVE noisy reference:')
253
+ for r in summaries:
254
+ print(f" {r['method']:<14} {r['inference']:<9} PSNR={r['psnr_mean']:6.2f} "
255
+ f"SSIM={r['ssim_mean']:.4f} edge_RMSE={r['edge_rmse_mean']:.4f}")
256
+ print('Mask-partition sensitivity (mean absolute difference between repeated runs):')
257
+ for r in variations:print(f" {r['mode']}: {r['mean_abs_prediction_difference']:.6f}")
258
+ print('Saved:',out/'summary.csv',out/'mask_variability.csv',out/'inference_comparison.png',flush=True)
259
+
260
+ if __name__=='__main__':main()