news 2026/9/29 23:40:38

AFSIM组件开发实战:从传感器到武器模型的完整扩展

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AFSIM组件开发实战:从传感器到武器模型的完整扩展

1. 动手前先搞明白:AFSIM的组件到底是怎么"长"进仿真里的

1.1 先搞清楚MNS、C++插件和仿真内核这三者的关系

我刚接触AFSIM时最大的困扰,是分不清自己写的代码到底在哪儿起作用。AFSIM整体是C++写的仿真内核,但用户面对最多的却是MNS(Mission Notion System)这种配置/脚本语言。场景里定义一架飞机、挂一个传感器、配一把武器,基本都用MNS的文本语法完成。真正需要写C++代码的地方,是当内置的传感器、武器、通信模型满足不了需求时,自己去扩展新类型。

这三者的关系可以类比成一个"积木工厂":

  • C++插件负责制造新的积木块(比如一种光电传感器、一种制导炸弹)
  • MNS脚本负责在仿真场景里把积木搭起来(在哪里放平台,传感器装在哪,武器参数调成多少)
  • 仿真内核负责给这些积木发"时间脉冲",让它们周期性地运行

所以理解组件开发的第一把钥匙是:你写的C++类是"类型",MNS里的配置项是"实例"。仿真运行到任意一个时刻,内核会根据场景定义创建出对应的C++对象,然后在每个仿真周期调用它的更新逻辑。组件开发的大部分工作,说到底就是两件事——实现这个类的行为逻辑,然后把它注册成MNS能识别的类型。

1.2 为什么非要走"插件"这条路,而不是直接改内核

很多第一次做AFSIM扩展的开发者会问:能不能直接在AFSIM源码里加一个类,编译进主程序?技术上当然可以,但几乎没人推荐这么干。原因有两个。

第一,AFSIM的主程序更新迭代频繁,官方发布新版本时你总要合代码,改内核会让升级成本变得极高。第二,AFSIM本身的架构已经把插件系统做得相当成熟:一个插件编译产物就是一个动态库,场景文件里一行plugin xxx就能加载,完全不需要动主程序。更重要的是,团队协作时,每个人维护自己的插件动态库,互相之间只依赖对外接口,这种松耦合的方式在实际项目里非常受益。

我记得第一次接入一个已有AFSIM项目时,项目里已经有七八个插件动态库,分别处理雷达模型、通信链路、电子战威胁、武器气动数据等。我只需要新建一个插件,在MNS里通过plugin命令注册进去,就能复用其他插件的能力,这种模块化设计大大降低了团队并行开发的冲突概率。所以无论你是个人做原型验证,还是团队做大型仿真系统,都应该把所有自研模型放进独立插件中,而不是动内核。

1.3 组件开发到底需要改哪些文件

一个典型的AFSIM组件扩展工程,最终产出物只有一个动态库文件,但工程内部通常需要这几类文件:

文件类型作用说明
头文件(.h)声明自定义类、成员变量、接口对应你要扩展的模型类型
源文件(.cpp)实现类的行为逻辑比如传感器扫描、武器制导
注册文件(.h/.cpp)把新类型注册到仿真系统告诉内核"这个类型叫这个名字"
CMakeLists或其他构建脚本编译打包成动态库输出插件库供MNS加载
测试场景(.txt)验证插件行为的MNS脚本方便调试和回归测试

对这种多文件组合的工程,早期我习惯把所有自定义类塞进一个afsim_plugins.cpp文件里,看起来省事,但到了中后期维护就想哭:一个类几百行,四五个类堆在一起,改一个变量都要滚动半天。现在我的做法是一个类一对.h/.cpp,再加一个统一的注册入口文件,构建系统用CMake组织,目录结构直观,问题定位也快。

2. 搭建开发环境与最小插件工程:先让仿真跑起来

2.1 编译环境与依赖准备

AFSIM的插件开发环境,本质上就是一个标准C++编译环境加AFSIM SDK头文件。不同操作系统下的准备不太一样:

  • Windows:需要Visual Studio(建议2019或以上),安装时勾选"使用C++的桌面开发"工作负载。AFSIM安装目录里的include文件夹就是SDK头文件,CMake会去这里找。
  • Linux:需要g++ 7以上和CMake 3.10以上。AFSIM的include目录同样需要能被CMake找到。

