"热成像测温"这四个字落到 Unity 项目里,通常意味着三件事:一台海康的测温型热像仪、一个已经跑起来的三维场景、以及一个必须在两周内把温度数字画到模型上的需求。Unity 本身完全不懂热成像,它只认纹理、网格和 Shader;而海康的测温相机只会通过自己的 SDK 往外吐字节流和温度矩阵。中间这层"翻译",就是本文要讲的全部内容。
我把标题写成"最简单且详细",不是营销话术,是因为这个方向网上的资料普遍两个极端:要么是三行代码配一张截图看不出所以然,要么是直接把 C 头文件翻译成 C# 却不说为什么这么写。我做过的几个项目里,真正卡住人的从来不是"Unity 怎么显示热图",而是登录失败返回 1、回调进不了主线程、温度矩阵解析出来全是 0 这类问题。这篇内容会从选型一路讲到排查,代码都是能直接抄的,坑也都标出来了。适合有 Unity 基础、第一次接触硬件 SDK 的开发者,也适合已经在用海康 SDK 但温度这块一直没跑通的人。
1. 先想清楚:Unity 里为什么非要接海康 SDK
1.1 三条能拿到温度的路,各自的门槛在哪
拿到热像仪的"温度",其实有三条路,门槛和用途完全不同,选错了会白白浪费一周。
第一条是RTSP 拉流。这条路最省事,Unity 里用 FFmpeg 或者现成的 RTSP 插件就能播,画面里也会叠着测温框和温度数字。但要注意:那层数字是相机烧录在视频画面里的像素。你拿到的是"画着数字的图",不是"数字本身"。想做温度报警、历史曲线、区域最高温统计,这条路直接死掉。
第二条是ISAPI(HTTP 接口)。海康的设备基本都开放了 HTTP 接口,测温规则、实时温度、区域统计都能以 JSON 形式取。这条路的好处是不依赖 DLL,跨平台也能用,写起来直观。坏处是轮询频率上不去,一般稳定在 2~5 Hz 就算不错了,而且每次请求都要走一遍 HTTP 认证。适合做"每秒刷一次温度卡片"这种需求,不适合 25 帧连续测温。
第三条是HCNetSDK(设备网络 SDK)。这是海康官方的 C 接口库,也是唯一能让你在 25 帧的节奏下、逐帧拿到原始温度矩阵的方式。测温数据会跟着码流一起推过来,你在回调里把温度帧挑出来解析即可。代价是 P/Invoke 封装、结构体对齐、线程调度这些脏活都得自己干。
| 路线 | 温度精度 | 刷新频率 | 开发成本 | 跨平台 |
|---|---|---|---|---|
| RTSP 拉流 | 无原始数据 | 高 | 极低 | 好 |
| ISAPI | 原始值 | 2~5 Hz | 低 | 好 |
| HCNetSDK | 原始矩阵 | 跟随码流 | 中高 | 仅 Windows(原生库) |
1.2 为什么最后落在 HCNetSDK 上
我自己的判断标准很简单:画面上的温度数字要不要参与逻辑运算。如果只是"把它显示出来给人看",ISAPI 甚至 RTSP 都够;只要涉及到"温度超过 60 度自动变色""框选区域内最高温打点""把温度映射到三维模型表面",就必须走 SDK。
还有一个很多人忽略的原因:热成像的伪彩是相机端渲染的,色标范围由相机的"温宽"设置决定,改了温宽画面就变。而原始温度矩阵是不随伪彩变化的,同一份数据你在 Unity 里可以渲染成任意色带、任意温宽,还能做自动增益。做数字孪生项目时,这个自由度是刚需。
当然也得说清楚限制:HCNetSDK 的 Windows 版是原生 DLL,Unity 只能在 Windows 平台(Editor 和 Standalone)用,打包安卓、iOS、WebGL 都不行。如果项目目标是移动端或者微信小游戏,趁早换 ISAPI 方案,别在这上面耗时间。
1.3 一套能跑通的整体架构
把整条链路拆开,大概是这么五层,后面每一层我都会展开:
- SDK 层:HCNetSDK.dll、PlayCtrl.dll 以及一堆依赖库,放在 Unity 的 Plugins 目录。
- 封装层:C# 的
DllImport声明、结构体定义、委托类型,负责把 C 接口翻译成 C# 能调的样子。 - 会话层:登录、开启预览、注册回调、退出时清理。这一层管生命周期。
- 解析层:在回调里把视频帧和温度帧分流,把温度二进制解析成
float[]或者short[]。 - 表现层:温度矩阵转
Texture2D,交给 Shader 做伪彩映射,再贴合到 UI 或者三维模型上。
这里有个贯穿全文的设计原则:回调线程只做搬运,不做解析,更不碰 Unity API。SDK 的数据回调跑在它自己的线程上,你在里面调用Texture2D.SetPixels会直接抛异常,严重的时候整个进程崩掉。正确做法是把数据往一个线程安全队列里一塞就返回,让 Unity 的Update去取。这个原则后面会反复出现。
还有一点值得提前说:温度矩阵和视频画面是两路数据,它们的帧率、时间戳可能不完全对齐。做高精度对齐(比如双光融合)的时候,得靠时间戳去配对,不能想当然认为第 N 帧温度就对应第 N 帧图像。
2. 环境准备:DLL 摆放和第一个必踩的坑
2.1 SDK 下载与文件清单
从海康开放平台下载"设备网络 SDK"(Windows 64 位版本),解压后的目录结构一般长这样:
CH-HCNetSDKV6.x.x.x_buildxxxxx_win64/ ├── bin/ # 运行库 ├── inc/ # HCNetSDK.h 等头文件 ├── lib/ # .lib 导入库(用不上,Unity 走 P/Invoke) ├── demo/ # C++ 示例 └── doc/ # 开发文档真正需要往 Unity 里放的,是bin目录下这些东西:
| 文件 / 目录 | 作用 | 是否必需 |
|---|---|---|
| HCNetSDK.dll | 主库,登录、预览、抓图都在这 | 必需 |
| PlayCtrl.dll | 播放/解码库,拿图像数据靠它 | 必需 |
| HCCore.dll | 核心依赖 | 必需 |
| hc_voice.dll / hc_voice_dll.dll | 语音对讲相关 | 用不到可不放 |
| HCNetSDKCom/ | 一堆功能子库(抓图、格式转换等) | 强烈建议放 |
| libcrypto-1_1-x64.dll / libssl-1_1-x64.dll | 加密依赖 | 必需 |
| SuperRender.dll | 渲染相关 | 建议放 |
提示:不同版本的 SDK 依赖文件差别不小,别照抄别人的清单。最稳的办法是把
bin整个目录拷过来,确认跑通之后再一个个删,看哪个删了会报错。
2.2 Unity 工程目录怎么摆
在Assets下建Plugins/x86_64,把所有 DLL 丢进去。HCNetSDKCom这个文件夹也一起放进去,Unity 会把它当成资源,不会自动拷到输出目录。
Assets/ └── Plugins/ └── x86_64/ ├── HCNetSDK.dll ├── PlayCtrl.dll ├── HCCore.dll ├── libcrypto-1_1-x64.dll ├── libssl-1_1-x64.dll ├── SuperRender.dll └── HCNetSDKCom/ ├── HCNetSDKCom.dll └── ...注意:这是全文最容易卡住新手的地方。Unity 在 Editor 里运行时,原生 DLL 的搜索路径是Unity 编辑器自己的安装目录,不是你的工程目录。所以你在 Editor 里点运行,大概率会报
DllNotFoundException。解决办法有两个:一是把 DLL 和HCNetSDKCom手动拷一份到编辑器安装目录/Editor/,二是直接在Editor目录建个软链接指过去。打包成 exe 之后,依赖就跟着 exe 走,不再有这个问题。
我在第一次接的时候在这卡了整整半天,一直以为是 DLL 名字写错了,其实是搜索路径的问题。这里有个快速验证方法:写一个空的DllImport("HCNetSDK.dll")函数,在Awake里调一下,如果抛DllNotFoundException就是路径或位数问题,如果抛EntryPointNotFoundException才是函数名问题。
2.3 Player Settings 与 64 位一致性
Edit → Project Settings → Player → Other Settings里的Architecture选x86_64。海康的 64 位 SDK 和 32 位 SDK 是完全不兼容的,混用会直接抛BadImageFormatException,提示"试图加载格式不正确的程序"。
另外,Api Compatibility Level建议选.NET Standard 2.1或.NET Framework(看你 Unity 版本),因为后面要用到System.Runtime.InteropServices里的东西,用默认配置一般也没问题,但结构体Marshal相关的一些 API 在极简配置下会缺失。
如果打算用 IL2CPP 打包(推荐,因为 Mono 在长时间跑机的场景下 GC 表现更差),记得回调函数要加[MonoPInvokeCallback],这点在第 3.3 节细说。
3. C# 封装层:结构体和回调的正确写法
3.1 结构体对齐:90% 的登录失败都出在这
海康 SDK 的结构体是用 C 写的,里面大量使用定长char数组和BYTE保留字段。翻译到 C# 时,三个地方必须对上:
[StructLayout(LayoutKind.Sequential)]保持字段顺序;CharSet.Ansi配合[MarshalAs(UnmanagedType.ByValTStr, SizeConst = N)]处理定长字符串;- 保留字段
byRes的长度必须和头文件完全一致。
第三点是最阴的。SDK 升级之后,某个结构体中间的byRes[3]可能变成byRes[4],你要是照着旧博客抄,结构体总大小就差了 1 字节,后面所有字段全部错位,登录函数返回失败,NET_DVR_GetLastError()给你一个 1(用户名密码错误),然后你会花一下午怀疑自己的密码。
我的做法是:每次拿到新版本 SDK,先打开inc/HCNetSDK.h,grep 出你要用的结构体定义,逐字段核对一遍再写。同时写一个自检函数,在Awake里打印Marshal.SizeOf,和你在 C 里sizeof()出来的值对比。
void Awake() { Debug.Log($"NET_DVR_USER_LOGIN_INFO = {Marshal.SizeOf(typeof(NET_DVR_USER_LOGIN_INFO))}"); Debug.Log($"NET_DVR_DEVICEINFO_V40 = {Marshal.SizeOf(typeof(NET_DVR_DEVICEINFO_V40))}"); Debug.Log($"NET_DVR_PREVIEWINFO = {Marshal.SizeOf(typeof(NET_DVR_PREVIEWINFO))}"); }下面是我实际用的一套定义(以 V6 系列 SDK 为例,新版本务必自行核对):
using System; using System.Runtime.InteropServices; namespace Hik.Thermal { public static class HCNetSDK { public const int NET_DVR_DEV_ADDRESS_MAX_LEN = 129; public const int NET_DVR_LOGIN_USERNAME_MAX_LEN = 64; public const int NET_DVR_LOGIN_PASSWD_MAX_LEN = 64; public const int SERIALNO_LEN = 48; public const int NAME_LEN = 32; public const int STREAM_ID_LEN = 32; // 回调数据类型 public const uint NET_DVR_SYSHEAD = 1; public const uint NET_DVR_STREAMDATA = 2; [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] public struct NET_DVR_USER_LOGIN_INFO { [MarshalAs(UnmanagedType.ByValTStr, SizeConst = NET_DVR_DEV_ADDRESS_MAX_LEN)] public string sDeviceAddress; public byte byUseTransport; public ushort wPort; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = NET_DVR_LOGIN_USERNAME_MAX_LEN)] public string sUserName; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = NET_DVR_LOGIN_PASSWD_MAX_LEN)] public string sPassword; public IntPtr cbLoginResult; public IntPtr pUser; public int bUseAsynLogin; public byte byProxyType; public byte byUseUTCTime; public byte byLoginMode; public byte byHttps; public int iProxyID; public byte byVerifyMode; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 119)] public byte[] byRes3; } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] public struct NET_DVR_DEVICEINFO_V30 { [MarshalAs(UnmanagedType.ByValArray, SizeConst = SERIALNO_LEN)] public byte[] sSerialNumber; public byte byAlarmInPortNum; public byte byAlarmOutPortNum; public byte byDiskNum; public byte byDVRType; public byte byChanNum; public byte byStartChan; public byte byAudioChanNum; public byte byIPChanNum; public byte byZeroChanNum; public byte byMainProto; public byte bySubProto; public byte bySupport; public byte bySupport1; public byte bySupport2; public ushort wDevType; public byte bySupport3; public byte byMultiStreamProto; public byte byStartDChan; public byte byStartDTalkChan; public byte byHighDChanNum; public byte bySupport4; public byte byLanguageType; public byte byVoiceInChanNum; public byte byStartVoiceInChanNo; public byte bySupport5; public byte bySupport6; public byte byMirrorChanNum; public ushort wStartMirrorChanNo; public byte bySupport7; public byte byRes2; } [StructLayout(LayoutKind.Sequential)] public struct NET_DVR_DEVICEINFO_V40 { public NET_DVR_DEVICEINFO_V30 struDeviceV30; public byte bySupportLock; public byte byRetryLoginTime; public byte byPasswordLevel; public byte byProxyType; public uint dwSurplusLockTime; public byte byCharEncodeType; public byte bySupportDev5; public byte bySupport; public byte byLoginMode; public uint dwOEMCode; public int iResidualValidity; public byte byResidualValidity; public byte bySingleStartDTalkChan; public byte bySingleDTalkChanNums; public byte byPassWordResetLevel; public byte bySupportStreamEncrypt; public byte byMarketType; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 238)] public byte[] byRes2; } } }提示:如果你的 SDK 里
NET_DVR_DEVICEINFO_V40的byRes2不是 238,登录一定失败。别问我是怎么知道的。
3.2 登录、预览、回调的核心封装
登录和开启预览的函数声明本身不复杂,关键是参数传递方式要选对。结构体一律用ref,回调委托直接用 C# 的delegate让运行时自动转换。
public delegate void REALDATACALLBACK( int lRealHandle, uint dwDataType, IntPtr pBuffer, uint dwBufSize, IntPtr pUser); [DllImport("HCNetSDK.dll", CallingConvention = CallingConvention.StdCall)] public static extern bool NET_DVR_Init(); [DllImport("HCNetSDK.dll", CallingConvention = CallingConvention.StdCall)] public static extern bool NET_DVR_Cleanup(); [DllImport("HCNetSDK.dll", CallingConvention = CallingConvention.StdCall)] public static extern int NET_DVR_Login_V40( ref NET_DVR_USER_LOGIN_INFO pLoginInfo, ref NET_DVR_DEVICEINFO_V40 lpDeviceInfo); [DllImport("HCNetSDK.dll", CallingConvention = CallingConvention.StdCall)] public static extern bool NET_DVR_Logout(int lUserID); [DllImport("HCNetSDK.dll", CallingConvention = CallingConvention.StdCall)] public static extern int NET_DVR_RealPlay_V40( int lUserID, ref NET_DVR_PREVIEWINFO lpPreviewInfo, REALDATACALLBACK fRealDataCallBack_V30, IntPtr pUser); [DllImport("HCNetSDK.dll", CallingConvention = CallingConvention.StdCall)] public static extern bool NET_DVR_StopRealPlay(int lRealHandle); [DllImport("HCNetSDK.dll", CallingConvention = CallingConvention.StdCall)] public static extern uint NET_DVR_GetLastError();调用约定这里有个细节:海康的 HCNetSDK 在 Windows 下是__stdcall,所以必须写CallingConvention.StdCall。写成默认的Winapi在 64 位下恰好也能跑(64 位只有一种调用约定),但在 32 位下会栈失衡直接崩。为了以后不踩坑,老老实实写清楚。
初始化时机建议放在场景加载时,并且整个进程只调用一次。NET_DVR_Init()内部会起线程、加载依赖,反复调用会泄漏。我一般写一个静态单例管理器:
public class HikThermalManager : MonoBehaviour { private static HikThermalManager _inst; private static bool _sdkInited; private int _userId = -1; private int _realHandle = -1; private void Awake() { if (_inst != null) { Destroy(gameObject); return; } _inst = this; DontDestroyOnLoad(gameObject); InitSdk(); } private void InitSdk() { if (_sdkInited) return; if (!HCNetSDK.NET_DVR_Init()) { Debug.LogError($"NET_DVR_Init 失败,错误码 {HCNetSDK.NET_DVR_GetLastError()}"); return; } _sdkInited = true; // 设置连接超时和重连,单位毫秒 HCNetSDK.NET_DVR_SetConnectTime(5000, 1); HCNetSDK.NET_DVR_SetReconnect(10000, true); } }NET_DVR_SetConnectTime和NET_DVR_SetReconnect这两个函数很值得加。默认的连接超时偏短,网络抖动一下就登录失败;开启自动重连之后,网线被拔掉再插回来,SDK 会自己把预览恢复,不用你写重连逻辑。
3.3 IL2CPP 下回调委托的保命写法
如果打包用 IL2CPP(强烈建议),回调委托有两个硬性要求:
第一,回调方法必须打[MonoPInvokeCallback]标记,否则 IL2CPP 生成的代码找不到这个函数的地址,运行时直接崩。
第二,委托实例必须用一个静态字段持有。C# 的委托是托管对象,如果你写成NET_DVR_RealPlay_V40(id, ref info, OnRealData, IntPtr.Zero)这种匿名或临时委托,方法返回后它就没有强引用了,GC 一跑,原生库回调过来就是一个野指针。
using AOT; using System.Collections.Concurrent; using System.Threading; public class HikCallbackBridge { // 静态持有,防止 GC 回收 private static readonly REALDATACALLBACK _realDataCb = OnRealData; private static readonly ConcurrentQueue<RawFrame> _frameQueue = new ConcurrentQueue<RawFrame>(); public struct RawFrame { public uint DataType; public byte[] Payload; public long TimestampMs; } public static REALDATACALLBACK RealDataCallback => _realDataCb; [MonoPInvokeCallback(typeof(REALDATACALLBACK))] private static void OnRealData(int lRealHandle, uint dwDataType, IntPtr pBuffer, uint dwBufSize, IntPtr pUser) { if (pBuffer == IntPtr.Zero || dwBufSize == 0) return; // 只做拷贝,不做任何解析,不碰 Unity API byte[] copy = new byte[dwBufSize]; Marshal.Copy(pBuffer, copy, 0, (int)dwBufSize); _frameQueue.Enqueue(new RawFrame { DataType = dwDataType, Payload = copy, TimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); // 队列积压保护,防止内存爆掉 while (_frameQueue.Count > 30) _frameQueue.TryDequeue(out _); } public static bool TryDequeue(out RawFrame frame) => _frameQueue.TryDequeue(out frame); }这段代码里的队列堆积保护是必需的。如果 Unity 主线程卡了一下(比如加载资源),而相机还在以 25 fps 推数据,队列几秒钟就能堆上百帧,每帧几十 KB 到几百 KB,内存直接起飞。超过阈值就丢最旧的帧,对实时预览来说完全无感。
4. 温度数据到底从哪来
4.1 先确认相机给了什么
这一节是全文最核心的部分,也是最容易走弯路的部分。不同型号、不同固件的海康热像仪,温度数据的输出方式差别非常大,没有一份放之四海皆准的代码。所以在写解析逻辑之前,必须先搞清楚你手上这台机器到底给了什么。
标准动作有三步:
第一步,确认型号支持测温。不是所有海康的热像仪都能出原始温度数据,有些只出伪彩视频。型号一般是DS-2TD开头。在相机 Web 界面的"热成像"或"测温"配置页里,找有没有"温度数据流""测温数据输出"这类开关,有就说明支持。
第二步,查能力集。用 SDK 的NET_DVR_GetDeviceAbility拉一份能力集 XML 下来,里面会列出设备支持的功能节点。搜一下有没有 thermal、thermometry 相关的标签。
第三步,也是最能说明问题的一步:把回调收到的原始数据 hexdump 出来看。
private static void DumpHex(byte[] data, int maxBytes = 64) { int n = Math.Min(maxBytes, data.Length); var sb = new System.Text.StringBuilder(); for (int i = 0; i < n; i++) { sb.Append(data[i].ToString("X2")).Append(' '); if ((i + 1) % 16 == 0) sb.Append('\n'); } Debug.Log($"帧长度 {data.Length}\n{sb}"); }在回调里把dwDataType和数据长度一起打出来,跑个十几秒,你就能看出规律:如果某一类帧的长度恒定、且和宽 × 高 × 2接近,那基本就是温度矩阵。
4.2 回调分流与温度帧解析
海康的预览回调会给你几种类型的数据,常见的是NET_DVR_SYSHEAD(1,码流头)和NET_DVR_STREAMDATA(2,码流数据)。温度数据在大多数型号上会混在STREAMDATA里,通过帧内部的包头区分;部分型号会单独走一类数据类型。具体的分发逻辑取决于你的设备,这里给出我实际项目里用得比较顺手的一套分层思路。
先按数据类型分两路:类型 1 是头部信息,交给播放库做初始化;类型 2 再做二次分流。二次分流靠的是帧头的魔数和长度自校验——不要只看魔数,加一层长度校验能过滤掉绝大多数误判。
// 常见的一种温度帧布局(务必用 hexdump 验证后再用) // [0..3] magic // [4..7] 时间戳(秒,小端 uint32) // [8..9] 宽度 (uint16 小端) // [10..11] 高度 (uint16 小端) // [12] 数据类型标志 // [13] 保留 // [14..] width*height 个 int16,小端,单位 0.1 摄氏度 private const int ThermalHeaderSize = 14; private static bool TryParseThermal(byte[] data, out short[] temps, out int w, out int h) { temps = null; w = 0; h = 0; if (data.Length < ThermalHeaderSize) return false; uint magic = BitConverter.ToUInt32(data, 0); if (magic != 0x5A5AA5A5) return false; // 魔数只是初筛 w = BitConverter.ToUInt16(data, 8); h = BitConverter.ToUInt16(data, 10); // 用尺寸做二次校验,这一步能挡掉 99% 的误判 if (w <= 0 || h <= 0 || w > 2048 || h > 2048) return false; if (data.Length < ThermalHeaderSize + w * h * 2) return false; temps = new short[w * h]; Buffer.BlockCopy(data, ThermalHeaderSize, temps, 0, w * h * 2); return true; }解析出来的是short[],单位是 0.1℃(这是我见过最多的一种约定),转成实际温度就是value / 10.0f。如果你拿到的值恒定不变、或者全是 0,先别怀疑解析代码,打印几个连续帧的原始字节看看数据区是不是全 0——相机端没开测温功能的时候,数据区就是全 0。
注意:
Buffer.BlockCopy从byte[]拷到short[]时,长度参数是字节数,不是元素个数。写w * h是错的,会少拷一半,表现是画面下半部分温度全是 0。这个错我犯过一次,查了两小时。
解析完之后建议加一道合理性检查:统计一下最大值和最小值,如果超出热像仪的物理量程(一般是 -20℃ 到 550℃,分档位),大概率是解析错位了。这道检查放在开发阶段很值,能第一时间发现对齐问题。
4.3 备用路线:ISAPI 轮询
如果折腾了半天发现你那台设备确实不出原始温度帧,或者只需要低频温度数据,ISAPI 是很好的兜底。它本质上就是 HTTP + Digest 认证 + JSON,直接用HttpWebRequest配NetworkCredential就能走通 Digest,比手写认证头省事得多。
public static string GetThermalJson(string ip, string user, string pass, string path) { var req = (System.Net.HttpWebRequest)System.Net.WebRequest.Create($"http://{ip}{path}"); req.Method = "GET"; req.Credentials = new System.Net.NetworkCredential(user, pass); req.PreAuthenticate = true; req.Timeout = 3000; req.Accept = "application/json"; using (var resp = (System.Net.HttpWebResponse)req.GetResponse()) using (var sr = new System.IO.StreamReader(resp.GetResponseStream())) { return sr.ReadToEnd(); } }具体路径各家不同,常见的形如/ISAPI/Thermal/channels/1/thermometry?format=json,里面会返回规则编号、最高温、最低温、平均温、坐标等信息。建议分出一个后台线程去做轮询,结果同样塞进队列让主线程取,别在Update里同步发 HTTP,一卡就是几十毫秒。
4.4 温度标定校验
拿到温度值之后,一定要做一次交叉验证。方法很土但很有效:拿一个已知温度的东西放到镜头前,比如一杯冰水(约 0℃)或者体温计贴着额头测出来的读数,对比 Unity 里显示的值。
如果偏差在 ±2℃ 以内,属于正常范围(热像仪本身的精度、发射率设置、环境温度补偿都会影响)。如果偏差很大,先去相机 Web 后台检查三个参数:发射率(常见材料查表)、目标距离、环境温度。这三个参数配错了,相机的测温结果本身就是错的,你在 Unity 里再怎么算也救不回来。
5. Unity 侧渲染:把温度矩阵变成能看的伪彩热图
5.1 温度矩阵到纹理:格式与双缓冲
温度矩阵的尺寸一般是 256×192、384×288、640×512 这几种。最省事的方式是把它归一化到 0~1 的浮点,存进一张TextureFormat.RFloat的纹理,Shader 里采样出来的就是温度比例值。
using Unity.Collections; using UnityEngine; public class ThermalTexture : MonoBehaviour { public Renderer TargetRenderer; private Texture2D _tex; private int _width, _height; private NativeArray<Color> _colorBuffer; private bool _dirty; private const float TempMin = -20f; private const float TempMax = 150f; public void Setup(int w, int h) { _width = w; _height = h; _tex = new Texture2D(w, h, TextureFormat.RFloat, false, true); _tex.filterMode = FilterMode.Bilinear; _tex.wrapMode = TextureWrapMode.Clamp; _tex.Apply(false, false); _colorBuffer = new NativeArray<Color>(w * h, Allocator.Persistent); if (TargetRenderer != null) TargetRenderer.material.mainTexture = _tex; } // 只在主线程调用 public void PushThermal(short[] temps, int w, int h) { if (w != _width || h != _height) return; float invRange = 1f / (TempMax - TempMin); for (int i = 0; i < temps.Length; i++) { float t = temps[i] * 0.1f; // 0.1℃ -> ℃ float n = Mathf.Clamp01((t - TempMin) * invRange); _colorBuffer[i] = new Color(n, 0f, 0f, 1f); // 只用 R 通道 } _dirty = true; } private void LateUpdate() { if (!_dirty || _tex == null) return; _tex.SetPixelData(_colorBuffer, 0); _tex.Apply(false, false); _dirty = false; } }这里有两个性能上的取舍值得说一下。
第一,_colorBuffer用NativeArray而不是Color[],避免在托管堆上反复分配。虽然是Allocator.Persistent,但一次性分配的东西不会有 GC 压力。
第二,SetPixelData比SetPixels快,因为它跳过了格式转换。用SetPixelData的前提是你的缓冲区格式和纹理格式完全一致,Color结构在内存里是 4×4 字节,对应RFloat只读第一个 float,没问题。
如果项目要跑在移动端(虽然 SDK 不支持,但如果你只是回放录制的温度数据),RFloat可能不被支持,可以降级成RGBAHalf或者把归一化值编码成两张 8 位纹理。判断方法是SystemInfo.SupportsTextureFormat(TextureFormat.RFloat)。
5.2 伪彩映射 Shader
Unity 内置没有现成的热成像色带,自己写一个查表的最省事也最可控。做一张 256×1 的渐变 LUT 贴图,把温度归一化值当 U 坐标采样就行。LUT 可以用 C# 在运行时生成——把常见色带(铁红、彩虹、白热、黑热)的关键色标写进一张表,线性插值出 256 个像素。
Shader "Thermal/PseudoColor" { Properties { _MainTex ("Thermal", 2D) = "black" {} _Lut ("Color LUT", 2D) = "white" {} _MinTemp ("Min Temp", Float) = -20 _MaxTemp ("Max Temp", Float) = 150 _ContourStep ("Contour Step", Float) = 5 } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; sampler2D _Lut; float _MinTemp; float _MaxTemp; float _ContourStep; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { float n = tex2D(_MainTex, i.uv).r; float temp = _MinTemp + n * (_MaxTemp - _MinTemp); float t = saturate(n); fixed3 col = tex2D(_Lut, float2(t, 0.5)).rgb; // 等温线:整数倍步长处压暗一点 float band = frac(temp / _ContourStep); float line = smoothstep(0.0, 0.06, band) * smoothstep(0.0, 0.06, 1.0 - band); col *= lerp(0.45, 1.0, line); return fixed4(col, 1.0); } ENDCG } } }_MinTemp和_MaxTemp就是常说的"温宽"。手动固定温宽做对比更直观,自动温宽(每帧取温度矩阵的实际最大最小值)更适合观测动态范围大的场景。我的做法是两者都留:默认手动,加一个开关切到自动,自动模式下用帧间平滑,避免画面疯狂闪烁。
等温线这个效果看着不起眼,但在实际排查过程中特别好用——一眼就能看出某块区域是不是比周围高了一档,比看颜色渐变直观得多。
5.3 从 2D 热图到 3D 模型
如果只是把热图铺在 UI 上,上面这些就够了。但数字孪生类项目通常要求把温度"贴"到三维设备模型上,这一步就要做投影。
简单场景下,可以用相机的外参把一个平面近似成投影面,把温度纹理通过Graphics.Blit或者一个投影 Shader 打到模型上。复杂一点的做法是给模型 UV 和热图 UV 之间建一个查找表,在建模阶段就对好。
还有一种偷懒但效果不错的做法:不要做全局投影,只做测温点。在模型上预设若干个点位,每个点位对应热图上的一个像素坐标,运行时把该点的温度值取出来,驱动一个粒子或者标签的颜色。工程量大减,视觉效果对业务方来说往往也够用了。
从温度矩阵里取点很快,就是数组下标运算,不需要ReadPixels——ReadPixels会触发 GPU 回读,一帧就是几毫秒,千万别每帧调。
6. 常见问题排查实录
6.1 错误码速查
NET_DVR_GetLastError()返回的值是排查的第一手资料。下面这张表是我这几年攒下来的,涵盖八成以上的问题:
| 错误码 | 含义 | 常见原因 |
|---|---|---|
| 1 | 用户名密码错误 | 结构体对齐错了导致密码被截断;密码含中文未转 GBK |
| 2 | 权限不足 | 账号不是管理员;设备端限制了预览通道 |
| 3 | SDK 未初始化 | 忘了调NET_DVR_Init,或者初始化失败没检查 |
| 4 | 通道号错误 | byStartChan没读,直接写了 1 |
| 7 | 连接设备失败 | IP/端口不对;网络不通;设备被防火墙挡了 |
| 10 | 预览失败 | 码流类型和设备不匹配;子码流没开 |
| 29 | 设备不支持该功能 | 固件太老;型号不带测温 |
| 41 | 资源不足 | 预览路数超了;句柄没释放 |
| 72 | 登录句柄无效 | 重复 logout;跨线程乱用句柄 |
错误码 1 是最常见的,而且最容易被误判。请记住:绝大多数"用户名密码错误"其实是结构体没对上。验证方法很简单,写个 C 小工程直接调 SDK 登录,如果能成,那就是 C# 这边的结构体有问题。
6.2 卡死、闪退与资源释放
Unity 里用原生 SDK,最容易出现的两个恶性问题:
一是退出时卡死。根因通常是退出流程里没有按正确顺序释放资源,SDK 的接收线程还在跑,Unity 主线程在等它。正确顺序是:停预览 → 注销登录 →NET_DVR_Cleanup。全部放在OnApplicationQuit或者OnDestroy里,并且加一个try-catch。
private void OnApplicationQuit() { try { if (_realHandle >= 0) { HCNetSDK.NET_DVR_StopRealPlay(_realHandle); _realHandle = -1; } if (_userId >= 0) { HCNetSDK.NET_DVR_Logout(_userId); _userId = -1; } HCNetSDK.NET_DVR_Cleanup(); _sdkInited = false; } catch (Exception e) { Debug.LogWarning($"清理 SDK 时出错: {e.Message}"); } }二是长时间跑机内存缓慢上涨。十有八九是回调里分配了byte[]但队列没有背压,或者每次解析温度都new short[]。解决办法是预分配固定大小的缓冲池,解析时复用。我一般准备 8 个short[]缓冲轮着用,覆盖住主线程处理延迟就够了。
还有一个隐藏的坑:如果你的项目里同时打开了多个相机的预览,每个预览的回调都会往同一个队列里塞数据,主线程分不清哪帧属于哪台相机。这时候要么给每台相机一个独立队列,要么在pUser里传一个标识,回调里读出来打标。
6.3 温度读不准的几个方向
温度显示不对,按这个顺序查:
先查相机端。发射率、距离、环境温度三个参数是不是按要求设了?测温规则画的区域是不是落在目标上?这些在 Web 后台一目了然,却最容易被跳过。
再查数据链路。打印温度矩阵的最大最小值,和相机后台显示的数字对比。如果量级差十倍,多半是单位搞错了——有的固件给的是 0.1℃,有的是 1℃,还有的给 100 倍整数。这个差异用一次对比就能定死。
最后查显示层。归一化的时候TempMin/TempMax设得太宽,温度变化在色带上就几乎看不出来,容易误以为数据没更新。把温宽收紧到目标温度附近,变化立刻明显。
提示:调试阶段一定要在屏幕上留一个文本,实时显示当前帧的温度最大值、最小值、平均值。这三行字能帮你排除掉绝大部分"到底是数据错了还是显示错了"的纠结。
7. 长时间跑机的稳定性优化
7.1 回调线程与队列背压
前面提过队列背压,这里说说不背压会怎样。SDK 回调线程的优先级通常不低,如果主线程因为加载场景卡了 300 毫秒,25 fps 下就是 7~8 帧堆在队列里,每帧 640×512×2 字节差不多 650 KB,一下就是 5 MB。跑一晚上,内存曲线就是一条缓慢上升的斜线。
背压策略我一般这么定:队列长度上限 10,超过就丢最旧的。同时统计一下丢帧数,如果丢帧率长期高于 5%,说明主线程的处理确实跟不上了,该去看是不是在Update里做了重活。
7.2 GC 与内存
原生互操作场景下有个特别隐蔽的 GC 来源:Marshal.Copy(pBuffer, copy, 0, size)里的copy。每帧都new byte[]的话,这些数组很快会进入 Gen1、Gen2,触发 Full GC 的时候主线程会卡几十毫秒,画面直接一顿。
解决办法是用ArrayPool<byte>或者自己维护一个缓冲池:
private static readonly ConcurrentQueue<byte[]> _pool = new ConcurrentQueue<byte[]>(); private static byte[] RentBuffer(int size) { if (_pool.TryDequeue(out var buf) && buf.Length >= size) return buf; return new byte[Mathf.NextPowerOfTwo(size)]; } private static void ReturnBuffer(byte[] buf) { if (buf != null && _pool.Count < 16) _pool.Enqueue(buf); }配合一个RawFrame里的长度字段,复用的缓冲只读前 N 个字节。这个改动看起来小,但在 7×24 小时跑机的项目里效果非常明显——内存曲线从锯齿变成了一条几乎水平的线。
7.3 帧率与 CPU 占用的实测取舍
温度矩阵的逐点归一化是纯 CPU 循环,640×512 就是 32 万次运算,一帧下来在主线程上是实打实的开销。实测下来,Color结构体循环比直接用float[]慢一点,因为Color有构造开销。
如果确实吃力,有两个方向:一是把归一化搬到 Job System 或者 Burst 里,能压到原来的几分之一;二是降采样,热图本身不需要和可见光一样清晰,把 640×512 抽成 320×256 显示,肉眼看不出区别,开销降四分之三。我在一个变电站巡检项目里用的就是第二种,推理侧还是全分辨率,只把显示降采样,CPU 占用从 18% 掉到了 6%。
最后再分享一个小技巧:如果你需要在运行时切换色带,别去重建纹理。把几种色带的 LUT 纹理都提前生成好,切换的时候只换 Shader 的_Lut引用,零开销,切起来也顺滑。