C# 实现扫描件自动分栏切图:垂直投影法,2319×1375 整页切分仅 107ms
不依赖 OpenCV,不依赖任何 AI 模型,只用 System.Drawing + 一个一维数组,就能把一张双栏扫描件精准切成单栏图片。文末附实测性能数据。
一、背景:固定坐标切图为什么不行
做作业批改、试卷归档、扫描件 OCR 这类需求时,经常要把"一张整页图"拆成"一栏一张图"。最常见的做法是平均切:
csharp
// 反面教材:按宽度均分
int w = source.Width / columns;
for (int i = 0; i < columns; i++)
Crop(source, new Rectangle(i * w, 0, w, source.Height));
代码只有三行,但实际用起来几乎一定翻车:
| 现象 | 原因 |
|---|---|
| 文字被拦腰切断 | 真实排版的栏宽并不相等,左栏 60%、右栏 40% 很常见 |
| 切出纯白碎条 | 扫描时纸张没铺满,左右两侧各有 30~80px 白边没算进去 |
| 切点落在装订线阴影上 | 装订线是一条深色窄带,被当成了内容 |
| 换一张图参数就失效 | 每张扫描件的边距、缩放比都不同 |
核心问题在于:均分算法假设"版面是我规定的样子",而真实图片需要算法自己去"看"版面长什么样。
我们要的是一种能回答"这一列到底有没有内容"的算法。
二、算法原理:把二维在这里插入图片描述
问题降成一维
人眼看一页纸,能立刻找到两栏之间的空白。计算机怎么找?
方法是垂直投影(Vertical Projection):
- 对图片的每一列
x,从上到下统计"墨迹像素"的数量; - 除以图片高度,得到这一列的墨迹占比
ink[x](0~1); - 把所有
ink[x]连起来看,就得到一条曲线。
这条曲线有个非常好的性质:
- 有文字的列 → 曲线在高位(占比可能是 20%~40%);
- 栏间空白的列 → 曲线直接掉到 0 附近,形成波谷。
于是"找栏间缝隙"这个二维视觉问题,就变成了在一维数组里找连续的低谷区段。

