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.
- structrestore3d/__init__.py +3 -0
- structrestore3d/__main__.py +4 -0
- structrestore3d/audit_masking.py +105 -0
- structrestore3d/cli.py +64 -0
- structrestore3d/compare_v0_v1.py +213 -0
- structrestore3d/diagnose_v1_inference.py +260 -0
- structrestore3d/infer_baseline.py +88 -0
- structrestore3d/step29_registration_probe.py +244 -0
- structrestore3d/structrestore_25d.py +150 -0
- structrestore3d/train_baseline.py +75 -0
- structrestore3d/train_fullres.py +78 -0
- structrestore3d/train_v1_audited.py +130 -0
- structrestore3d/validate_25d.py +120 -0
- structrestore_3d-0.1.0a1.dist-info/METADATA +80 -0
- structrestore_3d-0.1.0a1.dist-info/RECORD +19 -0
- structrestore_3d-0.1.0a1.dist-info/WHEEL +5 -0
- structrestore_3d-0.1.0a1.dist-info/entry_points.txt +2 -0
- structrestore_3d-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- structrestore_3d-0.1.0a1.dist-info/top_level.txt +1 -0
|
@@ -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()
|