第一次拿到海康的热成像相机,看着 SDK 压缩包里那一堆 DLL 和几千行的头文件,很多人的第一反应是:这玩意儿在 Unity 里难道要从解码器开始自己写?其实不用。真正卡住人的从来不是测温算法,而是"把海康的数据搬进 Unity"这条路上的几个硬骨头——非托管回调线程、结构体封送、64 位组件目录、打包后 DLL 消失。我见过不少团队在这上面耗掉一整周,最后发现只是少拷了一个文件夹。
这篇内容围绕Unity 接入海康 SDK 做热成像测温展开,从取数路线的选择讲到 C# 层封装、温度标定、纹理刷新和现场排错,尽量把每一步"为什么这么做"讲透。适合已经会写 Unity 脚本、但对原生 SDK 互操作不太熟的同学;如果你手上正好有一台测温型热像仪(点测温或全屏测温都行),跟着走一遍基本能跑出可用的温度数据。文中涉及的宏名和字段名以海康官方 SDK 头文件为准,不同版本会有差异,我会在关键处标出"以你手上的头文件为准",避免你照抄踩空。
1. 先搞清楚海康热成像的三条取数路线,别一上来就啃 SDK
热成像的温度数据不像普通摄像头那样"拉流就完事",它有一层额外的语义:你拿到的是像素灰度还是物理温度?这两者在设备端是两套完全不同的通路。所以在写第一行代码之前,先花十分钟确认自己的需求属于哪一类,比后面返工要划算得多。
我把常见的做法归成三条路线。它们的差别不在难易,而在帧率、精度、开发成本三个维度的取舍。下面这张表是我自己项目里总结的对照,你可以直接拿来评估。
| 路线 | 数据来源 | 典型帧率 | 开发成本 | 适用场景 |
|---|---|---|---|---|
| ISAPI 抓帧测温 | HTTP 接口返回温度矩阵 | 0.5~2 Hz | 低 | 巡检、定时抓拍、原型验证 |
| SDK 实时测温 | PlayM4 解码回调拿原始矩阵 | 25 Hz | 中高 | 实时监控、动态追踪、工业检测 |
| 混合方案 | 视频走 SDK,数值走 ISAPI | 视频 25 Hz / 数值 1 Hz | 中 | 多数互动展示类项目 |
1.1 ISAPI 抓帧测温:半小时能出图的"笨办法"
很多人不知道海康的设备本身就带 HTTP 接口,参数、抓图、测温都能走这条线。测温相关的资源一般在/ISAPI/Thermal/channels下面,你可以先用GET /ISAPI/Thermal/channels枚举一下设备上有几个热成像通道,再用GET /ISAPI/Thermal/channels/1/capabilities看这个通道支持哪些子能力。这一步很关键,因为不同固件版本的路径差别不小,有的机器是/ISAPI/Thermal/channels/1/thermometry/1/rulesTemperatureInfo,有的直接是/ISAPI/Thermal/channels/1/thermometryCapture,硬编码路径迟早出问题,先探测再拼路径才是稳妥做法。
抓一帧全屏温度矩阵的请求大概长这样(字段名请以 capabilities 返回的结构为准,这里只示意层级):
{ "ThermalCapture": { "captureType": "fullFrameTemperature", "temperatureUnit": "celsius" } }返回的是一个二维温度矩阵,行数列数等于热成像分辨率(常见 160x120、256x192、384x288、640x512)。这套方案的优点非常明显:不需要任何原生 DLL,UnityWebRequest 发出去、JsonUtility 解回来、写进 Texture2D,半天就能看到图。缺点同样明显:一帧数据体积不小,640x512 的浮点矩阵转成文本能到几 MB,设备处理一帧要几百毫秒,帧率根本上不去;而且它是"请求-响应"模型,没有实时性可言。
这里有个绕不开的坑:海康设备默认走Digest 认证,而 UnityWebRequest 对 Digest 的支持一直不太利索。我的做法是绕开 HTTP,直接用 SDK 里的NET_DVR_STDXMLConfig发 ISAPI 报文——这个函数复用已经登录的 SDK 会话,认证那一步 SDK 内部已经处理掉了,你只管拼 URL 和 XML 正文。代价是要多封送两个结构体,但比折腾认证省事得多。如果你坚持走纯 HTTP,那就得自己实现 Digest 摘要计算(MD5 + nonce 拼接),代码量不小,不是特别推荐。
1.2 SDK 实时测温:25fps 的代价是要处理非托管回调
要走实时路线,就必须面对海康的两套原生库:HCNetSDK(负责登录、预览、云台、配置)和PlayM4(负责把码流解码成 YUV 或位图)。热成像的原始温度矩阵不是从 HCNetSDK 直接出来的,而是在 PlayM4 解码回调里,以16 位无符号整数矩阵的形式吐出来的。海康的 PlayM4 里有一个专门用来回调温度数据的类型,头文件里通常以T_Y16开头命名(不同版本命名有差别,以你手上的 PlayM4.h 为准)。
拿到这个矩阵之后,你的工作流大概是:回调线程收到ushort[]→ 拷进线程安全队列 → 主线程取出来做标定换算 → 上传到 Texture2D → Shader 做伪彩映射。整条链路最需要小心的是回调线程不是 Unity 主线程,任何 UnityEngine 的 API 都不能在里面调,包括 Debug.Log 在部分平台也不安全。我一般只在回调里做一件事:memcpy 到预分配的缓冲区,然后置一个标志位。
这条路线的性能上限取决于设备,典型热成像的原始数据流是 25fps。听起来不多,但你要算一下带宽:384 × 288 × 2 字节 × 25 = 5.5 MB/s,如果分辨率是 640x512,那就是16 MB/s。这个量级在 PC 上不算什么,但如果你每一帧都new byte[],GC 会立刻教你做人——几秒钟就能堆出几十 MB 的垃圾,帧率肉眼可见地掉。
1.3 混合方案:视频走 SDK,数值走 ISAPI
实际项目里我用得最多的是混合方案,尤其是展示类的数字孪生或者工业看板。逻辑很简单:画面用 SDK 拉流渲染,保证流畅;温度数值用 ISAPI 定时轮询,1 秒一次足够。人眼对温度数字刷新的敏感度远低于画面流畅度,1 Hz 的数值更新在界面上完全看不出来卡顿,但省下来的解码压力非常可观。
具体做法是让 PlayM4 只解 YUV 出图,不碰温度数据回调;另外开一个协程,每 500ms 到 1s 调一次 ISAPI 拿规则测温结果(最高温、最低温、平均温、区域坐标)。两条线互不干扰,任何一条挂了都不至于整个界面黑掉。这个架构还有个额外好处:ISAPI 返回的温度值是设备已经标定好的,精度有保障,你不需要自己去猜标定系数。
注意:混合方案里画面和温度在时间上不是严格对齐的,画面可能比温度值晚 100~200ms。如果你做的是"点击画面某个点读出温度"这类交互,这个延迟没问题;但如果是做高速运动物体的温度追踪,就必须走全实时路线。
2. 把 HCNetSDK 和 PlayM4 请进 Unity 工程:目录、位数与初始化坑
选完路线,接下来是最容易劝退人的环节:让 Unity 找到并正确加载海康的原生库。这一步失败的表现往往很"玄学"——编辑器里跑得好好的,打包出来就崩;或者登录一直返回失败,但错误码又指向一个跟登录无关的地方。绝大多数情况下,问题都出在下面这三个点上。
2.1 别只复制 DLL:HCNetSDKCom 目录才是 64 位版的命门
海康的 64 位 SDK 包里,HCNetSDK.dll只是一个外壳,真正干活的是同目录下的HCNetSDKCom文件夹,里面有libcrypto、libssl、hpr、StreamTransClient、SystemTransform等一堆组件。这些组件的路径不是自动搜索的,必须在调用NET_DVR_Init之前显式告诉 SDK,否则初始化会失败,或者初始化"成功"但登录时各种莫名其妙地报错。
对应的是NET_DVR_SetSDKInitCfg这个函数,需要把加密组件和 SSL 组件的完整路径传进去。C# 层大致是这样:
// 组件目录路径,必须是绝对路径,且以 \ 结尾 string comDir = Path.Combine(Application.streamingAssetsPath, "HCNetSDKCom") + "\\"; IntPtr cryptoPath = Marshal.StringToHGlobalAnsi(Path.Combine(comDir, "libcrypto-1_1-x64.dll")); IntPtr sslPath = Marshal.StringToHGlobalAnsi(Path.Combine(comDir, "libssl-1_1-x64.dll")); NET_DVR_SetSDKInitCfg(SDK_INIT_CFG_TYPE.NET_SDK_INIT_CFG_LIBEAY_PATH, cryptoPath); NET_DVR_SetSDKInitCfg(SDK_INIT_CFG_TYPE.NET_SDK_INIT_CFG_SSLEAY_PATH, sslPath); // 用完记得释放,否则每次初始化都漏一块内存 Marshal.FreeHGlobal(cryptoPath); Marshal.FreeHGlobal(sslPath);我一般会把整个HCNetSDKCom放在Assets/StreamingAssets/下面,因为 StreamingAssets 在打包后会原样保留在可执行文件旁边,路径好预测。放到Assets/Plugins/x86_64/也行,但要注意 Unity 只会自动加载 Plugins 目录根层级的 DLL,子目录里的不会被当作插件处理,你得自己用绝对路径去 LoadLibrary。
还有一个细节容易漏:组件版本必须和主 DLL 版本一致。我踩过一次,主 DLL 用的是 6.x,HCNetSDKCom 是从另一个旧包里抠出来的,结果预览能出图但一取温度就返回错误。这种问题排查起来极其痛苦,因为错误码不指向版本冲突。养成习惯,解压 SDK 包之后整个目录一起拷,别挑着拷。
2.2 Unity 的 Plugins 平台设置与 DllImport 命名
HCNetSDK.dll和PlayM4.dll放进Assets/Plugins/x86_64/之后,在 Inspector 里确认Platform Settings勾选了 x86_64(64 位)并且没有勾 Any Platform。C# 侧的声明直接写文件名不带扩展名:
[DllImport("HCNetSDK")] public static extern bool NET_DVR_Init(); [DllImport("HCNetSDK")] public static extern bool NET_DVR_Login_V40( ref NET_DVR_USER_LOGIN_INFO pLoginInfo, ref NET_DVR_DEVICEINFO_V40 lpDeviceInfo); [DllImport("PlayM4")] public static extern int PlayM4_GetPort(ref int nPort);如果你打算同时支持 32 位和 64 位,DllImport 里可以写带占位符的名字HCNetSDK,然后靠 Plugins 目录的 x86 / x86_64 分目录自动选择,但前提是两个目录下的 DLL 都齐全,包括各自的 HCNetSDKCom。说实话,现在做 PC 端项目没必要再兼容 32 位,直接用 64 位能省掉一大半麻烦。
顺便提一个反直觉的点:DllImport的CallingConvention不要乱加。海康的库在 Windows 上是__stdcall,但默认的Winapi会解析成平台默认调用约定,在 64 位下其实和StdCall等价,所以不加也没事。但如果你为了"保险"手动写成Cdecl,栈会在每次调用后被破坏,表现是运行几秒后直接闪退,而且崩的地点每次都不同,非常难查。
2.3 NET_DVR_Init 之前必须做的事:日志、组件路径、异常回调
初始化的顺序有讲究,我按实际能跑通的顺序列一下:
- 设置组件路径(
NET_DVR_SetSDKInitCfg),必须在 Init 之前。 - 开启 SDK 日志(
NET_DVR_SetLogToFile),指定一个可写目录。这一步在开发期千万别省,体温一样的错误码,翻日志往往一句话就能定位。 - 调用
NET_DVR_Init()。 - 设置连接超时和重连策略(
NET_DVR_SetConnectTime、NET_DVR_SetReconnect)。 - 设置异常回调(
NET_DVR_SetExceptionCallBack_V30),用来接管断网、IP 冲突、解码异常这些情况。
日志目录选哪儿也有讲究。我曾经把它设到Application.dataPath下面,编辑器里没问题,打包之后那个目录是只读的,SDK 写日志失败,结果连带着某些内部状态也异常了。建议统一放到Application.persistentDataPath下的一个子目录,并且提前 Directory.CreateDirectory 建好,省得 SDK 自己创建失败。
提示:开发阶段每天开工前先清一次日志目录。海康 SDK 的日志是追加写的,跑一天能到几百 MB,而且里面的时间戳是设备时间不是本机时间,混着看很费劲。
3. 登录、预览、抓图:C# 结构体封送的关键细节
环境搞定之后进入正题:把设备登录上、把流拉起来。这一段的核心难点全在结构体封送上。海康的头文件里那些结构体,字段顺序、对齐方式、内嵌的定长字符数组,只要有一处对不上,轻则参数被截断,重则直接内存越界崩溃,而且崩溃位置和真正的错误点往往隔着好几层调用。
3.1 NET_DVR_USER_LOGIN_INFO 的字段陷阱与字符集
先看登录结构。NET_DVR_USER_LOGIN_INFO这个结构体有几个必须注意的地方:设备地址和用户名密码都是定长字节数组,不是 string;结构体第一个字段必须是dwSize,并且要赋成Marshal.SizeOf的结果,SDK 靠这个值判断版本。
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] public struct NET_DVR_USER_LOGIN_INFO { [MarshalAs(UnmanagedType.ByValArray, SizeConst = 129)] public byte[] sDeviceAddress; // 设备 IP 或域名 public byte byUseTransport; // 是否走私有协议,一般填 0 public ushort wPort; // 设备端口,默认 8000 [MarshalAs(UnmanagedType.ByValArray, SizeConst = 64)] public byte[] sUserName; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 64)] public byte[] sPassword; public IntPtr cbLoginResult; // 异步登录回调,同步登录填 IntPtr.Zero public IntPtr pUser; public bool bUseAsynLogin; // false = 同步登录,推荐 public byte byProxyType; public byte byUseUTCTime; public byte byLoginMode; public byte byHttps; public int iProxyID; public byte byVerifyMode; public byte[] byRes3; // 保留字段要对齐到指定长度 public uint dwSize; }这里的坑我列几个自己的血泪教训:
SizeConst必须和头文件里的宏完全一致。129 写成 128,登录可能"成功"但设备名读出来是乱码,或者干脆在NET_DVR_Login_V40里直接被拒绝。- 字符数组统一用
byte[],不要用string加ByValTStr。海康的字段很多是中英混填的,用 ANSI 字符串封送遇到非 ASCII 会出问题。自己写一个Encoding.Default.GetBytes的辅助函数填进去,长度不够补 0,超长直接报错,别指望 SDK 帮你截断。 byUseAsynLogin建议填 false。异步登录虽然不阻塞主线程,但你得处理回调回来时 Unity 对象可能已经被销毁的情况,反而更麻烦。海康的登录在局域网里通常几十毫秒就返回了,同步足够。- 密码里有特殊字符要小心。海康设备激活时设的密码如果包含
!、@、#这类符号,某些版本的 SDK 在解析时会截断,表现就是密码明明对却登不上。遇到这种先换成纯字母数字测一遍,确认是不是这个原因。
NET_DVR_DEVICEINFO_V40相对简单,主要是接收设备返回的信息,按头文件顺序抄下来就行,注意里面内嵌的NET_DVR_DEVICEINFO_V30要展开成字段而不是嵌套结构体,否则对齐可能出错。
3.2 预览句柄与解码通道:PlayM4 那套 API 到底在干什么
登录成功拿到lUserID之后,下一步是拉流。这里要把 HCNetSDK 和 PlayM4 的关系理清楚,不然代码写出来自己都不知道在干什么。
NET_DVR_RealPlay_V40的职责是建立码流通道:告诉设备"我要 101 通道的主码流",设备开始往这边推数据,SDK 返回一个预览句柄lRealPlayHandle。但这时候你手上还是压缩码流(H.264/H.265),不是能显示的图像。所以还需要:
PlayM4_GetPort拿一个解码通道号。PlayM4_SetStreamOpenMode设置流模式(实时流用STREAME_REALTIME)。PlayM4_OpenStream把解码通道和码流缓冲区关联起来。PlayM4_Play指定渲染窗口句柄(Unity 里没有 HWND,这里传IntPtr.Zero,因为我们要自己接管渲染)。NET_DVR_SetDecCallBackEx注册解码回调,之后每解出一帧,回调就会被触发一次。
这套流程和 DirectShow 那种"源滤镜-解码滤镜-渲染滤镜"的思路是一样的,只是海康把它拆成了两个库。理解了这个分工,后面调参数就不会瞎试。
回调里能拿到什么?取决于你注册的回调类型。普通的 YV12 回调给你的是解码后的图像数据,适合做视频显示;而温度数据需要注册专门的类型,拿到的是一个ushort矩阵。这里有个很关键的细节:温度数据的宽高和视频分辨率可能不一样。有些型号的视频流是 1920x1080(可见光叠加),温度矩阵只有 384x288,你在做坐标映射时必须分别处理,不能想当然用一个分辨率。
回调函数签名大致是:
public delegate void DecCBFun(int nPort, IntPtr pBuf, int nSize, ref NET_DVR_PICTURE_INFO pFrameInfo, int nUser, int nReserved2); DecCBFun _decCallback; // 必须存成字段,防止被 GC 回收 void OnDecode(int nPort, IntPtr pBuf, int nSize, ref NET_DVR_PICTURE_INFO info, int nUser, int nReserved2) { // 这里运行在 SDK 的解码线程,绝对不能碰 Unity API if (info.nType == TEMP_DATA_TYPE) // 温度数据类型,宏名以头文件为准 { lock (_tempLock) { if (_tempBuffer == null || _tempBuffer.Length != nSize / 2) return; // 尺寸变了,丢弃这一帧,别在这里 new Marshal.Copy(pBuf, _tempRawBytes, 0, nSize); _tempFrameReady = true; } } }3.3 回调委托必须"钉住",否则十分钟内必崩
这段代码里最要命的一行是DecCBFun _decCallback;。如果你写成局部变量传进NET_DVR_SetDecCallBackEx,那么这个委托对象在方法返回后就没有托管引用了,GC 随时可能把它回收掉。而 SDK 那边还记着这个函数指针,下一次数据来的时候就会跳进一块已经被释放的内存——表现是随机崩溃,有时候跑十分钟,有时候一登录就崩,调试器给出的堆栈毫无意义。
我解决这个问题的方式是在类里用一个静态字段持有所有回调委托,并且在OnDestroy里显式置为 null 之前,先停流、再登出、最后清空委托。顺序错了照样崩,因为回调可能在停流的过程中还在触发。
还有一件事:不要在回调里做任何耗时操作。我见过有人在回调里直接做温度换算和纹理上传,单帧耗时 30ms 以上,结果就是 SDK 内部缓冲区积压,画面越来越延迟,最后整个解码线程卡死。回调的唯一职责就是"拷贝数据、置标志位",其他全部交给主线程。
4. 温度数据从哪来:全屏矩阵的原始值与两点标定
这是整篇文章的核心,也是最容易出错的地方。拿到那个ushort矩阵之后,很多人的第一反应是上网搜"海康热成像温度计算公式",然后直接抄一个T = raw * 0.01 - 273.15上去。我劝你千万别这么干,同一个型号不同固件版本,标定系数都可能不一样,抄来的公式算出来的温度能差好几度,做工业检测直接就是事故。
4.1 T_Y16 原始矩阵的正确解读方式
先说结论:海康大部分测温型热像仪的全屏原始数据是 uint16,标定关系基本是线性的,形式为T(℃) = raw × a + b。这里的a通常在 0.01 量级(对应 0.01℃ 的分辨率),b是一个偏移量,可能是负数。但具体值取决于两个因素:测温档位和设备型号。
测温档位这件事值得单独说。热像仪一般有多个量程,比如 -20~150℃ 和 0~550℃。切换量程之后,同一个 raw 值对应的温度完全变了,因为 16 位的动态范围要覆盖不同的温度区间,精度分配自然不同。如果你在代码里硬编码了一套系数,然后在设备上切换了量程,读出来的温度就会荒谬地离谱——比如对着室温测出 300 度。
所以正确的做法是:每次开始测温前,先从设备读一次当前量程和标定参数。ISAPI 里有个测温基础参数的资源(一般在/ISAPI/Thermal/channels/<id>/thermometry下面),返回的内容里会带温度单位、量程、标定系数之类的字段。把它解析出来存到内存,换算时用这份参数,而不是用硬编码。
4.2 用设备自带规则测温值反解标定系数
如果设备返回的参数不够明确,或者你怀疑参数本身不准,那就用两点标定法自己求。这个方法我用了很多次,准确度完全够用,而且不需要黑体炉(有的话更好)。
具体步骤:
- 在设备 Web 页面上打开智能测温,画一个点规则放在画面中心。
- 让程序同时输出两个数据:设备报出的该点温度 T_ref,以及你在同一像素坐标取到的 raw 值 r。
- 找两个温差尽量大的场景。比如第一个场景对着室温物体(25℃ 左右),第二个场景对着刚烧开的水杯附近的空气(60~80℃),或者把手贴在镜头前方几秒。温差越大,拟合误差越小。
- 得到两组数据 (r1, T1) 和 (r2, T2),解方程:
// 两点线性拟合,求 T = a * raw + b float a = (T2 - T1) / (float)(r2 - r1); float b = T1 - a * r1; // 校验:用第三个场景验证误差,超过 1℃ 就重新采点 float checkTemp = a * r3 + b; Debug.Log($"标定校验:计算值 {checkTemp:F2}℃,设备值 {T3:F2}℃,误差 {checkTemp - T3:F2}℃");几个实操经验:采样点不要选画面边缘,热像仪的边缘往往有暗角和镜头衰减,raw 值偏低;避开强反射物体,金属、玻璃表面的反射会干扰测温;两点之间的温差最好大于 30℃,否则拟合出的a会有明显偏差。求出来的系数建议写进配置文件而不是代码里,这样现场换设备或者固件升级之后,改配置就行,不用重新出包。
注意:两点法求出来的是相对准确的系数,绝对精度取决于设备本身的辐射标定。如果你的项目要做医疗级或工业级计量,必须用标准黑体源做多点标定,并且定期复校。这一点不要省。
4.3 非测温机型的红线:只能做相对温差
这里必须划一条线。海康的热成像产品分两类:测温型和观测型。观测型(也叫非测温型)的热像仪只输出灰度图像,那个 uint16 值反映的是红外辐射强度的相对大小,没有经过辐射标定,不能换算成绝对温度。
我遇到过一次,客户拿了一台观测型机器,希望我们"用 SDK 读温度"。折腾了半天,读出来的数值范围倒是有,但换算成温度完全不符合物理规律。后来查规格书才确认,这台机器根本没有温度输出通道,PlayM4 的温度回调压根不会触发,我们读到的其实是图像数据。
所以买设备之前一定确认型号后缀和规格书里有没有"测温"字样,或者用GET /ISAPI/Thermal/channels看有没有 thermometry 相关的子资源。观测型可以做"相对温差成像",比如找出画面里最热的区域、做温度伪彩分布,但报出来的数字不能标"摄氏度",这个在合同和验收标准里要提前说清楚,不然后期扯皮很麻烦。
5. 把温度画到 Unity 里:纹理刷新、伪彩与框选交互
数据拿到了,接下来是把它变成用户能看懂的画面。这一段的性能和视觉效果直接决定项目品质,也是很多人做得最粗糙的地方——直接SetPixels32一个像素一个像素地写,帧率掉到 20 以下还以为是自己电脑不行。
5.1 从 ushort[] 到 Texture2D 的三种刷新方式与性能对比
同一件事有三种做法,性能差距能到十倍以上,我按推荐程度从低到高说。
第一种:CPU 逐像素转 Color32 再 SetPixels32。最直观,也最慢。384x288 一共 11 万个像素,每个像素要做一次浮点乘法、一次查色带、三次字节写入,然后SetPixels32还要再拷贝一次到原生内存,最后Apply()触发一次纹理上传。单帧耗时轻松超过 15ms,25fps 直接不可能。
第二种:LoadRawTextureData + RGBA32。你在一个NativeArray<Color32>里先把伪彩算好,然后texture.LoadRawTextureData(array)一次性上传。省掉了SetPixels32的那次拷贝,快不少,但 CPU 端的色带计算还在,仍然是瓶颈。
第三种:单通道 R16 纹理 + Shader 查色带。这是我最推荐的方案。做法是把 uint16 数据原样上传成一个 R16 的单通道纹理,伪彩映射全部放到 Shader 里做。CPU 端的工作量只剩一次 memcpy,GPU 那边一个全屏的片元着色器处理几百万像素毫无压力。
// 创建单通道 16 位纹理,只在尺寸变化时创建一次 _tempTex = new Texture2D(texWidth, texHeight, GraphicsFormat.R16_UNorm, TextureCreationFlags.None); _tempTex.filterMode = FilterMode.Bilinear; _tempTex.wrapMode = TextureWrapMode.Clamp; // 每帧只需这一次调用,数据已经在 NativeArray 里 _tempTex.LoadRawTextureData(_rawArray); _tempTex.Apply(false, false);注意这里用的是R16_UNorm,也就是把 0~65535 归一化到 0~1 存储。折算到温度的时候,Shader 里的公式是T = (texValue * 65535) * a + b,把a、b通过 Material 属性传进去就行。这样切量程的时候只需要改 Material 的两个 float,不用重建纹理,切换是瞬间完成的。
Shader 部分核心就是这么几行:
half raw = SAMPLE_TEXTURE2D(_TempTex, sampler_TempTex, uv).r * 65535.0; half temp = raw * _ScaleA + _OffsetB; half t = (temp - _TempMin) / max(_TempMax - _TempMin, 0.001); half3 col = tex2D(_PaletteTex, float2(saturate(t), 0.5)).rgb;_PaletteTex是一条一维色带纹理(用 2D 纹理的中间行),你可以在 Photoshop 里画好各种色带——铁红、彩虹、灰度、白热,换色带就是换一张图,非常方便。
5.2 伪彩映射:色带怎么做才像专业热像仪
色带这件事看着简单,实际上很影响观感。我自己用的几条经验:
色带的两端要留余量。如果把温度范围刚好卡在最低温和最高温上,画面会满屏都是饱和的红和黑,看起来非常"糊"。专业热像仪一般会做自动量程(Auto Range):统计当前帧的温度分布,取 2% 和 98% 分位数作为显示范围。这样画面里同时有红有蓝有绿,层次感立马就出来了。
色带的过渡要平滑。用 256 像素宽的一维纹理,FilterMode.Bilinear,采样出来的过渡非常顺。如果用 5~6 个色块硬接,会出现明显的色带断层。
温度数值和颜色要对应起来看。界面上最好放一条色带图例,标上当前的最低温和最高温。用户可以直观地知道"红色大概是 60 度左右",减少来回问"这个红的是多少度"。
上采样不要用最近邻。热成像分辨率普遍偏低,256x192 拉到全屏会有明显马赛克。用双线性插值会平滑很多,但会让数值读数看起来"糊"——所以一般做两层:底层用插值后的图像做视觉效果,上层单独把温度数值用精确坐标标注出来,两者互不影响。
5.3 点选、框选与最高温追踪的坐标换算
交互部分最容易出错的是坐标换算。这里涉及三套坐标系:屏幕坐标(鼠标位置)、视频纹理 UV、温度矩阵像素坐标。三者之间隔着一次缩放和一次可能的偏移。
温度矩阵到视频纹理的映射,取决于你的视频流是怎么配置的。如果是纯热成像通道,两者一般是等比例缩放,tempUV = videoUV;如果是可见光和热成像融合的通道,就要看设备端有没有做对齐标定。我的建议是自己做一次手动标定:在设备画面上放一个十字标记,然后在 Unity 里调整偏移和缩放参数,让标记和温度矩阵的对应位置重合。虽然土,但比相信设备文档靠谱。
最高温追踪的实现很简单,但要注意性能。在 11 万个 uint16 里找最大值,纯 C# 循环大概 0.3ms,完全可以接受。如果你想更省,用IJobParallelFor分块求局部最大值再合并,能压到 0.05ms 以内:
// 单线程版本,够用且好调试 int maxIdx = 0; ushort maxRaw = 0; for (int i = 0; i < _rawArray.Length; i++) { if (_rawArray[i] > maxRaw) { maxRaw = _rawArray[i]; maxIdx = i; } } int mx = maxIdx % texWidth; int my = maxIdx / texWidth; // 注意纹理 y 轴方向,可能需要翻转这里有个坑:纹理的 y 轴和屏幕坐标的 y 轴方向相反。Unity 的纹理坐标原点在左下,屏幕坐标原点在左上(或者在 UI 里又是另一套)。我第一次做的时候最高温标记总是上下颠倒,查了半天才发现是这里。建议在代码里明确写一个FlipY的开关,调试的时候一眼就能看出来对不对。
6. 联调现场最常遇到的六类故障与排查顺序
前面讲的都是"顺利情况",实际项目里大部分时间花在排错上。我把这些年遇到的高频问题整理成一张表,按现象分类,你可以对照着从最可能的开始查。
| 现象 | 最可能的原因 | 排查入口 |
|---|---|---|
| 登录返回失败,错误码 1 | 组件路径未设置 / HCNetSDKCom 缺失 | 开启 SDK 日志看加载记录 |
| 登录成功但预览黑屏 | PlayM4 端口未正确关联 / 解码回调类型不对 | 检查 PlayM4_OpenStream 返回值 |
| 编辑器正常,打包后崩溃 | DLL 未随包输出 / 组件路径用了 Editor 专用路径 | 检查导出目录下是否有 HCNetSDKCom |
| 温度回调不触发 | 设备非测温型 / 回调类型注册错误 | 用 ISAPI 探测 thermometry 资源 |
| 温度值明显偏大或偏小 | 量程切换后未重读标定系数 | 对比设备 Web 页面显示值 |
| 运行十几分钟后卡死 | 回调线程内存分配过多 / 委托被 GC | Profiler 看 GC Alloc |
6.1 从 NET_DVR_GetLastError 开始,而不是瞎猜
每次 HCNetSDK 的调用返回 false 之后,第一件事是调NET_DVR_GetLastError(),而不是去改参数试运气。错误码能覆盖绝大部分情况,常见的几个我列一下:1 是用户名密码错误或用户不存在,2 是权限不足,7 是连接设备失败(网络不通或端口错),47 是用户不存在,还有一种比较隐蔽的是"IP 通道达到上限",一般出现在短时间内反复登录登出、句柄没释放的情况下。
PlayM4 那套 API 的错误码是分开的,用PlayM4_GetLastError取。这个很容易忘,因为两个 GetLastError 名字太像,我见过有人在 PlayM4 失败之后去调 HCNetSDK 的取错误码,拿到一个跟当前问题完全无关的数字,然后查文档查到怀疑人生。
另外强烈建议:开发期把NET_DVR_SetLogToFile的日志等级开到 3(最高),日志里会打印每次 API 调用的入参和返回,很多问题看一眼日志就明白了。上线前记得关掉或者降级,否则日志文件增长很快。
6.2 打包后 DLL 找不到 / 回调线程崩溃
这两个问题我放在一起说,因为它们经常同时出现,而且都和"打包环境与编辑器环境的差异"有关。
DLL 找不到的典型表现是,编辑器里跑得好好的,打包出来启动就报DllNotFoundException。原因是 Unity 在编辑器里会从 Plugins 目录加载插件,但打包之后插件是按平台设置的目录结构输出的,如果你的 x86_64 目录没勾对,DLL 就不会被输出。另外如果你用了绝对路径去 LoadLibrary 加载 HCNetSDKCom 里的组件,那个路径在打包后必须重新计算——我一般统一用Application.streamingAssetsPath,因为它在所有平台上都是可读的。
回调线程崩溃的表现更隐蔽,通常是运行一段时间之后随机崩溃,堆栈指向 SDK 内部或者一个无效地址。除了前面说的委托被 GC 之外,还有两个原因:一是回调里调用了 UnityEngine 的 API(包括Time.time、Debug.Log、GameObject.SetActive),二是 SDK 的清理顺序不对。第二个问题的正确顺序是:停止预览 → 注销所有回调 → 登出 → 清理 SDK → 销毁纹理和缓冲区。中间任何一步跳过,都可能让回调在下一次触发时访问到已经释放的托管对象。
提示:在编辑器里调试回调崩溃时,Unity 的堆栈信息经常被优化掉,很难看。这时候用
EditorApplication.isPlaying之外的方式定位——在回调入口和出口各写一行文件日志(不是 Debug.Log),崩溃后看日志最后停在哪一行,往往能直接锁定问题。
6.3 温度对不上的三种可能
如果程序跑起来了,温度也能读出来,但和设备 Web 页面显示的对不上,按这个顺序排查:
第一,量程不一致。这是最常见的原因,占了我遇到过的一半以上。设备切换了测温档位,你的程序还是按旧系数换算。解决办法是在测温开始前主动读一次量程参数,或者干脆在 UI 上让用户手动选。
第二,坐标没对齐。你取的像素点和设备规则点不在同一个物理位置,测的压根不是同一个地方。验证方法很简单:在 Unity 里把温度矩阵的某个固定像素的值打印出来,和在设备 Web 上对着同一个点手动测量对比,如果两个位置的实际温度差很多,那数值对不上很正常。
第三,标定系数本身有问题。用前面讲的两点法重新标一次,如果重新标完之后误差还是超过 1℃,那就要怀疑设备本身有没有做过辐射标定,或者镜头前面是不是装了不该装的滤光片、防护罩。红外窗口材料对透过率影响很大,普通玻璃基本不透红外,装了玻璃罩子测出来的温度完全是错的。
7. 工程化落地:帧率、内存与线程模型的实际取舍
把功能跑通只是第一步,真正上线之前还有几个决定项目能不能稳定运行的工程问题。这部分我不讲理论,只说我在实际项目里最终采用的方案和理由。
关于帧率:热成像原始数据 25fps,但我会把它降到 10fps 甚至 5fps 处理,只把视频画面的帧率保持在高位。为什么?因为人眼对温度数字的变化并不敏感,一个温度数值每秒变 5 次已经显得很灵敏了,而处理 25fps 的温度矩阵多出来的 CPU 开销和内存带宽完全没必要。做法是在回调里做一个计数器,每 5 帧处理一次,其余的直接丢弃。这个取舍看似粗暴,但实际体验几乎无差别。
关于内存:所有缓冲区都在初始化时一次性分配好,回调里只用Marshal.Copy往已有缓冲区里拷,绝不 new。温度矩阵用NativeArray<ushort>分配,一来避免 GC,二来可以直接传给 Job 系统和LoadRawTextureData。这里有个细节,NativeArray 必须用Allocator.Persistent,因为它的生命周期跨帧,用Temp或TempJob会在几帧之后报释放警告。
// 初始化时一次性分配 _rawArray = new NativeArray<ushort>(texWidth * texHeight, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); // 回调里只用阿里云拷贝到托管缓冲,主线程再转到 NativeArray // 或者直接在回调里往 NativeArray 的指针写(需要加锁,注意线程安全) // 销毁时机:确认所有回调都已停止之后再 Dispose void OnDestroy() { StopPreviewAndWait(); // 内部等待回调计数归零 if (_rawArray.IsCreated) _rawArray.Dispose(); }关于线程模型:我用的是最朴素也最稳的一种——双缓冲 + 标志位。回调线程往缓冲 A 里写,写完把标志位置 1;主线程在 Update 里看到标志位是 1,就把缓冲 A 的内容拷到缓冲 B(或者直接交换指针),然后把标志位清 0。这样回调永远不会阻塞在主线程的锁上,主线程也不会读到写了一半的数据。代价是有一帧的延迟,对测温来说完全无所谓。
关于退出清理:这一段代码必须严谨,否则编辑器会时不时崩一次,让人以为程序有问题。我的做法是维护一个回调计数器,进回调加一、出回调减一,OnDestroy里先停流,再等计数器归零(加超时保护),然后才注销回调、登出、清理 SDK。加超时是因为极端情况下回调可能卡住,不能无限等下去——超时之后就放弃清理并打日志,总比整个进程挂掉好。
关于跨平台:如果你要做 Android 或 iOS,Windows 版的 HCNetSDK 是用不了的。海康在移动端有自己的 SDK,Android 那边是 Java 层的库,需要通过AndroidJavaObject从 Unity 侧调用,中间再串一层 JNI。这条路我走过一次,工作量大概是 PC 端的两到三倍,而且调试体验很差。如果不是硬性要求移动端,强烈建议在 PC 上跑,或者让设备端出一个 HTTP 接口,Unity 这边只做展示。用 ISAPI 方案的话,Android 和 iOS 反倒变得非常简单,因为只需要发 HTTP 请求,这也是我在移动端项目里优先选混合方案的原因。
再补充一个实际用下来的小技巧:调试阶段把温度矩阵同时输出成一张 BMP 或者 PNG 存到本地,出问题的时候可以直接用图片查看器看,比在 Unity 里盯着一个 Texture2D 效率高得多。PlayM4 有现成的转 BMP 接口(PlayM4_ConvertToBmpFile),一行调用就能存图,排错的时候非常省时间。