鸿蒙PC平台星图遥感适配实战:基于 Electron 壳方案的纯 JavaScript 遥感图像处理引擎开发
星图遥感是一款纯 JavaScript 实现的遥感图像处理工具,提供 18 种像素级处理算子(灰度化、反色、亮度/对比度、直方图拉伸/均衡、CLAHE 自适应均衡、高斯模糊、中值滤波、Sobel 边缘、Canny 边缘、形态学膨胀/腐蚀、Otsu 二值化、NDVI/NDWI/EVI/SAVI 光谱指数、KMeans 聚类)、处理链编排与增量执行、12 档缩放与像素探针、直方图统计与 PNG 导出。本文记录在鸿蒙 PC 平台上,基于 Electron 壳方案从零构建一个具备多算子处理链、真实像素运算、图像文件 base64 传输与直方图可视化的遥感图像处理环境的完整过程——涵盖 18 种图像处理算法实现、处理链增量缓存调度、Canvas 缩放与探针交互、鸿蒙 IPC 图像通道、自定义关于弹窗与 Catppuccin Mocha 深色主题等核心功能的实现细节与踩坑经验。
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_OrfeoToolBox
一、技术架构分析
1.1 整体架构
本项目采用 Electron Web 层 + 鸿蒙 HAP 壳工程的分层架构:
- Electron Web 层:运行于 ArkWeb 引擎,提供完整的遥感图像加载、18 种像素算子处理、处理链编排与增量执行、直方图统计、缩放探针与 PNG 导出能力
- 鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、文件系统访问、屏幕自适应等系统能力
这种架构的核心优势在于:业务代码与平台完全解耦,Electron 部分可以在任何平台独立运行,鸿蒙壳仅负责适配和打包。图像处理引擎为纯 JavaScript 实现,不依赖任何第三方库或 WebGL,所有运算通过逐像素遍历完成,处理结果通过 Canvas 2D API 的 putImageData 写入画布。
1.2 技术方案
| 对比项 | 原版 Orfeo ToolBox(C++ 遥感处理框架) | 鸿蒙适配版(星图遥感) |
|---|---|---|
| 图像处理 | C++ 管线 + 外部算子库 | 纯 JavaScript 逐像素运算 |
| 光谱指数 | 多波段 raster 运算 | RGB 通道模拟波段运算 |
| 边缘检测 | Canny/Sobel 原生实现 | JavaScript 卷积 + 非极大值抑制 |
| 开发语言 | C++ / Python | HTML5/Canvas/Vanilla JavaScript |
| 文件操作 | 本地文件 I/O | Node.js fs 模块 + IPC base64 传输 |
| 部署方式 | 命令行工具 / QGIS 插件 | HAP 包(鸿蒙应用) |
| 运行环境 | Windows/macOS/Linux | 鸿蒙 PC(ArkWeb 引擎) |
1.3 核心功能清单
| 功能模块 | 具体能力 |
|---|---|
| 基础增强 | 灰度化(BT.601 加权)、反色、亮度/对比度、直方图拉伸/均衡 |
| CLAHE | 分块限制对比度均衡 + 双线性插值,可调块数与限幅倍率 |
| 滤波去噪 | 高斯模糊(3x3/5x5 核)、中值滤波(3x3/5x5 窗口,像素上限保护) |
| 边缘检测 | Sobel 梯度幅值、Canny 完整管线(高斯平滑 + Sobel + NMS + 双阈值 BFS 滞后连接) |
| 形态学 | 灰度膨胀/腐蚀(平面结构元取窗口极值) |
| 二值化 | Otsu 大津法(类间方差最大化自动阈值) |
| 光谱指数 | NDVI 植被指数、NDWI 水体指数、EVI 增强植被指数、SAVI 土壤调节植被指数 |
| 图像分类 | KMeans Lloyd 迭代聚类(均匀间隔采样训练 + 全图分类) |
| 处理链 | 算子分组库 + 参数面板 + 顺序编排 + 增量缓存 + 异常跳过不中断 |
| 缩放与探针 | 12 档缩放(5%~800%)+ 适应窗口 + 点击像素读取 RGB 值 |
| 直方图 | 灰度统计 + Canvas 柱状图实时绘制 |
| 文件读写 | 图像打开(base64 传输,20MB 上限)+ 结果 PNG 导出 |
| 鸿蒙稳定 | 自定义关于弹窗 + 三防窗口配置 + 禁用原生菜单 + 状态栏反馈 |
二、环境准备
2.1 开发环境要求
| 项目 | 版本/信息 |
|---|---|
| 操作系统 | Windows 10/11 |
| 核心框架 | Electron (Node.js + Chromium) |
| 技术栈 | HTML5/Canvas 2D/Vanilla JavaScript |
| 渲染引擎 | 纯 JavaScript 像素级图像处理 |
| 目标设备 | 鸿蒙 PC |
| 目标架构 | arm64-v8a |
| 开发工具 | DevEco Studio(鸿蒙官方 IDE) |
2.2 项目结构
ohos_hap/
├── electron/ # Electron 壳
├── web_engine/ # 鸿蒙壳工程
│ └── src/main/resources/resfile/resources/app/ # 部署目录(业务代码平铺)
│ ├── main.js # 主进程(窗口管理 + 图像文件 IPC)
│ ├── renderer.js # 渲染进程(算子库 UI + 处理链 + 缩放探针 + 导出)
│ ├── index.html # 页面布局(工具栏 + 三栏主体 + 状态栏)
│ ├── otb-engine.js # 图像处理引擎(18 种算子 + 处理链重放 + 直方图)
│ ├── package.json # 项目配置
│ └── styles/
│ └── otb.css # Catppuccin Mocha 深色主题样式
└── AppScope/ # 应用配置(bundleName: com.wei.xingtuyaogan)
2.3 依赖说明
本项目零第三方库依赖,所有图像处理算法均由纯 JavaScript 手写实现:
| 模块 | 用途 |
|---|---|
| otb-engine.js | 图像处理引擎:18 种算子实现、处理链重放、直方图统计 |
| renderer.js | 渲染进程:算子库 UI、处理链管理、Canvas 缩放、像素探针 |
| Canvas 2D API | 图像绘制(putImageData)与直方图柱状图渲染 |
| Node.js fs | 主进程 base64 传输读写图像文件(20MB 上限) |
三、核心适配流程
3.1 主进程实现(main.js)
主进程负责窗口创建和图像文件操作 IPC。鸿蒙平台需要特殊的稳定性配置。
// 星图遥感 主进程
// 职责:窗口管理 + 图像文件打开/读取/保存/写入 IPC(真实 fs,base64 传输)
var electron = require('electron');
var app = electron.app;
var BrowserWindow = electron.BrowserWindow;
var screen = electron.screen;
var ipcMain = electron.ipcMain;
var dialog = electron.dialog;
var Menu = electron.Menu;
var fs = require('fs');
app.disableHardwareAcceleration();
// 移除应用级默认菜单栏(File/Edit/View/Window/Help)
Menu.setApplicationMenu(null);
var mainWindow = null;
var MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB 上限
function createWindow() {
try {
var display = screen.getPrimaryDisplay();
mainWindow = new BrowserWindow({
width: Math.floor(display.workAreaSize.width * 0.95),
height: Math.floor(display.workAreaSize.height * 0.90),
frame: true,
transparent: false,
resizable: true,
webPreferences: { nodeIntegration: true, contextIsolation: false }
});
mainWindow.loadFile('index.html');
// 窗口级菜单移除(与应用级双保险)
mainWindow.setMenu(null);
} catch (e) {
console.warn('[星图] 创建窗口失败:', e.message);
}
}
app.whenReady().then(function() { createWindow(); });
app.on('window-all-closed', function() { app.quit(); });
关键点说明:
- disableHardwareAcceleration——鸿蒙 ArkWeb 引擎下 GPU 硬件加速可能导致白屏或渲染异常,必须在 app ready 之前调用禁用
- Menu.setApplicationMenu(null) + mainWindow.setMenu(null) 双保险——彻底移除 Electron 默认菜单栏,避免鸿蒙下菜单渲染引发崩溃
- frame: true / transparent: false / resizable: true 三防配置——保留系统原生标题栏、禁止透明窗口、允许用户调整尺寸,这三项组合是鸿蒙上窗口稳定运行的基线
- try-catch 包裹 createWindow——鸿蒙下 SubWindow 创建可能超时,未捕获异常会导致主进程直接崩溃
- MAX_IMAGE_BYTES 20MB 上限——防止超大遥感图像文件撑爆内存
3.2 图像文件 IPC 通道
主进程注册四个 IPC 通道,分别处理图像文件的打开、读取、保存对话框和写入:
// ========== IPC 通道 ==========
// 打开图像对话框
ipcMain.handle('dialog:openImage', function() {
return dialog.showOpenDialog(mainWindow, {
title: '打开遥感图像',
filters: [
{ name: '图像文件', extensions: ['png', 'jpg', 'jpeg', 'bmp'] }
],
properties: ['openFile']
});
});
// 读取图像为 base64(供 img 标签加载)
ipcMain.handle('fs:readImage', function(event, filePath) {
try {
var stat = fs.statSync(filePath);
if (stat.size > MAX_IMAGE_BYTES) {
return { success: false, error: '文件超过 20 MB 上限' };
}
var buf = fs.readFileSync(filePath);
return {
success: true,
base64: buf.toString('base64'),
size: stat.size
};
} catch (e) {
return { success: false, error: e.message };
}
});
// 保存结果图像对话框
ipcMain.handle('dialog:saveImage', function() {
return dialog.showSaveDialog(mainWindow, {
title: '导出处理结果',
defaultPath: 'otb-result.png',
filters: [
{ name: 'PNG 图像', extensions: ['png'] }
]
});
});
// 写入图像文件(base64 → Buffer → 文件)
ipcMain.handle('fs:writeImage', function(event, filePath, base64) {
try {
var buf = Buffer.from(base64, 'base64');
fs.writeFileSync(filePath, buf);
return { success: true, size: buf.length };
} catch (e) {
return { success: false, error: e.message };
}
});
设计要点:
- 对话框与文件读写均走主进程 IPC——图像文件通过 base64 编码在主进程与渲染进程之间传输,渲染进程拿到 base64 后构造 data URI 赋给 img 标签加载
- 四通道分离——打开对话框(dialog:openImage)与读取文件内容(fs:readImage)分步执行,渲染进程先获取文件路径再请求读取,避免对话框取消时做无效 IO
- 20MB 文件大小保护——statSync 检查文件大小,超限直接返回错误,防止渲染进程因传输超大 base64 字符串而卡死
- 导出走双通道——先弹出保存对话框获取目标路径(dialog:saveImage),再将 Canvas toDataURL 产出的 base64 写入文件(fs:writeImage)
3.3 页面布局设计(index.html)
index.html 采用四段式结构:顶部工具栏 + 主体三栏(算子库面板 / 图像画布 / 直方图信息面板)+ 底部状态栏 + 关于对话框:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>星图遥感</title>
<link rel="stylesheet" href="styles/otb.css">
</head>
<body>
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="toolbar-brand">星图遥感</div>
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button class="tool-btn" id="btnOpenImage">🖼️ 打开图像</button>
<button class="tool-btn" id="btnExport">💾 导出结果</button>
<button class="tool-btn" id="btnReset">↩️ 重置链</button>
</div>
<div class="toolbar-spacer"></div>
<div class="toolbar-group">
<div class="seg-group" id="viewGroup">
<button class="seg-btn active" data-view="source">原图</button>
<button class="seg-btn" data-view="result">结果</button>
</div>
</div>
<!-- 缩放控件随视图切换组放在菜单栏;无图时整组隐藏 -->
<div class="toolbar-group hidden" id="zoomGroup">
<div class="toolbar-separator"></div>
<button class="zoom-btn" id="btnZoomOut">−</button>
<button class="zoom-btn" id="btnZoomFit">适应</button>
<button class="zoom-btn" id="btnZoomIn">+</button>
<span class="zoom-label" id="zoomLabel">适应窗口</span>
</div>
</div>
<!-- 主体 -->
<div class="main">
<!-- 左侧:算子库 + 处理链 -->
<div class="panel panel-left">
<div class="panel-scroll">
<div class="panel-title">算子库</div>
<div class="op-list" id="opList"></div>
<div class="op-params" id="opParams">
<div class="op-params-hint">选择算子后配置参数</div>
</div>
</div>
<!-- 固定在滚动区外:选完算子立刻可见,无需滚动寻找 -->
<button class="action-btn" id="btnAddOp">+ 添加到处理链</button>
<div class="chain-area">
<div class="panel-title">处理链</div>
<div class="chain-list" id="chainList">
<div class="chain-empty">空</div>
</div>
</div>
</div>
<!-- 中央:图像视图 -->
<div class="canvas-wrap" id="canvasWrap">
<div class="canvas-empty" id="canvasEmpty">① 打开图像 → ② 左侧点选算子 → ③ 添加到处理链</div>
<canvas id="sourceCanvas" class="hidden"></canvas>
<canvas id="resultCanvas" class="hidden"></canvas>
</div>
<!-- 右侧:直方图 + 信息 + 日志 -->
<div class="panel panel-right">
<div class="panel-title">直方图(当前视图)</div>
<canvas id="histCanvas" width="200" height="120"></canvas>
<div class="hist-legend">
<span class="legend-swatch legend-gray"></span><span class="legend-text">灰度</span>
</div>
<div class="panel-title">图像信息</div>
<div class="info-list" id="infoList">
<div class="info-row"><span class="info-key">状态</span><span class="info-val">未加载</span></div>
</div>
<div class="panel-title">处理日志</div>
<div class="op-log" id="opLog"></div>
</div>
</div>
<!-- 底部状态栏 -->
<div class="statusbar">
<span class="status-item" id="statusFile">未打开图像</span>
<div class="status-spacer"></div>
<span class="status-item status-hint">滚轮缩放 · 点图探针</span>
<span class="status-item" id="statusProbe">-</span>
<span class="status-item" id="statusChain">链: 0</span>
<span class="status-item" id="statusTime">-</span>
<span class="status-item status-link" id="btnAbout">关于</span>
</div>
<!-- 关于对话框(开源信息) -->
<div class="dialog-layer hidden" id="aboutDialogLayer">
<div class="dialog-box">
<div class="dialog-title">关于 星图遥感</div>
<div class="dialog-body about-body">
<h3>开源声明</h3>
<p>本应用是运行于鸿蒙 PC 的 Electron 壳应用(Electron 基于 MIT 协议开源),提供遥感图像浏览、8 种像素算子处理链与直方图统计功能。全部图像处理算法(NDVI/NDWI/EVI/SAVI/Otsu 阈值/边缘检测/K-Means 聚类/CLAHE 对比度自适应直方图均衡)均使用 JavaScript 独立实现;交互设计参考 Orfeo ToolBox 等开源遥感项目的通用使用习惯,全部代码独立编写,不包含其任何源代码,在此谨向原作者与开源社区致谢。</p>
<h3>免责声明</h3>
<p>本应用仅用于遥感图像的学习与验证,处理结果仅供参考,不可直接用于实际工程决策。使用者应自行承担因使用本应用产生的风险与后果。</p>
<h3>完全开源</h3>
<p>本项目代码完全开源,遵循 Apache-2.0 开源许可证。欢迎社区贡献代码、报告问题或提出功能建议。</p>
</div>
<div class="dialog-footer">
<button class="dialog-btn" id="btnAboutClose">关闭</button>
</div>
</div>
</div>
<script src="otb-engine.js"></script>
<script src="renderer.js"></script>
</body>
</html>
布局要点:
- Flex 纵向布局(toolbar / main / statusbar),关于对话框使用 fixed 覆盖(本项目无 CSS Grid 基础,采用 position: fixed 实现全屏遮罩)
- 主体区域三栏布局:左侧算子库面板(236px 固定宽)+ 中央图像画布(flex 弹性填充)+ 右侧信息面板(236px 固定宽)
- 左面板上下分区——上半部分为算子库(固定上限 62% + 独立滚动),下半部分为处理链(占据剩余空间),中间由"添加到处理链"按钮分隔
- 原图/结果双画布切换——sourceCanvas 与 resultCanvas 通过 hidden 类互斥显示
- 缩放控件组随图像加载动态显隐——无图时整组 hidden,有图后 remove hidden
3.4 图像处理引擎核心(otb-engine.js)
otb-engine.js 是整个应用的核心,实现了 18 种图像处理算子、处理链重放引擎与直方图统计。
3.4.1 数据结构与基础工具
// ========== 结构工厂与克隆 ==========
function createImageData(width, height) {
return {
width: width,
height: height,
data: new Uint8ClampedArray(width * height * 4)
};
}
function cloneImageData(img) {
return {
width: img.width,
height: img.height,
data: new Uint8ClampedArray(img.data)
};
}
var BAND_OFFSET = { R: 0, G: 1, B: 2 };
// BT.601 灰度
function luma(r, g, b) {
return 0.299 * r + 0.587 * g + 0.114 * b;
}
function clamp255(v) {
return v < 0 ? 0 : (v > 255 ? 255 : v);
}
所有图像数据采用与浏览器 ImageData 同形的结构(width / height / Uint8ClampedArray),RGBA 四通道交错存储。灰度转换采用 BT.601 标准加权公式(0.299R + 0.587G + 0.114B),通道值钳制在 0~255 范围内。
3.4.2 光谱指数算子(NDVI/NDWI/EVI/SAVI)
光谱指数是遥感领域的核心分析工具。本项目将普通 RGB 图像的不同通道模拟为近红外(NIR)、红(Red)、绿(Green)、蓝(Blue)波段,实现四种经典植被/水体指数:
// ========== 6. NDVI 波段运算 ==========
// NDVI = (NIR - Red) / (NIR + Red),映射 [-1,1] → [0,255]
function ndvi(img, params) {
var nirKey = params.nir === 'R' || params.nir === 'B' ? params.nir : 'G';
var redKey = params.red === 'R' || params.red === 'B' ? params.red : 'R';
var nirOff = BAND_OFFSET[nirKey];
var redOff = BAND_OFFSET[redKey];
var w = img.width, h = img.height;
var out = createImageData(w, h);
var d = img.data, o = out.data;
var minV = 1, maxV = -1;
for (var i = 0; i < d.length; i += 4) {
var nir = d[i + nirOff];
var red = d[i + redOff];
var denom = nir + red;
var v = denom === 0 ? 0 : (nir - red) / denom;
if (v < minV) minV = v;
if (v > maxV) maxV = v;
var gray = Math.round((v + 1) / 2 * 255);
o[i] = gray; o[i + 1] = gray; o[i + 2] = gray; o[i + 3] = d[i + 3];
}
out.otbMeta = { ndviMin: Math.round(minV * 1000) / 1000, ndviMax: Math.round(maxV * 1000) / 1000 };
return out;
}
// ========== 15. EVI 增强型植被指数 ==========
// EVI = 2.5·(NIR − Red) / (NIR + 6·Red − 7.5·Blue + 1)
function evi(img, params) {
var nirOff = bandOffsetParam(params.nir, 'G');
var redOff = bandOffsetParam(params.red, 'R');
var blueOff = bandOffsetParam(params.blue, 'B');
var out = createImageData(img.width, img.height);
var d = img.data, o = out.data;
var minV = 2.5, maxV = -2.5;
for (var i = 0; i < d.length; i += 4) {
var nir = d[i + nirOff];
var red = d[i + redOff];
var blue = d[i + blueOff];
var denom = nir + 6 * red - 7.5 * blue + 1;
var v = denom === 0 ? 0 : 2.5 * (nir - red) / denom;
if (v < -2.5) v = -2.5;
if (v > 2.5) v = 2.5;
if (v < minV) minV = v;
if (v > maxV) maxV = v;
var gray = Math.round((v + 2.5) / 5 * 255);
o[i] = gray; o[i + 1] = gray; o[i + 2] = gray; o[i + 3] = d[i + 3];
}
out.otbMeta = { eviMin: Math.round(minV * 1000) / 1000, eviMax: Math.round(maxV * 1000) / 1000 };
return out;
}
四种指数均采用归一化映射策略:将计算结果钳制到理论值域后线性映射到 0~255 灰度空间。每个算子通过 otbMeta 属性附加元数据(最小值/最大值),供信息面板和日志展示。用户可通过分段按钮组自由选择 NIR/Red/Blue 通道映射,适配不同类型的遥感影像。
3.4.3 Canny 边缘检测完整管线
Canny 边缘检测是本项目中最复杂的算子,包含六个完整步骤:
// ========== 12. Canny 边缘(高斯平滑→Sobel→非极大值抑制→双阈值滞后连接) ==========
function cannyEdge(img, params) {
var low = clampIntParam(params.low, 0, 255, 40);
var high = clampIntParam(params.high, 0, 255, 100);
if (low > high) { var t = low; low = high; high = t; } // 自动纠正低高倒置
var w = img.width, h = img.height;
var total = w * h;
var out = createImageData(w, h);
var o = out.data, d = img.data;
var edges = 0;
if (w >= 3 && h >= 3) {
// 1) 灰度平面
var gray = new Float64Array(total);
for (var p = 0; p < total; p++) gray[p] = luma(d[p * 4], d[p * 4 + 1], d[p * 4 + 2]);
// 2) 高斯 5×5 平滑
var smooth = new Float64Array(total);
for (var y = 0; y < h; y++) {
for (var x = 0; x < w; x++) {
var acc = 0, kk = 0;
for (var ky = -2; ky <= 2; ky++) {
var sy = Math.min(h - 1, Math.max(0, y + ky));
for (var kx = -2; kx <= 2; kx++) {
var sx = Math.min(w - 1, Math.max(0, x + kx));
acc += gray[sy * w + sx] * GAUSS5[kk++];
}
}
smooth[y * w + x] = acc / 159;
}
}
// 3) Sobel 幅值 + 梯度方向量化(0:±x 1:↘↖ 2:±y 3:↗↙)
var mag = new Float64Array(total);
var dir = new Uint8Array(total);
for (var y2 = 1; y2 < h - 1; y2++) {
for (var x2 = 1; x2 < w - 1; x2++) {
var gx = 0, gy = 0, kk2 = 0;
for (var ky2 = -1; ky2 <= 1; ky2++) {
for (var kx2 = -1; kx2 <= 1; kx2++) {
var v = smooth[(y2 + ky2) * w + (x2 + kx2)];
gx += v * SOBEL_GX[kk2];
gy += v * SOBEL_GY[kk2];
kk2++;
}
}
var i2 = y2 * w + x2;
mag[i2] = Math.sqrt(gx * gx + gy * gy);
var ang = Math.atan2(gy, gx) * 180 / Math.PI;
if (ang < 0) ang += 180;
dir[i2] = Math.round(ang / 45) % 4;
}
}
// 4) 非极大值抑制(沿梯度方向比较两邻居)
var nms = new Float64Array(total);
for (var y3 = 1; y3 < h - 1; y3++) {
for (var x3 = 1; x3 < w - 1; x3++) {
var i3 = y3 * w + x3;
var m = mag[i3];
if (m === 0) continue;
var n1, n2;
if (dir[i3] === 0) { n1 = mag[i3 - 1]; n2 = mag[i3 + 1]; }
else if (dir[i3] === 1) { n1 = mag[i3 + w + 1]; n2 = mag[i3 - w - 1]; }
else if (dir[i3] === 2) { n1 = mag[i3 - w]; n2 = mag[i3 + w]; }
else { n1 = mag[i3 - w + 1]; n2 = mag[i3 + w - 1]; }
if (m >= n1 && m >= n2) nms[i3] = m;
}
}
// 5) 双阈值 + BFS 滞后连接
var mark = new Uint8Array(total); // 0 无 1 弱 2 强
var queue = new Int32Array(total);
var qh = 0, qt = 0;
for (var p2 = 0; p2 < total; p2++) {
if (nms[p2] >= high) { mark[p2] = 2; queue[qt++] = p2; }
else if (nms[p2] >= low) mark[p2] = 1;
}
while (qh < qt) {
var cur = queue[qh++];
var cy = (cur / w) | 0, cx = cur % w;
for (var dy = -1; dy <= 1; dy++) {
var ny = cy + dy;
if (ny < 0 || ny >= h) continue;
for (var dx = -1; dx <= 1; dx++) {
var nx = cx + dx;
if (nx < 0 || nx >= w) continue;
var ni = ny * w + nx;
if (mark[ni] === 1) { mark[ni] = 2; queue[qt++] = ni; }
}
}
}
// 6) 输出
for (var p3 = 0; p3 < total; p3++) {
var v2 = mark[p3] === 2 ? 255 : 0;
o[p3 * 4] = v2; o[p3 * 4 + 1] = v2; o[p3 * 4 + 2] = v2; o[p3 * 4 + 3] = d[p3 * 4 + 3];
if (v2) edges++;
}
}
out.otbMeta = { low: low, high: high, edges: edges };
return out;
}
Canny 管线六步流程:
- 灰度化——BT.601 加权转单通道 Float64Array
- 高斯 5x5 平滑——抑制噪声,为后续梯度计算做准备
- Sobel 梯度——计算幅值(sqrt(gx2+gy2))和方向(atan2 量化到 4 个方向)
- 非极大值抑制(NMS)——沿梯度方向仅保留局部最大值,细化边缘至单像素宽
- 双阈值 BFS 滞后连接——高于高阈值的像素标记为强边缘,低于低阈值的丢弃,介于两者之间的像素若与强边缘 8 邻接则提升为强边缘
- 二值输出——强边缘 255,其余 0
3.4.4 CLAHE 自适应直方图均衡
CLAHE(Contrast Limited Adaptive Histogram Equalization)是本项目中算法复杂度最高的算子,采用分块限幅均衡 + 双线性插值策略:
// ========== 18. CLAHE(分块限制对比度均衡 + 双线性插值,输出灰度图) ==========
function clahe(img, params) {
var tiles = clampIntParam(params.tiles, 2, 32, 8); // 每轴块数
var clip = clampFloatParam(params.clip, 1, 20, 2.5); // 每 bin 限幅倍率
var w = img.width, h = img.height;
var total = w * h;
var out = createImageData(w, h);
var d = img.data, o = out.data;
// 灰度平面
var gray = new Uint8Array(total);
for (var p = 0; p < total; p++) {
gray[p] = clamp255(Math.round(luma(d[p * 4], d[p * 4 + 1], d[p * 4 + 2])));
}
// 全图单值:对比度增强无意义,直接输出灰度副本
var firstGray = gray[0];
var mono = true;
for (var p1 = 1; p1 < total; p1++) {
if (gray[p1] !== firstGray) { mono = false; break; }
}
if (mono) {
for (var p2 = 0; p2 < total; p2++) {
var qm = p2 * 4;
o[qm] = firstGray; o[qm + 1] = firstGray; o[qm + 2] = firstGray; o[qm + 3] = d[qm + 3];
}
out.otbMeta = { tiles: tiles, clip: clip };
return out;
}
var nx = Math.min(tiles, w);
var ny = Math.min(tiles, h);
var tileW = Math.ceil(w / nx);
var tileH = Math.ceil(h / ny);
// 每块:直方图 → 限幅重分布 → CDF 映射函数
var maps = new Array(ny * nx);
for (var ty = 0; ty < ny; ty++) {
for (var tx = 0; tx < nx; tx++) {
var x0 = tx * tileW, y0 = ty * tileH;
var x1 = Math.min(w, x0 + tileW), y1 = Math.min(h, y0 + tileH);
var tileTotal = (x1 - x0) * (y1 - y0);
var hist = new Array(256);
for (var i2 = 0; i2 < 256; i2++) hist[i2] = 0;
for (var yy = y0; yy < y1; yy++) {
for (var xx = x0; xx < x1; xx++) hist[gray[yy * w + xx]]++;
}
// 限幅:每 bin 上限 = clip × 平均,超出部分均匀摊回全部 bin
var limit = Math.max(1, Math.round(tileTotal / 256 * clip));
var excess = 0;
for (var b = 0; b < 256; b++) {
if (hist[b] > limit) { excess += hist[b] - limit; hist[b] = limit; }
}
var share = Math.floor(excess / 256);
var rem = excess - share * 256;
for (var b2 = 0; b2 < 256; b2++) hist[b2] += share + (b2 < rem ? 1 : 0);
// CDF 映射
var map = new Array(256);
var acc2 = 0;
for (var t = 0; t < 256; t++) {
acc2 += hist[t];
map[t] = Math.round(acc2 / tileTotal * 255);
}
maps[ty * nx + tx] = map;
}
}
// 像素级双线性插值(相邻 4 块映射函数加权)
for (var py = 0; py < h; py++) {
var uy = py / tileH - 0.5;
var j0 = Math.floor(uy);
var fy = uy - j0;
var ja = Math.min(ny - 1, Math.max(0, j0));
var jb = Math.min(ny - 1, Math.max(0, j0 + 1));
for (var px = 0; px < w; px++) {
var ux = px / tileW - 0.5;
var i0 = Math.floor(ux);
var fx = ux - i0;
var ia = Math.min(nx - 1, Math.max(0, i0));
var ib = Math.min(nx - 1, Math.max(0, i0 + 1));
var g0 = gray[py * w + px];
var m00 = maps[ja * nx + ia][g0];
var m01 = maps[ja * nx + ib][g0];
var m10 = maps[jb * nx + ia][g0];
var m11 = maps[jb * nx + ib][g0];
var top = m00 + (m01 - m00) * fx;
var bottom = m10 + (m11 - m10) * fx;
var g2 = clamp255(Math.round(top + (bottom - top) * fy));
var q3 = (py * w + px) * 4;
o[q3] = g2; o[q3 + 1] = g2; o[q3 + 2] = g2; o[q3 + 3] = d[q3 + 3];
}
}
out.otbMeta = { tiles: tiles, clip: clip };
return out;
}
CLAHE 算法核心步骤:
- 分块——将图像划分为 tiles x tiles 个子块(默认 8x8)
- 限幅——每个子块计算直方图后,将超过 clip x 平均值的 bin 截断,溢出部分均匀摊回所有 bin,防止噪声过度放大
- CDF 映射——每个子块基于限幅后的直方图计算累积分布函数映射表
- 双线性插值——每个像素的最终值由相邻 4 个子块的映射函数按距离加权得出,消除块间边界伪影
- 单值保护——如果整幅图像灰度完全一致,直接返回副本,避免除以零
3.4.5 KMeans 聚类分类
// ========== 13. KMeans 聚类分类(RGB 特征,均匀间隔采样训练 + 全图分类) ==========
function kmeansClassify(img, params) {
var k = clampIntParam(params.k, 2, 8, 3);
var w = img.width, h = img.height;
var total = w * h;
var d = img.data;
var out = createImageData(w, h);
var o = out.data;
// 训练样本:全图均匀间隔采样(确定性,上限 5 万点)
var sampleCount = Math.min(total, 50000);
var samples = new Float64Array(sampleCount * 3);
var stride = total / sampleCount;
for (var s = 0; s < sampleCount; s++) {
var sp = Math.min(total - 1, Math.floor(s * stride)) * 4;
samples[s * 3] = d[sp];
samples[s * 3 + 1] = d[sp + 1];
samples[s * 3 + 2] = d[sp + 2];
}
// 初始中心:样本均匀间隔取 k 个
var centers = new Float64Array(k * 3);
for (var c0 = 0; c0 < k; c0++) {
var ci = Math.floor((c0 + 0.5) / k * sampleCount);
centers[c0 * 3] = samples[ci * 3];
centers[c0 * 3 + 1] = samples[ci * 3 + 1];
centers[c0 * 3 + 2] = samples[ci * 3 + 2];
}
// Lloyd 迭代(上限 25 轮,中心移动量足够小提前收敛)
var iter = 0, moved = 1;
for (iter = 0; iter < 25 && moved > 0.5; iter++) {
// ... 分配样本到最近中心 → 重算中心 → 计算移动量
}
// 全图分类:每像素替换为所属类中心色
for (var p = 0; p < total; p++) {
var q = p * 4;
var best2 = 0, bestD2 = Infinity;
for (var c3 = 0; c3 < k; c3++) {
var dr2 = d[q] - centers[c3 * 3];
var dg2 = d[q + 1] - centers[c3 * 3 + 1];
var db2 = d[q + 2] - centers[c3 * 3 + 2];
var dd2 = dr2 * dr2 + dg2 * dg2 + db2 * db2;
if (dd2 < bestD2) { bestD2 = dd2; best2 = c3; }
}
o[q] = Math.round(centers[best2 * 3]);
o[q + 1] = Math.round(centers[best2 * 3 + 1]);
o[q + 2] = Math.round(centers[best2 * 3 + 2]);
o[q + 3] = d[q + 3];
}
out.otbMeta = { k: k, iterations: iter, centers: centersArr };
return out;
}
KMeans 采用三阶段策略:
- 采样训练——全图均匀间隔采样最多 5 万个像素点作为训练集,确保确定性(相同输入产生相同采样)
- Lloyd 迭代——初始中心从训练集均匀间隔取 k 个,每轮迭代将样本分配到最近中心并重算中心位置,最多 25 轮或中心移动量小于 0.5 时提前收敛
- 全图分类——每个像素计算到 k 个中心的欧氏距离,替换为最近中心的 RGB 值
3.4.6 处理链重放引擎
// ========== 处理链重放(算子异常不中断整链,失败步骤跳过并记录) ==========
function applyChain(source, chain) {
var current = cloneImageData(source);
var log = [];
for (var i = 0; i < chain.length; i++) {
var step = chain[i];
var fn = OP_FUNCTIONS[step.op];
if (!fn) {
log.push({ op: step.op, error: '未知算子' });
continue;
}
var t0 = Date.now();
try {
current = fn(current, step.params || {});
} catch (e) {
log.push({ op: step.op, error: (e && e.message) ? e.message : '算子执行失败', ms: Date.now() - t0 });
continue; // 失败步骤跳过,链上后续算子继续在当前图像上执行
}
var meta = current.otbMeta || null;
log.push({ op: step.op, ms: Date.now() - t0, meta: meta });
var clean = { width: current.width, height: current.height, data: current.data };
if (current.otbMeta) clean.otbMeta = current.otbMeta;
current = clean;
}
return { image: current, log: log };
}
处理链引擎的核心设计:
- 异常隔离——单个算子执行失败不会中断整条链,错误信息记录到日志后跳过该步骤,后续算子继续在当前图像上执行
- 元数据传递——每个算子通过 otbMeta 属性附加执行元数据(阈值、范围、边缘点数等),最终在日志和信息面板中展示
- 对象清理——每步执行后剥离非标准属性,仅保留 width/height/data/otbMeta,避免中间对象膨胀
3.5 渲染进程交互(renderer.js)
渲染进程负责算子库 UI 构建、处理链管理、增量缓存调度、Canvas 缩放与像素探针。
3.5.1 增量缓存调度
// 实际执行:链尾追加时直接在上次结果上只算新算子(增量);其余情形全链重算
function executeChain() {
if (!state.sourceData) return;
if (state.chain.length === 0) {
state.resultData = null;
state.logEntries = [];
state.totalMs = 0;
renderLog();
el.statusTime.textContent = '-';
updateView();
renderInfo();
setStatus('处理链已清空,显示原图');
return;
}
var appendMode = state.resultData && state.cacheSteps === state.chain.length - 1;
var t0 = Date.now();
var result;
try {
result = appendMode
? applyChain(state.resultData, [state.chain[state.chain.length - 1]])
: applyChain(state.sourceData, state.chain);
} catch (e) {
setStatus('处理链执行异常: ' + errText(e));
state.cacheSteps = -1;
return;
}
var elapsed = Date.now() - t0;
state.resultData = result.image;
state.cacheSteps = state.chain.length;
if (appendMode) {
state.logEntries.push(result.log[result.log.length - 1]);
state.totalMs += elapsed;
} else {
state.logEntries = result.log.slice();
state.totalMs = elapsed;
}
drawToCanvas(el.resultCanvas, state.resultData);
renderLog();
el.statusTime.textContent = state.totalMs + ' ms';
// 自动切到结果视图
state.currentView = 'result';
el.viewGroup.querySelectorAll('.seg-btn').forEach(function(b) {
b.classList.toggle('active', b.getAttribute('data-view') === 'result');
});
updateView();
renderInfo();
setStatus('完成:' + state.chain.length + ' 步 · ' + state.totalMs + ' ms' +
(appendMode ? '(本步增量)' : '(全链)'));
}
增量缓存的核心逻辑:
- appendMode 判定——当 resultData 存在且 cacheSteps 等于 chain.length - 1 时,说明本次操作仅在链尾追加了一个新算子,可以直接在上次结果上只执行这一步
- 结构变更失效——处理链中的上移/下移/删除操作会设置 cacheSteps = -1,强制下次执行走全链重算路径
- runPending 合并——快速连点"添加到处理链"时,30ms 延迟内的重复请求只执行最后一次,避免大图上重复做高开销的全链重算
3.5.2 12 档缩放与像素探针
var ZOOM_STEPS = [0.05, 0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8]; // 档位表
// dir: +1 放大一档 / -1 缩小一档
function setZoom(dir) {
var cur = state.zoomIndex === null ? nearestZoomIndex(fitScale()) : state.zoomIndex;
var next = Math.max(0, Math.min(ZOOM_STEPS.length - 1, cur + dir));
if (next === cur && state.zoomIndex !== null) return;
state.zoomIndex = next;
applyZoom();
}
function applyZoom() {
if (!state.sourceData) return;
var scale = state.zoomIndex === null ? fitScale() : ZOOM_STEPS[state.zoomIndex];
[el.sourceCanvas, el.resultCanvas].forEach(function(c) {
if (!c.classList.contains('hidden')) {
c.style.width = Math.max(1, Math.round(state.sourceData.width * scale)) + 'px';
c.style.height = Math.max(1, Math.round(state.sourceData.height * scale)) + 'px';
}
});
var pct = Math.round(scale * 100);
el.zoomLabel.textContent = state.zoomIndex === null ? '适应 ' + pct + '%' : pct + '%';
}
// ========== 像素探针(点击画布读当前视图像素值) ==========
function probePixel(e) {
var canvas = e.target;
var scale = state.zoomIndex === null ? fitScale() : ZOOM_STEPS[state.zoomIndex];
if (scale <= 0) return;
var rect = canvas.getBoundingClientRect();
var x = Math.floor((e.clientX - rect.left) / scale);
var y = Math.floor((e.clientY - rect.top) / scale);
var data = state.currentView === 'source' ? state.sourceData : state.resultData;
if (!data) data = state.sourceData;
if (x < 0 || y < 0 || x >= data.width || y >= data.height) return;
var q = (y * data.width + x) * 4;
state.probe = {
x: x, y: y,
r: data.data[q], g: data.data[q + 1], b: data.data[q + 2],
view: state.currentView
};
el.statusProbe.textContent = '(' + x + ',' + y + ') ' +
state.probe.r + + state.probe.g + ',' + state.probe.b;
}
缩放系统采用档位表制——12 个预设缩放比例(5%~800%),通过 CSS style.width/height 修改画布显示尺寸(不改变实际像素数据),image-rendering: pixelated 确保放大时显示硬边缘像素块。
像素探针通过鼠标点击坐标反算到图像像素坐标(除以当前缩放比例),直接读取 sourceData/resultData 的 Uint8ClampedArray 获取 RGB 值,显示在底部状态栏。
3.5.3 直方图统计与绘制
// ========== 直方图(真实统计绘制) ==========
function drawHistogram(imgData) {
var ctx = el.histCanvas.getContext('2d');
var W = el.histCanvas.width, H = el.histCanvas.height;
ctx.clearRect(0, 0, W, H);
if (!imgData) {
ctx.fillStyle = '#6c7086';
ctx.font = '11px Consolas';
ctx.fillText('无图像', 10, 20);
return;
}
var stat = histogram(imgData, 'gray');
var maxBin = 0;
for (var i = 0; i < 256; i++) {
if (stat.bins[i] > maxBin) maxBin = stat.bins[i];
}
if (maxBin === 0) return;
ctx.fillStyle = '#89b4fa';
for (var b = 0; b < 256; b++) {
var x = Math.round(b * W / 256);
var nextX = Math.round((b + 1) * W / 256);
var barH = Math.round(stat.bins[b] / maxBin * (H - 8));
if (barH > 0) {
ctx.fillRect(x, H - barH, Math.max(1, nextX - x), barH);
}
}
}
直方图采用灰度统计(BT.601 加权),256 个 bin 均匀分布在 200px 宽的 Canvas 上,最大 bin 高度归一化到画布高度减 8px 边距。视图切换时自动更新直方图——原图视图显示源图像统计,结果视图显示处理后图像统计。
四、鸿蒙稳定性适配
4.1 状态栏反馈替代 alert
鸿蒙 ArkWeb 引擎下,alert/confirm/prompt 等原生对话框可能触发适配层异常。本项目全部使用状态栏文字反馈:
function setStatus(text) {
if (!el.statusFile) return;
el.statusFile.textContent = String(text == null ? '' : text);
}
所有操作结果(打开图像、导出结果、处理链执行、异常报错等)均通过 setStatus() 在底部状态栏显示,不使用任何原生弹窗。
4.2 全局异常兜底
// 全局异常兜底:未捕获异常/未处理 Promise 拒绝一律上报状态栏,避免静默失灵
window.addEventListener('error', function(e) {
try { setStatus('界面异常: ' + errText((e && e.message) || '未知错误')); } catch (x) {}
});
window.addEventListener('unhandledrejection', function(e) {
try { setStatus('操作异常: ' + errText(e && e.reason)); } catch (x) {}
});
两个全局监听器确保任何未捕获的同步异常和未处理的 Promise 拒绝都会反馈到状态栏,而不是静默失败。这在鸿蒙壳环境下尤为重要——ArkWeb 引擎的控制台输出不可见,状态栏是唯一的用户反馈通道。
4.3 自定义关于弹窗(fixed 覆盖)
关于对话框使用 position: fixed 全屏覆盖方案(本项目采用 Flex 布局而非 Grid,无法使用 grid-area 叠加):
#aboutDialogLayer {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(17, 17, 27, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
通过 hidden 类控制显隐,点击"关于"按钮移除 hidden 类显示弹窗,点击"关闭"按钮或遮罩层添加 hidden 类隐藏。
4.4 Catppuccin Mocha 深色主题
样式文件采用 Catppuccin Mocha 配色方案,通过 CSS 变量统一管理。主题配色在功能元素上的映射:
- 品牌色(toolbar-brand):青色 #94e2d5
- 分段按钮选中态:青色 #94e2d5
- 主操作按钮(添加到处理链):青色边框 + 悬停填充
- 直方图柱状条:蓝色 #89b4fa
- 像素探针数值:橙色 #fab387
- 处理日志正常:绿色 #a6e3a1
- 处理日志错误:红色 #f38ba8
- 关于链接:蓝色 #89b4fa
4.5 应用名称配置
应用名称需在多个位置保持一致:
// AppScope/resources/base/element/string.json
{
"string": [
{
"name": "app_name",
"value": "星图遥感"
}
]
}
// electron/src/main/resources/base/element/string.json
{
"string": [
{
"name": "EntryAbility_label",
"value": "星图遥感"
}
]
}
// electron/src/main/resources/zh_CN/element/string.json
{
"string": [
{
"name": "EntryAbility_label",
"value": "星图遥感"
}
]
}
// electron/src/main/resources/en_US/element/string.json
{
"string": [
{
"name": "EntryAbility_label",
"value": "星图遥感"
}
]
}
注意:en_US 目录下 EntryAbility_label 也必须是中文——鸿蒙资源覆盖规则是 locale 目录优先于 base 目录,如果 en_US 留英文,英文系统语言下会显示英文名称而非中文品牌名。
4.6 算子库 UI 构建策略
算子库列表仅在初始化时构建一次,后续点击选中仅切换高亮类名,不重建 DOM:
function renderOpList() {
el.opList.innerHTML = '';
var lastGroup = null;
OP_DEFS.forEach(function(def) {
var group = def.group || '其他';
if (group !== lastGroup) {
var gt = document.createElement('div');
gt.className = 'op-group-title';
gt.textContent = group;
el.opList.appendChild(gt);
lastGroup = group;
}
var btn = document.createElement('button');
btn.className = 'op-btn' + (state.selectedOp === def.id ? ' active' : '');
btn.textContent = def.name;
btn.setAttribute('data-op', def.id);
btn.addEventListener('click', function() {
if (state.selectedOp === def.id) return;
state.selectedOp = def.id;
state.paramValues = {};
// 仅切换高亮,不重建列表
el.opList.querySelectorAll('.op-btn').forEach(function(b) {
b.classList.toggle('active', b.getAttribute('data-op') === def.id);
});
renderOpParams();
});
el.opList.appendChild(btn);
});
}
这一设计避免了在频繁交互场景下反复重建 DOM 导致的性能开销和滚动位置丢失问题。
五、构建与部署



