4. 阴影区域几何特征描述与筛选
几何特征的定义与计算口径
阈值分割得到的候选阴影掩膜只是“暗区”,其中既有真实目标阴影,也有礁石暗斑、底质纹理、拖鱼阴影和噪声团块。要筛出典型水下目标阴影,需要把每个连通域压缩成一组可比较的几何量。本例统一采用以下口径(像素坐标系,x 向右、y 向下,面积以像素计):
- 面积 A:连通域内前景像素总数,用于剔除过小噪声与超大背景暗区。
- 外接矩形:取最小外接矩形(旋转矩形),宽 w 为长边、高 h 为短边,长宽比 R = w / h ≥ 1。
- 矩形度:F = A / (w·h),反映区域填充外接矩形的程度,细长或分叉形状该值偏低。
- 紧致度:C = 4πA / P²,P 为周长,圆为 1,形状越不规则越小。
- 主轴方向:由二阶中心矩得到,用于判断阴影长轴是否与声呐航迹/距离向一致。
下面这张图给出从掩膜到特征向量再到规则筛选的完整数据流,并标出各特征对典型目标阴影的期望区间,便于对照实现结果。
用 OpenCV 实现特征提取
下面的脚本对单帧掩膜逐连通域计算上述特征,并按规则输出保留/剔除结果。输入为二值掩膜(前景 255),输出为特征表与筛选后的掩膜。示例阈值仅作教学起点,实际需按声呐量程与像素分辨率标定。
import cv2
import numpy as np
import math
def region_features(mask):
"""mask: uint8 二值图,前景=255。返回每个连通域的特征字典列表。"""
num, labels, stats, cents = cv2.connectedComponentsWithStats(mask, connectivity=8)
feats = []
for i in range(1, num): # 跳过背景 0
area = int(stats[i, cv2.CC_STAT_AREA])
if area < 20:
continue
comp = (labels == i).astype(np.uint8)
cnts, _ = cv2.findContours(comp, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
c = max(cnts, key=cv2.contourArea)
(cx, cy), (w, h), ang = cv2.minAreaRect(c)
w, h = max(w, h), min(w, h) # 长边为 w
rect_ratio = area / (w * h + 1e-6) # 矩形度
peri = cv2.arcLength(c, True)
compact = 4 * math.pi * area / (peri * peri + 1e-6) # 紧致度
# 主轴方向:二阶中心矩
ys, xs = np.nonzero(comp)
xs = xs - xs.mean(); ys = ys - ys.mean()
mu20 = (xs * xs).mean(); mu02 = (ys * ys).mean(); mu11 = (xs * ys).mean()
theta = 0.5 * math.atan2(2 * mu11, mu20 - mu02)
feats.append(dict(label=i, area=area, w=w, h=h,
aspect=w / (h + 1e-6), rect=rect_ratio,
compact=compact, theta=math.degrees(theta),
cx=cx, cy=cy))
return feats
def filter_shadows(feats, amin=80, amax=8000,
rmin=1.5, rmax=6.0, fmin=0.45, cmin=0.15,
track_deg=0.0, max_dev=30.0):
keep, drop = [], []
for f in feats:
dev = abs(((f["theta"] - track_deg + 90) % 180) - 90)
ok = (amin <= f["area"] <= amax and
rmin <= f["aspect"] <= rmax and
f["rect"] >= fmin and
f["compact"] >= cmin and
dev <= max_dev)
(keep if ok else drop).append(f)
return keep, drop
# 使用示例
mask = cv2.imread("shadow_mask.png", cv2.IMREAD_GRAYSCALE)
feats = region_features(mask)
keep, drop = filter_shadows(feats)
print(f"候选 {len(feats)},保留 {len(keep)},剔除 {len(drop)}")
for f in keep:
print(f"label={f['label']} A={f['area']} R={f['aspect']:.2f} "
f"F={f['rect']:.2f} C={f['compact']:.2f} θ={f['theta']:.1f}°")
规则阈值怎么定:用分布而不是拍脑袋
阈值应来自本批数据的特征分布。建议先对若干帧的候选区域做统计,观察目标阴影与干扰暗区在 R、F、C 上的分离程度,再取分位数作为初值。下表给出一组教学示例的统计口径与建议区间(数值为示例,需按实际数据重标定):
| 特征 | 典型目标阴影 | 礁石/圆斑 | 分叉纹理 | 建议初值 |
|---|---|---|---|---|
| 面积 A(像素) | 200–3000 | 50–400 | 100–1500 | [80, 8000] |
| 长宽比 R | 2.0–5.0 | 1.0–1.3 | 1.2–2.5 | [1.5, 6.0] |
| 矩形度 F | 0.60–0.85 | 0.70–0.85 | 0.20–0.40 | ≥ 0.45 |
| 紧致度 C | 0.45–0.80 | 0.75–0.90 | 0.05–0.15 | ≥ 0.15 |
| 主轴偏差 | ≤ 20° | 随机 | 随机 | ≤ 30° |
矩形度与紧致度联合使用能有效剔除分叉纹理:分叉形状外接矩形大而填充少,F 低;周长长而面积小,C 低。两者同时偏低时几乎可判定为非目标。
长宽比阈值不能过窄。目标斜航或近距成像时阴影可能接近方形,R 会降到 1.5 附近;若把下限设到 2.0 会漏检。建议先用召回率评估下限,再用精确率收紧上限。
结果验证与调参闭环
筛选效果用两个指标判断:保留区域中真实目标阴影的比例(精确率)与真实目标阴影被保留的比例(召回率)。做法是对一批帧人工标注目标阴影标签,将脚本输出的 keep 列表与标签比对。若召回偏低,优先放宽 R 下限与主轴偏差;若精确率偏低,优先提高 F 与 C 下限。每次只调一个参数并记录指标变化,避免多参数同时变动导致无法归因。
此外应检查被剔除样本的典型图像:若大量真实阴影因面积超上限被剔除,说明 Amax 与量程不匹配;若因 C 偏低被剔除,往往是分割掩膜边缘破碎,应回到预处理环节检查形态学闭运算的核尺寸,而不是继续放宽几何阈值。