星图遥感是一款纯 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++ / PythonHTML5/Canvas/Vanilla JavaScript
文件操作本地文件 I/ONode.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(); });

关键点说明:

  1. disableHardwareAcceleration——鸿蒙 ArkWeb 引擎下 GPU 硬件加速可能导致白屏或渲染异常,必须在 app ready 之前调用禁用
  2. Menu.setApplicationMenu(null) + mainWindow.setMenu(null) 双保险——彻底移除 Electron 默认菜单栏,避免鸿蒙下菜单渲染引发崩溃
  3. frame: true / transparent: false / resizable: true 三防配置——保留系统原生标题栏、禁止透明窗口、允许用户调整尺寸,这三项组合是鸿蒙上窗口稳定运行的基线
  4. try-catch 包裹 createWindow——鸿蒙下 SubWindow 创建可能超时,未捕获异常会导致主进程直接崩溃
  5. 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 管线六步流程:

  1. 灰度化——BT.601 加权转单通道 Float64Array
  2. 高斯 5x5 平滑——抑制噪声,为后续梯度计算做准备
  3. Sobel 梯度——计算幅值(sqrt(gx2+gy2))和方向(atan2 量化到 4 个方向)
  4. 非极大值抑制(NMS)——沿梯度方向仅保留局部最大值,细化边缘至单像素宽
  5. 双阈值 BFS 滞后连接——高于高阈值的像素标记为强边缘,低于低阈值的丢弃,介于两者之间的像素若与强边缘 8 邻接则提升为强边缘
  6. 二值输出——强边缘 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 算法核心步骤:

  1. 分块——将图像划分为 tiles x tiles 个子块(默认 8x8)
  2. 限幅——每个子块计算直方图后,将超过 clip x 平均值的 bin 截断,溢出部分均匀摊回所有 bin,防止噪声过度放大
  3. CDF 映射——每个子块基于限幅后的直方图计算累积分布函数映射表
  4. 双线性插值——每个像素的最终值由相邻 4 个子块的映射函数按距离加权得出,消除块间边界伪影
  5. 单值保护——如果整幅图像灰度完全一致,直接返回副本,避免除以零
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 采用三阶段策略:

  1. 采样训练——全图均匀间隔采样最多 5 万个像素点作为训练集,确保确定性(相同输入产生相同采样)
  2. Lloyd 迭代——初始中心从训练集均匀间隔取 k 个,每轮迭代将样本分配到最近中心并重算中心位置,最多 25 轮或中心移动量小于 0.5 时提前收敛
  3. 全图分类——每个像素计算到 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 导出
鸿蒙稳定禁用默认菜单 + 自定义关于弹窗 + 三防窗口 + 全局异常兜底

适配过程中最关键的三个点:

  1. 纯 JavaScript 逐像素运算——所有 18 种图像处理算子均通过遍历 Uint8ClampedArray 逐像素计算完成,不依赖 WebGL 或任何第三方库,在鸿蒙 ArkWeb 引擎上兼容性最好,但需要关注大图上的计算性能
  2. 增量缓存调度——处理链追加算子时直接在上次结果上只执行新步骤,结构变更(上移/下移/删除)时失效缓存走全链重算,配合 runPending 合并快速连点,兼顾了响应速度与计算正确性
  3. 处理链异常隔离——单个算子执行失败不中断整条链,错误信息记录到日志后跳过该步骤继续执行后续算子,确保用户的多步处理流程不会因为某一步的参数不当而全部报废

这些问题在标准 Electron 环境中不会出现或表现不同,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的排查和修复,最终实现了稳定可用的鸿蒙版纯 JavaScript 遥感图像处理环境。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