图中数据不是示意图,是用后文算法对一张 2319×1375 的测试图跑出来的真实投影曲线。
找到波谷之后,切点取波谷的中点而不是波谷边缘。原因很实际:中点距离两边文字都最远,即便切的时候有一两个像素的偏差,也不会碰到字。
三、完整实现
工程结构(.NET 8 类库,仅依赖 System.Drawing.Common):
ImageSplitter/
├── SplitModels.cs // 参数与结果模型
└── ColumnSplitter.cs // 切分算法
注意:
System.Drawing.Common从 .NET 6 起仅支持 Windows。类库已通过SupportedOSPlatform("windows")声明,非 Windows 平台会编译期提示。
3.1 参数与结果模型
先把可调参数集中成一个 options 对象,避免方法签名膨胀。
csharp
using System;
using System.Drawing;
using System.Collections.Generic;
namespace ImageSplitter
{
/// <summary>
/// 切分参数。
/// </summary>
public sealed class SplitOptions
{
/// <summary>
/// 深色阈值:灰度低于该值视为墨迹。铅笔、浅色字迹可调高到 160~180。
/// </summary>
public int DarkThreshold { get; set; } = 130;
/// <summary>
/// 一列中墨迹占比低于该值即视为空白列,默认 0.4%。
/// </summary>
public double MaxInkRatio { get; set; } = 0.004;
/// <summary>
/// 白缝的最小宽度(像素)。
/// </summary>
public int MinGutterWidth { get; set; } = 6;
/// <summary>
/// 忽略图片左右边缘该像素数内的空白,避免把整图白边当成白缝。
/// </summary>
public int EdgeMargin { get; set; } = 4;
/// <summary>
/// 期望栏数;0 表示按检出的白缝自动决定。
/// </summary>
public int Columns { get; set; } = 0;
/// <summary>
/// 最小栏宽(像素):窄于该值的段一律不承认,相邻白缝会被合并。
/// 用于消除"一条宽白缝被几粒杂散墨点打断成多条白缝"时切出的碎片栏。
/// 0 表示自动(图宽的 4%,不小于 24px)。
/// </summary>
public int MinPanelWidth { get; set; } = 0;
/// <summary>
/// 是否按墨迹外接框收紧每栏四周的白边。
/// </summary>
public bool TrimWhiteBorder { get; set; } = true;
/// <summary>
/// 收紧白边时保留的余量(像素)。
/// </summary>
public int TrimPadding { get; set; } = 6;
}
/// <summary>
/// 一条栏间白缝。
/// </summary>
public sealed class Gutter
{
public int Start { get; set; }
public int End { get; set; }
/// <summary>
/// 白缝宽度(像素)。
/// </summary>
public int Width => End - Start + 1;
public override string ToString() => $"{Start}-{End}({Width}px)";
}
/// <summary>
/// 切出来的一栏。
/// </summary>
public sealed class SplitPanel
{
/// <summary>从 1 开始的下标。</summary>
public int Index { get; set; }
/// <summary>该栏在源图中的左边界(含)。</summary>
public int Left { get; set; }
/// <summary>该栏在源图中的右边界(不含)。</summary>
public int Right { get; set; }
/// <summary>该栏实际宽度(已剔除白边)。</summary>
public int Width { get; set; }
/// <summary>该栏实际高度(已剔除白边)。</summary>
public int Height { get; set; }
/// <summary>该栏位图。仅 Split 返回;SplitAndSave 落盘后为 null。</summary>
public Bitmap Image { get; set; }
/// <summary>落盘路径;未落盘时为 null。</summary>
public string FilePath { get; set; }
}
/// <summary>
/// 一次切分的完整结果,释放时一并释放所有栏位图。
/// </summary>
public sealed class SplitReport : IDisposable
{
public Size SourceSize { get; set; }
/// <summary>检测到的候选白缝,按位置从左到右排列。</summary>
public List<Gutter> Gutters { get; set; } = new List<Gutter>();
/// <summary>最终切点,含 0 与源图宽度。</summary>
public int[] Cuts { get; set; } = new int[0];
public List<SplitPanel> Panels { get; set; } = new List<SplitPanel>();
/// <summary>是否检测到栏间白缝(false 说明可能是单栏图片)。</summary>
public bool GuttersFound => Gutters.Count > 0;
public void Dispose()
{
foreach (SplitPanel panel in Panels)
{
panel.Image?.Dispose();
panel.Image = null;
}
}
}
}
3.2 像素读取:为什么必须用 LockBits
这是整个算法里最容易踩坑、也最影响性能的一步。新手版本通常是:
csharp
// 千万别这么写
int gray = (bmp.GetPixel(x, y).R + bmp.GetPixel(x, y).G + bmp.GetPixel(x, y).B) / 3;
GetPixel 每次调用都要做一次边界检查 + GDI+ 内部互操作,单次开销在百纳秒级。2300×1400 的图有 320 万个像素,光取像素就要几百毫秒;如果按 GetPixel().R / .G / .B 分开取,还要再乘三。
正确做法是用 LockBits 把整幅图的内存一次性拷到托管数组:
csharp
/// <summary>
/// 把位图统一转成 24bpp 并一次性拷出 BGR 缓冲区。
/// 用 LockBits + Marshal.Copy 代替 GetPixel,2319×1375 的图从 483ms 降到 30ms。
/// </summary>
internal static byte[] ReadBgr(Bitmap bmp, out int stride, out int width, out int height)
{
width = bmp.Width;
height = bmp.Height;
// 统一转 24bpp:源图可能是 32bppArgb、8bppIndexed 等,格式不统一会让索引计算变得复杂
using (var normalized = new Bitmap(width, height, PixelFormat.Format24bppRgb))
{
using (var g = Graphics.FromImage(normalized))
{
g.DrawImage(bmp, new Rectangle(0, 0, width, height));
}
var rect = new Rectangle(0, 0, width, height);
BitmapData data = normalized.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
try
{
stride = data.Stride;
var buffer = new byte[stride * height];
Marshal.Copy(data.Scan0, buffer, 0, buffer.Length);
return buffer;
}
finally
{
normalized.UnlockBits(data);
}
}
}
这里有两个细节值得单独说:
- 必须先
DrawImage归一化成 24bpp 。GDI+ 的LockBits可以直接锁原格式,但那样就得为Format32bppArgb、Format8bppIndexed、调色板索引等各种格式分别写解析逻辑,得不偿失。归一化后每个像素固定 3 字节(B、G、R),索引计算变得无比简单。 stride不等于width * 3。Windows 位图按 4 字节对齐,比如宽度 2319 时width * 3 = 6957,实际stride是 6960,每行末尾有 3 字节填充。取像素必须用y * stride + x * 3,这是最常见的错位 Bug 来源。
3.3 建立墨迹投影
拿到 BGR 数组后,逐列统计深色像素占比:
csharp
/// <summary>
/// 计算每一列的墨迹占比(0~1)。注意 stride 含行对齐填充,取像素须用 y*stride+x*3。
/// </summary>
internal static double[] BuildInkProfile(byte[] bgr, int stride, int width, int height, int dark)
{
var ink = new double[width];
// 外层循环走列、内层走行,是为了让访问模式尽量连续
for (int x = 0; x < width; x++)
{
int darkCount = 0;
int offset = x * 3;
for (int y = 0; y < height; y++)
{
int i = y * stride + offset;
int gray = (bgr[i] + bgr[i + 1] + bgr[i + 2]) / 3;
if (gray < dark) darkCount++;
}
ink[x] = (double)darkCount / height;
}
return ink;
}
关于灰度公式,这里用的是最朴素的 (R+G+B)/3,而不是心理学加权公式 0.299R + 0.587G + 0.114B。原因是二值判断对精度不敏感,只关心"够不够黑",算术平均足够用,还能省掉浮点乘法。如果做的是图像显示、色彩分析,那就必须用加权公式。
输出的 ink[] 就是那张"一维投影图"。
3.4 检出白缝
在 ink[] 上扫一遍,连续低于阈值的列段就是一条候选白缝:
csharp
/// <summary>
/// 找出连续的空白列区间,作为候选栏间白缝。
/// </summary>
internal static List<Gutter> FindGutters(double[] ink, int width, SplitOptions options)
{
var gutters = new List<Gutter>();
int start = -1;
for (int x = 0; x < width; x++)
{
bool blank = ink[x] < options.MaxInkRatio;
if (blank && start < 0)
{
start = x; // 空白段起点
}
else if (!blank && start >= 0)
{
AddGutter(gutters, start, x - 1, width, options);
start = -1; // 空白段结束
}
}
// 收尾:如果图的最右侧是空白,循环里没有"非空白列"来触发收盘
if (start >= 0) AddGutter(gutters, start, width - 1, width, options);
return gutters;
}
AddGutter 负责三道准入检查,把"不是栏间缝隙的空白"筛掉:
csharp
/// <summary>
/// 校验一段空白列,符合条件才登记为候选白缝:过窄的、贴着左右纸边的都不算栏间白缝。
/// </summary>
private static void AddGutter(List<Gutter> gutters, int start, int end, int width, SplitOptions o)
{
// 1. 太窄:可能是字间距、标点间隙,不是栏间距
if (end - start + 1 < o.MinGutterWidth) return;
// 2. 贴着左边纸边:这是页面白边,不是栏间缝隙
if (start <= o.EdgeMargin) return;
// 3. 贴着右边纸边:同理
if (end >= width - o.EdgeMargin - 1) return;
gutters.Add(new Gutter { Start = start, End = end });
}
这三条检查看起来简单,但少了任何一条,自动化就不可靠:没有第 1 条,汉字之间的空隙会被当成白缝;没有第 2、3 条,纸张扫描时的边缘白边会切出一条 20px 宽的废图。
3.5 关键一步:合并被"打断"的白缝
这是本文最核心的一个细节,也是绝大多数同类实现会漏掉的地方。
真实扫描件里,一条本该是 40px 宽的干净白缝,经常被这些东西打断:
- 装订孔(打孔机的圆孔,深色)
- 扫描时的灰尘、墨点
- 手写笔记蹭到栏间的一条短横线
- 页面上跨栏的图注、页码
结果就是一条白缝被切成两三条。如果照单全收,切点会两两相邻,产出一两个像素宽的碎片栏。
判据很直接:两条白缝之间夹着的那点墨迹,撑不起一栏。如果中间夹的墨迹列数小于最小栏宽,就把它们视作同一条白缝。
csharp
/// <summary>
/// 合并"被一小段墨迹隔开"的白缝:两条白缝之间夹着的墨迹列数若小于最小栏宽,
/// 说明这点墨迹撑不起一栏(杂散墨点、装订孔、纸边阴影等),视为同一条白缝。
/// </summary>
internal static List<Gutter> MergeGutters(List<Gutter> gutters, int minPanel)
{
if (gutters.Count < 2) return gutters;
var merged = new List<Gutter> { new Gutter { Start = gutters[0].Start, End = gutters[0].End } };
for (int i = 1; i < gutters.Count; i++)
{
Gutter last = merged[merged.Count - 1];
int inkBetween = gutters[i].Start - last.End - 1; // 中间夹的墨迹列数
if (inkBetween < minPanel)
{
last.End = gutters[i].End; // 并进上一条,跨过那点杂墨
}
else
{
merged.Add(new Gutter { Start = gutters[i].Start, End = gutters[i].End });
}
}
return merged;
}
注意合并后 Gutter 的 Start/End 跨越了原本的墨迹段,所以后面取中点时,中点会落在那点杂墨附近------这正是我们想要的,因为真正的栏间缝隙中心就在那里。
下面这张图是测试图的真实数据:三个装订孔正好落在栏间白缝里,把一条 212px 宽的白缝打断成 1033--1154 和 1182--1244 两段,中间夹着 26px 的墨迹。26px 远小于最小栏宽 92px,所以两段被合并回一条,切点落在 1138。