这里我特别想强调一个坑:AFSIM的SDK头文件路径里通常会有平台标识,比如include/afsim下还有一层目录。如果你直接使用官方示例的CMakeLists,它通常已经处理好了这些路径,但如果你自己从零搭工程,非常容易在include_directories里少写一层子目录,导致头文件找不到。

一个比较稳妥的最小CMakeLists写法长这样:

cmake_minimum_required(VERSION 3.10) project(AFSIM_MyPlugin) # 变量改成你自己的AFSIM SDK路径 set(AFSIM_ROOT "C:/AFSIM/afsim") include_directories(${AFSIM_ROOT}/include) file(GLOB PLUGIN_SRC "src/*.cpp") add_library(AFSIM_MyPlugin SHARED ${PLUGIN_SRC}) # Linux/macOS下动态库前缀通常为 lib,Windows下则不需要 set_target_properties(AFSIM_MyPlugin PROPERTIES PREFIX "")

2.2 最小可编译插件骨架长什么样

要定义一个能被AFSIM识别的组件类型,核心是"注册"。AFSIM提供了一套宏和注册函数,你需要告诉框架:

  • 这个类型叫什么名字(MNS里要用到的字符串)
  • 创建这个类型的对象时,调用哪个工厂函数

拿一个最简单的自定义传感器举例,假设我打算做一个"光电探测传感器",MNS里想用sensor my_eo_sensor { type eo_custom; }这样的语法来实例化,那么C++侧需要有一个类继承自SensorEO(或者更底层的SensorType),并提供一个静态构造函数。

#include "sensor.h" #include "mns.h" class MyEOSensor : public SensorEO { public: // 构造函数的形参是AFSIM规定的固定模式: // 所属平台、实例名字、传感器类型对象 MyEOSensor(Platform* platform_ptr, const std::string& name, const SensorType* type_ptr) : SensorEO(platform_ptr, name, type_ptr) { } // 仿真周期更新入口 virtual void Update(double rate) override; }; // 工厂函数,AFSIM通过它创建对象实例 Sensor* CreateMyEOSensor(Platform* platform_ptr, const std::string& name, const SensorType* type_ptr) { return new MyEOSensor(platform_ptr, name, type_ptr); }

注册这一步通常放在一个单独的RegisterPlugins函数里:

#include "sensor.h" extern "C" AFSIM_PLUGIN_EXPORT void RegisterPlugins() { // 第一个参数是MNS里的类型名,第二个是工厂函数指针 SensorType::Register("eo_custom", CreateMyEOSensor); }

构建出动态库后,在MNS场景文件开头加载它:

// 场景文件考点 plugin AFSIM_MyPlugin platform MyUAV { position 0 0 3000 sensor my_eo { type eo_custom } }

到这一步,仿真内核在读到MyUAV平台定义、需要创建传感器时,就会调用CreateMyEOSensor工厂函数,得到一个MyEOSensor实例。整个流程就打通了。

3. 传感器模型扩展实操:开发一个光电目标探测传感器

3.1 从SensorEO继承还是从SensorType直接继承

AFSIM内置了几种常用传感器父类:SensorRadar、SensorEO、SensorIR、SensorRWR等。它们都继承自SensorType,区别在于预先实现了各自的信号级/物理级逻辑。比如SensorRader已经包含雷达方程、探测概率、距离衰减等基础模型,你只需要设置参数;SensorIR也有红外辐射计算基础。

那自定义光电传感器到底该继承哪个?

我的经验是:如果目标行为模式与现有子类高度匹配,就继承对应子类,减少底层工作量;如果传感器有非常特殊的工作模式、检测算法,果断从SensorType直接继承,否则会被父类里不太匹配的默认逻辑干扰。

比如我这个任务里要做的光电传感器,要模拟一个"宽视场搜索 + 窄视场跟踪"的双模式设备,而SensorEO默认逻辑已经包含视场、探测距离、目标检测机制,直接继承它并覆盖Update和探测相关接口,能省下很多底层交互代码。反过来,如果我想模拟一个完全自研的"对特定频段信号进行到达角测量"的传感器,那从SensorType更干净。

