☰
Delphi 7 停车收费端接TCG-225:预览解码与车牌识别回调
2026/10/26 4:59:13 网站建设 项目流程

简介:面向Delphi7开发者的海康TCG-225预览与车牌识别示例工程,适合安防视频集成、车牌识别入门及旧项目改造场景,重点解决摄像头预览、手动抓拍、监听抓拍事件并提取车牌号码等基础链路验证问题。压缩包共54个文件,约14.25MB,以32个dll和7个lib构成海康SDK运行依赖,辅以pas、dcu、dfm、dpr等Delphi源码与工程文件,以及exe、cfg、properties等可执行与配置项,便于直接编译调试或对照集成。已有1396人学习下载。工程保留了预览、抓拍、报警监听等模块化代码与SDK调用示例,读者可据此理解TCG-225的监听地址、端口和视频检测触发配置,掌握抓拍回调与车牌号码提取的实现思路,并借助现成工程排查依赖缺失、监听未触发等常见问题。包内还配有日志、工程配置等辅助文件,目录围绕SDK依赖、界面单元与编译产物组织,适合需要快速验证海康设备接入流程的开发者参考复用。

1. 一套老收费端要接新抓拍单元,卡住的地方往往不是网络

前阵子给一套跑了七八年的停车收费端做改造,界面是 Delphi 7 写的,现场设备换成了 TCG-225 抓拍单元,需求只有两句话:界面上能看到实时画面,车压到线圈就把车牌号写进收费记录。听着像两天活,实际在预览解码和 TC-225 车牌识别回调上卡了三天:回调里直接刷 VCL 把主线程拖死,按字符串硬读车牌字段出来全是乱码,压测到下午句柄数还在缓慢上涨。这类活的技术栈其实很固定——设备网络 SDK 的 32 位库 + 播放库 + 报警布防,难的是 Delphi 7 这套 Pascal 接口层怎么把它们接稳。下面按接口层、预览、识别、排查、验收的顺序拆开,新手能照着抄,维护老系统的同行能看到边界在哪。

2. 从 C 头文件到 Pascal 单元:接口层先立住再谈功能

Delphi 7 没有官方的 Pascal 封装,所有 SDK 函数、结构体、回调都得自己从.h搬过来。这一层搬错一个字节,后面预览花屏、车牌乱码都会找上门,所以别急着写界面,先把登录跑通。

2.1 开工前确认三件事:位宽、库版本、设备能力

第一件是位宽。Delphi 7 编译出来的永远是 32 位进程,所以只能加载 SDK 包里的 32 位库,包里通常会分32bit和64bit两个目录,拿错目录的典型表现是LoadLibrary直接返回 0,或者加载成功但一调函数就访问违例。第二件是库版本,主库、播放库、转码库要来自同一个 SDK 压缩包,混版本是花屏和崩溃的高发区。第三件是设备能力,至少要确认这台抓拍单元走的是抓拍机协议还是卡口协议,因为两者上报车牌的回调命令字不一样。

我的习惯是先不动 Pascal,直接用厂商 Demo 打通网络,能预览、能收到抓拍回调,再回头写代码。这一步省下来的一小时,后面要拿半天还。

// 版本自检,接现场第一天就把这行打印到日志里 ShowMessage(Format('SDK版本:%d 编译版本:%d', [NET_DVR_GetSDKVersion, NET_DVR_GetSDKBuildVersion]));

NET_DVR_GetSDKVersion返回的是主版本号,NET_DVR_GetSDKBuildVersion是构建号,两个都要记进日志。现场出问题时,第一句话问的就是"你用的哪个版本",有这行日志能省掉一轮扯皮。

2.2 结构体对齐:packed record 与 SizeOf 自检

C 头文件里所有设备结构体都包在#pragma pack(1)里,也就是按 1 字节对齐,而 Delphi 默认按 8 字节对齐。一个Byte + LongInt的组合,C 端占 5 字节,Delphi 默认会补成 8 字节,结构体长度一错,SDK 往里写数据就越界。

