news 2026/9/30 7:57:55

Unity 接入海康热成像 SDK:温度矩阵解析与伪彩热图渲染

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity 接入海康热成像 SDK:温度矩阵解析与伪彩热图渲染

"热成像测温"这四个字落到 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权限不足账号不是管理员;设备端限制了预览通道
3SDK 未初始化忘了调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引用,零开销,切起来也顺滑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 7:56:49

Spring Boot家教预约管理系统:从数据库设计到并发控制的实战全解析

做家教兼职管理系统那会儿&#xff0c;很多人说这类毕设项目就是“换个壳的CRUD”&#xff0c;但真把业务跑通之后我才发现&#xff0c;这个题目比表面看起来有嚼头得多。尤其是带着“补习班预约”这个功能&#xff0c;它涉及完整的多角色权限、状态机流转、时间冲突判断&#…

作者头像 李华
网站建设 2026/9/30 7:56:46

平台+AI重塑软件交付生态,成长型伙伴迎来第二增长曲线

前几天我在一个行业群里看到有人转发了摩尔元数2026成长型生态伙伴大会的消息&#xff0c;当时就对"平台AI"这个主题挺好奇。等真正把大会内容完整看完&#xff0c;又和几位参会的区域伙伴聊了一圈&#xff0c;我意识到这场大会传递的信号&#xff0c;可能比它本身的…

作者头像 李华
网站建设 2026/9/30 7:56:12

Windows 11记事本卡死深层解析:TSF、AppContainer与UI.Xaml阻塞链

1. 这不是记事本的问题&#xff0c;而是Windows 11底层服务链的“微小脱节” 你双击桌面快捷方式&#xff0c;光标转圈三秒&#xff0c;窗口边框刚浮现就卡死&#xff1b;右键新建→文本文档&#xff0c;图标悬停半秒后直接消失&#xff1b;任务管理器里notepad.exe进程CPU占0%…

作者头像 李华
网站建设 2026/9/30 7:55:32

微信API高效解析:Java对象映射与Jackson配置优化实战

做微信生态开发的同学应该都遇到过这种场景&#xff1a;调用微信API返回一大段JSON&#xff0c;字段命名是下划线风格&#xff08;比如nickname、openid&#xff09;&#xff0c;嵌套层级很深&#xff0c;时不时还冒出个负数错误码。要高效解析这些数据并映射到Java对象&#x…

作者头像 李华
网站建设 2026/9/30 7:54:41

PyTorch环境搭建与LoRA微调实战:从版本兼容到显存优化

1. 从零搭PyTorch环境&#xff1a;版本漂移比模型本身还难调上周帮一位做机器狗控制的朋友调LoRA训练脚本&#xff0c;他刚装完PyTorch&#xff0c;兴致勃勃准备微调一个7B模型&#xff0c;结果第一步就卡住了——import torch没问题&#xff0c;但torch.cuda.is_available()一…

作者头像 李华
网站建设 2026/9/30 7:54:41

Linux权限分层体系与故障排查实战:从文件权限到提权防御

1. 权限问题前先分清楚&#xff1a;你到底在跟哪一层权限打交道做Linux运维和开发这么多年&#xff0c;我最深的一个体会是&#xff1a;权限问题本身并不难&#xff0c;难的是你搞不清楚自己到底卡在哪一层。很多新手一看到"Permission denied"就跑去chmod 777&#…

作者头像 李华