/** * 自然写教室智能算力盒边缘计算软件 V1.0 * 笔迹预处理模块 - 笔迹坐标数据预处理管道 * * 对网关转发的原始笔迹坐标进行预处理: * 去噪滤波、坐标归一化、笔画分割、特征提取 * 预处理结果作为NPU/GPU推理的标准化输入 */ #ifndef STROKE_PREPROCESSOR_H #define STROKE_PREPROCESSOR_H #include #include #include #include #include // ==================== 基础数据结构 ==================== /** 原始笔迹坐标点(来自网关gRPC数据流) */ struct RawPoint { float x; // X坐标(点阵单位,约300DPI) float y; // Y坐标 float pressure; // 压力值 (0.0-1.0) uint32_t timestamp; // 采集时间戳(毫秒) bool pen_up; // 抬笔标记 }; /** 归一化后的坐标点 */ struct NormalizedPoint { float x; // 归一化X (0.0-1.0) float y; // 归一化Y (0.0-1.0) float pressure; // 压力值 (0.0-1.0) }; /** 笔画数据 */ struct Stroke { std::vector points; // 归一化坐标点序列 int stroke_index; // 笔画序号 float length; // 笔画路径长度 int duration_ms; // 书写耗时(毫秒) }; /** 预处理输出(用于NPU推理输入) */ struct PreprocessedData { std::vector image; // 渲染后的灰度图像 (H*W) int image_width; // 图像宽度 int image_height; // 图像高度 std::vector strokes; // 分割后的笔画列表 int total_points; // 总坐标点数 int stroke_count; // 笔画数量 }; // ==================== 去噪滤波器 ==================== /** * 笔迹去噪滤波器 * 消除点阵笔采集过程中的抖动噪声和异常跳跃点 * 多级滤波策略:异常点剔除 → 中值滤波 → 移动平均平滑 */ class StrokeNoiseFilter { public: /** * 构造函数 * max_jump: 最大允许跳跃距离(超过则视为异常点) * window_size: 滤波窗口大小(奇数) */ StrokeNoiseFilter(float max_jump = 50.0f, int window_size = 3) : max_jump_(max_jump), window_size_(window_size) {} /** * 剔除异常跳跃点 * 点阵笔摄像头短暂遮挡会导致坐标突变,需要过滤 */ std::vector remove_outliers(const std::vector& points) { if (points.size() < 3) return points; std::vector result; result.push_back(points[0]); for (size_t i = 1; i < points.size(); i++) { float dx = points[i].x - points[i-1].x; float dy = points[i].y - points[i-1].y; float dist = std::sqrt(dx * dx + dy * dy); // 跳跃距离在合理范围内才保留该点 if (dist <= max_jump_) { result.push_back(points[i]); } } return result; } /** * 中值滤波去噪 * 对X和Y坐标分别进行一维中值滤波 * 有效消除脉冲噪声同时保留笔画转折特征 */ std::vector median_filter(const std::vector& points) { int n = static_cast(points.size()); if (n < window_size_) return points; int half = window_size_ / 2; std::vector result(n); for (int i = 0; i < n; i++) { // 收集窗口内的X和Y值 std::vector wx, wy; for (int j = std::max(0, i - half); j <= std::min(n - 1, i + half); j++) { wx.push_back(points[j].x); wy.push_back(points[j].y); } // 排序取中值 std::sort(wx.begin(), wx.end()); std::sort(wy.begin(), wy.end()); result[i] = points[i]; result[i].x = wx[wx.size() / 2]; result[i].y = wy[wy.size() / 2]; } return result; } /** * 移动平均平滑 * 进一步减少微小抖动,使笔画更流畅 */ std::vector moving_average(const std::vector& points) { int n = static_cast(points.size()); if (n < 3) return points; std::vector result(n); int half = window_size_ / 2; for (int i = 0; i < n; i++) { float sum_x = 0, sum_y = 0; int count = 0; for (int j = std::max(0, i - half); j <= std::min(n - 1, i + half); j++) { sum_x += points[j].x; sum_y += points[j].y; count++; } result[i] = points[i]; result[i].x = sum_x / count; result[i].y = sum_y / count; } return result; } /** 执行完整去噪流程 */ std::vector apply(const std::vector& points) { auto step1 = remove_outliers(points); auto step2 = median_filter(step1); auto step3 = moving_average(step2); return step3; } private: float max_jump_; int window_size_; }; // ==================== 坐标归一化器 ==================== /** * 坐标归一化器 * 将不同纸张尺寸和分辨率的原始坐标统一归一化到[0,1]范围 * 保持宽高比以避免笔迹变形 */ class CoordinateNormalizer { public: CoordinateNormalizer(bool preserve_aspect = true) : preserve_aspect_(preserve_aspect) {} /** * Min-Max归一化,映射到[0,1]范围 */ std::vector normalize(const std::vector& points) { if (points.empty()) return {}; // 计算坐标范围 float min_x = points[0].x, max_x = points[0].x; float min_y = points[0].y, max_y = points[0].y; for (const auto& p : points) { min_x = std::min(min_x, p.x); max_x = std::max(max_x, p.x); min_y = std::min(min_y, p.y); max_y = std::max(max_y, p.y); } float range_x = max_x - min_x; float range_y = max_y - min_y; // 保持宽高比时使用统一的缩放因子 float scale = 1.0f; if (preserve_aspect_) { scale = std::max(range_x, range_y); if (scale < 1e-6f) scale = 1.0f; } std::vector result; result.reserve(points.size()); for (const auto& p : points) { NormalizedPoint np; if (preserve_aspect_) { np.x = (p.x - min_x) / scale; np.y = (p.y - min_y) / scale; } else { np.x = (range_x > 1e-6f) ? (p.x - min_x) / range_x : 0.5f; np.y = (range_y > 1e-6f) ? (p.y - min_y) / range_y : 0.5f; } np.pressure = p.pressure; result.push_back(np); } return result; } private: bool preserve_aspect_; }; // ==================== 笔画分割器 ==================== /** * 笔画分割器 * 根据抬笔事件和时间间隔将连续坐标流分割为独立笔画 */ class StrokeSegmenter { public: StrokeSegmenter(int time_threshold_ms = 200, int min_points = 3) : time_threshold_(time_threshold_ms), min_points_(min_points) {} /** * 将原始点序列分割为笔画列表 */ std::vector> segment(const std::vector& points) { if (points.empty()) return {}; std::vector> strokes; std::vector current; current.push_back(points[0]); for (size_t i = 1; i < points.size(); i++) { bool is_break = points[i].pen_up; int time_gap = static_cast(points[i].timestamp - points[i-1].timestamp); if ((is_break || time_gap > time_threshold_) && static_cast(current.size()) >= min_points_) { strokes.push_back(current); current.clear(); } if (!points[i].pen_up) { current.push_back(points[i]); } } if (static_cast(current.size()) >= min_points_) { strokes.push_back(current); } return strokes; } private: int time_threshold_; int min_points_; }; // ==================== 图像渲染器 ==================== /** * 笔迹图像渲染器 * 将归一化坐标渲染为灰度图像作为CNN模型输入 * 使用Bresenham直线算法连接相邻坐标点 */ class StrokeImageRenderer { public: StrokeImageRenderer(int width = 64, int height = 64) : width_(width), height_(height) {} /** * 将坐标序列渲染为灰度图像 * 输出一维浮点数组,值域[0,1],1表示笔迹 */ std::vector render(const std::vector& points) { std::vector image(width_ * height_, 0.0f); for (size_t i = 1; i < points.size(); i++) { int x0 = static_cast(points[i-1].x * (width_ - 1)); int y0 = static_cast(points[i-1].y * (height_ - 1)); int x1 = static_cast(points[i].x * (width_ - 1)); int y1 = static_cast(points[i].y * (height_ - 1)); // 裁剪到图像范围 x0 = std::clamp(x0, 0, width_ - 1); y0 = std::clamp(y0, 0, height_ - 1); x1 = std::clamp(x1, 0, width_ - 1); y1 = std::clamp(y1, 0, height_ - 1); float pressure = (points[i-1].pressure + points[i].pressure) * 0.5f; // Bresenham直线算法 draw_line(image, x0, y0, x1, y1, pressure); } return image; } private: void draw_line(std::vector& image, int x0, int y0, int x1, int y1, float value) { int dx = std::abs(x1 - x0); int dy = std::abs(y1 - y0); int sx = (x0 < x1) ? 1 : -1; int sy = (y0 < y1) ? 1 : -1; int err = dx - dy; while (true) { int idx = y0 * width_ + x0; if (idx >= 0 && idx < width_ * height_) { image[idx] = std::max(image[idx], value); } if (x0 == x1 && y0 == y1) break; int e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } } int width_; int height_; }; // ==================== 预处理管道(整合) ==================== /** * 笔迹预处理管道 * 整合去噪、归一化、分割、渲染的完整处理流程 * 输入原始坐标点序列,输出标准化的推理输入数据 */ class StrokePreprocessor { public: StrokePreprocessor(int image_size = 64) : noise_filter_(50.0f, 3), normalizer_(true), segmenter_(200, 3), renderer_(image_size, image_size), image_size_(image_size) {} /** * 执行完整预处理管道 * 流程:原始坐标 → 去噪 → 归一化 → 笔画分割 → 图像渲染 */ PreprocessedData process(const std::vector& raw_points) { PreprocessedData result; // 步骤1:去噪滤波 auto denoised = noise_filter_.apply(raw_points); // 步骤2:坐标归一化 auto normalized = normalizer_.normalize(denoised); // 步骤3:笔画分割 auto stroke_groups = segmenter_.segment(denoised); // 构建笔画数据 for (int i = 0; i < static_cast(stroke_groups.size()); i++) { Stroke stroke; stroke.stroke_index = i; auto norm_group = normalizer_.normalize(stroke_groups[i]); stroke.points = norm_group; stroke.length = calc_path_length(norm_group); if (stroke_groups[i].size() >= 2) { stroke.duration_ms = static_cast( stroke_groups[i].back().timestamp - stroke_groups[i].front().timestamp); } result.strokes.push_back(stroke); } // 步骤4:渲染为灰度图像 result.image = renderer_.render(normalized); result.image_width = image_size_; result.image_height = image_size_; result.total_points = static_cast(denoised.size()); result.stroke_count = static_cast(result.strokes.size()); return result; } private: float calc_path_length(const std::vector& points) { float total = 0.0f; for (size_t i = 1; i < points.size(); i++) { float dx = points[i].x - points[i-1].x; float dy = points[i].y - points[i-1].y; total += std::sqrt(dx * dx + dy * dy); } return total; } StrokeNoiseFilter noise_filter_; CoordinateNormalizer normalizer_; StrokeSegmenter segmenter_; StrokeImageRenderer renderer_; int image_size_; }; #endif // STROKE_PREPROCESSOR_H