解决办法有两个:在单元开头加{$A-},或者每个结构体声明成packed record。我倾向前者,一个单元一次搞定,不容易漏。

unit uNetSdk; interface uses Windows; const SDK_DLL = 'HCNetSDK.dll'; // 换成你解压出来的 32 位主库文件名 type // 对应 C 端 NET_DVR_DEVICEINFO_V30,总长 80 字节 TNetDvrDeviceInfoV30 = packed record sSerialNumber : array[0..47] of AnsiChar; // 设备序列号,无结尾 0 byAlarmInPortNum : Byte; byAlarmOutPortNum : Byte; byDiskNum : Byte; byDVRType : Byte; byChanNum : Byte; // 模拟通道数 byStartChan : Byte; // 起始通道号,一般从 1 开始 byAudioChanNum : Byte; byIPChanNum : Byte; // 数字通道数低 8 位 byZeroChanNum : Byte; byMainProto : Byte; bySubProto : Byte; bySupport : Byte; bySupport1 : Byte; bySupport2 : Byte; wDevType : Word; // 设备类型,抓拍单元和 NVR 不一样 bySupport3 : Byte; byMultiStreamProto: Byte; byStartDChan : Byte; byStartDTalkChan : Byte; byHighDChanNum : Byte; bySupport4 : Byte; byLanguageType : Byte; byVoiceInChanNum : Byte; byStartVoiceInChanNo: Byte; bySupport5 : Byte; bySupport6 : Byte; byMirrorChanNum : Byte; wStartMirrorChanNo: Word; bySupport7 : Byte; byRes2 : Byte; end; implementation initialization // 长度自检,和 C 端 sizeof(NET_DVR_DEVICEINFO_V30) 对一遍 Assert(SizeOf(TNetDvrDeviceInfoV30) = 80, '结构体长度不符,检查对齐方式'); end.

那段Assert不是摆设。搬迁结构体时我一般会在 initialization 段把所有关键结构体都断言一遍长度,对着头文件注释里的字节数逐个核。运行期断言比事后调半天内存越界便宜太多。表里是我常核的几个:

结构体C 端长度常见错法
NET_DVR_DEVICEINFO_V3080忘记 sSerialNumber 是 48 字节定长数组
NET_DVR_PREVIEWINFO按头文件版本拿旧版结构体配新版库
NET_ITS_PICTURE_INFO按头文件版本pBuffer 在 32 位下是 4 字节,误当 8 字节
NET_DVR_ALARMER272结尾 byRes2 补齐字节被省掉

NET_DVR_PREVIEWINFO这类结构体各版本字段有增删,最稳的办法是把头文件里的sizeof注释抄下来,在initialization里断言,跑一次就知道对不对。

2.3 登录链路:Init 只能调一次,错误码要翻译成人话

登录本身很简单,坑在两处:NET_DVR_Init在整个进程里只能调一次,某些老代码在窗体创建里调、在重连里也调,第二次之后所有资源都乱;以及失败时返回 -1,不去取错误码就完全不知道是密码错还是端口不通。

function TDevClient.Login(const AHost: string; APort: Word; const AUser, APwd: string): Boolean; var Info: TNetDvrDeviceInfoV30; Err : DWORD; begin Result := False; if FUserId >= 0 then Exit; ZeroMemory(@Info, SizeOf(Info)); // 连接超时 5 秒、重连 3 次,别用默认值,现场网络差的时候默认值等死人 NET_DVR_SetConnectTime(5000, 3); FUserId := NET_DVR_Login_V30(PAnsiChar(AnsiString(AHost)), APort, PAnsiChar(AnsiString(AUser)), PAnsiChar(AnsiString(APwd)), Info); if FUserId < 0 then begin Err := NET_DVR_GetLastError; FLastError := Format('登录失败,错误码 %d:%s', [Err, ErrToText(Err)]); Exit; end; FChanNum := Info.byChanNum; FStartChan:= Info.byStartChan; Result := True; end;

