Files
u52f85420 04bf90c09b feat: 导入 IRSTD 研究代码与文档(小目标检测)
- 新增 irstd 核心包、scripts、tests、configs
- 新增研究文档:literature_review / mathematical_analysis / novelty_matrix / experiment_plan / research_decision / research_log / manuscript_outline
- 新增 evidence、results 数据与审计产物
- 新增 pyproject.toml、requirements、.gitignore
2026-11-01 10:10:33 +00:00

79 lines
5.0 KiB
Python

"""Calibrate/evaluate frozen candidates. No training or test threshold selection."""
import argparse,json,hashlib
from pathlib import Path
import numpy as np
from irstd.core import candidate_pool,image_stats,aggregate,calibrate,components
from irstd.data import read_manifest,read_pair,sha
def main():
p=argparse.ArgumentParser();p.add_argument('--manifest',required=True);p.add_argument('--predictions',required=True)
p.add_argument('--out',required=True);p.add_argument('--gate');p.add_argument('--split',choices=['dev','cal','test'],default='test')
p.add_argument('--calibrate',action='store_true');p.add_argument('--certificate');p.add_argument('--threshold',type=float,default=.5)
p.add_argument('--base-threshold',type=float,default=.1);p.add_argument('--cap',type=int,default=64);p.add_argument('--radius',type=float,default=3.)
p.add_argument('--alpha',type=float,default=.1);p.add_argument('--delta',type=float,default=.05)
p.add_argument('--risk',choices=['count','any'],default='count');p.add_argument('--pixel-baseline',action='store_true')
p.add_argument('--calibration-method',choices=['bonferroni','fixed_sequence'],default='bonferroni')
a=p.parse_args()
if a.calibrate and (a.split!='cal' or a.pixel_baseline or a.certificate):p.error('Calibration requires cal split, fixed candidates and no certificate input')
if a.pixel_baseline and (a.gate or a.certificate):p.error('Pixel baseline cannot use gate/candidate certificate')
if not 0<=a.threshold<=1:p.error('threshold outside [0,1]')
rows=read_manifest(a.manifest);root=Path(a.predictions)
provenance_path=root/'provenance.json'
if not provenance_path.exists():raise ValueError('Missing prediction provenance.json')
provenance=json.loads(provenance_path.read_text())
if provenance.get('manifest_sha256')!=sha(a.manifest):raise ValueError('Prediction manifest differs')
pipeline=dict(manifest_sha256=sha(a.manifest),provenance=provenance,
base_threshold=a.base_threshold,cap=a.cap,radius=a.radius,
gate_sha256=sha(a.gate) if a.gate else None,
prediction_hashes={r['id']:sha(root/(r['id']+'.npy')) for r in rows},
input_hashes={r['id']:dict(image=sha(r['image']),mask=sha(r['mask'])) for r in rows},
source_hashes={name:sha(Path(__file__).resolve().parents[1]/name) for name in ['irstd/core.py','irstd/models.py','scripts/evaluate.py']},
evaluation_version=1)
fingerprint=hashlib.sha256(json.dumps(pipeline,sort_keys=True).encode()).hexdigest()
gate=None
if a.gate:
import torch
from irstd.models import CandidateGate
ck=torch.load(a.gate,map_location='cpu',weights_only=True);cfg=ck['config']
for k in ('cap','base_threshold','radius'):
if cfg[k]!=getattr(a,k):raise ValueError('Gate pipeline differs: '+k)
if cfg['manifest_sha256']!=sha(a.manifest):raise ValueError('Gate manifest differs')
gate=CandidateGate();gate.load_state_dict(ck['state_dict']);gate.eval()
thresholds=np.linspace(0,1,101).tolist() if a.calibrate else [a.threshold]
reject_all=False
if a.certificate:
cert=json.loads(Path(a.certificate).read_text())
if cert['pipeline_fingerprint']!=fingerprint:raise ValueError('Certificate invalidated by changed pipeline/data')
reject_all=cert['reject_all'];thresholds=[cert['threshold'] if not reject_all else 1.]
selected=[r for r in rows if r['split']==a.split]
if not selected:raise ValueError('Empty split')
fp=[];stats=[];per_image=[]
for r in selected:
prob=np.load(root/(r['id']+'.npy'),allow_pickle=False);_,gt=read_pair(r)
if prob.shape!=gt.shape:raise ValueError('Prediction shape mismatch')
masks,features=candidate_pool(prob,a.base_threshold,a.cap)
scores=features[:,0]
if gate is not None and len(features):
with torch.no_grad():scores=gate(torch.from_numpy(features)).sigmoid().numpy()
row_stats=[]
for t in thresholds:
keep=[] if reject_all else [m for m,s in zip(masks,scores) if s>=t]
if a.pixel_baseline:keep=components(prob>=t)
row_stats.append(image_stats(keep,gt,a.radius))
fp.append([s['fp'] for s in row_stats]);stats.append(row_stats[0])
per_image.append(dict(id=r['id'],group=r['group'],**row_stats[0]))
if a.calibrate:
result=calibrate(fp,thresholds,a.cap,a.alpha,a.delta,a.risk,a.calibration_method)
result.update(pipeline_fingerprint=fingerprint,pipeline=pipeline,
calibration_ids=[r['id'] for r in selected],
calibration_groups=len(set(r['group'] for r in selected)),
assumption_notice='Conditional on iid images; group labels alone do not verify independence. Do not pool adjacent video frames.')
else:
result=dict(metrics=aggregate(stats),per_image=per_image,pipeline_fingerprint=fingerprint,
split=a.split,threshold=None if reject_all else thresholds[0],reject_all=reject_all,
certificate_used=a.certificate,mode='pixel' if a.pixel_baseline else 'fixed_candidate')
dest=Path(a.out);dest.parent.mkdir(parents=True,exist_ok=True)
if dest.exists():raise FileExistsError(dest)
dest.write_text(json.dumps(result,indent=2,allow_nan=False));print(dest)
if __name__=='__main__':main()