├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── pointcloud2pgm_slicer ├── __init__.py ├── config.py ├── controller.py ├── loader.py ├── main.py ├── model.py └── view.py ├── pytest.ini ├── requirements.txt ├── setup.py └── test └── test_model.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | services: 13 | xvfb: 14 | image: "selenium/standalone-chrome" 15 | options: --privileged 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: '3.9' 22 | - name: Install dependencies 23 | run: | 24 | python3 -m pip install --upgrade pip 25 | pip install -r requirements.txt 26 | pip install -e . 27 | - name: Run tests 28 | run: | 29 | # ヘッドレスモードでGUIテストを実行するために xvfb-run を利用 30 | xvfb-run --auto-servernum pytest --maxfail=1 --disable-warnings -q 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__/ 2 | pointcloud2pgm_slicer/pointcloud2pgm_slicer.egg-info/ 3 | .pytest_cache/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pointcloud2pgm_slicer 2 | 3 | ## 概要 4 | これは点群データ(.pcdまたは.ply形式)からPGM画像を生成するプログラムです。 \ 5 | 点群データをユーザが指定する高さ(z軸方向)範囲で抽出し、XY平面に投影して二値化することでPGM画像を生成します。 \ 6 | 加えて、画像に対応するパラメータ(解像度や原点、閾値など)を記述したYAMLファイルも生成します。 7 | 8 | ## デモ動画 9 | [![](https://img.youtube.com/vi/gKtSeKtFF_E/0.jpg)](https://www.youtube.com/watch?v=gKtSeKtFF_E&ab_channel=caffeline) 10 | 11 | ## 依存ライブラリ 12 | - Python 3.x 13 | - [numpy](https://numpy.org/) 14 | - [matplotlib](https://matplotlib.org/) 15 | - [open3d](http://www.open3d.org/) 16 | - [pyvista](https://docs.pyvista.org/) 17 | - [PyQt5](https://pypi.org/project/PyQt5/) 18 | - [pyvistaqt](https://pypi.org/project/pyvistaqt/) 19 | 20 | ## インストールと実行方法 21 | このツールは、以下の2通りの方法で実行できます。 22 | 23 | ### 1. パッケージとしてインストールして実行する方法 24 | 1. 依存関係のインストール 25 | ```bash 26 | pip install -r requirements.txt 27 | ``` 28 | 2. パッケージのインストール 29 | ```bash 30 | pip install -e . 31 | ``` 32 | 3. 実行 33 | ```bash 34 | python3 -m pointcloud2pgm_slicer.main 35 | ``` 36 | ### 2. 直接実行する方法 37 | 1. 依存関係のインストール 38 | ```bash 39 | pip install -r requirements.txt 40 | ``` 41 | 2. 実行 42 | ```bash 43 | python3 pointcloud2pgm_slicer/main.py 44 | ``` 45 | 46 | ## **GUIの操作** 47 | プログラム起動後、ウィンドウが表示されます。 48 | - **Min Z / Max Z スライダーおよび数値入力**: 点群データ内のz軸の抽出範囲を設定します。 49 | - **Reset ボタン**: z軸の設定を全範囲にリセットします。 50 | - **Set Output File Name ボタン**: 出力PGMファイルのファイル名を変更します。 51 | - **Set Resolution ボタン**: 1ピクセルあたりの実空間距離(m/px)を設定します。 52 | - **Convert to PGM ボタン**: 現在の設定に基づき、点群データをPGM画像およびYAMLファイルに変換して出力します。 53 | 54 | ## 設定の変更 55 | - **設定ファイル:** [pointcloud2pgm_slicer/config.py](pointcloud2pgm_slicer/config.py) 56 | 57 | - VOXEL_SIZE 58 | - 描画用の点群のダウンサンプリングに使用するボクセルサイズ 59 | 60 | - MIN_OCCUPIED_POINTS 61 | - PGM画像生成時に各ピクセル内の点群数を基準に占有判定を行うための閾値 62 | - 各ピクセルに含まれる点の数が MIN_OCCUPIED_POINTS 以上であれば、そのピクセルは占有と判定 63 | - 未満の場合は未占有 64 | 65 | ## ライセンス 66 | [Apache License 2.0](LICENSE) 67 | -------------------------------------------------------------------------------- /pointcloud2pgm_slicer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cafeline/pointcloud2pgm_slicer/11e5c84ea4d0c6ee58f6f6c246dd2b0745b90323/pointcloud2pgm_slicer/__init__.py -------------------------------------------------------------------------------- /pointcloud2pgm_slicer/config.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ryo Funai 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | VOXEL_SIZE: float = 0.2 # 点群のダウンサンプリングに使用するボクセルサイズ[m] (描画の軽量化のみに使用し、pgmには影響しない) 5 | MIN_OCCUPIED_POINTS: int = 1 # 各ピクセルの点数がこの値以上なら占有と判断 -------------------------------------------------------------------------------- /pointcloud2pgm_slicer/controller.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ryo Funai 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ 5 | ModelとViewの仲介および各種ロジック処理 6 | """ 7 | import sys 8 | import numpy as np 9 | from PyQt5 import QtWidgets, QtCore 10 | from model import IPointCloudModel 11 | from view import IPointCloudView 12 | 13 | class PointCloudController: 14 | def __init__( 15 | self, 16 | model: IPointCloudModel, 17 | view: IPointCloudView, 18 | loader, 19 | output_dir: str, 20 | ) -> None: 21 | self.model = model 22 | self.view = view 23 | self.loader = loader 24 | self.output_dir = output_dir 25 | 26 | self.timer_interval = 10 # [ms] 27 | self.update_timer = QtCore.QTimer() 28 | self.update_timer.setSingleShot(True) 29 | self.update_timer.timeout.connect(self.update_filter) 30 | 31 | self._connect_signals() 32 | 33 | # LoaderスレッドのシグナルをControllerのコールバックに接続して起動 34 | self.loader.loaded.connect(self.on_point_cloud_loaded) 35 | self.loader.error.connect(self.on_point_cloud_error) 36 | self.loader.start() 37 | 38 | def _connect_signals(self) -> None: 39 | self.view.zmin_spin.valueChanged.connect(self.on_zmin_changed) 40 | self.view.zmin_slider.valueChanged.connect(self.on_zmin_slider_changed) 41 | self.view.zmax_spin.valueChanged.connect(self.on_zmax_changed) 42 | self.view.zmax_slider.valueChanged.connect(self.on_zmax_slider_changed) 43 | self.view.reset_button.clicked.connect(self.on_reset) 44 | self.view.convert_button.clicked.connect(self.on_convert) 45 | self.view.set_output_filename_button.clicked.connect(self.on_set_output_filename) 46 | self.view.set_resolution_button.clicked.connect(self.on_set_resolution) 47 | 48 | def on_point_cloud_loaded(self, pcd) -> None: 49 | try: 50 | self.model.set_point_cloud_data(pcd) 51 | except ValueError as e: 52 | QtWidgets.QMessageBox.critical(self.view, "Error", str(e)) 53 | sys.exit(1) 54 | 55 | overall_min = self.model.overall_z_min 56 | overall_max = self.model.overall_z_max 57 | current_min = self.model.current_min_z 58 | current_max = self.model.current_max_z 59 | 60 | self.view.zmin_spin.setRange(overall_min, overall_max) 61 | self.view.zmin_spin.setValue(current_min) 62 | self.view.zmin_slider.setMinimum(int(overall_min * self.view.slider_multiplier)) 63 | self.view.zmin_slider.setMaximum(int(overall_max * self.view.slider_multiplier)) 64 | self.view.zmin_slider.setValue(int(current_min * self.view.slider_multiplier)) 65 | 66 | self.view.zmax_spin.setRange(overall_min, overall_max) 67 | self.view.zmax_spin.setValue(current_max) 68 | self.view.zmax_slider.setMinimum(int(overall_min * self.view.slider_multiplier)) 69 | self.view.zmax_slider.setMaximum(int(overall_max * self.view.slider_multiplier)) 70 | self.view.zmax_slider.setValue(int(current_max * self.view.slider_multiplier)) 71 | 72 | # 初期表示:モデルの全点群データを描画 73 | full_poly = self.model.display_cloud 74 | self.view.plotter.clear() 75 | self.view.actor = self.view.plotter.add_mesh( 76 | full_poly, 77 | scalars="z", 78 | cmap="nipy_spectral", 79 | point_size=2, 80 | render_points_as_spheres=False, 81 | show_scalar_bar=True, 82 | scalar_bar_args={ 83 | "title": "Z Value", 84 | "vertical": True, 85 | "position_x": 0.9, 86 | "position_y": 0.1, 87 | "width": 0.02, 88 | "height": 0.8, 89 | "title_font_size": 14, 90 | "label_font_size": 12, 91 | }, 92 | reset_camera=True 93 | ) 94 | self.view.cloud_mesh = full_poly 95 | self.view.plotter.reset_camera() 96 | self.view.plotter.show_axes() 97 | self.view.plotter.render() 98 | 99 | if hasattr(self.view, "splash") and self.view.splash: 100 | self.view.splash.finish(self.view) 101 | self.view.show() 102 | 103 | def on_point_cloud_error(self, error_msg: str) -> None: 104 | QtWidgets.QMessageBox.critical(self.view, "Error", f"点群データの読み込みに失敗しました:\n{error_msg}") 105 | sys.exit(1) 106 | 107 | def on_zmin_changed(self, value: float) -> None: 108 | self.model.current_min_z = value 109 | self.view.update_slider_value(self.view.zmin_slider, value) 110 | if self.model.current_min_z > self.model.current_max_z: 111 | self.model.current_max_z = self.model.current_min_z 112 | self.view.update_spin_value(self.view.zmax_spin, self.model.current_max_z) 113 | self.view.update_slider_value(self.view.zmax_slider, self.model.current_max_z) 114 | self.update_timer.start(self.timer_interval) 115 | 116 | def on_zmax_changed(self, value: float) -> None: 117 | self.model.current_max_z = value 118 | self.view.update_slider_value(self.view.zmax_slider, value) 119 | if self.model.current_max_z < self.model.current_min_z: 120 | self.model.current_min_z = self.model.current_max_z 121 | self.view.update_spin_value(self.view.zmin_spin, self.model.current_min_z) 122 | self.view.update_slider_value(self.view.zmin_slider, self.model.current_min_z) 123 | self.update_timer.start(self.timer_interval) 124 | 125 | def on_zmin_slider_changed(self, slider_value: int) -> None: 126 | new_value = slider_value / self.view.slider_multiplier 127 | self.view.update_spin_value(self.view.zmin_spin, new_value) 128 | self.model.current_min_z = new_value 129 | self.update_timer.start(self.timer_interval) 130 | 131 | def on_zmax_slider_changed(self, slider_value: int) -> None: 132 | new_value = slider_value / self.view.slider_multiplier 133 | self.view.update_spin_value(self.view.zmax_spin, new_value) 134 | self.model.current_max_z = new_value 135 | self.update_timer.start(self.timer_interval) 136 | 137 | def on_reset(self) -> None: 138 | self.model.current_min_z = self.model.overall_z_min 139 | self.model.current_max_z = self.model.overall_z_max 140 | self.view.update_spin_value(self.view.zmin_spin, self.model.current_min_z) 141 | self.view.update_spin_value(self.view.zmax_spin, self.model.current_max_z) 142 | self.view.update_slider_value(self.view.zmin_slider, self.model.current_min_z) 143 | self.view.update_slider_value(self.view.zmax_slider, self.model.current_max_z) 144 | self.update_timer.start(self.timer_interval) 145 | 146 | def update_filter(self) -> None: 147 | polydata = self.model.get_polydata(self.model.current_min_z, self.model.current_max_z) 148 | if polydata is None: 149 | if self.view.actor is not None: 150 | self.view.plotter.remove_actor(self.view.actor) 151 | self.view.actor = None 152 | self.view.cloud_mesh = None 153 | self.view.plotter.render() 154 | return 155 | 156 | if self.view.actor is not None and self.view.cloud_mesh is not None: 157 | if self.view.cloud_mesh.n_points == polydata.n_points: 158 | np.copyto(self.view.cloud_mesh.points, polydata.points) 159 | self.view.cloud_mesh["z"] = polydata["z"] 160 | self.view.cloud_mesh.Modified() 161 | else: 162 | new_mesh = polydata 163 | mapper = self.view.actor.GetMapper() 164 | mapper.SetInputData(new_mesh) 165 | self.view.cloud_mesh = new_mesh 166 | else: 167 | self.view.actor = self.view.plotter.add_mesh( 168 | polydata, 169 | scalars="z", 170 | cmap="nipy_spectral", 171 | point_size=2, 172 | render_points_as_spheres=False, 173 | show_scalar_bar=True, 174 | scalar_bar_args={ 175 | "title": "Z Value", 176 | "vertical": True, 177 | "position_x": 0.9, 178 | "position_y": 0.1, 179 | "width": 0.02, 180 | "height": 0.8, 181 | "title_font_size": 14, 182 | "label_font_size": 12, 183 | }, 184 | reset_camera=False 185 | ) 186 | self.view.cloud_mesh = polydata 187 | self.view.plotter.render() 188 | 189 | def on_set_output_filename(self) -> None: 190 | text, ok = QtWidgets.QInputDialog.getText( 191 | self.view, 192 | "Output File Name", 193 | "出力PGMファイル名:", 194 | text=self.view.user_output_filename 195 | ) 196 | if ok and text.strip(): 197 | self.view.user_output_filename = text.strip() 198 | self.view.output_file_label.setText("Output File Name: " + self.view.user_output_filename) 199 | self.view.plotter.render() 200 | 201 | def on_set_resolution(self) -> None: 202 | dialog = QtWidgets.QInputDialog(self.view) 203 | dialog.setInputMode(QtWidgets.QInputDialog.DoubleInput) 204 | dialog.setWindowTitle("Set Resolution") 205 | dialog.setLabelText("解像度[m/px] を入力してください:") 206 | dialog.setDoubleValue(self.view.user_resolution) 207 | dialog.setDoubleDecimals(3) 208 | 209 | spin_box = dialog.findChild(QtWidgets.QDoubleSpinBox) 210 | if spin_box is not None: 211 | spin_box.setSingleStep(0.05) 212 | 213 | if dialog.exec_() == QtWidgets.QDialog.Accepted: 214 | self.view.user_resolution = dialog.doubleValue() 215 | self.view.resolution_label.setText("Resolution: {:.3f} [m/px]".format(self.view.user_resolution)) 216 | self.view.plotter.render() 217 | 218 | def on_convert(self) -> None: 219 | try: 220 | pgm_path, yaml_path = self.model.convert_to_pgm( 221 | self.model.current_min_z, 222 | self.model.current_max_z, 223 | self.view.user_resolution, 224 | self.output_dir, 225 | self.view.user_output_filename 226 | ) 227 | except Exception as e: 228 | QtWidgets.QMessageBox.critical(self.view, "Error", f"PGM/YAML出力時にエラーが発生しました: {e}") 229 | return 230 | 231 | message = f"PGMファイルを出力しました: {pgm_path}\nYAMLファイルを出力しました: {yaml_path}" 232 | QtWidgets.QMessageBox.information(self.view, "Success", message) 233 | self.view.show_pgm_image(pgm_path) 234 | -------------------------------------------------------------------------------- /pointcloud2pgm_slicer/loader.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ryo Funai 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ 5 | バックグラウンドで点群を読み込む 6 | """ 7 | from PyQt5 import QtCore 8 | import open3d as o3d 9 | 10 | class PointCloudLoaderThread(QtCore.QThread): 11 | loaded = QtCore.pyqtSignal(object) 12 | error = QtCore.pyqtSignal(str) 13 | 14 | def __init__(self, input_file: str, parent=None) -> None: 15 | super().__init__(parent) 16 | self.input_file = input_file 17 | 18 | def run(self) -> None: 19 | try: 20 | pcd = o3d.io.read_point_cloud(self.input_file) 21 | if not pcd.has_points(): 22 | self.error.emit(f"点群が存在しません: {self.input_file}") 23 | return 24 | # 生の点群をそのままemit(描画用のダウンサンプリングはModel側で実施) 25 | self.loaded.emit(pcd) 26 | except Exception as e: 27 | self.error.emit(str(e)) 28 | -------------------------------------------------------------------------------- /pointcloud2pgm_slicer/main.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ryo Funai 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import sys 5 | import argparse 6 | from PyQt5 import QtWidgets, QtCore, QtGui 7 | from model import PointCloudModel 8 | from view import PointCloudView 9 | from controller import PointCloudController 10 | from loader import PointCloudLoaderThread 11 | 12 | def main() -> None: 13 | parser = argparse.ArgumentParser() 14 | parser.add_argument("input_file", help="入力点群ファイル (.pcd または .ply)") 15 | parser.add_argument("output_dir", help="出力先ディレクトリ") 16 | args = parser.parse_args() 17 | 18 | app = QtWidgets.QApplication(sys.argv) 19 | 20 | # スプラッシュスクリーンの設定 21 | pixmap = QtGui.QPixmap(400, 300) 22 | pixmap.fill(QtCore.Qt.gray) 23 | splash = QtWidgets.QSplashScreen(pixmap, QtCore.Qt.WindowStaysOnTopHint) 24 | splash.showMessage("Loading point cloud...", QtCore.Qt.AlignCenter | QtCore.Qt.AlignBottom, QtCore.Qt.white) 25 | splash.show() 26 | 27 | # DI(依存性注入) 28 | model = PointCloudModel() 29 | view = PointCloudView() 30 | view.splash = splash # スプラッシュスクリーンをViewに注入 31 | loader = PointCloudLoaderThread(args.input_file) 32 | output_dir = args.output_dir 33 | 34 | # Controllerに各コンポーネントを注入 35 | _ = PointCloudController(model, view, loader, output_dir) 36 | 37 | sys.exit(app.exec_()) 38 | 39 | if __name__ == "__main__": 40 | main() 41 | -------------------------------------------------------------------------------- /pointcloud2pgm_slicer/model.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ryo Funai 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ 5 | 点群データの保持、処理、およびPGM/YAML変換を行う 6 | """ 7 | from abc import ABC, abstractmethod 8 | import os 9 | import numpy as np 10 | import open3d as o3d 11 | import pyvista as pv 12 | from typing import Optional, Tuple 13 | from config import MIN_OCCUPIED_POINTS, VOXEL_SIZE 14 | 15 | class IPointCloudModel(ABC): 16 | @abstractmethod 17 | def set_point_cloud_data(self, pcd: o3d.geometry.PointCloud) -> None: 18 | """点群データの読み込み後の初期化""" 19 | pass 20 | 21 | @abstractmethod 22 | def get_polydata(self, min_z: float, max_z: float) -> Optional[pv.PolyData]: 23 | """指定Z範囲におけるフィルタ済みの描画用PolyDataを返す""" 24 | pass 25 | 26 | @abstractmethod 27 | def convert_to_pgm( 28 | self, 29 | min_z: float, 30 | max_z: float, 31 | resolution: float, 32 | output_dir: str, 33 | output_filename: str, 34 | occupied_thresh: float, 35 | free_thresh: float, 36 | negate: int, 37 | ) -> Tuple[str, str]: 38 | """PGM/YAML変換を行い、生成ファイルのパスを返す""" 39 | pass 40 | 41 | class PointCloudModel(IPointCloudModel): 42 | def __init__(self) -> None: 43 | # 生データ(PGM変換用) 44 | self.raw_points: Optional[np.ndarray] = None 45 | self.raw_z: Optional[np.ndarray] = None 46 | # 描画用(ダウンサンプリング済み) 47 | self.display_cloud: Optional[pv.PolyData] = None 48 | 49 | self.overall_z_min: Optional[float] = None 50 | self.overall_z_max: Optional[float] = None 51 | self.current_min_z: Optional[float] = None 52 | self.current_max_z: Optional[float] = None 53 | 54 | def set_point_cloud_data(self, pcd: o3d.geometry.PointCloud) -> None: 55 | self.raw_points = np.asarray(pcd.points) 56 | if self.raw_points.size == 0: 57 | raise ValueError("生の点群が存在しません。") 58 | self.raw_z = self.raw_points[:, 2] 59 | self.overall_z_min = float(np.min(self.raw_z)) 60 | self.overall_z_max = float(np.max(self.raw_z)) 61 | self.current_min_z = self.overall_z_min 62 | self.current_max_z = self.overall_z_max 63 | 64 | # ダウンサンプリング 65 | pcd_down = pcd.voxel_down_sample(VOXEL_SIZE) 66 | down_points = np.asarray(pcd_down.points) 67 | if down_points.size == 0: 68 | self.display_cloud = None 69 | else: 70 | down_z = down_points[:, 2] 71 | self.display_cloud = pv.PolyData(down_points) 72 | self.display_cloud["z"] = down_z 73 | 74 | def get_polydata(self, min_z: float, max_z: float) -> Optional[pv.PolyData]: 75 | # 描画用のダウンサンプリング済みデータからフィルタ 76 | if self.display_cloud is None: 77 | return None 78 | points = self.display_cloud.points 79 | z = self.display_cloud["z"] 80 | mask = (z >= min_z) & (z <= max_z) 81 | filtered_points = points[mask] 82 | if filtered_points.size == 0: 83 | return None 84 | filtered_z = z[mask] 85 | polydata = pv.PolyData(filtered_points) 86 | polydata["z"] = filtered_z 87 | return polydata 88 | 89 | def convert_to_pgm( 90 | self, 91 | min_z: float, 92 | max_z: float, 93 | resolution: float, 94 | output_dir: str, 95 | output_filename: str, 96 | occupied_thresh: float = 0.65, 97 | free_thresh: float = 0.2, 98 | negate: int = 0, 99 | ) -> Tuple[str, str]: 100 | # 生データを用いてPGM/YAML変換を実施 101 | if self.raw_points is None or self.raw_z is None: 102 | raise ValueError("生の点群データが未初期化です。") 103 | mask = (self.raw_z >= min_z) & (self.raw_z <= max_z) 104 | filtered_points = self.raw_points[mask] 105 | if filtered_points.size == 0: 106 | raise ValueError("指定されたZ範囲内に生の点群が存在しません。") 107 | # XY平面への投影と範囲計算 108 | min_x = float(filtered_points[:, 0].min()) 109 | max_x = float(filtered_points[:, 0].max()) 110 | min_y = float(filtered_points[:, 1].min()) 111 | max_y = float(filtered_points[:, 1].max()) 112 | res_x = int(np.ceil((max_x - min_x) / resolution)) 113 | res_y = int(np.ceil((max_y - min_y) / resolution)) 114 | 115 | x_coords = filtered_points[:, 0] 116 | y_coords = filtered_points[:, 1] 117 | hist, _, _ = np.histogram2d( 118 | x_coords, y_coords, bins=[res_x, res_y], range=[[min_x, max_x], [min_y, max_y]] 119 | ) 120 | accum = np.flipud(hist.T).astype(np.int32) 121 | image = np.where(accum >= MIN_OCCUPIED_POINTS, 0, 255).astype(np.uint8) 122 | 123 | # PGMファイルの生成 124 | output_pgm = os.path.join(output_dir, output_filename) 125 | self._save_pgm(output_pgm, image, res_x, res_y) 126 | # YAMLファイルの生成 127 | yaml_name = os.path.splitext(output_pgm)[0] + ".yaml" 128 | self._save_yaml(yaml_name, os.path.basename(output_pgm), min_x, min_y, resolution, occupied_thresh, free_thresh, negate) 129 | return output_pgm, yaml_name 130 | 131 | def _save_pgm(self, filename: str, image: np.ndarray, width: int, height: int) -> None: 132 | with open(filename, "w") as f: 133 | f.write("P2\n") 134 | f.write(f"{width} {height}\n") 135 | f.write("255\n") 136 | for row in image: 137 | f.write(" ".join(map(str, row)) + "\n") 138 | 139 | def _save_yaml( 140 | self, 141 | filename: str, 142 | pgm_file: str, 143 | min_x: float, 144 | min_y: float, 145 | resolution: float, 146 | occupied_thresh: float, 147 | free_thresh: float, 148 | negate: int, 149 | ) -> None: 150 | with open(filename, "w") as f: 151 | f.write(f"image: {pgm_file}\n") 152 | f.write(f"resolution: {resolution}\n") 153 | f.write(f"origin: [{min_x}, {min_y}, 0.0]\n") 154 | f.write(f"occupied_thresh: {occupied_thresh}\n") 155 | f.write(f"free_thresh: {free_thresh}\n") 156 | f.write(f"negate: {negate}\n") 157 | -------------------------------------------------------------------------------- /pointcloud2pgm_slicer/view.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Ryo Funai 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | """ 5 | GUIの構築と更新を行う 6 | """ 7 | from abc import ABC, ABCMeta, abstractmethod 8 | from PyQt5 import QtWidgets, QtCore 9 | from pyvistaqt import QtInteractor 10 | import matplotlib 11 | import matplotlib.pyplot as plt 12 | import numpy as np 13 | 14 | class IPointCloudView(ABC): 15 | @abstractmethod 16 | def update_spin_value(self, spin: QtWidgets.QDoubleSpinBox, value: float) -> None: 17 | pass 18 | 19 | @abstractmethod 20 | def update_slider_value(self, slider: QtWidgets.QSlider, value: float) -> None: 21 | pass 22 | 23 | @abstractmethod 24 | def show_pgm_image(self, pgm_file: str) -> None: 25 | pass 26 | 27 | 28 | class MetaQWidgetABC(type(QtWidgets.QMainWindow), ABCMeta): 29 | pass 30 | 31 | class PointCloudView(QtWidgets.QMainWindow, IPointCloudView, metaclass=MetaQWidgetABC): 32 | def __init__(self, parent=None) -> None: 33 | super().__init__(parent) 34 | self.setWindowTitle("Map Editor") 35 | self.slider_multiplier = 1000 36 | 37 | # ユーザ設定の初期値 38 | self.user_output_filename = "output_map.pgm" 39 | self.user_resolution = 0.2 40 | 41 | # 描画関連の属性 42 | self.actor = None 43 | self.cloud_mesh = None 44 | 45 | # コントロールパネル用ウィジェット 46 | self.zmin_spin = None 47 | self.zmin_slider = None 48 | self.zmax_spin = None 49 | self.zmax_slider = None 50 | self.output_file_label = None 51 | self.resolution_label = None 52 | 53 | # PyVista描画エリアのセットアップ 54 | self.plotter = QtInteractor(self) 55 | self.plotter.add_text("Loading point cloud...", position='upper_left', font_size=24) 56 | self.plotter.show_axes() 57 | self.setCentralWidget(self.plotter) 58 | 59 | self._setup_control_panel() 60 | 61 | def _setup_control_panel(self) -> None: 62 | dock = QtWidgets.QDockWidget("", self) 63 | self.addDockWidget(QtCore.Qt.RightDockWidgetArea, dock) 64 | control_widget = QtWidgets.QWidget() 65 | dock.setWidget(control_widget) 66 | 67 | layout = QtWidgets.QVBoxLayout(control_widget) 68 | sliders_container = QtWidgets.QWidget() 69 | sliders_layout = QtWidgets.QHBoxLayout(sliders_container) 70 | sliders_layout.setContentsMargins(0, 0, 0, 0) 71 | 72 | # 仮の初期値(後で更新) 73 | dummy_min, dummy_max, dummy_val = 0.0, 1.0, 0.0 74 | 75 | # min zグループ 76 | min_group = QtWidgets.QWidget() 77 | min_layout = QtWidgets.QVBoxLayout(min_group) 78 | min_layout.setContentsMargins(0, 0, 0, 0) 79 | min_label = QtWidgets.QLabel("min z:") 80 | min_label.setAlignment(QtCore.Qt.AlignCenter) 81 | min_layout.addWidget(min_label) 82 | min_control, self.zmin_spin, self.zmin_slider = self._create_slider_control(dummy_min, dummy_max, dummy_val) 83 | min_layout.addWidget(min_control) 84 | sliders_layout.addWidget(min_group) 85 | 86 | # max zグループ 87 | max_group = QtWidgets.QWidget() 88 | max_layout = QtWidgets.QVBoxLayout(max_group) 89 | max_layout.setContentsMargins(0, 0, 0, 0) 90 | max_label = QtWidgets.QLabel("max z:") 91 | max_label.setAlignment(QtCore.Qt.AlignCenter) 92 | max_layout.addWidget(max_label) 93 | max_control, self.zmax_spin, self.zmax_slider = self._create_slider_control(dummy_min, dummy_max, dummy_val) 94 | max_layout.addWidget(max_control) 95 | sliders_layout.addWidget(max_group) 96 | 97 | layout.addWidget(sliders_container) 98 | 99 | # ボタン群の作成 100 | self.reset_button = self._create_button("Reset") 101 | self.convert_button = self._create_button("Convert to PGM") 102 | self.set_output_filename_button = self._create_button("Set Output File Name") 103 | self.set_resolution_button = self._create_button("Set Resolution[m/px]") 104 | 105 | layout.addWidget(self.reset_button) 106 | layout.addWidget(self.convert_button) 107 | layout.addWidget(self.set_output_filename_button) 108 | layout.addWidget(self.set_resolution_button) 109 | 110 | self.output_file_label = QtWidgets.QLabel("Output File Name: " + self.user_output_filename) 111 | self.resolution_label = QtWidgets.QLabel("Resolution: {:.3f} [m/px]".format(self.user_resolution)) 112 | layout.addWidget(self.output_file_label) 113 | layout.addWidget(self.resolution_label) 114 | layout.addStretch() 115 | 116 | def _create_slider_control(self, min_val: float, max_val: float, initial_val: float): 117 | container = QtWidgets.QWidget() 118 | v_layout = QtWidgets.QVBoxLayout(container) 119 | spin = QtWidgets.QDoubleSpinBox() 120 | spin.setRange(min_val, max_val) 121 | spin.setValue(initial_val) 122 | spin.setDecimals(3) 123 | v_layout.addWidget(spin) 124 | slider = QtWidgets.QSlider(QtCore.Qt.Vertical) 125 | slider.setMinimum(int(min_val * self.slider_multiplier)) 126 | slider.setMaximum(int(max_val * self.slider_multiplier)) 127 | slider.setValue(int(initial_val * self.slider_multiplier)) 128 | slider.setTickPosition(QtWidgets.QSlider.NoTicks) 129 | v_layout.addWidget(slider, alignment=QtCore.Qt.AlignHCenter) 130 | return container, spin, slider 131 | 132 | def _create_button(self, text: str) -> QtWidgets.QPushButton: 133 | return QtWidgets.QPushButton(text) 134 | 135 | def update_spin_value(self, spin: QtWidgets.QDoubleSpinBox, value: float) -> None: 136 | spin.blockSignals(True) 137 | spin.setValue(value) 138 | spin.blockSignals(False) 139 | 140 | def update_slider_value(self, slider: QtWidgets.QSlider, value: float) -> None: 141 | slider.blockSignals(True) 142 | slider.setValue(int(value * self.slider_multiplier)) 143 | slider.blockSignals(False) 144 | 145 | def show_pgm_image(self, pgm_file: str) -> None: 146 | try: 147 | with open(pgm_file, "r") as f: 148 | header = f.readline().strip() 149 | if header != "P2": 150 | QtWidgets.QMessageBox.warning(self, "Error", "不明なPGM形式です。") 151 | return 152 | dims = f.readline().strip().split() 153 | if len(dims) != 2: 154 | QtWidgets.QMessageBox.warning(self, "Error", "PGM画像のサイズ情報が不正です。") 155 | return 156 | width, height = int(dims[0]), int(dims[1]) 157 | _ = int(f.readline().strip()) 158 | data = np.array(f.read().split(), dtype=np.uint8) 159 | except Exception as e: 160 | QtWidgets.QMessageBox.critical(self, "Error", f"PGM画像読み込み時にエラーが発生しました: {e}") 161 | return 162 | 163 | try: 164 | image = data.reshape((height, width)) 165 | except Exception as e: 166 | QtWidgets.QMessageBox.critical(self, "Error", f"画像変換時にエラーが発生しました: {e}") 167 | return 168 | 169 | matplotlib.use("Qt5Agg") 170 | plt.figure("PGM Image") 171 | plt.imshow(image, cmap="gray", interpolation="nearest") 172 | plt.axis("off") 173 | plt.show(block=False) 174 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | qt_api = pyqt5 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-qt 3 | PyQt5 4 | open3d 5 | pyvista 6 | pyvistaqt 7 | matplotlib 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="pointcloud2pgm_slicer", 5 | version="0.1", 6 | packages=find_packages(), 7 | install_requires=[ 8 | "pytest", 9 | "pytest-qt", 10 | "PyQt5", 11 | "open3d", 12 | "pyvista", 13 | "pyvistaqt", 14 | "matplotlib" 15 | ], 16 | ) 17 | -------------------------------------------------------------------------------- /test/test_model.py: -------------------------------------------------------------------------------- 1 | import os 2 | import numpy as np 3 | import open3d as o3d 4 | import pytest 5 | import sys 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "pointcloud2pgm_slicer"))) 7 | from model import PointCloudModel 8 | 9 | 10 | @pytest.fixture 11 | def sample_point_cloud(): 12 | # ダミーの点群データを生成する 13 | pcd = o3d.geometry.PointCloud() 14 | points = np.array([ 15 | [0.0, 0.0, 0.0], 16 | [1.0, 1.0, 1.0], 17 | [2.0, 2.0, 2.0], 18 | [3.0, 3.0, 3.0] 19 | ]) 20 | pcd.points = o3d.utility.Vector3dVector(points) 21 | return pcd 22 | 23 | def test_set_point_cloud_data(sample_point_cloud): 24 | model = PointCloudModel() 25 | model.set_point_cloud_data(sample_point_cloud) 26 | # overall_z_min, overall_z_max が正しく設定されるか 27 | assert model.overall_z_min == 0.0 28 | assert model.overall_z_max == 3.0 29 | # full_cloud に z 値が付与されているか 30 | assert "z" in model.display_cloud.array_names 31 | 32 | def test_get_polydata(sample_point_cloud): 33 | model = PointCloudModel() 34 | model.set_point_cloud_data(sample_point_cloud) 35 | # 1.0~2.0の範囲に該当する点は [1,1,1] と [2,2,2] の2点 36 | polydata = model.get_polydata(1.0, 2.0) 37 | assert polydata is not None 38 | np_points = polydata.points 39 | assert np_points.shape[0] == 2 40 | 41 | def test_convert_to_pgm(tmp_path, sample_point_cloud): 42 | model = PointCloudModel() 43 | model.set_point_cloud_data(sample_point_cloud) 44 | output_dir = tmp_path / "output" 45 | output_dir.mkdir() 46 | output_filename = "test_map.pgm" 47 | pgm_path, yaml_path = model.convert_to_pgm( 48 | min_z=0.0, 49 | max_z=3.0, 50 | resolution=1.0, 51 | output_dir=str(output_dir), 52 | output_filename=output_filename, 53 | occupied_thresh=0.65, 54 | free_thresh=0.2, 55 | negate=0 56 | ) 57 | # ファイルが出力されているか確認 58 | assert os.path.exists(pgm_path) 59 | assert os.path.exists(yaml_path) 60 | # PGMファイルの最初の行が "P2" となっているかチェック 61 | with open(pgm_path, "r") as f: 62 | header = f.readline().strip() 63 | assert header == "P2" 64 | --------------------------------------------------------------------------------