参数上要留意三点:IP、用户名、密码都必须是PAnsiChar,Delphi 7 的string默认就是 AnsiString,但显式转换一次能避免将来换成 2009 以后版本时踩坑;APort是Word,SDK 默认 8000,改过端口的现场要问清楚;Info必须清零后再传,否则某些版本会用脏数据填byStartChan。

ErrToText这个函数看着土,作用极大。SDK 错误码有几百个,常用的就十来个:1 是用户名密码错,2 是权限不足,3 是超时,7 是连接失败,17 是参数错误。把常用的翻成中文写进日志,现场排查时不用再翻文档。

2.4 回调声明:stdcall、对象方法与生命周期

SDK 里所有回调都是CALLBACK,也就是__stdcall。Delphi 里声明成普通函数指针加stdcall就行,但如果想回调进类的方法,直接传@TForm1.OnRealData会崩,因为对象方法隐含一个 Self 参数。

type TRealDataCallBack = procedure(lRealHandle: LongInt; dwDataType: DWORD; pBuffer: PByte; dwBufSize: DWORD; pUser: Pointer); stdcall; TDevClient = class private FOnData: TRealDataCallBack; public procedure HandleRealData(lRealHandle: LongInt; dwDataType: DWORD; pBuffer: PByte; dwBufSize: DWORD; pUser: Pointer); stdcall; function StartPreview(AWnd: HWND): Boolean; end; procedure TDevClient.HandleRealData(lRealHandle: LongInt; dwDataType: DWORD; pBuffer: PByte; dwBufSize: DWORD; pUser: Pointer); stdcall; begin // 这里只做搬运,不做任何耗时操作 case dwDataType of 1: DoSysHead(pBuffer, dwBufSize); // 系统头 2: DoStreamData(pBuffer, dwBufSize); // 码流 end; end; function TDevClient.StartPreview(AWnd: HWND): Boolean; var M : TMethod; Cb : TRealDataCallBack; Pi : TNetDvrPreviewInfo; begin // 把对象方法转成裸函数指针:Data 存 Self,Code 存入口 M.Data := Self; M.Code := @TDevClient.HandleRealData; Cb := TRealDataCallBack(M); FOnData := Cb; // 关键:必须存成字段,防止被当成临时变量回收 ZeroMemory(@Pi, SizeOf(Pi)); Pi.lChannel := FStartChan; Pi.dwStreamType := 1; // 0 主码流,1 子码流 Pi.dwLinkMode := 0; // 0 TCP,最稳 Pi.hPlayWnd := AWnd; Pi.bBlocked := 0; FRealHandle := NET_DVR_RealPlay_V40(FUserId, Pi, Cb, nil); Result := FRealHandle >= 0; end;

FOnData := Cb这行是整个接口层最容易漏的地方。TMethod转出来的指针是栈上的临时变量,传给 SDK 后如果立刻出作用域,回调触发时就是野指针,表现是随机崩溃,Debug 版跑得好好的、Release 版跑十分钟就挂。存进字段之后就安全了。

同理,外部函数声明如果全用external 'xxx.dll'静态绑定,DLL 缺失时程序启动就报错弹窗,用户体验极差。我的做法是主库静态绑定、播放库改成LoadLibrary + GetProcAddress动态加载,这样即使现场少拷了播放库,也只是预览不可用,登录和识别照跑。

3. 实时预览:取流、解码、上屏是三段独立的事

预览这条链路,很多人写成一个整体,出问题就完全无从下手。拆成三段看:SDK 负责取流,播放库负责解码,窗口句柄负责上屏,每段都有自己的生命周期和失败表现。

3.1 取流方式选型:RealPlay 回调还是主动取流

