feat: initial commit - massive-cloud point cloud processing system

This commit is contained in:
2026-05-27 09:51:42 +00:00
commit 16811100d1
391 changed files with 78338 additions and 0 deletions

61
.dockerignore Normal file
View File

@@ -0,0 +1,61 @@
# 宏量点云处理软件 - Docker构建忽略文件
# 依赖目录
node_modules
.pnpm-store
# 构建输出
dist
build
.next
.nuxt
# 开发文件
.git
.gitignore
.github
.vscode
.idea
# 环境文件
.env
.env.*
!.env.example
# 日志
logs
*.log
npm-debug.log*
pnpm-debug.log*
# 测试
coverage
.nyc_output
*.test.ts
*.spec.ts
__tests__
# 文档
*.md
!README.md
docs
# Docker相关
Dockerfile*
docker-compose*
.docker
# 本地大数据目录(开发 compose 挂载到 /data,勿打入构建上下文)
data
# 算法源码与样例数据(13.5GB 量级)——镜像里通过卷或下载脚本注入,
# 不打进构建上下文。在容器内通过 ALG_ROOT 环境变量指向挂载点。
alg
src/alg
# 其他
.DS_Store
Thumbs.db
*.swp
*.swo
.manus-logs

147
.gitignore vendored Normal file
View File

@@ -0,0 +1,147 @@
# Dependencies
**/node_modules
.pnpm-store/
# Build outputs
dist/
build/
*.dist
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
*.bak
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
# Gatsby files
.cache/
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Database
*.db
*.sqlite
*.sqlite3
# ─── 算法原始压缩包(大文件,不入库)──────────────────────────────────────────
alg/*.zip
alg/*.rar
alg/*.tar.gz
alg/*.7z
# ─── 算法测试数据(大文件,不入库)────────────────────────────────────────────
alg/**/test_data/**/*.ply
alg/**/test_data/**/*.stp
alg/**/test_data/**/*.step
alg/**/test_data/runs/
# ─── 运行时数据(不入库)────────────────────────────────────────────────────────
# 点云、分析结果、上传文件等运行时产生的数据
data/
# ─── 算法示例数据 / 运行产物(不入库)──────────────────────────────────────────
# flatness-suite 示例数据集、可视化输出、运行结果
alg/point_porj/datasets/
alg/point_porj/vis/
alg/point_porj/results/
alg/point_porj/excel/
# 点云测距示例点云文件(大文件)
alg/点云测距v1.1/*.ply
alg/点云测距v1.1/train_measurement_system/visualizations/
alg/点云测距v1.1/train_measurement_system/results/
# Python 构建产物
**/__pycache__/
**/*.pyc
**/*.pyo
**/*.egg-info/
**/.eggs/
**/dist-info/
# 算法运行中间产物
alg/delivery_v1.1/coupler_panel_release/runs/
alg/delivery_v1.1/underframe_deflection/runs/

0
.gitkeep Normal file
View File

35
.prettierignore Normal file
View File

@@ -0,0 +1,35 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
dist/
build/
*.dist
# Generated files
*.tsbuildinfo
coverage/
# Package files
package-lock.json
pnpm-lock.yaml
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
# Environment files
.env*
# IDE files
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db

15
.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"proseWrap": "preserve"
}

0
=1.10 Normal file
View File

16
=3.1 Normal file
View File

@@ -0,0 +1,16 @@
Looking in indexes: https://pypi.tuna.tsinghua.edu.cn/simple
Collecting scipy
Downloading https://pypi.tuna.tsinghua.edu.cn/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (37.7 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 37.7/37.7 MB 3.8 MB/s 0:00:09
Collecting openpyxl
Downloading https://pypi.tuna.tsinghua.edu.cn/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl (250 kB)
Requirement already satisfied: numpy<2.5,>=1.23.5 in /usr/local/lib/python3.10/dist-packages (from scipy) (2.2.6)
Collecting et-xmlfile (from openpyxl)
Downloading https://pypi.tuna.tsinghua.edu.cn/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl (18 kB)
Installing collected packages: scipy, et-xmlfile, openpyxl
Successfully installed et-xmlfile-2.0.0 openpyxl-3.1.5 scipy-1.15.3
WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.
[notice] A new release of pip is available: 26.0.1 -> 26.1.1
[notice] To update, run: python3 -m pip install --upgrade pip

