图像算术运算原理与安全实践:OpenCV加减乘除的像素级控制
1. 项目概述为什么图像的加减乘除不是数学题而是视觉控制的核心开关“数字图像处理——图像的算术运算Python实现”这个标题乍看像一门本科实验课的作业名但如果你真把它当成“用OpenCV调几个函数”的入门练习那大概率会在后续做图像融合、运动目标增强、医学影像配准甚至工业缺陷检测时卡在第一关——连最基础的像素级控制权都没拿稳。我带过不少刚从编程课转来学图像处理的开发者他们能熟练写递归、调API却常在调试一张叠加后的热成像图时反复问“为什么两张图相加后全变黑了”“为什么mask乘上去反而把目标区域变透明了”——问题不在代码语法而在对算术运算背后的数据语义与数值边界逻辑缺乏体感。这组运算加、减、乘、除是数字图像处理中最早被定义、最底层、也最容易被低估的一类操作。它不涉及傅里叶变换的频域玄学也不依赖卷积核的参数调优但它直接决定了你能否安全地把红外图像和可见光图像对齐叠加能否从连续帧中稳定提取运动物体能否在保留原始纹理的前提下给X光片增强病灶对比度。它的核心不是“怎么算”而是“在哪种数据类型下算”“算完结果落在哪个合法区间”“溢出或截断时人眼看到的是什么”。比如cv2.add(img1, img2)和img1 img2在NumPy中行为完全不同前者自动做饱和裁剪25510→255后者直接溢出变025510→9因uint8溢出。这种差异在调试阶段可能让你花三小时找bug而根源只是一行类型声明没写对。适合谁读如果你正面临这些场景需要把多光谱图像合成一张伪彩色图想用差分法检测流水线上零件是否到位正在复现一篇论文里“将掩膜与原图逐像素相乘以提取ROI”的步骤却得到全黑结果或者只是想搞懂为什么Photoshop里“线性 Dodge添加”模式和“正常”模式叠加效果天差地别——那么这篇内容就是为你写的。它不讲抽象公式只讲你在Jupyter里敲下每一行代码时内存里到底发生了什么以及为什么那样做才不会让图像“突然消失”。2. 核心原理拆解算术运算不是数学搬运工而是像素世界的交通管制员2.1 图像数据的本质别再把图像当“图片”要当“二维数组数据契约”很多人第一次用cv2.imread()读图后打印img.dtype看到uint8就以为“哦0-255的整数”然后直接拿去算加法。这是所有坑的起点。图像在内存中从来不是“一张画”而是一个严格遵循数据类型契约的二维或三维NumPy数组。这个契约包含三重约束数值范围约束uint8强制限定每个像素值必须是0~255之间的无符号整数运算规则约束NumPy对uint8数组执行操作时底层用的是模256加法2551025521而非数学加法存储精度约束uint8每个像素只占1字节无法表示小数或负数任何超出范围的中间结果都会被强制截断或回绕。举个实操例子你想把一张灰度图亮度整体提升30直觉写img 30。如果原图有大量230以上的像素23030260但uint8存不下260于是变成260 % 256 4——原本亮部区域瞬间变成一片死黑。而cv2.add(img, np.full_like(img, 30))会自动做饱和运算23030→25525530→255结果是平滑提亮。这不是OpenCV“更聪明”而是它明确遵守了图像处理领域的安全运算契约输出值必须落在[0, 255]内且不破坏原有视觉结构。提示永远不要对uint8图像直接使用 - * /等Python原生运算符。它们服务于通用计算不服务于视觉保真。真正的图像算术运算必须在明确的数据类型上下文中进行——要么用cv2.add/sub/multiply/divide系列函数内置饱和/缩放逻辑要么手动转换为float64进行计算再安全映射回uint8。2.2 四类运算的视觉语义加法是“能量叠加”减法是“差异探测”乘法是“区域筛选”除法是“对比归一化”每种算术运算在图像领域都有不可替代的物理意义理解这点才能避免“为了用而用”。加法Addition本质是光能量叠加。典型场景如多曝光合成HDR、荧光显微图像通道融合、夜间红外可见光图像增强。关键约束是两图必须尺寸一致、通道数一致若存在亮度失配需先做伽马校正或直方图匹配否则叠加后会出现明显色块。我曾调试一个安防项目把低照度RGB图和热力图直接相加结果人脸区域发绿——因为热力图是单通道OpenCV自动广播为三通道时用了错误的插值方式导致R/G/B通道增益不一致。解决方案是显式用cv2.cvtColor(thermal, cv2.COLOR_GRAY2BGR)转换并确认插值模式。减法Subtraction核心价值是运动/变化检测。背景减除法Background Subtraction就是经典应用用当前帧减去长期平均的背景帧得到运动前景。但这里有个致命细节——cv2.subtract()默认做饱和减法0-10→0而差分检测需要保留负值信息运动物体可能比背景暗。因此实际中常用cv2.absdiff()取绝对差或手动转float32计算(img_float - bg_float)再取np.abs()最后用cv2.convertScaleAbs()安全转回uint8。某次产线检测中客户抱怨漏检深色零件查因发现他们用了cv2.subtract零件区域因比背景暗而全被裁成0差分图上完全不可见。乘法Multiplication这是空间掩膜Masking的基石。例如用圆形mask提取瞳孔区域masked_img cv2.multiply(img, mask)。但mask必须是0-1范围的浮点图或0-255的uint8图此时OpenCV内部会自动归一化。若mask是二值图0或255直接相乘会导致目标区域亮度翻倍255×255远超255必须先除以255。更稳妥的做法是统一用float32mask_float mask.astype(np.float32) / 255.0再img_float * mask_float。我在做遥感图像云层剔除时就因mask未归一化导致云区边缘出现异常高亮环。除法Division主要解决光照不均校正。比如文档扫描时纸张边缘因镜头衰减变暗可用img / background_estimate来均衡。但除法极易引发除零错误和数值爆炸暗区像素值接近0除以小数后值极大。实践中必须加防呆background_est np.where(background_est 0, 1, background_est)再result np.clip(img.astype(np.float32) / background_est, 0, 255)。某医疗设备商曾因未处理除零导致CT图像校正后出现大片白色噪点返工两周。2.3 Python生态工具链选型为什么不用PIL而选OpenCVNumPy组合虽然PILPillow也能做图像算术但工业级图像处理几乎清一色用OpenCVNumPy原因很实在OpenCV的cv2.add系列函数专为图像优化底层用SIMD指令加速对大图如4K医学影像性能比纯NumPy快3~5倍且内置dtype安全检查传入float32图会自动按浮点规则运算传入uint8则走饱和逻辑无需用户手动判断。NumPy提供灵活的数据操控层当你需要复杂运算如(AB)/2 * C时NumPy的广播机制和向量化操作比OpenCV的单函数调用更自由。但必须配合astype()和np.clip()手动管理数据流。PIL的局限性其ImageChops模块仅支持有限运算add/sub/multiply且返回新Image对象无法原地修改对多通道图的通道处理不直观需拆通道再合并不支持float类型图像的直接保存需转uint8。我的经验是简单、标准、需高性能的运算如实时视频叠加用cv2.add需要自定义复合逻辑或中间结果分析如调试差分阈值用NumPyPIL仅用于最终结果保存或简单格式转换。曾有个项目要求在树莓派上实时处理1080p视频用PIL做每帧加法延迟达120ms换cv2.addWeighted加权叠加后压到18ms——这就是底层优化的实打实差距。3. 实操全流程从读图到可视化每一步都踩过坑的硬核步骤3.1 环境准备与数据加载避开dtype陷阱的第一道防线开始前请确保环境已安装核心库pip install opencv-python numpy matplotlib注意opencv-python是官方预编译包比源码编译快得多且默认启用Intel IPP加速对算术运算提升显著。数据加载看似简单却是最多人栽跟头的环节。以下代码演示正确姿势import cv2 import numpy as np import matplotlib.pyplot as plt # ✅ 正确明确指定读取模式避免BGR/RGB混淆 # 读取为灰度图单通道返回uint8 gray_img cv2.imread(sample.jpg, cv2.IMREAD_GRAYSCALE) # dtype: uint8 # 读取为彩色图BGR顺序返回uint8 color_img cv2.imread(sample.jpg, cv2.IMREAD_COLOR) # dtype: uint8, shape: (H,W,3) # ❌ 危险直接用plt.imread()读取 # plt.imread()默认返回float32 [0,1]范围与cv2.uint8不兼容 # pil_img plt.imread(sample.jpg) # dtype: float32, range [0,1] # result cv2.add(color_img, pil_img) # 类型不匹配报错 # ✅ 安全转换若必须用plt.imread立即归一化并转uint8 # pil_img_uint8 (pil_img * 255).astype(np.uint8) # ✅ 关键检查打印dtype和shape养成习惯 print(fGray image dtype: {gray_img.dtype}, shape: {gray_img.shape}) print(fColor image dtype: {color_img.dtype}, shape: {color_img.shape})注意cv2.imread()读取的彩色图是BGR顺序不是RGB。如果你后续要用matplotlib显示它按RGB渲染必须转换plt.imshow(cv2.cvtColor(color_img, cv2.COLOR_BGR2RGB))。否则颜色会严重失真蓝色变红色红色变蓝色。我第一次做色彩校准时就因忘了这步把一张蓝天白云图调成了“紫天黄云”调试半小时才发现是通道顺序问题。3.2 加法运算实战如何安全叠加两张图并控制融合强度加法最常见需求是图像融合Image Blending但直接cv2.add是“硬叠加”无法调节权重。真正实用的是cv2.addWeighted它实现加权和dst α·src1 β·src2 γ。下面以“将logo叠加到背景图右上角”为例展示完整流程# 1. 加载背景图和logo图 background cv2.imread(background.jpg) logo cv2.imread(logo.png, cv2.IMREAD_UNCHANGED) # 读取含alpha通道的png # 2. 检查logo是否有alpha通道第四通道 if logo.shape[2] 4: b, g, r, alpha cv2.split(logo) # 创建三通道alpha掩膜0-255 - 0.0-1.0 alpha_mask alpha.astype(np.float32) / 255.0 # 提取rgb三通道 logo_rgb cv2.merge([b, g, r]) else: logo_rgb logo alpha_mask np.ones((logo.shape[0], logo.shape[1]), dtypenp.float32) # 3. 调整logo大小并定位到背景右上角 h_bg, w_bg background.shape[:2] h_logo, w_logo logo_rgb.shape[:2] x_offset w_bg - w_logo - 10 # 右边距10像素 y_offset 10 # 上边距10像素 # 4. 创建roi区域背景上待覆盖的区域 roi background[y_offset:y_offseth_logo, x_offset:x_offsetw_logo] # 5. 关键用alpha掩膜做加权融合 # roi部分(1-alpha)*背景 alpha*logo # 注意所有参与运算的数组必须同dtype这里统一转float32 roi_float roi.astype(np.float32) logo_float logo_rgb.astype(np.float32) # 计算融合结果 blended_roi (1 - alpha_mask[..., np.newaxis]) * roi_float \ alpha_mask[..., np.newaxis] * logo_float # 6. 安全转回uint8并赋值回背景 blended_roi_uint8 np.clip(blended_roi, 0, 255).astype(np.uint8) background[y_offset:y_offseth_logo, x_offset:x_offsetw_logo] blended_roi_uint8 # 7. 显示结果 plt.figure(figsize(12, 4)) plt.subplot(131), plt.imshow(cv2.cvtColor(background, cv2.COLOR_BGR2RGB)), plt.title(With Logo) plt.subplot(132), plt.imshow(cv2.cvtColor(roi, cv2.COLOR_BGR2RGB)), plt.title(Original ROI) plt.subplot(133), plt.imshow(cv2.cvtColor(blended_roi_uint8, cv2.COLOR_BGR2RGB)), plt.title(Blended ROI) plt.show()这段代码解决了三个关键痛点alpha通道处理自动识别png的透明度并转换为0~1浮点掩膜数据类型安全所有中间计算在float32下进行避免uint8溢出空间对齐通过roi切片精准定位不依赖cv2.addWeighted的全局运算可实现局部融合。实操心得cv2.addWeighted虽方便但只适用于整图等比例融合。一旦需要局部、非矩形或带透明度的融合如logo叠加必须手动切片掩膜计算。我曾用addWeighted强行融合logo结果透明边缘出现白色镶边——因为addWeighted把alpha通道当普通通道处理了而cv2.split能精确分离alpha。3.3 减法与差分检测从背景建模到运动目标提取的完整链路减法的核心是背景减除Background Subtraction但直接cv2.absdiff往往效果差。工业级方案需结合背景建模。以下用cv2.createBackgroundSubtractorMOG2高斯混合模型演示# 初始化背景减除器参数可根据场景调整 fgbg cv2.createBackgroundSubtractorMOG2( history500, # 背景模型学习帧数越大越慢但越稳 varThreshold16, # 像素方差阈值越小越敏感易误检 detectShadowsTrue # 是否检测阴影增加计算量 ) cap cv2.VideoCapture(video.mp4) while True: ret, frame cap.read() if not ret: break # 1. 转灰度减除器通常对灰度图更鲁棒 gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 2. 应用背景减除得到前景掩膜uint8, 0或255 fgmask fgbg.apply(gray) # 3. 关键后处理形态学去噪 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3,3)) fgmask cv2.morphologyEx(fgmask, cv2.MORPH_OPEN, kernel) # 开运算去小噪点 fgmask cv2.morphologyEx(fgmask, cv2.MORPH_CLOSE, kernel) # 闭运算填小洞 # 4. 提取连通区域过滤小目标 contours, _ cv2.findContours(fgmask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for contour in contours: area cv2.contourArea(contour) if area 500: # 过滤面积小于500像素的噪声 x, y, w, h cv2.boundingRect(contour) cv2.rectangle(frame, (x,y), (xw,yh), (0,255,0), 2) cv2.imshow(Frame, frame) cv2.imshow(FG Mask, fgmask) if cv2.waitKey(30) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码展示了减法的工程化落地背景建模优于静态背景MOG2能适应缓慢光照变化如白天到黄昏而静态背景图在光照漂移时会大面积误检形态学操作不可省略原始fgmask充满椒盐噪声开闭运算能有效清理连通域分析是闭环单纯显示掩膜没用必须提取轮廓并绘制边界框这才是业务需要的输出。常见问题为什么fgbg.apply()返回的掩膜全是黑色答案通常是1视频太短history500还没学完背景2varThreshold设得太大微小运动也被忽略。建议首次调试时设history10varThreshold5快速验证流程再逐步调参。3.4 乘法与掩膜应用从ROI提取到图像修复的精细化控制乘法最典型应用是感兴趣区域ROI提取。但很多教程只教img * mask却不说mask怎么来、怎么优化。以下演示从边缘检测生成高质量mask的全流程# 1. 加载图像并转灰度 img cv2.imread(object.jpg) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 2. 高斯模糊降噪边缘检测前必做 blurred cv2.GaussianBlur(gray, (5,5), 0) # 3. Canny边缘检测 edges cv2.Canny(blurred, 50, 150) # 50/150是高低阈值 # 4. 从边缘生成填充mask关键 # 先找轮廓 contours, _ cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 创建全黑mask mask np.zeros(gray.shape, dtypenp.uint8) # 用最大轮廓填充假设目标是最大物体 if contours: max_contour max(contours, keycv2.contourArea) cv2.drawContours(mask, [max_contour], -1, 255, thicknesscv2.FILLED) # 5. 优化mask形态学闭合高斯模糊羽化 kernel np.ones((5,5), np.uint8) mask_closed cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) mask_blurred cv2.GaussianBlur(mask_closed, (21,21), 0) # 21x21大核羽化 # 6. 归一化mask到[0,1]并应用乘法 mask_normalized mask_blurred.astype(np.float32) / 255.0 result (img.astype(np.float32) * mask_normalized[..., np.newaxis]).astype(np.uint8) # 7. 可视化对比 plt.figure(figsize(15, 5)) plt.subplot(141), plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)), plt.title(Original) plt.subplot(142), plt.imshow(edges, cmapgray), plt.title(Canny Edges) plt.subplot(143), plt.imshow(mask_blurred, cmapgray), plt.title(Feathered Mask) plt.subplot(144), plt.imshow(cv2.cvtColor(result, cv2.COLOR_BGR2RGB)), plt.title(Masked Result) plt.show()这个流程的价值在于边缘检测生成mask比手动画更客观尤其对复杂形状如树叶、血管羽化Feathering避免硬边直接二值mask会产生锯齿高斯模糊后乘法结果边缘自然过渡mask_normalized[..., np.newaxis]确保通道对齐mask_normalized是二维H,Wimg是三维H,W,3np.newaxis在末尾加一维使其变为H,W,1NumPy自动广播到3通道。实操心得羽化核大小21x21需根据目标尺寸调整。小目标用5,5大目标用31,31。我处理一张1000x1000的电路板图时用5,5核羽化结果焊点边缘还是生硬换成15,15后边缘过渡完美。记住羽化半径≈目标最小特征尺寸的1/3。3.5 除法与光照校正消除扫描文档阴影的实战方案除法在文档处理中至关重要。以下用“扫描件阴影校正”为例展示基于背景估计的除法流程def correct_illumination(img, kernel_size21): 基于背景估计的光照校正 kernel_size: 背景估计的高斯核大小越大越平滑适合大范围阴影 # 1. 转灰度并转float32 if len(img.shape) 3: gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) else: gray img.copy() gray_float gray.astype(np.float32) # 2. 用大核高斯模糊估计背景慢变光照分量 background cv2.GaussianBlur(gray_float, (kernel_size, kernel_size), 0) # 3. 防除零背景中0值替换为极小正数 background_safe np.where(background 0, 1e-6, background) # 4. 除法校正原图 / 背景 ≈ 反射分量去除光照影响 corrected gray_float / background_safe # 5. 归一化到[0,255]并转uint8 corrected np.clip(corrected * 255, 0, 255) return corrected.astype(np.uint8) # 应用校正 doc_img cv2.imread(scanned_doc.jpg) corrected_img correct_illumination(doc_img, kernel_size51) # 对比显示 plt.figure(figsize(12, 5)) plt.subplot(121), plt.imshow(cv2.cvtColor(doc_img, cv2.COLOR_BGR2RGB)), plt.title(Original Scan) plt.subplot(122), plt.imshow(corrected_img, cmapgray), plt.title(Illumination Corrected) plt.show()此方案的关键设计点大核高斯模糊模拟背景光照kernel_size51意味着用51x51邻域平均能有效滤除文字细节只保留慢变的光照趋势1e-6防除零比np.finfo(float).eps更稳妥后者是机器精度极限约1e-16在float32下可能仍导致数值不稳定np.clip防止校正后过曝某些区域校正后可能255必须截断。注意事项此方法对强阴影如台灯直射效果好但对折痕、污渍等高频噪声无效。若需更高精度应结合cv2.xphoto.Inpaint进行后续修复。我帮某档案馆处理民国文献时先用此法校正整体阴影再用inpaint修复虫蛀孔洞效果远超单一方法。4. 工具链深度解析OpenCV函数参数背后的物理意义与调参逻辑4.1cv2.addvscv2.addWeighted何时用哪个参数如何量化选择函数适用场景核心参数参数物理意义典型取值cv2.add(src1, src2)简单饱和叠加如亮度提升无强制饱和裁剪min(255, src1src2)无需调参cv2.addWeighted(src1, α, src2, β, γ)加权融合如logo叠加、多光谱合成α,β,γαβ应≈1.0保证亮度守恒γ是全局偏置通常0α0.7,β0.3,γ0参数选择逻辑α和β之和应接近1.0若αβ1结果整体变亮αβ1则变暗。例如融合红外强调热源和可见光强调纹理时若红外信息更重要设α0.8,β0.2γ极少使用仅当需全局增益偏移如校正传感器固定偏置时设非零值一般保持0性能差异cv2.addWeighted比两次cv2.add加一次cv2.multiply快约40%因其单次调用完成全部计算。实测案例在4K视频实时处理中cv2.addWeighted帧耗时12ms而cv2.add(cv2.multiply(img1,0.7), cv2.multiply(img2,0.3))耗时17ms——少一次内存分配和遍历。4.2cv2.subtract与cv2.absdiff差分检测中的符号敏感性抉择函数输出特性适用场景风险点cv2.subtract(src1, src2)饱和减法max(0, src1-src2)快速获取“src1比src2亮多少”的绝对差丢失src1src2的负值信息暗区运动被抹为0cv2.absdiff(src1, src2)绝对差src1-src2选择逻辑若只关心“有没有运动”用absdiff最简单若需区分“变亮运动”如灯打开和“变暗运动”如物体遮挡必须用float32计算diff img1_float - img2_float再分别取np.maximum(diff,0)和np.minimum(diff,0)subtract在uint8下性能略优少一次绝对值计算但代价是信息损失。提示cv2.absdiff对uint8和float32都支持且结果自动适配输入类型。而cv2.subtract在float32下不做饱和直接输出负值——这正是你需要的。4.3cv2.multiply的scale参数为什么有时要除以255有时不用cv2.multiply(src1, src2, dstNone, scale1)中的scale参数常被忽略但它决定结果是否需手动归一化当src1和src2都是uint80-255时scale1结果直接饱和裁剪255×25565025→255相当于mask未归一化当src1是uint8src2是float32[0,1]时scale1结果自动按浮点规则计算mask已归一化当src1和src2都是uint8但希望结果反映真实乘积比例设scale1/255.0cv2.multiply(img, mask, scale1/255.0)等价于(img * mask) / 255。所以是否设scale取决于mask的数据范围mask是0/255二值图 →scale1/255.0mask是0.0/1.0浮点图 →scale1不确定mask范围 → 统一转float32并归一化不用scale我在做卫星图像云检测时云掩膜是0/1浮点图直接cv2.multiply(img, cloud_mask, scale1)而做老照片修复时划痕掩膜是0/255图必须scale1/255.0否则划痕区域全变白。5. 常见问题与排查技巧实录那些调试时抓狂的瞬间与解法5.1 “图像叠加后全黑/全白”问题速查表现象最可能原因快速验证方法解决方案叠加后全黑1.uint8溢出25510→02. mask未归一化255×255→2553. 通道数不匹配单通道mask乘三通道图打印result.min(), result.max()若为0且max0说明全黑1. 改用cv2.add或转float322.mask mask.astype(np.float32)/255.03.mask cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)叠加后全白1. 除法中背景估计过小除零后无穷大2.float32未clip2553.cv2.multiply中scale过大打印np.isnan(result).any()和result.max()1.background np.where(background0, 1e-6, background)2.result np.clip(result, 0, 255)3. 检查scale值或改用归一化mask边缘出现彩色镶边BGR/RGB通道错位如用plt.imshow显示BGR图查看result.shape若为(H,W,3)检查显示代码plt.imshow(cv2.cvtColor(result, cv2.COLOR_BGR2RGB))实操心得每次调试前先运行这三行print(fInput dtype: {img.dtype}, min/max: {img.min()}/{img.max()}) print(fResult dtype: {result.dtype}, min/max: {result.min()}/{result.max()}) print(fNaN count: {np.isnan(result).sum()})90%的“图像消失”问题靠这三行就能定位。5.2 “差分图噪声太多”问题从参数到后处理的系统优化差分图噪声多不能只怪算法要系统排查前端降噪原始视频是否已加高斯模糊cv2.GaussianBlur(frame, (3,3), 0)可滤除高频传感器噪声是否开启detectShadowsFalse阴影检测会增加计算量且引入伪影模型参数varThreshold设太高如50会漏检微小运动设太低如5会把噪声当运动建议从20开始试history太小100导致背景模型不稳定尤其对长视频后处理黄金组合# 推荐形态学序列经百个项目验证 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3,3)) fgmask cv2.morphologyEx(fgmask, cv2.MORPH_OPEN, kernel) # 去孤立噪点 fgmask cv2.morphologyEx(fgmask, cv2.MORPH_CLOSE, kernel) # 填小空洞 fgmask cv2.morphologyEx(fgmask, cv2.MORPH_CLOSE, kernel) # 再次闭合强化连通