NET_DVR_RealPlay_V40是主流做法,它把取流和解码显示串起来,回调里拿到码流后喂给播放库,播放库再画到指定窗口。另一种是主动取流,自己建 socket 按私有协议拉数据,灵活但要处理心跳、丢包、重连,除非要做服务端录制,否则没必要。

三个参数值得展开说。dwStreamType选主码流还是子码流:抓拍单元的图片分辨率往往很高,主码流解码对老机器是负担,界面上只是给人看个大概位置,选子码流完全够,识别用的高清图是从抓拍回调里单独拿的,跟预览码流没关系。dwLinkMode选传输方式:0 是 TCP,1 是 UDP,3 和 4 是 RTSP 系列。局域网内一律选 TCP,UDP 看着延迟低,丢一个包就是一帧花屏,长期跑下来画面上全是马赛克残留。hPlayWnd是目标窗口句柄,后面单独说。

提示:NET_DVR_RealPlay_V40返回的是播放句柄,不是用户 ID,两者不能混用。停止预览要用NET_DVR_StopRealPlay传播放句柄,注销要用NET_DVR_Logout传用户 ID。

3.2 播放库:端口分配与数据投喂顺序不能乱

播放库是有状态的,一个端口对应一路流。整个流程必须严格按顺序:先拿到系统头,再OpenStream、Play,之后才能InputData。顺序反了就是黑屏,而且不会有任何错误提示,纯玄学。

type TPlayMgr = class private FPort : LongInt; FOpened : Boolean; FPlayWnd : HWND; public procedure Feed(dwDataType: DWORD; pBuf: PByte; nSize: DWORD); procedure Close; end; procedure TPlayMgr.Feed(dwDataType: DWORD; pBuf: PByte; nSize: DWORD); begin if dwDataType = NET_DVR_SYSHEAD then begin if FOpened then Close; // 重连时会再送一次系统头 if not PlayM4_GetPort(FPort) then Exit; // 实时模式,缓冲池给 1MB 足够子码流 PlayM4_SetStreamOpenMode(FPort, STREAME_REALTIME); if not PlayM4_OpenStream(FPort, pBuf, nSize, 1024 * 1024) then begin PlayM4_FreePort(FPort); Exit; end; PlayM4_SetDisplayBuf(FPort, 15); // 显示缓冲帧数,网络抖动时调大 PlayM4_Play(FPort, FPlayWnd); FOpened := True; end else if (dwDataType = NET_DVR_STREAMDATA) and FOpened then begin // 投喂失败不退出,让它自然丢掉这一帧 PlayM4_InputData(FPort, pBuf, nSize); end; end; procedure TPlayMgr.Close; begin if not FOpened then Exit; PlayM4_Stop(FPort); PlayM4_CloseStream(FPort); PlayM4_FreePort(FPort); // 必须回收,否则重连几十次端口就耗光了 FOpened := False; end;

PlayM4_GetPort分配的端口号是全局的,用完不还,重连几十次之后GetPort就会失败,表现是"越重连越不显示",重启程序又好了。这是老系统里最常见的资源泄漏,写Close的时候一定把三步都补齐。

STREAME_REALTIME和STREAME_FILE的区别在于丢帧策略:实时模式网络跟不上时会主动丢帧保延迟,文件模式会缓存全部。界面预览必须用实时模式,否则延迟会越积越多。

3.3 上屏:直接把窗口句柄交给解码器,别在回调里刷界面

播放库里画图是直接往窗口 DC 上画的,所以hPlayWnd传TPanel.Handle就行,画面会自动填满客户区。这里有两个细节:面板的BevelOuter要设成bvNone,否则边框会被画面盖住;面板上不要叠任何控件,包括 Label 和按钮,因为它们会被画面直接覆盖,不是 Z 序问题,是解码器压根不理会子控件。

传句柄前必须确认句柄已经创建:

if not Panel1.HandleAllocated then Panel1.HandleNeeded; // 确保窗体已显示,句柄已分配 StartPreview(Panel1.Handle);

