彻底重写增强方法:用形态学闭运算替代高斯模糊做背景估计

之前所有阴影后处理方案都会误伤文字,根本原因是高斯模糊
做背景估计时会把远处亮区域扩散到阴影处,导致阴影处bg-gray
差值大,被当成墨迹变黑。

改用morphologyEx(CLOSE)做背景估计:闭运算=先膨胀后腐蚀,
只保留局部最亮值作为背景,不会跨区域扩散。阴影区域的背景
估计也低,bg-gray差值自然小,阴影不会变黑。

删除所有阴影后处理代码(方差检测、条带检测、形态学掩码),
代码从1240行精简到1048行。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 17:15:00 +08:00
parent ed205c4f04
commit 436c02d2a7

View File

@@ -322,7 +322,8 @@ public static class DocumentScanner
// ========================================== // ==========================================
// 第3步图像增强 // 第3步图像增强
// 减法提取墨迹深度 → 非线性加深 → 锐化 // 核心用morphologyEx(CLOSE)做背景估计(比高斯模糊更贴合局部亮度)
// 阴影区域的背景估计也低 → bg-gray差值小 → 阴影自然不变黑
// ========================================== // ==========================================
private static Mat EnhanceDocument(Mat src) private static Mat EnhanceDocument(Mat src)
{ {
@@ -330,15 +331,26 @@ public static class DocumentScanner
Mat gray = new Mat(); Mat gray = new Mat();
Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY); Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY);
// --- b: 背景估计 --- // --- b: 背景估计(形态学闭运算)---
int blurSize = Math.Max(gray.Width, gray.Height) / 8; // 闭运算 = 先膨胀后腐蚀,效果是填平暗区域(文字),保留亮区域(背景)
if (blurSize % 2 == 0) blurSize++; // 比高斯模糊更好:不会把远处的亮区域扩散到阴影处
if (blurSize < 51) blurSize = 51; int morphSize = Math.Max(gray.Width, gray.Height) / 25;
if (morphSize < 25) morphSize = 25;
if (morphSize % 2 == 0) morphSize++;
Mat morphKernel = Cv2.GetStructuringElement(MorphShapes.Ellipse,
new OpenCvSharp.Size(morphSize, morphSize));
Mat background = new Mat(); Mat background = new Mat();
Cv2.GaussianBlur(gray, background, new OpenCvSharp.Size(blurSize, blurSize), 0); Cv2.MorphologyEx(gray, background, MorphTypes.Close, morphKernel);
morphKernel.Dispose();
// --- c: 减法提取墨迹 + 非线性加深 --- // 对背景估计做轻度模糊,消除形态学的块状伪影
int smoothSize = morphSize / 2;
if (smoothSize % 2 == 0) smoothSize++;
if (smoothSize < 3) smoothSize = 3;
Cv2.GaussianBlur(background, background, new OpenCvSharp.Size(smoothSize, smoothSize), 0);
// --- c: 减法提取墨迹 ---
int w = gray.Width; int w = gray.Width;
int h = gray.Height; int h = gray.Height;
byte[] grayData = new byte[w * h]; byte[] grayData = new byte[w * h];
@@ -348,8 +360,7 @@ public static class DocumentScanner
Marshal.Copy(background.Data, bgData, 0, bgData.Length); Marshal.Copy(background.Data, bgData, 0, bgData.Length);
background.Dispose(); background.Dispose();
// 百分位数而非最大值来计算增益(排除极端值) // 95百分位数增益
// 先收集所有正墨迹值
int[] inkHist = new int[256]; int[] inkHist = new int[256];
for (int i = 0; i < grayData.Length; i++) for (int i = 0; i < grayData.Length; i++)
{ {
@@ -358,8 +369,6 @@ public static class DocumentScanner
if (ink > 255) ink = 255; if (ink > 255) ink = 255;
inkHist[ink]++; inkHist[ink]++;
} }
// 找 95 百分位数作为参考墨迹深度
int totalPixels = w * h; int totalPixels = w * h;
int target95 = (int)(totalPixels * 0.95); int target95 = (int)(totalPixels * 0.95);
int cumulative = 0; int cumulative = 0;
@@ -374,35 +383,7 @@ public static class DocumentScanner
} }
} }
// 计算"正常纸面亮度":取灰度图中心区域的均值 // 墨迹映射(纯净版,不做任何阴影抑制)
int cx1 = w / 4;
int cx2 = w * 3 / 4;
int cy1 = h / 4;
int cy2 = h * 3 / 4;
long centerSum = 0;
int centerCount = 0;
for (int y = cy1; y < cy2; y++)
{
for (int x = cx1; x < cx2; x++)
{
centerSum += grayData[y * w + x];
centerCount++;
}
}
int paperBright = (int)(centerSum / Math.Max(centerCount, 1));
// 墨迹映射(带折痕/阴影抑制)
//
// 核心区分逻辑:文字 vs 折痕/阴影
// 文字 = 高频信号,笔画边缘锐利,局部灰度变化剧烈
// 折痕/阴影 = 低频信号,缓慢渐变,局部灰度变化平缓
//
// 方法:先正常生成墨迹图,然后用"墨迹图的局部方差"来区分
// 高方差区域 = 文字(保留)
// 低方差但偏暗区域 = 折痕/阴影(推向白色)
// 先生成原始墨迹图(不做任何抑制)
byte[] inkData = new byte[w * h];
for (int i = 0; i < grayData.Length; i++) for (int i = 0; i < grayData.Length; i++)
{ {
int ink = bgData[i] - grayData[i]; int ink = bgData[i] - grayData[i];
@@ -417,180 +398,6 @@ public static class DocumentScanner
if (val < 0) val = 0; if (val < 0) val = 0;
if (val > 255) val = 255; if (val > 255) val = 255;
resultData[i] = (byte)val; resultData[i] = (byte)val;
inkData[i] = (byte)(255 - val); // 墨迹强度0=无墨, 255=纯黑
}
// 计算两级局部方差:
// 小核(7):检测文字笔画(高频),文字处方差高
// 大核(31):检测折痕渐变(低频),折痕处方差低
// 只有两级方差都低时才抑制,避免误伤折痕附近的文字
Mat grayMat = new Mat(h, w, MatType.CV_8U, grayData);
// 小核方差(保护文字)
Mat localMeanS = new Mat();
Cv2.Blur(grayMat, localMeanS, new OpenCvSharp.Size(7, 7));
Mat diffS = new Mat();
Cv2.Absdiff(grayMat, localMeanS, diffS);
localMeanS.Dispose();
Mat localVarS = new Mat();
Cv2.Blur(diffS, localVarS, new OpenCvSharp.Size(7, 7));
diffS.Dispose();
// 大核方差(检测折痕)
Mat localMeanL = new Mat();
Cv2.Blur(grayMat, localMeanL, new OpenCvSharp.Size(31, 31));
Mat diffL = new Mat();
Cv2.Absdiff(grayMat, localMeanL, diffL);
localMeanL.Dispose();
Mat localVarL = new Mat();
Cv2.Blur(diffL, localVarL, new OpenCvSharp.Size(31, 31));
diffL.Dispose();
grayMat.Dispose();
byte[] varDataS = new byte[w * h];
byte[] varDataL = new byte[w * h];
Marshal.Copy(localVarS.Data, varDataS, 0, varDataS.Length);
Marshal.Copy(localVarL.Data, varDataL, 0, varDataL.Length);
localVarS.Dispose();
localVarL.Dispose();
// 抑制逻辑:
// 小核方差 < 5 且 大核方差 < 12 → 确定是平坦渐变区域(折痕/阴影)
// 小核方差 >= 5 → 有文字笔画,不抑制(即使大核方差低)
for (int i = 0; i < resultData.Length; i++)
{
if (inkData[i] > 10 && varDataS[i] < 5 && varDataL[i] < 12)
{
double suppressRatio = (double)varDataS[i] / 5.0;
int origVal = resultData[i];
resultData[i] = (byte)(origVal + (int)((255 - origVal) * (1.0 - suppressRatio)));
}
}
// --- 条带状折痕检测 ---
// 折痕特征:垂直或水平方向上,某列/行有大量连续暗像素
// 文字不会形成这种长条状连续暗区域
//
// 对每列统计暗像素比例,高比例的列标记为折痕条带
int inkThreshForStripe = 30; // 墨迹强度>30才算暗像素
double stripeRatioThresh = 0.4; // 一列中40%以上是暗像素就是条带
int stripeHalfWidth = 3; // 条带半宽左右各扩展3像素
// 垂直条带检测(按列扫描)
bool[] vStripe = new bool[w];
for (int x = 0; x < w; x++)
{
int darkCount = 0;
for (int y = 0; y < h; y++)
{
if (inkData[y * w + x] > inkThreshForStripe) darkCount++;
}
vStripe[x] = ((double)darkCount / h) > stripeRatioThresh;
}
// 水平条带检测(按行扫描)
bool[] hStripe = new bool[h];
for (int y = 0; y < h; y++)
{
int darkCount = 0;
for (int x = 0; x < w; x++)
{
if (inkData[y * w + x] > inkThreshForStripe) darkCount++;
}
hStripe[y] = ((double)darkCount / w) > stripeRatioThresh;
}
// 扩展条带宽度并抑制
for (int i = 0; i < resultData.Length; i++)
{
int x = i % w;
int y = i / w;
bool inStripe = false;
// 检查是否在垂直条带范围内
for (int dx = -stripeHalfWidth; dx <= stripeHalfWidth; dx++)
{
int nx = x + dx;
if (nx >= 0 && nx < w && vStripe[nx]) { inStripe = true; break; }
}
// 检查是否在水平条带范围内
if (!inStripe)
{
for (int dy = -stripeHalfWidth; dy <= stripeHalfWidth; dy++)
{
int ny = y + dy;
if (ny >= 0 && ny < h && hStripe[ny]) { inStripe = true; break; }
}
}
if (inStripe && inkData[i] > 5)
{
// 在条带内:检查这个像素是否真的是文字
// 文字在条带内的特征:小核方差高(笔画边缘锐利)
if (varDataS[i] < 10)
{
// 方差不高 → 不是文字,是折痕本身 → 推白
resultData[i] = 255;
}
// 方差高 → 是文字,保留不动
}
}
// --- 大面积阴影检测(形态学方法)---
// 思路:
// 1. 对原始灰度二值化,找出所有暗像素
// 2. 用大核腐蚀,去掉小面积暗区域(文字笔画)
// 3. 再膨胀回来,剩下的就是大面积暗区域(阴影/折痕)
// 4. 在这些区域内,只保留高方差像素(文字),其余推白
Mat grayForShadow = new Mat(h, w, MatType.CV_8U, grayData);
// 二值化:暗像素=255, 亮像素=0
int shadowBinThresh = (int)(paperBright * 0.65);
Mat darkBin = new Mat();
Cv2.Threshold(grayForShadow, darkBin, shadowBinThresh, 255, ThresholdTypes.BinaryInv);
grayForShadow.Dispose();
// 腐蚀:去掉小面积暗区域(文字笔画宽度一般<10px
// 腐蚀核要大于文字笔画宽度
int erodeSize = Math.Max(w, h) / 80;
if (erodeSize < 15) erodeSize = 15;
if (erodeSize % 2 == 0) erodeSize++;
Mat erodeK = Cv2.GetStructuringElement(MorphShapes.Ellipse,
new OpenCvSharp.Size(erodeSize, erodeSize));
Mat eroded = new Mat();
Cv2.Erode(darkBin, eroded, erodeK);
darkBin.Dispose();
// 膨胀回来(比腐蚀核稍大,确保覆盖阴影边缘)
int dilateSize = erodeSize + 10;
if (dilateSize % 2 == 0) dilateSize++;
Mat dilateK = Cv2.GetStructuringElement(MorphShapes.Ellipse,
new OpenCvSharp.Size(dilateSize, dilateSize));
Mat shadowMask = new Mat();
Cv2.Dilate(eroded, shadowMask, dilateK);
eroded.Dispose();
erodeK.Dispose();
dilateK.Dispose();
byte[] shadowMaskData = new byte[w * h];
Marshal.Copy(shadowMask.Data, shadowMaskData, 0, shadowMaskData.Length);
shadowMask.Dispose();
// 在阴影区域内:保护文字,只清除阴影背景
// 判断逻辑:
// 墨迹强度高inkData > 40→ 有明显文字 → 保留
// 墨迹强度低 → 阴影背景 → 推白
for (int i = 0; i < resultData.Length; i++)
{
if (shadowMaskData[i] > 128)
{
if (inkData[i] < 40)
{
// 墨迹弱 → 阴影背景 → 白色
resultData[i] = 255;
}
// 墨迹强 → 文字 → 保留原值
}
} }
Mat enhanced = new Mat(h, w, MatType.CV_8U); Mat enhanced = new Mat(h, w, MatType.CV_8U);