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

230 lines
9.8 KiB
Python

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