简介:这套资源是一套基于C#与.NET框架开发的全套PACS(医学影像存档与通信系统)源码,适合医疗信息化开发者、HIS/LIS系统集成人员以及想了解医学影像技术的软件工程师。源码围绕图像采集、DICOM通信、SQL数据库存储、工作站显示等核心模块展开,完整呈现了从患者登记、影像上传、序列管理到报告查看的临床业务流程。资源以rar压缩包形式提供,包体约399.87MB,内含项目源码、部署说明及使用文档,便于快速还原系统环境并开展二次开发;部署部分覆盖SQL服务器配置、DICOM服务启动、数据库脚本导入等关键环节,使用部分则说明如何通过.NET控件完成患者信息添加、影像上传和报告查看。虽然平台未单独列明文件总数与类型清单,但压缩包内模块划分清晰,适合作为PACS入门学习和再工程基线;已有554人浏览学习,值得希望借助C#/.NET医疗项目提升实战能力、或准备构建类似影像管理系统的开发者参考。
1. 为什么值得拆一套C#版PACS源码:DICOM协议、.NET控件与全链路落地
如果要在 .NET 技术栈里挑一门同时覆盖网络通信、文件归档、桌面交互和图像处理的综合实战,PACS(医学影像归档与通信系统)源码算是最硬核的方向之一。它既不是普通 CRUD,也不只是控件皮肤,而是围绕 DICOM 协议构建的完整影像链路:设备端用 C-STORE 把影像推过来,服务端解析 Tag、落盘归档,阅片端再把像素渲染到 WinForms 控件上。对做 C#/.NET 软件或插件开发的从业者来说,这套源码的价值在于——你能看到协议层怎么建模、数据层怎么做对象管理、UI 层怎么和像素缓冲交互,而不是只在业务系统里写增删改查。
这套资源解决的核心问题很具体:影像科每天产生大量 CT/MR/DX 数据,如果靠手动拷贝、按患者文件夹堆放,查询和调阅都会失控。PACS 把“接收—存储—调阅—胶片打印”串成一条自动化管线,技师推完片子就能在阅片端看到。适合三类人:刚接触医学影像的 C# 开发、想给现有 .NET 系统加影像模块的工程师、以及需要评估自研 PACS 成本的团队负责人。下文按我拆这套源码时的思路,从协议、架构、代码实现到排错逐层展开。
2. 先摸清PACS的四块底:协议、架构、存储与数据流决定代码往哪读
拿到任何一套 PACS 源码,第一步不是打开解决方案,而是先确认它站在哪套协议和架构之上。很多人一上来就搜“图像显示在哪”,结果在 WinForms 控件里绕了半天,却不知道影像数据是怎么从网络进到硬盘的。这章把四个底层问题理清楚,后面读代码才有坐标。
2.1 DICOM不是文件格式,是通信规则:C-STORE/C-FIND/C-MOVE的职责边界
DICOM 常被误解为“一种医学图像格式”,实际上它是一整套通信协议和数据结构标准。你在源码里看到的 .dcm 文件,只是 DICOM 标准里“介质存储”那一部分的具体化形式。真正的协议核心是网络通信服务:C-STORE 负责把影像从 SCU(客户端)推送到 SCP(服务端),C-FIND 用于在远端查询患者/检查列表,C-MOVE 则请求对方把影像传输到第三个节点。理解这三者,你才能分清源码里哪些模块是接收、哪些模块是查询代理。
读这套源码时,先找服务端入口。通常是一个继承自 DICOM 服务基类的类,重写了关联响应和 C-STORE 请求处理函数。关联(Association)阶段相当于 TCP 握手之外的另一层握手:SCU 发送关联请求,声明自己要用的抽象语法(SOP Class)和传输语法(Transfer Syntax),SCP 逐项确认。如果传输语法不匹配,比如设备端用 JPEG-LS 压缩,服务端只接收未压缩的格式,关联会被拒绝或转入降级协商。
C-MOVE 的职责也需要单独确认。很多 PACS 的 C-MOVE 不是从设备拉图,而是把请求转发给存储节点执行。源码里如果出现 MoveOriginator 相关的 Tag,说明它在追踪移动请求的发起者。这块逻辑容易和 C-STORE 混淆,判断方法很简单:C-STORE 只管“传”,C-MOVE 管“让谁传给谁”。读代码时盯住服务类里实现了哪几个接口,就能快速知道这套源码覆盖了哪些服务,不支持的扩展点在哪。
2.2 C/S架构与影像存储分层:从接收到归档的链路拆解
PACS 多数采用 C/S 架构,服务端负责接收和归档,客户端负责展示和操作。这套 C# 源码也不例外。客户端通过 DICOM 协议访问服务端,而不是直接读数据库里的 BLOB,这是医学影像系统区别于普通文件管理系统的关键点——影像数据必须保留 DICOM 原貌,包括患者信息、设备参数、序列属性等几千个 Tag,拆成独立字段存储反而会丢失原始语义。
存储层通常是分层设计。第一层是接收缓冲目录,SCP 收到影像后先落到一个临时的文件系统目录,再异步写入正式存储池。这套源码若在接收函数里直接写数据库,基本可以判断它并不适合高并发场景。正确的做法是解耦接收与归档:接收线程只管把 DICOM 文件完整写到磁盘,另一个服务或后台进程扫描缓冲目录,解析 SOP Instance UID、Study Instance UID 等关键 Tag,写入文件索引库并移动文件到按检查归档的目录结构中。
文件目录的组织方式也决定了后续调阅效率。合理结构一般是“患者ID/检查UID/序列UID/实例UID.dcm”这种层级,这样通过 UID 直接拼接路径就能定位文件,不需要每次查数据库。数据库只保存检查状态和索引信息,而不是影像本体。拆源码时重点看路径生成函数——如果它用数据库自增 ID 做目录名,迁移数据或跨院互认时会非常痛苦。
2.3 与HIS/RIS的对接:HL7消息与Worklist的联动方式
PACS 不是孤立系统,它需要从 RIS(放射信息系统)获取检查申请单,这就是 Worklist 服务的场景。设备端在拍片前会向 PACS 发起 C-FIND Worklist 查询,获取今天要做的患者列表和检查部位。源码里如果实现了 Worklist SCP,通常会在查询响应里拼装患者姓名、检查描述、体位等信息,这些字段来自数据库或对接消息。
对接方式最常见的是 HL7 消息。RIS 通过 HL7 的 ADT(患者入出转)和 ORM(医嘱)消息通知 PACS 新检查、修改或取消。这套源码若包含消息监听服务,一般会监听一个 TCP 端口解析 HL7 管道分隔文本。这里有个隐藏坑:HL7 字段分隔符是竖线(|),但组件分隔符是脱字符(^),解析时必须注意转义字符。很多联调问题就出在“把组件分隔符也按字段处理”。源码中如果有专门的 HL7 工具类,读它时顺手确认转义处理是否完整。
从这套源码的边界也能看出它的定位:如果 Worklist 和 HL7 都齐全,说明它是一套完整的科室级 PACS;如果只有 C-STORE 和查询,那它更接近影像归档存储库。拆源码前先认边界,能帮你决定是整体复用还是只抽取部分模块。
3. C#实现DICOM接收与影像解析:从.NET服务到控件显示一步步落代码
原理看清后,就该动代码了。这套源码的核心是把 DICOM 协议栈和 WinForms 控件串起来。这章我按“接收—解析—显示”三个环节,拆出最小可复现的代码路径。如果你之前只写过业务系统,这一章的节奏会比较快,但每一步都是后续排错的基础。
3.1 用fo-dicom搭建C-STORE SCP接收端:AE Title与传输语法协商
DICOM 服务端在 .NET 生态里最常见的实现是 fo-dicom 库。它把关联协商、PDU 解析、传输语法编解码都封装好了,我们只需要实现服务接口。下面的代码是接收端核心骨架,负责接收设备推送的 DICOM 文件并落盘:
// CStoreSCP.cs using Dicom; using Dicom.Network; using Dicom.Log; public class CStoreSCP : DicomService, IDicomServiceProvider, IDicomCStoreProvider { private readonly string _storagePath; public CStoreSCP(Stream stream, DicomAssociation association) : base(stream, association) { _storagePath = Path.Combine(AppContext.BaseDirectory, "STORAGE"); Directory.CreateDirectory(_storagePath); } // 关联阶段:校验请求方身份,决定接受还是拒绝 public async Task OnReceiveAssociationRequestAsync(DicomAssociation association) { // 常见做法:先检查CalledAE Title是否等于本服务配置的AE if (association.CalledAE != "PACS_SERVER") { await SendAssociationRejectAsync( DicomRejectResult.Permanent, DicomRejectSource.ServiceUser, DicomRejectReason.CalledAENotRecognized); return; } // 逐个确认表示上下文,接收方可以只接受未压缩语法 foreach (var pc in association.PresentationContexts) { pc.SetResult(DicomPresentationContextResult.Accept); } await SendAssociationAcceptAsync(association); } // C-STORE请求处理:接收整个DICOM文件并保存 public async Task<DicomCStoreResponse> OnCStoreRequestAsync(DicomCStoreRequest request) { try { var dataset = request.Dataset; var sopInstanceUid = dataset.GetSingleValue<string>(DicomTag.SOPInstanceUID); var studyUid = dataset.GetSingleValue<string>(DicomTag.StudyInstanceUID); var seriesUid = dataset.GetSingleValue<string>(DicomTag.SeriesInstanceUID); // 按 患者/检查/序列/实例 四级目录落盘 var saveDir = Path.Combine(_storagePath, studyUid, seriesUid); Directory.CreateDirectory(saveDir); var savePath = Path.Combine(saveDir, sopInstanceUid + ".dcm"); await Task.Run(() => request.File.Save(savePath)); return new DicomCStoreResponse(request, DicomStatus.Success); } catch (Exception ex) { // 生产环境建议记录详细日志后再返回失败 Logger.Error($"C-STORE failed: {ex}"); return new DicomCStoreResponse(request, DicomStatus.ProcessingFailure); } } // 关联释放与异常处理的默认实现 public Task OnReceiveAssociationReleaseRequestAsync(DicomAssociation association) { return SendAssociationReleaseResponseAsync(); } public void OnReceiveAbort(DicomAbort aborted) { Logger.Warn($"Association aborted: {aborted.Reason}"); } }这段代码的关键在于三个接口方法:关联请求、C-STORE 请求、释放请求。OnReceiveAssociationRequestAsync 里做的 AE Title 校验是第一步过滤——如果请求方发来的 CalledAE 不对,直接拒绝,避免无关设备往存储里写垃圾数据。OnCStoreRequestAsync 是接收主逻辑,调 request.File.Save 会按照 DICOM 文件的原始编码保存,不改变任何 Tag。
参数说明里最需要注意的是 SOPInstanceUID 和 StudyInstanceUID 这类 UID。UID 在 DICOM 里是全局唯一的字符串标识,不能为空。如果设备端数据不规范,这里可能拿到空串,导致目录路径变成 STORAGE\ 的根目录,文件四散。兜底做法是用 Guid 生成内部标识,但这会丢失与原始影像的关联关系,所以我在代码里保留了 UID 优先的策略。
3.2 影像解析与像素数据处理:从DICOM Tag到可显示图像
接收只是开始,影像只有解析成像素才能上屏。DICOM 文件里的像素数据存于 Tag (7FE0,0010),但它不一定是原生 RGB。CT 影像一般是 Monochrome2(灰度),每个像素 12 或 16 位,可能存在高位填充。直接按字节读出来显示会得到一张黑屏或花屏。正确的解析路径是先读 PhotometricInterpretation(光度解释)、BitsAllocated(分配位数)、BitsStored(有效位数)、PixelRepresentation(有符号/无符号)这几个关键 Tag。
// PixelDataHelper.cs using Dicom; using Dicom.Imaging; using Dicom.Imaging.Codec; using System.Drawing; using System.Drawing.Imaging; public static class PixelDataHelper { public static Bitmap ToBitmap(DicomDataset dataset, double windowCenter, double windowWidth) { // 创建一个像素访问器,fo-dicom会按传输语法自动解码 var pixelData = DicomPixelData.Create(dataset); // 灰度图像取第一个帧 var frame = pixelData.GetFrame(0); var bitsAllocated = dataset.GetSingleValue<ushort>(DicomTag.BitsAllocated); var bitsStored = dataset.GetSingleValue<ushort>(DicomTag.BitsStored); var pixelRepresentation = dataset.GetSingleValue<ushort>(DicomTag.PixelRepresentation); // 把帧数据读入 ushort 数组,注意大小端已在解码时处理 var buffer = new ushort[frame.Size]; frame.Data.CopyTo(buffer, 0); // 调窗映射:线性变换 [min,max] -> [0,255] var min = (int)(windowCenter - windowWidth / 2.0); var max = (int)(windowCenter + windowWidth / 2.0); var bmp = new Bitmap(pixelData.Width, pixelData.Height, PixelFormat.Format8bppIndexed); var rect = new Rectangle(0, 0, bmp.Width, bmp.Height); var bitmapData = bmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format8bppIndexed); unsafe { var ptr = (byte*)bitmapData.Scan0; for (int i = 0; i < buffer.Length; i++) { // CT值通常按无符号处理,但部分设备会存有符号值 int val = pixelRepresentation == 0 ? buffer[i] : (short)buffer[i]; val = val >> (bitsAllocated - bitsStored); // 右移去除高位填充 int gray = (val - min) * 255 / (max - min); if (gray < 0) gray = 0; if (gray > 255) gray = 255; ptr[i] = (byte)gray; } } bmp.UnlockBits(bitmapData); return bmp; } }这段代码里的重点不是循环本身,而是“右移”这一步。BitsAllocated=16、BitsStored=12 是 CT 常见配置,设备把有效 12 位存在 16 位容器的低 12 位,高 4 位填充 0。如果不右移,值域会偏大,图像整体偏白。PixelRepresentation 判断有符号还是无符号也很关键,MRI 图像的像素值可以是负的,如果当成无符号数读,整张图的灰度会错乱。
调窗参数从 WindowCenter 和 WindowWidth 两个 Tag 取,这是设备给出的默认值。实际阅片时,医生会手动调整窗宽窗位(比如肺窗、骨窗),所以这个函数在源码里会被多次调用。性能方面要注意:GetFrame(0) 只取第一帧,多帧影像(如动态增强)需要循环取帧,建议在 3.3 的界面里做帧缓存,避免每次重绘都解码一整遍。
3.3 WinForms控件展示与调窗:.NET下的交互实现
显示层在这套源码里用的是 .NET 原生的 WinForms 控件。把像素数据渲染到 PictureBox 是简单做法,但它自带的缩放算法和像素格式处理不一定符合阅片场景,尤其是需要精确 1:1 显示、平移和窗宽窗位实时调节时,更可控的方式是自定义控件。
// ImageViewer.cs — 自定义图片显示控件 using System.Drawing; using System.Windows.Forms; public class ImageViewer : Control { private Bitmap _image; private double _windowCenter; private double _windowWidth; private Point _lastMousePos; public ImageViewer() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); } public void LoadImage(Bitmap image, double wc, double ww) { _image = image; _windowCenter = wc; _windowWidth = ww; Invalidate(); } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (_image == null) return; var g = e.Graphics; // 按控件大小等比缩放绘制,保持影像纵横比 var scale = Math.Min((double)Width / _image.Width, (double)Height / _image.Height); var drawWidth = (int)(_image.Width * scale); var drawHeight = (int)(_image.Height * scale); var offsetX = (Width - drawWidth) / 2; var offsetY = (Height - drawHeight) / 2; g.DrawImage(_image, new Rectangle(offsetX, offsetY, drawWidth, drawHeight)); } protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); _lastMousePos = e.Location; } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (e.Button == MouseButtons.Left) { // 拖拽平移:改变显示偏移量 // 实现时把偏移量保存为类字段,并调用 Invalidate() 刷新 Invalidate(); } } }自定义控件的好处是鼠标事件完全可控。OnMouseMove 里根据鼠标位移更新平移量,滚轮事件里可以调整窗宽窗位,OnPaint 里重新计算映射——这是普通 PictureBox 做不到的。代码里的 SetStyle 双缓冲是必备的,否则拖动图片时会有闪烁。
控件与像素数据之间建议加一层 ViewModel:控件的 LoadImage 传入的是调窗后的 Bitmap,而调窗操作放在 3.2 的处理函数里。这样 UI 层不直接碰 DICOM Tag,切换窗宽窗位时只用重新调用一次 ToBitmap 再 LoadImage。要注意的是,8bpp 索引色 Bitmap 在缩放时可能出现色带,处理方式是重绘时用 InterpolationMode.HighQualityBilinear 或把像素数据转成 24bpp RGB 再显示。
4. PACS开发避坑:AE Title、压缩语法、中文乱码与显示异常最常出问题
拆这套源码、跑通链路的过程中,我记录了几个高频踩坑点。这些问题在验收阶段尤其容易出现,每一条都按现象、原因、解决整理如下。
4.1 设备连接被拒:AE Title大小写不匹配
现象:设备端配置好 IP 和端口,发送影像时 PACS 直接返回 Association Rejected,日志显示 CalledAENotRecognized。
原因:DICOM 的 AE Title 是大小写敏感的应用实体名称,设备端配置的 CalledAE 与服务端 Accept 的完全字符串比对,差一个大小写字母都判失败。很多现场实施人员会习惯性把 AE Title 写成大写或小写,导致两边不一致。
解决:服务端关联请求处理里增加宽松校验,常见做法是允许配置一个 AE Title 列表,匹配时统一转大写后再比较。也可以在拒收前打日志,把请求里的 CalledAE 和本机配置的都打印出来,方便现场快速排查。从那以后我每次部署 PACS 都会先手动测试两端 AE Title 的一致性,而不是等设备推图时报错。
4.2 影像接收失败:压缩传输语法未注册
现象:普通 CT 影像接收正常,但某台设备发来压缩后的 MR 影像时,C-STORE 请求被拒绝或返回 Cannot Understand。
原因:DICOM 关联协商时,设备端会在表示上下文里列出它支持的传输语法,比如 JPEG Baseline、JPEG-LS。服务端如果只接受了隐式 VR Little Endian(未压缩),设备就会放弃发送,或者按未压缩重传(很多设备默认不重传,直接报错)。
解决:在关联请求处理循环里,把设备端提出的所有传输语法全部 SetResult(Accept)。fo-dicom 在接收到压缩帧数据时会自动调用编解码器,不需要手动干预,前提是项目里引用了对应的编解码程序集。我一般会在 OnReceiveAssociationRequestAsync 里加一个循环:不管 pc.AbstractSyntax 和 TransferSyntax 是什么,都先 Accept,然后在 C-STORE 处理时再针对不支持的语法返回失败。这样可以先让关联建立,拿到完整错误信息,而不是在握手阶段就断掉。
4.3 患者姓名中文乱码:字符集声明缺失或解析错误
现象:设备端、服务端、阅片端都是中文操作系统,但 PACS 里显示的患者姓名是乱码,英文姓名正常,中文全部变成问号或特殊字符。
原因:DICOM 默认字符集是 ISO-IR 6(ASCII)。中文姓名必须在 SpecificCharacterSet 这个 Tag(0008,0005)里声明,比如 GB18030 或 ISO_IR 192(UTF-8)。部分老设备只翻译数据不声明字符集,或者声明了但 PACS 端没有按声明解码。
解决:解析 Dataset 时先读 SpecificCharacterSet,再用对应编码转换 PatientName。fo-dicom 的 DicomDataset 在读取字符串时本身支持字符集转换,但前提是数据集里正确声明了该 Tag。如果设备端不声明,常见的兜底做法是在接收后强制将 PatientName 按 GBK 解码一遍,把转出来的非法字节纠正。注意这个兜底不能用于所有 Tag,只处理 PersonName 相关字段,否则会把本来的合法字符破坏掉。
4.4 保存文件名非法:SOPInstanceUID包含路径分隔符
现象:接收设备推送的文件,部分能保存,部分在 Directory.CreateDirectory 或文件写入时报路径错误。
原因:DICOM 标准对 UID 的字符集限制为数字和点号,但个别设备存在不规范实现,把 UID 和附加信息拼接,比如加了“.”加设备号,甚至包含绝对路径的分隔符。这种情况在第三方导出工具生成的 DICOM 文件里偶尔出现。
解决:保存路径里的每个 UID 段都要做合法性过滤。我一般会写一个 SanitizeUid 函数,把非数字和非点号的字符替换为下划线,同时保证替换后的字符串不为空。更深一层的做法是不依赖 UID 做文件系统路径,而是在数据库里建 UID 与存储路径的映射,文件名为 GUID,索引表保存真实 UID。代价是查文件要多一次数据库 IO,好处是彻底规避文件名非法的问题。
4.5 图像显示偏白或全黑:BitsStored与BitsAllocated的位深坑
现象:接收的是 CT 影像,显示时整个图像一片白,或者只有极少数像素有深浅变化,窗宽窗位数据也正常。
原因:这通常不是调窗函数的问题,而是像素解析时没有处理 BitsStored 与 BitsAllocated 的差异。如果 BitsAllocated=16、BitsStored=12,像素的低 12 位有效,但高 4 位可能是噪声或保留位。直接把 ushort 值映射到 0-255,值域被拉高,图像自然偏白。
解决:在 3.2 的 ToBitmap 里做右移处理:val = val >> (bitsAllocated - bitsStored)。同时注意 PixelRepresentation=1(有符号)时,要先转 short 再移位,否则负数补位会全变成零。还有一个细节是有些压缩格式(如 JPEG-LS)解码后像素是归一化到 0 到 2^bits-1 范围的,这时不需要右移,判断方式是看传输语法是否带 Lossless 字样。这个坑排查起来比较费时间,建议在显示端加一个像素值统计功能,显示当前鼠标位置的像素灰度值,能一眼看出数据范围是否合理。
5. 联调与进阶:用真实DICOM影像验证传输和显示,假影像一测就露馅
前面几章把代码链路走通了,最后一章说验证。很多人把“能接收到文件”当作联调完成,但接收成功只代表网络层通了,不代表影像数据完整、Tag 正确、显示可信。我习惯用三步走做验证,简单但能拦住大部分问题。
第一步是校验传输完整性。接收端保存文件后,计算 DICOM 像素数据的哈希,与发送端原始文件的像素数据哈希对比。不需要第三方工具,fo-dicom 的 DicomPixelData 可以直接读原始帧数据:
// 校验发送端和接收端同一影像的像素是否一致 var sentFile = DicomFile.Open(@"C:\raw\original.dcm"); var receivedFile = DicomFile.Open(@"C:\STORAGE\received.dcm"); var sentPixels = DicomPixelData.Create(sentFile.Dataset).GetFrame(0).Data; var receivedPixels = DicomPixelData.Create(receivedFile.Dataset).GetFrame(0).Data; Console.WriteLine($"像素字节数一致: {sentPixels.Length == receivedPixels.Length}"); Console.WriteLine($"像素内容一致: {sentPixels.SequenceEqual(receivedPixels)}");如果两边像素字节数相等但内容不一致,一般不是传输问题,而是文件保存时被重新编码了——比如服务端强制把传输语法改成小端未压缩。这时候要检查保存逻辑是否调用了 DicomFile.Save 之外的额外处理。
第二步是验证 Tag 解析。我通常会挑十个核心 Tag 做断言:PatientID、StudyDate、Modality、SeriesInstanceUID、SOPInstanceUID、Rows、Columns、BitsAllocated、PhotometricInterpretation、WindowCenter。任何一个为空或格式异常,都说明数据集不完整。记得把断言结果输出到日志,方便追溯。
第三步是真实影像的目视验证。打开显示界面,用已知病理特征的 DICOM 影像做测试——比如一张肺部 CT 应该能看到气管分叉和纵隔结构,一张膝关节 MR 应该能区分皮质骨和关节液。如果看到的图像纹理完全随机,大概率是像素位深或调窗映射的问题,而不是显示控件的问题。
进阶方向有两个值得投入。一个是多线程接收与存储的平衡:设备推图往往是并发多个实例,服务端如果单线程串行 Save,高峰期必然积压。常见做法是接收线程池 + 持久化队列,接收后先入队列,归档服务批量写盘。另一个是预取策略:阅片端打开一个患者时,提前把下一序列的影像加载进内存缓存,减少医生拖动滚动条时的等待感。
这两块做好,PACS 从“能跑”到“好用”的距离就缩短了大半。从那以后我每次做完影像模块联调,都会强制把这三步验证走一遍,像素哈希不一致就当场查,而不是等到科室反馈“图不对”。这套源码里如果遇到类似功能,建议也按这个顺序去验证。
希望帮到你。
本文还有配套的精品资源,点击获取