| import sys |
| import os |
| import argparse |
| import pathlib |
| from dataclasses import dataclass |
| from typing import List, Optional, Tuple |
|
|
| |
| try: |
| from PyQt5 import QtCore, QtWidgets |
| _qt_backend = "qt-pyqt5" |
| except ImportError: |
| from PySide2 import QtCore, QtWidgets |
| _qt_backend = "qt-pyside2" |
|
|
| from OCC.Display.backend import load_backend |
|
|
| load_backend(_qt_backend) |
|
|
| from OCC.Core.STEPControl import STEPControl_Reader |
| from OCC.Core.IFSelect import IFSelect_RetDone |
| from OCC.Core.TopExp import TopExp_Explorer |
| from OCC.Core.TopAbs import TopAbs_FACE |
| from OCC.Core.TopoDS import topods |
| from OCC.Core.Quantity import Quantity_Color, Quantity_TOC_RGB |
| from OCC.Display.qtDisplay import qtViewer3d |
| try: |
| from OCC.Core.Aspect import Aspect_TOL_SOLID |
| from OCC.Core.Prs3d import Prs3d_LineAspect |
| except Exception: |
| Aspect_TOL_SOLID = None |
| Prs3d_LineAspect = None |
| try: |
| from OCC.Core.Graphic3d import Graphic3d_NOM_MATTE, Graphic3d_NOM_NEON |
| except Exception: |
| Graphic3d_NOM_MATTE = None |
| Graphic3d_NOM_NEON = None |
| try: |
| from OCC.Core.Graphic3d import Graphic3d_TOSM_UNLIT |
| except Exception: |
| Graphic3d_TOSM_UNLIT = None |
|
|
| CLASS_NAMES = [ |
| "SOL", |
| "EOS", |
| "rect_slot", |
| "tri_slot", |
| "cir_slot", |
| "rect_psg", |
| "tri_psg", |
| "hexa_psg", |
| "hole", |
| "rect_step", |
| "tside_step", |
| "slant_step", |
| "rect_b_step", |
| "tri_step", |
| "cir_step", |
| "rect_b_slot", |
| "cir_b_slot", |
| "u_b_slot", |
| "rect_pkt", |
| "key_pkt", |
| "tri_pkt", |
| "hexa_pkt", |
| "o_ring", |
| "b_hole", |
| "chamfer", |
| "fillet", |
| ] |
|
|
| CLASS_COLORS_HEX = [ |
| "#1f77b4", |
| "#ff7f0e", |
| "#2ca02c", |
| "#d62728", |
| "#9467bd", |
| "#8c564b", |
| "#e377c2", |
| "#7f7f7f", |
| "#bcbd22", |
| "#17becf", |
| "#393b79", |
| "#637939", |
| "#8c6d31", |
| "#843c39", |
| "#7b4173", |
| "#3182bd", |
| "#e6550d", |
| "#31a354", |
| "#756bb1", |
| "#636363", |
| "#6baed6", |
| "#fd8d3c", |
| "#74c476", |
| "#9e9ac8", |
| "#a1d99b", |
| "#fdd0a2", |
| ] |
|
|
| UNLABELED_COLOR_HEX = "#d0d0d0" |
| HIGHLIGHT_COLOR_HEX = "#FFD400" |
| EDGE_COLOR_HEX = "#2b2b2b" |
|
|
|
|
| def hex_to_rgb01(color_hex: str) -> Tuple[float, float, float]: |
| color_hex = color_hex.lstrip("#") |
| r = int(color_hex[0:2], 16) / 255.0 |
| g = int(color_hex[2:4], 16) / 255.0 |
| b = int(color_hex[4:6], 16) / 255.0 |
| return r, g, b |
|
|
|
|
| def rgb01_to_quantity(rgb: Tuple[float, float, float]) -> Quantity_Color: |
| return Quantity_Color(rgb[0], rgb[1], rgb[2], Quantity_TOC_RGB) |
|
|
|
|
| def text_color_for_bg(color_hex: str) -> str: |
| r, g, b = hex_to_rgb01(color_hex) |
| luminance = (0.299 * r + 0.587 * g + 0.114 * b) |
| return "#000000" if luminance > 0.6 else "#ffffff" |
|
|
|
|
| @dataclass |
| class FaceItem: |
| face: object |
| ais: object |
|
|
|
|
| class FaceLabeler(QtWidgets.QMainWindow): |
| def __init__(self, step_path: Optional[str] = None, output_dir: Optional[str] = None): |
| super().__init__() |
| self.setWindowTitle("BRepMFR Face Labeler") |
| self.resize(1600, 1000) |
| self.setMinimumSize(1200, 800) |
|
|
| self.class_names = CLASS_NAMES |
| self.class_colors_rgb = [hex_to_rgb01(c) for c in CLASS_COLORS_HEX] |
| self.class_colors = [rgb01_to_quantity(c) for c in self.class_colors_rgb] |
| self.unlabeled_color = rgb01_to_quantity(hex_to_rgb01(UNLABELED_COLOR_HEX)) |
|
|
| self.face_items: List[FaceItem] = [] |
| self.labels: List[Optional[int]] = [] |
| self.current_index: Optional[int] = None |
| self.highlight_enabled = True |
| self.step_path: Optional[str] = None |
| self.output_dir: Optional[str] = output_dir |
|
|
| self.highlight_color = rgb01_to_quantity(hex_to_rgb01(HIGHLIGHT_COLOR_HEX)) |
|
|
| self._build_ui() |
| self.update_step_label() |
|
|
| if step_path: |
| self.load_step(step_path) |
|
|
| def _build_ui(self) -> None: |
| central = QtWidgets.QWidget(self) |
| root_layout = QtWidgets.QHBoxLayout(central) |
| root_layout.setContentsMargins(8, 8, 8, 8) |
| root_layout.setSpacing(8) |
|
|
| self.viewer = qtViewer3d(central) |
| self.viewer.InitDriver() |
| self.display = self.viewer._display |
| try: |
| self.display.Context.SetAutomaticHilight(False) |
| except Exception: |
| pass |
| self._configure_viewer_visuals() |
| root_layout.addWidget(self.viewer, 1) |
|
|
| panel = QtWidgets.QWidget(central) |
| panel_layout = QtWidgets.QVBoxLayout(panel) |
| panel_layout.setContentsMargins(0, 0, 0, 0) |
| panel_layout.setSpacing(6) |
| root_layout.addWidget(panel, 0) |
|
|
| self.btn_import_step = QtWidgets.QPushButton("Import STEP") |
| self.btn_import_step.clicked.connect(self.on_import_step) |
| panel_layout.addWidget(self.btn_import_step) |
|
|
| self.btn_export_seg = QtWidgets.QPushButton("Export .seg") |
| self.btn_export_seg.clicked.connect(self.on_export_seg) |
| panel_layout.addWidget(self.btn_export_seg) |
|
|
| self.btn_review = QtWidgets.QPushButton("Review") |
| self.btn_review.clicked.connect(self.on_review) |
| panel_layout.addWidget(self.btn_review) |
|
|
| panel_layout.addSpacing(8) |
|
|
| nav_layout = QtWidgets.QHBoxLayout() |
| self.btn_prev = QtWidgets.QPushButton("<< Prev") |
| self.btn_prev.clicked.connect(self.on_prev) |
| self.btn_next = QtWidgets.QPushButton("Next >>") |
| self.btn_next.clicked.connect(self.on_next) |
| nav_layout.addWidget(self.btn_prev) |
| nav_layout.addWidget(self.btn_next) |
| panel_layout.addLayout(nav_layout) |
|
|
| self.step_label = QtWidgets.QLabel("STEP: (none)") |
| self.step_label.setWordWrap(True) |
| panel_layout.addWidget(self.step_label) |
|
|
| self.info_label = QtWidgets.QLabel("No STEP loaded") |
| self.info_label.setWordWrap(True) |
| panel_layout.addWidget(self.info_label) |
|
|
| panel_layout.addSpacing(8) |
|
|
| legend_label = QtWidgets.QLabel("Assign Label") |
| legend_label.setStyleSheet("font-weight: bold;") |
| panel_layout.addWidget(legend_label) |
|
|
| grid = QtWidgets.QGridLayout() |
| grid.setSpacing(6) |
| for idx, name in enumerate(self.class_names): |
| btn = QtWidgets.QPushButton(f"{idx}: {name}") |
| bg = CLASS_COLORS_HEX[idx] |
| fg = text_color_for_bg(bg) |
| btn.setStyleSheet(f"background-color: {bg}; color: {fg};") |
| btn.clicked.connect(lambda checked=False, i=idx: self.assign_label(i)) |
| grid.addWidget(btn, idx, 0) |
| panel_layout.addLayout(grid) |
|
|
| panel_layout.addStretch(1) |
|
|
| self.setCentralWidget(central) |
|
|
| def update_step_label(self) -> None: |
| if self.step_path: |
| name = os.path.basename(self.step_path) |
| self.step_label.setText(f"STEP: {name}") |
| self.setWindowTitle(f"BRepMFR Face Labeler - {name}") |
| else: |
| self.step_label.setText("STEP: (none)") |
| self.setWindowTitle("BRepMFR Face Labeler") |
|
|
| def keyPressEvent(self, event) -> None: |
| if event.key() in (QtCore.Qt.Key_Right, QtCore.Qt.Key_D): |
| self.on_next() |
| return |
| if event.key() in (QtCore.Qt.Key_Left, QtCore.Qt.Key_A): |
| self.on_prev() |
| return |
| super().keyPressEvent(event) |
|
|
| def on_import_step(self) -> None: |
| path, _ = QtWidgets.QFileDialog.getOpenFileName( |
| self, "Open STEP", "", "STEP Files (*.stp *.step)" |
| ) |
| if path: |
| self.load_step(path) |
|
|
| def on_export_seg(self) -> None: |
| if not self.labels: |
| QtWidgets.QMessageBox.warning(self, "Export", "No STEP loaded.") |
| return |
| if any(label is None for label in self.labels): |
| QtWidgets.QMessageBox.warning( |
| self, |
| "Export", |
| "Unlabeled faces remain. Label all faces before exporting.", |
| ) |
| return |
| if self.output_dir: |
| if not self.step_path: |
| QtWidgets.QMessageBox.warning(self, "Export", "No STEP loaded.") |
| return |
| output_dir = pathlib.Path(self.output_dir) |
| output_dir.mkdir(parents=True, exist_ok=True) |
| filename = pathlib.Path(self.step_path).with_suffix(".seg").name |
| path = output_dir / filename |
| self.save_seg(str(path)) |
| return |
|
|
| path, _ = QtWidgets.QFileDialog.getSaveFileName( |
| self, "Export .seg", "", "SEG Files (*.seg)" |
| ) |
| if path: |
| self.save_seg(path) |
|
|
| def on_review(self) -> None: |
| if not self.labels: |
| QtWidgets.QMessageBox.information(self, "Review", "No STEP loaded.") |
| return |
| counts = [0 for _ in self.class_names] |
| unlabeled = [] |
| for idx, label in enumerate(self.labels): |
| if label is None: |
| unlabeled.append(idx) |
| else: |
| counts[label] += 1 |
|
|
| lines = [ |
| f"Total faces: {len(self.labels)}", |
| f"Unlabeled: {len(unlabeled)}", |
| "", |
| ] |
| for idx, name in enumerate(self.class_names): |
| lines.append(f"{idx} {name}: {counts[idx]}") |
|
|
| if unlabeled: |
| preview = ", ".join(str(i) for i in unlabeled[:20]) |
| if len(unlabeled) > 20: |
| preview += ", ..." |
| lines.append("") |
| lines.append(f"Unlabeled indices: {preview}") |
| lines.append("") |
| lines.append("Jump to first unlabeled?") |
| res = QtWidgets.QMessageBox.question( |
| self, |
| "Review", |
| "\n".join(lines), |
| QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, |
| ) |
| if res == QtWidgets.QMessageBox.Yes: |
| self.set_current_index(unlabeled[0]) |
| else: |
| self.highlight_enabled = False |
| if self.current_index is not None: |
| self.set_face_color( |
| self.current_index, self.get_base_color(self.current_index) |
| ) |
| QtWidgets.QMessageBox.information(self, "Review", "\n".join(lines)) |
|
|
| def on_prev(self) -> None: |
| if self.current_index is None: |
| return |
| if self.current_index <= 0: |
| return |
| self.set_current_index(self.current_index - 1) |
|
|
| def on_next(self) -> None: |
| if self.current_index is None: |
| return |
| if self.current_index >= len(self.face_items) - 1: |
| return |
| self.set_current_index(self.current_index + 1) |
|
|
| def load_step(self, path: str) -> None: |
| reader = STEPControl_Reader() |
| status = reader.ReadFile(path) |
| if status != IFSelect_RetDone: |
| QtWidgets.QMessageBox.warning( |
| self, "Load STEP", f"Failed to read STEP file: {path}" |
| ) |
| return |
| reader.TransferRoots() |
| shape = reader.OneShape() |
| self.step_path = path |
| self.update_step_label() |
|
|
| self.display.EraseAll() |
| self.face_items.clear() |
| self.labels.clear() |
| self.highlight_enabled = True |
| self.current_index = None |
|
|
| explorer = TopExp_Explorer(shape, TopAbs_FACE) |
| while explorer.More(): |
| face = topods.Face(explorer.Current()) |
| ais = self.display.DisplayShape(face, update=False, color=self.unlabeled_color) |
| if isinstance(ais, list): |
| ais = ais[0] |
| self._apply_face_material(ais) |
| try: |
| self.display.Context.SetDisplayMode(ais, 1, False) |
| except Exception: |
| pass |
| try: |
| self.display.Context.Redisplay(ais, False) |
| except Exception: |
| pass |
| self.face_items.append(FaceItem(face=face, ais=ais)) |
| self.labels.append(None) |
| explorer.Next() |
|
|
| if not self.face_items: |
| QtWidgets.QMessageBox.warning( |
| self, "Load STEP", "No faces found in STEP file." |
| ) |
| self.display.Repaint() |
| return |
|
|
| self.display.FitAll() |
| self.set_current_index(0) |
| self.display.Repaint() |
|
|
| def save_seg(self, path: str) -> None: |
| with open(path, "w", encoding="utf-8") as handle: |
| for label in self.labels: |
| handle.write(f"{label}\n") |
| QtWidgets.QMessageBox.information(self, "Export", f"Saved: {path}") |
|
|
| def assign_label(self, label_index: int) -> None: |
| if self.current_index is None: |
| return |
| self.labels[self.current_index] = label_index |
| self.apply_current_highlight(self.current_index) |
| self.update_info() |
|
|
| def update_info(self) -> None: |
| if self.current_index is None: |
| self.info_label.setText("No STEP loaded") |
| return |
| label = self.labels[self.current_index] |
| label_text = "Unlabeled" if label is None else f"{label}: {self.class_names[label]}" |
| self.info_label.setText( |
| f"Face {self.current_index + 1}/{len(self.face_items)}\n" |
| f"Label: {label_text}" |
| ) |
|
|
| def get_base_color(self, index: int) -> Quantity_Color: |
| label = self.labels[index] |
| return self.unlabeled_color if label is None else self.class_colors[label] |
|
|
| def get_highlight_color(self, index: int) -> Quantity_Color: |
| return self.highlight_color |
|
|
| def set_current_index(self, index: int) -> None: |
| if not self.face_items: |
| return |
| index = max(0, min(index, len(self.face_items) - 1)) |
| if self.current_index is not None: |
| self.set_face_color(self.current_index, self.get_base_color(self.current_index)) |
| self.current_index = index |
| self.apply_current_highlight(self.current_index) |
| self.update_info() |
|
|
| def apply_current_highlight(self, index: int) -> None: |
| if self.highlight_enabled: |
| self.set_face_color(index, self.get_highlight_color(index)) |
| else: |
| self.set_face_color(index, self.get_base_color(index)) |
|
|
| def set_face_color(self, index: int, color: Quantity_Color) -> None: |
| ais = self.face_items[index].ais |
| if isinstance(ais, list): |
| for item in ais: |
| self._set_ais_color(item, color) |
| else: |
| self._set_ais_color(ais, color) |
| self.display.Repaint() |
|
|
| def _set_ais_color(self, ais, color: Quantity_Color) -> None: |
| try: |
| ais.SetColor(color) |
| except Exception: |
| self.display.Context.SetColor(ais, color, False) |
| self._apply_face_material(ais) |
| self.display.Context.Redisplay(ais, False) |
|
|
| def _apply_face_material(self, ais) -> None: |
| |
| applied = False |
| if Graphic3d_NOM_NEON is not None: |
| try: |
| ais.SetMaterial(Graphic3d_NOM_NEON) |
| applied = True |
| except Exception: |
| pass |
| if not applied and Graphic3d_NOM_MATTE is not None: |
| try: |
| ais.SetMaterial(Graphic3d_NOM_MATTE) |
| except Exception: |
| pass |
| self._apply_face_edges(ais) |
|
|
| def _configure_viewer_visuals(self) -> None: |
| |
| if Graphic3d_TOSM_UNLIT is None: |
| return |
| try: |
| self.display.View.SetShadingModel(Graphic3d_TOSM_UNLIT) |
| except Exception: |
| pass |
|
|
| def _apply_face_edges(self, ais) -> None: |
| if Prs3d_LineAspect is None or Aspect_TOL_SOLID is None: |
| return |
| try: |
| drawer = ais.Attributes() |
| drawer.SetFaceBoundaryDraw(True) |
| line_aspect = Prs3d_LineAspect( |
| rgb01_to_quantity(hex_to_rgb01(EDGE_COLOR_HEX)), |
| Aspect_TOL_SOLID, |
| 1.0, |
| ) |
| drawer.SetFaceBoundaryAspect(line_aspect) |
| except Exception: |
| pass |
|
|
|
|
| def main() -> int: |
| parser = argparse.ArgumentParser(description="BRepMFR Face Labeler") |
| parser.add_argument("step_path", nargs="?", help="Optional STEP file to open") |
| parser.add_argument( |
| "--output_dir", |
| type=str, |
| default=None, |
| help="Output directory for .seg exports (auto-save with input filename)", |
| ) |
| parser.add_argument( |
| "--output_folder", |
| type=str, |
| default=None, |
| help="Alias of --output_dir", |
| ) |
| args = parser.parse_args() |
| if args.output_dir and args.output_folder and args.output_dir != args.output_folder: |
| raise SystemExit("--output_dir and --output_folder must match when both are provided") |
|
|
| app = QtWidgets.QApplication(sys.argv) |
| step_path = args.step_path if args.step_path and os.path.exists(args.step_path) else None |
| output_dir = args.output_folder or args.output_dir |
| window = FaceLabeler(step_path=step_path, output_dir=output_dir) |
| window.show() |
| return app.exec_() |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|