
简介面向仍在使用 Delphi7 维护老项目的开发者这份压缩包给出了一套可直接参考的海康 TCG-225 摄像机接入示例覆盖实时预览、手动抓拍、开启监听以及从抓拍事件中提取车牌号码等环节适合需要把旧系统与新网络摄像机对接、又不愿整体迁移技术栈的团队参考。包内共 54 个文件体积约 14.25MB其中 32 个 dll 与 7 个 lib 构成海康 SDK 的运行时与链接库3 个 pas、3 个 dcu 及配套 dfm、dpr 是 Pascal 源码与编译单元另附 properties、cfg 等配置文件层次清晰。需要说明的是启用监听时摄像机需单独配置监听地址与端口测试中以视频检测方式、默认绘制检测线触发抓拍识别效果稳定。目前已有 1396 人学习源码中的单元划分与配置项可作为二次开发起点帮助快速验证从预览到车牌识别的完整链路减少在 SDK 调用与监听配置上的反复试错。1. 从一台现场摄像机说起为什么还要用 Delphi 7 做预览和车牌识别上个月去一个地磅房改程序现场那台工控机是 Windows XP跑着一套 Delphi 7 写的称重收费程序显示器还是 4:3 的老液晶。客户新装了一台型号 TCG-225 的网络摄像机要求很简单车过磅的时候界面上要能看到实时画面抬杆之前把车牌号读出来自动填进单据。整套源码里没有 JSON 库没有 HTTP 客户端连字符串都是 AnsiString。这就是这个项目要解决的问题用 Delphi 7 把网络摄像机的实时预览、抓图、车牌识别串成一条能落地的链路。它适合两类人一类是维护老桌面系统的开发者手上是闸机、地磅、停车场、门禁这些场景另一类是想给旧程序加视频能力又不想整体重写的人。下面按我拆这套代码的顺序讲先看结构再看预览怎么通最后看车牌怎么读出来、怎么改。2. 技术栈与工程结构Delphi 7 设备网络SDK 的组合方式2.1 选型账网络SDK、播放库、识别引擎各管什么这套程序能跑起来靠的是三层东西拼在一起任何一层缺了都只能看到黑屏或者一堆错误码。第一层是设备网络SDK负责跟摄像机通信。登录、取通道信息、取码流、远程抓图、云台控制全在这一层。它本质是一个 32 位的动态库导出上百个 C 风格的函数Delphi 要做的就是把这些函数声明成stdcall的外部函数再把结构体按字节对齐翻译过来。第二层是播放库负责解码。网络SDK 拿到的是压缩码流H.264 或者 H.265不能直接画。播放库把码流喂进去解码出 YV12 图像数据再通过回调交给你。也有省事的做法把窗口句柄直接传给网络SDK让它内部调播放库自己画但那样你拿不到每一帧做抓拍和识别就不方便了。第三层是车牌识别引擎。这里有个分岔取决于摄像机型号。带智能算法的摄像机比如某些带车牌抓拍功能的型号设备端自己就能出结果SDK 提供接口直接取识别记录程序只负责轮询和入库这是最省事的一条路。而 TCG-225 这类普通网络枪机只有图像能力没有车牌算法就必须走第二条路本地抓一张图交给识别引擎做 OCR。至于为什么不换成 C# 或者 Qt现场的答案通常很朴素工控机上没装 .NET Framework 对应版本装了也不一定敢升Delphi 7 编译出来是原生 exe扔几个 DLL 到目录就能跑不需要运行时。用老工具维护老系统是成本最低的选择不是技术偏好。提示识别引擎的选择直接决定程序结构。设备端智能的方案程序里几乎没有图像处理代码本地 OCR 的方案抓拍质量、曝光、触发时机都会变成你要操心的事。2.2 工程目录与单元职责我把这套代码重构成了一个可维护的目录核心原则是「SDK 调用」和「业务逻辑」分开业务单元里不出现任何NET_DVR_开头的函数名。ParkingPlate/ ├─ bin/ 运行目录exe 与所有 DLL 同级 │ ├─ NetDevSdk.dll 设备网络SDK 主库 │ ├─ PlayCtrl.dll 播放/解码库 │ ├─ PlateEngine.dll 本地车牌识别引擎 │ └─ ParkingPlate.exe ├─ src/ │ ├─ uSdkType.pas SDK 结构体、常量、错误码 │ ├─ uSdkApi.pas SDK 函数声明与动态装载 │ ├─ uDevSession.pas 登录、注销、断线重连、通道枚举 │ ├─ uPreview.pas 预览启动、码流回调、解码回调 │ ├─ uCapture.pas 抓图、JPEG 落盘、文件命名 │ ├─ uPlate.pas 识别引擎封装接口 两种实现 │ ├─ uPlateBiz.pas 去抖、业务字段组装、入库 │ ├─ frmMain.pas 主窗体预览区与识别结果区 │ └─ frmPlate.pas 识别记录查询窗体 ├─ log/ 按天切分的运行日志 └─ config.ini 设备地址、端口、账号、通道、识别阈值这个划分带来的直接好处是换摄像机品牌只改uSdkApi和uDevSession换识别引擎只改uPlate里的实现类业务表结构变化只动uPlateBiz。我在现场遇到过最糟的情况是所有代码堆在一个 8000 行的窗体单元里SDK 句柄、SQL 语句、界面控件混在一起改一个抓图路径要重新通读全文。分层不是为了好看是为了半夜改需求的时候能定位。config.ini我坚持用 INI 而不是注册表理由很实际现场运维人员会用记事本改 IP但不会用 regedit。配置项包括设备 IP、端口、用户名、密码、通道号、抓图保存目录、识别置信度下限、去抖时间窗。密码在 INI 里是明文这一点要有心理准备能做的只有把文件权限收紧不要存在共享目录里。3. 预览链路拆解从设备登录到画面落到 TImage 的完整过程3.1 SDK 的动态加载与设备登录这里我不建议用external NetDevSdk.dll的静态声明。原因很直接DLL 缺一个依赖或者位数不对程序启动时直接报「无法定位程序入口」连日志都来不及写。动态加载能把失败点控制在初始化阶段还能给出明确提示。unit uSdkApi; interface const SDK_DLL NetDevSdk.dll; PLAY_DLL PlayCtrl.dll; type // 只列出本项目用到的几个其余按同样方式追加 TNetDvrInit function: BOOL; stdcall; TNetDvrCleanup procedure; stdcall; TNetDvrLoginV30 function(sDeviceIP, sUserName, sPassword: PAnsiChar; wPort: Word; var lpDeviceInfo: NET_DVR_DEVICEINFO_V30): LongInt; stdcall; TNetDvrLogout function(lUserID: LongInt): BOOL; stdcall; TNetDvrGetLastError function: DWord; stdcall; var NET_DVR_Init: TNetDvrInit nil; NET_DVR_Cleanup: TNetDvrCleanup nil; NET_DVR_Login_V30: TNetDvrLoginV30 nil; NET_DVR_Logout: TNetDvrLogout nil; NET_DVR_GetLastError: TNetDvrGetLastError nil; function SdkLoad: Boolean; procedure SdkUnload; implementation var GSdkHandle: THandle 0; function SdkLoad: Boolean; begin Result : False; if GSdkHandle 0 then GSdkHandle : LoadLibrary(PChar(SDK_DLL)); if GSdkHandle 0 then Exit; // 逐个取地址有一个取不到就整体失败避免半残状态 NET_DVR_Init : GetProcAddress(GSdkHandle, NET_DVR_Init); NET_DVR_Cleanup : GetProcAddress(GSdkHandle, NET_DVR_Cleanup); NET_DVR_Login_V30 : GetProcAddress(GSdkHandle, NET_DVR_Login_V30); NET_DVR_Logout : GetProcAddress(GSdkHandle, NET_DVR_Logout); NET_DVR_GetLastError : GetProcAddress(GSdkHandle, NET_DVR_GetLastError); Result : Assigned(NET_DVR_Init) and Assigned(NET_DVR_Login_V30); end;这段代码有两个点值得说。一是GetProcAddress必须逐个判空SDK 版本不一致时某些函数名会变取不到地址却继续调用就是访问违例。二是SdkLoad要在程序启动最早期调用并且全进程只调一次配合NET_DVR_Init也只调一次。登录本身不复杂坑在字符串。function TDevSession.Login(const AHost: string; APort: Word; const AUser, APwd: string): Boolean; var DevInfo: NET_DVR_DEVICEINFO_V30; R: LongInt; SHost, SUser, SPwd: AnsiString; // 必须用局部变量托住别写成临时对象 begin Result : False; FillChar(DevInfo, SizeOf(DevInfo), 0); SHost : AnsiString(AHost); SUser : AnsiString(AUser); SPwd : AnsiString(APwd); R : NET_DVR_Login_V30(PAnsiChar(SHost), APort, PAnsiChar(SUser), PAnsiChar(SPwd), DevInfo); if R 0 then begin FLastErr : NET_DVR_GetLastError; WriteLog(login fail, err IntToStr(FLastErr)); Exit; end; FUserId : R; FChanNum : DevInfo.byChanNum; // 模拟通道数 FStartCh : DevInfo.byStartChan; // 起始通道号不一定是 1 Result : True; end;参数说明一下返回值R是用户句柄所有后续接口都靠它返回负数表示失败具体原因用NET_DVR_GetLastError取。DevInfo是出参里面的byStartChan特别重要有些设备通道号从 1 开始有些从 33 开始硬编码通道号会导致预览黑屏但登录成功。登录成功后句柄要存好注销时原样传给NET_DVR_Logout。3.2 实时预览与解码回调预览有两种模式一种是传窗口句柄让 SDK 自己画一种是传一个空的hPlayWnd在码流回调里自己处理。做车牌识别必须选后者因为你需要拿到具体帧。启动预览的代码如下核心是填NET_DVR_PREVIEWINFOfunction TPreviewChannel.Start(const AUserID: LongInt; AChannel: LongInt): Boolean; var Info: NET_DVR_PREVIEWINFO; R: LongInt; begin Result : False; FillChar(Info, SizeOf(Info), 0); Info.lChannel : AChannel; Info.dwStreamType : 0; // 0主码流1子码流 Info.dwLinkMode : 0; // 0TCP1UDP2多播3RTP Info.hPlayWnd : 0; // 关键置空码流交给我们 Info.lRealPlayType : 0; // 0实时预览 FRealHandle : NET_DVR_RealPlay_V40(AUserID, Info, RealDataCallBack, Self); if FRealHandle 0 then begin WriteLog(realplay fail, err IntToStr(NET_DVR_GetLastError)); Exit; end; FPort : NET_DVR_GetRealPlayerIndex(FRealHandle); // 拿播放库端口号 PlayM4_SetDecCallBackEx(FPort, DecCallBack, Self, 0); PlayM4_Play(FPort, 0); Result : True; end;lChannel是通道号dwStreamType选主码流画面清晰但带宽高选子码流省带宽但抓图分辨率不够做识别dwLinkMode我一般固定 TCPUDP 在网络抖动时丢包画面会花hPlayWnd置 0 是整段代码的分水岭置了窗口句柄就再也拿不到帧。码流回调只是把数据转交给播放库真正的图像在解码回调里procedure RealDataCallBack(lRealHandle: LongInt; dwDataType: DWord; pBuffer: PByte; dwBufSize: DWord; pUser: Pointer); stdcall; begin case dwDataType of 0: ; // 原始码流一般不用 1: PlayM4_InputData(TPreviewChannel(pUser).FPort, pBuffer^, dwBufSize); // 私有码流 2: PlayM4_InputData(TPreviewChannel(pUser).FPort, pBuffer^, dwBufSize); // 标准视频 end; end; procedure DecCallBack(nPort: LongInt; pBuf: PChar; nSize: LongInt; pFrameInfo: PFRAME_INFO; nUser: LongInt); stdcall; var Ch: TPreviewChannel; begin Ch : TPreviewChannel(nUser); if pFrameInfo^.nType T_YV12 then Exit; // 3 YV12其他格式跳过 // 回调运行在 SDK 的解码线程里绝不能在这里直接摸 VCL 控件 Ch.PostYv12Frame(pBuf, pFrameInfo^.nWidth, pFrameInfo^.nHeight, nSize); end;pFrameInfo里的nType是像素格式nWidth/nHeight是尺寸nStamp是时间戳nElapse是相对时间。YV12 是 YUV420 的一种排列一帧数据 宽×高 的 Y 分量 两块 宽×高/4 的 UV 分量转 RGB 才能画到TImage。我一般的做法是在PostYv12Frame里把数据拷进一个缓冲队列然后Synchronize或者PostMessage丢给主线程由主线程转位图并Canvas.Draw到预览区。为什么不在回调里直接转见第 5 章。3.3 抓拍落盘识别前的关键一步抓图有两个来源一个是设备远程抓图一个是本地解码帧转位图。前者简单一行调用后者可控能保证抓到的就是当前看到的画面。function TDevSession.CaptureJpeg(AChannel: LongInt; const AFile: string): Boolean; var Para: NET_DVR_JPEGPARA; SFile: AnsiString; begin Para.wPicSize : 2; // 0QCIF 1CIF 24CIF 3720P 41080P按设备支持选 Para.wPicQuality : 0; // 0最好 1较好 2一般数字越小质量越高 SFile : AnsiString(AFile); Result : NET_DVR_CaptureJPEGPicture(FUserId, AChannel, Para, PAnsiChar(SFile)); if not Result then WriteLog(capture fail, err IntToStr(NET_DVR_GetLastError)); end;wPicSize决定抓图分辨率做车牌识别至少要 4CIF最好 1080P否则车牌在画面里只占几十个像素识别引擎也认不出来。wPicQuality影响 JPG 压缩率压缩太狠会丢高频细节车牌边缘糊成一团。文件命名我固定成通道号_年月日时分秒毫秒_序号.jpg落在按天分的子目录里。这样做的原因很实在出了问题客户能给一张图你从文件名就知道是哪台机器什么时候抓的。文件名里不要用中文有些识别引擎读中文路径会失败。抓拍之后还有一步容易被忽略判断这张图值不值得送去识别。画面全黑、全灰、车还没进区域送去识别就是浪费算力还会污染统计。我的做法是先算一次平均亮度低于阈值直接丢弃这是最廉价的前置过滤。4. 车牌识别模块一帧图到一条结构化结果4.1 识别引擎的两种接法前面说过识别引擎有两条路。设备端智能的方案代码里长这样// 设备端智能直接取识别结果不做本地 OCR function TDevicePlateRecognizer.Recognize(const ACtx: TPlateContext): TPlateResult; var Cond: NET_DVR_PLATE_COND; Rec: array[0..9] of NET_DVR_PLATE_RESULT; Ret, i: LongInt; begin FillChar(Cond, SizeOf(Cond), 0); Cond.dwChannel : ACtx.Channel; Cond.dwStartTime : ACtx.StartUnix; Cond.dwEndTime : ACtx.EndUnix; Ret : NET_DVR_GetPlateResult(ACtx.UserID, Cond, Rec[0], 10); Result.Valid : False; if Ret 0 then Exit; for i : 0 to Ret - 1 do if Trim(string(Rec[i].sLicense)) then begin Result.Plate : Trim(string(Rec[i].sLicense)); Result.Color : Rec[i].byColor; Result.Conf : 100; // 设备端不返回置信度按满分处理 Result.Valid : True; Break; end; end;本地 OCR 的方案把接口抽出来实现类换成调用识别引擎的 DLLtype IPlateRecognizer interface function Recognize(const ACtx: TPlateContext): TPlateResult; function Name: string; end; TLocalEngineRecognizer class(TInterfacedObject, IPlateRecognizer) public function Recognize(const ACtx: TPlateContext): TPlateResult; function Name: string; end; function TLocalEngineRecognizer.Recognize(const ACtx: TPlateContext): TPlateResult; var Buf: array[0..255] of AnsiChar; Conf: Integer; SFile: AnsiString; begin Result.Valid : False; SFile : AnsiString(ACtx.ImageFile); FillChar(Buf, SizeOf(Buf), 0); Conf : PlateEngine_RecognizeFile(PAnsiChar(SFile), Buf[0], SizeOf(Buf)); if (Conf ACtx.MinConf) or (StrLen(Buf) 0) then Exit; // 低于阈值当没识别到 Result.Plate : Trim(string(Buf)); Result.Conf : Conf; Result.Valid : True; end;抽接口的意义在于换引擎不改业务。现场跑过两套引擎一套对蓝牌准一套对新能源绿牌准最后做成配置项config.ini里写Enginelocal还是Enginedevice工厂函数读配置创建对应实现。这个改动只花了一个下午省掉了以后每次换供应商重写一遍的麻烦。4.2 结果结构与业务字段映射识别结果不只是车牌号。业务上要落库的字段至少有这些字段类型来源说明PlateNostring[16]识别引擎车牌号去空格后统一大写PlateColorByte识别引擎0 蓝 1 黄 2 白 3 黑 4 绿绿牌是新能源ConfidenceInteger识别引擎0-100低于阈值不进业务表CaptureTimeTDateTime抓图时刻用本地时间不用设备时间ChannelNoInteger配置哪台摄像机ImagePathstring抓图模块相对路径便于迁移WeightNostring业务地磅单号关联称重记录建表语句CREATE TABLE T_PLATE_RECORD ( ID INTEGER PRIMARY KEY AUTOINCREMENT, PLATE_NO VARCHAR(16) NOT NULL, PLATE_COLOR SMALLINT DEFAULT 0, CONFIDENCE SMALLINT DEFAULT 0, CAPTURE_TIME TIMESTAMP NOT NULL, CHANNEL_NO INTEGER NOT NULL, IMAGE_PATH VARCHAR(255), WEIGHT_NO VARCHAR(32), BIZ_STATUS SMALLINT DEFAULT 0 ); CREATE INDEX IDX_PLATE_NO ON T_PLATE_RECORD (PLATE_NO, CAPTURE_TIME); CREATE INDEX IDX_CAP_TIME ON T_PLATE_RECORD (CAPTURE_TIME);BIZ_STATUS这个字段是我后加的0 表示识别出来但还没绑到单据1 表示已绑定2 表示人工修正过。为什么要这个字段因为识别率和业务正确率不是一回事识别对了但绑错了单的情况一定会有留个状态位才能事后追溯。索引建在(PLATE_NO, CAPTURE_TIME)上是因为查「这辆车今天过了几次」是最高频的查询。颜色字段别小看。新能源绿牌是 8 位字符老蓝牌是 7 位识别引擎对两者的模型不一样长度校验的时候要分开处理用Length(PlateNo)一刀切会把绿牌全判成非法。4.3 现场调参与的触发去抖同一个车牌在 3 秒内被识别 20 次是常态不减抖的结果就是数据库里塞满重复记录、屏幕上疯狂刷新。我的做法是滑动时间窗加车牌比对function TPlateDebouncer.Accept(const APlate: string; ANow: TDateTime): Boolean; var i: Integer; begin Result : True; // 清理窗口外的旧记录 for i : FItems.Count - 1 downto 0 do if (ANow - FItems[i].Stamp) * 86400 FWindowSec then FItems.Delete(i); // 同一车牌在窗口内出现过就丢弃 for i : 0 to FItems.Count - 1 do if FItems[i].Plate APlate then begin Result : False; Break; end; if Result then FItems.Add(APlate, ANow); end;FWindowSec默认 5 秒地磅场景可以调到 10 秒因为一辆大车过磅本身就要十几秒。这个值配小了会重复入库配大了会漏掉紧跟其后的另一辆车现场要按车流密度调。比去抖更重要的是抓拍质量。车牌识别率上不去八成不是算法问题是图像问题。我总结下来要调这三处一是快门白天阳光直射时自动曝光会把快门拉到 1/1000 以上车牌是清楚的夜间车灯一照快门掉到 1/25车一动就是拖影识别率断崖下跌。二是补光红外补光在夜间会把蓝牌打成灰白颜色识别全废最好用白光常亮或者曝闪。三是抓拍时机车头刚进画面时车牌在边缘畸变大车走到画面中间偏下时最正用一个区域判断把抓图卡在这个位置。摄像机的参数不是代码能解决的但代码可以规避。比如夜间识别率低的时候我加了一条兜底逻辑首次识别置信度低于 60延时 300 毫秒再抓一张两张都低就转人工。这条规则让夜班的人工干预量降了一半。5. 避坑与排查Delphi 7 调设备SDK 最容易翻车的地方5.1 崩溃、卡死、句柄泄漏的根因现象一程序跑几分钟直接消失没有任何错误框。原因基本是回调函数的调用约定写错。C 那边的回调是__stdcallDelphi 里如果漏了stdcall默认是register参数传递顺序和栈清理方都不一样第一次回调就把栈搞乱了表现为随机崩溃。另一个隐蔽的写法是给回调加了stdcall但把方法procedure of object当回调传方法指针是 8 字节函数指针是 4 字节编译能过运行时必炸。解决办法是回调一律写成全局函数或类函数class procedure需要对象上下文就通过pUser参数传在函数里强转回来。现象二预览窗口拖动一下就卡死任务管理器显示 CPU 100%。原因是在解码回调里直接操作了 VCL 控件。解码回调运行在 SDK 的解码线程VCL 不是线程安全的TImage.Picture.Bitmap.Canvas.Draw和Application.ProcessMessages一起用就是死锁套餐。正确做法是回调里只做内存拷贝用PostMessage把帧序号发给主线程主线程取帧渲染。拷贝的时候要注意pBuf的生命周期只到回调返回为止不拷走就是野指针。现象三连续运行一天后新开预览失败错误码提示连接数超限。句柄没释放。NET_DVR_RealPlay_V40返回的预览句柄、NET_DVR_Login_V30返回的用户句柄、播放库的端口号三样都要按相反顺序释放PlayM4_Stop→PlayM4_FreePort→NET_DVR_StopRealPlay→NET_DVR_Logout。少放一个设备端的连接数就一直被占着一般设备最多允许 4 到 6 路占满就再也连不上。我在窗体OnDestroy里做了统一释放并且加了try..finally防止中途异常跳过释放。现象四开发机上一切正常拷到现场机器上启动即报错。位数不匹配或者缺依赖。设备网络SDK 是 32 位库Delphi 7 编译出来的也是 32 位这一点没问题但播放库和识别引擎可能各自依赖不同的 VC 运行库缺失时表现是 DLL 加载成功、调用时返回 -1。排查方法是拿 Dependency Walker 看依赖树或者干脆在bin目录里把运行库一起带上。另外 Windows 7 以上系统从System32加载 DLL 有权限和重定向问题稳妥做法是把所有 DLL 放在 exe 同目录并且用SetDllDirectory把当前目录加进搜索路径。5.2 现场故障对照表五类现象逐个拆第一类登录返回 -1错误码提示用户名密码错。登录失败先别改代码。用厂商的客户端工具在同一台机器、同一个网段试一次。工具能登、程序不能登多半是密码里有特殊字符被编码搞坏了Delphi 7 的AnsiString在某些系统区域设置下会把全角字符转成问号。还有一种情况是设备开启了登录锁定连续失败几次之后会静默拒绝一段时间重启设备才能解。第二类登录成功预览一直黑屏。先看NET_DVR_GetLastError的返回值。如果预览启动返回了有效句柄但没画面检查三个地方通道号对不对用byStartChan加上偏移不要硬编码 1码流类型是不是设备不支持有些设备只有子码流可用网络是不是跨了 NAT跨网段时设备返回的是内网 IPSDK 按那个 IP 去拉流必然失败需要在初始化时设置设备的映射地址或者把 SDK 的取流方式切换成 TCP 透传。第三类画面正常抓图返回失败。NET_DVR_CaptureJPEGPicture对分辨率参数很挑设备不支持的wPicSize直接返回失败。排查方法是从最低档开始试能成功再往上调。另一个原因是抓图目录不存在或者没写权限Delphi 里ForceDirectories要在调用前执行路径拼接用IncludeTrailingPathDelimiter别手写反斜杠。第四类抓图成功识别引擎一直返回空。先把抓下来的图打开看一眼十有八九是人眼都看不清。车牌在画面里宽度小于 80 像素识别引擎基本无能为力。解决方向是调焦距让车牌占更大画面或者把抓图分辨率提到 1080P。如果图很清晰还是识别不到检查图片格式部分引擎只吃 BMP 或者特定色深的 JPG转一道格式就好了。第五类识别结果时对时错同一辆车两次结果不同。看置信度分布。如果都在 50 到 70 之间晃说明图像质量在临界点调摄像机的快门和补光比调代码有用。如果置信度很高但字符错多半是字符集映射问题识别引擎输出的可能是 GBK而程序按 UTF-8 解释或者反过来。Delphi 7 的字符串默认是 ANSI跟引擎保持一致的代码页最省事跨代码页转换用MultiByteToWideChar显式转别依赖系统默认。注意每一条排查都建议先写日志再动手改。日志里记时间、函数名、返回值、错误码出问题时能省掉一半的猜测。我见过太多现场是靠「改一改试试」推进的最后连改了什么都不知道。6. 二次开发把预览和识别封成可复用组件这套代码最有价值的部分不是能预览能识别而是可以拆成一个与业务无关的组件下次接停车场、接门禁、接工地出入口直接拿去用。我做的改造分三步。第一步把通道抽象成对象。TPreviewChannel持有登录句柄、预览句柄、播放端口号、通道号、状态机未连接/登录中/预览中/断开对外只暴露Open、Close、Capture、OnPlateRecognized四个成员。主窗体不再关心 SDK 函数只挂事件。第二步把重连做成状态机而不是定时器硬重启。设备掉线时NET_DVR_RealPlay_V40会触发异常回调在回调里把状态置为断开主线程的 500 毫秒心跳检测到断开才发起重连重连失败做指数退避1 秒、2 秒、4 秒封顶 30 秒。直接在主线程while循环里重试界面会假死运维以为程序崩了就去强杀进程。第三步把识别结果做成事件加队列。解码线程识别出结果后不直接写库扔进一个TThreadedQueue由独立的写库线程消费。这样数据库慢的时候不会卡住预览SDK 的回调也不会因为等 SQL 而堆积。改造前后的对比改造点改造前改造后新增一路摄像机复制粘贴一大段窗体代码配置里加一行创建新 channel 对象换识别引擎全局搜索替换函数名实现IPlateRecognizer改一行配置掉线恢复人工重启程序自动重连退避封顶 30 秒数据库卡顿预览跟着卡队列缓冲互不影响验证方法我固定成三步拔网线 60 秒再插回看是否自动恢复且不重复入库用视频播放器对着摄像机放一段车牌视频看识别率和误识率把数据库文件改成只读看程序是否只报错不崩溃。这三步能覆盖八成的现场异常。说个教训。早期版本我把识别逻辑写在解码回调里测试时一切正常上线第三天开始随机崩溃日志里什么都没有。查了两天才定位到是回调线程里调了FormatDateTime那个函数内部用了全局的区域设置对象多线程下不是安全的。从那以后我每次加回调都强制走一遍「回调里只允许 memcpy 和 PostMessage」这条线别的一律挪到主线程或者工作线程。这套规矩听着死板但确实让我少加了很多夜班。希望帮到你。本文还有配套的精品资源点击获取