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:
1 parent
9b4a29c4d2
commit
04bf90c09b
35 files changed
+2220
-2
No files matched your search
+229
@@ -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]
|
||||
Reference in new issue
Block a user