彻底重写增强方法:用形态学闭运算替代高斯模糊做背景估计
之前所有阴影后处理方案都会误伤文字,根本原因是高斯模糊 做背景估计时会把远处亮区域扩散到阴影处,导致阴影处bg-gray 差值大,被当成墨迹变黑。 改用morphologyEx(CLOSE)做背景估计:闭运算=先膨胀后腐蚀, 只保留局部最亮值作为背景,不会跨区域扩散。阴影区域的背景 估计也低,bg-gray差值自然小,阴影不会变黑。 删除所有阴影后处理代码(方差检测、条带检测、形态学掩码), 代码从1240行精简到1048行。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
235
CamScanner.cs
235
CamScanner.cs
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user