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
This commit is contained in:
u52f85420 committed 2026-11-01 10:10:33 +00:00
1 parent 9b4a29c4d2
commit 04bf90c09b
35 files changed
+2220 -2

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
"""Reference CPU implementation. Coordinates are (row, col), native pixels."""
import itertools
import math
import numpy as np
from scipy import ndimage
from scipy.stats import beta, binom
def components(mask):
mask = np.asarray(mask, dtype=bool)
if mask.ndim != 2:
raise ValueError('Expected a 2D mask')
labels, n = ndimage.label(mask, structure=np.ones((3, 3), dtype=int))
return [labels == i for i in range(1, n + 1)]
def centers(masks):
return np.asarray([np.argwhere(m).mean(0) for m in masks], float).reshape(-1, 2)
def eligibility(pred_masks, gt_masks, radius=3.0):
if radius < 0:
raise ValueError('radius must be nonnegative')
a, b = centers(pred_masks), centers(gt_masks)
return np.linalg.norm(a[:, None] - b[None, :], axis=-1) <= radius
def matching(edges):
"""Deterministic maximum cardinality bipartite matching, not greedy matching."""
edges = np.asarray(edges, bool)
if edges.ndim != 2:
raise ValueError('Expected two-dimensional eligibility')
owner = [-1] * edges.shape[1]
def augment(i, seen):
for j in np.flatnonzero(edges[i]):
if j in seen:
continue
seen.add(j)
if owner[j] < 0 or augment(owner[j], seen):
owner[j] = i
return True
return False
for i in range(edges.shape[0]):
augment(i, set())
return sorted((i, j) for j, i in enumerate(owner) if i >= 0)
def candidate_pool(prob, base_threshold=0.1, cap=64):
"""Threshold ONCE; subsequent gate thresholds delete whole fixed components."""
prob = np.asarray(prob, float)
if prob.ndim != 2 or not np.isfinite(prob).all() or np.any((prob < 0) | (prob > 1)):
raise ValueError('Expected finite 2D probabilities in [0,1]')
if not 0 <= base_threshold <= 1 or not isinstance(cap, int) or cap < 1:
raise ValueError('Invalid threshold/cap')
masks = components(prob >= base_threshold)
masks.sort(key=lambda m: -float(prob[m].max())) # stable ties: raster component order
masks = masks[:cap]
features = []
for m in masks:
p = prob[m]
ring = ndimage.binary_dilation(m, iterations=2) & ~m
contrast = float(p.mean() - prob[ring].mean()) if ring.any() else 0.0
features.append([float(p.max()), float(p.mean()), float(p.std()),
float(np.log1p(m.sum())), contrast])
return masks, np.asarray(features, np.float32).reshape(-1, 5)
def canonical_labels(pred_masks, gt_mask, radius=3.0):
gt = components(gt_mask)
pairs = matching(eligibility(pred_masks, gt, radius))
labels = np.zeros(len(pred_masks), np.float32)
areas = np.ones(len(pred_masks), np.float32)
for i, j in pairs:
labels[i] = 1
areas[i] = gt[j].sum()
return labels, areas
def image_stats(pred_masks, gt_mask, radius=3.0):
gt_mask = np.asarray(gt_mask, bool)
gt = components(gt_mask)
pairs = matching(eligibility(pred_masks, gt, radius))
tp = len(pairs)
union_pred = np.zeros_like(gt_mask)
for m in pred_masks:
if m.shape != gt_mask.shape:
raise ValueError('Shape mismatch')
union_pred |= m
intersection = int((union_pred & gt_mask).sum())
union = int((union_pred | gt_mask).sum())
fp_pixels = int((union_pred & ~gt_mask).sum())
matched = {i for i, _ in pairs}
# Separate legacy-style unmatched-component area from literal background-pixel FP.
unmatched_area = sum(int(m.sum()) for i, m in enumerate(pred_masks) if i not in matched)
return dict(tp=tp, fp=len(pred_masks)-tp, fn=len(gt)-tp,
intersection=intersection, union=union,
pred_pixels=int(union_pred.sum()), gt_pixels=int(gt_mask.sum()),
fp_pixels=fp_pixels, unmatched_area=unmatched_area,
pixels=gt_mask.size, niou=(intersection/union if union else 1.0))
def aggregate(rows):
if not rows:
raise ValueError('Cannot evaluate an empty split')
sums = {k: sum(r[k] for r in rows) for k in rows[0] if k != 'niou'}
def ratio(a, b):
return a/b if b else None
tp, fp, fn = (sums[k] for k in ('tp', 'fp', 'fn'))
return dict(images=len(rows), **sums,
IoU=ratio(sums['intersection'], sums['union']),
nIoU=float(np.mean([r['niou'] for r in rows])),
Pd=ratio(tp, tp+fn), object_F1=ratio(2*tp, 2*tp+fp+fn),
pixel_F1=ratio(2*sums['intersection'], sums['pred_pixels']+sums['gt_pixels']),
FP_per_image=fp/len(rows), P_any_FA=sum(r['fp']>0 for r in rows)/len(rows),
Fa_pixel=sums['fp_pixels']/sums['pixels'],
Fa_unmatched_area=sums['unmatched_area']/sums['pixels'])
def cp_upper(successes, n, error):
if n < 1 or not 0 < error < 1 or not 0 <= successes <= n:
raise ValueError('Invalid binomial parameters')
return 1.0 if successes == n else float(beta.ppf(1-error, successes+1, n-successes))
def calibrate(fp_counts, thresholds, cap, alpha=0.1, delta=0.05, risk='count', method='bonferroni'):
"""Finite-grid CP calibration. Rows MUST be iid held-out deployment units.
Does not infer iid sampling from filenames. Empty fallback is explicit.
"""
counts = np.asarray(fp_counts)
ts = np.asarray(thresholds, float)
if (counts.ndim != 2 or counts.shape[0] < 1 or counts.shape[1] != len(ts)
or len(ts) < 1 or not np.isfinite(counts).all()
or not np.isfinite(ts).all() or np.any((ts<0)|(ts>1))
or len(np.unique(ts)) != len(ts) or not isinstance(cap, int) or cap < 1
or np.any(counts < 0) or np.any(counts > cap) or np.any(counts != np.floor(counts))
or not 0 < delta < 1 or not 0 <= alpha <= (cap if risk=='count' else 1)
or risk not in ('count', 'any') or method not in ('bonferroni', 'fixed_sequence')):
raise ValueError('Invalid calibration input')
n, m = counts.shape
if method == 'fixed_sequence':
# Order fixed before inspecting labels; stop at the first failed test.
order = np.argsort(-ts).tolist()
tested = []
chosen = None
limit = cap if risk == 'count' else 1
for j in order:
values = counts[:, j] if risk == 'count' else (counts[:, j] > 0)
mean = float(np.mean(values))/limit
target = alpha/limit
if target == 1:
pvalue = 0.0 # Risk <= known support is deterministic.
elif target == 0 or mean >= target:
pvalue = 1.0
elif risk == 'any':
pvalue = float(binom.cdf(int(np.sum(values)), n, target))
else:
kl = ((mean*math.log(mean/target) if mean else 0.0)
+ (1-mean)*math.log((1-mean)/(1-target)))
pvalue = math.exp(-n*kl)
tested.append(dict(threshold=float(ts[j]), pvalue=pvalue))
if pvalue > delta:
break
chosen = j
return dict(method=method, risk=risk, alpha=alpha, delta=delta, n=n, cap=cap,
thresholds=ts.tolist(), tested=tested,
threshold=None if chosen is None else float(ts[chosen]),
reject_all=chosen is None,
certified_risk_budget=0.0 if chosen is None else alpha,
status='reject_all_no_nontrivial_certificate' if chosen is None else 'certified_under_iid_assumptions')
tails = cap if risk == 'count' else 1
bounds = [sum(cp_upper(int((counts[:,j]>=k).sum()), n, delta/(m*tails))
for k in range(1, tails+1)) for j in range(m)]
feasible = [j for j in range(m) if bounds[j] <= alpha]
# Lowest threshold keeps largest nested set; no label-based utility search needed.
chosen = min(feasible, key=lambda j: ts[j]) if feasible else None
return dict(method=method, risk=risk, alpha=alpha, delta=delta, n=n, cap=cap,
thresholds=ts.tolist(), upper_bounds=bounds,
threshold=None if chosen is None else float(ts[chosen]),
reject_all=chosen is None,
selected_upper_bound=0.0 if chosen is None else bounds[chosen],
status='reject_all_no_nontrivial_certificate' if chosen is None else 'certified_under_iid_assumptions')
def softplus_bound(scores, negative, tau=1.0):
if tau <= 0:
raise ValueError('tau must be positive')
return float(np.logaddexp(0, np.asarray(scores)[np.asarray(negative, bool)]/tau).sum()/math.log(2))
def exact_mwis(weights, edges):
"""Tiny-instance oracle only; exponential, capped at 20 nodes."""
w = np.asarray(weights, float)
if len(w) > 20:
raise ValueError('Oracle limited to 20 nodes')
best, selection = 0.0, []
for bits in itertools.product((0,1), repeat=len(w)):
if any(bits[i] and bits[j] for i,j in edges):
continue
score = float(w @ bits)
if score > best:
best, selection = score, [i for i,b in enumerate(bits) if b]
return best, selection
def best_path(node_scores, transition_costs):
"""Complete path through layered DAG. inf cost denotes forbidden edge."""
if not node_scores or len(transition_costs) != len(node_scores)-1:
raise ValueError('Inconsistent layers')
dp = np.asarray(node_scores[0], float)
if not len(dp):
return -math.inf, []
parents = []
for scores, costs in zip(node_scores[1:], transition_costs):
scores, costs = np.asarray(scores,float), np.asarray(costs,float)
if costs.shape != (len(dp), len(scores)) or not len(scores):
raise ValueError('Invalid transitions')
val = dp[:,None]-costs
par = val.argmax(0)
dp = scores + val[par, np.arange(len(scores))]
parents.append(par)
k = int(dp.argmax())
best = float(dp[k])
if not np.isfinite(best):
return -math.inf, []
path = [k]
for par in reversed(parents):
k = int(par[k]); path.append(k)
return best, path[::-1]