☰
Delphi 7 集成网络摄像机:实时预览、抓图与车牌识别实战
2026/10/26 13:20:37 网站建设 项目流程

简介:面向仍在使用 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_PREVIEWINFO:

function 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; // 0=TCP,1=UDP,2=多播,3=RTP 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我一般固定 TCP,UDP 在网络抖动时丢包,画面会花;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; // 0=QCIF 1=CIF 2=4CIF 3=720P 4=1080P,按设备支持选 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 的方案,把接口抽出来,实现类换成调用识别引擎的 DLL:

type 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里写Engine=local还是Engine=device,工厂函数读配置创建对应实现。这个改动只花了一个下午,省掉了以后每次换供应商重写一遍的麻烦。

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 那边的回调是__stdcall,Delphi 里如果漏了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,跨网段时设备返回的是内网 IP,SDK 按那个 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」这条线,别的一律挪到主线程或者工作线程。这套规矩听着死板,但确实让我少加了很多夜班。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询