C# 实现扫描件自动分栏切图

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):

  1. 对图片的每一列 x,从上到下统计"墨迹像素"的数量;
  2. 除以图片高度,得到这一列的墨迹占比 ink[x](0~1);
  3. 把所有 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);
        }
    }
}

这里有两个细节值得单独说:

  1. 必须先 DrawImage 归一化成 24bpp 。GDI+ 的 LockBits 可以直接锁原格式,但那样就得为 Format32bppArgb、Format8bppIndexed、调色板索引等各种格式分别写解析逻辑,得不偿失。归一化后每个像素固定 3 字节(B、G、R),索引计算变得无比简单。
  2. 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 的形态学操作也不迟。

九、总结

回顾一下这个算法做对的三件事:

  1. 降维:把"找栏"这个二维视觉问题,转成一维数组找低谷,复杂度从 O(W×H) 的连通域分析降到 O(W×H) 的计数 + O(W) 的扫描。
  2. 抗干扰:三层过滤(白缝最小宽度、边缘留白、最小栏宽合并)+ 一层离群剔除,让自动模式在真实扫描件上可用。
  3. 性能 :LockBits 块拷贝替代 GetPixel,灰度统计从 483ms 降到 30ms,整页切分稳定在 107ms 左右。

完整代码约 700 行(含注释),无第三方依赖(除 System.Drawing.Common),可以直接放进任何 .NET 8 Windows 项目。

如果这篇文章帮你省下了调参的时间,欢迎点赞收藏。评论区可以聊聊你遇到的扫描件版式问题,我可以针对性补方案。


标签:C#、图像处理、.NET 8、算法、扫描件切图、垂直投影

相关推荐
Oiiouui2 小时前
Godot-GridGame架构数据层篇
架构·godot·1024程序员节
java_nnnn2 小时前
Redis 核心特性与应用场景:从底层原理到分布式系统应用
数据库·redis·1024程序员节
Random Glow2 小时前
一条 SQL 从输入到返回,中间经历了什么
数据库·后端·mysql·性能优化·innodb·1024程序员节
宝绿特塑业再生科技2 小时前
板材挤出过程振动与尺寸波动:户外家具行业转向再生料后,工艺稳定性成为核心议题
科技·1024程序员节·户外家具·再生塑料·塑木桌椅·hdpe再生颗粒
comedate2 小时前
【SMLib】SMLib 五层架构深度解析:SMLib / SM_API / SM_API_USD / SmPyLib 各管什么
架构·cad·1024程序员节·openusd·smlib·nurbs
纪念 2292 小时前
C++ 算法(四)
1024程序员节
餐赢计餐饮策划设计2 小时前
如何判断国潮养生设计是否适合自己?
python·1024程序员节
lzx_0022 小时前
Linux 开发工具(六) 版本管理器git的简单使用
git·1024程序员节
讯捷蓝达2 小时前
华为 2288H V5 服务器不开机,显示 “888“ 是不是通病?
1024程序员节