5.1 构建 HAP 包
在 DevEco Studio 中执行 Build → Build Hap(s),生成 unsigned HAP 包。由于本项目使用 Electron 壳方案,构建流程与标准鸿蒙应用一致。
5.2 签名配置
Release 签名需要配置 build-profile.json5:
"signingConfigs": [{
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "XingtuYaogan.p12",
"keyAlias": "xingtuyaogan1",
"signAlg": "SHA256withECDSA",
"profile": "XingtuYaoganRelease.p7b",
"certpath": "XingtuYaogan.cer"
}
}]
5.3 部署目录同步
业务代码的开发目录与部署目录分离,每次修改后需同步到 web_engine 的 resfile 目录:
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force
Copy-Item "electron-apps/OrfeoToolBox/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force
六、常见问题与解决方案
Q1:AGC 审核驳回——应用名称与包名不一致
问题现象: 提交 AGC 审核后收到驳回意见,指出应用显示名称与包名或内部配置不一致。
根因分析: AppScope/app.json5 中的 bundleName、app_name,以及三套 EntryAbility_label(base/zh_CN/en_US)未统一。
解决: 确保以下五处名称完全一致:
- AppScope/app.json5 → bundleName: com.wei.xingtuyaogan
- AppScope/resources/base/element/string.json → app_name: 星图遥感
- electron/src/main/resources/base/element/string.json → EntryAbility_label: 星图遥感
- electron/src/main/resources/en_US/element/string.json → EntryAbility_label: 星图遥感
- electron/src/main/resources/zh_CN/element/string.json → EntryAbility_label: 星图遥感
教训: 鸿蒙资源覆盖规则是 locale 目录 > base 目录,修改应用名时必须全局 grep 所有 element/string.json 文件,不能只改 base。zh_CN 曾被 DevEco 旧缓冲区还原导致名称回退,需二次修复并末轮回读确认。
Q2:构建签名报错 ERROR: 11014001 key alias not found
问题现象: 在 DevEco Studio 中执行 Build → Build Hap(s),SignHap 阶段报错 ERROR: 11014001,提示签名密钥别名找不到。
根因分析: build-profile.json5 中 signingConfigs.default.material.keyAlias 填写的值与 .p12 签名文件内实际存储的别名不一致。
解决: 使用 DevEco 内置 JBR 的 keytool 查看 p12 内真实别名:
"%DEVECO_JBR%\jbr\bin\keytool" -list -keystore "XingtuYaogan.p12" -storepass 你的密码
查看输出中的 Alias Name,然后填入 build-profile.json5:
"signingConfigs": [{
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "C:/path/to/XingtuYaogan.p12",
"keyAlias": "xingtuyaogan1", // 必须与 p12 内真实别名完全一致
"signAlg": "SHA256withECDSA",
"profile": "C:/path/to/XingtuYaoganRelease.p7b",
"certpath": "C:/path/to/XingtuYaogan.cer"
}
}]
Q3:大图处理时界面长时间无响应
问题现象: 对高分辨率遥感图像执行 Canny 或 CLAHE 等计算密集型算子时,界面完全冻结,状态栏不更新,用户以为应用卡死。
根因分析: 算子执行是同步的逐像素遍历,大图(如 4096x4096)单次运算可能需要数秒。如果直接在事件回调中执行,会阻塞主线程导致 UI 冻结。
解决: 采用"先亮状态再延迟执行"的调度模式:
function runChain() {
if (!state.sourceData) return;
var mp = (state.sourceData.width * state.sourceData.height / 1000000).toFixed(1);
setStatus(state.cacheSteps === state.chain.length - 1 && state.resultData
? '计算中…(增量执行最后一步)'
: '计算中…(' + mp + ' 百万像素 × ' + state.chain.length + ' 步,请稍候)');
if (state.runPending) return;
state.runPending = true;
setTimeout(function() {
state.runPending = false;
executeChain();
}, 30);
}
30ms 延迟让状态栏先刷新"计算中"提示,用户确认操作已被响应;runPending 标志合并快速连点,避免重复重算。
Q4:处理链上移/下移后结果不正确
问题现象: 在处理链中调整算子顺序后,输出结果与预期不符,或者某些步骤的效果丢失。
根因分析: 上移/下移操作修改了 chain 数组的顺序,但没有重置增量缓存。如果 cacheSteps 仍然指向之前的步数,下次执行会错误地走 appendMode 增量路径,只计算新链的最后一步而非全链。
解决: 任何结构性变更(上移/下移/删除)都必须失效增量缓存:
up.addEventListener('click', function() {
if (idx === 0) return;
var t = state.chain[idx - 1];
state.chain[idx - 1] = state.chain[idx];
state.chain[idx] = t;
state.cacheSteps = -1; // 结构变更:需全链重算
renderChain();
runChain();
});
Q5:中值滤波 5x5 核在大图上导致应用崩溃
问题现象: 对高分辨率图像使用 5x5 中值滤波时,应用直接崩溃或长时间无响应后被系统杀死。
根因分析: 5x5 中值滤波需要对每个像素的 25 个邻居进行排序,计算量是 3x3 核的约 5.5 倍(窗口面积比),大图(如 4096x4096 = 1600 万像素)上总运算量极大。
解决: 设置像素上限保护,5x5 核限制在 4MP 以内,超出则抛出错误提示降级到 3x3:
function medianFilter(img, params) {
var size = parseInt(params.size, 10) === 5 ? 5 : 3;
var maxPixels = size === 5 ? 4194304 : 16777216;
var w = img.width, h = img.height;
if (w * h > maxPixels) {
throw new Error(size + '×' + size + ' 中值滤波像素上限 ' + Math.round(maxPixels / 1000000) + ' MP,请改用 3×3 核');
}
// ... 正常执行
}
处理链的异常隔离机制确保此错误被捕获并记录到日志,不会中断后续算子执行。
Q6:修改了代码,但 HAP 安装后没有生效
问题现象: 修改了 renderer.js、otb-engine.js 等文件,重新构建 HAP 安装后,应用行为没有任何变化。
根因分析: 鸿蒙壳工程的实际运行文件从 web_engine/src/main/resources/resfile/resources/app/ 部署目录加载,而非开发目录。修改后必须手动同步。
解决: 每次修改后执行同步命令:
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force
Copy-Item "electron-apps/OrfeoToolBox/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force
验证方法: 同步后用文件哈希对比确认一致性。
Q7:AGC 审核驳回——requestPermissions 声明了受限 ACL 权限
问题现象: AGC 审核驳回,指出 web_engine/src/main/module.json5 中声明了受限 ACL 权限,但应用隐私政策中未声明对应用途。
根因分析: Electron 壳工程模板默认声明了大量系统权限,星图遥感作为图像处理工具不需要位置、相机、麦克风等权限。
解决: 精简权限声明,只保留应用实际使用的 14 项系统权限(INTERNET、GET_NETWORK_INFO、ACCESS_CERT_MANAGER、RUNNING_LOCK、PRINT、PREPARE_APP_TERMINATE、ACCESS_BIOMETRIC、PRIVACY_WINDOW、WINDOW_TOPMOST、GYROSCOPE、ACCELEROMETER、SET_ABILITY_INSTANCE_INFO、GET_FILE_ICON、LOCK_WINDOW_CURSOR),移除 FILE_ACCESS_PERSIST、READ_WRITE_DESKTOP_DIRECTORY、READ_PASTEBOARD、CAMERA、MICROPHONE 等不需要的权限。
七、总结
本文完整记录了在鸿蒙 PC 平台上适配星图遥感图像处理器的全过程。通过 Electron 壳方案,我们实现了一个功能完整的纯 JavaScript 遥感图像处理环境,核心特性包括:
| 特性 | 实现方案 |
|---|---|
| 18 种算子 | 灰度化/反色/亮度对比度/拉伸/均衡/CLAHE/高斯/中值/Sobel/Canny/膨胀/腐蚀/Otsu/NDVI/NDWI/EVI/SAVI/KMeans |
| 处理链 | 分组算子库 + 参数面板 + 顺序编排 + 增量缓存 + 异常跳过 |
| Canny 管线 | 高斯平滑 + Sobel 梯度 + NMS + 双阈值 BFS 滞后连接 |
| CLAHE | 分块限幅均衡 + 双线性插值 + 单值保护 |
| KMeans | 均匀间隔采样 + Lloyd 迭代 + 全图分类 |
| 缩放与探针 | 12 档缩放(5%~800%)+ 适应窗口 + 像素 RGB 读取 |
| 直方图 | 灰度统计 + Canvas 柱状图实时绘制 |
| 文件读写 | base64 传输(20MB 上限)+ PNG 导出 |
| 鸿蒙稳定 | 禁用默认菜单 + 自定义关于弹窗 + 三防窗口 + 全局异常兜底 |
适配过程中最关键的三个点:
- 纯 JavaScript 逐像素运算——所有 18 种图像处理算子均通过遍历 Uint8ClampedArray 逐像素计算完成,不依赖 WebGL 或任何第三方库,在鸿蒙 ArkWeb 引擎上兼容性最好,但需要关注大图上的计算性能
- 增量缓存调度——处理链追加算子时直接在上次结果上只执行新步骤,结构变更(上移/下移/删除)时失效缓存走全链重算,配合 runPending 合并快速连点,兼顾了响应速度与计算正确性
- 处理链异常隔离——单个算子执行失败不中断整条链,错误信息记录到日志后跳过该步骤继续执行后续算子,确保用户的多步处理流程不会因为某一步的参数不当而全部报废
这些问题在标准 Electron 环境中不会出现或表现不同,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的排查和修复,最终实现了稳定可用的鸿蒙版纯 JavaScript 遥感图像处理环境。
更多推荐



所有评论(0)