3.2 核心逻辑实现:探测目标并输出位置报告

传感器模型的价值在于"从仿真环境中获取信息,生成检测报告,供平台决策使用"。以我的光电传感器为例,实现要点如下:

  • 在Update函数里,获取传感器当前指向、平台位置、姿态
  • 扫描视场内的所有目标(通过AFSIM的"实体列表"或查询接口)
  • 判定目标是否落在视场角内、距离是否满足探测条件
  • 满足条件则调用EnqueueDetection(或对应版本中的检测报告接口),生成一条带时间戳的目标位置报告

简化版代码思路:

void MyEOSensor::Update(double rate) { // 1. 获取本平台状态 const Platform* self = GetPlatform(); // 2. 获取当前传感器在该平台坐标系下的扫描方向 // (这里简化成跟踪一条预设视线束) MNS_Vector scan_dir = ...; // 3. 遍历场景中的目标实体 for (const SimEntity* entity : World().GetEntities()) { if (entity->GetId() == self->GetId()) continue; // 不考虑自己 // 4. 计算目标相对传感器方位、距离、视线 // 用目标位置与自身位置做向量运算 // 5. 判断目标是否在光电视场内 if (IsInsideFov(relative_bearing, relative_elevation)) { // 6. 生成检测报告 GenerateDetection(entity, relative_position, rate); } } }

我个人在实践中发现一个特别容易忽略的地方:仿真的更新频率和你传感器采样的数据速率不是一回事。Update被调用的频率由仿真步长决定,但传感器本身可能存在积分时间、数据刷新周期。比如一个光电传感器每秒只刷新10帧图像,那我就应该积累一段时间的观测数据,而不是每一帧都强行输出一个新检测。否则会导致探测数据量爆炸,而且下游火控模型会把大量"同一目标的不同时刻观测"误认为多目标。我的做法是在传感器类型里增加update_interval参数,没到刷新时刻直接返回,到了才执行探测流程。

3.3 把传感器的可调参数暴露给MNS配置

组件扩展如果想复用,必须让仿真工程师在MNS里能调参数。AFSIM标准的做法是在构造函数里读取类型参数,或者通过类型对象上的配置接口。大多数组件会选择在构造函数里调用GetTypePtr()->GetConfig...这类方法,获得MNS配置里的值。

MNS里可以这样配置:

sensor my_eo { type eo_custom field_of_view 3.5 // 视场角(度) max_range 8000 // 最大作用距离(米) target_size_threshold 2.0// 目标尺寸阈值 }

C++侧在构造函数中读取:

MyEOSensor::MyEOSensor(Platform* platform_ptr, const std::string& name, const SensorType* type_ptr) : SensorEO(platform_ptr, name, type_ptr) { // 从MNS配置中读取视场角,默认值给3度 m_FOV = type_ptr->GetConfigDouble("field_of_view", 3.0); }

为什么要留给MNS来配?因为同样一个光电传感器,装在侦察无人机上和装在战斗机吊舱上,视场、探测距离需求完全不同。写死在代码里,每次调整都要重新编译插件;通过MNS配置,仿真人员可以自己调参,代码一行不用改。这也是组件开发和"写死逻辑"最大的区别之一,尽量把行为参数化、外部化。

4. 武器模型扩展实操:把一颗制导炸弹"塞"进仿真里

4.1 武器模型的生命周期:挂载、发射、飞行、命中

AFSIM中武器模型同样有清晰的继承体系:WeaponType是所有武器的基类,下面有BombType、MissileType、GunType等常用子类。它们在生命周期上的共性大于差异。

一个制导炸弹的完整生命周期大致是:

  1. 挂载阶段:武器实体创建,挂在平台的挂点上,此时没有独立运动能力
  2. 发射阶段:平台执行release weapon指令,武器与平台解耦,开始进入飞行状态
  3. 飞行阶段:武器根据预设制导律和目标数据,不断修正飞行方向
  4. 命中阶段:武器到达目标附近或引信触发,产生伤害效果

AFSIM中你要做的核心事情,就是在这几个生命周期节点注入自己的逻辑。比如炸弹的下落姿态、阻力特性、制导方式,都是可以"自定义"的部分。

4.2 制导与控制:让武器找到目标

对于制导武器,最重要的两个问题是:

  • 导航输入:目标位置从哪来
  • 控制律:飞行过程中每一帧怎么调整速度方向

导航输入可以有多种:发射前由平台火控系统预装定目标坐标;飞行中通过武器自带的导引头接收目标的反射信号;也可以通过数据链从平台持续获取目标更新。我这里用的场景是"光电传感器持续跟踪目标,并通过数据链把目标位置传给武器",所以武器飞行中要读取一个"目标位置更新"的消息。

控制律方面,我实现了一个简化的比例导引律。比例导引的核心思想是:导弹/炸弹的速度方向变化率正比于视线角变化率。工程上写起来思路是这样:

void MyGuidedBomb::Guidance(double rate) { // 获取目标当前位置 Vector target_pos = GetCurrentTargetPosition(); // 计算武器到目标的视线方向 Vector to_target = target_pos - GetPosition(); double rng = to_target.Magnitude(); if (rng < 1.0) return; // 已到达目标附近,无需制导 // 计算视线角速率(通过对比上一帧的视线方向) Vector los_rate = (to_target.Normalize() - m_LastLOS).Normalize() / rate; m_LastLOS = to_target.Normalize(); // 比例导引:速度方向修正量与视线角速率成正比 Vector accel_cmd = m_NavGain * CrossProduct(GetVelocity().Normalize(), los_rate); SetAccelerationCommand(accel_cmd); }

当然这是一个极度简化的示意。真实项目中还要考虑过载限制、舵面响应延迟、气动数据插值等。但在AFSIM组件开发的框架下,你要掌握的思维模式是:把飞机的运动学模型抽象成位置、速度、加速度命令,剩下的交给AFSIM的动力学推进器去积分。你不需要自己写运动方程,只需要告诉框架"这帧武器该往哪个方向加加速度"。

4.3 挂载、发射命令与命中判定

武器挂在平台上并使用MNS定义,这是最直观的一步:

platform MyUAV { weapon my_bomb { type guided_bomb_custom count 2 } }

开火指令在MNS里通过fire或shoot之类的命令触发(实际命令名根据版本和平台配置略有差异),执行时机通常在平台的脚本任务中:

// 简化示例:发现目标后,让武器站对目标实施打击 if (火控系统已锁定) { fire_target target_entity weapon_system my_bomb }

命中判定是另一个容易踩坑的点。AFSIM武器系统的默认逻辑里,通常以武器与目标之间的距离是否小于一个预定阈值来判断是否命中。这个阈值可以在MNS里配置,但如果你自定义了武器模型,最好在Update中显式处理碰炸逻辑,尤其是制导炸弹这种"实体碰碰"武器:

void MyGuidedBomb::Update(double rate) { // 先执行基类逻辑 BombType::Update(rate); // 检查是否到达目标附近 double altitude = GetPosition().z(); if (altitude <= m_DetonationAltitude && m_Fused == false) { m_Fused = true; // 对目标施加毁伤效果 if (GetTrackedTarget()) { ApplyDamage(GetTrackedTarget(), m_WarheadDamage); } } }

需要说明的是,AFSIM的毁伤效果建模有自己的一套接口和判定流程,实际项目中要仔细阅读对应版本的文档。我这里只是为了展示"在哪一步注入自定义逻辑"的思路,具体API以你使用的AFSIM版本为准。

5. 串起完整链路:光电传感器引导武器命中目标

5.1 场景配置:平台、传感器、武器与任务指令

组件开发跑通之后,最激动人心也最容易出问题的,是把你做出来的传感器和武器放进同一个场景,完成一次"发现—跟踪—打击"的闭环。这个环节能检验一个组件的完整性,也能暴露出很多在单组件测试里发现不了的问题。

我的场景是一个简化的对地打击用例,涉及两个平台:

  • 一架无人机(安装自研光电传感器和制导炸弹)
  • 一辆地面装甲车(作为目标)

MNS场景文件的主要框架如下:

plugin AFSIM_MyPlugin platform MyUAV { position 0 0 3000 heading 0 speed 50 sensor my_eo { type eo_custom field_of_view 3.5 max_range 8000 } weapon my_bomb { type guided_bomb_custom count 2 } // 任务脚本:先搜索目标,再发射武器 task search_and_strike { // ... } } platform EnemyTank { position 4000 500 0 heading 180 speed 5 }

任务脚本可以写在MNS里,用AFSIM的脚本条件触发。比如无人机沿规划航线飞行,光电传感器一旦检测到地面目标,就进入跟踪模式,火控系统解算目标位置并装定给炸弹,然后投弹。由于AFSIM的MNS支持条件和事件,你甚至不需要写C++就能实现这个"反应链"。

5.2 验证路径:Mystic可视化与日志分析

开发过程中我最离不开的工具是AFSIM自带的可视化调试工具Mystic。它能以2D/3D视图展示实体运动轨迹、传感器探测范围、武器飞行轨迹,还可以暂停单步执行,逐帧检查变量。

使用Mystic调试时,我会特别留意几个关键帧:

  • 目标是否在仿真开始后的一定时间内被传感器发现
  • 传感器的检测报告是否送达到火控系统
  • 炸弹释放后速度和位置是否合理
  • 炸弹是否在一个合理的时间窗口内到达目标附近并命中

如果某一步不满足预期,优先检查日志。AFSIM有比较完善的日志系统,可以在MNS里开启不同模块的日志输出,也可以在自定义组件里加入自己的日志打印。我习惯在每个关键节点打印带时间戳的调试信息,比如:

// 在传感器探测到目标时打印 PrintLog(LogLevel::Info, "EO sensor detected entity %d at range %.1f m", target_id, range);

很多莫名其妙的问题——比如"传感器明明指向目标却不输出检测""炸弹飞了但没命中"——最终都能在日志里找到线索,不是某个消息漏发了,就是某个坐标系的转换算错了。

6. 组件开发绕不开的坑与调试经验

6.1 类型注册了,场景里却实例化失败

这是新手最常遇到的第一道坎。我在自己的项目里也踩过一回:MNS里写type eo_custom,仿真却报"invalid type"。

排查思路分三步:

  1. 确认插件加载成功:检查场景文件里的plugin命令是否写对,动态库路径是否存在。AFSIM加载插件失败时会有明确日志,一般不会静默失败。
  2. 确认注册函数确实被导出:RegisterPlugins函数必须加上AFSIM_PLUGIN_EXPORT导出宏,否则在部分编译器上符号会被隐藏,运行时无法被调用。
  3. 确认类型名完全匹配:MNS里写的是eo_custom,那么注册时的第一个参数必须是"eo_custom"。多一个空格、错一个字母都会失败。

6.2 Update不触发,传感器永远没有检测输出

代码编译通过,场景也能跑,但传感器好像"瞎了"。遇到这种问题,我建议先检查传感器类型的设计逻辑——AFSIM中传感器的Update函数是否能被调用,有时候取决于传感器是否被"激活"。

  • 传感器是否有供电开关?很多AFSIM模型有powered属性,默认关闭,需要显式设置为"on"才能开始工作。
  • 传感器是否在正确的"运行状态"下?有些传感器只有平台处于特定模式时才允许探测。
  • 有没有在自己的Initialized方法里初始化成员变量?

我遇到过一个很隐蔽的问题:传感器Update里用了某个成员变量做视场角判断,但这个成员变量是在构造函数里从配置读取的。由于AFSIM创建对象时,类型对象的配置解析发生在某个特定阶段,如果我在构造函数里调用读取参数的时机不对,就会拿到默认值,导致视场角为0,什么都探测不到。后来我在Initialized里重新读取一遍参数,问题就消失了。

6.3 坐标系和朝向计算的"坑中坑"

传感器检测和武器制导都严重依赖坐标系变换。AFSIM里有全局坐标系、平台坐标系、传感器局部坐标系等概念。我早期开发时在这个问题上栽过好几次跟头。

举个例子:传感器扫描方向通常定义在传感器局部坐标系中,而目标位置是全局坐标。如果你跳过坐标变换,直接拿全局位置和传感器朝向来算视场角,得到的结果必然是混乱的。同一类问题也会出现在武器制导里——从目标位置减去武器位置时,一定要搞清楚两个量是否在同一个坐标系下。

我的习惯是:在一个组件里只用一个主坐标系,最方便的是全局坐标。传感器局部坐标系里的参数(比如扫描方向)通过AFSIM提供的矩阵变换接口转成全局向量,再参与所有运算。尽量减少"在局部坐标系里计算的中间量",能显著降低出错概率。

6.4 动态库更新后场景缓存没刷新的问题

最后分享一个开发效率相关的坑。在Windows上开发时,我多次发现修改C++代码重新编译后,运行仿真还是旧行为。后来发现是场景文件或仿真工作目录里存在缓存文件,导致加载的插件动态库是旧的。清理掉缓存、确认动态库路径指向的是新编译的产物,问题就解决了。

在Linux上则要留意动态库的.so后缀和RPATH配置,AFSIM在某些环境下不会自动搜索当前目录的动态库,导致加载失败。我一般把插件动态库放在仿真运行目录下,或者通过环境变量显式指定搜索路径,省得每次手动复制。

说实话,AFSIM组件开发的门槛不在C++语言本身,也不在模型理论上,而在于你要习惯"框架主动调你代码"这种模式。传感器和武器扩展都是围绕生命周期方法做文章:初始化、更新、状态切换、事件处理。只要把第一个自定义传感器跑通,后面扩展任何新类型都会顺很多。希望这篇文章能帮你少走我踩过的那几条弯路。

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

Jev API接入实战:TypeSafe决策与置信度路由配置指南

最近在开发者圈子里&#xff0c;Jev 这个词的出现频率明显高了起来。一方面是"斯坦福教授用 Jev 构建数据系统"这类消息带来的关注度&#xff0c;另一方面是 Codex、OpenCode 这类编码 Agent 工具开始有人把 Jev 的 API Key 配置进去做决策节点。但大多数人对它的认知…

作者头像 李华
网站建设 2026/9/29 23:40:31

RelayRouter:面向LLM API的协议感知型智能路由中间件

1. RelayRouter 是什么&#xff1a;不是网关&#xff0c;而是 API 流量的“智能调度台”很多人第一眼看到 RelayRouter&#xff0c;会下意识把它当成 Nginx 或 Kong 那类传统反向代理网关——这恰恰是踩坑的第一步。我去年在给一家做多模型服务编排的 SaaS 公司做架构咨询时&am…

作者头像 李华
网站建设 2026/9/29 23:40:15

十分钟搭建AI微服务底座:向导式安装与JDK 21实践

微服务架构这个词&#xff0c;这几年被聊得太多&#xff0c;以至于很多人一听到"从零搭一套微服务底座"就本能地觉得是个大工程——要选注册中心、配配置中心、搭网关、接链路追踪、搞容器编排&#xff0c;没个三五天根本跑不起来。我自己早期也是这么想的&#xff0…

作者头像 李华
网站建设 2026/9/29 23:39:09

从Hive到MaxCompute:SQL迁移、任务类型与常见报错排查实践

1. 为什么说MaxCompute是Hive的进阶者1.1 从Hive到MaxCompute&#xff1a;你需要知道的定位差异先说清楚一件事&#xff1a;Hive和MaxCompute&#xff08;以前叫ODPS&#xff09;本质上都是“SQL on Hadoop/分布式系统”这一思路的产物。你用Hive写SQL做离线数仓&#xff0c;再…

作者头像 李华
网站建设 2026/9/29 23:38:13

FunASR+Paraformer本地部署实战:5分钟跑通中文语音识别

如果你最近在折腾语音识别&#xff0c;一定绕不开达摩院开源的FunASR和它背后的Paraformer模型。说实话&#xff0c;我从Kaldi时代一路用过来&#xff0c;中间也玩过Whisper和各种商业API&#xff0c;但FunASR这套是目前在中文场景下部署成本最低、效果最稳的方案之一&#xff…

作者头像 李华
网站建设 2026/9/29 23:37:56

回形针的隐藏价值:从办公文具到万能改造工具

paperclip&#xff0c;翻译过来就是回形针。这两天它又在网络热搜上冒了个头&#xff0c;但别误会&#xff0c;热搜里的paperclip不是哪个新科技产品&#xff0c;就是你办公桌上、书包夹层里、抽屉底部那些散落一地的银亮小铁丝。我最近刚好在整理桌面&#xff0c;翻出一大把攒…

作者头像 李华