干图像算法这行的朋友,早晚都会碰到一个看着特别简单、上手才发现全是细节的任务:海天线检测。我第一次做这个是在一个岸基监控的改进项目里,甲方要求把画面里的天空区域和海面区域自动分开,好给后面目标检测省计算量。当时第一反应就是阈值分割加直线检测,于是很自然地选了Otsu和Hough变换这套组合。但真正把算法跑起来之后,我才发现这条“海天线”远没有肉眼看上去那么听话——云雾、浪花、反光、船体遮挡,每一种情况都能让算法在边缘疯狂试探。这篇就把我在这类项目里积累的完整思路、参数设计、代码框架和踩坑记录整理出来,给正打算做海天线检测的朋友当个参考。
1. 海天线检测这件事,难点从来都不只是"找一条线"
1.1 哪些场景真的需要海天线
海天线检测(也叫海岸线检测、天海分界线检测)在很多视觉系统里都是前置关键步骤。最常见的几种需求:
- 岸基光电监控:把画面分成天空、海面两个区域,目标检测只在海面区域跑,可以大幅减少云层引起的误报,也能缩小搜索范围。
- 船载或艇载摄像稳定系统:当船体晃动导致画面倾斜时,海天线可以作为水平基准,用来估计滚转角度。
- 无人艇、无人机的海面目标识别:先定位海天线,再把图像分成不同波段分别处理,比如天空区域做云层抑制,海面区域做暗目标增强。
- 红外或微光夜视场景:这类图像纹理细节少,海天线往往是少数稳定可用的结构特征,会被用来做帧间配准或相机姿态估计。
说白了,海天线不是一个“发论文式”的孤立任务,它经常要充当整个系统的地基。地基斜了,上面所有模块都会跟着出问题。
1.2 看着明显的图,算法为什么会翻车
人眼去看一张晴天海景图,会觉得天空和海面分界极其清楚。但这种直觉放到像素层面往往站不住脚。
第一,天空和海面的灰度差异不是恒定的。晴朗正午可能相差很大,但阴天、雾天、逆光时两者灰度可能非常接近,直方图上可能只有一个峰。第二,海面本身不是均匀的,有波浪亮纹、白帽泡沫、镜面反光;天空也不干净,有云层、渐变、大气散射。第三,船、岛礁、岸线建筑会把海天线截断,导致完整直线根本不存在。第四,传感器噪声和压缩编码会引入伪边缘,让边缘检测结果变成一团乱麻。
所以这个任务的本质不是“能不能找出一条线”,而是“在干扰那么多的前提下,找出一条空间上连续、时间上稳定、物理上可信的线”。理解了这点,再去选Otsu和Hough,心里就有数了。
2. 用Otsu做海天分割:双峰假设与它的边界
2.1 类间方差最大化到底在做什么
Otsu(大津法)做的事情,简单说就是在灰度直方图上找一个阈值,把像素分成前景和背景两类,使得这两类之间的方差最大。数学上就是最大化类间方差:
σ_b² = ω₀ω₁(μ₀ − μ₁)²
其中ω₀、ω₁是两类像素占比,μ₀、μ₁是两类的灰度均值。这个公式的本质是:如果阈值切得好,两类的中心应该离得足够远,同时每类内部的像素别太散。
我最初用Otsu时有个误解,以为它必须精确地把“天空”和“海面”分开。其实Otsu并不关心谁是前景谁是背景,它只负责“把直方图切成两坨尽量分离的分布”。在海天线场景里,如果没有云层干扰,天空整体偏亮、海面整体偏暗,Otsu找到的阈值通常正好落在海天线附近。这个阈值分割后的二值图,就变成了一幅“天空一块、海面一块”的粗划分图。
2.2 分割结果如何喂给后续的直线检测
很多教程会把Otsu放在预处理阶段,然后直接去跑Canny加Hough。但我在项目里的做法是:Otsu分割后,先把二值图做形态学处理,再从中提取边界,最后才交给Hough。
原因很简单。直接用Canny从原始灰度图上找边缘,海面波浪的纹理也会产生大量边缘点;而这些边缘点到了Hough空间里,很可能聚成一条伪直线。但如果先通过Otsu把图像分成两大块,再在二值图的边界上做Hough,相当于用“区域一致性”过滤掉了大部分颗粒噪声,只保留了“天空块”跟“海面块”之间的那条主要分界。
实际操作时我会在二值图上使用开运算,核选一个稍微偏横向的矩形,比如3×5。这么做有两个考虑:一是去掉海面区域离散的小白点,二是因为海天线本身接近水平,横向核不容易把它抹掉。如果选方形核甚至竖长核,就可能在边界处留下锯齿,给后续Hough投票引入干扰。
2.3 Otsu的失败模式:双峰假设没了怎么办
Otsu最怕的就是直方图只有一个峰,典型场景是海雾弥漫或者阴天。此时天空和海面灰度非常接近,直方图呈现出一种近似单峰偏斜的形状,Otsu就会强行在某个毫无物理意义的位置切开,分割结果会乱七八糟。另一个容易出问题的场景是强烈反光,海面某一片区域亮度跟云层一样高,Otsu可能把“高亮海面”和“天空”划成一类,导致分割边界跑到海面中间去。
所以必须清醒认识:Otsu只是这套算法链上的一环,而不是万能的分割器。它负责提供一个“候选边界”,后面的Hough参数约束、直线筛选和时序滤波才是兜底的手段。如果你测试的场景里海雾占比很高,那要么增加一个基于纹理特征的分类器去判断当前画面是否适合Otsu,要么直接用灰度梯度峰值定位代替Otsu,这我后面会讲。
3. Hough变换找直线:投票机制与针对海天线的参数收编
3.1 图像空间到参数空间的映射
Hough变换的核心思想,是把图像空间里的一条直线映射成参数空间里的一个点。对于直线y = kx + b,直接用斜率和截距表示有个问题:垂直直线是无穷大斜率,没法处理。所以OpenCV里的标准Hough变换用的是极坐标形式:
ρ = x·cosθ + y·sinθ
其中ρ是原点到直线的垂直距离,θ是这条垂线与x轴的夹角。每条直线对应一个(ρ, θ)对。算法遍历边缘点,对每个点,在所有可能的方向θ上算出对应的ρ,然后在累加器里投票。最后票数超过阈值的那组(ρ, θ),就代表“有好多边缘点都落在这条直线上”。
这里有个值得说的直觉:Hough不是“拟合”出一条线,而是“选举”出一条线。每个边缘点都是选民,每个可能的(ρ, θ)都是候选人,票数最高且超过门槛的候选人胜出。所以它对部分遮挡、断线非常宽容——只要海天线有足够多肉眼可见的边缘点,它就能被选出来。
3.2 角度范围、投票阈值和分辨率的选择
通用教程会让你在全角度范围0~180度内做Hough变换,但在海天线这个具体任务里,这样做纯属浪费算力,还会引入干扰。海天线在画面里通常是接近水平的,即使船体摇摆或相机安装有倾角,其角度一般也不会偏离水平超过±15度。所以我把θ范围直接限制在一个窄带里,比如80°~100°(以标准OpenCV角度定义为参考),或者更宽裕一点的70°~110°,视安装环境和陀螺仪数据而定。
这个约束非常关键。云层边缘常常是斜的,岛礁轮廓更是各种角度都有;如果把全角度都开放,那些杂散边缘很可能聚集成票数不低的伪直线。角度收编之后,大量干扰从源头就被排除了。
投票阈值怎么选?我的经验是:不要直接拿OpenCV的阈值参数乱试,先看一下提取出的边缘点总数。一般来说,海天线如果贯穿画面宽度,能贡献的边缘点数量级在几百到几千。阈值设成边缘点总数的1%到2%左右比较合理。比如边缘点有5000个,阈值可以设在80到100。太小会选出碎片线,太大又可能漏检。
ρ和θ的分辨率也影响巨大。θ分辨率我通常设0.5度,ρ分辨率设1像素。分辨率设太高(比如θ=0.1度)不仅慢,还容易把一条线上原本连续的点拆到邻近格子,导致投票不集中;分辨率设太低(比如ρ=2像素)又会把相邻的平行线混在一起,产生“平均化”的偏差。
3.3 多条直线怎么合并成一条海天线
单帧图像里,Hough可能同时选出好几条高票直线。它们往往来自:云层底部边界、海天线本体、船只甲板边缘、海面波浪带等。我的合并策略分两步:
第一步,按角度和截距聚类。把所有候选直线按θ聚成小组,同一组内θ差不超过2度,ρ差不超过15像素。这样基本能归类出“同一条物理直线”的不同片段。
第二步,在每组内按“票数×长度”打分,选一个代表。为什么不光看票数?因为有些边缘点密集但很短(比如船体的护栏),票数虽高但覆盖范围不够,作为海天线使用会带来很大的外推误差。长度可以从直线与图像边界的交点算出来,或者用HoughLinesP统计线段的实际覆盖范围来近似。
合并完成之后,最终的(ρ, θ)还要结合一点物理先验:海天线在画面中的位置应当在图像上部或中部,不应该神秘地出现在图像底部。如果选出的直线中心点落在图像下1/3区域,基本可以直接判定是误检。
4. 一套能落地的流水线:从输入图像到一条能用的海天线
4.1 完整流程与每步的设计意图
我最后落地到项目里的流程,大致分七步:
- 输入图像转为灰度图,必要时先降采样到宽度640~960像素,提高处理速度。
- 用高斯滤波或双边滤波轻微去噪。双边滤波在保留海天线边缘的同时抑制海面纹理,效果比高斯更好,但要注意参数不能太强,否则边界会变得模糊。
- 做Otsu阈值分割,得到前景/背景二值图。然后用3×5矩形核对二值图做开运算,去除海面白色噪点。
- 从处理后的二值图中提取轮廓或直接用Canny提取边缘。如果直接用Canny,要对二值图先做一次膨胀,否则Canny会把二值边界的里外两侧都检出,产生双线。
- 限定ROI区域,通常是图像中间宽度、上部到下部2/3高度范围,排除近景船体、岸线这类干扰。
- 在ROI上做HoughLines,角度范围限制在水平方向±15度。
- 对候选直线做角度-截距聚类,选出置信度最高的那条作为海天线。
每一步看上去都很简单,但每一步都在消减一种特定的干扰。Otsu消减纹理噪声,开运算消减小目标,ROI消减前景干扰,角度限定消减斜向云层。真正到实践里你会发现,哪一个环节被省略,最后选出的直线都会“飘”。
4.2 核心代码实现(Python + OpenCV)
下面这段代码是这个流程最精简的可运行版本,也是我最初在项目里验证方案时用的雏形。它不追求极致性能,但逻辑完整性足够。
import cv2 import numpy as np def detect_sea_sky_line(img): # 1. 灰度化与缩放 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) h, w = gray.shape if w > 960: scale = 960.0 / w gray = cv2.resize(gray, (960, int(h * scale))) h, w = gray.shape # 2. 双边滤波,保留边缘、抑制纹理 gray = cv2.bilateralFilter(gray, d=7, sigmaColor=25, sigmaSpace=25) # 3. Otsu分割 _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 4. 开运算去噪,核偏横向 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 3)) binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) # 5. 提取二值图边缘 edges = cv2.Canny(binary, 50, 150) edges = cv2.dilate(edges, np.ones((3, 3), np.uint8)) # 让边缘更连续 # 6. 限定ROI,排除画面下方近景干扰 roi = np.zeros_like(edges) roi_top = int(h * 0.05) roi_bottom = int(h * 0.7) roi[roi_top:roi_bottom, :] = edges[roi_top:roi_bottom, :] # 7. Hough变换,限定接近水平的角度 lines = cv2.HoughLines(roi, rho=1, theta=np.deg2rad(0.5), threshold=80) if lines is None: return None # 8. 筛选与合并候选直线 candidates = [] for line in lines: rho, theta = line[0] angle_deg = np.rad2deg(theta) # 只保留接近水平方向的直线,角度范围可调 if abs(angle_deg - 90.0) > 15.0: continue candidates.append((rho, theta)) if not candidates: return None # 简单的按角度聚类,取每类的平均 candidates.sort(key=lambda x: x[1]) groups = [] current_group = [candidates[0]] for rho, theta in candidates[1:]: if abs(theta - current_group[-1][1]) < np.deg2rad(2): current_group.append((rho, theta)) else: groups.append(current_group) current_group = [(rho, theta)] groups.append(current_group) best_group = max(groups, key=lambda g: len(g)) rho = np.mean([item[0] for item in best_group]) theta = np.mean([item[1] for item in best_group]) # 9. 计算两个端点用于绘制 a = np.cos(theta) b = np.sin(theta) x0 = a * rho y0 = b * rho pt1 = (int(x0 - w * (-b)), int(y0 - w * a)) pt2 = (int(x0 + w * (-b)), int(y0 + w * a)) return pt1, pt2, (rho, theta) img = cv2.imread("sea_sky.jpg") result = detect_sea_sky_line(img) if result: pt1, pt2, _ = result cv2.line(img, pt1, pt2, (0, 0, 255), 2) cv2.imwrite("result.jpg", img)这里面有个细节很容易忽略:Canny之前我把二值图膨胀了一次。因为二值图边缘本来就锐利,Canny会同时检测到前景边界和背景边界,形成平行双线。膨胀后边缘会轻微拓宽,Canny输出的边缘位置相对稳定,Hough投票更集中。
4.3 单帧结果到多帧时序滤波
在视频流里,单帧检测结果往往有明显的抖动。海天线在物理世界位置变化缓慢,如果画面没有大幅度俯仰或滚转,它的ρ和θ应该是平滑缓变的。所以我用滑动窗口或简单的一阶低通滤波来处理:
θ_smooth = α·θ_current + (1−α)·θ_prev
ρ_smooth = α·ρ_current + (1−α)·ρ_prev
α通常在0.2到0.4之间。视频帧率25fps时,α=0.3大约对应3到5帧的有效响应时间,既能抑制抖动,又不会让结果跟不上画面变化。实测中这个平滑在风浪天特别有效——如果完全不做平滑,输出的海天线会在海面波浪边缘之间来回跳。
润色一点:平滑应该在坐标域做,而不是角度域直接平均。因为角度在接近0度和360度时存在绕回问题,虽然海天线被限制在水平附近,不太会碰到这种极端情况,但养成这个习惯没坏处。
5. 实测翻车与排查:三个我真实遇到的失效场景
5.1 场景一:海天灰度接近,Otsu分割错乱
某次我在阴天环境做采集测试,画面上方是灰白云层,下方是灰暗海水,直方图几乎没有双峰。Otsu切出的二值图里,天空和海面被胡乱分成碎片,导致后续Hough找出来的直线位置忽上忽下,完全不能用。
排查的时候我是这样链式分析的:先输出Otsu的二值图和灰度直方图,发现阈值被定在了一个“中间灰”的位置;再输出边缘图,发现边缘点大量聚集在中部偏上的云层底部;最后Hough选取的直线就是云底边缘。所以问题的根不在Hough,而在Otsu。
解决方案分两条路走。一条路是加一个“雾天检测”前置判断:计算直方图的峰谷比或者类间方差实际值,如果类间方差过小,说明双峰不成立,那么放弃Otsu,直接改用梯度峰值的方案。另一条路是保留Otsu但把ROI进一步缩小到图像中部区域,利用“海天线在画面中间区域”的先验来降低云层影响。两种方案我最后都实现了,以第一个为主,因为它是从根上解决问题。
5.2 场景二:云层边缘被当成海天线
晴天最容易翻的车,是天空中有横向拉得很长的云层底部,它和海天线一样接近水平,而且对比度高。Hough投票时云层底部边缘点数量甚至可能超过真正的海天线,毕竟云底往往更锐利。
我的排查过程:把Hough所有候选直线的端点可视化画出来,发现被选中的那条线在图像上方位移过大,和人工标注的海天线位置差了四五十个像素。进一步检查二值图发现,Otsu其实分割得没问题,海天线区域也有完整的边缘,只是云层的投票更集中。
解决办法靠两个配合。第一,添加一条或多条“区域惩罚”:海天线的置信度不只取决于边缘投票数,还要考虑直线下方像素的灰度方差。真正的海天线下方是海面纹理,灰度方差大;云层边缘下方常是天空渐变区,灰度方差小。这个特征在晴天场景下区分度很高。第二,限制候选直线的ρ范围,利用上一帧的结果只在附近一个带状区域(比如±30像素)搜索,这样即使云层投票多,也不会被选中。这就是时空连续性先验的工程化落地。
5.3 场景三:分辨率一变,参数全部失效
有次我把算法从1080P的采集画面迁移到另一个项目的512×384红外小图上,结果海天线检测率直线下降。查了半天发现是Hough阈值和形态学核没跟着缩放:1080P下阈值80在512宽图像上变得过严,3×5的核在新分辨率下也显得太大,把本来就不宽的海天线边缘抹掉了一部分。
这个问题特别容易忽略,因为代码里没有任何报错,输出结果就是“找不到线”。现在我的做法是:所有和尺寸相关的参数都按图像宽度动态计算。Hough阈值设为边缘点总数的1%~2%,形态学核设为宽度的0.5%~1%,高斯滤波核保持固定但尺寸按宽度缩放。参数相对化以后,从1080P切到任何分辨率都不用再手工调一轮。
5.4 排查方法论:先把中间结果可视化,再谈调参
几次翻车经历让我养成一个习惯:任何一行参数改动之前,先把以下四个中间结果可视化保存下来:
- Otsu二值图:看分割是否符合“上天下海”的直觉。
- 形态学后的二值图:看去噪是否过度或不足。
- 边缘图:看海天线在哪个位置、有没有双线、有没有断裂。
- Hough候选直线叠加图:看每条候选线的来源。
这四张图一摆出来,算法在哪一步开始出错就一目了然。我见过太多朋友抱着Hough的threshold从50调到200,调了半天不知道问题其实是Otsu那边的核太大把边界抹平了。可视化中间结果花不了多少时间,但能省下几晚上的空转。
6. 效果评估、参数鲁棒性与可选的后手
6.1 精度和稳定性怎么量化
海天线检测的“准不准”不能靠肉眼感觉,必须量化。我在项目里用的是三个指标:
- 角度误差:预测海天线与人工标注之间的夹角,单位度。晴天场景我要求平均角度误差小于1度,阴天小于2度。
- 截距误差:在图像垂直方向上,预测线与标注线的平均像素距离。这个指标比角度要更敏感,一个小的角度偏差在图像边缘会被放大成几十像素的位置偏差。
- 连续帧稳定度:相邻帧之间预测线的ρ和θ变化量标准差。抖动过大的系统说明平滑不够。
做法是取十几张覆盖不同光照、不同海况的图,手工标出海天线两个端点,然后写脚本自动比对。这个标注工作很机械但值得做,没有量化就没有后续优化的方向。
6.2 鲁棒性的进一步加固思路
如果发现当前方案在特定场景下还是不稳,我建议按下面两条路线打补丁:
- 对于中远海、低纹理场景,可以在Hough之后加一个最小二乘拟合验证。Hough给出一个粗直线估计后,用该直线附近的边缘点做亚像素精度的直线拟合,能有效缓解Hough离散化带来的截距误差。
- 对于极度复杂的近岸场景,单一Otsu加Hough确实可能到极限了。可以引入基于纹理方向场的分类器,或者干脆用一个轻量分割网络来输出海天线概率图,再从概率图上找分界线。但轻量网络需要考虑标注成本、误检责任归属和算力预算,我一般不轻易上,除非Otsu加Hough已经被证明确实压不住。
6.3 我的一些工程体会
做了几个类似项目之后,我最大的感受是:这类视觉任务的成败,八成不取决于算法多高级,而取决于你对输入数据的脾气摸得有多清。Otsu加Hough是一对经典的组合拳,各司其职又互相弥补——Otsu负责“分割出区域”,Hough负责“从边界里选中那条编辑想要的线”。但每一拳都有命中范围,你必须在旁边设计好约束条件和候选筛选机制,让算法在“看不懂”的时候做保守判断,而不是瞎猜。
另外,代码里一定要把每个中间状态都留成可调试的输出接口。我后来把核心函数改造成了类似“若debug=True则保存中间图片”的结构,无论是接手新项目还是排查旧问题,都省很多事。如果你正准备在监控、无人船或者影像增强项目里做海天线检测,希望这套流程和踩坑记录能帮你少走几步弯路。