264
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,264 @@
# 宏量点云处理软件 - 内网Docker部署指南
本文档详细说明如何在内网服务器上使用Docker部署宏量点云处理软件。
## 系统要求
### 硬件要求
| 配置项 | 最低要求 | 推荐配置 |
|--------|----------|----------|
| CPU | 4核 | 8核+ |
| 内存 | 8GB | 16GB+ |
| 存储 | 100GB SSD | 500GB+ SSD |
| 网络 | 100Mbps | 1Gbps |
### 软件要求
- 操作系统: Linux (Ubuntu 20.04+, CentOS 7+, Debian 10+)
- Docker: 20.10+
- Docker Compose: 2.0+
## 快速开始
### 1. 安装Docker和Docker Compose
```bash
# Ubuntu/Debian
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# 安装Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
# 重新登录以应用用户组更改
```
### 2. 获取项目代码
```bash
# 从GitHub克隆 (如果已导出到GitHub)
git clone https://github.com/your-org/massive-point-cloud.git
cd massive-point-cloud
# 或者直接上传项目文件到服务器
```
### 3. 配置环境变量
```bash
# 创建环境变量文件
cat > .env << 'EOF'
# 安全配置 - 请修改为随机值
JWT_SECRET=$(openssl rand -base64 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -base64 16)
# MinIO配置
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=$(openssl rand -base64 16)
# 应用配置
VITE_APP_TITLE=宏量点云处理系统
EOF
# 或者手动编辑
nano .env
```
### 4. 启动服务
```bash
# 基础部署 (应用 + MySQL + MinIO)
docker-compose up -d
# 查看服务状态
docker-compose ps
# 查看日志
docker-compose logs -f
```
### 5. 访问应用
- **Web应用**: http://your-server-ip:3000
- **MinIO控制台**: http://your-server-ip:9001
## 部署架构
```
┌─────────────────────────────────────────────────────────────┐
│ 内网服务器 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Docker Network │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ App │ │ MySQL │ │ MinIO │ │ │
│ │ │ :3000 │ │ :3306 │ │ :9000 / :9001 │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────────┬─────────┘ │ │
│ │ │ │ │ │ │
│ │ └─────────────┴──────────────────┘ │ │
│ │ │ │ │
│ └──────────────────────┼──────────────────────────────┘ │
│ │ │
│ ┌──────────────────────┴──────────────────────────────┐ │
│ │ Nginx (可选, 生产环境) │ │
│ │ :80 / :443 │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## 服务说明
| 服务 | 端口 | 说明 |
|------|------|------|
| app | 3000 | 主应用服务 |
| mysql | 3306 | MySQL数据库 |
| minio | 9000 | MinIO API |
| minio | 9001 | MinIO控制台 |
| nginx | 80/443 | 反向代理 (可选) |
| redis | 6379 | 缓存服务 (可选) |
## 高级配置
### 启用Nginx反向代理
```bash
# 使用production profile启动
docker-compose --profile production up -d
```
### 启用Redis缓存
```bash
# 使用with-cache profile启动
docker-compose --profile with-cache up -d
```
### 配置HTTPS
1. 将SSL证书放置到 `docker/nginx/ssl/` 目录:
- `cert.pem` - 证书文件
- `key.pem` - 私钥文件
2. 编辑 `docker/nginx/nginx.conf`,取消HTTPS服务器配置的注释
3. 重启Nginx:
```bash
docker-compose restart nginx
```
### 数据持久化
所有数据存储在Docker卷中:
| 卷名 | 用途 |
|------|------|
| pointcloud-mysql-data | MySQL数据 |
| pointcloud-minio-data | 点云文件存储 |
| pointcloud-redis-data | Redis缓存 |
| pointcloud-app-logs | 应用日志 |
备份数据:
```bash
# 备份MySQL
docker exec massive-point-cloud-mysql mysqldump -u root -p pointcloud_db > backup.sql
# 备份MinIO数据
docker run --rm -v pointcloud-minio-data:/data -v $(pwd):/backup alpine tar cvf /backup/minio-backup.tar /data
```
## 运维命令
```bash
# 查看所有服务状态
docker-compose ps
# 查看实时日志
docker-compose logs -f app
# 重启单个服务
docker-compose restart app
# 停止所有服务
docker-compose down
# 停止并删除数据卷 (危险!)
docker-compose down -v
# 更新应用
git pull
docker-compose build app
docker-compose up -d app
# 进入容器调试
docker exec -it massive-point-cloud-app sh
docker exec -it massive-point-cloud-mysql mysql -u root -p
```
## 故障排查
### 应用无法启动
```bash
# 检查日志
docker-compose logs app
# 检查数据库连接
docker exec massive-point-cloud-app curl -f http://mysql:3306 || echo "MySQL not ready"
```
### 数据库连接失败
```bash
# 检查MySQL状态
docker-compose logs mysql
# 手动测试连接
docker exec -it massive-point-cloud-mysql mysql -u pointcloud -p -e "SELECT 1"
```
### MinIO无法访问
```bash
# 检查MinIO状态
docker-compose logs minio
# 检查存储桶
docker exec massive-point-cloud-minio-init mc ls myminio/
```
## 性能优化
### MySQL优化
编辑 `docker/mysql/my.cnf`:
```ini
[mysqld]
innodb_buffer_pool_size = 2G
innodb_log_file_size = 512M
max_connections = 200
```
### 增加文件上传限制
修改 `docker/nginx/nginx.conf`:
```nginx
client_max_body_size 5G;
```
## 安全建议
1. **修改默认密码**: 务必修改 `.env` 中的所有默认密码
2. **限制端口暴露**: 生产环境只暴露80/443端口
3. **启用防火墙**: 配置iptables或firewalld
4. **定期备份**: 设置自动备份任务
5. **更新镜像**: 定期更新Docker镜像修复安全漏洞
## 联系支持
如遇到部署问题,请检查:
1. Docker和Docker Compose版本是否满足要求
2. 服务器资源是否充足
3. 端口是否被占用
4. 防火墙规则是否正确

133
Dockerfile Normal file
View File

@@ -0,0 +1,133 @@
# 宏量点云处理软件 - 生产环境Dockerfile
# 多阶段构建,优化镜像大小
# ============================================
# 阶段1: 依赖安装
# ============================================
FROM node:22-alpine AS deps
WORKDIR /app
# 安装pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# 复制依赖文件
COPY package.json pnpm-lock.yaml* ./
COPY patches ./patches
# 安装依赖
RUN pnpm install --frozen-lockfile --prod=false
# ============================================
# 阶段2: 构建应用
# ============================================
FROM node:22-alpine AS builder
WORKDIR /app
# 安装pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# 复制依赖
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 设置构建时环境变量
ARG VITE_APP_TITLE="宏量点云处理系统"
ARG VITE_OAUTH_PORTAL_URL="disabled"
ARG VITE_APP_ID=""
ENV VITE_APP_TITLE=$VITE_APP_TITLE
ENV VITE_OAUTH_PORTAL_URL=$VITE_OAUTH_PORTAL_URL
ENV VITE_APP_ID=$VITE_APP_ID
# 构建应用
RUN pnpm build
# ============================================
# 阶段3: 生产运行环境(bookworm:与 dev 一致,便于 cadquery manylinux 轮子)
# ============================================
FROM node:22-bookworm-slim AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
dumb-init \
python3 \
python3-pip \
ca-certificates \
libgl1 \
libglib2.0-0 \
libxrender1 \
libxext6 \
libsm6 \
libice6 \
libx11-6 \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
# cadquery-ocp 轮子较大,须加大 pip 超时;覆盖:--build-arg PIP_DEFAULT_TIMEOUT=600
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
ARG PIP_DEFAULT_TIMEOUT=300
COPY requirements-geometry.txt /tmp/requirements-geometry.txt
RUN python3 -m pip install --no-cache-dir --break-system-packages \
--default-timeout="${PIP_DEFAULT_TIMEOUT}" \
--retries 10 \
-i "${PIP_INDEX_URL}" \
-r /tmp/requirements-geometry.txt \
&& rm /tmp/requirements-geometry.txt \
&& python3 -c "import cadquery; print('cadquery OK')"
# 安装 Miniforge(conda-forge 原生,无 Anaconda ToS 限制)用于 pythonocc-core
ARG CONDA_INSTALLER_URL=https://mirrors.tuna.tsinghua.edu.cn/github-release/conda-forge/miniforge/LatestRelease/Miniforge3-Linux-x86_64.sh
RUN curl -fsSL "${CONDA_INSTALLER_URL}" -o /tmp/miniforge.sh \
&& bash /tmp/miniforge.sh -b -p /opt/conda \
&& rm /tmp/miniforge.sh \
&& PY_VER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") \
&& /opt/conda/bin/conda config --set channel_priority strict \
&& /opt/conda/bin/conda create -y -n occ python="${PY_VER}" pythonocc-core=7.9.3 -c conda-forge \
&& /opt/conda/bin/conda clean -afy
RUN PY_VER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") \
&& CONDA_SP="/opt/conda/envs/occ/lib/python${PY_VER}/site-packages" \
&& SYS_SP=$(python3 -c "import site; print(site.getsitepackages()[0])") \
&& ln -sf "${CONDA_SP}/OCC" "${SYS_SP}/OCC"
ENV PYTHON3_PATH=/usr/bin/python3
# 创建非 root 用户(bookworm 用 groupadd/useradd)
RUN groupadd --gid 1001 nodejs && \
useradd --uid 1001 --gid nodejs --system --shell /usr/sbin/nologin appuser
# 安装pnpm (用于运行时)
RUN corepack enable && corepack prepare pnpm@latest --activate
# 复制构建产物
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/drizzle ./drizzle
# STEP 装配检测:子进程脚本(runStepParseMvp 从 dist 相对定位 ../scripts)
COPY --from=builder /app/scripts ./scripts
# 通用算法插件(Route B):manifests / Python 适配器 / mock fixtures。
# registry.ts 通过 PLUGIN_DIR 环境变量定位;默认从 dist/plugins/ 加载。
COPY --from=builder /app/server/plugins ./dist/plugins
ENV PLUGIN_DIR=/app/dist/plugins
# 创建日志目录
RUN mkdir -p /app/logs && chown -R appuser:nodejs /app
# 切换到非root用户
USER appuser
# 暴露端口
EXPOSE 3000
# 健康检查 (使用环境变量中的端口,默认3000)
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:${PORT:-3000}/api/health || exit 1
# 使用dumb-init作为PID 1进程
ENTRYPOINT ["dumb-init", "--"]
# 启动应用
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,61 @@
# coupler-panel-validation
车钩面板 CAD-STP 到点云测量验证系统。
从 CAD STP 文件出发,构造 GT、合成模拟点云、跑完整测量流水线,
对比 GT,输出实验报告与可视化。
## 一句话用法
```python
from coupler_panel import compute_coupler_measurement, load_point_cloud
points = load_point_cloud("scan.ply") # (N, 3) ndarray, 单位 mm
result = compute_coupler_measurement(points)
print(result.theta_right_deg, result.theta_ref_deg)
print(result.dx_y_max_mm, result.dx_z_max_mm)
```
完整文档见 [docs/USAGE.md](docs/USAGE.md)。
## 安装
```bash
# 仅核心计算(numpy/scipy)
pip install -e .
# 加上点云 IO + 去噪 + 配准
pip install -e ".[cloud]"
# 加上 STP 解析
pip install -e ".[cad]"
# 加上可视化
pip install -e ".[viz]"
# 全装
pip install -e ".[all]"
```
## 命令行
```bash
# 1) 已有点云,直接测量
coupler-measure --input-cloud scan.ply --out-dir runs/demo
# 2) 从 CAD STP 生成模拟点云并测量
coupler-measure --stp panel.stp --out-dir runs/from_cad
# 3) 跑参数 sweep(采样×密度×噪声×变换矩阵)
coupler-measure --sweep configs/experiments/full_sweep.yaml --out-dir runs/sweep
```
## 坐标系约定
- x = 车体纵向
- y = 车体宽度
- z = 车体高度
- 单位 mm
- 拟合面板法向 `n = (n_x, n_y, n_z)` 强制 `n_x > 0`
详见 USAGE.md 第 5 节。

View File

@@ -0,0 +1,376 @@
# coupler_panel — 集成开发者使用指南
> 高铁车钩面板偏转角与最大偏差测量算法
>
> 主输入:**CAD STP + 点云 PLY + ROI JSON**
>
> 主输出:两个方向偏转角 `θ_right` / `θ_ref`、最大 x 向偏差 `dx_y_max` / `dx_z_max`,以及一份完整可序列化的 `report.json`。
---
## 1. 一句话集成
```python
from coupler_panel.pipeline import run_coupler_measurement_pipeline
result = run_coupler_measurement_pipeline(
input_cloud = "scan.ply", # 你的测量点云(已在 CAD 帧)
cad_stp_path = "panel.stp", # 同款面板的 CAD STP
roi_json = "roi.json", # ROI 描述(不裁剪就传 mode=all)
out_dir = "runs/today", # 中间产物 + report.json 落盘目录
)
print(result.theta_right_deg, result.theta_ref_deg) # 两个偏转角
print(result.dx_y_max_mm, result.dx_z_max_mm) # 最大 x 偏差
```
返回 `CouplerMeasurement`,全字段语义见 §4。`out_dir` 下额外得到 8 个文件(含 `report.json`,便于排查 / 后处理)。
---
## 2. 安装
```bash
# 全量安装(推荐)— core + 点云 IO + STP 解析 + 可视化 + Excel
python -m pip install -r requirements.txt
pip install -e ".[all]"
# 也可以按需取子集(见 pyproject.toml 的 extras):
pip install -e ".[cloud]" # 只要点云 + 去噪
pip install -e ".[cad]" # 需要解析 STP
pip install -e ".[report]" # 需要 Excel 报表
```
`requirements.txt` 锁定了端到端跑通过的版本(Python 3.10 验证)。
---
## 3. 三件输入的准备
### 3.1 CAD STP
- 提供面板的 STEP 文件路径。
- 算法用它做两件事:
1. 从拓扑直接提 4 个定位孔 + 中心大孔 + 面板法向(写入 `01_gt.json`,作审计用)
2. 把 CAD 法向作为 `panel_normal_seed` 喂给 hole-detection 的投影几何,加速并稳定 2D 网格化
- **可以不传**(`cad_stp_path=None`)—— 此时 hole detection 走 PCA 自行估法向;适用于你已经预先把法向算好/扫描坐标系本来就规范的场景。建议生产里**始终传**,便于审计。
### 3.2 点云 PLY
- 支持 `.ply` / `.pcd` / `.xyz` / `.txt` / `.csv`(PCD 与二进制 PLY 需要 open3d)。
- 数组形状 `(N, 3)`,单位 **mm**。
- **必须在 CAD 坐标系**:
- x = 车体纵向,y = 车体宽度,z = 车体高度
- 算法默认面板法向 `n_x > 0`
- **不再做配准**(参见 §6 "为什么不配准")。这意味着上游扫描端外参必须已经标定 —— 把点云对齐到 CAD 帧是扫描软件的职责,不是本算法的职责。
- 可以带颜色 / 法向等额外列;算法只读前 3 列。
### 3.3 ROI JSON
定义"PLY 里哪些点要纳入后续算法",**轴对齐 bounding box(AABB)**格式:
```json
{
"x_min": -12500.0,
"x_max": 12500.0,
"y_min": 150.0,
"y_max": 4100.0,
"z_min": -1680.0,
"z_max": 200.0
}
```
六个 key 全部必填,单位 mm,坐标系 CAD 帧。点 `(x, y, z)` 被保留的条件是:
```
x_min <= x <= x_max ∧ y_min <= y <= y_max ∧ z_min <= z <= z_max
```
- **想保留全部点云**:把 6 个边界写得比点云自身 bbox 还宽即可(不需要任何 mode 切换)。
- **想不写文件**:传 `roi_json=None`,算法走"±∞" 默认(pass-through)。
- **想程序内构造**:传 `roi_spec=ROISpec(x_min=..., ...)`。
---
## 4. 公共 API 详解
### 4.1 主入口 `run_coupler_measurement_pipeline`
```python
def run_coupler_measurement_pipeline(
input_cloud : str | Path, # 必填:点云路径
out_dir : str | Path, # 必填:输出目录(自动创建)
*,
config_path : str | Path | None = None, # 可选:自定义 YAML
profile : str = "auto", # 可选:preset profile
cad_stp_path: str | Path | None = None, # 推荐:CAD STP 路径
roi_spec : ROISpec | None = None, # 二选一:内存 ROI 对象
roi_json : str | Path | None = None, # 二选一:ROI JSON 路径
progress : Callable[[str, float], None] | None = None,
) -> CouplerMeasurement
```
**算法链路(固定不可绕过)**:
```
load_point_cloud → apply_roi → [optional CAD parse → panel_normal_seed]
→ denoise_cloud → compute_coupler_measurement
├─ validate
├─ detect 4 holes
├─ build reference frame
├─ extract panel ROI
├─ fit panel plane (RANSAC + SVD)
└─ compute metrics
```
**Profile**:传 `"auto"` 即可;若你的面板型号对应到一个特定 profile,可指定 `profile="lu1_af4"` 等(详见 `configs/profiles/`)。
**配置覆盖**:
```python
result = run_coupler_measurement_pipeline(
input_cloud="scan.ply",
out_dir="runs/x",
config_path={
# 任何在 configs/default.yaml 里的字段都可以这样覆盖
"denoise": {"steps": [{"type": "voxel", "voxel_mm": 0.3}]},
"plane_fit": {"ransac_inlier_thresh_mm": 1.0},
"metrics": {"right_axis_sign": -1},
},
)
```
### 4.2 返回值 `CouplerMeasurement`
```python
@dataclass
class CouplerMeasurement:
# 版本号
schema_version: str # 字段语义稳定性 → SCHEMA_VERSION
algorithm_version: str # 数值结果稳定性 → ALGORITHM_VERSION
# === 6 个主测量量 ===
theta_right_deg: float # 右偏转角 atan2(n_y, n_x),applied right_axis_sign
theta_ref_deg: float # 上偏转角 atan2(n_z, n_x)
D_y_max_mm: float # max |y_i - C_y|
D_z_max_mm: float # max |z_i - C_z|
dx_y_max_mm: float # |A| · D_y_max, A = -n_y/n_x
dx_z_max_mm: float # |B| · D_z_max, B = -n_z/n_x
# === 几何 ===
center_C: tuple[float, float, float] # 面板中心 (4 孔均值)
normal: tuple[float, float, float] # 拟合面板法向,n_x > 0
# === 详细子结构 ===
holes: HoleSet # 检测到的 4 个定位孔 + 可选中心孔
plane: PanelPlane # normal + offset + inlier_ratio + RMS
frame: ReferenceFrame # C + 参考面 z=C_z + 纵向中面 y=C_y
# === 诊断 ===
quality_flags: list[str] # 例如 'plane_inlier_ratio_low'
timings: list[StageTiming] # 每阶段秒数 + 峰值内存
report: dict # 全量可序列化,与 report.json 一致
```
```python
result.to_dict() # 完全 JSON 化的 dict(落 DB / 网络传输用)
```
### 4.3 异常
```
CouplerError # 所有可预见异常的基类
├── InvalidPointCloudError # 输入形状/dtype/NaN 非法
│ └── UnsupportedFormatError # 文件后缀不支持
├── InsufficientPointsError # 点数 < 200 或 ROI 后 < 100
├── CADParseError # STP 解析失败 / 找不到 4 孔
├── HoleDetectionError # 点云上 4 孔检测失败
└── PlaneFitError # RANSAC 找不到平面
```
非致命问题通过 `result.quality_flags` 暴露:
| Flag | 含义 |
|---|---|
| `plane_inlier_ratio_low` | 平面拟合内点率低于 `plane_fit.min_inlier_ratio` |
### 4.4 进度回调
```python
def on_progress(stage: str, fraction: float) -> None:
print(f"[{fraction:5.1%}] {stage}")
result = run_coupler_measurement_pipeline(..., progress=on_progress)
```
阶段顺序:`validate → detect_holes → build_frame → extract_roi → fit_plane → metrics`。
---
## 5. ROI JSON schema 详细
**完整字段**(6 个 key,全部必填,单位 mm,坐标系 CAD 帧):
```json
{
"x_min": -12500.0,
"x_max": 12500.0,
"y_min": 150.0,
"y_max": 4100.0,
"z_min": -1680.0,
"z_max": 200.0
}
```
**规则**:
- 必须满足 `x_min < x_max` 且 `y_min < y_max` 且 `z_min < z_max`,否则 `load_roi_json` 抛 `ValueError`
- 不在 [min, max] 闭区间内的点被丢弃(用 `>=` 与 `<=`,包含边界)
- 多余的 key(例如 `comment`、`source`)会被忽略,方便挂元数据
**手动构造 ROI(不读文件)**:
```python
from coupler_panel.roi_select import ROISpec, default_roi_spec
# 全保留默认(六个轴都是 ±∞)
spec = default_roi_spec()
# 自定义 bbox
spec = ROISpec(
x_min=-12000, x_max= 100,
y_min= -300, y_max= 1200,
z_min= -1000, z_max= 800,
)
result = run_coupler_measurement_pipeline(..., roi_spec=spec)
```
**诊断输出**:
每次运行后 `out_dir/roi.json` 写出实际生效的 spec + 应用诊断:
```jsonc
{
"spec": { "x_min": -12000.0, ... },
"diagnostics": {
"kind": "aabb",
"n_in": 96662,
"n_out": 96662,
"kept_ratio": 1.0,
"bounds": { "x_min": -12000.0, ... }
}
}
```
---
## 6. 为什么主路径**不做配准**
车钩面板上的 4 个定位孔与面板一起刚体旋转。如果用"扫到的 4 孔 → CAD 原始 4 孔"做 Kabsch 配准,数学上会**精确解出**"扫描器位姿误差 · 面板偏转"的复合刚体变换并反向作用 —— 把面板偏转随同位姿误差**一起消掉**,下游平面拟合再读不到偏转信号。
这是几何性质,不是实现 bug。
工程上,主算法假设扫描端外参标定让点云已经在 CAD 帧。如果你的设备做不到位姿标定,要么:
1. 在面板**外**装基准(不动)做 Kabsch
2. 用 ICP 或其他不依赖 4 孔的方法
研究模式(`coupler_panel.pipeline.run_from_cad_pipeline`)保留了 4 孔 Kabsch + 注入偏转 oracle 的能力,用于上限对照实验;生产集成请用 `run_coupler_measurement_pipeline`。
---
## 7. 落盘产物(`out_dir/`)
| 文件 | 内容 |
|---|---|
| `config.resolved.yaml` | 解析合并后实际生效的完整配置 |
| `roi.json` | ROI spec + 应用诊断(n_in / n_out / kept_ratio) |
| `01_gt.json` | (传了 cad_stp_path 时)从 STP 提的审计用 GT |
| `02_sampled.ply` | ROI 裁剪后的点云 |
| `05_denoised.ply` | 去噪后点云(喂给测量算法的最终点云) |
| `10_metrics.json` | 等价于 `report.json` 的内部 report 部分 |
| `report.json` | **完整结果**,等于 `result.to_dict()`;schema 见 §4.2 |
| `images/` | 占位空目录(仅 `viz` 模块写图时会用) |
---
## 8. 集成模板
```python
import logging
from pathlib import Path
from coupler_panel.pipeline import run_coupler_measurement_pipeline
from coupler_panel.exceptions import CouplerError
log = logging.getLogger(__name__)
def measure_coupler(
cloud_path: Path,
cad_stp: Path,
roi_json: Path,
out_dir: Path,
) -> dict | None:
"""主集成入口。失败时返回 None;成功时返回 6 个测量量 + flags。"""
try:
result = run_coupler_measurement_pipeline(
input_cloud = cloud_path,
cad_stp_path = cad_stp,
roi_json = roi_json,
out_dir = out_dir,
progress = lambda s, f: log.debug("%s %.0f%%", s, f * 100),
)
except CouplerError as exc:
log.error("coupler measurement failed: %s", exc)
return None
if result.quality_flags:
log.warning("quality flags: %s", result.quality_flags)
return {
"schema_version": result.schema_version,
"algorithm_version": result.algorithm_version,
"theta_right_deg": result.theta_right_deg,
"theta_ref_deg": result.theta_ref_deg,
"dx_y_max_mm": result.dx_y_max_mm,
"dx_z_max_mm": result.dx_z_max_mm,
"center_C_mm": list(result.center_C),
"normal": list(result.normal),
"flags": result.quality_flags,
}
```
---
## 9. 命令行(备用,非主集成路径)
```bash
# 等价于 run_coupler_measurement_pipeline(input_cloud=..., out_dir=...)
coupler-measure --input-cloud scan.ply --out-dir runs/today
```
> 当前 CLI 暂未把 `--cad` 与 `--roi` 暴露出来(设计上 CLI 主要给研究流程用);集成方请直接用 Python API。
---
## 10. 版本兼容性
| 标识 | 在哪 | 含义 |
|---|---|---|
| `coupler_panel.__version__` | `coupler_panel._version` | 包版本,遵循语义化版本 |
| `coupler_panel.ALGORITHM_VERSION` | 同上 | 算法版本;升级算法时递增,**数值结果可能变化** |
| `coupler_panel.SCHEMA_VERSION` | 同上 | `CouplerMeasurement` / `report.json` 字段结构;只在破坏性变更时递增;**新增可选字段不递增** |
集成建议:
1. 落库时把 `algorithm_version` 写进每条记录,便于历史复现
2. 反序列化 `report.json` 时校验 `schema_version` 主版本号
---
## 11. 坐标 & 单位速查
| 量 | 单位 | 约定 |
|---|---|---|
| 点云坐标 | mm | x=纵向、y=宽度、z=高度 |
| 角度 | 度 (deg) | `θ_right` = atan2(n_y, n_x)、`θ_ref` = atan2(n_z, n_x) |
| 法向 | 单位向量 | 强制 `n_x > 0` |
| `right_axis_sign` | ±1 | 如果车体"右"是 +y 留 1;如果是 -y 则配置 -1 |

View File

@@ -0,0 +1,147 @@
# Default configuration for coupler panel measurement pipeline.
# All numeric units in mm / degree unless otherwise stated.
# Override fields by passing a dict or a YAML path with the same nested shape.
schema_version: "1.0"
# --- CAD parsing & GT extraction --------------------------------------------
cad:
# OpenCASCADE tessellation quality.
linear_deflection_mm: 0.5
angular_deflection_rad: 0.1
# Hole topology classification.
# The four locating holes are expected to be small and equal-radius;
# the central pass-through hole is expected to be much larger.
locating_hole_radius_mm: [4.0, 12.0] # [min, max] of expected locating hole radius
central_hole_radius_min_mm: 20.0 # any circle larger than this is treated as the centre hole
rectangle_aspect_tol: 0.15 # tolerance for "the 4 hole centres form a rectangle"
# --- Sampling ---------------------------------------------------------------
sampling:
method: poisson_disk # uniform_area | poisson_disk | adaptive | grid_spacing
# Use either spacing_mm OR num_points; if both set, spacing_mm wins.
spacing_mm: 1.0
num_points: 100000
seed: 42
adaptive:
curvature_weight: 1.0
min_spacing_factor: 0.25 # smallest spacing = spacing_mm * factor
max_spacing_factor: 4.0
# --- Simulated scan (optional) ----------------------------------------------
scan_simulator:
enabled: false
view_origin: [10000.0, 0.0, 0.0] # camera position in CAD frame, mm
hpr_radius_mm: 50000.0 # hidden-point-removal radius
# --- Panel deflection injection (the SIGNAL the algorithm should measure) --
# This rotates the panel (and its 4 locating holes) by a known small angle.
# After running the pipeline, theta_right_deg / theta_ref_deg in the result
# should match these values within the algorithm's recovery accuracy.
panel_deflection:
enabled: false
theta_right_deg: 0.0 # atan2(n_y, n_x) target (rotation around z)
theta_ref_deg: 0.0 # atan2(n_z, n_x) target (rotation around -y)
# --- Synthetic noise --------------------------------------------------------
# Realistic LIDAR / structured-light noise on a ~2 m × 2 m panel is ≈ 50-80
# µm = 0.05-0.08 mm. Larger sigma is mostly to test robustness, not to
# match a real sensor.
noise:
enabled: true
sigma_mm: 0.05
mode: isotropic # isotropic | along_normal
seed: 43
# --- Synthetic scan-pose perturbation ---------------------------------------
# Mounting / scanner-stage jitter. 4-hole Kabsch should cancel this; if it
# leaks into the final angles that's a registration error, not a measurement
# of panel deflection. Set rotation_deg=0, translation_mm=0 for "ideal pose"
# experiments that isolate the deflection-recovery accuracy.
transform:
enabled: true
rotation_deg: 0.5 # magnitude; axis is randomised
rotation_axis: random # random | x | y | z | [ax, ay, az]
translation_mm: 2.0 # magnitude; direction is randomised
translation_dir: random
seed: 44
# --- Denoise pipeline -------------------------------------------------------
# Steps are executed in the listed order. Set steps to [] to disable.
denoise:
steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5
- type: statistical
nb_neighbors: 30
std_ratio: 2.0
- type: radius
nb_points: 8
radius_mm: 2.0
# Optional MLS smoothing (requires open3d ≥ 0.18 with ml extras).
smoothing:
enabled: false
search_radius_mm: 2.0
polynomial_order: 2
# --- Registration -----------------------------------------------------------
registration:
method: kabsch_four_holes # only this method is implemented; documented & enforced
# The four GT hole centres come from CAD; the four scan hole centres come
# from the detection module. Pairing is by greedy nearest-neighbour after
# a coarse PCA-based pre-alignment of the centroid.
pre_align: pca_centroid
refuse_if_residual_above_mm: 5.0 # quality flag if RMS pairing residual exceeds this
# --- 4-hole detection in point cloud ----------------------------------------
detection:
# Local panel-plane projection.
panel_normal_seed: from_cad # from_cad | pca | given
panel_normal_pca_kth: 1 # use k-th smallest PCA eigenvector as panel normal
# 2D occupancy grid.
grid_pixel_mm: 0.3
panel_min_area_ratio: 0.4 # largest connected component must be ≥ this fraction of bbox
hole_min_area_mm2: 10.0
hole_max_area_mm2: 1500.0
closing_iters: 2 # 原来没有,加形态学闭运算填补边界断裂
fill_bg_below_mm2: 5.0 # 原来没有,填充小碎片
# Circle fitting on hole boundary.
circle_fit:
method: algebraic_then_lsq # algebraic_then_lsq | hyper | taubin
inlier_residual_mm: 0.5
# Selection.
select_top_k_by_radius_match: 4
rectangle_aspect_tol: 0.20
# --- Panel ROI extraction ---------------------------------------------------
roi:
hole_dilate_mm: 3.0 # exclude points within (hole_radius + dilate)
central_hole_dilate_mm: 5.0
edge_buffer_mm: 5.0 # shrink ROI rectangle inward by this
chamfer_normal_dot_min: 0.85 # exclude points whose normal deviates more than acos(0.85) from panel normal
outlier_z_score: 3.0
# --- Plane fit --------------------------------------------------------------
plane_fit:
method: ransac_svd
ransac_iterations: 200
ransac_inlier_thresh_mm: auto # auto = 3*sigma_mm
min_inlier_ratio: 0.6
enforce_nx_positive: true
# --- Metrics ----------------------------------------------------------------
metrics:
# If your right axis is +y, leave this 1. If right is -y, set to -1.
right_axis_sign: 1
# Angles smaller than this are clamped to 0 (purely cosmetic; raw value is
# always in the report).
angle_clamp_deg: 0.0
# --- Output / artifacts -----------------------------------------------------
artifacts:
save_intermediate: true
ply_format: binary # binary | ascii
image_dpi: 150
heatmap_colormap: viridis

View File

@@ -0,0 +1,13 @@
# Parameter sweep over point cloud density.
matrix:
sampling.spacing_mm:
- 0.25
- 0.5
- 1.0
- 2.0
- 5.0
fixed:
sampling.method: poisson_disk
noise.sigma_mm: 0.5
transform.rotation_deg: 3.0
transform.translation_mm: 5.0

View File

@@ -0,0 +1,34 @@
# Full cartesian product. WARNING: 4 * 5 * 8 * 4 * 4 = 2560 runs.
# Use only when you have time and disk.
matrix:
sampling.method:
- uniform_area
- poisson_disk
- adaptive
- grid_spacing
sampling.spacing_mm:
- 0.25
- 0.5
- 1.0
- 2.0
- 5.0
noise.sigma_mm:
- 0.0
- 0.25
- 0.5
- 1.0
- 2.0
- 4.0
- 10.0
- 20.0
transform.rotation_deg:
- 1.0
- 3.0
- 5.0
- 10.0
transform.translation_mm:
- 0.5
- 1.0
- 5.0
- 10.0
fixed: {}

View File

@@ -0,0 +1,47 @@
# Sweep matrix for the lu1-AF4 coupler panel STP. Total 9 runs.
stp_path: D:/myDocuments/PKU/high_speed_train_proj/lu1-M000004835653_AF4_ASMP072708187_N_AF4.stp
matrix:
noise.sigma_mm: [0.0, 0.5, 2.0]
transform.rotation_deg: [1.0, 3.0, 10.0]
fixed:
sampling.method: poisson_disk
sampling.spacing_mm: null
sampling.num_points: 500000
sampling.seed: 42
scan_simulator.enabled: true
scan_simulator.view_origin: [10000.0, 0.0, 0.0]
scan_simulator.hpr_radius_mm: 200000.0
noise.seed: 43
transform.translation_mm: 5.0
transform.seed: 44
cad.locating_hole_radius_mm: [14.0, 18.0]
cad.central_hole_radius_min_mm: 80.0
detection.grid_pixel_mm: 2.0
detection.fill_bg_below_mm2: 100.0
detection.hole_min_area_mm2: 600.0
detection.hole_max_area_mm2: 5000.0
detection.locating_hole_radius_mm: [14.0, 22.0]
detection.panel_min_area_ratio: 0.15
detection.rectangle_aspect_tol: 0.40
detection.circle_fit.method: algebraic_then_lsq
detection.circle_fit.inlier_residual_mm: 1.5
roi.hole_dilate_mm: 5.0
roi.central_hole_dilate_mm: 10.0
roi.edge_buffer_mm: 8.0
roi.chamfer_normal_dot_min: 0.80
roi.outlier_z_score: 3.0
plane_fit.ransac_iterations: 300
plane_fit.ransac_inlier_thresh_mm: auto
plane_fit.min_inlier_ratio: 0.5
plane_fit.enforce_nx_positive: true
metrics.right_axis_sign: 1
denoise.steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 1.0
- type: statistical
nb_neighbors: 20
std_ratio: 2.5

View File

@@ -0,0 +1,74 @@
# Deflection-recovery sweep: vary the INJECTED panel-deflection angle and
# verify the algorithm reads it back. Single-axis at a time so the
# response of theta_right and theta_ref can be analysed independently.
stp_path: D:/myDocuments/PKU/high_speed_train_proj/lu1-M000004835653_AF4_ASMP072708187_N_AF4.stp
matrix:
panel_deflection.theta_right_deg: [0.0, 0.1, 0.3, 0.5, 1.0, 2.0]
fixed:
panel_deflection.enabled: true
panel_deflection.theta_ref_deg: 0.0
sampling.method: poisson_disk
sampling.spacing_mm: null
sampling.num_points: 500000
sampling.seed: 42
scan_simulator.enabled: true
scan_simulator.view_origin: [10000.0, 0.0, 0.0]
scan_simulator.hpr_radius_mm: 200000.0
noise.enabled: true
noise.sigma_mm: 0.05
noise.mode: isotropic
noise.seed: 43
transform.enabled: true
transform.rotation_deg: 0.5
transform.translation_mm: 2.0
transform.rotation_axis: random
transform.translation_dir: random
transform.seed: 44
cad.locating_hole_radius_mm: [14.0, 18.0]
cad.central_hole_radius_min_mm: 80.0
cad.linear_deflection_mm: 0.5
cad.angular_deflection_rad: 0.1
detection.panel_normal_seed: pca
detection.grid_pixel_mm: 2.0
detection.fill_bg_below_mm2: 100.0
detection.hole_min_area_mm2: 600.0
detection.hole_max_area_mm2: 5000.0
detection.locating_hole_radius_mm: [14.0, 22.0]
detection.panel_min_area_ratio: 0.15
detection.rectangle_aspect_tol: 0.40
detection.circle_fit.method: algebraic_then_lsq
detection.circle_fit.inlier_residual_mm: 1.5
roi.hole_dilate_mm: 5.0
roi.central_hole_dilate_mm: 10.0
roi.edge_buffer_mm: 8.0
roi.chamfer_normal_dot_min: 0.80
roi.outlier_z_score: 3.0
plane_fit.ransac_iterations: 300
plane_fit.ransac_inlier_thresh_mm: auto
plane_fit.min_inlier_ratio: 0.5
plane_fit.enforce_nx_positive: true
metrics.right_axis_sign: 1
metrics.angle_clamp_deg: 0.0
registration.method: kabsch_four_holes
registration.refuse_if_residual_above_mm: 5.0
denoise.steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5
- type: statistical
nb_neighbors: 20
std_ratio: 2.5

View File

@@ -0,0 +1,73 @@
# Point-cloud density sweep: fix deflection + noise, vary num_points to
# show how many points the algorithm needs.
stp_path: D:/myDocuments/PKU/high_speed_train_proj/lu1-M000004835653_AF4_ASMP072708187_N_AF4.stp
matrix:
sampling.num_points: [200000, 350000, 500000, 750000, 1000000]
fixed:
panel_deflection.enabled: true
panel_deflection.theta_right_deg: 0.5
panel_deflection.theta_ref_deg: 0.3
sampling.method: poisson_disk
sampling.spacing_mm: null
sampling.seed: 42
scan_simulator.enabled: true
scan_simulator.view_origin: [10000.0, 0.0, 0.0]
scan_simulator.hpr_radius_mm: 200000.0
noise.enabled: true
noise.sigma_mm: 0.05
noise.mode: isotropic
noise.seed: 43
transform.enabled: true
transform.rotation_deg: 0.5
transform.translation_mm: 2.0
transform.rotation_axis: random
transform.translation_dir: random
transform.seed: 44
cad.locating_hole_radius_mm: [14.0, 18.0]
cad.central_hole_radius_min_mm: 80.0
cad.linear_deflection_mm: 0.5
cad.angular_deflection_rad: 0.1
detection.panel_normal_seed: pca
detection.grid_pixel_mm: 2.0
detection.fill_bg_below_mm2: 100.0
detection.hole_min_area_mm2: 600.0
detection.hole_max_area_mm2: 5000.0
detection.locating_hole_radius_mm: [14.0, 28.0] # wider window for low-density tests
detection.panel_min_area_ratio: 0.15
detection.rectangle_aspect_tol: 0.40
detection.circle_fit.method: algebraic_then_lsq
detection.circle_fit.inlier_residual_mm: 1.5
roi.hole_dilate_mm: 5.0
roi.central_hole_dilate_mm: 10.0
roi.edge_buffer_mm: 8.0
roi.chamfer_normal_dot_min: 0.80
roi.outlier_z_score: 3.0
plane_fit.ransac_iterations: 300
plane_fit.ransac_inlier_thresh_mm: auto
plane_fit.min_inlier_ratio: 0.5
plane_fit.enforce_nx_positive: true
metrics.right_axis_sign: 1
metrics.angle_clamp_deg: 0.0
registration.method: kabsch_four_holes
registration.refuse_if_residual_above_mm: 5.0
denoise.steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5
- type: statistical
nb_neighbors: 20
std_ratio: 2.5

View File

@@ -0,0 +1,73 @@
# Noise sweep: fix the panel deflection at (theta_right=0.5°, theta_ref=0.3°)
# and vary Gaussian noise sigma to characterise robustness.
stp_path: D:/myDocuments/PKU/high_speed_train_proj/lu1-M000004835653_AF4_ASMP072708187_N_AF4.stp
matrix:
noise.sigma_mm: [0.0, 0.025, 0.05, 0.1, 0.2, 0.5]
fixed:
panel_deflection.enabled: true
panel_deflection.theta_right_deg: 0.5
panel_deflection.theta_ref_deg: 0.3
sampling.method: poisson_disk
sampling.spacing_mm: null
sampling.num_points: 500000
sampling.seed: 42
scan_simulator.enabled: true
scan_simulator.view_origin: [10000.0, 0.0, 0.0]
scan_simulator.hpr_radius_mm: 200000.0
noise.enabled: true
noise.mode: isotropic
noise.seed: 43
transform.enabled: true
transform.rotation_deg: 0.5
transform.translation_mm: 2.0
transform.rotation_axis: random
transform.translation_dir: random
transform.seed: 44
cad.locating_hole_radius_mm: [14.0, 18.0]
cad.central_hole_radius_min_mm: 80.0
cad.linear_deflection_mm: 0.5
cad.angular_deflection_rad: 0.1
detection.panel_normal_seed: pca
detection.grid_pixel_mm: 2.0
detection.fill_bg_below_mm2: 100.0
detection.hole_min_area_mm2: 600.0
detection.hole_max_area_mm2: 5000.0
detection.locating_hole_radius_mm: [14.0, 22.0]
detection.panel_min_area_ratio: 0.15
detection.rectangle_aspect_tol: 0.40
detection.circle_fit.method: algebraic_then_lsq
detection.circle_fit.inlier_residual_mm: 1.5
roi.hole_dilate_mm: 5.0
roi.central_hole_dilate_mm: 10.0
roi.edge_buffer_mm: 8.0
roi.chamfer_normal_dot_min: 0.80
roi.outlier_z_score: 3.0
plane_fit.ransac_iterations: 300
plane_fit.ransac_inlier_thresh_mm: auto
plane_fit.min_inlier_ratio: 0.5
plane_fit.enforce_nx_positive: true
metrics.right_axis_sign: 1
metrics.angle_clamp_deg: 0.0
registration.method: kabsch_four_holes
registration.refuse_if_residual_above_mm: 5.0
denoise.steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5
- type: statistical
nb_neighbors: 20
std_ratio: 2.5

View File

@@ -0,0 +1,78 @@
# Sampling-strategy sweep: 4 methods × same other settings, so the saved
# intermediates per run/<method>/02_sampled.ply, 03_noisy.ply, ... can be
# compared apples-to-apples.
stp_path: D:/myDocuments/PKU/high_speed_train_proj/lu1-M000004835653_AF4_ASMP072708187_N_AF4.stp
matrix:
sampling.method:
- uniform_area
- poisson_disk
- adaptive
- grid_spacing
fixed:
panel_deflection.enabled: true
panel_deflection.theta_right_deg: 0.5
panel_deflection.theta_ref_deg: 0.3
sampling.spacing_mm: null
sampling.num_points: 500000
sampling.seed: 42
scan_simulator.enabled: true
scan_simulator.view_origin: [10000.0, 0.0, 0.0]
scan_simulator.hpr_radius_mm: 200000.0
noise.enabled: true
noise.sigma_mm: 0.05
noise.mode: isotropic
noise.seed: 43
transform.enabled: true
transform.rotation_deg: 0.5
transform.translation_mm: 2.0
transform.rotation_axis: random
transform.translation_dir: random
transform.seed: 44
cad.locating_hole_radius_mm: [14.0, 18.0]
cad.central_hole_radius_min_mm: 80.0
cad.linear_deflection_mm: 0.5
cad.angular_deflection_rad: 0.1
detection.panel_normal_seed: pca
detection.grid_pixel_mm: 2.0
detection.fill_bg_below_mm2: 100.0
detection.hole_min_area_mm2: 600.0
detection.hole_max_area_mm2: 5000.0
detection.locating_hole_radius_mm: [14.0, 22.0]
detection.panel_min_area_ratio: 0.15
detection.rectangle_aspect_tol: 0.40
detection.circle_fit.method: algebraic_then_lsq
detection.circle_fit.inlier_residual_mm: 1.5
roi.hole_dilate_mm: 5.0
roi.central_hole_dilate_mm: 10.0
roi.edge_buffer_mm: 8.0
roi.chamfer_normal_dot_min: 0.80
roi.outlier_z_score: 3.0
plane_fit.ransac_iterations: 300
plane_fit.ransac_inlier_thresh_mm: auto
plane_fit.min_inlier_ratio: 0.5
plane_fit.enforce_nx_positive: true
metrics.right_axis_sign: 1
metrics.angle_clamp_deg: 0.0
registration.method: kabsch_four_holes
registration.refuse_if_residual_above_mm: 5.0
denoise.steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5
- type: statistical
nb_neighbors: 20
std_ratio: 2.5

View File

@@ -0,0 +1,16 @@
# Parameter sweep over Gaussian noise sigma.
matrix:
noise.sigma_mm:
- 0.0
- 0.25
- 0.5
- 1.0
- 2.0
- 4.0
- 10.0
- 20.0
fixed:
sampling.method: poisson_disk
sampling.spacing_mm: 1.0
transform.rotation_deg: 3.0
transform.translation_mm: 5.0

View File

@@ -0,0 +1,12 @@
# Parameter sweep over sampling methods (other knobs fixed at default).
matrix:
sampling.method:
- uniform_area
- poisson_disk
- adaptive
- grid_spacing
fixed:
sampling.spacing_mm: 1.0
noise.sigma_mm: 0.5
transform.rotation_deg: 3.0
transform.translation_mm: 5.0

View File

@@ -0,0 +1,16 @@
# Parameter sweep over GT rigid transform magnitude.
matrix:
transform.rotation_deg:
- 1.0
- 3.0
- 5.0
- 10.0
transform.translation_mm:
- 0.5
- 1.0
- 5.0
- 10.0
fixed:
sampling.method: poisson_disk
sampling.spacing_mm: 1.0
noise.sigma_mm: 0.5

View File

@@ -0,0 +1,17 @@
# Profile: high-density scan (≈100w points, sub-mm spacing).
sampling:
spacing_mm: 0.25
num_points: 1000000
denoise:
steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.25
- type: statistical
nb_neighbors: 50
std_ratio: 2.0
detection:
grid_pixel_mm: 0.25
plane_fit:
ransac_iterations: 500

View File

@@ -0,0 +1,18 @@
# Profile: low-density scan (≈1w points, ≈2 mm spacing).
sampling:
spacing_mm: 2.0
num_points: 10000
denoise:
steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 1.0
- type: statistical
nb_neighbors: 15
std_ratio: 2.5
detection:
grid_pixel_mm: 1.0
hole_min_area_mm2: 20.0
plane_fit:
ransac_iterations: 100

View File

@@ -0,0 +1,106 @@
# Profile tuned for the lu1-M000004835653_AF4_ASMP072708187_N_AF4.stp panel.
#
# Topology summary (from cad/iter_circular_edges):
# - locating holes: r = 16 mm × 4 corners at (y=±100, z=±180)
# - central hole: r = 100 mm at (y≈1, z=0)
# - panel size: ~200 × 360 mm in y-z, ~76 mm thick along x
#
# Use:
# coupler-measure --stp <path> --out-dir <dir> --profile lu1_af4
cad:
linear_deflection_mm: 0.5
angular_deflection_rad: 0.1
# CAD-side: strict band that selects only the r=16 mm corner holes.
locating_hole_radius_mm: [14.0, 18.0]
central_hole_radius_min_mm: 80.0
rectangle_aspect_tol: 0.30
sampling:
method: poisson_disk
spacing_mm: null
num_points: 500000
seed: 42
# Critical: simulate a single-view "+x looking at -x" scan. Without this,
# back-face points project into the hole regions and detection fails.
scan_simulator:
enabled: true
view_origin: [10000.0, 0.0, 0.0]
hpr_radius_mm: 200000.0
# --- Panel deflection injection -------------------------------------------
# This is the *signal* we want the algorithm to recover. By default it is
# off — sweeps override these.
panel_deflection:
enabled: true
theta_right_deg: 0.5 # rotation around z so n_y > 0
theta_ref_deg: 0.3 # rotation around -y so n_z > 0
# --- Realistic LiDAR / structured-light noise -----------------------------
noise:
enabled: true
sigma_mm: 0.05 # ≈ 50 µm; matches typical industrial scanners
mode: isotropic
seed: 43
# --- Scanner-pose jitter --------------------------------------------------
# Modest values: 4-hole Kabsch should remove this completely.
transform:
enabled: true
rotation_deg: 0.5
translation_mm: 2.0
rotation_axis: random
translation_dir: random
seed: 44
denoise:
steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5 # finer voxel for low-noise data
- type: statistical
nb_neighbors: 20
std_ratio: 2.5
detection:
panel_normal_seed: pca # critical: use cloud's own PCA, not CAD seed
grid_pixel_mm: 2.0
closing_iters: 0
fill_bg_below_mm2: 100.0
panel_min_area_ratio: 0.15
hole_min_area_mm2: 600.0
hole_max_area_mm2: 5000.0
locating_hole_radius_mm: [14.0, 22.0]
circle_fit:
method: algebraic_then_lsq
inlier_residual_mm: 1.5
rectangle_aspect_tol: 0.40
roi:
hole_dilate_mm: 5.0
central_hole_dilate_mm: 10.0
edge_buffer_mm: 8.0
chamfer_normal_dot_min: 0.80
outlier_z_score: 3.0
plane_fit:
ransac_iterations: 300
ransac_inlier_thresh_mm: auto
min_inlier_ratio: 0.5
enforce_nx_positive: true
metrics:
right_axis_sign: 1
angle_clamp_deg: 0.0
artifacts:
save_intermediate: true
ply_format: binary
image_dpi: 150
heatmap_colormap: viridis
registration:
method: kabsch_four_holes
refuse_if_residual_above_mm: 5.0

View File

@@ -0,0 +1,116 @@
"""Coupler-panel CAD-STP to point-cloud measurement validation system.
The package is split into two distinct layers:
**1. Public measurement algorithm (integration API).**
The single function software developers should call from their
product code. Takes a clean point cloud, returns a
:class:`CouplerMeasurement`. Stable schema across patch releases —
see ``docs/USAGE.md``.
Entry points:
- :func:`compute_coupler_measurement` — pure, in-memory.
- :class:`CouplerMeasurement`, :class:`HoleSet`, :class:`PanelPlane`,
:class:`ReferenceFrame`, :class:`Hole`.
- :mod:`coupler_panel.exceptions`.
- :func:`load_point_cloud`, :func:`write_point_cloud` for IO.
**2. Validation / data-preparation harness (research only).**
Used to generate synthetic point clouds from CAD, denoise them, and
register them into the CAD frame. Not part of the integration
surface; layout and signatures may change.
Entry points:
- :mod:`coupler_panel.data_prep` — STP → mesh → sample → noise →
transform → denoise → register.
- :mod:`coupler_panel.pipeline` — convenience wrappers that save
every artifact to disk.
"""
from __future__ import annotations
# --- Stable, integration-safe public surface --------------------------------
from ._version import ALGORITHM_VERSION, SCHEMA_VERSION, __version__
from .exceptions import (
CADParseError,
CouplerError,
HoleDetectionError,
InsufficientPointsError,
InvalidPointCloudError,
PlaneFitError,
RegistrationError,
UnsupportedFormatError,
)
from .io_utils import load_point_cloud, write_point_cloud
from .structures import (
CouplerMeasurement,
Hole,
HoleSet,
PanelPlane,
ReferenceFrame,
StageTiming,
)
def __getattr__(name: str):
"""Lazy export of heavy / optional symbols.
Avoids importing matplotlib / open3d / OCP at ``import coupler_panel``
time so a thin integration deployment keeps a fast, dependency-light
startup.
"""
if name in ("compute_coupler_measurement", "ProgressCallback"):
from .api import compute_coupler_measurement, ProgressCallback
return {"compute_coupler_measurement": compute_coupler_measurement, "ProgressCallback": ProgressCallback}[name]
if name == "run_coupler_measurement_pipeline":
from .pipeline import run_coupler_measurement_pipeline
return run_coupler_measurement_pipeline
if name == "run_from_cad_pipeline":
from .pipeline import run_from_cad_pipeline
return run_from_cad_pipeline
if name == "prepare_cloud_from_cad":
from .data_prep import prepare_cloud_from_cad
return prepare_cloud_from_cad
if name == "prepare_cloud_from_scan":
from .data_prep import prepare_cloud_from_scan
return prepare_cloud_from_scan
raise AttributeError(name)
__all__ = [
"__version__",
"ALGORITHM_VERSION",
"SCHEMA_VERSION",
# Stable measurement API
"compute_coupler_measurement",
"ProgressCallback",
"CouplerMeasurement",
"Hole",
"HoleSet",
"PanelPlane",
"ReferenceFrame",
"StageTiming",
"load_point_cloud",
"write_point_cloud",
# Exceptions
"CouplerError",
"InvalidPointCloudError",
"UnsupportedFormatError",
"InsufficientPointsError",
"CADParseError",
"HoleDetectionError",
"RegistrationError",
"PlaneFitError",
# Research-only convenience (data prep + persisting orchestration)
"run_coupler_measurement_pipeline",
"run_from_cad_pipeline",
"prepare_cloud_from_cad",
"prepare_cloud_from_scan",
]

View File

@@ -0,0 +1,19 @@
"""Version markers for coupler_panel.
- ``__version__`` — package version (semver).
- ``ALGORITHM_VERSION`` — algorithm/numerics version. Numerical results may
change when this increments. Increment whenever any computation in
``compute_coupler_measurement`` changes.
- ``SCHEMA_VERSION`` — result-schema version. Only increments on a
*breaking* change to the ``CouplerMeasurement`` dataclass or to
``report.json`` field semantics. New optional fields do **not** bump
this.
"""
from __future__ import annotations
__version__ = "0.1.0"
ALGORITHM_VERSION = "0.1.0"
SCHEMA_VERSION = "1.0"
__all__ = ["__version__", "ALGORITHM_VERSION", "SCHEMA_VERSION"]

View File

@@ -0,0 +1,251 @@
"""Public Python API — **measurement algorithm only**.
This module is what software developers integrate against. It takes a
*clean, already-registered* point cloud (in the CAD / world frame) of
the coupler panel and returns a :class:`CouplerMeasurement`.
Responsibilities of this layer:
validate → detect 4 holes → build reference frame
→ extract panel ROI → fit panel plane → compute metrics
Things that are **not** in this layer (they live in
:mod:`coupler_panel.data_prep`):
- Reading STEP files / generating synthetic point clouds
- Adding noise / random rigid transforms
- Denoising (passthrough / SOR / ROR / voxel / smoothing)
- 4-hole Kabsch registration into the CAD frame
The reason: those steps are *engineering* steps that vary per sensor
setup; the production caller usually has their own scan + cleanup
pipeline and just wants the deflection numbers out the other side.
"""
from __future__ import annotations
import time
import tracemalloc
from typing import Any, Callable
import numpy as np
from ._version import ALGORITHM_VERSION, SCHEMA_VERSION
from .config import load_config, resolve_auto
from .detection import detect_four_holes
from .exceptions import (
InsufficientPointsError,
InvalidPointCloudError,
PlaneFitError,
)
from .geometry import build_reference_frame, extract_panel_roi, fit_panel_plane
from .metrics import compute_deflection_metrics
from .structures import (
CouplerMeasurement,
HoleSet,
StageTiming,
)
ProgressCallback = Callable[[str, float], None]
_STAGES = (
"validate",
"detect_holes",
"build_frame",
"extract_roi",
"fit_plane",
"metrics",
)
def _ping(progress: ProgressCallback | None, stage: str, frac: float) -> None:
if progress is not None:
try:
progress(stage, float(frac))
except Exception:
pass
def _validate_points(points: Any) -> np.ndarray:
try:
pts = np.asarray(points, dtype=np.float64)
except Exception as exc:
raise InvalidPointCloudError(f"could not coerce points to ndarray: {exc}") from exc
if pts.ndim != 2 or pts.shape[1] != 3:
raise InvalidPointCloudError(f"expected (N, 3) points; got shape {pts.shape}")
if pts.shape[0] < 200:
raise InsufficientPointsError(f"too few points: {pts.shape[0]} (need ≥ 200)")
if not np.isfinite(pts).all():
raise InvalidPointCloudError("input contains NaN/Inf")
return pts
def compute_coupler_measurement(
points: Any,
config: str | dict[str, Any] | None = None,
profile: str = "auto",
*,
panel_normal_seed: np.ndarray | None = None,
progress: ProgressCallback | None = None,
) -> CouplerMeasurement:
"""Run the **measurement algorithm** on an already-prepared point cloud.
Parameters
----------
points : array-like
``(N, 3)`` cloud in the CAD / world frame, units mm.
The cloud is expected to be denoised and rigidly aligned to the
CAD frame before this call. See :mod:`coupler_panel.data_prep`
for a reference implementation of that preparation step.
config : path / dict / None
Optional override of the default YAML config.
profile : str
``"auto"`` / ``"default"`` skip profile loading. Other names are
resolved against ``configs/profiles/<name>.yaml``.
panel_normal_seed : (3,) or None
Optional prior on the panel normal (e.g. the GT normal). Speeds
up and stabilises the in-plane projection used by hole detection.
``None`` falls back to PCA on the cloud.
progress : callable or None
``progress(stage_name, fraction)`` called once per stage.
Returns
-------
:class:`CouplerMeasurement`
Raises
------
InvalidPointCloudError, InsufficientPointsError, HoleDetectionError,
PlaneFitError, CouplerError
"""
cfg = load_config(config, profile=profile)
cfg = resolve_auto(cfg)
quality_flags: list[str] = []
timings: list[StageTiming] = []
# ---- validate ----------------------------------------------------------
pts = _validate_points(points)
_ping(progress, "validate", 1.0 / len(_STAGES))
# ---- detect 4 holes ----------------------------------------------------
t0 = time.perf_counter()
tracemalloc.start()
detection = detect_four_holes(
pts,
detection_cfg=cfg["detection"],
cad_cfg=cfg["cad"],
panel_normal_seed=panel_normal_seed,
)
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
timings.append(StageTiming("detect_holes", time.perf_counter() - t0, peak / 1024))
_ping(progress, "detect_holes", 2.0 / len(_STAGES))
detected_holes = detection.holes_3d
# ---- reference frame ---------------------------------------------------
t0 = time.perf_counter()
frame = build_reference_frame(detected_holes)
timings.append(StageTiming("build_frame", time.perf_counter() - t0, 0.0))
_ping(progress, "build_frame", 3.0 / len(_STAGES))
# ---- extract panel ROI -------------------------------------------------
t0 = time.perf_counter()
tracemalloc.start()
if panel_normal_seed is not None:
seed3 = np.asarray(panel_normal_seed, dtype=np.float64)
else:
seed3 = np.array([1.0, 0.0, 0.0])
roi_pts, roi_diag = extract_panel_roi(
pts,
holes=detected_holes,
panel_normal_seed=seed3,
roi_cfg=cfg["roi"],
)
if roi_pts.shape[0] < 100:
raise InsufficientPointsError(
f"only {roi_pts.shape[0]} points survived ROI extraction"
)
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
timings.append(StageTiming("extract_roi", time.perf_counter() - t0, peak / 1024))
_ping(progress, "extract_roi", 4.0 / len(_STAGES))
# ---- fit panel plane ---------------------------------------------------
t0 = time.perf_counter()
tracemalloc.start()
plane = fit_panel_plane(roi_pts, plane_fit_cfg=cfg["plane_fit"])
if plane.inlier_ratio < float(cfg["plane_fit"].get("min_inlier_ratio", 0.6)):
quality_flags.append("plane_inlier_ratio_low")
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
timings.append(StageTiming("fit_plane", time.perf_counter() - t0, peak / 1024))
_ping(progress, "fit_plane", 5.0 / len(_STAGES))
# ---- metrics -----------------------------------------------------------
t0 = time.perf_counter()
metrics_cfg = cfg.get("metrics", {})
metrics = compute_deflection_metrics(
normal=plane.normal,
center_C=frame.center_C,
panel_points=roi_pts,
right_axis_sign=int(metrics_cfg.get("right_axis_sign", 1)),
angle_clamp_deg=float(metrics_cfg.get("angle_clamp_deg", 0.0)),
)
timings.append(StageTiming("metrics", time.perf_counter() - t0, 0.0))
_ping(progress, "metrics", 1.0)
report = {
"schema_version": SCHEMA_VERSION,
"algorithm_version": ALGORITHM_VERSION,
"config": cfg,
"n_points_in": int(pts.shape[0]),
"detection": detection.diagnostics,
"roi": roi_diag,
"plane": {
"normal": list(plane.normal),
"point_on_plane": list(plane.point_on_plane),
"d_offset": plane.d_offset,
"inlier_ratio": plane.inlier_ratio,
"inlier_residual_rms_mm": plane.inlier_residual_rms_mm,
},
"frame": {
"center_C": list(frame.center_C),
"reference_plane_z": frame.reference_plane_z,
"longitudinal_mid_y": frame.longitudinal_mid_y,
},
"metrics": metrics.to_dict(),
"quality_flags": quality_flags,
"timings": [
{"stage": t.stage, "seconds": t.seconds, "peak_kib": t.peak_kib}
for t in timings
],
}
return CouplerMeasurement(
schema_version=SCHEMA_VERSION,
algorithm_version=ALGORITHM_VERSION,
theta_right_deg=metrics.theta_right_deg,
theta_ref_deg=metrics.theta_ref_deg,
D_y_max_mm=metrics.D_y_max_mm,
D_z_max_mm=metrics.D_z_max_mm,
dx_y_max_mm=metrics.dx_y_max_mm,
dx_z_max_mm=metrics.dx_z_max_mm,
center_C=frame.center_C,
normal=plane.normal,
holes=detected_holes,
plane=plane,
frame=frame,
quality_flags=quality_flags,
timings=timings,
report=report,
)
__all__ = [
"compute_coupler_measurement",
"ProgressCallback",
]

View File

@@ -0,0 +1,21 @@
"""CAD STP parsing + GT extraction.
This package depends on ``cadquery-ocp`` (OCP) for OpenCascade access. If
OCP is not installed the modules will raise :class:`ImportError` at import
time only when actually used; pure point-cloud workflows do not need it.
"""
from .stp_parser import StepShape, load_step
from .mesh import tessellate_to_trimesh
from .gt_extractor import (
GroundTruth,
extract_ground_truth,
)
__all__ = [
"StepShape",
"load_step",
"tessellate_to_trimesh",
"GroundTruth",
"extract_ground_truth",
]

View File

@@ -0,0 +1,310 @@
"""Extract Ground Truth geometry from a parsed STEP shape.
Strategy
--------
1. Walk every circular edge in the shape (``iter_circular_edges``).
2. Cluster by radius:
- radii inside ``locating_hole_radius_mm`` → candidate locating holes
- radii ≥ ``central_hole_radius_min_mm`` → candidate central hole
3. Discard duplicates (front + back face of the same hole share centre and
axis up to a sign).
4. Find a set of four locating holes whose centres form a near-rectangle:
pairwise sort by axis-distance, then verify diagonals roughly equal.
5. Order the four locating holes consistently (anti-clockwise when viewed
from +x looking down −x). This ordering is the canonical pairing for
Kabsch later.
6. Define:
- ``C`` = mean of the four locating-hole centres (== diagonal
intersection for a rectangle)
- ``n`` = panel normal = unit axis of the locating circles, signed
so that ``n_x > 0``
- reference line, plane, longitudinal mid-plane in :class:`ReferenceFrame`
This GT can be used as
- the gold standard the measurement is compared against, AND
- a prior law that bootstraps point-cloud-side hole detection (we know the
panel normal a priori).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from ..exceptions import CADParseError
from ..structures import Hole, HoleSet, PanelPlane, ReferenceFrame
from .stp_parser import StepShape, iter_circular_edges
@dataclass
class GroundTruth:
"""All CAD-derived reference values used as the GT for validation."""
holes: HoleSet
plane: PanelPlane
frame: ReferenceFrame
raw_circles: list[dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"holes": {
"locating": [
{
"center": list(h.center),
"radius_mm": h.radius_mm,
"fit_residual_rms_mm": h.fit_residual_rms_mm,
"role": h.role,
}
for h in self.holes.locating
],
"central": (
None
if self.holes.central is None
else {
"center": list(self.holes.central.center),
"radius_mm": self.holes.central.radius_mm,
"fit_residual_rms_mm": self.holes.central.fit_residual_rms_mm,
"role": self.holes.central.role,
}
),
},
"plane": {
"normal": list(self.plane.normal),
"point_on_plane": list(self.plane.point_on_plane),
"d_offset": self.plane.d_offset,
"inlier_ratio": self.plane.inlier_ratio,
"inlier_residual_rms_mm": self.plane.inlier_residual_rms_mm,
},
"frame": {
"center_C": list(self.frame.center_C),
"line_direction": list(self.frame.line_direction),
"reference_plane_z": self.frame.reference_plane_z,
"longitudinal_mid_y": self.frame.longitudinal_mid_y,
},
"raw_circles": self.raw_circles,
}
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def extract_ground_truth(
step: StepShape,
cad_cfg: dict[str, Any],
) -> GroundTruth:
"""Extract GT from a parsed STEP file using the ``cad`` config block."""
r_lo, r_hi = cad_cfg["locating_hole_radius_mm"]
r_central_min = float(cad_cfg["central_hole_radius_min_mm"])
rect_aspect_tol = float(cad_cfg.get("rectangle_aspect_tol", 0.15))
raw: list[dict[str, Any]] = []
locating: list[dict[str, Any]] = []
central: list[dict[str, Any]] = []
for center, axis, radius, _edge in iter_circular_edges(step.shape):
item = {
"center": center,
"axis": axis,
"radius": radius,
}
raw.append(item)
if r_lo <= radius <= r_hi:
locating.append(item)
elif radius >= r_central_min:
central.append(item)
if not locating:
raise CADParseError(
f"no circular edge with radius in [{r_lo}, {r_hi}] mm; "
"adjust cad.locating_hole_radius_mm to match your panel"
)
locating = _dedupe_circles(locating, r_tol=0.05, c_tol=0.5)
central = _dedupe_circles(central, r_tol=0.5, c_tol=2.0)
four = _select_rectangle(locating, rect_aspect_tol=rect_aspect_tol)
four_ordered = _order_rectangle(four)
panel_normal = _consensus_axis(four_ordered)
panel_normal = _enforce_nx_positive(panel_normal)
centers = np.asarray([c["center"] for c in four_ordered], dtype=np.float64)
C = centers.mean(axis=0)
holes = HoleSet(
locating=tuple( # type: ignore[arg-type]
Hole(
center=tuple(c["center"]),
radius_mm=float(c["radius"]),
fit_residual_rms_mm=0.0,
role="locating",
)
for c in four_ordered
),
central=(
Hole(
center=tuple(central[0]["center"]),
radius_mm=float(central[0]["radius"]),
fit_residual_rms_mm=0.0,
role="central",
)
if central
else None
),
)
plane = PanelPlane(
normal=tuple(panel_normal),
point_on_plane=tuple(C),
d_offset=float(-np.dot(panel_normal, C)),
inlier_ratio=1.0,
inlier_residual_rms_mm=0.0,
)
frame = ReferenceFrame(
center_C=tuple(C),
line_direction=(1.0, 0.0, 0.0),
reference_plane_z=float(C[2]),
longitudinal_mid_y=float(C[1]),
)
return GroundTruth(holes=holes, plane=plane, frame=frame, raw_circles=raw)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _dedupe_circles(
items: list[dict[str, Any]],
*,
r_tol: float,
c_tol: float,
) -> list[dict[str, Any]]:
"""Drop circles that share centre and radius (front/back face copies)."""
out: list[dict[str, Any]] = []
for it in items:
c = np.asarray(it["center"])
r = it["radius"]
is_dup = False
for kept in out:
if abs(kept["radius"] - r) <= r_tol:
if np.linalg.norm(np.asarray(kept["center"]) - c) <= c_tol:
is_dup = True
break
if not is_dup:
out.append(it)
return out
def _select_rectangle(
items: list[dict[str, Any]],
*,
rect_aspect_tol: float,
) -> list[dict[str, Any]]:
"""Pick four items whose centres look most like a rectangle.
For tractability we (a) require ≥ 4 candidates, (b) if exactly 4, return
them, (c) otherwise enumerate all 4-subsets and pick the one with the
smallest "rectangleness" residual.
"""
if len(items) < 4:
raise CADParseError(
f"only {len(items)} locating-hole candidates found; need ≥ 4"
)
if len(items) == 4:
return items
from itertools import combinations
best_score = float("inf")
best: list[dict[str, Any]] | None = None
for combo in combinations(items, 4):
s = _rectangle_score(combo)
if s < best_score:
best_score = s
best = list(combo)
assert best is not None
if best_score > rect_aspect_tol:
# Still accept the best — the tolerance is informational; warn via
# raw_circles in the GT report.
pass
return best
def _rectangle_score(items) -> float:
"""0 = perfect rectangle. Larger = worse."""
c = np.asarray([i["center"] for i in items], dtype=np.float64)
# Use distances between the 4 centres. For a rectangle, the 6 pairwise
# distances are: two "long" sides equal, two "short" sides equal, two
# diagonals equal.
from itertools import combinations
d = sorted(np.linalg.norm(c[i] - c[j]) for i, j in combinations(range(4), 2))
if d[0] < 1e-6:
return 1e6
s1, s2 = d[0], d[1] # short sides
l1, l2 = d[2], d[3] # long sides
g1, g2 = d[4], d[5] # diagonals
score = (
abs(s1 - s2) / max(s1, s2)
+ abs(l1 - l2) / max(l1, l2)
+ abs(g1 - g2) / max(g1, g2)
)
return score
def _order_rectangle(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Return four hole dicts in a canonical (-y/-z, +y/-z, +y/+z, -y/+z) order.
The ordering is taken in the y-z plane (panel normal is along ±x). This
matches how a panel is normally described: bottom-left, bottom-right,
top-right, top-left when viewed from +x looking down −x.
"""
c = np.asarray([i["center"] for i in items], dtype=np.float64)
cy = c[:, 1].mean()
cz = c[:, 2].mean()
quadrant: dict[tuple[bool, bool], int] = {}
for idx, p in enumerate(c):
key = (p[1] >= cy, p[2] >= cz)
quadrant[key] = idx
order = [
(False, False),
(True, False),
(True, True),
(False, True),
]
if not all(q in quadrant for q in order):
# Geometry is degenerate; fall back to angular sort.
ang = np.arctan2(c[:, 2] - cz, c[:, 1] - cy)
idxs = np.argsort(ang)
return [items[i] for i in idxs]
return [items[quadrant[q]] for q in order]
def _consensus_axis(items: list[dict[str, Any]]) -> np.ndarray:
"""Average and re-normalise the circle axis directions."""
axes = np.asarray([i["axis"] for i in items], dtype=np.float64)
# Flip any axes that disagree with the first.
ref = axes[0]
for k in range(1, len(axes)):
if np.dot(axes[k], ref) < 0:
axes[k] = -axes[k]
n = axes.mean(axis=0)
n /= max(np.linalg.norm(n), 1e-12)
return n
def _enforce_nx_positive(n: np.ndarray) -> np.ndarray:
if n[0] < 0:
return -n
return n
__all__ = ["GroundTruth", "extract_ground_truth"]

View File

@@ -0,0 +1,143 @@
"""Tessellation of a TopoDS_Shape into a (V, F) triangle mesh.
The output is a small dataclass — we do not import ``trimesh`` until the
caller actually asks for it, to keep the core dependency surface minimal.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import numpy as np
from .stp_parser import StepShape, _import_ocp, iter_faces
@dataclass
class TriangleMesh:
"""Vertex / face arrays.
Attributes
----------
vertices : ``(V, 3) float64``
faces : ``(F, 3) int64`` (vertex indices)
face_normals : ``(F, 3) float64``
"""
vertices: np.ndarray
faces: np.ndarray
face_normals: np.ndarray
@property
def n_vertices(self) -> int:
return int(self.vertices.shape[0])
@property
def n_faces(self) -> int:
return int(self.faces.shape[0])
def to_trimesh(self):
"""Return a ``trimesh.Trimesh`` (lazy import)."""
try:
import trimesh
except ImportError as exc:
raise ImportError(
"trimesh is required for to_trimesh(); "
"install with: pip install 'coupler-panel-validation[cad]'"
) from exc
return trimesh.Trimesh(
vertices=self.vertices,
faces=self.faces,
face_normals=self.face_normals,
process=False,
)
def tessellate_to_trimesh(
step: StepShape,
*,
linear_deflection_mm: float = 0.5,
angular_deflection_rad: float = 0.1,
) -> TriangleMesh:
"""Tessellate a :class:`StepShape` into a single combined triangle mesh.
Each face is tessellated independently (with the location of the edge
transferred onto the vertices), then concatenated. Triangle winding is
flipped where the OCP face is reversed, so :attr:`face_normals` is the
*outward* normal in the assembly frame.
"""
occ = _import_ocp()
BRep_Tool = occ["BRep_Tool"]
TopLoc_Location = occ["TopLoc_Location"]
BRepMesh_IncrementalMesh = occ["BRepMesh_IncrementalMesh"]
BRepMesh_IncrementalMesh(
step.shape,
float(linear_deflection_mm),
False,
float(angular_deflection_rad),
True,
)
all_v: list[np.ndarray] = []
all_f: list[np.ndarray] = []
all_n: list[np.ndarray] = []
v_offset = 0
for face in iter_faces(step.shape):
loc = TopLoc_Location()
triangulation = BRep_Tool.Triangulation_s(face, loc)
if triangulation is None:
continue
trsf = loc.Transformation()
n_nodes = triangulation.NbNodes()
n_tri = triangulation.NbTriangles()
if n_nodes == 0 or n_tri == 0:
continue
v = np.empty((n_nodes, 3), dtype=np.float64)
for i in range(n_nodes):
p = triangulation.Node(i + 1).Transformed(trsf)
v[i, 0] = p.X()
v[i, 1] = p.Y()
v[i, 2] = p.Z()
f = np.empty((n_tri, 3), dtype=np.int64)
is_reversed = face.Orientation() == 1 # TopAbs_REVERSED
for j in range(n_tri):
tri = triangulation.Triangle(j + 1)
i1, i2, i3 = tri.Get()
if is_reversed:
f[j] = (i3 - 1, i2 - 1, i1 - 1)
else:
f[j] = (i1 - 1, i2 - 1, i3 - 1)
# Outward face normal per triangle.
a = v[f[:, 0]]
b = v[f[:, 1]]
c = v[f[:, 2]]
normals = np.cross(b - a, c - a)
norms = np.linalg.norm(normals, axis=1, keepdims=True)
norms[norms < 1e-12] = 1.0
normals = normals / norms
all_v.append(v)
all_f.append(f + v_offset)
all_n.append(normals)
v_offset += n_nodes
if not all_v:
raise RuntimeError(
"tessellation produced no triangles; "
"try a finer linear_deflection_mm"
)
vertices = np.concatenate(all_v, axis=0)
faces = np.concatenate(all_f, axis=0)
face_normals = np.concatenate(all_n, axis=0)
return TriangleMesh(vertices=vertices, faces=faces, face_normals=face_normals)
__all__ = ["TriangleMesh", "tessellate_to_trimesh"]

View File

@@ -0,0 +1,219 @@
"""STP file loading via OCP (OpenCascade Python bindings).
The OCP import is deferred so the rest of the package does not require
OpenCascade to be installed.
The parser exposes a thin :class:`StepShape` wrapper that gives the rest
of the package what it needs: the underlying ``TopoDS_Shape`` for
tessellation and topology traversal.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..exceptions import CADParseError
@dataclass
class StepShape:
"""Result of loading a STEP file.
Attributes
----------
shape : ``OCP.TopoDS.TopoDS_Shape``
The root shape (assembly compound or single solid).
source : ``Path``
Source file path, for debugging.
"""
shape: Any
source: Path
def _import_ocp() -> dict[str, Any]:
"""Import the OCP modules we need. Raises a clean error if missing."""
try:
from OCP.STEPControl import STEPControl_Reader # type: ignore
from OCP.IFSelect import IFSelect_RetDone # type: ignore
from OCP.TopoDS import TopoDS, TopoDS_Shape # type: ignore
from OCP.TopAbs import ( # type: ignore
TopAbs_FACE,
TopAbs_EDGE,
TopAbs_VERTEX,
)
from OCP.TopExp import TopExp_Explorer # type: ignore
from OCP.TopLoc import TopLoc_Location # type: ignore
from OCP.BRep import BRep_Tool # type: ignore
from OCP.BRepMesh import BRepMesh_IncrementalMesh # type: ignore
from OCP.BRepGProp import BRepGProp # type: ignore
from OCP.GProp import GProp_GProps # type: ignore
from OCP.GeomAbs import GeomAbs_Circle # type: ignore
from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface # type: ignore
from OCP.gp import gp_Pnt, gp_Dir, gp_Vec # type: ignore
except ImportError as exc:
raise ImportError(
"OCP (cadquery-ocp) is required to parse STEP files. "
"Install with: pip install 'coupler-panel-validation[cad]'"
) from exc
return {
"STEPControl_Reader": STEPControl_Reader,
"IFSelect_RetDone": IFSelect_RetDone,
"TopoDS": TopoDS,
"TopoDS_Shape": TopoDS_Shape,
"TopAbs_FACE": TopAbs_FACE,
"TopAbs_EDGE": TopAbs_EDGE,
"TopAbs_VERTEX": TopAbs_VERTEX,
"TopExp_Explorer": TopExp_Explorer,
"TopLoc_Location": TopLoc_Location,
"BRep_Tool": BRep_Tool,
"BRepMesh_IncrementalMesh": BRepMesh_IncrementalMesh,
"BRepGProp": BRepGProp,
"GProp_GProps": GProp_GProps,
"GeomAbs_Circle": GeomAbs_Circle,
"BRepAdaptor_Curve": BRepAdaptor_Curve,
"BRepAdaptor_Surface": BRepAdaptor_Surface,
"gp_Pnt": gp_Pnt,
"gp_Dir": gp_Dir,
"gp_Vec": gp_Vec,
}
def load_step(path: str | Path) -> StepShape:
"""Read a STEP file and return its root :class:`TopoDS_Shape`.
Raises :class:`CADParseError` on read failure.
"""
p = Path(path)
if not p.exists():
raise CADParseError(f"STEP file not found: {p}")
occ = _import_ocp()
reader = occ["STEPControl_Reader"]()
status = reader.ReadFile(str(p))
if status != occ["IFSelect_RetDone"]:
raise CADParseError(f"OCP failed to read {p} (status={status})")
n_roots = reader.TransferRoots()
if n_roots <= 0:
raise CADParseError(f"no roots transferred from {p}")
shape = reader.OneShape()
return StepShape(shape=shape, source=p)
# ---------------------------------------------------------------------------
# Topology iteration helpers (used by gt_extractor)
# ---------------------------------------------------------------------------
def _shape_key(shape) -> int:
"""Stable identity key for a TopoDS_Shape across OCP versions.
OCP 7.8+ removed ``HashCode``. We fall back to the underlying TShape's
Python id, which is stable per OpenCASCADE topology object.
"""
try:
return shape.HashCode(2**31 - 1)
except AttributeError:
try:
return id(shape.TShape())
except Exception:
return id(shape)
def iter_faces(shape: Any):
"""Yield every TopoDS_Face inside ``shape``.
Two visits of the *same underlying surface with opposite orientations*
are treated as distinct because the orientation governs the outward
normal of the produced triangulation.
"""
occ = _import_ocp()
TopoDS = occ["TopoDS"]
explorer = occ["TopExp_Explorer"](shape, occ["TopAbs_FACE"])
while explorer.More():
raw = explorer.Current()
try:
yield TopoDS.Face_s(raw)
except Exception:
pass
explorer.Next()
def iter_circular_edges(shape: Any):
"""Yield ``(center_xyz, axis_xyz, radius_mm, edge)`` for every circular
edge in ``shape``. Duplicates (same circle on two adjacent faces) are
NOT removed here — downstream ``_dedupe_circles`` does that by
geometry (centre + radius).
"""
occ = _import_ocp()
explorer = occ["TopExp_Explorer"](shape, occ["TopAbs_EDGE"])
TopoDS = occ["TopoDS"]
BRepAdaptor_Curve = occ["BRepAdaptor_Curve"]
GeomAbs_Circle = occ["GeomAbs_Circle"]
while explorer.More():
raw = explorer.Current()
try:
edge = TopoDS.Edge_s(raw)
curve = BRepAdaptor_Curve(edge)
if curve.GetType() == GeomAbs_Circle:
circ = curve.Circle()
loc = circ.Location()
ax = circ.Axis().Direction()
radius = float(circ.Radius())
center = (float(loc.X()), float(loc.Y()), float(loc.Z()))
axis = (float(ax.X()), float(ax.Y()), float(ax.Z()))
yield center, axis, radius, edge
except Exception:
pass
explorer.Next()
def iter_cylindrical_faces(shape: Any):
"""Yield ``(center, axis, radius, face)`` for every cylinder face.
Cylinder faces are typically the *walls* of through-holes; their
radii match the circular edges that bound them.
"""
occ = _import_ocp()
TopoDS = occ["TopoDS"]
BRepAdaptor_Surface = occ["BRepAdaptor_Surface"]
explorer = occ["TopExp_Explorer"](shape, occ["TopAbs_FACE"])
while explorer.More():
raw = explorer.Current()
try:
face = TopoDS.Face_s(raw)
surf = BRepAdaptor_Surface(face)
if int(surf.GetType()) == 1:
cyl = surf.Cylinder()
loc = cyl.Location()
ax = cyl.Axis().Direction()
radius = float(cyl.Radius())
yield (
(float(loc.X()), float(loc.Y()), float(loc.Z())),
(float(ax.X()), float(ax.Y()), float(ax.Z())),
radius,
face,
)
except Exception:
pass
explorer.Next()
def face_area(face: Any) -> float:
occ = _import_ocp()
props = occ["GProp_GProps"]()
occ["BRepGProp"].SurfaceProperties_s(face, props)
return float(props.Mass())
__all__ = [
"StepShape",
"load_step",
"iter_faces",
"iter_circular_edges",
"face_area",
]

View File

@@ -0,0 +1,161 @@
"""``coupler-measure`` command-line entry point.
Three modes::
# measure an existing point cloud (production-like flow)
coupler-measure --input-cloud scan.ply --out-dir runs/demo
# research: synthesise a cloud from a STEP file and measure it
coupler-measure --stp panel.stp --out-dir runs/from_cad
# research: parameter sweep
coupler-measure --sweep matrix.yaml --out-dir runs/sweep_xx
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
from pathlib import Path
log = logging.getLogger("coupler_panel.cli")
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="coupler-measure",
description="Coupler-panel CAD-STP to point-cloud measurement & validation.",
)
src = p.add_mutually_exclusive_group(required=True)
src.add_argument("--input-cloud", type=Path, help="Path to a PLY/XYZ/CSV/PCD point cloud.")
src.add_argument("--stp", type=Path, help="Path to a STEP file (research mode).")
src.add_argument("--sweep", type=Path, help="Path to a sweep matrix YAML.")
p.add_argument("--out-dir", type=Path, required=True, help="Output directory.")
p.add_argument("--config", type=Path, default=None, help="YAML config overrides.")
p.add_argument(
"--profile",
type=str,
default="auto",
help="auto | default | high_density | low_density | custom",
)
p.add_argument(
"--target-holes",
type=Path,
default=None,
help="DEPRECATED — the main pipeline no longer performs 4-hole Kabsch "
"registration (it cancels the panel deflection signal). The flag "
"is accepted for backward compatibility but is ignored.",
)
p.add_argument(
"--render",
action="store_true",
help="After measurement, render PNG diagnostics into <out-dir>/images/.",
)
p.add_argument("-v", "--verbose", action="count", default=0, help="-v: INFO, -vv: DEBUG")
return p
def _setup_logging(verbose: int) -> None:
level = logging.WARNING
if verbose == 1:
level = logging.INFO
elif verbose >= 2:
level = logging.DEBUG
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
def _on_progress(stage: str, fraction: float) -> None:
log.info("[%5.1f%%] %s", fraction * 100, stage)
def _maybe_render(out_dir: Path) -> None:
try:
from .viz import render_all
render_all(out_dir)
except ImportError:
log.warning("matplotlib not installed; skip --render")
except Exception as exc:
log.warning("render_all failed: %s", exc)
def main(argv: list[str] | None = None) -> int:
args = _build_parser().parse_args(argv)
_setup_logging(args.verbose)
try:
if args.input_cloud is not None:
from .pipeline import run_coupler_measurement_pipeline
if args.target_holes is not None:
log.warning(
"--target-holes is deprecated and ignored; the main pipeline "
"no longer performs Kabsch registration."
)
result = run_coupler_measurement_pipeline(
input_cloud=args.input_cloud,
out_dir=args.out_dir,
config_path=args.config,
profile=args.profile,
progress=_on_progress,
)
print(
json.dumps(
{
"theta_right_deg": result.theta_right_deg,
"theta_ref_deg": result.theta_ref_deg,
"dx_y_max_mm": result.dx_y_max_mm,
"dx_z_max_mm": result.dx_z_max_mm,
"quality_flags": result.quality_flags,
},
indent=2,
ensure_ascii=False,
)
)
elif args.stp is not None:
from .pipeline import run_from_cad_pipeline
summary = run_from_cad_pipeline(
stp_path=args.stp,
out_dir=args.out_dir,
config_path=args.config,
profile=args.profile,
progress=_on_progress,
)
print(
json.dumps(
{
"diff": summary["diff"],
"quality_flags": summary["measured"]["quality_flags"],
},
indent=2,
ensure_ascii=False,
)
)
elif args.sweep is not None:
from importlib import import_module
mod = import_module("experiments.sweep_runner")
mod.main(matrix_path=args.sweep, out_dir=args.out_dir, config_path=args.config)
if args.render:
_maybe_render(args.out_dir)
except Exception as exc:
log.exception("pipeline failed: %s", exc)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,209 @@
"""Configuration loading and merging.
Configs are nested dicts. The single source of truth for defaults is
``configs/default.yaml`` (shipped alongside the package); ``DEFAULT_CONFIG``
mirrors its structure for in-memory programmatic overrides.
Public functions:
- :func:`load_config` — load a YAML file or accept an already-built dict.
- :func:`load_profile` — load ``configs/profiles/<name>.yaml`` and merge
on top of the defaults.
- :func:`deep_merge` — recursive dict merge (override wins).
- :func:`apply_dotted` — set a value via ``"sampling.spacing_mm"``.
- :func:`resolve_auto` — fill in fields whose value is the literal
string ``"auto"`` (currently: ``plane_fit.ransac_inlier_thresh_mm``).
"""
from __future__ import annotations
import copy
from pathlib import Path
from typing import Any
import yaml
_PKG_ROOT = Path(__file__).resolve().parent
_DEFAULT_YAML = _PKG_ROOT.parent / "configs" / "default.yaml"
def _load_yaml(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not isinstance(data, dict):
raise ValueError(f"{path}: expected a mapping at the top level")
return data
def _load_default() -> dict[str, Any]:
if _DEFAULT_YAML.exists():
return _load_yaml(_DEFAULT_YAML)
# Package may have been installed without configs/. Fall back to a minimal
# in-code default so library use without the YAML still works.
return _IN_CODE_FALLBACK
# Loaded lazily; recomputed on each call to keep tests deterministic.
def default_config() -> dict[str, Any]:
"""Return a fresh copy of the default configuration."""
return copy.deepcopy(_load_default())
DEFAULT_CONFIG: dict[str, Any] # populated below for convenience
def load_config(
source: str | Path | dict[str, Any] | None,
*,
profile: str = "auto",
) -> dict[str, Any]:
"""Resolve the final configuration to use.
The merge order is::
default.yaml < profile (if any) < source (path or dict)
Parameters
----------
source : path, dict, or None
If ``None``, only defaults (and the profile) are used.
profile : str
``"auto"`` / ``"default"`` skip the profile step. Any other name
is looked up at ``configs/profiles/<name>.yaml``.
"""
cfg = default_config()
if profile not in (None, "auto", "default", "custom"):
prof_path = _PKG_ROOT.parent / "configs" / "profiles" / f"{profile}.yaml"
if not prof_path.exists():
raise FileNotFoundError(f"profile '{profile}' not found at {prof_path}")
cfg = deep_merge(cfg, _load_yaml(prof_path))
if source is None:
pass
elif isinstance(source, dict):
cfg = deep_merge(cfg, source)
else:
cfg = deep_merge(cfg, _load_yaml(Path(source)))
return cfg
def load_profile(name: str) -> dict[str, Any]:
"""Load a profile YAML directly; useful for tests."""
path = _PKG_ROOT.parent / "configs" / "profiles" / f"{name}.yaml"
return _load_yaml(path)
def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
"""Recursive dict merge. ``override`` wins on leaf collisions."""
out = copy.deepcopy(base)
for key, val in override.items():
if (
key in out
and isinstance(out[key], dict)
and isinstance(val, dict)
):
out[key] = deep_merge(out[key], val)
else:
out[key] = copy.deepcopy(val)
return out
def apply_dotted(cfg: dict[str, Any], dotted_key: str, value: Any) -> dict[str, Any]:
"""Set ``cfg["a"]["b"]["c"] = value`` from ``"a.b.c"``. Returns ``cfg``."""
parts = dotted_key.split(".")
node = cfg
for p in parts[:-1]:
node = node.setdefault(p, {})
if not isinstance(node, dict):
raise TypeError(f"cannot apply '{dotted_key}': '{p}' is not a dict")
node[parts[-1]] = value
return cfg
def resolve_auto(cfg: dict[str, Any]) -> dict[str, Any]:
"""Replace literal ``"auto"`` markers with concrete values.
Currently:
- ``plane_fit.ransac_inlier_thresh_mm == "auto"`` → ``3 * noise.sigma_mm``
(or 0.5 mm if ``noise.sigma_mm == 0``).
"""
out = copy.deepcopy(cfg)
pf = out.get("plane_fit", {})
if pf.get("ransac_inlier_thresh_mm", None) == "auto":
sigma = float(out.get("noise", {}).get("sigma_mm", 0.0) or 0.0)
pf["ransac_inlier_thresh_mm"] = max(3.0 * sigma, 0.5)
out["plane_fit"] = pf
return out
# Minimal in-code fallback used only when the YAML is missing.
_IN_CODE_FALLBACK: dict[str, Any] = {
"schema_version": "1.0",
"cad": {
"linear_deflection_mm": 0.5,
"angular_deflection_rad": 0.1,
"locating_hole_radius_mm": [4.0, 12.0],
"central_hole_radius_min_mm": 20.0,
"rectangle_aspect_tol": 0.15,
},
"sampling": {"method": "poisson_disk", "spacing_mm": 1.0, "num_points": 100000, "seed": 42},
"noise": {"enabled": True, "sigma_mm": 0.5, "mode": "isotropic", "seed": 43},
"transform": {
"enabled": True,
"rotation_deg": 3.0,
"rotation_axis": "random",
"translation_mm": 5.0,
"translation_dir": "random",
"seed": 44,
},
"denoise": {"steps": []},
"registration": {"method": "kabsch_four_holes", "pre_align": "pca_centroid"},
"detection": {
"panel_normal_seed": "from_cad",
"grid_pixel_mm": 0.5,
"panel_min_area_ratio": 0.4,
"hole_min_area_mm2": 30.0,
"hole_max_area_mm2": 800.0,
"circle_fit": {"method": "algebraic_then_lsq", "inlier_residual_mm": 0.5},
"select_top_k_by_radius_match": 4,
"rectangle_aspect_tol": 0.20,
},
"roi": {
"hole_dilate_mm": 3.0,
"central_hole_dilate_mm": 5.0,
"edge_buffer_mm": 5.0,
"chamfer_normal_dot_min": 0.85,
"outlier_z_score": 3.0,
},
"plane_fit": {
"method": "ransac_svd",
"ransac_iterations": 200,
"ransac_inlier_thresh_mm": "auto",
"min_inlier_ratio": 0.6,
"enforce_nx_positive": True,
},
"metrics": {"right_axis_sign": 1, "angle_clamp_deg": 0.0},
"artifacts": {
"save_intermediate": True,
"ply_format": "binary",
"image_dpi": 150,
"heatmap_colormap": "viridis",
},
}
DEFAULT_CONFIG = copy.deepcopy(_IN_CODE_FALLBACK)
__all__ = [
"DEFAULT_CONFIG",
"default_config",
"load_config",
"load_profile",
"deep_merge",
"apply_dotted",
"resolve_auto",
]

View File

@@ -0,0 +1,528 @@
"""Data-preparation pipeline (research / validation use only).
This module is **not** part of the integration API. It exists so that
researchers can take a CAD STP file, synthesise scan-like point clouds,
denoise them, and align them to the CAD frame — i.e. it produces an
input that :func:`coupler_panel.api.compute_coupler_measurement` can
then run on.
Stages performed (each one is independently re-runnable so the experiment
sweep can iterate over individual knobs)::
parse_cad STP → mesh + GT
synthesize_cloud mesh → sampled (multi-strategy) → noisy → transformed
denoise_cloud cloud → cloud (configurable pipeline)
register_cloud cloud → cloud (4-hole Kabsch into CAD frame)
The end-to-end convenience wrapper :func:`prepare_cloud_from_cad` glues
the four stages together and returns a :class:`PreparedCloud` ready to
hand to the measurement API.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
from .config import load_config, resolve_auto
from .denoise import run_pipeline as run_denoise
from .detection import detect_four_holes
from .exceptions import RegistrationError
from .registration import register_four_holes
from .structures import Hole, HoleSet
# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------
@dataclass
class CADParsed:
"""Output of :func:`parse_cad`."""
mesh: Any # coupler_panel.cad.mesh.TriangleMesh
gt: Any # coupler_panel.cad.gt_extractor.GroundTruth
@dataclass
class SynthesizedCloud:
"""Output of :func:`synthesize_cloud`."""
sampled: np.ndarray # (Ns, 3) fresh from the mesh, optional HPR applied
deflected: np.ndarray # (Ns, 3) + panel-deflection injection (known GT)
noisy: np.ndarray # (Ns, 3) + Gaussian noise
transformed: np.ndarray # (Ns, 3) + scanner-pose rigid transform
gt_transform_4x4: np.ndarray # (4, 4) scanner-pose transform applied at end
gt_holes_in_scan_frame: HoleSet # deflected GT holes after T_jitter — Kabsch target
# ---- Panel-deflection ground truth ----------------------------------
panel_deflection_R: np.ndarray # (3, 3) rotation that tilts the panel
panel_deflection_theta_right_deg: float
panel_deflection_theta_ref_deg: float
gt_holes_after_deflection: HoleSet # in CAD frame, post-deflection
gt_normal_after_deflection: np.ndarray # (3,) deflected panel normal in CAD frame
info: dict[str, Any] = field(default_factory=dict)
@dataclass
class DenoisedCloud:
"""Output of :func:`denoise_cloud`."""
points: np.ndarray
log: list[dict[str, Any]]
@dataclass
class RegisteredCloud:
"""Output of :func:`register_cloud`."""
points: np.ndarray # cloud aligned into the CAD frame
transform_4x4: np.ndarray # the rigid alignment (scan → CAD)
rmse_mm: float
max_residual_mm: float
detected_holes_in_cad_frame: HoleSet
@dataclass
class PreparedCloud:
"""End-to-end output, ready to feed into the public measurement API.
``points`` is in the CAD frame, denoised, with all the meta-data the
experiment / validation harness needs to score the measurement.
"""
points: np.ndarray
gt: Any # GroundTruth
gt_transform_4x4: np.ndarray | None
registration_transform_4x4: np.ndarray | None
registration_rmse_mm: float | None
detected_holes_in_cad_frame: HoleSet | None
intermediates: dict[str, Any] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Stage 1 — STP → mesh + GT
# ---------------------------------------------------------------------------
def parse_cad(stp_path: str | Path, cad_cfg: dict[str, Any]) -> CADParsed:
from .cad import extract_ground_truth, load_step, tessellate_to_trimesh
step = load_step(stp_path)
mesh = tessellate_to_trimesh(
step,
linear_deflection_mm=float(cad_cfg["linear_deflection_mm"]),
angular_deflection_rad=float(cad_cfg["angular_deflection_rad"]),
)
gt = extract_ground_truth(step, cad_cfg)
return CADParsed(mesh=mesh, gt=gt)
# ---------------------------------------------------------------------------
# Stage 2 — mesh → sampled → noisy → transformed
# ---------------------------------------------------------------------------
def _build_panel_deflection_R(theta_right_deg: float, theta_ref_deg: float) -> np.ndarray:
"""Build the 3×3 rotation that tilts a panel whose normal is (1,0,0).
The post-rotation normal is::
n = (cos(th_ref)·cos(th_right), cos(th_ref)·sin(th_right), sin(th_ref))
so that ``atan2(n_y, n_x) = th_right`` and ``atan2(n_z, n_x) = th_ref``
exactly.
R = R_z(th_right) · R_y(-th_ref)
"""
a = np.deg2rad(float(theta_right_deg))
b = np.deg2rad(float(theta_ref_deg))
Rz = np.array(
[
[np.cos(a), -np.sin(a), 0.0],
[np.sin(a), np.cos(a), 0.0],
[0.0, 0.0, 1.0],
]
)
Ry_neg = np.array(
[
[np.cos(b), 0.0, -np.sin(b)],
[0.0, 1.0, 0.0],
[np.sin(b), 0.0, np.cos(b)],
]
)
return Rz @ Ry_neg
def _rotate_holeset(holes: HoleSet, R: np.ndarray) -> HoleSet:
locating = tuple(
Hole(
center=tuple((R @ np.asarray(h.center)).tolist()),
radius_mm=h.radius_mm,
fit_residual_rms_mm=h.fit_residual_rms_mm,
role=h.role,
)
for h in holes.locating
)
central = (
None
if holes.central is None
else Hole(
center=tuple((R @ np.asarray(holes.central.center)).tolist()),
radius_mm=holes.central.radius_mm,
fit_residual_rms_mm=holes.central.fit_residual_rms_mm,
role="central",
)
)
return HoleSet(locating=locating, central=central)
def synthesize_cloud(
parsed: CADParsed,
*,
sampling_cfg: dict[str, Any],
noise_cfg: dict[str, Any],
transform_cfg: dict[str, Any],
scan_cfg: dict[str, Any] | None = None,
deflection_cfg: dict[str, Any] | None = None,
) -> SynthesizedCloud:
from .sampling import resolve_num_points, sample_mesh
from .simulation import (
add_gaussian_noise,
apply_rigid,
sample_random_rigid,
simulate_scan_view,
)
n = resolve_num_points(
parsed.mesh,
num_points=sampling_cfg.get("num_points"),
spacing_mm=sampling_cfg.get("spacing_mm"),
)
sampled = sample_mesh(
parsed.mesh,
method=sampling_cfg["method"],
num_points=n,
spacing_mm=sampling_cfg.get("spacing_mm"),
seed=int(sampling_cfg.get("seed", 42)),
curvature_weight=float(sampling_cfg.get("adaptive", {}).get("curvature_weight", 1.0)),
)
# Optional: simulate a single-view scan via hidden-point removal.
# Without this, the sampler returns points from BOTH sides of the panel
# (front face, back face, cylinder hole walls, …); the projected
# occupancy grid then has no cavities because the back-face points fill
# the hole region. Enable this for any "facing-scan" simulation.
if scan_cfg and bool(scan_cfg.get("enabled", False)):
view = scan_cfg.get("view_origin", [10000.0, 0.0, 0.0])
sampled = simulate_scan_view(
sampled,
view_origin=view,
hpr_radius_mm=float(scan_cfg.get("hpr_radius_mm", 50000.0)),
)
# ---- Panel deflection injection ---------------------------------------
# This is the *signal* the algorithm should later recover. We rotate
# the entire panel (and its 4 locating holes + central hole) by a known
# small angle so that the GT panel normal becomes
# n_new = R · (1, 0, 0)
# with atan2(n_new.y, n_new.x) = theta_right_deg (rotation around z)
# and atan2(n_new.z, n_new.x) = theta_ref_deg (rotation around -y).
# The 4 hole centres rotate with the panel, so 4-hole Kabsch (which
# targets the *deflected* GT holes) does NOT undo the deflection — the
# panel orientation in the registered cloud still carries the signal.
th_right = 0.0
th_ref = 0.0
if deflection_cfg and bool(deflection_cfg.get("enabled", False)):
th_right = float(deflection_cfg.get("theta_right_deg", 0.0))
th_ref = float(deflection_cfg.get("theta_ref_deg", 0.0))
R_panel = _build_panel_deflection_R(th_right, th_ref)
deflected = sampled @ R_panel.T
gt_holes_after = _rotate_holeset(parsed.gt.holes, R_panel)
gt_normal_after = R_panel @ np.asarray(parsed.gt.plane.normal, dtype=np.float64)
sampled = deflected # downstream uses the deflected cloud as the new ground
if bool(noise_cfg.get("enabled", True)) and float(noise_cfg.get("sigma_mm", 0.0)) > 0:
noisy = add_gaussian_noise(
sampled,
sigma_mm=float(noise_cfg["sigma_mm"]),
mode=noise_cfg.get("mode", "isotropic"),
seed=int(noise_cfg.get("seed", 43)),
)
else:
noisy = sampled.copy()
# The "GT-side" scan-pose perturbation: shifts and rotates the cloud as
# a whole to mimic mounting / scanner-stage repeatability error. It is
# NOT the deflection we want to measure; 4-hole Kabsch should cancel it.
if bool(transform_cfg.get("enabled", True)):
T = sample_random_rigid(
rotation_deg=float(transform_cfg.get("rotation_deg", 0.0)),
translation_mm=float(transform_cfg.get("translation_mm", 0.0)),
rotation_axis=transform_cfg.get("rotation_axis", "random"),
translation_dir=transform_cfg.get("translation_dir", "random"),
seed=int(transform_cfg.get("seed", 44)),
)
transformed = apply_rigid(noisy, T.matrix)
Mat = T.matrix
R = Mat[:3, :3]
t = Mat[:3, 3]
# Deflected GT holes, then jitter — same composition the cloud went through.
gt_holes_scan = HoleSet(
locating=tuple( # type: ignore[arg-type]
Hole(
center=tuple((R @ np.asarray(h.center) + t).tolist()),
radius_mm=h.radius_mm,
fit_residual_rms_mm=0.0,
role=h.role,
)
for h in gt_holes_after.locating
),
central=(
None
if gt_holes_after.central is None
else Hole(
center=tuple(
(R @ np.asarray(gt_holes_after.central.center) + t).tolist()
),
radius_mm=gt_holes_after.central.radius_mm,
fit_residual_rms_mm=0.0,
role="central",
)
),
)
info = {"transform": T.to_dict()}
gt_T = T.matrix
else:
transformed = noisy.copy()
gt_holes_scan = gt_holes_after
info = {"transform": {"enabled": False}}
gt_T = np.eye(4)
return SynthesizedCloud(
sampled=sampled,
deflected=deflected,
noisy=noisy,
transformed=transformed,
gt_transform_4x4=gt_T,
gt_holes_in_scan_frame=gt_holes_scan,
panel_deflection_R=R_panel,
panel_deflection_theta_right_deg=th_right,
panel_deflection_theta_ref_deg=th_ref,
gt_holes_after_deflection=gt_holes_after,
gt_normal_after_deflection=gt_normal_after,
info=info,
)
# ---------------------------------------------------------------------------
# Stage 3 — denoise
# ---------------------------------------------------------------------------
def denoise_cloud(
points: np.ndarray,
denoise_cfg: dict[str, Any],
) -> DenoisedCloud:
pts, log = run_denoise(points, denoise_cfg.get("steps", []))
return DenoisedCloud(points=pts, log=log)
# ---------------------------------------------------------------------------
# Stage 4 — register (4-hole Kabsch)
# ---------------------------------------------------------------------------
def register_cloud(
points: np.ndarray,
*,
target_holes: HoleSet,
detection_cfg: dict[str, Any],
cad_cfg: dict[str, Any],
registration_cfg: dict[str, Any],
panel_normal_seed: np.ndarray | None = None,
) -> RegisteredCloud:
"""Detect 4 holes in ``points`` and Kabsch-align them to ``target_holes``.
``target_holes`` is the GT (CAD-frame) hole set. The output cloud is
in the CAD frame. The 4-hole pairing is canonical-quadrant-ordered
on both sides, so no greedy NN matching is needed.
"""
detection = detect_four_holes(
points,
detection_cfg=detection_cfg,
cad_cfg=cad_cfg,
panel_normal_seed=panel_normal_seed,
)
src = detection.holes_3d.locating_centers()
tgt = target_holes.locating_centers()
kabsch = register_four_holes(
src,
tgt,
refuse_above_mm=registration_cfg.get("refuse_if_residual_above_mm"),
)
R = kabsch.matrix[:3, :3]
t = kabsch.matrix[:3, 3]
aligned = points @ R.T + t
detected_in_cad = HoleSet(
locating=tuple( # type: ignore[arg-type]
Hole(
center=tuple((R @ np.asarray(h.center) + t).tolist()),
radius_mm=h.radius_mm,
fit_residual_rms_mm=h.fit_residual_rms_mm,
role=h.role,
)
for h in detection.holes_3d.locating
),
central=(
None
if detection.holes_3d.central is None
else Hole(
center=tuple(
(R @ np.asarray(detection.holes_3d.central.center) + t).tolist()
),
radius_mm=detection.holes_3d.central.radius_mm,
fit_residual_rms_mm=detection.holes_3d.central.fit_residual_rms_mm,
role="central",
)
),
)
return RegisteredCloud(
points=aligned,
transform_4x4=kabsch.matrix,
rmse_mm=kabsch.rmse_mm,
max_residual_mm=kabsch.max_residual_mm,
detected_holes_in_cad_frame=detected_in_cad,
)
# ---------------------------------------------------------------------------
# End-to-end convenience: STP → PreparedCloud
# ---------------------------------------------------------------------------
def prepare_cloud_from_cad(
stp_path: str | Path,
config: str | Path | dict[str, Any] | None = None,
profile: str = "auto",
) -> PreparedCloud:
"""Run all four stages and return a :class:`PreparedCloud`.
The returned cloud is denoised and aligned into the CAD frame; pass
``prepared.points`` to
:func:`coupler_panel.api.compute_coupler_measurement`.
"""
cfg = load_config(config, profile=profile)
cfg = resolve_auto(cfg)
parsed = parse_cad(stp_path, cfg["cad"])
synth = synthesize_cloud(
parsed,
sampling_cfg=cfg["sampling"],
noise_cfg=cfg.get("noise", {}),
transform_cfg=cfg.get("transform", {}),
scan_cfg=cfg.get("scan_simulator", {}),
deflection_cfg=cfg.get("panel_deflection", {}),
)
denoised = denoise_cloud(synth.transformed, cfg.get("denoise", {}))
# Important: register against the *deflected* GT — the holes have moved
# with the panel, so Kabsch only undoes the scan-pose jitter, NOT the
# injected deflection. The plane fit downstream then sees the tilted
# panel and can recover theta_right / theta_ref.
registered = register_cloud(
denoised.points,
target_holes=synth.gt_holes_after_deflection,
detection_cfg=cfg["detection"],
cad_cfg=cfg["cad"],
registration_cfg=cfg["registration"],
)
return PreparedCloud(
points=registered.points,
gt=parsed.gt,
gt_transform_4x4=synth.gt_transform_4x4,
registration_transform_4x4=registered.transform_4x4,
registration_rmse_mm=registered.rmse_mm,
detected_holes_in_cad_frame=registered.detected_holes_in_cad_frame,
intermediates={
"config": cfg,
"sampled": synth.sampled,
"noisy": synth.noisy,
"transformed": synth.transformed,
"denoised": denoised.points,
"denoise_log": denoised.log,
},
)
def prepare_cloud_from_scan(
points: np.ndarray,
*,
config: str | Path | dict[str, Any] | None = None,
profile: str = "auto",
target_holes: HoleSet | None = None,
) -> PreparedCloud:
"""Same as :func:`prepare_cloud_from_cad` but starting from a real scan.
Skips parsing / synthesis. If ``target_holes`` is given, runs the
4-hole Kabsch registration; otherwise assumes the cloud is already
in the CAD frame.
"""
cfg = load_config(config, profile=profile)
cfg = resolve_auto(cfg)
denoised = denoise_cloud(np.asarray(points, dtype=np.float64), cfg.get("denoise", {}))
if target_holes is None:
return PreparedCloud(
points=denoised.points,
gt=None,
gt_transform_4x4=None,
registration_transform_4x4=None,
registration_rmse_mm=None,
detected_holes_in_cad_frame=None,
intermediates={"config": cfg, "denoise_log": denoised.log},
)
registered = register_cloud(
denoised.points,
target_holes=target_holes,
detection_cfg=cfg["detection"],
cad_cfg=cfg["cad"],
registration_cfg=cfg["registration"],
)
return PreparedCloud(
points=registered.points,
gt=None,
gt_transform_4x4=None,
registration_transform_4x4=registered.transform_4x4,
registration_rmse_mm=registered.rmse_mm,
detected_holes_in_cad_frame=registered.detected_holes_in_cad_frame,
intermediates={
"config": cfg,
"denoised": denoised.points,
"denoise_log": denoised.log,
},
)
__all__ = [
"CADParsed",
"SynthesizedCloud",
"DenoisedCloud",
"RegisteredCloud",
"PreparedCloud",
"parse_cad",
"synthesize_cloud",
"denoise_cloud",
"register_cloud",
"prepare_cloud_from_cad",
"prepare_cloud_from_scan",
]

View File

@@ -0,0 +1,29 @@
"""Composable point cloud denoising pipeline.
Each step is a pure function ``points -> points``. Steps are configured
via YAML and chained by :func:`run_pipeline`.
Available steps (matching ``denoise.steps[].type``):
- ``passthrough`` — keep points inside an axis-aligned bounding box
- ``voxel`` — voxel-grid downsample
- ``statistical`` — statistical outlier removal (SOR)
- ``radius`` — radius outlier removal (ROR)
- ``smoothing`` — MLS smoothing (only when explicitly enabled)
"""
from .passthrough import passthrough_filter
from .voxel import voxel_downsample
from .statistical import statistical_outlier_removal
from .radius import radius_outlier_removal
from .smoothing import mls_smoothing
from .pipeline import run_pipeline
__all__ = [
"passthrough_filter",
"voxel_downsample",
"statistical_outlier_removal",
"radius_outlier_removal",
"mls_smoothing",
"run_pipeline",
]

View File

@@ -0,0 +1,41 @@
"""Passthrough (axis-aligned bounding-box) filter."""
from __future__ import annotations
import numpy as np
def passthrough_filter(
points: np.ndarray,
*,
bbox: tuple[tuple[float, float], tuple[float, float], tuple[float, float]] | None = None,
bbox_padding_mm: float | None = None,
) -> np.ndarray:
"""Keep only points inside ``bbox``.
``bbox`` is ``((xmin, xmax), (ymin, ymax), (zmin, zmax))``.
If ``bbox`` is ``None`` and ``bbox_padding_mm`` is given, the bbox is
derived from the cloud itself, padded by ``bbox_padding_mm`` on each
side — which is a useful no-op default that nonetheless drops far-out
NaNs / spurious points.
"""
pts = np.asarray(points, dtype=np.float64)
if bbox is None:
if bbox_padding_mm is None:
return pts.copy()
pad = float(bbox_padding_mm)
lo = pts.min(axis=0) - pad
hi = pts.max(axis=0) + pad
bbox = ((lo[0], hi[0]), (lo[1], hi[1]), (lo[2], hi[2]))
(xlo, xhi), (ylo, yhi), (zlo, zhi) = bbox
mask = (
(pts[:, 0] >= xlo) & (pts[:, 0] <= xhi)
& (pts[:, 1] >= ylo) & (pts[:, 1] <= yhi)
& (pts[:, 2] >= zlo) & (pts[:, 2] <= zhi)
)
return pts[mask]
__all__ = ["passthrough_filter"]

View File

@@ -0,0 +1,71 @@
"""Compose denoising steps from a YAML-style step list.
Example config::
denoise:
steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5
- type: statistical
nb_neighbors: 30
std_ratio: 2.0
"""
from __future__ import annotations
from typing import Any, Callable
import numpy as np
from .passthrough import passthrough_filter
from .radius import radius_outlier_removal
from .smoothing import mls_smoothing
from .statistical import statistical_outlier_removal
from .voxel import voxel_downsample
_STEP_FNS: dict[str, Callable[..., np.ndarray]] = {
"passthrough": passthrough_filter,
"voxel": voxel_downsample,
"statistical": statistical_outlier_removal,
"radius": radius_outlier_removal,
"smoothing": mls_smoothing,
}
def run_pipeline(
points: np.ndarray,
steps: list[dict[str, Any]] | None,
*,
on_step: Callable[[str, np.ndarray], None] | None = None,
) -> tuple[np.ndarray, list[dict[str, Any]]]:
"""Apply each step in order and return the final cloud + a per-step log.
Each log entry is::
{"type": ..., "params": {...}, "n_in": int, "n_out": int}
"""
pts = np.asarray(points, dtype=np.float64)
log: list[dict[str, Any]] = []
if not steps:
return pts.copy(), log
for raw in steps:
cfg = dict(raw)
kind = cfg.pop("type")
if kind not in _STEP_FNS:
raise ValueError(f"unknown denoise step '{kind}'; valid: {sorted(_STEP_FNS)}")
fn = _STEP_FNS[kind]
n_in = pts.shape[0]
pts = fn(pts, **cfg)
log.append(
{"type": kind, "params": cfg, "n_in": n_in, "n_out": int(pts.shape[0])}
)
if on_step is not None:
on_step(kind, pts)
return pts, log
__all__ = ["run_pipeline"]

View File

@@ -0,0 +1,38 @@
"""Radius outlier removal (ROR).
A point is kept iff at least ``nb_points`` other points lie within
``radius_mm``. Useful for getting rid of spray noise in scanner data.
"""
from __future__ import annotations
import numpy as np
def radius_outlier_removal(
points: np.ndarray,
*,
nb_points: int = 8,
radius_mm: float = 2.0,
) -> np.ndarray:
pts = np.asarray(points, dtype=np.float64)
n = pts.shape[0]
if n == 0:
return pts.copy()
try:
from scipy.spatial import cKDTree
tree = cKDTree(pts)
counts = tree.query_ball_point(pts, r=float(radius_mm), return_length=True)
# `counts` includes the point itself; threshold accordingly.
keep = counts >= int(nb_points) + 1
except ImportError:
diff = pts[:, None, :] - pts[None, :, :]
d = np.sqrt(np.sum(diff * diff, axis=2))
keep = (d <= float(radius_mm)).sum(axis=1) >= int(nb_points) + 1
return pts[keep]
__all__ = ["radius_outlier_removal"]

View File

@@ -0,0 +1,84 @@
"""Optional moving-least-squares (MLS) smoothing.
For each point, fit a local polynomial in the neighbour set's tangent
plane and project the point onto it. Implementation here is a degree-2
local fit using only numpy + scipy.cKDTree. For most coupler-panel work
this stage is *off* by default — leave it disabled unless you actually
need smoothing in addition to SOR/ROR.
"""
from __future__ import annotations
import numpy as np
def mls_smoothing(
points: np.ndarray,
*,
search_radius_mm: float = 2.0,
polynomial_order: int = 2,
min_neighbors: int = 8,
) -> np.ndarray:
pts = np.asarray(points, dtype=np.float64)
n = pts.shape[0]
if n == 0:
return pts.copy()
try:
from scipy.spatial import cKDTree
except ImportError:
# Without a tree this is hopelessly slow; skip safely.
return pts.copy()
tree = cKDTree(pts)
out = pts.copy()
for i in range(n):
idx = tree.query_ball_point(pts[i], r=float(search_radius_mm))
if len(idx) < min_neighbors:
continue
nbh = pts[idx]
center = nbh.mean(axis=0)
# Local frame from PCA on the neighbourhood.
Q = nbh - center
cov = (Q.T @ Q) / max(len(idx) - 1, 1)
w, V = np.linalg.eigh(cov)
# Smallest eigenvector ≈ surface normal; the other two span the tangent.
normal = V[:, 0]
tangents = V[:, 1:]
local_xy = Q @ tangents # (k, 2)
local_z = Q @ normal # (k,)
if polynomial_order == 1:
A = np.column_stack([np.ones(len(idx)), local_xy])
else:
x, y = local_xy[:, 0], local_xy[:, 1]
A = np.column_stack([np.ones(len(idx)), x, y, x * x, x * y, y * y])
try:
coef, *_ = np.linalg.lstsq(A, local_z, rcond=None)
except np.linalg.LinAlgError:
continue
# Project pts[i] onto the surface in local frame.
rel = pts[i] - center
xy_i = np.array([rel @ tangents[:, 0], rel @ tangents[:, 1]])
if polynomial_order == 1:
z_i = coef[0] + coef[1] * xy_i[0] + coef[2] * xy_i[1]
else:
z_i = (
coef[0]
+ coef[1] * xy_i[0]
+ coef[2] * xy_i[1]
+ coef[3] * xy_i[0] ** 2
+ coef[4] * xy_i[0] * xy_i[1]
+ coef[5] * xy_i[1] ** 2
)
out[i] = center + tangents[:, 0] * xy_i[0] + tangents[:, 1] * xy_i[1] + normal * z_i
return out
__all__ = ["mls_smoothing"]

View File

@@ -0,0 +1,44 @@
"""Statistical outlier removal (SOR).
For each point, compute the mean distance to its ``k`` nearest neighbours.
A point whose mean distance exceeds the global mean by ``std_ratio`` ×
the global std is dropped.
"""
from __future__ import annotations
import numpy as np
def statistical_outlier_removal(
points: np.ndarray,
*,
nb_neighbors: int = 30,
std_ratio: float = 2.0,
) -> np.ndarray:
pts = np.asarray(points, dtype=np.float64)
n = pts.shape[0]
if n <= nb_neighbors + 1:
return pts.copy()
try:
from scipy.spatial import cKDTree
tree = cKDTree(pts)
dist, _ = tree.query(pts, k=nb_neighbors + 1)
# First column is the point itself (distance 0).
mean_d = dist[:, 1:].mean(axis=1)
except ImportError:
# Plain numpy fallback (O(N²); fine for N up to a few thousand).
diff = pts[:, None, :] - pts[None, :, :]
d = np.sqrt(np.sum(diff * diff, axis=2))
d.sort(axis=1)
mean_d = d[:, 1 : nb_neighbors + 1].mean(axis=1)
mu = mean_d.mean()
sigma = mean_d.std()
keep = mean_d <= mu + float(std_ratio) * sigma
return pts[keep]
__all__ = ["statistical_outlier_removal"]

View File

@@ -0,0 +1,37 @@
"""Voxel-grid downsampling."""
from __future__ import annotations
import numpy as np
def voxel_downsample(points: np.ndarray, *, voxel_mm: float) -> np.ndarray:
"""Replace each occupied voxel with the centroid of its points."""
if voxel_mm <= 0:
raise ValueError("voxel_mm must be > 0")
pts = np.asarray(points, dtype=np.float64)
if pts.shape[0] == 0:
return pts.copy()
keys = np.floor(pts / float(voxel_mm)).astype(np.int64)
# Compose a single 1-D key for fast unique() grouping.
kmin = keys.min(axis=0)
keys = keys - kmin
span = keys.max(axis=0) + 1
flat = keys[:, 0] + span[0] * (keys[:, 1] + span[1] * keys[:, 2])
order = np.argsort(flat, kind="stable")
flat_sorted = flat[order]
pts_sorted = pts[order]
# Group by run-length on the sorted flat keys.
boundaries = np.concatenate(([0], np.where(np.diff(flat_sorted) != 0)[0] + 1, [pts.shape[0]]))
n_groups = len(boundaries) - 1
out = np.empty((n_groups, 3), dtype=np.float64)
for i in range(n_groups):
s, e = boundaries[i], boundaries[i + 1]
out[i] = pts_sorted[s:e].mean(axis=0)
return out
__all__ = ["voxel_downsample"]

View File

@@ -0,0 +1,36 @@
"""Four-hole detection on a panel point cloud.
Pipeline (see ``detect_four_holes``)::
1. plane_local.project_to_panel(...) # cloud -> 2D coords
2. occupancy_grid.build(...) # 2D grid + connected components
3. occupancy_grid.find_holes(...) # holes (cavities) inside main panel CC
4. circle_fit.fit_circle(...) # boundary point fit
5. four_hole_selector.select(...) # keep the 4 best, ordered
"""
from .plane_local import LocalPanelFrame, project_to_panel, lift_back
from .circle_fit import fit_circle, fit_circle_lsq
from .occupancy_grid import (
OccupancyGrid,
HoleCandidate,
build_grid,
find_panel_holes,
)
from .four_hole_selector import select_four_holes
from .detect_holes import detect_four_holes, FourHoleDetection
__all__ = [
"LocalPanelFrame",
"project_to_panel",
"lift_back",
"fit_circle",
"fit_circle_lsq",
"OccupancyGrid",
"HoleCandidate",
"build_grid",
"find_panel_holes",
"select_four_holes",
"detect_four_holes",
"FourHoleDetection",
]

View File

@@ -0,0 +1,106 @@
"""2D circle fitting.
Two routines:
- :func:`fit_circle_algebraic` — Pratt-style algebraic fit (closed-form).
Fast, biased, but a great initial guess.
- :func:`fit_circle_lsq` — Levenberg-Marquardt geometric fit.
Unbiased, requires an initial guess.
- :func:`fit_circle` — algebraic → geometric, returns the result
along with the per-point residuals.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class CircleFit:
cx: float
cy: float
radius: float
residual_rms_mm: float
residual_max_mm: float
n_points: int
def fit_circle_algebraic(uv: np.ndarray) -> tuple[float, float, float]:
"""Return (cx, cy, radius) by Pratt's algebraic fit."""
if uv.shape[0] < 3:
raise ValueError("need ≥ 3 points to fit a circle")
x = uv[:, 0]
y = uv[:, 1]
A = np.column_stack([2.0 * x, 2.0 * y, np.ones_like(x)])
b = x ** 2 + y ** 2
sol, *_ = np.linalg.lstsq(A, b, rcond=None)
cx, cy, c = sol
r2 = c + cx ** 2 + cy ** 2
if r2 <= 0:
raise ValueError("algebraic fit produced non-positive squared radius")
return float(cx), float(cy), float(np.sqrt(r2))
def fit_circle_lsq(
uv: np.ndarray,
*,
init: tuple[float, float, float] | None = None,
max_iter: int = 30,
tol: float = 1e-8,
) -> tuple[float, float, float]:
"""Geometric (orthogonal-residual) least-squares circle fit.
Uses Gauss–Newton on f_i(p) = sqrt((x_i-cx)² + (y_i-cy)²) - r.
"""
if init is None:
cx, cy, r = fit_circle_algebraic(uv)
else:
cx, cy, r = init
x = uv[:, 0]
y = uv[:, 1]
for _ in range(max_iter):
dx = x - cx
dy = y - cy
d = np.sqrt(dx * dx + dy * dy)
d_safe = np.where(d > 1e-12, d, 1e-12)
# Residual.
f = d - r
# Jacobian wrt (cx, cy, r).
J = np.column_stack([-dx / d_safe, -dy / d_safe, -np.ones_like(d)])
try:
delta, *_ = np.linalg.lstsq(J, -f, rcond=None)
except np.linalg.LinAlgError:
break
cx += delta[0]
cy += delta[1]
r += delta[2]
if np.max(np.abs(delta)) < tol:
break
return float(cx), float(cy), float(r)
def fit_circle(uv: np.ndarray, *, refine: bool = True) -> CircleFit:
cx, cy, r = fit_circle_algebraic(uv)
if refine:
cx, cy, r = fit_circle_lsq(uv, init=(cx, cy, r))
d = np.sqrt((uv[:, 0] - cx) ** 2 + (uv[:, 1] - cy) ** 2)
res = d - r
return CircleFit(
cx=cx,
cy=cy,
radius=r,
residual_rms_mm=float(np.sqrt(np.mean(res ** 2))),
residual_max_mm=float(np.max(np.abs(res))),
n_points=int(uv.shape[0]),
)
__all__ = [
"CircleFit",
"fit_circle",
"fit_circle_algebraic",
"fit_circle_lsq",
]

View File

@@ -0,0 +1,154 @@
"""High-level orchestration: cloud → 4 ordered hole centres in 3D."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import numpy as np
from ..exceptions import HoleDetectionError
from ..structures import Hole, HoleSet
from .circle_fit import fit_circle
from .four_hole_selector import CircleCandidate, select_four_holes
from .occupancy_grid import build_grid, find_panel_holes
from .plane_local import LocalPanelFrame, project_to_panel
@dataclass
class FourHoleDetection:
holes_3d: HoleSet
frame: LocalPanelFrame
candidates: list[CircleCandidate]
diagnostics: dict[str, Any]
def detect_four_holes(
points: np.ndarray,
*,
detection_cfg: dict[str, Any],
cad_cfg: dict[str, Any],
panel_normal_seed: np.ndarray | None = None,
) -> FourHoleDetection:
"""Detect the four locating-hole centres in 3D from a panel point cloud."""
pts = np.asarray(points, dtype=np.float64)
if pts.shape[0] < 100:
raise HoleDetectionError(f"too few points for hole detection: {pts.shape[0]}")
# 1. Project onto local panel plane.
seed_mode = detection_cfg.get("panel_normal_seed", "from_cad")
if panel_normal_seed is not None and seed_mode != "pca":
seed = np.asarray(panel_normal_seed, dtype=np.float64)
else:
seed = "pca"
frame, uv = project_to_panel(pts, normal_seed=seed)
# 2. Build 2D occupancy grid.
pixel_mm = float(detection_cfg["grid_pixel_mm"])
closing_iters = int(detection_cfg.get("closing_iters", 0))
fill_bg_below = float(detection_cfg.get("fill_bg_below_mm2", 0.0))
grid = build_grid(
uv,
pixel_mm=pixel_mm,
closing_iters=closing_iters,
fill_bg_below_mm2=fill_bg_below,
)
# 3. Find panel + hole cavities.
panel_mask, holes = find_panel_holes(
grid,
panel_min_area_ratio=float(detection_cfg.get("panel_min_area_ratio", 0.4)),
hole_min_area_mm2=float(detection_cfg.get("hole_min_area_mm2", 30.0)),
hole_max_area_mm2=float(detection_cfg.get("hole_max_area_mm2", 800.0)),
point_uv=uv,
)
if len(holes) < 4:
raise HoleDetectionError(
f"only {len(holes)} hole-shaped cavities found in occupancy grid; "
"consider lowering grid_pixel_mm or relaxing hole_*_area_mm2"
)
# 4. Fit a circle to each cavity boundary.
candidates: list[CircleCandidate] = []
for h in holes:
try:
cf = fit_circle(h.boundary_uv)
except Exception:
continue
candidates.append(
CircleCandidate(
fit=cf,
centroid_uv=h.centroid_uv,
area_mm2=h.area_mm2,
n_boundary_points=int(h.boundary_uv.shape[0]),
)
)
if len(candidates) < 4:
raise HoleDetectionError(
f"only {len(candidates)} circles fitted (need ≥ 4)"
)
# 5. Pick the 4 most rectangle-like, equal-radius circles.
# Detection can override the CAD-side radius range (rasterisation
# biases the fitted radius outwards).
r_lo, r_hi = detection_cfg.get(
"locating_hole_radius_mm",
cad_cfg["locating_hole_radius_mm"],
)
ordered, sel_idx, diag = select_four_holes(
candidates,
target_radius_lo_mm=float(r_lo),
target_radius_hi_mm=float(r_hi),
rectangle_aspect_tol=float(
detection_cfg.get(
"rectangle_aspect_tol",
cad_cfg.get("rectangle_aspect_tol", 0.20),
)
),
)
# 6. Lift the four hole centres back to 3D (signed_distance = 0).
centers_uv = np.array([(c.fit.cx, c.fit.cy) for c in ordered])
centers_3d = frame.from_2d(centers_uv, signed_distance=0.0)
# 7. Identify a central hole (radius ≥ central_hole_radius_min_mm).
central_hole = None
central_min = float(cad_cfg.get("central_hole_radius_min_mm", 20.0))
for cand in candidates:
if cand.fit.radius >= central_min:
uv_c = np.array([[cand.fit.cx, cand.fit.cy]])
xyz_c = frame.from_2d(uv_c, signed_distance=0.0)[0]
central_hole = Hole(
center=tuple(xyz_c.tolist()),
radius_mm=float(cand.fit.radius),
fit_residual_rms_mm=float(cand.fit.residual_rms_mm),
role="central",
)
break
holes_3d = HoleSet(
locating=tuple( # type: ignore[arg-type]
Hole(
center=tuple(centers_3d[i].tolist()),
radius_mm=float(c.fit.radius),
fit_residual_rms_mm=float(c.fit.residual_rms_mm),
role="locating",
)
for i, c in enumerate(ordered)
),
central=central_hole,
)
return FourHoleDetection(
holes_3d=holes_3d,
frame=frame,
candidates=candidates,
diagnostics={
"n_cavities": len(holes),
"n_circle_fits": len(candidates),
"selection": diag,
},
)
__all__ = ["FourHoleDetection", "detect_four_holes"]

View File

@@ -0,0 +1,145 @@
"""Pick four locating holes out of a list of fitted-circle candidates.
Given more than four candidates, choose the 4-subset that:
1. Has the smallest standard deviation of radius (the four locating
holes are equal-radius by spec).
2. Forms a near-rectangle (small `rectangle_score`, see CAD GT module).
3. Each radius is within ``[r_lo, r_hi]``.
We score the cartesian product of "best radii" and "best rectangle"
jointly, weighted.
"""
from __future__ import annotations
from dataclasses import dataclass
from itertools import combinations
import numpy as np
from .circle_fit import CircleFit
@dataclass
class CircleCandidate:
"""A candidate locating hole, in the panel's local 2D frame."""
fit: CircleFit
centroid_uv: tuple[float, float] # from occupancy grid (used as initial guess)
area_mm2: float
n_boundary_points: int
def _rectangle_score(centers_uv: np.ndarray) -> float:
"""0 = perfect rectangle. Larger = worse."""
if centers_uv.shape[0] != 4:
return float("inf")
d = sorted(
float(np.linalg.norm(centers_uv[i] - centers_uv[j]))
for i, j in combinations(range(4), 2)
)
if d[0] < 1e-6:
return 1e6
s1, s2 = d[0], d[1]
l1, l2 = d[2], d[3]
g1, g2 = d[4], d[5]
return (
abs(s1 - s2) / max(s1, s2)
+ abs(l1 - l2) / max(l1, l2)
+ abs(g1 - g2) / max(g1, g2)
)
def _radius_score(radii: np.ndarray, target_lo: float, target_hi: float) -> float:
if np.any(radii < target_lo) or np.any(radii > target_hi):
return float("inf")
if radii.mean() <= 0:
return float("inf")
return float(radii.std() / max(radii.mean(), 1e-6))
def _order_quadrants(centers_uv: np.ndarray, candidates) -> tuple[list, list[int]]:
"""Order 4 candidates as (-u/-v, +u/-v, +u/+v, -u/+v) in the local frame."""
cu = centers_uv[:, 0].mean()
cv = centers_uv[:, 1].mean()
quadrant: dict[tuple[bool, bool], int] = {}
for idx, p in enumerate(centers_uv):
quadrant[(p[0] >= cu, p[1] >= cv)] = idx
order = [(False, False), (True, False), (True, True), (False, True)]
if not all(q in quadrant for q in order):
# Degenerate, fall back to angular sort.
ang = np.arctan2(centers_uv[:, 1] - cv, centers_uv[:, 0] - cu)
idxs = np.argsort(ang).tolist()
return [candidates[i] for i in idxs], idxs
idxs = [quadrant[q] for q in order]
return [candidates[i] for i in idxs], idxs
def select_four_holes(
candidates: list[CircleCandidate],
*,
target_radius_lo_mm: float,
target_radius_hi_mm: float,
rectangle_aspect_tol: float = 0.20,
radius_weight: float = 1.0,
rectangle_weight: float = 1.0,
) -> tuple[list[CircleCandidate], list[int], dict]:
"""Pick the best 4-subset; return (ordered subset, indices into input, diag).
Raises ``RuntimeError`` if no valid 4-subset exists.
"""
n = len(candidates)
if n < 4:
raise RuntimeError(f"need ≥ 4 candidates, got {n}")
best_idx: tuple[int, ...] | None = None
best_score = float("inf")
best_diag: dict = {}
for combo in combinations(range(n), 4):
radii = np.array([candidates[i].fit.radius for i in combo])
rs = _radius_score(radii, target_radius_lo_mm, target_radius_hi_mm)
if not np.isfinite(rs):
continue
centers = np.array([candidates[i].fit.cx for i in combo]), np.array(
[candidates[i].fit.cy for i in combo]
)
centers_uv = np.column_stack(centers)
rect = _rectangle_score(centers_uv)
score = radius_weight * rs + rectangle_weight * rect
if score < best_score:
best_score = score
best_idx = combo
best_diag = {
"radius_score": rs,
"rectangle_score": rect,
"score": score,
"radii_mean": float(radii.mean()),
"radii_std": float(radii.std()),
}
if best_idx is None:
raise RuntimeError(
"no valid 4-subset (radius constraints excluded all combinations); "
f"target_radius=[{target_radius_lo_mm}, {target_radius_hi_mm}], "
f"got {sorted(round(c.fit.radius, 3) for c in candidates)}"
)
subset = [candidates[i] for i in best_idx]
centers_uv = np.array([(c.fit.cx, c.fit.cy) for c in subset])
ordered, perm = _order_quadrants(centers_uv, subset)
final_idx = [best_idx[p] for p in perm]
best_diag["selected_indices"] = list(final_idx)
best_diag["selected_radii"] = [c.fit.radius for c in ordered]
if best_diag["rectangle_score"] > rectangle_aspect_tol:
best_diag.setdefault("warnings", []).append("rectangle_score_above_tol")
return ordered, final_idx, best_diag
__all__ = [
"CircleCandidate",
"select_four_holes",
]

View File

@@ -0,0 +1,350 @@
"""2D occupancy grid + connected-component analysis on panel-projected points.
Steps used by :func:`detect_four_holes`:
1. ``build_grid`` — rasterise (u, v) points to a binary grid.
2. ``find_panel_holes`` — find the largest connected component (= panel),
then enumerate its internal cavities (= holes). Each cavity gets its
boundary points (in the original 3D order) so we can fit a circle.
Implementation is pure numpy + a small flood-fill — no scikit-image
dependency.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class OccupancyGrid:
occupied: np.ndarray # (H, W) bool
pixel_mm: float
u_min: float
v_min: float
point_indices: np.ndarray # (H, W, K_max)? No — see point_pixel_idx instead
point_pixel_idx: np.ndarray # (N, 2) int (row, col)
@dataclass(frozen=True)
class HoleCandidate:
"""One internal cavity of the panel CC."""
boundary_uv: np.ndarray # (M, 2) float — boundary pixel centres
boundary_point_idx: np.ndarray # (M,) int — indices into the original cloud, may be empty
centroid_uv: tuple[float, float]
area_mm2: float
bbox_uv: tuple[float, float, float, float] # (umin, vmin, umax, vmax)
# ---------------------------------------------------------------------------
def _binary_dilate(grid: np.ndarray, iters: int = 1) -> np.ndarray:
"""4-connected dilation, ``iters`` times. Pure numpy."""
if iters <= 0:
return grid.copy()
out = grid.copy()
for _ in range(iters):
nxt = out.copy()
nxt[1:, :] |= out[:-1, :]
nxt[:-1, :] |= out[1:, :]
nxt[:, 1:] |= out[:, :-1]
nxt[:, :-1] |= out[:, 1:]
out = nxt
return out
def _binary_erode(grid: np.ndarray, iters: int = 1) -> np.ndarray:
if iters <= 0:
return grid.copy()
inv = ~grid
inv = _binary_dilate(inv, iters)
return ~inv
def _binary_close(grid: np.ndarray, iters: int = 1) -> np.ndarray:
"""Close = dilate then erode. Fills small gaps without growing the mask."""
return _binary_erode(_binary_dilate(grid, iters), iters)
def _fill_small_bg_components(grid: np.ndarray, min_keep_area_px: int) -> np.ndarray:
"""Set every 0-component smaller than ``min_keep_area_px`` to 1.
Equivalent to "area opening of the background" — fills sparse-coverage
gaps without shrinking the real cavities the caller wants to detect.
Border-touching background components are always kept (they're outside
the panel, not real holes).
"""
H, W = grid.shape
bg_labels = _flood_fill_label(~grid)
if bg_labels.max() == 0:
return grid.copy()
# Identify border components.
border = set()
for j in range(W):
if bg_labels[0, j] != 0:
border.add(int(bg_labels[0, j]))
if bg_labels[H - 1, j] != 0:
border.add(int(bg_labels[H - 1, j]))
for i in range(H):
if bg_labels[i, 0] != 0:
border.add(int(bg_labels[i, 0]))
if bg_labels[i, W - 1] != 0:
border.add(int(bg_labels[i, W - 1]))
counts = np.bincount(bg_labels.ravel())
out = grid.copy()
fill_labels = np.zeros(len(counts) + 1, dtype=bool)
for k in range(1, len(counts)):
if k in border:
continue
if counts[k] < min_keep_area_px:
fill_labels[k] = True
fill_mask = fill_labels[bg_labels]
out[fill_mask] = True
return out
def build_grid(
uv: np.ndarray,
*,
pixel_mm: float,
pad_pixels: int = 4,
closing_iters: int = 0,
fill_bg_below_mm2: float = 0.0,
) -> OccupancyGrid:
"""Rasterise points to a binary grid.
``closing_iters`` is the number of binary-closing passes applied as a
cheap defragmenter (also shrinks real holes by ``iters`` pixels — use
sparingly).
``fill_bg_below_mm2`` is the preferred defragmenter: it fills every
background component smaller than this area (in mm²) with foreground.
Real cavities (holes the caller wants to detect) keep their original
size because they exceed the threshold.
"""
if uv.shape[0] == 0:
raise ValueError("empty point set")
u_min = float(uv[:, 0].min()) - pad_pixels * pixel_mm
v_min = float(uv[:, 1].min()) - pad_pixels * pixel_mm
u_max = float(uv[:, 0].max()) + pad_pixels * pixel_mm
v_max = float(uv[:, 1].max()) + pad_pixels * pixel_mm
W = int(np.ceil((u_max - u_min) / pixel_mm)) + 1
H = int(np.ceil((v_max - v_min) / pixel_mm)) + 1
cols = np.clip(((uv[:, 0] - u_min) / pixel_mm).astype(np.int64), 0, W - 1)
rows = np.clip(((uv[:, 1] - v_min) / pixel_mm).astype(np.int64), 0, H - 1)
occ = np.zeros((H, W), dtype=bool)
occ[rows, cols] = True
if closing_iters > 0:
occ = _binary_close(occ, closing_iters)
if fill_bg_below_mm2 > 0:
min_keep_px = max(1, int(np.ceil(float(fill_bg_below_mm2) / (pixel_mm ** 2))))
occ = _fill_small_bg_components(occ, min_keep_px)
return OccupancyGrid(
occupied=occ,
pixel_mm=float(pixel_mm),
u_min=u_min,
v_min=v_min,
point_indices=np.empty(0),
point_pixel_idx=np.column_stack([rows, cols]),
)
# ---------------------------------------------------------------------------
def _flood_fill_label(grid: np.ndarray) -> np.ndarray:
"""4-connected labelling. Background (False) gets label 0."""
H, W = grid.shape
labels = np.zeros((H, W), dtype=np.int32)
next_label = 0
stack: list[tuple[int, int]] = []
for i in range(H):
for j in range(W):
if grid[i, j] and labels[i, j] == 0:
next_label += 1
stack.append((i, j))
while stack:
r, c = stack.pop()
if r < 0 or r >= H or c < 0 or c >= W:
continue
if not grid[r, c] or labels[r, c] != 0:
continue
labels[r, c] = next_label
stack.append((r - 1, c))
stack.append((r + 1, c))
stack.append((r, c - 1))
stack.append((r, c + 1))
return labels
def _largest_component(labels: np.ndarray) -> int:
if labels.max() == 0:
return 0
counts = np.bincount(labels.ravel())
counts[0] = 0
return int(np.argmax(counts))
def _holes_inside(
labels: np.ndarray,
panel_label: int,
) -> list[np.ndarray]:
"""Return masks of background components fully enclosed by ``panel_label``.
A 'hole' is a 0-component that is not connected to the image border
via 4-connectivity through other 0-pixels.
"""
H, W = labels.shape
bg = labels == 0
# Label background components.
bg_labels = _flood_fill_label(bg)
# Components that touch the border.
border_components = set()
for j in range(W):
if bg_labels[0, j] != 0:
border_components.add(int(bg_labels[0, j]))
if bg_labels[H - 1, j] != 0:
border_components.add(int(bg_labels[H - 1, j]))
for i in range(H):
if bg_labels[i, 0] != 0:
border_components.add(int(bg_labels[i, 0]))
if bg_labels[i, W - 1] != 0:
border_components.add(int(bg_labels[i, W - 1]))
holes: list[np.ndarray] = []
n_bg = int(bg_labels.max())
for k in range(1, n_bg + 1):
if k in border_components:
continue
mask = bg_labels == k
# Sanity: at least one neighbouring pixel must belong to ``panel_label``.
# (Otherwise it's enclosed by a different fg component, which we ignore.)
# Quick neighbour test:
from numpy import roll
nbr = (
(roll(labels, 1, axis=0) == panel_label)
| (roll(labels, -1, axis=0) == panel_label)
| (roll(labels, 1, axis=1) == panel_label)
| (roll(labels, -1, axis=1) == panel_label)
)
if np.any(mask & nbr):
holes.append(mask)
return holes
def _hole_boundary_pixels(panel_mask: np.ndarray, hole_mask: np.ndarray) -> np.ndarray:
"""Pixels in ``panel_mask`` that touch a pixel in ``hole_mask``."""
from numpy import roll
nbr = (
roll(hole_mask, 1, axis=0)
| roll(hole_mask, -1, axis=0)
| roll(hole_mask, 1, axis=1)
| roll(hole_mask, -1, axis=1)
)
return panel_mask & nbr
def find_panel_holes(
grid: OccupancyGrid,
*,
panel_min_area_ratio: float = 0.4,
hole_min_area_mm2: float = 30.0,
hole_max_area_mm2: float = 800.0,
point_uv: np.ndarray | None = None,
) -> tuple[np.ndarray, list[HoleCandidate]]:
"""Return (panel_mask, list_of_holes).
``point_uv`` is optional but, if provided, lets us hand back the
*original-cloud* point indices that fall inside each hole's boundary
pixels (= proper boundary points for a 3D circle fit).
"""
H, W = grid.occupied.shape
fg_labels = _flood_fill_label(grid.occupied)
panel_label = _largest_component(fg_labels)
if panel_label == 0:
raise RuntimeError("no occupied pixels")
panel_mask = fg_labels == panel_label
panel_area = panel_mask.sum() * grid.pixel_mm ** 2
bbox_area = H * W * grid.pixel_mm ** 2
if panel_area / max(bbox_area, 1e-9) < panel_min_area_ratio:
# The panel CC is suspiciously small. We still proceed; downstream
# code will report a quality flag.
pass
holes_masks = _holes_inside(fg_labels, panel_label)
out: list[HoleCandidate] = []
# Pre-compute (row, col) → list of original-cloud indices, for boundary recovery.
pix_rc = grid.point_pixel_idx if point_uv is not None else None
for mask in holes_masks:
area = float(mask.sum() * grid.pixel_mm ** 2)
if area < hole_min_area_mm2 or area > hole_max_area_mm2:
continue
bnd_mask = _hole_boundary_pixels(panel_mask, mask)
if not bnd_mask.any():
continue
bnd_rc = np.argwhere(bnd_mask) # (M, 2): (row, col)
bnd_uv = np.column_stack(
[
grid.u_min + (bnd_rc[:, 1] + 0.5) * grid.pixel_mm,
grid.v_min + (bnd_rc[:, 0] + 0.5) * grid.pixel_mm,
]
)
rows = np.argwhere(mask)
cu = grid.u_min + (rows[:, 1] + 0.5).mean() * grid.pixel_mm
cv = grid.v_min + (rows[:, 0] + 0.5).mean() * grid.pixel_mm
bbox = (
grid.u_min + rows[:, 1].min() * grid.pixel_mm,
grid.v_min + rows[:, 0].min() * grid.pixel_mm,
grid.u_min + rows[:, 1].max() * grid.pixel_mm,
grid.v_min + rows[:, 0].max() * grid.pixel_mm,
)
if pix_rc is not None:
# Map boundary pixels back to point indices.
row_col_set = set(map(tuple, bnd_rc.tolist()))
point_idx = np.array(
[i for i, (r, c) in enumerate(map(tuple, pix_rc.tolist())) if (r, c) in row_col_set],
dtype=np.int64,
)
else:
point_idx = np.empty(0, dtype=np.int64)
out.append(
HoleCandidate(
boundary_uv=bnd_uv,
boundary_point_idx=point_idx,
centroid_uv=(float(cu), float(cv)),
area_mm2=area,
bbox_uv=bbox,
)
)
return panel_mask, out
__all__ = [
"OccupancyGrid",
"HoleCandidate",
"build_grid",
"find_panel_holes",
]

View File

@@ -0,0 +1,127 @@
"""Build a local 2D frame on the panel and project / lift between it and 3D.
The panel's normal is roughly along ±x in the CAD frame, but the point
cloud may be misaligned. We accept a ``normal_seed`` (from the GT or
from a robust PCA on the point cloud) and orthonormalise around it.
Local 2D conventions
--------------------
- ``u`` axis: closest to world +y inside the panel plane
- ``v`` axis: closest to world +z inside the panel plane
So a top-down picture of the panel has u → right, v → up, and matches
how the four locating holes are arranged in the y-z plane.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class LocalPanelFrame:
origin: np.ndarray # (3,) — point on plane (typically the centroid)
normal: np.ndarray # (3,) — unit, n_x > 0
u_axis: np.ndarray # (3,)
v_axis: np.ndarray # (3,)
def to_2d(self, points: np.ndarray) -> np.ndarray:
"""Project ``points`` onto the panel plane in (u, v)."""
rel = points - self.origin
return np.column_stack([rel @ self.u_axis, rel @ self.v_axis])
def signed_distance(self, points: np.ndarray) -> np.ndarray:
return (points - self.origin) @ self.normal
def from_2d(self, uv: np.ndarray, *, signed_distance: float = 0.0) -> np.ndarray:
"""Lift ``(u, v)`` coords back into 3D, optionally with a normal offset."""
uv = np.atleast_2d(uv)
return (
self.origin
+ uv[:, 0:1] * self.u_axis
+ uv[:, 1:2] * self.v_axis
+ float(signed_distance) * self.normal
)
def _orthonormal_basis(normal: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Return (u, v) so that {u, v, normal} is an RHC orthonormal basis,
with ``u`` the in-plane vector closest to world +y.
"""
n = normal / max(np.linalg.norm(normal), 1e-12)
y = np.array([0.0, 1.0, 0.0])
u = y - np.dot(y, n) * n
if np.linalg.norm(u) < 1e-6:
# n is parallel to y; pick z instead.
z = np.array([0.0, 0.0, 1.0])
u = z - np.dot(z, n) * n
u /= np.linalg.norm(u)
v = np.cross(n, u)
v /= np.linalg.norm(v)
return u, v
def _pca_normal(points: np.ndarray) -> np.ndarray:
centroid = points.mean(axis=0)
Q = points - centroid
cov = (Q.T @ Q) / max(points.shape[0] - 1, 1)
w, V = np.linalg.eigh(cov)
# Smallest eigenvector ≈ panel normal.
n = V[:, 0]
if n[0] < 0:
n = -n
return n
def project_to_panel(
points: np.ndarray,
*,
normal_seed: np.ndarray | str = "pca",
origin: np.ndarray | None = None,
pca_kth: int = 0,
) -> tuple[LocalPanelFrame, np.ndarray]:
"""Project ``points`` onto a local panel frame.
Returns
-------
frame : :class:`LocalPanelFrame`
uv : ``(N, 2)`` projected coordinates
Parameters
----------
normal_seed : str or (3,)
``"pca"`` → estimate via PCA on the cloud (smallest variance axis).
otherwise a 3-vector.
origin : (3,) or None
If ``None``, use the cloud centroid.
"""
pts = np.asarray(points, dtype=np.float64)
if isinstance(normal_seed, str):
if normal_seed == "pca":
normal = _pca_normal(pts)
else:
raise ValueError(f"unknown normal_seed string '{normal_seed}'")
else:
normal = np.asarray(normal_seed, dtype=np.float64).reshape(3)
normal = normal / max(np.linalg.norm(normal), 1e-12)
if normal[0] < 0:
normal = -normal
if origin is None:
origin = pts.mean(axis=0)
origin = np.asarray(origin, dtype=np.float64).reshape(3)
u, v = _orthonormal_basis(normal)
frame = LocalPanelFrame(origin=origin, normal=normal, u_axis=u, v_axis=v)
return frame, frame.to_2d(pts)
def lift_back(uv: np.ndarray, frame: LocalPanelFrame) -> np.ndarray:
return frame.from_2d(uv, signed_distance=0.0)
__all__ = ["LocalPanelFrame", "project_to_panel", "lift_back"]

View File

@@ -0,0 +1,55 @@
"""Public exception hierarchy for coupler_panel.
Catch :class:`CouplerError` for any algorithm failure that should be
surfaced to the user. Subclasses indicate what specifically went wrong so
callers can render targeted error messages.
Non-fatal data-quality issues are **not** raised; they are reported via
``CouplerMeasurement.quality_flags``.
"""
from __future__ import annotations
class CouplerError(Exception):
"""Base class for all errors raised by the coupler-panel pipeline."""
class InvalidPointCloudError(CouplerError):
"""Raised when the input point cloud is malformed or unreadable."""
class UnsupportedFormatError(InvalidPointCloudError):
"""Raised when the input file extension is not supported."""
class InsufficientPointsError(CouplerError):
"""Raised when the input has too few points to run the pipeline."""
class CADParseError(CouplerError):
"""Raised when the STP file cannot be parsed or has unexpected topology."""
class HoleDetectionError(CouplerError):
"""Raised when the four locating holes cannot be reliably detected."""
class RegistrationError(CouplerError):
"""Raised when 4-hole Kabsch registration fails (e.g. degenerate pairing)."""
class PlaneFitError(CouplerError):
"""Raised when the panel plane cannot be fitted (too few inliers, etc.)."""
__all__ = [
"CouplerError",
"InvalidPointCloudError",
"UnsupportedFormatError",
"InsufficientPointsError",
"CADParseError",
"HoleDetectionError",
"RegistrationError",
"PlaneFitError",
]

View File

@@ -0,0 +1,11 @@
"""Geometry construction & plane fitting for the coupler panel."""
from .reference import build_reference_frame
from .roi import extract_panel_roi
from .plane_fit import fit_panel_plane
__all__ = [
"build_reference_frame",
"extract_panel_roi",
"fit_panel_plane",
]

View File

@@ -0,0 +1,113 @@
"""Robust panel-plane fitting.
Pipeline:
1. RANSAC: pick 3 random points, build a plane, count inliers within
``inlier_thresh_mm``. Repeat ``ransac_iterations`` times; keep the
best inlier set.
2. SVD: refit the plane on the inliers (eigenvector with the smallest
eigenvalue of the centred covariance).
3. Sign convention: enforce ``n_x > 0``.
Returns a :class:`PanelPlane`.
"""
from __future__ import annotations
from typing import Any
import numpy as np
from ..exceptions import PlaneFitError
from ..structures import PanelPlane
def _plane_from_three(p1, p2, p3) -> tuple[np.ndarray, float] | None:
n = np.cross(p2 - p1, p3 - p1)
norm = np.linalg.norm(n)
if norm < 1e-9:
return None
n = n / norm
d = -float(np.dot(n, p1))
return n, d
def _svd_plane(points: np.ndarray) -> tuple[np.ndarray, float]:
centroid = points.mean(axis=0)
Q = points - centroid
cov = (Q.T @ Q) / max(points.shape[0] - 1, 1)
w, V = np.linalg.eigh(cov)
n = V[:, 0]
n = n / max(np.linalg.norm(n), 1e-12)
d = -float(np.dot(n, centroid))
return n, d
def fit_panel_plane(
points: np.ndarray,
*,
plane_fit_cfg: dict[str, Any],
seed: int | None = 7,
) -> PanelPlane:
pts = np.asarray(points, dtype=np.float64)
n_pts = pts.shape[0]
if n_pts < 3:
raise PlaneFitError(f"need ≥ 3 points, got {n_pts}")
iterations = int(plane_fit_cfg.get("ransac_iterations", 200))
thresh = plane_fit_cfg.get("ransac_inlier_thresh_mm", 1.5)
if thresh in (None, "auto"):
thresh = 1.5
thresh = float(thresh)
min_inlier_ratio = float(plane_fit_cfg.get("min_inlier_ratio", 0.6))
enforce_nx_positive = bool(plane_fit_cfg.get("enforce_nx_positive", True))
rng = np.random.default_rng(seed)
best_inliers: np.ndarray | None = None
best_count = -1
for _ in range(iterations):
sample = rng.choice(n_pts, size=3, replace=False)
plane = _plane_from_three(pts[sample[0]], pts[sample[1]], pts[sample[2]])
if plane is None:
continue
n, d = plane
dist = np.abs(pts @ n + d)
inliers = dist <= thresh
c = int(inliers.sum())
if c > best_count:
best_count = c
best_inliers = inliers
if best_inliers is None or best_count < 3:
raise PlaneFitError(
f"RANSAC found {best_count} inliers in {iterations} iters at "
f"thresh={thresh} mm; check noise level / threshold"
)
inlier_ratio = best_count / n_pts
inlier_pts = pts[best_inliers]
n, d = _svd_plane(inlier_pts)
if enforce_nx_positive and n[0] < 0:
n = -n
d = -d
residuals = inlier_pts @ n + d
rms = float(np.sqrt(np.mean(residuals ** 2)))
if inlier_ratio < min_inlier_ratio:
# Caller will log a quality flag; we still return the result.
pass
centroid = inlier_pts.mean(axis=0)
return PanelPlane(
normal=tuple(n.tolist()),
point_on_plane=tuple(centroid.tolist()),
d_offset=float(d),
inlier_ratio=float(inlier_ratio),
inlier_residual_rms_mm=rms,
)
__all__ = ["fit_panel_plane"]

View File

@@ -0,0 +1,32 @@
"""Construct the reference frame from four hole centres.
Given the four locating-hole centres :math:`H_1 .. H_4`:
- Panel centre :math:`C` = mean of the four centres (= diagonal
intersection for a rectangle).
- Reference line :math:`L = C + \\lambda \\hat e_x`.
- Reference plane: :math:`z = C_z`.
- Longitudinal mid-plane: :math:`y = C_y`.
"""
from __future__ import annotations
import numpy as np
from ..structures import HoleSet, ReferenceFrame
def build_reference_frame(holes: HoleSet) -> ReferenceFrame:
centers = holes.locating_centers()
if centers.shape != (4, 3):
raise ValueError(f"need 4 locating-hole centres, got shape {centers.shape}")
C = centers.mean(axis=0)
return ReferenceFrame(
center_C=tuple(C.tolist()),
line_direction=(1.0, 0.0, 0.0),
reference_plane_z=float(C[2]),
longitudinal_mid_y=float(C[1]),
)
__all__ = ["build_reference_frame"]

View File

@@ -0,0 +1,159 @@
"""Extract the *clean* panel ROI from the registered point cloud.
Strategy:
1. Take points whose 2D projection falls inside the four-hole rectangle
(shrunk inwards by ``edge_buffer_mm``).
2. Drop points within ``hole_dilate_mm`` of any locating-hole centre
(radius + dilation).
3. Drop points within ``central_hole_dilate_mm`` of the central hole.
4. Drop chamfer points (per-point normal deviates from panel normal by
more than ``acos(chamfer_normal_dot_min)``). Normals are estimated
on the fly via PCA on each point's k-neighbourhood.
5. Drop z-score outliers along the panel normal.
"""
from __future__ import annotations
from typing import Any
import numpy as np
from ..structures import HoleSet
def _kth_normals(points: np.ndarray, k: int = 20) -> np.ndarray:
"""Per-point normal via PCA on the k-NN. Returns ``(N, 3)``."""
n = points.shape[0]
if n < k + 1:
return np.tile(np.array([1.0, 0.0, 0.0]), (n, 1))
try:
from scipy.spatial import cKDTree
except ImportError:
return np.tile(np.array([1.0, 0.0, 0.0]), (n, 1))
tree = cKDTree(points)
_, idx = tree.query(points, k=k + 1)
normals = np.empty((n, 3), dtype=np.float64)
for i in range(n):
nbh = points[idx[i, 1:]]
c = nbh.mean(axis=0)
Q = nbh - c
cov = (Q.T @ Q) / max(k - 1, 1)
try:
w, V = np.linalg.eigh(cov)
normals[i] = V[:, 0]
except np.linalg.LinAlgError:
normals[i] = np.array([1.0, 0.0, 0.0])
return normals
def _signed_to_plane(points: np.ndarray, normal: np.ndarray, offset: float) -> np.ndarray:
"""Signed distance from each point to plane n·x + d = 0."""
return points @ normal + offset
def extract_panel_roi(
points: np.ndarray,
*,
holes: HoleSet,
panel_normal_seed: np.ndarray,
roi_cfg: dict[str, Any],
) -> tuple[np.ndarray, dict[str, Any]]:
"""Return (ROI points, diagnostics dict).
``holes`` and ``panel_normal_seed`` are expected in the *same* frame
as ``points`` — registration must already have happened before this
is called.
"""
pts = np.asarray(points, dtype=np.float64)
if pts.shape[0] == 0:
return pts.copy(), {"n_in": 0, "n_out": 0, "stages": []}
n = np.asarray(panel_normal_seed, dtype=np.float64)
n = n / max(np.linalg.norm(n), 1e-12)
if n[0] < 0:
n = -n
centers_3d = holes.locating_centers() # (4, 3)
C = centers_3d.mean(axis=0)
# Build local 2D frame to test "inside the rectangle".
y_world = np.array([0.0, 1.0, 0.0])
u = y_world - np.dot(y_world, n) * n
if np.linalg.norm(u) < 1e-6:
u = np.array([0.0, 0.0, 1.0]) - np.dot(np.array([0.0, 0.0, 1.0]), n) * n
u /= np.linalg.norm(u)
v = np.cross(n, u)
v /= np.linalg.norm(v)
rel = pts - C
uv = np.column_stack([rel @ u, rel @ v])
centers_uv = np.column_stack([(centers_3d - C) @ u, (centers_3d - C) @ v])
edge_buf = float(roi_cfg.get("edge_buffer_mm", 5.0))
u_lo = centers_uv[:, 0].min() + edge_buf
u_hi = centers_uv[:, 0].max() - edge_buf
v_lo = centers_uv[:, 1].min() + edge_buf
v_hi = centers_uv[:, 1].max() - edge_buf
stages: list[dict[str, Any]] = []
n_in = pts.shape[0]
mask = (uv[:, 0] >= u_lo) & (uv[:, 0] <= u_hi) & (uv[:, 1] >= v_lo) & (uv[:, 1] <= v_hi)
stages.append({"name": "rectangle", "n_kept": int(mask.sum())})
# 2. Locating hole exclusion (use 3D distance to each hole's centre,
# with the hole's own radius + dilation).
hole_dilate = float(roi_cfg.get("hole_dilate_mm", 3.0))
for h in holes.locating:
center = np.asarray(h.center)
r = float(h.radius_mm) + hole_dilate
d2 = np.sum((pts - center) ** 2, axis=1)
mask &= d2 > r * r
stages.append({"name": "locating_holes", "n_kept": int(mask.sum())})
# 3. Central hole exclusion.
if holes.central is not None:
center = np.asarray(holes.central.center)
r = float(holes.central.radius_mm) + float(roi_cfg.get("central_hole_dilate_mm", 5.0))
d2 = np.sum((pts - center) ** 2, axis=1)
mask &= d2 > r * r
stages.append({"name": "central_hole", "n_kept": int(mask.sum())})
# 4. Chamfer/normal filter — only run if we still have enough points to PCA.
chamfer_dot_min = float(roi_cfg.get("chamfer_normal_dot_min", 0.85))
if mask.sum() > 50 and chamfer_dot_min > 0:
kept = pts[mask]
normals = _kth_normals(kept, k=20)
# Align all normals so they point along +n_panel (sign-invariant).
flips = (normals @ n) < 0
normals[flips] = -normals[flips]
keep_local = (normals @ n) >= chamfer_dot_min
# Map back into the full mask.
idxs = np.where(mask)[0]
new_mask = np.zeros_like(mask)
new_mask[idxs[keep_local]] = True
mask = new_mask
stages.append({"name": "chamfer_normal", "n_kept": int(mask.sum())})
# 5. Z-score outlier rejection along panel normal.
z_score = float(roi_cfg.get("outlier_z_score", 3.0))
if mask.sum() > 50 and z_score > 0:
kept = pts[mask]
d = kept @ n
mu = d.mean()
sigma = d.std()
if sigma > 1e-12:
keep_local = np.abs(d - mu) <= z_score * sigma
idxs = np.where(mask)[0]
new_mask = np.zeros_like(mask)
new_mask[idxs[keep_local]] = True
mask = new_mask
stages.append({"name": "z_score", "n_kept": int(mask.sum())})
out = pts[mask]
return out, {"n_in": int(n_in), "n_out": int(out.shape[0]), "stages": stages}
__all__ = ["extract_panel_roi"]

View File

@@ -0,0 +1,25 @@
"""Point-cloud / artifact IO utilities for coupler_panel."""
from .pointcloud_io import (
load_point_cloud,
write_point_cloud,
write_metadata_json,
)
from .artifacts import (
ensure_dir,
write_json,
write_yaml,
write_csv_rows,
Artifacts,
)
__all__ = [
"load_point_cloud",
"write_point_cloud",
"write_metadata_json",
"ensure_dir",
"write_json",
"write_yaml",
"write_csv_rows",
"Artifacts",
]

View File

@@ -0,0 +1,168 @@
"""Artifact directory layout for one pipeline run.
A run directory looks like::
runs/<ts>/
├── 00_cad_mesh.ply
├── 01_gt.json
├── 02_sampled.ply
├── 03_noisy.ply
├── 04_transformed.ply
├── 05_denoised.ply
├── 06_registered.ply
├── 07_holes.json
├── 08_panel_roi.ply
├── 09_plane.json
├── 10_metrics.json
├── images/
├── config.resolved.yaml
└── report.json
Most modules write through the :class:`Artifacts` helper so naming stays
consistent.
"""
from __future__ import annotations
import csv
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Sequence
import numpy as np
import yaml
# ---------------------------------------------------------------------------
# Generic helpers
# ---------------------------------------------------------------------------
def ensure_dir(path: str | Path) -> Path:
p = Path(path)
p.mkdir(parents=True, exist_ok=True)
return p
def write_json(data: Any, path: str | Path) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2, default=_json_default)
return path
def write_yaml(data: Any, path: str | Path) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as fh:
yaml.safe_dump(_yaml_default(data), fh, allow_unicode=True, sort_keys=False)
return path
def write_csv_rows(
rows: Iterable[Sequence[Any]],
path: str | Path,
*,
header: Sequence[str] | None = None,
) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
if header is not None:
w.writerow(header)
for row in rows:
w.writerow(row)
return path
def _json_default(obj: Any) -> Any:
if isinstance(obj, np.ndarray):
return obj.tolist()
if isinstance(obj, (np.floating, np.integer)):
return obj.item()
if isinstance(obj, Path):
return str(obj)
raise TypeError(f"not JSON serialisable: {type(obj).__name__}")
def _yaml_default(obj: Any) -> Any:
if isinstance(obj, dict):
return {k: _yaml_default(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_yaml_default(v) for v in obj]
if isinstance(obj, np.ndarray):
return obj.tolist()
if isinstance(obj, (np.floating, np.integer)):
return obj.item()
return obj
# ---------------------------------------------------------------------------
# Stage-naming helper
# ---------------------------------------------------------------------------
@dataclass
class Artifacts:
"""Resolves stage filenames inside a run directory.
Use :meth:`path` to get the canonical name for a given stage:
>>> art = Artifacts("runs/demo")
>>> art.path("sampled", ext="ply")
PosixPath('runs/demo/02_sampled.ply')
"""
root: Path
_STAGE_INDEX: dict[str, int] = None # type: ignore[assignment]
# Canonical stage ordering. Keys here are referenced from pipeline.py.
STAGE_ORDER = (
"cad_mesh", # 00
"gt", # 01
"sampled", # 02
"noisy", # 03
"transformed", # 04
"denoised", # 05
"registered", # 06
"holes", # 07
"panel_roi", # 08
"plane", # 09
"metrics", # 10
)
def __init__(self, root: str | Path) -> None:
self.root = ensure_dir(root)
ensure_dir(self.root / "images")
self._STAGE_INDEX = {name: i for i, name in enumerate(self.STAGE_ORDER)}
def path(self, stage: str, *, ext: str) -> Path:
idx = self._STAGE_INDEX.get(stage)
if idx is None:
raise KeyError(f"unknown stage '{stage}'; valid: {list(self._STAGE_INDEX)}")
ext = ext.lstrip(".")
return self.root / f"{idx:02d}_{stage}.{ext}"
def image(self, name: str) -> Path:
return self.root / "images" / name
@property
def report_json(self) -> Path:
return self.root / "report.json"
@property
def resolved_config(self) -> Path:
return self.root / "config.resolved.yaml"
__all__ = [
"ensure_dir",
"write_json",
"write_yaml",
"write_csv_rows",
"Artifacts",
]

View File

@@ -0,0 +1,238 @@
"""Point cloud read / write.
Supported formats:
- ``.ply`` binary or ASCII (open3d if available, else minimal builtin reader)
- ``.xyz`` whitespace-separated XYZ[ R G B][ NX NY NZ]
- ``.txt`` alias for ``.xyz``
- ``.csv`` comma-separated, first three columns must be X, Y, Z
- ``.pcd`` open3d only
The XYZ reader also serves as a fallback for ``.ply`` if open3d is
unavailable and the PLY is ASCII.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import numpy as np
from ..exceptions import InvalidPointCloudError, UnsupportedFormatError
_TEXT_SUFFIXES = (".xyz", ".txt", ".csv")
_OPEN3D_SUFFIXES = (".ply", ".pcd")
def _try_import_open3d():
try:
import open3d as o3d # type: ignore
return o3d
except Exception:
return None
# ---------------------------------------------------------------------------
# Loading
# ---------------------------------------------------------------------------
def load_point_cloud(path: str | Path) -> np.ndarray:
"""Load a point cloud as a float64 ``(N, 3)`` ndarray.
Raises
------
UnsupportedFormatError
If the file extension is not supported.
InvalidPointCloudError
If the file is missing, empty, or fails to parse.
"""
path = Path(path)
if not path.exists():
raise InvalidPointCloudError(f"point cloud not found: {path}")
suffix = path.suffix.lower()
if suffix in _TEXT_SUFFIXES:
return _load_text(path, sep="," if suffix == ".csv" else None)
if suffix in _OPEN3D_SUFFIXES:
o3d = _try_import_open3d()
if o3d is not None:
pcd = o3d.io.read_point_cloud(str(path))
arr = np.asarray(pcd.points, dtype=np.float64)
if arr.size == 0:
# PLY may also be ASCII XYZ-style; fall back.
if suffix == ".ply":
return _load_ascii_ply_fallback(path)
raise InvalidPointCloudError(f"empty point cloud: {path}")
return arr
if suffix == ".ply":
return _load_ascii_ply_fallback(path)
raise InvalidPointCloudError(
f"open3d is required to read {suffix}; install with `pip install open3d`"
)
raise UnsupportedFormatError(f"unsupported extension: {suffix}")
def _load_text(path: Path, sep: str | None) -> np.ndarray:
try:
if sep is None:
arr = np.loadtxt(path, comments=("#", "//"))
else:
arr = np.loadtxt(path, comments=("#", "//"), delimiter=sep)
except Exception as exc:
raise InvalidPointCloudError(f"failed to parse {path}: {exc}") from exc
if arr.ndim == 1:
arr = arr.reshape(1, -1)
if arr.shape[1] < 3:
raise InvalidPointCloudError(
f"{path} has fewer than 3 columns (got {arr.shape[1]})"
)
return arr[:, :3].astype(np.float64, copy=False)
def _load_ascii_ply_fallback(path: Path) -> np.ndarray:
"""Minimal ASCII-PLY reader for the 'just XYZ' case."""
with path.open("r", encoding="utf-8", errors="replace") as fh:
header_done = False
n_vertices = 0
x_idx = y_idx = z_idx = -1
prop_idx = 0
format_line: str | None = None
for line in fh:
line = line.strip()
if line == "end_header":
header_done = True
break
if line.startswith("format"):
format_line = line
elif line.startswith("element vertex"):
n_vertices = int(line.split()[-1])
prop_idx = 0
elif line.startswith("property"):
name = line.split()[-1]
if name == "x":
x_idx = prop_idx
elif name == "y":
y_idx = prop_idx
elif name == "z":
z_idx = prop_idx
prop_idx += 1
if not header_done or n_vertices <= 0 or -1 in (x_idx, y_idx, z_idx):
raise InvalidPointCloudError(
f"unsupported PLY header in {path}; install open3d for full PLY support"
)
if format_line and "binary" in format_line:
raise InvalidPointCloudError(
f"{path} is binary PLY; install open3d to read it"
)
out = np.empty((n_vertices, 3), dtype=np.float64)
for i in range(n_vertices):
parts = fh.readline().split()
out[i, 0] = float(parts[x_idx])
out[i, 1] = float(parts[y_idx])
out[i, 2] = float(parts[z_idx])
return out
# ---------------------------------------------------------------------------
# Writing
# ---------------------------------------------------------------------------
def write_point_cloud(
points: np.ndarray,
path: str | Path,
*,
colors: np.ndarray | None = None,
binary: bool = True,
) -> Path:
"""Write a point cloud. Format is inferred from the extension.
For ``.ply``/``.pcd`` open3d is preferred; if open3d is missing and the
target is ``.ply``, we fall back to writing ASCII PLY ourselves.
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
points = np.ascontiguousarray(points, dtype=np.float64)
if points.ndim != 2 or points.shape[1] != 3:
raise InvalidPointCloudError(
f"expected (N,3) points, got shape {points.shape}"
)
suffix = path.suffix.lower()
if suffix in (".xyz", ".txt"):
np.savetxt(path, points, fmt="%.6f")
return path
if suffix == ".csv":
np.savetxt(path, points, fmt="%.6f", delimiter=",")
return path
if suffix in _OPEN3D_SUFFIXES:
o3d = _try_import_open3d()
if o3d is not None:
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points)
if colors is not None:
pcd.colors = o3d.utility.Vector3dVector(np.asarray(colors)[:, :3])
o3d.io.write_point_cloud(str(path), pcd, write_ascii=not binary)
return path
if suffix == ".ply":
_write_ascii_ply(points, path, colors=colors)
return path
raise InvalidPointCloudError(
f"open3d is required to write {suffix}; install with `pip install open3d`"
)
raise UnsupportedFormatError(f"unsupported extension: {suffix}")
def _write_ascii_ply(
points: np.ndarray,
path: Path,
colors: np.ndarray | None = None,
) -> None:
have_color = colors is not None
n = points.shape[0]
with path.open("w", encoding="ascii") as fh:
fh.write("ply\nformat ascii 1.0\n")
fh.write(f"element vertex {n}\n")
fh.write("property float x\nproperty float y\nproperty float z\n")
if have_color:
fh.write("property uchar red\nproperty uchar green\nproperty uchar blue\n")
fh.write("end_header\n")
if have_color:
cols = np.clip(np.asarray(colors), 0.0, 1.0)
cols = (cols * 255).astype(np.uint8)
for p, c in zip(points, cols):
fh.write(f"{p[0]:.6f} {p[1]:.6f} {p[2]:.6f} {int(c[0])} {int(c[1])} {int(c[2])}\n")
else:
for p in points:
fh.write(f"{p[0]:.6f} {p[1]:.6f} {p[2]:.6f}\n")
def write_metadata_json(meta: dict[str, Any], path: str | Path) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as fh:
json.dump(meta, fh, ensure_ascii=False, indent=2, default=_json_default)
return path
def _json_default(obj: Any) -> Any:
if isinstance(obj, np.ndarray):
return obj.tolist()
if isinstance(obj, (np.floating, np.integer)):
return obj.item()
raise TypeError(f"not JSON serialisable: {type(obj).__name__}")
__all__ = [
"load_point_cloud",
"write_point_cloud",
"write_metadata_json",
]

View File

@@ -0,0 +1,14 @@
"""Deflection-angle and maximum-deviation computations + GT comparison."""
from .deflection import (
DeflectionMetrics,
compute_deflection_metrics,
)
from .compare import compare_to_gt, GTComparison
__all__ = [
"DeflectionMetrics",
"compute_deflection_metrics",
"compare_to_gt",
"GTComparison",
]

View File

@@ -0,0 +1,60 @@
"""Compare measured deflection against the GT (CAD-derived) values."""
from __future__ import annotations
from dataclasses import dataclass
from .deflection import DeflectionMetrics
@dataclass(frozen=True)
class GTComparison:
measured: DeflectionMetrics
gt: DeflectionMetrics
@property
def d_theta_right_deg(self) -> float:
return self.measured.theta_right_deg - self.gt.theta_right_deg
@property
def d_theta_ref_deg(self) -> float:
return self.measured.theta_ref_deg - self.gt.theta_ref_deg
@property
def d_D_y_max_mm(self) -> float:
return self.measured.D_y_max_mm - self.gt.D_y_max_mm
@property
def d_D_z_max_mm(self) -> float:
return self.measured.D_z_max_mm - self.gt.D_z_max_mm
@property
def d_dx_y_max_mm(self) -> float:
return self.measured.dx_y_max_mm - self.gt.dx_y_max_mm
@property
def d_dx_z_max_mm(self) -> float:
return self.measured.dx_z_max_mm - self.gt.dx_z_max_mm
def to_dict(self) -> dict[str, float | dict]:
return {
"measured": self.measured.to_dict(),
"gt": self.gt.to_dict(),
"diff": {
"theta_right_deg": self.d_theta_right_deg,
"theta_ref_deg": self.d_theta_ref_deg,
"D_y_max_mm": self.d_D_y_max_mm,
"D_z_max_mm": self.d_D_z_max_mm,
"dx_y_max_mm": self.d_dx_y_max_mm,
"dx_z_max_mm": self.d_dx_z_max_mm,
"abs_theta_right_deg": abs(self.d_theta_right_deg),
"abs_theta_ref_deg": abs(self.d_theta_ref_deg),
},
}
def compare_to_gt(measured: DeflectionMetrics, gt: DeflectionMetrics) -> GTComparison:
return GTComparison(measured=measured, gt=gt)
__all__ = ["GTComparison", "compare_to_gt"]

View File

@@ -0,0 +1,103 @@
"""Deflection-angle and maximum-deviation formulas.
For a fitted panel-plane normal :math:`(n_x, n_y, n_z)` with
:math:`n_x > 0`, the spec is::
theta_right_deg = atan2(n_y, n_x) * 180/pi (right-axis sign applied)
theta_ref_deg = atan2(n_z, n_x) * 180/pi
A = -n_y / n_x
B = -n_z / n_x
D_y_max = max |y_i - C_y|
D_z_max = max |z_i - C_z|
dx_y_max = |A| * D_y_max
dx_z_max = |B| * D_z_max
``points`` should already be in the *world / CAD* frame, i.e. after the
4-hole Kabsch registration, and clipped to the panel ROI so the maxima
do not pick up edge or chamfer pixels.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import numpy as np
@dataclass(frozen=True)
class DeflectionMetrics:
theta_right_deg: float
theta_ref_deg: float
D_y_max_mm: float
D_z_max_mm: float
dx_y_max_mm: float
dx_z_max_mm: float
A: float # -n_y / n_x
B: float # -n_z / n_x
def to_dict(self) -> dict[str, Any]:
return {
"theta_right_deg": self.theta_right_deg,
"theta_ref_deg": self.theta_ref_deg,
"D_y_max_mm": self.D_y_max_mm,
"D_z_max_mm": self.D_z_max_mm,
"dx_y_max_mm": self.dx_y_max_mm,
"dx_z_max_mm": self.dx_z_max_mm,
"A": self.A,
"B": self.B,
}
def compute_deflection_metrics(
*,
normal: tuple[float, float, float] | np.ndarray,
center_C: tuple[float, float, float] | np.ndarray,
panel_points: np.ndarray,
right_axis_sign: int = 1,
angle_clamp_deg: float = 0.0,
) -> DeflectionMetrics:
"""Compute the six measurands."""
n = np.asarray(normal, dtype=np.float64).reshape(3)
if n[0] <= 0:
# Caller is expected to enforce n_x > 0 before this; we hard-fail
# because every formula divides by n_x.
raise ValueError(
f"compute_deflection_metrics requires n_x > 0; got n = {tuple(n)}"
)
C = np.asarray(center_C, dtype=np.float64).reshape(3)
pts = np.asarray(panel_points, dtype=np.float64)
if pts.ndim != 2 or pts.shape[1] != 3:
raise ValueError(f"panel_points must be (N,3); got {pts.shape}")
if pts.shape[0] == 0:
raise ValueError("panel_points is empty")
sign = 1.0 if int(right_axis_sign) >= 0 else -1.0
theta_right = sign * float(np.degrees(np.arctan2(n[1], n[0])))
theta_ref = float(np.degrees(np.arctan2(n[2], n[0])))
if abs(theta_right) < float(angle_clamp_deg):
theta_right = 0.0
if abs(theta_ref) < float(angle_clamp_deg):
theta_ref = 0.0
A = float(-n[1] / n[0])
B = float(-n[2] / n[0])
D_y_max = float(np.max(np.abs(pts[:, 1] - C[1])))
D_z_max = float(np.max(np.abs(pts[:, 2] - C[2])))
return DeflectionMetrics(
theta_right_deg=theta_right,
theta_ref_deg=theta_ref,
D_y_max_mm=D_y_max,
D_z_max_mm=D_z_max,
dx_y_max_mm=abs(A) * D_y_max,
dx_z_max_mm=abs(B) * D_z_max,
A=A,
B=B,
)
__all__ = ["DeflectionMetrics", "compute_deflection_metrics"]

View File

@@ -0,0 +1,307 @@
"""End-to-end orchestration for *research / validation* runs.
Composition::
data_prep.prepare_cloud_from_cad / from_scan (Phase A)
api.compute_coupler_measurement (Phase B)
Phase A is the validation / data-preparation part: STP → mesh → sample
→ noise → transform → denoise → register. Phase B is the public
measurement algorithm. This module saves every intermediate artifact
under a structured run directory so engineers can debug and so the
sweep runner can compare runs.
Production callers should *not* use this module — they should call
:func:`coupler_panel.api.compute_coupler_measurement` directly with
their own clean point cloud.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import numpy as np
from .api import compute_coupler_measurement, ProgressCallback
from .config import load_config, resolve_auto
from .data_prep import (
PreparedCloud,
denoise_cloud,
parse_cad,
prepare_cloud_from_cad,
prepare_cloud_from_scan,
register_cloud,
synthesize_cloud,
)
from .io_utils import (
Artifacts,
load_point_cloud,
write_json,
write_point_cloud,
write_yaml,
)
from .metrics import compute_deflection_metrics
from .roi_select import ROISpec, apply_roi, default_roi_spec, load_roi_json
from .structures import CouplerMeasurement
# ---------------------------------------------------------------------------
# Cloud-only research entry point
# ---------------------------------------------------------------------------
def run_coupler_measurement_pipeline(
input_cloud: str | Path,
out_dir: str | Path,
*,
config_path: str | Path | None = None,
profile: str = "auto",
cad_stp_path: str | Path | None = None,
roi_spec: ROISpec | None = None,
roi_json: str | Path | None = None,
progress: ProgressCallback | None = None,
) -> CouplerMeasurement:
"""Main measurement entry: cloud (in CAD frame) → ROI → denoise → measure.
**No registration step.** The input cloud is assumed to already live
in the CAD / world frame (scanner extrinsics calibrated upstream).
If it doesn't, 4-hole Kabsch against nominal CAD holes would also
cancel the panel-deflection signal we want to measure — so this
pipeline deliberately does not register. For the research path that
*does* register (using oracle deflected GT), see
:func:`run_from_cad_pipeline`.
Inputs:
- ``cad_stp_path`` — optional STEP file. Parsed for the analytical
panel-normal seed (speeds up + stabilises hole detection's
in-plane projection); the parsed GT is written as ``01_gt.json``
to ``out_dir`` for auditing.
- ``roi_spec`` / ``roi_json`` — ROI selector applied to the cloud
*before* denoising. If both omitted, the default ``mode='all'``
spec (pass-through) is used. ``roi_spec`` wins over ``roi_json``.
Writes every intermediate to ``out_dir``. Returns a
:class:`CouplerMeasurement`.
"""
art = Artifacts(out_dir)
cfg = load_config(config_path, profile=profile)
cfg = resolve_auto(cfg)
write_yaml(cfg, art.resolved_config)
# ---- Load + ROI clip --------------------------------------------------
raw_pts = load_point_cloud(input_cloud)
if roi_spec is None:
roi_spec = load_roi_json(roi_json) if roi_json is not None else default_roi_spec()
pts, roi_diag = apply_roi(raw_pts, roi_spec)
write_point_cloud(pts, art.path("sampled", ext="ply"))
write_json({"spec": roi_spec.to_dict(), "diagnostics": roi_diag}, art.root / "roi.json")
# ---- Optional CAD parse (panel_normal_seed + audit GT JSON) ----------
panel_seed: np.ndarray | None = None
if cad_stp_path is not None:
parsed = parse_cad(cad_stp_path, cfg["cad"])
write_json(parsed.gt.to_dict(), art.path("gt", ext="json"))
panel_seed = np.asarray(parsed.gt.plane.normal, dtype=np.float64)
# ---- Denoise (no Kabsch, no prepare_cloud_from_scan) -----------------
denoised = denoise_cloud(pts, cfg.get("denoise", {}))
write_point_cloud(denoised.points, art.path("denoised", ext="ply"))
# ---- Measurement ------------------------------------------------------
result = compute_coupler_measurement(
denoised.points,
config=cfg,
profile="custom",
panel_normal_seed=panel_seed,
progress=progress,
)
write_json(result.to_dict(), art.report_json)
write_json(result.to_dict()["report"], art.path("metrics", ext="json"))
return result
# ---------------------------------------------------------------------------
# CAD-driven research entry point
# ---------------------------------------------------------------------------
def run_from_cad_pipeline(
stp_path: str | Path,
out_dir: str | Path,
*,
config_path: str | Path | None = None,
profile: str = "auto",
progress: ProgressCallback | None = None,
) -> dict[str, Any]:
"""End-to-end research run: STP → synthesise → prepare → measure → diff GT.
Returns a summary dict containing the measurement, GT metrics, and
measured-minus-GT differences. Writes every intermediate cloud and
JSON to ``out_dir``.
"""
art = Artifacts(out_dir)
cfg = load_config(config_path, profile=profile)
cfg = resolve_auto(cfg)
write_yaml(cfg, art.resolved_config)
# ---- Phase A.1 — parse + GT -------------------------------------------
parsed = parse_cad(stp_path, cfg["cad"])
write_point_cloud(parsed.mesh.vertices, art.path("cad_mesh", ext="ply"))
write_json(parsed.gt.to_dict(), art.path("gt", ext="json"))
# ---- Phase A.2 — synthesise --------------------------------------------
synth = synthesize_cloud(
parsed,
sampling_cfg=cfg["sampling"],
noise_cfg=cfg.get("noise", {}),
transform_cfg=cfg.get("transform", {}),
scan_cfg=cfg.get("scan_simulator", {}),
deflection_cfg=cfg.get("panel_deflection", {}),
)
write_point_cloud(synth.sampled, art.path("sampled", ext="ply"))
write_point_cloud(synth.noisy, art.path("noisy", ext="ply"))
write_point_cloud(synth.transformed, art.path("transformed", ext="ply"))
# ---- Phase A.3 — denoise ----------------------------------------------
denoised = denoise_cloud(synth.transformed, cfg.get("denoise", {}))
write_point_cloud(denoised.points, art.path("denoised", ext="ply"))
# ---- Phase A.4 — register ---------------------------------------------
# Use PCA on the cloud itself for the projection seed (the cloud is
# still in the pre-Kabsch frame here; using the CAD normal would
# cause foreshortening bias). Register against the *deflected* GT
# holes so Kabsch only cancels scanner-pose jitter, not the panel
# deflection signal we want to measure.
registered = register_cloud(
denoised.points,
target_holes=synth.gt_holes_after_deflection,
detection_cfg=cfg["detection"],
cad_cfg=cfg["cad"],
registration_cfg=cfg["registration"],
panel_normal_seed=None,
)
write_point_cloud(registered.points, art.path("registered", ext="ply"))
# ---- Phase B — measurement algorithm ----------------------------------
result = compute_coupler_measurement(
registered.points,
config=cfg,
profile="custom",
panel_normal_seed=np.asarray(parsed.gt.plane.normal, dtype=np.float64),
progress=progress,
)
# ---- GT metrics --------------------------------------------------------
# GT for the metrics is the *deflected* panel: angles equal the
# injection, and the maximum-distance / maximum-x-deviation values are
# computed over the deflected 4-hole rectangle (the same primitive the
# algorithm later sees through the noise + jitter chain).
gt_normal = synth.gt_normal_after_deflection
centers = synth.gt_holes_after_deflection.locating_centers()
gt_metrics = compute_deflection_metrics(
normal=gt_normal,
center_C=tuple(centers.mean(axis=0).tolist()),
panel_points=centers,
right_axis_sign=int(cfg["metrics"].get("right_axis_sign", 1)),
angle_clamp_deg=float(cfg["metrics"].get("angle_clamp_deg", 0.0)),
)
diff = {
"theta_right_deg": result.theta_right_deg - gt_metrics.theta_right_deg,
"theta_ref_deg": result.theta_ref_deg - gt_metrics.theta_ref_deg,
"D_y_max_mm": result.D_y_max_mm - gt_metrics.D_y_max_mm,
"D_z_max_mm": result.D_z_max_mm - gt_metrics.D_z_max_mm,
"dx_y_max_mm": result.dx_y_max_mm - gt_metrics.dx_y_max_mm,
"dx_z_max_mm": result.dx_z_max_mm - gt_metrics.dx_z_max_mm,
}
summary = {
"stp": str(stp_path),
"panel_deflection_injected": {
"theta_right_deg": synth.panel_deflection_theta_right_deg,
"theta_ref_deg": synth.panel_deflection_theta_ref_deg,
"R": synth.panel_deflection_R.tolist(),
},
"scan_pose_perturbation": synth.gt_transform_4x4.tolist(),
"registration": {
"matrix": registered.transform_4x4.tolist(),
"rmse_mm": registered.rmse_mm,
"max_residual_mm": registered.max_residual_mm,
},
"measured": result.to_dict(),
"gt_cad": parsed.gt.to_dict(),
"gt_after_deflection": {
"normal": gt_normal.tolist(),
"locating_centers": centers.tolist(),
},
"gt_metrics": gt_metrics.to_dict(),
"diff": diff,
"config": cfg,
}
write_json(summary, art.path("metrics", ext="json"))
write_json(summary, art.report_json)
return summary
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _try_load_normal_from_gt_json(path: str | Path) -> np.ndarray | None:
"""Read ``plane.normal`` from a GT JSON if present. Returns ``None`` otherwise."""
import json
try:
with Path(path).open("r", encoding="utf-8") as fh:
d = json.load(fh)
except Exception:
return None
n = d.get("plane", {}).get("normal")
if n is None or len(n) != 3:
return None
arr = np.asarray(n, dtype=np.float64)
norm = float(np.linalg.norm(arr))
if norm < 1e-9:
return None
return arr / norm
def _load_holeset_from_json(path: str | Path):
"""Reconstruct a HoleSet from the JSON written by ``GroundTruth.to_dict``."""
import json
from .structures import Hole, HoleSet
with Path(path).open("r", encoding="utf-8") as fh:
d = json.load(fh)
holes = d["holes"]
locating = tuple(
Hole(
center=tuple(h["center"]),
radius_mm=float(h["radius_mm"]),
fit_residual_rms_mm=float(h.get("fit_residual_rms_mm", 0.0)),
role=h.get("role", "locating"),
)
for h in holes["locating"]
)
central = None
if holes.get("central") is not None:
c = holes["central"]
central = Hole(
center=tuple(c["center"]),
radius_mm=float(c["radius_mm"]),
fit_residual_rms_mm=float(c.get("fit_residual_rms_mm", 0.0)),
role="central",
)
return HoleSet(locating=locating, central=central)
__all__ = [
"run_coupler_measurement_pipeline",
"run_from_cad_pipeline",
]

View File

@@ -0,0 +1,18 @@
"""Rigid registration via 4 hole-centre correspondence (Kabsch / Umeyama).
This package deliberately ships **only** the four-hole method. We do
*not* expose a panel-plane ICP variant because that would absorb the
deflection signal we want to measure.
"""
from .kabsch import (
KabschResult,
kabsch_umeyama,
register_four_holes,
)
__all__ = [
"KabschResult",
"kabsch_umeyama",
"register_four_holes",
]

View File

@@ -0,0 +1,114 @@
"""Rigid registration from a small number of 3D point correspondences.
We use the closed-form Umeyama solution (Kabsch with the optional scale
turned off — we want a rigid transform). Given 4 source points P (in the
scan/world frame) and 4 target points Q (in the CAD/GT frame), we solve
for the rigid T such that ``T @ P ≈ Q``.
The four locating holes give us four exact correspondences; their
indexing must match (we ensure this in :mod:`coupler_panel.detection`
via the canonical quadrant ordering).
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from ..exceptions import RegistrationError
@dataclass(frozen=True)
class KabschResult:
"""4×4 transform plus quality stats."""
matrix: np.ndarray # (4, 4) — applies to source to align with target
rmse_mm: float
max_residual_mm: float
n_pairs: int
def apply(self, points: np.ndarray) -> np.ndarray:
R = self.matrix[:3, :3]
t = self.matrix[:3, 3]
return np.asarray(points, dtype=np.float64) @ R.T + t
def kabsch_umeyama(
source: np.ndarray,
target: np.ndarray,
*,
allow_reflection: bool = False,
) -> KabschResult:
"""Solve for rigid T such that T·source ≈ target."""
P = np.asarray(source, dtype=np.float64)
Q = np.asarray(target, dtype=np.float64)
if P.shape != Q.shape or P.ndim != 2 or P.shape[1] != 3:
raise RegistrationError(
f"shape mismatch: source={P.shape}, target={Q.shape}; "
"expected (N,3) with the same N"
)
n = P.shape[0]
if n < 3:
raise RegistrationError(f"need ≥ 3 correspondences, got {n}")
p_bar = P.mean(axis=0)
q_bar = Q.mean(axis=0)
Pc = P - p_bar
Qc = Q - q_bar
H = Pc.T @ Qc # (3, 3)
U, _S, Vt = np.linalg.svd(H)
R = Vt.T @ U.T
if not allow_reflection and np.linalg.det(R) < 0:
Vt[-1, :] *= -1.0
R = Vt.T @ U.T
t = q_bar - R @ p_bar
M = np.eye(4)
M[:3, :3] = R
M[:3, 3] = t
P_aligned = P @ R.T + t
res = np.linalg.norm(P_aligned - Q, axis=1)
return KabschResult(
matrix=M,
rmse_mm=float(np.sqrt(np.mean(res ** 2))),
max_residual_mm=float(res.max()),
n_pairs=int(n),
)
def register_four_holes(
source_centers: np.ndarray,
target_centers: np.ndarray,
*,
refuse_above_mm: float | None = None,
) -> KabschResult:
"""Wrapper for the canonical 4-hole pairing case.
The two arrays **must** be index-aligned (same canonical quadrant
order). The detection / GT modules in this project are responsible
for producing them in the same order.
"""
P = np.asarray(source_centers, dtype=np.float64)
Q = np.asarray(target_centers, dtype=np.float64)
if P.shape != (4, 3) or Q.shape != (4, 3):
raise RegistrationError(
f"register_four_holes requires (4,3) inputs; got {P.shape} and {Q.shape}"
)
result = kabsch_umeyama(P, Q)
if refuse_above_mm is not None and result.rmse_mm > float(refuse_above_mm):
raise RegistrationError(
f"4-hole Kabsch RMSE {result.rmse_mm:.3f} mm exceeds {refuse_above_mm} mm — "
"wrong correspondences, missed hole, or detection error"
)
return result
__all__ = ["KabschResult", "kabsch_umeyama", "register_four_holes"]

View File

@@ -0,0 +1,5 @@
"""Batch-evaluation reporting helpers (xlsx output)."""
from .excel import EvalRow, write_excel_report, build_row_from_summary
__all__ = ["EvalRow", "write_excel_report", "build_row_from_summary"]

View File

@@ -0,0 +1,285 @@
"""Aggregate batch-eval results into an xlsx report.
Each row corresponds to one input PLY × one ROI JSON × one run.
Columns (kept stable across releases unless the field is removed from
the upstream report):
sample_id, ply_path, roi_kind,
theta_right_gt_deg, theta_ref_gt_deg,
theta_right_pred_deg, theta_ref_pred_deg,
theta_right_err_deg, theta_ref_err_deg,
D_y_max_mm, D_z_max_mm,
dx_y_max_mm, dx_z_max_mm,
center_C_x_mm, center_C_y_mm, center_C_z_mm,
normal_x, normal_y, normal_z,
plane_inlier_ratio, plane_residual_rms_mm,
registration_rmse_mm, registration_max_residual_mm,
n_points_in, n_points_after_roi,
quality_flags,
pipeline_seconds, inner_stage_seconds,
algorithm_version, schema_version
``pipeline_seconds`` is the **wall-clock** time from the moment the
inputs (cloud PLY + ROI JSON, optionally CAD STP) enter
``run_coupler_measurement_pipeline`` to the moment the
``CouplerMeasurement`` is returned — i.e. the algorithm's total
processing time per sample.
``inner_stage_seconds`` is the older, narrower number: the sum of
``StageTiming.seconds`` recorded inside ``compute_coupler_measurement``
itself (validate → detect_holes → … → metrics), excluding file IO,
cloud load, ROI apply, denoise, and any CAD STP parse. It is kept for
per-stage debugging only.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
try:
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
except ImportError as exc: # pragma: no cover
raise ImportError(
"openpyxl is required for Excel reporting; install with `pip install openpyxl`"
) from exc
# ---------------------------------------------------------------------------
# Row schema
# ---------------------------------------------------------------------------
COLUMNS: tuple[tuple[str, str], ...] = (
("sample_id", "Sample ID"),
("ply_path", "Input PLY"),
("roi_kind", "ROI kind"),
("theta_right_gt_deg", "θ_right GT (°)"),
("theta_ref_gt_deg", "θ_ref GT (°)"),
("theta_right_pred_deg", "θ_right Pred (°)"),
("theta_ref_pred_deg", "θ_ref Pred (°)"),
("theta_right_err_deg", "θ_right Err (°)"),
("theta_ref_err_deg", "θ_ref Err (°)"),
("D_y_max_mm", "D_y_max (mm)"),
("D_z_max_mm", "D_z_max (mm)"),
("dx_y_max_mm", "dx_y_max (mm)"),
("dx_z_max_mm", "dx_z_max (mm)"),
("center_C_x_mm", "C_x (mm)"),
("center_C_y_mm", "C_y (mm)"),
("center_C_z_mm", "C_z (mm)"),
("normal_x", "n_x"),
("normal_y", "n_y"),
("normal_z", "n_z"),
("plane_inlier_ratio", "plane inlier ratio"),
("plane_residual_rms_mm", "plane res RMS (mm)"),
("registration_rmse_mm", "Kabsch RMSE (mm)"),
("registration_max_residual_mm", "Kabsch max res (mm)"),
("n_points_in", "N in"),
("n_points_after_roi", "N after ROI"),
("quality_flags", "quality_flags"),
("pipeline_seconds", "Algorithm runtime (s)"),
("inner_stage_seconds", "compute_*_measurement (s)"),
("algorithm_version", "algo ver"),
("schema_version", "schema ver"),
)
@dataclass
class EvalRow:
"""One row in the Excel report (one PLY × one ROI × one config)."""
sample_id: str
ply_path: str
roi_kind: str
theta_right_gt_deg: float | None
theta_ref_gt_deg: float | None
theta_right_pred_deg: float
theta_ref_pred_deg: float
theta_right_err_deg: float | None
theta_ref_err_deg: float | None
D_y_max_mm: float
D_z_max_mm: float
dx_y_max_mm: float
dx_z_max_mm: float
center_C_x_mm: float
center_C_y_mm: float
center_C_z_mm: float
normal_x: float
normal_y: float
normal_z: float
plane_inlier_ratio: float
plane_residual_rms_mm: float
registration_rmse_mm: float | None
registration_max_residual_mm: float | None
n_points_in: int
n_points_after_roi: int
quality_flags: str
pipeline_seconds: float
inner_stage_seconds: float
algorithm_version: str
schema_version: str
extra: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
d = asdict(self)
d.pop("extra", None)
return d
# ---------------------------------------------------------------------------
# Row construction from pipeline outputs
# ---------------------------------------------------------------------------
def build_row_from_summary(
*,
sample_id: str,
ply_path: str | Path,
roi_kind: str,
measured: dict[str, Any],
gt_angles: tuple[float, float] | None,
registration: dict[str, Any] | None,
n_points_in: int,
n_points_after_roi: int,
pipeline_seconds: float,
) -> EvalRow:
"""Construct an :class:`EvalRow` from the pipeline's report dict.
``measured`` is the ``CouplerMeasurement.to_dict()`` payload. It
embeds ``report`` (the inner dict ``compute_coupler_measurement``
wrote). ``gt_angles`` is the injected ``(theta_right_deg,
theta_ref_deg)`` or ``None`` when not known.
``pipeline_seconds`` should be the wall-clock duration of the
``run_coupler_measurement_pipeline`` call — i.e. the algorithm's
end-to-end runtime per sample. Caller is responsible for timing.
"""
report = measured.get("report", {})
plane = report.get("plane", {})
timings = report.get("timings", [])
inner_s = float(sum(t.get("seconds", 0.0) for t in timings))
th_r_pred = float(measured["theta_right_deg"])
th_z_pred = float(measured["theta_ref_deg"])
if gt_angles is None:
th_r_gt = th_z_gt = None
th_r_err = th_z_err = None
else:
th_r_gt = float(gt_angles[0])
th_z_gt = float(gt_angles[1])
th_r_err = th_r_pred - th_r_gt
th_z_err = th_z_pred - th_z_gt
cx, cy, cz = measured["center_C"]
nx, ny, nz = measured["normal"]
return EvalRow(
sample_id=sample_id,
ply_path=str(ply_path),
roi_kind=roi_kind,
theta_right_gt_deg=th_r_gt,
theta_ref_gt_deg=th_z_gt,
theta_right_pred_deg=th_r_pred,
theta_ref_pred_deg=th_z_pred,
theta_right_err_deg=th_r_err,
theta_ref_err_deg=th_z_err,
D_y_max_mm=float(measured["D_y_max_mm"]),
D_z_max_mm=float(measured["D_z_max_mm"]),
dx_y_max_mm=float(measured["dx_y_max_mm"]),
dx_z_max_mm=float(measured["dx_z_max_mm"]),
center_C_x_mm=float(cx),
center_C_y_mm=float(cy),
center_C_z_mm=float(cz),
normal_x=float(nx),
normal_y=float(ny),
normal_z=float(nz),
plane_inlier_ratio=float(plane.get("inlier_ratio", float("nan"))),
plane_residual_rms_mm=float(plane.get("inlier_residual_rms_mm", float("nan"))),
registration_rmse_mm=(None if registration is None else float(registration.get("rmse_mm", float("nan")))),
registration_max_residual_mm=(
None
if registration is None
else float(registration.get("max_residual_mm", float("nan")))
),
n_points_in=int(n_points_in),
n_points_after_roi=int(n_points_after_roi),
quality_flags=";".join(measured.get("quality_flags", [])) or "",
pipeline_seconds=float(pipeline_seconds),
inner_stage_seconds=inner_s,
algorithm_version=str(measured.get("algorithm_version", "")),
schema_version=str(measured.get("schema_version", "")),
)
# ---------------------------------------------------------------------------
# Excel writer
# ---------------------------------------------------------------------------
def write_excel_report(rows: list[EvalRow], path: str | Path) -> Path:
"""Write rows to an xlsx file. Returns the path."""
out = Path(path)
out.parent.mkdir(parents=True, exist_ok=True)
wb = Workbook()
ws = wb.active
ws.title = "results"
header_font = Font(bold=True)
header_fill = PatternFill("solid", fgColor="DDDDDD")
center = Alignment(horizontal="center")
# Header row.
for col_idx, (_, label) in enumerate(COLUMNS, start=1):
cell = ws.cell(row=1, column=col_idx, value=label)
cell.font = header_font
cell.fill = header_fill
cell.alignment = center
# Data rows.
for r_idx, row in enumerate(rows, start=2):
d = row.to_dict()
for c_idx, (key, _) in enumerate(COLUMNS, start=1):
v = d.get(key)
if isinstance(v, float) and (v != v): # NaN
v = ""
ws.cell(row=r_idx, column=c_idx, value=v)
# Number formatting for float-typed cells.
float_keys = {
"theta_right_gt_deg", "theta_ref_gt_deg",
"theta_right_pred_deg", "theta_ref_pred_deg",
"theta_right_err_deg", "theta_ref_err_deg",
"D_y_max_mm", "D_z_max_mm", "dx_y_max_mm", "dx_z_max_mm",
"center_C_x_mm", "center_C_y_mm", "center_C_z_mm",
"normal_x", "normal_y", "normal_z",
"plane_inlier_ratio", "plane_residual_rms_mm",
"registration_rmse_mm", "registration_max_residual_mm",
"pipeline_seconds", "inner_stage_seconds",
}
for c_idx, (key, _) in enumerate(COLUMNS, start=1):
if key in float_keys:
for r_idx in range(2, len(rows) + 2):
ws.cell(row=r_idx, column=c_idx).number_format = "0.000000"
# Auto-width (cap at 32).
for c_idx, (_, label) in enumerate(COLUMNS, start=1):
col_letter = ws.cell(row=1, column=c_idx).column_letter
max_len = len(str(label))
for r_idx in range(2, len(rows) + 2):
v = ws.cell(row=r_idx, column=c_idx).value
if v is None:
continue
max_len = max(max_len, len(str(v)))
ws.column_dimensions[col_letter].width = min(max_len + 2, 32)
ws.freeze_panes = "D2"
wb.save(out)
return out
__all__ = ["EvalRow", "COLUMNS", "build_row_from_summary", "write_excel_report"]

View File

@@ -0,0 +1,25 @@
"""Region-of-interest pre-filter for the input point cloud.
The main pipeline takes a *raw* point cloud (PLY) plus a ROI JSON
descriptor (an axis-aligned bounding box in the CAD frame) that tells
which subset of the cloud should actually feed the downstream algorithm.
ROI JSON layout — six flat keys, all in mm::
{
"x_min": -12500.0, "x_max": 12500.0,
"y_min": 150.0, "y_max": 4100.0,
"z_min": -1680.0, "z_max": 200.0
}
"""
from .schema import ROISpec, default_roi_spec, load_roi_json, validate_roi_spec
from .apply import apply_roi
__all__ = [
"ROISpec",
"default_roi_spec",
"load_roi_json",
"validate_roi_spec",
"apply_roi",
]

View File

@@ -0,0 +1,48 @@
"""Apply an axis-aligned bounding-box :class:`ROISpec` to a point cloud."""
from __future__ import annotations
from typing import Any
import numpy as np
from .schema import ROISpec
def apply_roi(points: np.ndarray, spec: ROISpec) -> tuple[np.ndarray, dict[str, Any]]:
"""Filter ``points`` by the axis-aligned bounding box in ``spec``.
Returns ``(filtered_points, diagnostics)``. ``diagnostics`` is a small
JSON-friendly dict suitable for embedding in a run report.
"""
pts = np.asarray(points, dtype=np.float64)
n_in = int(pts.shape[0])
if n_in == 0:
return pts.copy(), {
"kind": spec.kind,
"n_in": 0,
"n_out": 0,
"kept_ratio": 0.0,
"bounds": spec.to_dict(),
}
x, y, z = pts[:, 0], pts[:, 1], pts[:, 2]
mask = (
(x >= spec.x_min) & (x <= spec.x_max)
& (y >= spec.y_min) & (y <= spec.y_max)
& (z >= spec.z_min) & (z <= spec.z_max)
)
out = pts[mask]
n_out = int(out.shape[0])
return out, {
"kind": spec.kind,
"n_in": n_in,
"n_out": n_out,
"kept_ratio": float(n_out / n_in),
"bounds": spec.to_dict(),
}
__all__ = ["apply_roi"]

View File

@@ -0,0 +1,127 @@
"""ROI JSON schema definition + loader.
The ROI descriptor is a **flat axis-aligned bounding box** in the CAD
frame. Six numbers, no nesting:
{
"x_min": -12500.0,
"x_max": 12500.0,
"y_min": 150.0,
"y_max": 4100.0,
"z_min": -1680.0,
"z_max": 200.0
}
Semantics
---------
- All units in **mm**, all coordinates in the CAD frame (x = longitudinal,
y = width, z = height).
- A point ``(x, y, z)`` is kept iff::
x_min <= x <= x_max and y_min <= y <= y_max and z_min <= z <= z_max
- To pass the whole cloud through unchanged, supply bounds wider than the
cloud's own bbox (or use :func:`default_roi_spec`, which returns
``±∞`` on every axis).
"""
from __future__ import annotations
import json
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass
class ROISpec:
"""Axis-aligned bounding box in CAD-frame mm."""
x_min: float
x_max: float
y_min: float
y_max: float
z_min: float
z_max: float
@property
def kind(self) -> str:
"""Stable identifier for logging / reporting."""
return "aabb"
def to_dict(self) -> dict[str, float]:
return {
"x_min": float(self.x_min),
"x_max": float(self.x_max),
"y_min": float(self.y_min),
"y_max": float(self.y_max),
"z_min": float(self.z_min),
"z_max": float(self.z_max),
}
def default_roi_spec() -> ROISpec:
"""Pass-through ROI: infinite bounds on every axis."""
inf = math.inf
return ROISpec(
x_min=-inf, x_max=inf,
y_min=-inf, y_max=inf,
z_min=-inf, z_max=inf,
)
def validate_roi_spec(spec: ROISpec) -> None:
"""Raise ``ValueError`` if any axis has ``min >= max``."""
pairs = (
("x", spec.x_min, spec.x_max),
("y", spec.y_min, spec.y_max),
("z", spec.z_min, spec.z_max),
)
for axis, lo, hi in pairs:
if not (lo < hi):
raise ValueError(
f"ROI {axis}_min must be strictly less than {axis}_max "
f"(got {axis}_min={lo}, {axis}_max={hi})"
)
def load_roi_json(path: str | Path | None) -> ROISpec:
"""Read a ROI JSON file. ``None`` → default pass-through spec.
The file must contain all six keys ``x_min`` / ``x_max`` / ``y_min`` /
``y_max`` / ``z_min`` / ``z_max``. Extra keys are ignored (so you
can attach a ``comment`` etc. without the loader complaining).
"""
if path is None:
return default_roi_spec()
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"roi.json not found: {path}")
with path.open("r", encoding="utf-8") as fh:
data: dict[str, Any] = json.load(fh)
required = ("x_min", "x_max", "y_min", "y_max", "z_min", "z_max")
missing = [k for k in required if k not in data]
if missing:
raise ValueError(f"roi.json {path} is missing required keys: {missing}")
spec = ROISpec(
x_min=float(data["x_min"]),
x_max=float(data["x_max"]),
y_min=float(data["y_min"]),
y_max=float(data["y_max"]),
z_min=float(data["z_min"]),
z_max=float(data["z_max"]),
)
validate_roi_spec(spec)
return spec
__all__ = [
"ROISpec",
"default_roi_spec",
"validate_roi_spec",
"load_roi_json",
]

View File

@@ -0,0 +1,61 @@
"""Mesh-to-point-cloud sampling strategies.
Unified entry point::
from coupler_panel.sampling import sample_mesh
pts = sample_mesh(mesh, method="poisson_disk", num_points=100_000)
Each strategy lives in its own module; the dispatcher just routes by name.
``mesh`` must expose ``vertices: (V, 3)`` and ``faces: (F, 3)`` as ndarrays
(such as our :class:`coupler_panel.cad.mesh.TriangleMesh`).
"""
from __future__ import annotations
from typing import Any
import numpy as np
from .uniform_area import sample_uniform_area
from .poisson_disk import sample_poisson_disk
from .adaptive import sample_adaptive
from .grid_spacing import sample_grid_spacing, num_points_from_spacing
_DISPATCH = {
"uniform_area": sample_uniform_area,
"poisson_disk": sample_poisson_disk,
"adaptive": sample_adaptive,
"grid_spacing": sample_grid_spacing,
}
def sample_mesh(mesh, method: str, **kwargs) -> np.ndarray:
"""Dispatch to the named strategy. ``**kwargs`` are passed through."""
if method not in _DISPATCH:
raise ValueError(
f"unknown sampling method '{method}'; valid: {sorted(_DISPATCH)}"
)
return _DISPATCH[method](mesh, **kwargs)
def resolve_num_points(mesh, *, num_points: int | None, spacing_mm: float | None) -> int:
"""If ``spacing_mm`` is given, convert it to a point count using the
mesh surface area; otherwise return ``num_points``.
"""
if spacing_mm is not None and spacing_mm > 0:
return num_points_from_spacing(mesh, spacing_mm)
if num_points is None:
raise ValueError("either num_points or spacing_mm must be set")
return int(num_points)
__all__ = [
"sample_mesh",
"sample_uniform_area",
"sample_poisson_disk",
"sample_adaptive",
"sample_grid_spacing",
"resolve_num_points",
"num_points_from_spacing",
]

View File

@@ -0,0 +1,104 @@
"""Curvature-adaptive sampling.
Idea: weight every triangle by ``area * (1 + k * curvature_proxy)``, then
do area-weighted sampling on the *weighted* areas. The curvature proxy is
``1 - mean_dot(face_normal, neighbour_face_normals)``, normalised to
[0, 1] across the mesh.
This produces denser samples on rounded edges, hole boundaries, and small
features without needing a real curvature operator.
"""
from __future__ import annotations
from typing import Any
import numpy as np
from .uniform_area import _triangle_areas, sample_uniform_area
def _face_normals(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray:
a = vertices[faces[:, 0]]
b = vertices[faces[:, 1]]
c = vertices[faces[:, 2]]
n = np.cross(b - a, c - a)
norms = np.linalg.norm(n, axis=1, keepdims=True)
norms[norms < 1e-12] = 1.0
return n / norms
def _vertex_face_adjacency(faces: np.ndarray, n_vertices: int) -> list[list[int]]:
adj: list[list[int]] = [[] for _ in range(n_vertices)]
for i, f in enumerate(faces):
adj[f[0]].append(i)
adj[f[1]].append(i)
adj[f[2]].append(i)
return adj
def _curvature_proxy(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray:
"""Per-face roughness in [0, 1]."""
fn = _face_normals(vertices, faces)
adj = _vertex_face_adjacency(faces, vertices.shape[0])
proxy = np.zeros(faces.shape[0], dtype=np.float64)
for i, f in enumerate(faces):
neighbours: set[int] = set()
for v in f:
neighbours.update(adj[v])
neighbours.discard(i)
if not neighbours:
continue
nb = fn[list(neighbours)]
d = np.clip(nb @ fn[i], -1.0, 1.0)
proxy[i] = 1.0 - np.mean(d)
if proxy.max() > 0:
proxy = proxy / proxy.max()
return proxy
def sample_adaptive(
mesh,
*,
num_points: int,
seed: int | None = 42,
curvature_weight: float = 1.0,
**_: Any,
) -> np.ndarray:
"""Curvature-weighted sampling."""
if num_points <= 0:
return np.empty((0, 3), dtype=np.float64)
v = np.asarray(mesh.vertices, dtype=np.float64)
f = np.asarray(mesh.faces, dtype=np.int64)
if f.shape[0] == 0:
return np.empty((0, 3), dtype=np.float64)
# On large meshes the O(F·avg_degree) curvature pass is expensive; if we
# are above ~200k faces, fall back to plain uniform_area, which is the
# natural definition when curvature_weight = 0.
if f.shape[0] > 200_000:
return sample_uniform_area(mesh, num_points=num_points, seed=seed)
areas = _triangle_areas(v, f)
proxy = _curvature_proxy(v, f)
weights = areas * (1.0 + float(curvature_weight) * proxy)
total = weights.sum()
if total <= 0:
return sample_uniform_area(mesh, num_points=num_points, seed=seed)
rng = np.random.default_rng(seed)
probs = weights / total
tri_idx = rng.choice(f.shape[0], size=num_points, p=probs)
u = rng.random(num_points)
w = rng.random(num_points)
flip = u + w > 1.0
u[flip] = 1.0 - u[flip]
w[flip] = 1.0 - w[flip]
a = v[f[tri_idx, 0]]
b = v[f[tri_idx, 1]]
c = v[f[tri_idx, 2]]
return a + (b - a) * u[:, None] + (c - a) * w[:, None]
__all__ = ["sample_adaptive"]

View File

@@ -0,0 +1,56 @@
"""Spacing-driven sampling.
Translates a target inter-point spacing in mm into a point count, then
defers to area-weighted sampling. Useful when you want to sweep over
``spacing_mm ∈ {0.25, 0.5, 1, 2, 5}`` rather than over absolute counts.
For an isotropic surface sample, the mean nearest-neighbour distance is
roughly ``sqrt(area / n)`` (square cells). We invert that:
n ≈ area / spacing²
and add a small fudge factor of 1.15 to compensate for sample variance.
"""
from __future__ import annotations
from typing import Any
import numpy as np
from .uniform_area import _triangle_areas, sample_uniform_area
def num_points_from_spacing(mesh, spacing_mm: float, *, fudge: float = 1.15) -> int:
if spacing_mm <= 0:
raise ValueError("spacing_mm must be > 0")
v = np.asarray(mesh.vertices, dtype=np.float64)
f = np.asarray(mesh.faces, dtype=np.int64)
total_area = float(_triangle_areas(v, f).sum())
n = int(round(fudge * total_area / (spacing_mm ** 2)))
return max(n, 1)
def sample_grid_spacing(
mesh,
*,
spacing_mm: float | None = None,
num_points: int | None = None,
seed: int | None = 42,
fudge: float = 1.15,
**_: Any,
) -> np.ndarray:
"""If ``spacing_mm`` is given, derive ``num_points`` from it.
Falls through to area-weighted uniform sampling.
"""
if spacing_mm is not None:
n = num_points_from_spacing(mesh, float(spacing_mm), fudge=fudge)
elif num_points is not None:
n = int(num_points)
else:
raise ValueError("either spacing_mm or num_points must be set")
return sample_uniform_area(mesh, num_points=n, seed=seed)
__all__ = ["sample_grid_spacing", "num_points_from_spacing"]

View File

@@ -0,0 +1,95 @@
"""Poisson-disk surface sampling.
Uses ``open3d.geometry.TriangleMesh.sample_points_poisson_disk`` when
open3d is available; falls back to area-weighted uniform sampling
followed by an O(N²) Bridson-style culling on small meshes.
"""
from __future__ import annotations
from typing import Any
import numpy as np
from .uniform_area import sample_uniform_area
def _try_open3d():
try:
import open3d as o3d # type: ignore
return o3d
except ImportError:
return None
def sample_poisson_disk(
mesh,
*,
num_points: int,
seed: int | None = 42,
init_factor: int = 5,
**_: Any,
) -> np.ndarray:
"""Poisson-disk samples on a triangle mesh."""
if num_points <= 0:
return np.empty((0, 3), dtype=np.float64)
o3d = _try_open3d()
if o3d is not None:
# open3d wants its own mesh class.
m = o3d.geometry.TriangleMesh()
m.vertices = o3d.utility.Vector3dVector(np.asarray(mesh.vertices, dtype=np.float64))
m.triangles = o3d.utility.Vector3iVector(np.asarray(mesh.faces, dtype=np.int32))
m.compute_vertex_normals()
# Open3D's API takes seed via 'seed' kwarg (0.18+) — passing it best-effort.
try:
pcd = m.sample_points_poisson_disk(
number_of_points=int(num_points),
init_factor=int(init_factor),
seed=int(seed) if seed is not None else -1,
)
except TypeError:
pcd = m.sample_points_poisson_disk(
number_of_points=int(num_points),
init_factor=int(init_factor),
)
return np.asarray(pcd.points, dtype=np.float64)
# ---- Fallback: oversample + grid cull ---------------------------------
over = sample_uniform_area(
mesh,
num_points=num_points * max(init_factor, 5),
seed=seed,
)
# Estimate target spacing from mesh bbox.
bbox = over.max(axis=0) - over.min(axis=0)
if num_points <= 1:
return over[:num_points]
target_spacing = float(np.cbrt(np.prod(bbox)) / max(num_points, 1) ** (1 / 3))
return _grid_cull(over, target_spacing, num_points, seed)
def _grid_cull(
points: np.ndarray,
spacing: float,
keep_n: int,
seed: int | None,
) -> np.ndarray:
"""Pick at most one point per voxel of size ``spacing``."""
rng = np.random.default_rng(seed)
perm = rng.permutation(points.shape[0])
voxel = np.floor(points[perm] / max(spacing, 1e-6)).astype(np.int64)
seen: set[tuple[int, int, int]] = set()
out_idx: list[int] = []
for k, key in enumerate(map(tuple, voxel)):
if key in seen:
continue
seen.add(key)
out_idx.append(perm[k])
if len(out_idx) >= keep_n:
break
return points[np.asarray(out_idx)]
__all__ = ["sample_poisson_disk"]

View File

@@ -0,0 +1,53 @@
"""Area-weighted uniform triangle sampling."""
from __future__ import annotations
from typing import Any
import numpy as np
def _triangle_areas(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray:
a = vertices[faces[:, 0]]
b = vertices[faces[:, 1]]
c = vertices[faces[:, 2]]
return 0.5 * np.linalg.norm(np.cross(b - a, c - a), axis=1)
def sample_uniform_area(
mesh,
*,
num_points: int,
seed: int | None = 42,
**_: Any,
) -> np.ndarray:
"""Sample ``num_points`` from a triangle mesh, weighted by area.
Returns an ``(N, 3) float64`` ndarray.
"""
if num_points <= 0:
return np.empty((0, 3), dtype=np.float64)
v = np.asarray(mesh.vertices, dtype=np.float64)
f = np.asarray(mesh.faces, dtype=np.int64)
areas = _triangle_areas(v, f)
total = float(areas.sum())
if total <= 0:
raise ValueError("mesh has zero total area")
rng = np.random.default_rng(seed)
probs = areas / total
tri_idx = rng.choice(len(f), size=num_points, p=probs)
u = rng.random(num_points)
w = rng.random(num_points)
flip = u + w > 1.0
u[flip] = 1.0 - u[flip]
w[flip] = 1.0 - w[flip]
a = v[f[tri_idx, 0]]
b = v[f[tri_idx, 1]]
c = v[f[tri_idx, 2]]
return a + (b - a) * u[:, None] + (c - a) * w[:, None]
__all__ = ["sample_uniform_area"]

View File

@@ -0,0 +1,21 @@
"""Synthetic-scan utilities: noise, rigid transform, optional view simulator."""
from .noise import add_gaussian_noise
from .transform import (
RandomRigidTransform,
apply_rigid,
compose_rigid,
invert_rigid,
sample_random_rigid,
)
from .scan_simulator import simulate_scan_view
__all__ = [
"add_gaussian_noise",
"RandomRigidTransform",
"apply_rigid",
"compose_rigid",
"invert_rigid",
"sample_random_rigid",
"simulate_scan_view",
]

View File

@@ -0,0 +1,57 @@
"""Synthetic Gaussian noise generation."""
from __future__ import annotations
import numpy as np
def add_gaussian_noise(
points: np.ndarray,
*,
sigma_mm: float,
mode: str = "isotropic",
normals: np.ndarray | None = None,
seed: int | None = 43,
) -> np.ndarray:
"""Return a noisy copy of ``points``.
Parameters
----------
points : (N, 3)
sigma_mm : float
Standard deviation in mm. ``0`` is a no-op (returns a copy).
mode : {"isotropic", "along_normal"}
``isotropic`` — N(0, σ²) noise added independently to x/y/z.
``along_normal`` — N(0, σ²) along ``normals`` only. Requires
a per-point normal of shape ``(N, 3)``.
normals : (N, 3) or None
Required when ``mode == "along_normal"``.
"""
points = np.asarray(points, dtype=np.float64)
if sigma_mm == 0:
return points.copy()
if sigma_mm < 0:
raise ValueError("sigma_mm must be ≥ 0")
rng = np.random.default_rng(seed)
if mode == "isotropic":
return points + rng.normal(0.0, sigma_mm, size=points.shape)
if mode == "along_normal":
if normals is None:
raise ValueError("along_normal mode requires per-point normals")
normals = np.asarray(normals, dtype=np.float64)
if normals.shape != points.shape:
raise ValueError(
f"normals shape {normals.shape} != points shape {points.shape}"
)
norms = np.linalg.norm(normals, axis=1, keepdims=True)
norms[norms < 1e-12] = 1.0
unit = normals / norms
offset = rng.normal(0.0, sigma_mm, size=points.shape[0])[:, None]
return points + unit * offset
raise ValueError(f"unknown noise mode '{mode}'")
__all__ = ["add_gaussian_noise"]

View File

@@ -0,0 +1,80 @@
"""Single-view scan simulator (optional).
Models a structured-light / line-laser scanner that only sees points
visible from a single camera origin. Implementation is the *hidden-point
removal* (HPR) of Katz, Tal & Basri (2007), which works directly on a
point cloud — no mesh raycasting needed.
Open3D ships an HPR primitive; we route to that when available, with a
plain-numpy fallback for the simple convex-hull formulation.
"""
from __future__ import annotations
import numpy as np
def _try_open3d():
try:
import open3d as o3d # type: ignore
return o3d
except ImportError:
return None
def simulate_scan_view(
points: np.ndarray,
*,
view_origin,
hpr_radius_mm: float,
) -> np.ndarray:
"""Return only the points visible from ``view_origin``.
Parameters
----------
view_origin : (3,)
hpr_radius_mm : float
HPR sphere radius. Larger = more points kept. Pick something
much larger than the cloud diameter (e.g. 100× diameter) to
approximate a "all front-facing surfaces" view.
"""
pts = np.asarray(points, dtype=np.float64)
o = np.asarray(view_origin, dtype=np.float64).reshape(3)
o3d = _try_open3d()
if o3d is not None:
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(pts)
_, idx = pcd.hidden_point_removal(o, float(hpr_radius_mm))
return pts[np.asarray(idx, dtype=np.int64)]
# ---- numpy fallback: spherical-flip + convex-hull selection ----------
# Flip every point through a sphere of radius R centred at the view.
diff = pts - o
norms = np.linalg.norm(diff, axis=1, keepdims=True)
norms[norms < 1e-12] = 1.0
R = float(hpr_radius_mm)
flipped = pts + 2.0 * (R - norms) * (diff / norms)
# Add the viewpoint itself as a phantom point so its convex hull
# encloses the visible-from-origin set.
augmented = np.vstack([flipped, o[None, :]])
try:
from scipy.spatial import ConvexHull
hull = ConvexHull(augmented, qhull_options="QJ")
keep = np.asarray(hull.vertices, dtype=np.int64)
# Drop the phantom point.
keep = keep[keep < pts.shape[0]]
return pts[keep]
except Exception:
# Last-resort: keep points whose dot with (point - origin) along
# the centroid axis is positive. Crude but never crashes.
centroid = pts.mean(axis=0)
view_dir = centroid - o
view_dir /= max(np.linalg.norm(view_dir), 1e-12)
d = (pts - o) @ view_dir
return pts[d > 0]
__all__ = ["simulate_scan_view"]

View File

@@ -0,0 +1,162 @@
"""Random rigid transforms used to mimic mounting jitter / scan pose noise.
The transform is stored as a 4×4 homogeneous matrix; the GT used for
validation is exactly this matrix. The 4-hole Kabsch registration step
should recover its inverse up to noise.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Sequence
import numpy as np
@dataclass(frozen=True)
class RandomRigidTransform:
"""Outcome of one random rigid sample."""
matrix: np.ndarray # (4, 4) float64
rotation_deg: float
rotation_axis: tuple[float, float, float]
translation_mm: float
translation_dir: tuple[float, float, float]
seed: int | None
def to_dict(self) -> dict:
return {
"matrix": self.matrix.tolist(),
"rotation_deg": self.rotation_deg,
"rotation_axis": list(self.rotation_axis),
"translation_mm": self.translation_mm,
"translation_dir": list(self.translation_dir),
"seed": self.seed,
}
# ---------------------------------------------------------------------------
# Construction
# ---------------------------------------------------------------------------
def _rodrigues(axis: np.ndarray, angle_rad: float) -> np.ndarray:
"""Rodrigues rotation around a unit ``axis``."""
axis = axis / max(np.linalg.norm(axis), 1e-12)
K = np.array(
[
[0.0, -axis[2], axis[1]],
[axis[2], 0.0, -axis[0]],
[-axis[1], axis[0], 0.0],
],
dtype=np.float64,
)
s = np.sin(angle_rad)
c = np.cos(angle_rad)
return np.eye(3) + s * K + (1.0 - c) * (K @ K)
def _resolve_axis(axis_spec, rng) -> np.ndarray:
if isinstance(axis_spec, str):
spec = axis_spec.lower()
if spec == "x":
return np.array([1.0, 0.0, 0.0])
if spec == "y":
return np.array([0.0, 1.0, 0.0])
if spec == "z":
return np.array([0.0, 0.0, 1.0])
if spec == "random":
v = rng.normal(size=3)
return v / max(np.linalg.norm(v), 1e-12)
raise ValueError(f"unknown rotation_axis '{axis_spec}'")
arr = np.asarray(axis_spec, dtype=np.float64)
if arr.shape != (3,):
raise ValueError("rotation_axis must be 'x'/'y'/'z'/'random' or a 3-vector")
n = np.linalg.norm(arr)
if n < 1e-12:
raise ValueError("rotation_axis vector is degenerate")
return arr / n
def _resolve_dir(dir_spec, rng) -> np.ndarray:
if isinstance(dir_spec, str):
if dir_spec == "random":
v = rng.normal(size=3)
return v / max(np.linalg.norm(v), 1e-12)
raise ValueError(f"unknown translation_dir '{dir_spec}'")
arr = np.asarray(dir_spec, dtype=np.float64)
if arr.shape != (3,):
raise ValueError("translation_dir must be 'random' or a 3-vector")
n = np.linalg.norm(arr)
if n < 1e-12:
raise ValueError("translation_dir vector is degenerate")
return arr / n
def sample_random_rigid(
*,
rotation_deg: float,
translation_mm: float,
rotation_axis: str | Sequence[float] = "random",
translation_dir: str | Sequence[float] = "random",
seed: int | None = 44,
) -> RandomRigidTransform:
rng = np.random.default_rng(seed)
axis = _resolve_axis(rotation_axis, rng)
direction = _resolve_dir(translation_dir, rng)
R = _rodrigues(axis, np.deg2rad(float(rotation_deg)))
t = direction * float(translation_mm)
M = np.eye(4)
M[:3, :3] = R
M[:3, 3] = t
return RandomRigidTransform(
matrix=M,
rotation_deg=float(rotation_deg),
rotation_axis=tuple(axis.tolist()),
translation_mm=float(translation_mm),
translation_dir=tuple(direction.tolist()),
seed=seed,
)
# ---------------------------------------------------------------------------
# Application
# ---------------------------------------------------------------------------
def apply_rigid(points: np.ndarray, T: np.ndarray) -> np.ndarray:
"""Apply a 4×4 homogeneous transform to an ``(N, 3)`` cloud."""
P = np.asarray(points, dtype=np.float64)
if P.ndim != 2 or P.shape[1] != 3:
raise ValueError(f"expected (N,3), got {P.shape}")
R = np.asarray(T, dtype=np.float64)[:3, :3]
t = np.asarray(T, dtype=np.float64)[:3, 3]
return P @ R.T + t
def compose_rigid(*Ts: np.ndarray) -> np.ndarray:
out = np.eye(4)
for T in Ts:
out = out @ np.asarray(T, dtype=np.float64)
return out
def invert_rigid(T: np.ndarray) -> np.ndarray:
R = T[:3, :3]
t = T[:3, 3]
inv = np.eye(4)
inv[:3, :3] = R.T
inv[:3, 3] = -R.T @ t
return inv
__all__ = [
"RandomRigidTransform",
"sample_random_rigid",
"apply_rigid",
"compose_rigid",
"invert_rigid",
]

View File

@@ -0,0 +1,148 @@
"""Result dataclasses for coupler_panel.
These types form the **stable public schema** of the package; their
field names and semantics are guarded by ``SCHEMA_VERSION`` (see
``_version.py``).
All numeric units are **mm** for distances and **degree** for angles
unless otherwise noted.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any
import numpy as np
# ---------------------------------------------------------------------------
# Building blocks
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Hole:
"""A single detected (or GT) circular hole on the panel."""
center: tuple[float, float, float] # (x, y, z), mm
radius_mm: float
fit_residual_rms_mm: float # 0 for GT
role: str # "locating" | "central" | "unknown"
@dataclass(frozen=True)
class HoleSet:
"""The four locating holes plus an optional central hole.
``locating`` is ordered consistently between GT and detection so that
Kabsch pairing is index-aligned.
"""
locating: tuple[Hole, Hole, Hole, Hole]
central: Hole | None = None
def locating_centers(self) -> np.ndarray:
"""Return a (4, 3) ndarray of locating-hole centres."""
return np.asarray([h.center for h in self.locating], dtype=np.float64)
@dataclass(frozen=True)
class PanelPlane:
"""The fitted (or GT) panel plane."""
normal: tuple[float, float, float] # unit vector, n_x > 0 enforced
point_on_plane: tuple[float, float, float]
d_offset: float # n . x + d = 0
inlier_ratio: float # 1.0 for GT
inlier_residual_rms_mm: float # 0 for GT
@dataclass(frozen=True)
class ReferenceFrame:
"""Reference geometry derived from the four-hole panel centre."""
center_C: tuple[float, float, float]
# Reference line L = C + lam * e_x (always parallel to the +x axis).
line_direction: tuple[float, float, float] = (1.0, 0.0, 0.0)
# Reference plane: z = C_z. Stored as plane normal + offset.
reference_plane_z: float = 0.0
# Longitudinal mid-plane: y = C_y.
longitudinal_mid_y: float = 0.0
@dataclass(frozen=True)
class StageTiming:
"""Per-stage wall-clock and peak-memory cost."""
stage: str
seconds: float
peak_kib: float
# ---------------------------------------------------------------------------
# Top-level result
# ---------------------------------------------------------------------------
@dataclass
class CouplerMeasurement:
"""Top-level result of the coupler-panel measurement pipeline.
See ``docs/USAGE.md`` for the stable-API contract.
"""
schema_version: str
algorithm_version: str
# --- Primary measurands ---
theta_right_deg: float # atan2(n_y, n_x), with right_axis_sign applied
theta_ref_deg: float # atan2(n_z, n_x)
D_y_max_mm: float # max |y_i - C_y| over panel ROI
D_z_max_mm: float # max |z_i - C_z| over panel ROI
dx_y_max_mm: float # |A| * D_y_max, A = -n_y / n_x
dx_z_max_mm: float # |B| * D_z_max, B = -n_z / n_x
# --- Geometry ---
center_C: tuple[float, float, float]
normal: tuple[float, float, float] # n_x > 0
holes: HoleSet
plane: PanelPlane
frame: ReferenceFrame
# --- Diagnostics ---
quality_flags: list[str] = field(default_factory=list)
timings: list[StageTiming] = field(default_factory=list)
report: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""Return a fully JSON-serialisable dict."""
return _to_jsonable(asdict(self))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _to_jsonable(obj: Any) -> Any:
"""Recursively convert numpy / tuple structures into plain Python types."""
if isinstance(obj, dict):
return {k: _to_jsonable(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_to_jsonable(v) for v in obj]
if isinstance(obj, np.ndarray):
return obj.tolist()
if isinstance(obj, (np.floating, np.integer)):
return obj.item()
return obj
__all__ = [
"Hole",
"HoleSet",
"PanelPlane",
"ReferenceFrame",
"StageTiming",
"CouplerMeasurement",
]

View File

@@ -0,0 +1,21 @@
"""Diagnostic visualisation utilities (matplotlib + open3d offscreen).
All renderers degrade gracefully if matplotlib / open3d are missing —
they raise :class:`ImportError` only when actually called. The pipeline
calls them via :func:`render_all`, which silently skips on missing deps.
"""
from .cloud_view import render_cloud, render_cloud_compare
from .holes_view import render_holes
from .plane_view import render_plane_with_refs
from .heatmap import render_panel_heatmap
from .render_all import render_all
__all__ = [
"render_cloud",
"render_cloud_compare",
"render_holes",
"render_plane_with_refs",
"render_panel_heatmap",
"render_all",
]

View File

@@ -0,0 +1,27 @@
"""Open3D offscreen renderer factory.
Wraps the shifting open3d offscreen API so callers don't have to. Returns
``None`` if open3d is not available — callers should fall back to
matplotlib in that case.
"""
from __future__ import annotations
from typing import Any
def make_offscreen_renderer(width: int = 1024, height: int = 768) -> Any | None:
try:
import open3d as o3d # type: ignore
except ImportError:
return None
# open3d's offscreen path moved between 0.16 / 0.18.
try:
renderer = o3d.visualization.rendering.OffscreenRenderer(width, height)
return renderer
except Exception:
return None
__all__ = ["make_offscreen_renderer"]

View File

@@ -0,0 +1,134 @@
"""Point cloud rendering using matplotlib.
By default we render a 2D (y, z) projection — the coupler panel is a thin
sheet whose normal is along x, so a top-down y-z view is more readable
than a 3D scatter. Pass ``mode="3d"`` to force a 3D scatter (slower and
broken on some matplotlib versions, kept for completeness).
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
def _import_plt():
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: WPS433
return plt
def _decimate(points: np.ndarray, max_points: int) -> np.ndarray:
n = points.shape[0]
if n <= max_points:
return points
rng = np.random.default_rng(0)
idx = rng.choice(n, size=max_points, replace=False)
return points[idx]
def render_cloud(
points: np.ndarray,
out_path: str | Path,
*,
title: str = "",
color: str | np.ndarray = "tab:blue",
max_points: int = 80_000,
dpi: int = 150,
mode: str = "2d",
) -> Path:
plt = _import_plt()
pts = _decimate(np.asarray(points, dtype=np.float64), max_points)
if mode == "3d":
return _render_3d(pts, out_path, title=title, color=color, dpi=dpi)
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
axes[0].scatter(pts[:, 1], pts[:, 2], s=0.5, c=color)
axes[0].set_xlabel("y (mm)")
axes[0].set_ylabel("z (mm)")
axes[0].set_aspect("equal")
axes[0].set_title("y-z projection (panel face)")
axes[1].scatter(pts[:, 0], pts[:, 2], s=0.5, c=color)
axes[1].set_xlabel("x (mm)")
axes[1].set_ylabel("z (mm)")
axes[1].set_aspect("equal")
axes[1].set_title("x-z projection (side view)")
if title:
fig.suptitle(title)
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out_path, dpi=dpi)
plt.close(fig)
return out_path
def render_cloud_compare(
clouds: dict[str, np.ndarray],
out_path: str | Path,
*,
title: str = "",
max_points: int = 50_000,
dpi: int = 150,
) -> Path:
"""Side-by-side y-z projections of several clouds."""
plt = _import_plt()
n = len(clouds)
fig, axes = plt.subplots(1, n, figsize=(6 * n, 6), squeeze=False)
cmap = plt.get_cmap("tab10")
for i, (name, pts) in enumerate(clouds.items()):
ax = axes[0, i]
sub = _decimate(np.asarray(pts, dtype=np.float64), max_points)
ax.scatter(sub[:, 1], sub[:, 2], s=0.5, c=[cmap(i)])
ax.set_title(name)
ax.set_xlabel("y (mm)")
ax.set_ylabel("z (mm)")
ax.set_aspect("equal")
if title:
fig.suptitle(title)
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out_path, dpi=dpi)
plt.close(fig)
return out_path
def _render_3d(pts, out_path, *, title, color, dpi):
plt = _import_plt()
fig = plt.figure(figsize=(8, 6))
try:
ax = fig.add_subplot(111, projection="3d")
ax.scatter(pts[:, 0], pts[:, 1], pts[:, 2], s=0.5, c=color)
ax.set_xlabel("x (mm)")
ax.set_ylabel("y (mm)")
ax.set_zlabel("z (mm)")
if title:
ax.set_title(title)
except Exception:
# Fallback to 2D when matplotlib's 3D backend is broken (e.g. mpl 3.10
# has a known incompatibility with mpl_toolkits.mplot3d).
ax = fig.add_subplot(111)
ax.scatter(pts[:, 1], pts[:, 2], s=0.5, c=color)
ax.set_aspect("equal")
ax.set_xlabel("y (mm)")
ax.set_ylabel("z (mm)")
if title:
ax.set_title(f"{title} (3D backend unavailable)")
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out_path, dpi=dpi)
plt.close(fig)
return out_path
__all__ = ["render_cloud", "render_cloud_compare"]

View File

@@ -0,0 +1,73 @@
"""Panel deviation heatmap.
Colours each panel-ROI point by its signed perpendicular distance to
the fitted plane, projected onto a 2D image of the panel face.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
def _import_plt():
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
return plt
def render_panel_heatmap(
panel_points: np.ndarray,
*,
plane_normal: tuple[float, float, float],
plane_d: float,
center_C: tuple[float, float, float],
out_path: str | Path,
title: str = "",
cmap: str = "viridis",
dpi: int = 150,
) -> Path:
plt = _import_plt()
pts = np.asarray(panel_points, dtype=np.float64)
n = np.asarray(plane_normal, dtype=np.float64)
n = n / max(np.linalg.norm(n), 1e-12)
C = np.asarray(center_C, dtype=np.float64)
# Local 2D frame on the panel.
y_world = np.array([0.0, 1.0, 0.0])
u = y_world - np.dot(y_world, n) * n
if np.linalg.norm(u) < 1e-6:
u = np.array([0.0, 0.0, 1.0]) - np.dot(np.array([0.0, 0.0, 1.0]), n) * n
u /= np.linalg.norm(u)
v = np.cross(n, u)
rel = pts - C
uu = rel @ u
vv = rel @ v
signed_dist = pts @ n + float(plane_d)
fig, ax = plt.subplots(figsize=(8, 7))
sc = ax.scatter(uu, vv, c=signed_dist, cmap=cmap, s=2)
cb = plt.colorbar(sc, ax=ax)
cb.set_label("signed distance to fitted plane (mm)")
ax.set_aspect("equal")
ax.set_xlabel("panel u (≈ y axis, mm)")
ax.set_ylabel("panel v (≈ z axis, mm)")
if title:
ax.set_title(title)
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out_path, dpi=dpi)
plt.close(fig)
return out_path
__all__ = ["render_panel_heatmap"]

View File

@@ -0,0 +1,70 @@
"""4-hole detection diagnostic plot.
Shows the panel cloud projected to its local 2D frame, with each
detected circle drawn on top and the four selected locating holes
highlighted.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
def _import_plt():
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Circle # noqa: WPS433
return plt, Circle
def render_holes(
uv_points: np.ndarray,
candidates: list,
selected_indices: list[int],
out_path: str | Path,
*,
title: str = "",
dpi: int = 150,
) -> Path:
plt, Circle = _import_plt()
fig, ax = plt.subplots(figsize=(8, 8))
if uv_points.shape[0] > 50_000:
idx = np.random.default_rng(0).choice(uv_points.shape[0], 50_000, replace=False)
sub = uv_points[idx]
else:
sub = uv_points
ax.scatter(sub[:, 0], sub[:, 1], s=0.3, c="lightgray")
for i, cand in enumerate(candidates):
is_sel = i in set(selected_indices)
col = "tab:red" if is_sel else "tab:blue"
ax.add_patch(
Circle(
(cand.fit.cx, cand.fit.cy),
cand.fit.radius,
fill=False,
edgecolor=col,
lw=2 if is_sel else 1,
)
)
ax.plot(cand.fit.cx, cand.fit.cy, "+", color=col)
ax.set_aspect("equal")
ax.set_xlabel("u (mm)")
ax.set_ylabel("v (mm)")
if title:
ax.set_title(title)
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out_path, dpi=dpi)
plt.close(fig)
return out_path
__all__ = ["render_holes"]

View File

@@ -0,0 +1,105 @@
"""Plane + reference geometry overlay (2D projection).
Renders the y-z view of the panel ROI with the fitted plane normal,
reference line L, reference plane z=C_z, and longitudinal mid y=C_y
overlaid as annotations.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
def _import_plt():
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
return plt
def render_plane_with_refs(
panel_points: np.ndarray,
*,
plane_normal: tuple[float, float, float],
center_C: tuple[float, float, float],
out_path: str | Path,
title: str = "",
line_length_mm: float = 200.0,
dpi: int = 150,
) -> Path:
plt = _import_plt()
pts = np.asarray(panel_points, dtype=np.float64)
n = np.asarray(plane_normal, dtype=np.float64)
n = n / max(np.linalg.norm(n), 1e-12)
C = np.asarray(center_C, dtype=np.float64)
if pts.shape[0] > 80_000:
idx = np.random.default_rng(0).choice(pts.shape[0], 80_000, replace=False)
pts = pts[idx]
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# Panel face view: y-z, with a small arrow for the normal projected
# into y-z (the in-plane part of n).
ax = axes[0]
ax.scatter(pts[:, 1], pts[:, 2], s=0.5, c="lightgray")
# Reference plane z = C_z
ax.axhline(C[2], color="tab:green", lw=1, alpha=0.7, label=f"ref plane z = {C[2]:.2f}")
# Longitudinal mid y = C_y
ax.axvline(C[1], color="tab:orange", lw=1, alpha=0.7, label=f"long. mid y = {C[1]:.2f}")
ax.scatter([C[1]], [C[2]], color="black", s=40, zorder=5, label=f"C = ({C[1]:.1f}, {C[2]:.1f})")
# Project normal into y-z
n_yz = np.array([n[1], n[2]])
if np.linalg.norm(n_yz) > 1e-3:
scale = line_length_mm * 0.5
ax.annotate(
"",
xy=(C[1] + n_yz[0] * scale, C[2] + n_yz[1] * scale),
xytext=(C[1], C[2]),
arrowprops=dict(arrowstyle="->", color="tab:red", lw=2),
)
ax.set_aspect("equal")
ax.set_xlabel("y (mm)")
ax.set_ylabel("z (mm)")
ax.set_title("y-z view: ROI + reference geometry + n's y-z projection")
ax.legend(loc="upper right", fontsize="x-small")
# x-y side view: shows the panel tilt out of x.
ax = axes[1]
ax.scatter(pts[:, 0], pts[:, 1], s=0.5, c="lightgray")
# Reference line L (along +x at y=C_y, z=C_z, projected to x-y → horizontal at y=C_y)
L = float(line_length_mm)
ax.plot([C[0] - L, C[0] + L], [C[1], C[1]], color="tab:blue", lw=2, label="ref line L")
# Plane normal (x, y components)
n_xy = np.array([n[0], n[1]])
scale = line_length_mm * 0.5
ax.annotate(
"",
xy=(C[0] + n_xy[0] * scale, C[1] + n_xy[1] * scale),
xytext=(C[0], C[1]),
arrowprops=dict(arrowstyle="->", color="tab:red", lw=2),
)
ax.scatter([C[0]], [C[1]], color="black", s=40, zorder=5)
ax.set_aspect("equal")
ax.set_xlabel("x (mm)")
ax.set_ylabel("y (mm)")
ax.set_title(f"x-y view: n=({n[0]:.4f},{n[1]:.4f},{n[2]:.4f})")
ax.legend(loc="upper right", fontsize="x-small")
if title:
fig.suptitle(title)
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out_path, dpi=dpi)
plt.close(fig)
return out_path
__all__ = ["render_plane_with_refs"]

View File

@@ -0,0 +1,112 @@
"""One-shot rendering of every diagnostic image for a single run.
Reads the artifact directory written by :mod:`coupler_panel.pipeline` and
fills in ``images/`` with PNG plots. Silently skips any stage that
cannot be loaded (e.g. ``denoised.ply`` if denoise was disabled).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import numpy as np
from ..io_utils import load_point_cloud, Artifacts
def _safe_load(path: Path) -> np.ndarray | None:
try:
return load_point_cloud(path) if path.exists() else None
except Exception:
return None
def render_all(run_dir: str | Path, *, dpi: int = 150) -> dict[str, Path]:
"""Render every diagnostic image we know how to make for this run."""
art = Artifacts(run_dir)
out: dict[str, Path] = {}
# Lazy imports — matplotlib not strictly required to run pipeline.
try:
from .cloud_view import render_cloud, render_cloud_compare
from .plane_view import render_plane_with_refs
from .heatmap import render_panel_heatmap
except ImportError:
return out
stages = ("cad_mesh", "sampled", "noisy", "transformed", "denoised", "registered")
clouds: dict[str, np.ndarray] = {}
for st in stages:
pts = _safe_load(art.path(st, ext="ply"))
if pts is not None and pts.shape[0] > 0:
clouds[st] = pts
try:
out[st] = render_cloud(
pts, art.image(f"{st}.png"), title=st, dpi=dpi,
)
except Exception:
pass
# Per-stage 2-up comparisons of selected pairs.
if "sampled" in clouds and "noisy" in clouds:
try:
out["sampled_vs_noisy"] = render_cloud_compare(
{"sampled": clouds["sampled"], "noisy": clouds["noisy"]},
art.image("sampled_vs_noisy.png"),
dpi=dpi,
)
except Exception:
pass
if "transformed" in clouds and "registered" in clouds:
try:
out["transformed_vs_registered"] = render_cloud_compare(
{"transformed": clouds["transformed"], "registered": clouds["registered"]},
art.image("transformed_vs_registered.png"),
dpi=dpi,
)
except Exception:
pass
# Plane + reference geometry (read from report.json).
report_path = art.report_json
if report_path.exists():
try:
import json
with report_path.open("r", encoding="utf-8") as fh:
report: dict[str, Any] = json.load(fh)
measured = report.get("measured", report)
plane = measured.get("plane")
frame = measured.get("frame")
roi_pts_path = art.path("panel_roi", ext="ply")
roi_pts = _safe_load(roi_pts_path)
if roi_pts is None and "registered" in clouds:
roi_pts = clouds["registered"]
if plane and frame and roi_pts is not None and roi_pts.shape[0] > 0:
out["plane_with_refs"] = render_plane_with_refs(
roi_pts,
plane_normal=tuple(plane["normal"]),
center_C=tuple(frame["center_C"]),
out_path=art.image("plane_with_refs.png"),
title="fitted plane + reference geometry",
dpi=dpi,
)
out["heatmap"] = render_panel_heatmap(
roi_pts,
plane_normal=tuple(plane["normal"]),
plane_d=float(plane["d_offset"]),
center_C=tuple(frame["center_C"]),
out_path=art.image("deviation_heatmap.png"),
title="panel deviation heatmap",
dpi=dpi,
)
except Exception:
pass
return out
__all__ = ["render_all"]

View File

@@ -0,0 +1,128 @@
"""Generate synthetic test point clouds at varying injected panel-deflection
angles. Each cloud is produced by the *existing* CAD synthesis path
(parse → sample → deflect → noise) **with the scanner-pose jitter step
turned off**, so each PLY already lives in the CAD frame — which is the
contract the main measurement pipeline expects (no registration step).
Each PLY is paired with one row in ``manifest.json`` so the batch
evaluator knows the GT angle to compare against.
Run::
python examples/generate_test_clouds.py \
--stp test_data/lu1.stp \
--out test_data/synth \
--profile lu1_af4
"""
from __future__ import annotations
import argparse
import logging
from pathlib import Path
from coupler_panel.config import load_config, resolve_auto
from coupler_panel.data_prep import parse_cad, synthesize_cloud
from coupler_panel.io_utils import write_point_cloud, write_json
# (theta_right_deg, theta_ref_deg) pairs — mirrors the spirit of the
# existing lu1_deflection_sweep matrix.
DEFAULT_INJECTIONS = [
(0.0, 0.0),
(0.1, 0.0),
(0.3, 0.0),
(0.5, 0.0),
(1.0, 0.0),
(2.0, 0.0),
(0.0, 0.5),
(0.5, 0.5),
(1.0, 1.0),
]
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--stp", type=Path, required=True, help="Path to the panel STEP file.")
p.add_argument("--out", type=Path, required=True, help="Output directory for PLYs + manifest.")
p.add_argument("--profile", type=str, default="lu1_af4", help="Config profile.")
p.add_argument(
"--config",
type=Path,
default=None,
help="Optional YAML override; takes precedence over --profile.",
)
return p
def main(argv: list[str] | None = None) -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("generate_test_clouds")
args = _build_parser().parse_args(argv)
cfg = load_config(args.config, profile=args.profile)
cfg = resolve_auto(cfg)
log.info("parsing CAD: %s", args.stp)
parsed = parse_cad(args.stp, cfg["cad"])
out_dir = args.out
out_dir.mkdir(parents=True, exist_ok=True)
# CAD nominal GT — kept as an audit reference (not consumed by the
# main pipeline, which only needs the STP).
write_json(parsed.gt.to_dict(), out_dir / "gt.json")
manifest: list[dict] = []
for i, (th_r, th_z) in enumerate(DEFAULT_INJECTIONS):
cfg_i = dict(cfg)
cfg_i["panel_deflection"] = {
"enabled": True,
"theta_right_deg": float(th_r),
"theta_ref_deg": float(th_z),
}
# Scanner-pose jitter is OFF: the cloud must already be in the
# CAD frame because the main pipeline does not register.
cfg_i["transform"] = {"enabled": False}
log.info("[%d/%d] synthesising θ_right=%+.2f° θ_ref=%+.2f°",
i + 1, len(DEFAULT_INJECTIONS), th_r, th_z)
synth = synthesize_cloud(
parsed,
sampling_cfg=cfg_i["sampling"],
noise_cfg=cfg_i.get("noise", {}),
transform_cfg=cfg_i["transform"],
scan_cfg=cfg_i.get("scan_simulator", {}),
deflection_cfg=cfg_i["panel_deflection"],
)
sample_id = (
f"r{th_r:+.2f}_z{th_z:+.2f}"
.replace("+", "p").replace("-", "n").replace(".", "")
)
ply_path = out_dir / f"cloud_{sample_id}.ply"
# synth.transformed == synth.noisy when transform is disabled.
write_point_cloud(synth.transformed, ply_path)
manifest.append({
"sample_id": sample_id,
"ply": ply_path.name,
"theta_right_deg": float(th_r),
"theta_ref_deg": float(th_z),
"n_points": int(synth.transformed.shape[0]),
})
write_json(
{
"stp": str(args.stp),
"profile": args.profile,
"gt": "gt.json",
"samples": manifest,
},
out_dir / "manifest.json",
)
log.info("done: %d clouds + gt.json + manifest.json under %s", len(manifest), out_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,58 @@
"""Minimal integration example for software developers.
This script shows the *only* code path a production caller needs:
load a clean, registered point cloud → call the API → use the result
Run::
python examples/integrate_minimal.py path/to/scan.ply
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from coupler_panel import (
CouplerError,
compute_coupler_measurement,
load_point_cloud,
)
def main(input_path: Path) -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("integrate_minimal")
points = load_point_cloud(input_path)
log.info("loaded %d points from %s", points.shape[0], input_path)
try:
result = compute_coupler_measurement(
points,
progress=lambda s, f: log.info("[%5.1f%%] %s", f * 100, s),
)
except CouplerError as exc:
log.error("measurement failed: %s", exc)
return 2
print(f"theta_right_deg = {result.theta_right_deg:+.4f}")
print(f"theta_ref_deg = {result.theta_ref_deg:+.4f}")
print(f"D_y_max_mm = {result.D_y_max_mm:.3f}")
print(f"D_z_max_mm = {result.D_z_max_mm:.3f}")
print(f"dx_y_max_mm = {result.dx_y_max_mm:.3f}")
print(f"dx_z_max_mm = {result.dx_z_max_mm:.3f}")
print(f"center_C = {tuple(round(c, 3) for c in result.center_C)}")
print(f"normal = {tuple(round(c, 6) for c in result.normal)}")
print(f"quality_flags = {result.quality_flags}")
return 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print(__doc__.strip())
sys.exit(1)
sys.exit(main(Path(sys.argv[1])))

View File

@@ -0,0 +1,8 @@
{
"x_min": -12000.0,
"x_max": 100.0,
"y_min": -300.0,
"y_max": 1200.0,
"z_min": -1000.0,
"z_max": 800.0
}

View File

@@ -0,0 +1,147 @@
"""Batch evaluator: main measurement program with CAD + cloud + ROI inputs.
For each PLY listed in the manifest:
1. Load the cloud (PLY).
2. Apply the ROI JSON (currently ``mode='all'`` placeholder).
3. Feed (CAD STP, ROI'd cloud, GT JSON) into the existing measurement
pipeline — internal stages (denoise / Kabsch / detection / ROI /
plane fit / metrics) are unchanged.
4. Compare θ_right / θ_ref against the injected GT recorded in the
manifest.
5. Append a row to the Excel report.
Run::
python examples/run_batch_eval.py \
--cad test_data/lu1.stp \
--data test_data/synth \
--roi examples/roi_all.json \
--out runs/batch_eval \
--profile lu1_af4
"""
from __future__ import annotations
import argparse
import json
import logging
import time
from pathlib import Path
from coupler_panel.exceptions import CouplerError
from coupler_panel.io_utils import load_point_cloud
from coupler_panel.pipeline import run_coupler_measurement_pipeline
from coupler_panel.reporting import build_row_from_summary, write_excel_report
from coupler_panel.roi_select import load_roi_json
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--cad", type=Path, required=True, help="STEP file (panel CAD).")
p.add_argument("--data", type=Path, required=True, help="Directory with PLYs + manifest.json.")
p.add_argument("--roi", type=Path, required=True, help="ROI JSON file.")
p.add_argument("--out", type=Path, required=True, help="Output directory for runs + xlsx.")
p.add_argument("--profile", type=str, default="lu1_af4", help="Config profile.")
p.add_argument("--config", type=Path, default=None, help="Optional YAML override.")
return p
def main(argv: list[str] | None = None) -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("run_batch_eval")
args = _build_parser().parse_args(argv)
args.out.mkdir(parents=True, exist_ok=True)
manifest_path = args.data / "manifest.json"
with manifest_path.open("r", encoding="utf-8") as fh:
manifest = json.load(fh)
samples = manifest["samples"]
roi_spec = load_roi_json(args.roi)
log.info(
"loaded ROI spec (%s): x∈[%g,%g] y∈[%g,%g] z∈[%g,%g]",
roi_spec.kind,
roi_spec.x_min, roi_spec.x_max,
roi_spec.y_min, roi_spec.y_max,
roi_spec.z_min, roi_spec.z_max,
)
log.info("samples in manifest: %d", len(samples))
rows = []
for i, sample in enumerate(samples, start=1):
sid = sample["sample_id"]
ply = args.data / sample["ply"]
log.info("[%d/%d] %s — %s", i, len(samples), sid, ply.name)
run_dir = args.out / sid
try:
# Pre-count points to record n_points_in (before ROI).
n_in = int(load_point_cloud(ply).shape[0])
# Main pipeline: CAD STP is parsed for the panel-normal seed
# only; the cloud is assumed to be in the CAD frame already
# (scanner extrinsics calibrated upstream). No Kabsch.
# The wall-clock duration from "inputs enter the algorithm"
# to "result returned" goes straight into the Excel report.
t_start = time.perf_counter()
result = run_coupler_measurement_pipeline(
input_cloud=ply,
out_dir=run_dir,
config_path=args.config,
profile=args.profile,
cad_stp_path=args.cad,
roi_spec=roi_spec,
)
pipeline_seconds = time.perf_counter() - t_start
except CouplerError as exc:
log.error(" -> FAILED: %s", exc)
continue
except Exception as exc:
log.exception(" -> CRASH: %s", exc)
continue
# n_after_roi is recorded in run_dir/roi.json.diagnostics.n_out
roi_diag_path = run_dir / "roi.json"
n_after = n_in
if roi_diag_path.exists():
with roi_diag_path.open("r", encoding="utf-8") as fh:
n_after = int(json.load(fh).get("diagnostics", {}).get("n_out", n_in))
# Kabsch RMSE: pull from the prepared/registration log if present
# (the persisted pipeline does not currently expose this in the
# report dict; it's available via report.json side-files but for a
# minimum-touch wiring we leave it None when not surfaced).
registration = None
row = build_row_from_summary(
sample_id=sid,
ply_path=str(ply),
roi_kind=roi_spec.kind,
measured=result.to_dict(),
gt_angles=(float(sample["theta_right_deg"]), float(sample["theta_ref_deg"])),
registration=registration,
n_points_in=n_in,
n_points_after_roi=n_after,
pipeline_seconds=pipeline_seconds,
)
rows.append(row)
log.info(" θ_right pred=%+.4f° GT=%+.2f° err=%+.4f°",
row.theta_right_pred_deg, row.theta_right_gt_deg, row.theta_right_err_deg)
log.info(" θ_ref pred=%+.4f° GT=%+.2f° err=%+.4f°",
row.theta_ref_pred_deg, row.theta_ref_gt_deg, row.theta_ref_err_deg)
log.info(" runtime: %.3f s (inner stages: %.3f s)",
row.pipeline_seconds, row.inner_stage_seconds)
if not rows:
log.error("no successful runs; nothing to write")
return 2
xlsx_path = args.out / "eval_report.xlsx"
write_excel_report(rows, xlsx_path)
log.info("wrote %s (%d rows)", xlsx_path, len(rows))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,43 @@
"""Full research demo: STP → synthetic cloud → measure → diff GT.
Run::
python examples/run_full_validation.py path/to/panel.stp runs/demo
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
from coupler_panel.pipeline import run_from_cad_pipeline
def main(stp: Path, out: Path) -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
summary = run_from_cad_pipeline(
stp_path=stp,
out_dir=out,
progress=lambda s, f: print(f"[{f*100:5.1f}%] {s}", flush=True),
)
print("\n=== diff (measured - GT) ===")
print(json.dumps(summary["diff"], indent=2))
# Render diagnostic PNGs.
try:
from coupler_panel.viz import render_all
render_all(out)
except ImportError:
print("(matplotlib not installed; skip rendering)")
return 0
if __name__ == "__main__":
if len(sys.argv) != 3:
print(__doc__.strip())
sys.exit(1)
sys.exit(main(Path(sys.argv[1]), Path(sys.argv[2])))

View File

@@ -0,0 +1,89 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "coupler-panel-validation"
version = "0.1.0"
description = "CAD-STP to point-cloud measurement validation system for coupler panel deflection."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "Proprietary" }
authors = [{ name = "PKU High-Speed Train Project" }]
keywords = ["point-cloud", "cad", "step", "metrology", "rail", "coupler"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Manufacturing",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering",
]
# Core: pure compute (no plotting, no CAD).
dependencies = [
"numpy>=1.24",
"scipy>=1.10",
"pyyaml>=6.0",
"tqdm>=4.65",
]
[project.optional-dependencies]
# Required to run the full simulation / measurement pipeline:
# point-cloud IO, sampling, denoising, registration helpers.
cloud = [
"open3d>=0.18",
"trimesh>=4.0",
]
# Required to parse STP files into a tessellated mesh and to extract
# topological GT (hole circles, panel plane).
cad = [
"cadquery-ocp>=7.7",
"trimesh>=4.0",
]
# Visualisation (PNG diagnostics + offscreen 3D renders).
viz = [
"matplotlib>=3.7",
"open3d>=0.18",
]
# Excel batch-evaluation reporting (reporting.excel).
report = [
"openpyxl>=3.1",
]
# Convenience: install everything (compute + cad + cloud + plotting + report).
all = [
"open3d>=0.18",
"trimesh>=4.0",
"cadquery-ocp>=7.7",
"matplotlib>=3.7",
"openpyxl>=3.1",
]
dev = [
"pytest>=7",
"pytest-cov",
]
[project.scripts]
coupler-measure = "coupler_panel.cli:main"
[tool.setuptools]
packages = [
"coupler_panel",
"coupler_panel.cad",
"coupler_panel.sampling",
"coupler_panel.simulation",
"coupler_panel.denoise",
"coupler_panel.registration",
"coupler_panel.detection",
"coupler_panel.geometry",
"coupler_panel.metrics",
"coupler_panel.viz",
"coupler_panel.io_utils",
"coupler_panel.roi_select",
"coupler_panel.reporting",
]
include-package-data = true
[tool.setuptools.package-data]
coupler_panel = ["py.typed"]

View File

@@ -0,0 +1,52 @@
# coupler-panel-validation — locked dependency list.
#
# Versions in this file are the ones the package has been *end-to-end
# verified* against on Python 3.10 (Windows 11). For a clean install use:
#
# python -m pip install -r requirements.txt
# pip install -e .
#
# If you only need a subset of the functionality, use the optional
# extras declared in pyproject.toml instead:
#
# pip install -e ".[cloud]" # core + point-cloud IO + denoise
# pip install -e ".[cad]" # core + STP parsing + GT extraction
# pip install -e ".[viz]" # core + matplotlib diagnostics
# pip install -e ".[report]" # core + Excel batch reporting
# pip install -e ".[all]" # everything above
#
# ---------------------------------------------------------------------------
# Core compute — required even for the thinnest integration deployment.
# ---------------------------------------------------------------------------
numpy==1.26.4
scipy==1.15.3
pyyaml==6.0.2
tqdm==4.65.0
# ---------------------------------------------------------------------------
# Point-cloud IO + denoise helpers (PLY/PCD read & write, voxel/SOR/ROR,
# normal-via-PCA). Required for the main pipeline; the package will fall
# back to ASCII-only PLY reading without open3d, which is not enough for
# the binary PLYs produced by the data generator.
# ---------------------------------------------------------------------------
open3d==0.19.0
trimesh==4.12.2
# ---------------------------------------------------------------------------
# STP / STEP parsing — required if you pass cad_stp_path. (Skip this group
# if you don't have CAD STP at integration time and instead pre-compute
# the panel-normal seed yourself.)
# ---------------------------------------------------------------------------
cadquery-ocp==7.8.1.1.post1
# ---------------------------------------------------------------------------
# Diagnostic PNG renders + offscreen 3D screenshots — only used by
# coupler_panel.viz.render_all (optional).
# ---------------------------------------------------------------------------
matplotlib==3.10.8
# ---------------------------------------------------------------------------
# Excel batch-eval reporting — required if you use
# coupler_panel.reporting.write_excel_report or examples/run_batch_eval.py.
# ---------------------------------------------------------------------------
openpyxl==3.1.2

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
schema_version: '1.0'
cad:
linear_deflection_mm: 0.5
angular_deflection_rad: 0.1
locating_hole_radius_mm:
- 14.0
- 18.0
central_hole_radius_min_mm: 80.0
rectangle_aspect_tol: 0.15
sampling:
method: grid_spacing
spacing_mm: null
num_points: 500000
seed: 42
adaptive:
curvature_weight: 1.0
min_spacing_factor: 0.25
max_spacing_factor: 4.0
scan_simulator:
enabled: true
view_origin:
- 10000.0
- 0.0
- 0.0
hpr_radius_mm: 200000.0
panel_deflection:
enabled: true
theta_right_deg: 0.5
theta_ref_deg: 0.3
noise:
enabled: true
sigma_mm: 0.05
mode: isotropic
seed: 43
transform:
enabled: true
rotation_deg: 0.5
rotation_axis: random
translation_mm: 2.0
translation_dir: random
seed: 44
denoise:
steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5
- type: statistical
nb_neighbors: 20
std_ratio: 2.5
smoothing:
enabled: false
search_radius_mm: 2.0
polynomial_order: 2
registration:
method: kabsch_four_holes
pre_align: pca_centroid
refuse_if_residual_above_mm: 5.0
detection:
panel_normal_seed: pca
panel_normal_pca_kth: 1
grid_pixel_mm: 2.0
panel_min_area_ratio: 0.15
hole_min_area_mm2: 600.0
hole_max_area_mm2: 5000.0
circle_fit:
method: algebraic_then_lsq
inlier_residual_mm: 1.5
select_top_k_by_radius_match: 4
rectangle_aspect_tol: 0.4
fill_bg_below_mm2: 100.0
locating_hole_radius_mm:
- 14.0
- 22.0
roi:
hole_dilate_mm: 5.0
central_hole_dilate_mm: 10.0
edge_buffer_mm: 8.0
chamfer_normal_dot_min: 0.8
outlier_z_score: 3.0
plane_fit:
method: ransac_svd
ransac_iterations: 300
ransac_inlier_thresh_mm: 0.5
min_inlier_ratio: 0.5
enforce_nx_positive: true
metrics:
right_axis_sign: 1
angle_clamp_deg: 0.0
artifacts:
save_intermediate: true
ply_format: binary
image_dpi: 150
heatmap_colormap: viridis

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
{
"stp": "test_data\\lu1.stp",
"profile": "lu1_af4",
"gt": "gt.json",
"samples": [
{
"sample_id": "rp000_zp000",
"ply": "cloud_rp000_zp000.ply",
"theta_right_deg": 0.0,
"theta_ref_deg": 0.0,
"n_points": 96662
},
{
"sample_id": "rp010_zp000",
"ply": "cloud_rp010_zp000.ply",
"theta_right_deg": 0.1,
"theta_ref_deg": 0.0,
"n_points": 96459
},
{
"sample_id": "rp030_zp000",
"ply": "cloud_rp030_zp000.ply",
"theta_right_deg": 0.3,
"theta_ref_deg": 0.0,
"n_points": 96605
},
{
"sample_id": "rp050_zp000",
"ply": "cloud_rp050_zp000.ply",
"theta_right_deg": 0.5,
"theta_ref_deg": 0.0,
"n_points": 96706
},
{
"sample_id": "rp100_zp000",
"ply": "cloud_rp100_zp000.ply",
"theta_right_deg": 1.0,
"theta_ref_deg": 0.0,
"n_points": 96664
},
{
"sample_id": "rp200_zp000",
"ply": "cloud_rp200_zp000.ply",
"theta_right_deg": 2.0,
"theta_ref_deg": 0.0,
"n_points": 96713
},
{
"sample_id": "rp000_zp050",
"ply": "cloud_rp000_zp050.ply",
"theta_right_deg": 0.0,
"theta_ref_deg": 0.5,
"n_points": 96666
},
{
"sample_id": "rp050_zp050",
"ply": "cloud_rp050_zp050.ply",
"theta_right_deg": 0.5,
"theta_ref_deg": 0.5,
"n_points": 96614
},
{
"sample_id": "rp100_zp100",
"ply": "cloud_rp100_zp100.ply",
"theta_right_deg": 1.0,
"theta_ref_deg": 1.0,
"n_points": 96601
}
]
}

View File

@@ -0,0 +1,200 @@
# underframe-deflection
**从扫描点云测量高铁底架纵梁挠度的 Python 算法包:接受"CAD 基准 + 扫描点云 + ROI JSON"三件套输入,自动配准、去噪、识别梁、出 Excel 误差报表。**
[![python](https://img.shields.io/badge/python-3.10%2B-blue)]() [![version](https://img.shields.io/badge/version-1.1.0-green)]() [![status](https://img.shields.io/badge/status-beta-yellow)]()
---
## 这是什么
输入一份现场扫描点云(`.ply / .xyz`),一份 CAD 基准(`.ply / .stl / .obj`),一份描述测量区域的 ROI JSON,本包会:
1. **去噪**:passthrough → 统计离群 → 半径离群 → 双边滤波 → 体素降采样
2. **配准**:用 ICP 把扫描点云刚性对齐到 CAD 坐标系
3. **识别梁**:沿车长切片、Beam-search 跟踪左右纵梁
4. **拟合**:鲁棒样条拟合代表线
5. **找参考**:基于车体孔特征或线端点定参考线
6. **算挠度**:沿 X 轴逐点计算并选出绝对值最大的测量点
输出:完整 JSON 报告、Excel 误差报表(5 sheet)、沿 X 的挠度 CSV、候选切片诊断 CSV、5 张诊断 PNG、4 个着色 PLY 叠加图。
---
## 安装
```bash
# 进入本目录后
pip install -e .[all] # 推荐:core + matplotlib + openpyxl
# 或仅安装核心 + 自己选 extra
pip install -e . # 仅 numpy + scipy(只能调用 compute_deflection)
pip install -e .[viz] # +matplotlib(PNG 诊断图)
pip install -e .[excel] # +openpyxl(.xlsx 报表)
# 或直接用 requirements 锁版本
pip install -r requirements.txt
```
安装后会注册命令行 `underframe-measure`,Python 内可直接 `import underframe_deflection`。
---
## 上手
```bash
underframe-measure \
--input-cloud scan.ply \
--cad reference.ply \
--roi roi.json \
--out-dir out/
```
或者 Python 调用:
```python
from underframe_deflection import run_measurement_pipeline
report = run_measurement_pipeline(
input_cloud="scan.ply",
cad_path="reference.ply",
roi_path="roi.json",
out_dir="out/",
)
print(report["deflection"]["left"]["selected_measurement"]["signed_deflection_mm"])
print(report["deflection"]["right"]["selected_measurement"]["signed_deflection_mm"])
```
`test_data/` 自带可直接运行的样例:
```bash
underframe-measure \
--input-cloud test_data/m000004802409t02_asm_100000pts.ply \
--cad test_data/m000004802409t02_asm_100000pts.ply \
--roi test_data/sample_roi.json \
--out-dir test_data/runs/demo
```
---
## 文档导览
| 文件 | 用途 |
| --- | --- |
| **`README.md`** (本文件) | 仓库概览,30 秒上手 |
| **`USAGE.md`** | 集成开发者完整指南:三种调用方式、输入格式、读结果、异常、配置覆盖、模板 |
| `requirements.txt` | 锁定版本的依赖清单 |
| `pyproject.toml` | 打包描述(pip install 的入口) |
---
## 仓库结构
```
underframe_deflection/
├── README.md ← 本文件
├── USAGE.md ← 集成开发者文档
├── requirements.txt
├── pyproject.toml ← pip 打包描述(无需 setup.py)
├── _version.py ← __version__ / ALGORITHM_VERSION / SCHEMA_VERSION
├── __init__.py ← 公开 API 出口
│
├── api.py ← compute_deflection() 主入口
├── pipeline.py ← run_measurement_pipeline() 端到端编排
├── cli.py ← 命令行入口(underframe-measure)
│
├── config.py ← DEFAULT_CONFIG 与档位
├── preprocess.py ← SOR / radius / voxel / normals / bilateral
├── cad_io.py ← CAD 读取 (PLY/XYZ/STL/OBJ)
├── registration.py ← PCA 粗对齐 + trimmed ICP
├── roi.py ← ROI JSON 读取 + AABB passthrough
│
├── frame.py ← 车体坐标系推断 + ROI mask
├── beams.py ← 切片 + 候选条带 + Beam-search 跟踪
├── fitting.py ← 代表点 + 鲁棒样条拟合
├── reference.py ← 参考线检测(孔 / 端点)
│
├── artifacts.py ← JSON / CSV / PNG / PLY writer
├── excel_report.py ← Excel 写入器 (5 sheet)
├── ply_overlay.py ← 着色 PLY 叠加图
├── visualization.py ← 诊断 PNG 绘图
│
├── io.py ← 文件 I/O 工具
├── exceptions.py ← 公开异常层级
├── structures.py ← 内部数据类
│
└── test_data/
├── m000004802409t02_asm.stp ← 原始 STEP(仅参考,不被解析)
├── m000004802409t02_asm_100000pts.ply ← STEP 采样得到的 CAD 基准点云
└── sample_roi.json ← 样例 ROI(AABB)
```
---
## 输入约定
所有点云的坐标轴必须:
- **X**:纵向(车长)
- **Y**:横向(车宽)
- **Z**:高度,**向上为正**
- 单位:**毫米**
CAD 基准也必须遵循同样的轴向;ROI JSON 中的 bbox 字段在 CAD 坐标系下。
如果你的扫描设备轴向不同,请在调用前自行做旋转/置换。
---
## 公开 API
```python
from underframe_deflection import (
# 主入口
compute_deflection, # 纯内存计算
run_measurement_pipeline, # 文件读 + 算 + 文件写
# 单步工具(高级用法)
load_point_cloud, # 读扫描点云
read_cad_reference, # 读 CAD
load_roi_aabb, # 读 ROI JSON
register_to_cad, # ICP 配准
bilateral_filter_points, # 双边滤波
apply_passthrough, # AABB 过滤
# 结果数据类
DeflectionResult,
SideDeflection,
DeflectionCurves,
RegistrationResult,
# 异常
DeflectionError,
InvalidPointCloudError,
InsufficientPointsError,
UnsupportedFormatError,
FrameInferenceError,
# 版本
__version__,
ALGORITHM_VERSION,
SCHEMA_VERSION,
)
```
详见 [`USAGE.md`](./USAGE.md)。
---
## 状态与版本
- **当前版本**:1.1.0
- **算法版本**:0.1.0(v1.1 没有动算法主流程,仅扩展 I/O / 预处理 / 配准)
- **Schema 版本**:1.0(v1.1 仅新增可选字段,向后兼容)
- **Python**:≥ 3.10(Windows / Linux / macOS 通用)
---
## License
Proprietary — PKU High-Speed Train Project.

View File

@@ -0,0 +1,363 @@
# underframe_deflection 集成调用指南 (v1.1)
> 高铁底架纵梁挠度测量算法 —— 基于点云的计算。
> 本文档面向把本算法集成进上层软件的开发者:怎么准备 **CAD 基准 + 扫描点云 + ROI JSON** 三件套输入、怎么调用、怎么读结果。
---
## 0. TL;DR
```python
from underframe_deflection import run_measurement_pipeline
report = run_measurement_pipeline(
input_cloud="scan.ply",
cad_path="reference.ply",
roi_path="roi.json",
out_dir="out/",
)
# 关心的两个核心数值
print(report["deflection"]["left"]["selected_measurement"]["signed_deflection_mm"])
print(report["deflection"]["right"]["selected_measurement"]["signed_deflection_mm"])
```
落盘后会得到:
```
out/
├── report.json # 完整结果
├── report.xlsx # 误差报表(Summary + 4 张中间表)
├── deflection_curves.csv # 沿 X 的挠度曲线
├── beam_tracks.csv # 候选梁切片诊断
├── images/ # 5 张诊断 PNG
└── ply/ # 4 个着色 PLY 叠加图
```
---
## 1. 算法的三个输入
| 输入 | 类型 | 必需 | 说明 |
| --- | --- | --- | --- |
| **扫描点云** | `.ply / .xyz / .txt / .csv` | ✅ 必须 | 现场采集得到的 (N, 3) XYZ 点云,单位 mm |
| **CAD 基准** | `.ply / .xyz / .stl / .obj` | 可选但强烈建议 | 预先 tessellate / 采样过的 CAD 几何,作为配准基准坐标系;不直接吃 STEP/IGES |
| **ROI JSON** | `.json` | 可选但强烈建议 | 描述车底测量区域的轴对齐 bbox,坐标基于 CAD 坐标系 |
> 三者**全给**时走 v1.1 完整流程:去噪 → 配准到 CAD 系 → 按 ROI JSON 截区 → 算挠度 → 出 Excel。
> 只给扫描点云时走 legacy 流程,与本算法 v1.0 行为完全一致(仅多出 bilateral 这一步可用 `--no-bilateral` 关掉)。
### 1.1 ROI JSON 文件格式
固定 6 个 float 字段,单位 mm,CAD 坐标系下:
```json
{
"x_min": -12500.0,
"x_max": 12500.0,
"y_min": 150.0,
"y_max": 4100.0,
"z_min": -1680.0,
"z_max": 200.0
}
```
约束:每个轴 `min < max`,且都是有限实数;任一字段缺失或非法 → `InvalidPointCloudError`。
`test_data/sample_roi.json` 是一个可直接使用的样例。
### 1.2 CAD 基准
- **不解析 STEP / IGES**。如果你只有 `.stp`,请用 CAD 软件或离线脚本先把它转成 `.stl` / `.ply` / `.obj`,再喂给本算法。
- 三角网格的话只读顶点位置,face 信息忽略 —— 算法内部把 CAD 当作"参考点集"用 ICP 对齐。
- `test_data/m000004802409t02_asm_100000pts.ply` 是从原始 STEP 采样得到的 10 万点 PLY,可直接当 CAD 基准。
---
## 2. 三种调用方式
### 2.1 命令行(最简单的集成方式)
```bash
underframe-measure \
--input-cloud scan.ply \
--cad reference.ply \
--roi roi.json \
--out-dir out/
```
可选开关:
| 开关 | 含义 |
| --- | --- |
| `--config CONFIG.json` | 覆盖部分内部超参数(结构同 `DEFAULT_CONFIG`) |
| `--profile {auto,M,TC,TP,custom}` | 调参档位 |
| `--skip-registration` | 给了 `--cad` 但不跑 ICP(适合调试或扫描已经预对齐) |
| `--no-bilateral` | 关闭双边滤波(v1.0 行为) |
| `--skip-excel` | 不写 `.xlsx` |
未安装本包时,等价命令:
```bash
python -m underframe_deflection.cli --input-cloud scan.ply ...
```
### 2.2 一次性 Python 函数(推荐)
```python
from underframe_deflection import run_measurement_pipeline
report = run_measurement_pipeline(
input_cloud="scan.ply",
cad_path="reference.ply", # 可选
roi_path="roi.json", # 可选
out_dir="out/",
config_path=None, # 可选 JSON 配置覆盖
profile="auto", # 可选档位
skip_registration=False,
enable_bilateral=True,
skip_excel=False,
)
```
**返回**:`report` 是一个 `dict`,结构与 `out/report.json` 完全一致。
### 2.3 纯内存计算(不读写文件)
如果你已经从其它来源拿到了点云和 ROI(例如直接从相机驱动 / 数据库取数据),可以避开文件 I/O:
```python
import numpy as np
from underframe_deflection import compute_deflection, load_point_cloud
points = load_point_cloud("scan.ply") # 或自己构造 (N, 3) ndarray
roi_aabb = { # 与 ROI JSON 同结构的 dict
"x_min": -12500, "x_max": 12500,
"y_min": 150, "y_max": 4100,
"z_min": -1680, "z_max": 200,
}
result = compute_deflection(points, roi_aabb=roi_aabb)
print(result.left.selected_signed_mm, result.right.selected_signed_mm)
```
> 注意:`compute_deflection` 本身**不做 CAD 配准**。如果你想自己控制配准过程,先调用 `register_to_cad`,把变换应用到点云后再喂给 `compute_deflection`,配合 `config["frame"]["use_input_axes"]=True`(这本来就是默认值)即可:
```python
from underframe_deflection import (
read_cad_reference, register_to_cad, compute_deflection,
)
from underframe_deflection.registration import apply_transform
scan = load_point_cloud("scan.ply")
cad = read_cad_reference("reference.ply")
# 你需要一份预解算好的 config(spacing 之类)。最简单的办法:
from underframe_deflection.config import load_config, resolve_numeric_parameters, cloud_geometry
from underframe_deflection.preprocess import estimate_spacing
cfg = load_config(None, "auto")
cfg = resolve_numeric_parameters(cfg, cloud_geometry(scan), estimate_spacing(scan))
reg = register_to_cad(scan, cad, cfg)
scan_aligned = apply_transform(scan, reg.R, reg.t)
result = compute_deflection(scan_aligned, roi_aabb=roi_aabb)
```
---
## 3. 读取输出
### 3.1 核心数值(最常用)
```python
left = report["deflection"]["left"]["selected_measurement"]
right = report["deflection"]["right"]["selected_measurement"]
if left["available"]:
x_mm = left["x_mm"]
signed = left["signed_deflection_mm"] # 带符号,向下为负
absolute = left["absolute_deflection_mm"]
type_ = left["selected_type"] # "downward" / "upward"
beam_pt = left["beam_point"] # [x, y, z]
ref_pt = left["reference_point"] # [x, y, z]
```
如果你用的是 `compute_deflection`,公开数据类更直接:
```python
result.left.selected_signed_mm
result.left.max_downward_mm
result.left.max_downward_x_mm
result.left.max_upward_mm
result.left.reference_confidence
result.curves.x_mm # ndarray
result.curves.left_deflection_mm # ndarray, NaN 表示该 X 处无效
```
### 3.2 质量警告 `quality_flags`
**非致命**问题列在这里,调用方应判断是否要重测:
| Flag | 含义 |
| --- | --- |
| `left_track_missing` / `right_track_missing` | 单侧梁追踪失败 |
| `left_representative_line_missing` / `right_...` | 单侧代表线拟合失败 |
| `left_reference_unavailable` / `right_...` | 单侧参考线不可用 |
| `left_reference_low_confidence` / `right_...` | 参考线置信度低 |
| `left_track_low_coverage` / `right_...` | 单侧追踪覆盖率 < 45% |
| `left_right_symmetry_warning` | 左右对称性偏差 > 18% |
| **`registration_not_converged`** | ICP 未收敛到容差内 |
| **`registration_poor_quality`** | ICP 最终 RMS > max(10 mm, 2×点间距) |
| **`registration_low_inlier_ratio`** | ICP 内点比例 < 50% |
| **`passthrough_empty`** | ROI passthrough 后点过少,已自动回退 |
| **`bilateral_degraded`** | bilateral k 邻域被裁剪 |
加粗的几项是 v1.1 新增。
### 3.3 配准信息
```python
reg = report["registration"]
if reg["performed"]:
reg["rms_mm"] # 最终对齐残差
reg["initial_rms_mm"] # 粗对齐后的初始残差
reg["iterations"]
reg["inlier_ratio"]
reg["converged"] # bool
reg["R"] # 3x3 list
reg["t"] # length-3 list
```
### 3.4 Excel 误差报表
`out/report.xlsx` 5 个 sheet:
| Sheet | 内容 |
| --- | --- |
| **Summary** | key/value 形式:版本、输入路径、配准统计、预处理各阶段计数、左右挠度核心数值、质量警告 |
| **Deflection_Curves** | 沿 X 的逐点表:x / 左右 beam_y, beam_z / 左右 ref_y, ref_z / 左右 deflection / 左右 confidence |
| **Beam_Tracks** | 每片候选条带:slice_index、y_center、z_low、width、score、selected_rank 等 |
| **Reference_Lines** | 左右参考线 front/back 端点、direction、residual、confidence |
| **Representative_Points** | 左右每个代表点的 x/y/z/width/residual/confidence |
> 用 `openpyxl`、`pandas.read_excel`、Excel/LibreOffice 都可以直接读。
### 3.5 异常
| 异常类 | 触发条件 |
| --- | --- |
| `InvalidPointCloudError` | 点云 / CAD / ROI JSON 文件格式非法、字段缺失、AABB 反转 |
| `UnsupportedFormatError` | 文件后缀不在支持列表 |
| `InsufficientPointsError` | 点数过少(点云 < 256)或 ROI 过滤后无重叠 |
| `FrameInferenceError` | 自动坐标系推断失败(仅 legacy 路径) |
集成方应在外层 `try / except DeflectionError` 兜底,并把 `quality_flags` 落入日志或上层告警。
---
## 4. 配置覆盖(高级)
绝大多数场景不需要碰配置。需要微调时,通过 `--config my.json` 或 `config=` 传入嵌套 dict,与 `DEFAULT_CONFIG` 深度合并:
```python
config = {
"preprocess": {
"bilateral": {"enabled": True, "k": 32, "sigma_n_deg": 10.0},
# 也可以从外部传 passthrough,但通常用 ROI JSON 即可
},
"registration": {
"icp_max_iter": 80,
"trim_ratio": 0.15,
},
"reference": {
"low_confidence_threshold": 0.5,
},
"deflection": {
"evaluation_grid_factor": 0.5, # 更密的挠度采样
},
}
```
完整字段定义见 `underframe_deflection/config.py: DEFAULT_CONFIG`。
---
## 5. 进度回调
`compute_deflection` 支持 `progress` 回调:
```python
def on_progress(stage: str, fraction: float) -> None:
print(f"[{fraction:5.1%}] {stage}")
result = compute_deflection(points, roi_aabb=roi_aabb, progress=on_progress)
```
阶段顺序:`preprocess → frame → beam_tracking → line_fitting → reference → deflection`。
`run_measurement_pipeline` 当前未透传 progress 回调,需要时请直接调 `compute_deflection`。
---
## 6. 坐标系约定
输入点云**必须**满足以下约定,否则结果无意义:
- **X 轴**:纵向(车长方向)
- **Y 轴**:横向(车宽方向)
- **Z 轴**:高度方向,**向上为正**
- 单位:毫米
CAD 基准也必须遵循同样的轴向;ROI JSON 中的 bbox 字段在 CAD 坐标系下。
扫描点云轴向不同 → 调用前自行做置换 / 旋转。
---
## 7. 版本兼容性
- `__version__` —— 包版本
- `ALGORITHM_VERSION` —— 算法版本(升级会改变数值结果)
- `SCHEMA_VERSION` —— `report.json` 结构版本(仅在破坏性变更时递增;新字段不递增)
v1.1 新增了非破坏性的可选字段(`cad_reference / roi_source / roi_aabb / registration / preprocess.passthrough / preprocess.bilateral / roi.source / quality_flags 新值`),`SCHEMA_VERSION` 保持 `"1.0"` 不变。
---
## 8. 集成模板
```python
import logging
from underframe_deflection import (
run_measurement_pipeline,
DeflectionError,
)
log = logging.getLogger(__name__)
def measure(scan_path: str, cad_path: str, roi_path: str, out_dir: str) -> dict | None:
try:
report = run_measurement_pipeline(
input_cloud=scan_path,
cad_path=cad_path,
roi_path=roi_path,
out_dir=out_dir,
)
except DeflectionError as exc:
log.error("deflection failed: %s", exc)
return None
flags = report.get("quality_flags") or []
if flags:
log.warning("quality flags: %s", flags)
return {
"schema_version": report["schema_version"],
"algorithm_version": report["algorithm_version"],
"left_signed_mm": report["deflection"]["left"].get("selected_measurement", {}).get("signed_deflection_mm"),
"right_signed_mm": report["deflection"]["right"].get("selected_measurement", {}).get("signed_deflection_mm"),
"registration_rms_mm": report["registration"].get("rms_mm"),
"flags": flags,
}
```

Some files were not shown because too many files have changed in this diff Show More