如果不做这一步合并,算法会认为这里有两个栏间缝隙,切出 [0, 1093, 1213, 2319] 三个切点,中间那张只有 120px 宽的图就是纯粹的碎片。
3.6 计算切点
csharp
/// <summary>
/// 最小栏宽:显式配置优先,否则取图宽的 4%,且不小于 24px。
/// </summary>
internal static int ResolveMinPanel(int width, SplitOptions options)
{
if (options.MinPanelWidth > 0) return options.MinPanelWidth;
return Math.Max(24, width / 25);
}
/// <summary>
/// 从候选白缝里挑出最宽的若干条,取中点作为切点。
/// </summary>
internal static int[] ComputeCuts(int width, List<Gutter> gutters, SplitOptions options)
{
int minPanel = ResolveMinPanel(width, options);
// 切点离左右边缘太近会被丢弃,避免把纸边切成一条窄条
var usable = new List<Gutter>(gutters.Count);
foreach (Gutter g in gutters)
{
int mid = (g.Start + g.End) / 2;
if (mid < minPanel || width - mid < minPanel) continue;
usable.Add(g);
}
if (usable.Count == 0) return new[] { 0, width }; // 没白缝 = 单栏图
// 指定栏数 → 取最宽的 N-1 条;否则全用
int need = options.Columns > 1 ? options.Columns - 1 : usable.Count;
if (need > usable.Count) need = usable.Count;
var picked = new List<Gutter>(usable);
picked.Sort((a, b) => b.Width.CompareTo(a.Width)); // 按宽度降序
picked = picked.GetRange(0, need);
picked.Sort((a, b) => a.Start.CompareTo(b.Start)); // 按位置升序
var cuts = new List<int> { 0 };
foreach (Gutter g in picked) cuts.Add((g.Start + g.End) / 2);
cuts.Add(width);
return cuts.ToArray();
}
这里"取最宽的 N-1 条"是个很实用的策略:当用户明确指定 Columns = 3 时,图片里可能检测到 5 条白缝(多出来的是段落间距造成的),按宽度排序取前 2 条最宽的,命中率远高于"取最左边 2 条"。
自动模式下还要再补一刀,剔除离群窄段:
csharp
/// <summary>
/// 自动模式下剔除"宽度远小于其它栏"的切段:这类窄条基本是纸边、装订线、页码边条,
/// 不是真正的栏。判据是窄于其余栏宽度中位数的一半(且不小于最小栏宽)。
/// 指定栏数时不做此过滤,按用户的意图切。
/// </summary>
internal static int[] DropNarrowOutliers(int[] cuts, int minPanel)
{
if (cuts.Length <= 2) return cuts;
var list = new List<int>(cuts);
while (list.Count > 2)
{
// 1. 算出当前每一段的宽度
var widths = new List<int>(list.Count - 1);
for (int i = 0; i < list.Count - 1; i++) widths.Add(list[i + 1] - list[i]);
// 2. 中位数的一半作为"太窄"的门槛
var sorted = new List<int>(widths);
sorted.Sort();
int limit = Math.Max(minPanel, sorted[sorted.Count / 2] / 2);
// 3. 找出最窄的离群段
int pick = -1, narrowest = int.MaxValue;
for (int i = 0; i < widths.Count; i++)
{
if (widths[i] < limit && widths[i] < narrowest)
{
narrowest = widths[i];
pick = i;
}
}
if (pick < 0) break;
// 4. 去掉窄段的右边界;若窄段就是最后一段,则去掉它的左边界,保留收尾切点
if (pick + 1 == list.Count - 1) list.RemoveAt(pick);
else list.RemoveAt(pick + 1);
}
return list.ToArray();
}
为什么用中位数而不是平均数?因为一段几像素宽的离群条会把平均数拉得很低,导致门槛失效;中位数对离群值免疫,判断更稳。
3.7 裁剪与白边收紧
切点算出来后,相邻切点之间就是一个矩形。裁剪本身很简单:
csharp
/// <summary>
/// 按矩形区域裁剪出一个新位图(统一为 24bpp)。
/// </summary>
internal static Bitmap Crop(Bitmap source, Rectangle rect)
{
var target = new Bitmap(rect.Width, rect.Height, PixelFormat.Format24bppRgb);
using (var g = Graphics.FromImage(target))
{
// 用 DrawImage 的源矩形重载,比 Clone 更可控,且能顺带做格式转换
g.DrawImage(source, new Rectangle(0, 0, rect.Width, rect.Height),
rect, GraphicsUnit.Pixel);
}
return target;
}
然后是白边收紧:算出这一栏里墨迹的外接框,四周裁掉多余空白并留一点余量。
csharp
/// <summary>
/// 按墨迹外接框收紧白边,保留 TrimPadding 像素余量。
/// </summary>
internal static Bitmap TrimByInk(Bitmap panel, SplitOptions options)
{
byte[] pixels = ReadBgr(panel, out int stride, out int width, out int height);
int minX = width, minY = height, maxX = -1, maxY = -1;
for (int y = 0; y < height; y++)
{
int rowStart = y * stride;
for (int x = 0; x < width; x++)
{
int i = rowStart + x * 3;
int gray = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
if (gray >= options.DarkThreshold) continue;
// 一边扫一边更新外接框,省掉第二次遍历
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
if (maxX < 0) return panel; // 整栏空白,原样返回
int pad = options.TrimPadding;
var box = Rectangle.FromLTRB(
Math.Max(minX - pad, 0),
Math.Max(minY - pad, 0),
Math.Min(maxX + pad + 1, width),
Math.Min(maxY + pad + 1, height));
if (box.Width == width && box.Height == height) return panel; // 无需裁剪
Bitmap trimmed = Crop(panel, box);
panel.Dispose(); // 原图被替换,及时释放
return trimmed;
}
这一步的价值:切出来的图不再是"某栏所在的矩形",而是"紧贴内容的矩形"。做缩略图列表时观感差别巨大,喂给 OCR 也少了大量无用背景。
3.8 主流程
把上面这些串起来:
csharp
private static SplitReport SplitInternal(Bitmap source, SplitOptions options, string outputDir)
{
options = options ?? new SplitOptions();
var report = new SplitReport { SourceSize = source.Size };
try
{
// 1. 取像素
byte[] pixels = ReadBgr(source, out int stride, out int width, out int height);
// 2. 垂直投影
double[] ink = BuildInkProfile(pixels, stride, width, height, options.DarkThreshold);
// 3. 检出候选白缝
report.Gutters = FindGutters(ink, width, options);
// 4. 合并被打断的白缝
int minPanel = ResolveMinPanel(width, options);
if (report.Gutters.Count > 1) report.Gutters = MergeGutters(report.Gutters, minPanel);
// 5. 算切点
report.Cuts = ComputeCuts(width, report.Gutters, options);
if (options.Columns <= 1) report.Cuts = DropNarrowOutliers(report.Cuts, minPanel);
// 6. 逐栏裁剪
for (int i = 0; i < report.Cuts.Length - 1; i++)
{
var rect = new Rectangle(report.Cuts[i], 0,
report.Cuts[i + 1] - report.Cuts[i], height);
if (rect.Width <= 0) continue;
Bitmap panel = Crop(source, rect);
if (options.TrimWhiteBorder) panel = TrimByInk(panel, options);
var item = new SplitPanel
{
Index = i + 1,
Left = rect.Left,
Right = rect.Right,
Width = panel.Width,
Height = panel.Height
};
if (outputDir == null)
{
item.Image = panel; // 留在内存
}
else
{
string path = Path.Combine(outputDir, $"col{i + 1}.png");
panel.Save(path, ImageFormat.Png);
panel.Dispose(); // 落盘后立刻释放
item.FilePath = path;
}
report.Panels.Add(item);
}
return report;
}
catch
{
report.Dispose(); // 中途失败不泄漏位图
throw;
}
}
3.9 对外入口
只暴露两个方法,一个不落盘、一个落盘:
csharp
public static class ColumnSplitter
{
/// <summary>
/// 切分图片但不落盘,每栏位图放在 SplitPanel.Image 中。
/// </summary>
public static SplitReport Split(string sourcePath, SplitOptions options = null)
{
if (!File.Exists(sourcePath)) throw new FileNotFoundException("图片不存在", sourcePath);
using (var source = LoadImage(sourcePath))
{
return SplitInternal(source, options, null);
}
}
/// <summary>
/// 切分图片并把每栏保存为 PNG(col1.png、col2.png ...)。
/// </summary>
public static SplitReport SplitAndSave(string sourcePath, string outputDir, SplitOptions options = null)
{
if (!File.Exists(sourcePath)) throw new FileNotFoundException("图片不存在", sourcePath);
if (string.IsNullOrEmpty(outputDir)) throw new ArgumentException("输出目录不能为空", nameof(outputDir));
Directory.CreateDirectory(outputDir);
using (var source = LoadImage(sourcePath))
{
return SplitInternal(source, options, outputDir);
}
}
/// <summary>
/// 统一按文件流加载,避免源文件被长期占用。
/// </summary>
private static Bitmap LoadImage(string path)
{
byte[] bytes = File.ReadAllBytes(path);
using (var ms = new MemoryStream(bytes))
using (var fromStream = new Bitmap(ms))
{
return new Bitmap(fromStream);
}
}
}
LoadImage 这里绕了一圈,是个必须知道的坑:new Bitmap(stream) 要求流在位图的整个生命周期内保持打开,否则后续访问像素会抛 ArgumentException。如果直接用 new Bitmap(path),图会被锁定到进程退出 ,用户想在资源管理器里删都删不掉。所以先读成字节数组,再解码,最后 new Bitmap(bitmap) 复制一份彻底脱离流。
四、三个必须处理的问题
把上面几个模块单独拆出来看,其实每个都对应一个真实踩过的坑:
| 坑 | 现象 | 解法 |
|---|---|---|
| 白缝被杂墨打断 | 切出 1~2px 宽的碎片图 | MergeGutters:中间墨迹窄于最小栏宽就合并 |
| 纸边被当成白缝 | 每张图左右各多出一条废图 | AddGutter 的 EdgeMargin 检查 |
| 扫描歪斜 / 页码边条 | 切点落在纸边,最后一栏是个窄条 | DropNarrowOutliers 中位数剔除 |
还有一个隐藏的工程坑:位图泄漏 。切图会产生大量中间位图,如果用 using 之外的方式创建又忘了 Dispose,跑几百张图后 GDI+ 句柄耗尽,程序直接黑屏或抛 OutOfMemoryException。所以 SplitReport 实现了 IDisposable,统一在 Dispose 里回收所有 SplitPanel.Image,调用方只需要 using 一下。
五、性能实测
测试环境:.NET 10 / Release / Windows 11,样本图 2319×1375(共 3,188,625 像素),每个数字跑一次预热后计时。
| 取像素方式 | 灰度统计耗时 | 说明 |
|---|---|---|
Bitmap.GetPixel 逐点读 |
483 ms | 每像素一次 GDI+ 互操作调用 |
LockBits + Marshal.Copy |
30 ms | 一次性块拷贝,之后纯托管内存访问 |
加速比约 16 倍。
顺带解释一个常见疑问:网上常说 GetPixel 慢到"数秒",为什么这里只有 483ms?因为本次基准每像素只调一次 GetPixel 拿 Color。如果你按最原始的写法分别取 GetPixel(x,y).R、.G、.B,那就是 3 次调用,耗时直接翻三倍到 1.4 秒左右------"数秒"的说法多半来自这种写法或更老的机器。
完整切分流程(含 PNG 解码、投影、切点计算、裁剪、白边收紧)的情况:
| 阶段 | 耗时 |
|---|---|
| 完整切分(5 次中位数) | 107 ms |
| 其中:取像素 + 投影 | 30 ms |
| 其余:PNG 解码 + 裁剪 + 收边 | ~77 ms |
可以看到,算法本身只占三成时间,剩下的七成都在解码和 GDI+ 绘制上。如果换成批处理场景,把这个 107ms 再压缩的优化方向不是改算法,而是:
- 复用解码后的位图,避免每张图重新解码;
- 落盘时用
ImageFormat.Jpeg或降低质量参数,PNG 编码是这里的大头; - 如果不需要原始分辨率输出,先缩放再切,耗时会按面积平方级下降。
顺带说,改用 unsafe 指针遍历可以再快一点(省掉数组边界检查),但收益只有 10%~20%,而 AllowUnsafeBlocks 带来的代码审查成本不值得。LockBits 已经能拿到绝大部分收益了。
六、参数怎么调
| 参数 | 默认值 | 什么时候要改 |
|---|---|---|
DarkThreshold |
130 | 铅笔、浅灰印刷体调高到 160~180;深色背景扫描件需要先反相 |
MaxInkRatio |
0.004 | 噪点多的图调高到 0.01;稀疏手写稿调低到 0.001 |
MinGutterWidth |
6 | 字间距大的排版(如英文)可调到 10~15 |
EdgeMargin |
4 | 扫描白边很宽时可加大,避免边缘阴影被当白缝 |
Columns |
0(自动) | 明确知道是几栏时直接指定,最稳 |
MinPanelWidth |
0(图宽 4%) | 图很宽(>4000px)时建议手动设成 100 以上 |
TrimWhiteBorder |
true | 需要保留原始排版比例时设为 false |
调参的推荐顺序:先固定 Columns,再调 DarkThreshold,最后才碰 MaxInkRatio。因为栏数是最确定的先验知识,先锁死它,剩下的参数只需要保证"能检出足够多的白缝"即可。
七、怎么用
控制台:批量切
csharp
using System;
using ImageSplitter;
class Program
{
static void Main()
{
var report = ColumnSplitter.SplitAndSave(
@"D:\scan\page01.png",
@"D:\scan\out",
new SplitOptions { Columns = 2 });
Console.WriteLine($"切点:{string.Join(", ", report.Cuts)}");
foreach (var p in report.Panels)
Console.WriteLine($"col{p.Index} {p.Width}×{p.Height} {p.FilePath}");
}
}
WinForms:实时预览切点
界面上把 Cuts 画成虚线,用户不用点"切图"就能看到切点位置:
csharp
private void OnPreviewPaint(object sender, PaintEventArgs e)
{
Graphics g = e.Graphics;
g.Clear(Color.White);
if (_source == null) return;
Rectangle area = picPreview.ClientRectangle;
float scale = Math.Min((float)area.Width / _source.Width, (float)area.Height / _source.Height);
float width = _source.Width * scale;
float height = _source.Height * scale;
float offsetX = (area.Width - width) / 2f;
float offsetY = (area.Height - height) / 2f;
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
g.DrawImage(_source, offsetX, offsetY, width, height);
if (_report == null || _report.Cuts.Length < 2) return;
// 首尾切点是图片边界,不画
using (var pen = new Pen(Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dash })
{
for (int i = 1; i < _report.Cuts.Length - 1; i++)
{
float x = offsetX + _report.Cuts[i] * scale;
g.DrawLine(pen, x, offsetY, x, offsetY + height);
}
}
}
切图是 CPU 密集操作,务必放到后台线程,否则界面会卡住:
csharp
SplitReport report = await Task.Run(() => ColumnSplitter.Split(path, options));
八、这个算法的边界
说得实在一点,垂直投影法不是万能的,以下几种情况它会失效:
| 场景 | 原因 | 应对方向 |
|---|---|---|
| 横排分页(上下分块) | 只做了列投影 | 加一次水平投影,按行切页 |
| 图片倾斜 >2° | 列投影被"抹平",白缝消失 | 先做倾斜校正(霍夫变换检测基线) |
| 深色背景 + 浅色字 | 灰度阈值方向反了 | 先反相,或把判定改成"与背景色的距离" |
| 栏间有跨栏大图/表格 | 白缝被完全堵死 | 引入连通域分析,或允许人工指定切点 |
| 手写体连笔跨栏 | 墨迹确实连在一起 | 无解,只能人工干预 |
之所以不上 OpenCV,是因为这个场景下投影法的性价比极高:零依赖、几十毫秒、参数可解释。真到了需要处理倾斜、复杂版面的程度,再引入 OpenCV 的形态学操作也不迟。
九、总结
回顾一下这个算法做对的三件事:
- 降维:把"找栏"这个二维视觉问题,转成一维数组找低谷,复杂度从 O(W×H) 的连通域分析降到 O(W×H) 的计数 + O(W) 的扫描。
- 抗干扰:三层过滤(白缝最小宽度、边缘留白、最小栏宽合并)+ 一层离群剔除,让自动模式在真实扫描件上可用。
- 性能 :
LockBits块拷贝替代GetPixel,灰度统计从 483ms 降到 30ms,整页切分稳定在 107ms 左右。
完整代码约 700 行(含注释),无第三方依赖(除 System.Drawing.Common),可以直接放进任何 .NET 8 Windows 项目。
如果这篇文章帮你省下了调参的时间,欢迎点赞收藏。评论区可以聊聊你遇到的扫描件版式问题,我可以针对性补方案。
标签:C#、图像处理、.NET 8、算法、扫描件切图、垂直投影