真正要小心的是"别在回调里做事"。回调线程是 SDK 内部的,在里面调Label1.Caption := ...或者写文件,轻则界面卡顿,重则死锁——VCL 不是线程安全的,而 SDK 回调线程往往还持有内部锁。

procedure TDevClient.HandleRealData(lRealHandle: LongInt; dwDataType: DWORD; pBuffer: PByte; dwBufSize: DWORD; pUser: Pointer); stdcall; begin Inc(FByteCount, dwBufSize); // 只更新计数,跨线程用 Interlocked 或直接是原子写 if GetTickCount - FLastTick > 1000 then begin FLastTick := GetTickCount; // 通知主线程刷新,绝不在本线程碰 VCL PostMessage(FMainWnd, WM_STREAM_STAT, FByteCount, 0); end; case dwDataType of NET_DVR_SYSHEAD: DoSysHead(pBuffer, dwBufSize); NET_DVR_STREAMDATA: DoStreamData(pBuffer, dwBufSize); end; end;

PostMessage而不是SendMessage,是因为前者是投递、立即返回,后者会同步等主线程处理完,一旦主线程正在等 SDK 的某个同步调用,两边就互相等,典型死锁。这个WM_STREAM_STAT消息在窗体里处理成码率显示,顺带就成了断流检测的依据——超过 15 秒没收到新字节,说明流断了。

4. 车牌识别:布防、解析、落盘,一步都不能省

预览跑通只是让人看得见,收费端真正要的是车牌字符串和那张抓拍图。这条链路走的是报警布防,跟预览是两套独立机制:预览断了不影响识别,识别布防断了预览照常,所以两者要分开做健康检查。

4.1 布防链路:回调、布防、状态,三样缺一不可

顺序是先注册消息回调,再开布防通道,最后开设备侧的抓拍规则。反过来的话,布防建立那一瞬间产生的抓拍会丢掉。

function TDevClient.StartAlarm: Boolean; var Param : TNetDvrSetupAlarmParam; M : TMethod; Cb : TMsgCallBack; begin Result := False; M.Data := Self; M.Code := @TDevClient.HandleAlarm; Cb := TMsgCallBack(M); FAlarmCb := Cb; // 索引 0 表示只注册一路回调,多设备可以按索引区分 if not NET_DVR_SetDVRMessageCallBack_V50(0, Cb, nil) then Exit; ZeroMemory(@Param, SizeOf(Param)); Param.dwSize := SizeOf(Param); Param.byLevel := 1; // 1 表示布防到设备级 Param.byAlarmInfoType := 0; // 0 走老的报警信息结构,兼容性好 Param.byDeployType := 0; // 0 客户端布防,1 设备布防 FAlarmHandle := NET_DVR_SetupAlarmChan_V41(FUserId, Param); Result := FAlarmHandle >= 0; if not Result then FLastError := Format('布防失败,错误码 %d', [NET_DVR_GetLastError]); end;

byAlarmInfoType这个字段值得单独说。设成 1 会走新的报警信息结构,字段更全但要求设备固件版本够新;设成 0 走兼容结构,字段少一些,老固件也能用。我一般先用 0 打通,确认能收到抓拍之后再考虑要不要切 1 拿更多字段。

布防建立之后,设备侧还得配好抓拍规则(触发方式、车道、识别区域),这部分在设备网页端配,代码里改不了。很多"布防成功但收不到回调"的问题,最后都是设备侧规则没开。

4.2 解析抓拍结果:先 Dump 再定偏移,别硬背结构体

回调进来后,pAlarmInfo指向一块结构体加变长数据的连续内存。命令字0x3050是抓拍结果,部分老固件会走0x2800,两个都要接住。

麻烦的是车牌字符串的位置。不同 SDK 版本里这个结构体长度不一样,车牌字段在结构尾部的扩展区,跟着版本漂移。硬背偏移是最容易翻车的做法,换个版本全乱。我的思路是先定图、再定牌:图片信息结构相对稳定,先把它解析对,拿到车牌小图自己看一眼,然后用候选偏移去试,用正则校验命中的那个。

// 判断某段内存是否像车牌:首字节是 GBK 汉字,第 3 位是字母,后面 5~6 位字母数字 function LooksLikePlate(const S: AnsiString): Boolean; var i, n: Integer; begin Result := False; n := Length(S); if n < 7 then Exit; if (Byte(S[1]) < $B0) or (Byte(S[1]) > $F7) then Exit; // 省份简称高位 if (Byte(S[2]) < $A1) or (Byte(S[2]) > $FE) then Exit; // 低位 if not (S[3] in ['A'..'Z']) then Exit; for i := 4 to 7 do if not (S[i] in ['A'..'Z', '0'..'9']) then Exit; Result := True; end; // 在结构体尾部一小段范围内扫,找到第一个像车牌的偏移 function LocatePlateOffset(Base: PByte; ScanLen: Integer; out Offset: Integer): AnsiString; var p: PAnsiChar; i: Integer; begin Result := ''; Offset := -1; p := PAnsiChar(Base); for i := 0 to ScanLen - 16 do if LooksLikePlate(AnsiString(Copy(p + i, 1, 16))) then begin Offset := i; Result := Copy(p + i, 1, 16); // 截到第一个 #0 为止 Result := AnsiString(PAnsiChar(Result)); Exit; end; end;

定位一次之后把偏移写成常量,同时记录 SDK 版本号,将来升级再扫一遍。这比在论坛上抄一个偏移值靠谱得多。新能源车牌是 8 位,校验里的循环上界要放到 8,LooksLikePlate只校验前 7 位就是为了兼容这个。

抓拍结果里的图片信息结构相对稳定,一张抓拍通常带好几张图:

字段含义使用建议
dwDataLen图片字节数必须用它拷贝,不能用固定长度
byType1 车牌小图 / 2 场景大图 / 3 合成图收费存档一般存 2,识别结果存 1
byDataType图片编码类型0 是 JPEG,其他要转码
pBuffer图片数据指针只在回调期间有效

byType的具体取值各版本注释略有出入,以你手上头文件的注释为准,别拿别人的表硬套。

4.3 抓拍图落盘:缓冲区只有一次机会

这点必须说清楚:pBuffer指向的是 SDK 内部缓冲,回调函数返回后随时可能被覆写。直接存指针、另开线程慢慢写,是最典型的翻车写法,表现是图片偶尔正常、偶尔花屏、偶尔只有半张。

procedure TDevClient.HandleAlarm(lCommand: LongInt; pAlarmer: Pointer; pAlarmInfo: PAnsiChar; dwBufLen: DWORD; pUser: Pointer): BOOL; stdcall; var Rec : TPlateRecord; Pic : TItsPictureInfo; Mem : Pointer; Info : PByte; begin Result := True; if (lCommand <> $3050) and (lCommand <> $2800) then Exit; Info := PByte(pAlarmInfo); if dwBufLen < 32 then Exit; Move(Info^, Pic, SizeOf(Pic)); if (Pic.pBuffer = nil) or (Pic.dwDataLen = 0) then Exit; if Pic.dwDataLen > 4 * 1024 * 1024 then Exit; // 防御性上限 // 关键:当场拷走,不保留任何 SDK 内部指针 GetMem(Mem, Pic.dwDataLen); try CopyMemory(Mem, Pic.pBuffer, Pic.dwDataLen); Rec.Plate := ExtractPlate(Info, dwBufLen); Rec.PicBuf := Mem; Rec.PicLen := Pic.dwDataLen; Rec.CapTime:= Now; Mem := nil; // 所有权交给队列 FQueue.Push(Rec); // 无锁队列,工作线程负责写盘 <p> <a href="https://download.csdn.net/download/gekun/12638720" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

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

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

立即咨询