Skip to content

Modules

PolyscopeApp

Source code in yumo/app.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
class PolyscopeApp:
    def __init__(self, config: Config):
        ps.set_program_name("Yumo")
        ps.set_print_prefix("[Yumo][Polyscope] ")
        ps.set_ground_plane_mode("shadow_only")
        ps.set_up_dir("z_up")
        ps.set_front_dir("x_front")

        ps.init()

        self.config = config

        self.context = Context(data_preprocess_method=config.data_preprocess_method)

        loaded_materials = self.prepare_materials(ASSETS_ROOT / "materials")  # extended builtin materials
        if self.config.custom_materials_path is not None:
            loaded_materials.update(self.prepare_materials(self.config.custom_materials_path))

        loaded_cmaps = self.prepare_colormaps(ASSETS_ROOT / "colormaps")  # extended builtin colormaps
        if self.config.custom_colormaps_path is not None:
            loaded_cmaps.update(self.prepare_colormaps(self.config.custom_colormaps_path))

        self._loaded_materials = list(loaded_materials)

        self.context.cmap = get_cmaps()[0]  # initialize default colormap with the first one
        self._loaded_cmaps = loaded_cmaps

        self._default_view_mat: np.ndarray | None = None

        self._colorbar_fontsize: int = 12

        self._should_init_quantities = True

        self._view_controls_msgs: str = ""

        self._picker_should_query_field = False
        self._picker_msgs: list[str] = []
        self._picker_msgs_padding: int = 5  # 5 lines of min padding

        self.slices = Slices("slices", self.context)
        self.structures: dict[str, Structure] = {}

        self.prepare_data_and_init_structures()

    def prepare_colormaps(self, cmap_dir) -> dict[str, str]:
        if not cmap_dir.exists():
            logger.warning(f"Colormap directory not found: {cmap_dir}")
            return {}

        loaded = {}
        for path in cmap_dir.glob("*_colormap.png"):
            name = path.stem.removesuffix("_colormap")  # e.g. "RdBu_colormap" -> "RdBu"
            try:
                ps.load_color_map(name, str(path))
                loaded[name] = str(path)
                logger.info(f"Loaded colormap: {name}")
            except Exception as e:
                logger.warning(f"Failed to load colormap {path.name}: {e}")

        if loaded:
            # Add them to the list of available colormaps
            set_cmaps([*loaded.keys(), *get_cmaps()])

        return loaded

    def prepare_materials(self, base_path: Path) -> set[str]:
        if not base_path.exists() or not base_path.is_dir():
            logger.error(f"Materials path does not exist or is not a directory: {base_path}")
            return set()

        logger.info(f"Loading materials from directory {base_path}")

        # Collect potential material sets by grouping on the prefix (before last "_")
        materials = {}

        paths = natsort.natsorted(base_path.glob("*"))

        for file in paths:
            stem = file.stem  # e.g. "wood_r" -> stem
            # Cut off last part after "_"
            if "_" not in stem:
                logger.debug(f"Skipping unexpected filename {file.name}")
                continue

            prefix = stem.rsplit("_", 1)[0]  # "wood_r" -> "wood"
            full_prefix_path = base_path / prefix
            materials[prefix] = full_prefix_path

        # Load each material set
        for prefix, filename_base in materials.items():
            logger.debug(f"Registering material '{prefix}' from base '{filename_base}'")
            ps.load_blendable_material(
                mat_name=prefix,
                filename_base=str(filename_base),
                filename_ext=".hdr",
            )

        if materials:
            set_materials([*materials.keys(), *get_materials()])  # prepend to builtin materials

        return set(materials.keys())

    def prepare_data_and_init_structures(self):
        """Load data from files, create structures."""
        # 1. Load raw data
        logger.info(f"Loading data from {self.config.data_path}")
        points = parse_plt_file(self.config.data_path, skip_zeros=self.config.skip_zeros)
        if self.config.sample_rate < 1.0:
            logger.info(
                f"Downsampling points from {points.shape[0]:,} to {int(points.shape[0] * self.config.sample_rate):,}"
            )
            indices = np.random.choice(
                points.shape[0], size=int(points.shape[0] * self.config.sample_rate), replace=False
            )
            points = points[indices]

        points = data_transform(points, method=self.config.data_preprocess_method)

        self.context.points = points

        self.context.center = np.mean(points[:, :3], axis=0)
        self.context.bbox_min = np.min(points[:, :3], axis=0)
        self.context.bbox_max = np.max(points[:, :3], axis=0)

        self.context.points_densest_distance = estimate_densest_point_distance(
            points[:, :3],
            k=5000,  # TODO: hard-coded
            quantile=0.01,
        )

        if self.config.mesh_path:
            logger.info(f"Loading mesh from {self.config.mesh_path}")
            self.context.mesh_vertices, self.context.mesh_faces = load_mesh(str(self.config.mesh_path))  # type: ignore[misc]

        # 2. Calculate statistics and set initial context
        self.context.min_value = np.min(points[:, 3])
        self.context.max_value = np.max(points[:, 3])
        self.context.color_min = self.context.min_value
        self.context.color_max = self.context.max_value

        # 3. Instantiate structures
        self.structures["points"] = PointCloudStructure("points", self.context, self.context.points, enabled=False)

        if self.context.mesh_vertices is not None and self.context.mesh_faces is not None:
            self.structures["mesh"] = MeshStructure(
                "mesh", self.context, self.context.mesh_vertices, self.context.mesh_faces, enabled=True
            )

    def update_all_scalar_quantities_colormap(self):
        """Update colormaps on all structures (including slices)."""
        for structure in self.structures.values():
            structure.update_all_quantities_colormap()

        self.slices.update_all_quantities_colormap()

    # --- UI Methods ---

    def _ui_top_text_brief(self):
        """A top text bar showing brief"""
        with ui_tree_node("Brief", open_first_time=True) as expanded:
            if not expanded:
                return
            psim.Text(f"Data: {self.config.data_path}")
            psim.Text(f"Mesh: {self.config.mesh_path if self.config.mesh_path else 'N/A'}")

            if self.config.mesh_path:  # the mesh should be loaded if the path is provided
                psim.Text(
                    f"Mesh vertices: {len(self.context.mesh_vertices):,}, "  # type: ignore[arg-type]
                    f"faces: {len(self.context.mesh_faces):,}"  # type: ignore[arg-type]
                )

            psim.Text(f"Points: {self.context.points.shape[0]:,}")
            psim.SameLine()
            psim.Text(f"Points densest distance: {self.context.points_densest_distance:.4g}")

            psim.Text(f"Points center: {fmt3(self.context.center)}")
            psim.Text(f"Bbox min: {fmt3(self.context.bbox_min)}")
            psim.Text(f"Bbox max: {fmt3(self.context.bbox_max)}")

            psim.Text(f"Data preprocess: {self.context.data_preprocess_method}")
            psim.Text(
                f"{'Data' if self.context.data_preprocess_method == 'identity' else 'Preprocessed data'} range: "
                f"[{self.context.min_value:.2g}, {self.context.max_value:.2g}]"
            )

        psim.Separator()

    def _ui_view_controls(self):
        with ui_tree_node("View Controls", open_first_time=False) as expanded:
            if not expanded:
                return

            if psim.Button("Reset Camera View"):
                if self._default_view_mat is None:
                    msg = "Default camera view matrix is not set. Please set it first."
                    logger.warning(msg)
                    ps.warning(msg)
                else:
                    ps.set_camera_view_matrix(self._default_view_mat)

            psim.SameLine()

            if psim.Button("Export Camera View"):
                view_mat = ps.get_camera_view_matrix()
                camera_view_file = Path(datetime.datetime.now().strftime("%Y%m%d_%H%M%S_cam_view.json"))
                with open(camera_view_file, "w") as f:
                    f.write(export_camera_view(view_mat))

                msg = f"Camera view exported to \n{camera_view_file.absolute()}"
                logger.info(msg)
                self._view_controls_msgs = msg

            psim.Text(self._view_controls_msgs)

    def _ui_coord_picker(self):
        with ui_tree_node("Coord Picker", open_first_time=True) as expanded:
            if not expanded:
                return

        inv_transform = (
            functools.partial(inverse_data_transform, method=self.context.data_preprocess_method)
            if self.context.data_preprocess_method != "identity"
            else None
        )

        changed, query_field = psim.Checkbox("Query Field", self._picker_should_query_field)
        if changed:
            self._picker_should_query_field = query_field

        io = psim.GetIO()
        if io.MouseClicked[0]:  # left click
            self._picker_msgs = []

            screen_coords = io.MousePos
            world_coords = ps.screen_coords_to_world_position(screen_coords)

            msg = f"World coord picked: {world_coords}"
            logger.debug(msg)
            self._picker_msgs.append(msg)

            pick_result: ps.PickResult = ps.pick(screen_coords=screen_coords)
            if pick_result.is_hit:
                self._process_pick_result(pick_result, world_coords, inv_transform)

        for msg in self._picker_msgs:
            psim.Text(msg)

        # Add padding to prevent UI jumping up and down
        for _ in range(max(0, self._picker_msgs_padding - len(self._picker_msgs))):
            psim.Text("")

        psim.Separator()

    def _handle_query_field(self, world_coords, inv_transform) -> float | None:
        """Query scalar field at given world coords and log the result."""
        field_value = query_scalar_field(world_coords, self.context.points)
        msg = f"Field value: {field_value:.4g}"
        if inv_transform:
            msg += f" (inverse transformed: {inv_transform(field_value):.4g})"
        logger.debug(msg)
        self._picker_msgs.append(msg)
        return field_value

    def _process_pick_result(self, pick_result: ps.PickResult, world_coords, inv_transform):
        """Handle what happens after a successful pick."""
        logger.debug(pick_result.structure_data)
        self._picker_msgs.append(f"Picked {pick_result.structure_name}: {pick_result.structure_data}")

        field_value = None
        if self._picker_should_query_field:
            field_value = self._handle_query_field(world_coords, inv_transform)

        if pick_result.structure_name == "mesh":
            texture_value = self._handle_mesh_pick(pick_result, inv_transform=inv_transform)
            if texture_value is not None and field_value is not None:
                rel_err = abs((texture_value - field_value) / field_value)
                msg = f"Relative error: {rel_err * 100:,.2f}%"
                logger.debug(msg)
                self._picker_msgs.append(msg)

    def _handle_mesh_pick(
        self,
        pick_result: ps.PickResult,
        inv_transform=None,
    ) -> np.float64 | None:
        """Handle mesh picking cases: face, vertex, corner."""
        logger.debug("Picked mesh")
        mesh: MeshStructure = self.structures["mesh"]  # type: ignore[assignment]

        data = pick_result.structure_data
        if "bary_coords" in data:  # ---- face hit
            barycentric_coord = data["bary_coords"].reshape(1, 3)
            indices = np.array([data["index"]], dtype=int)
            uv_coords = map_to_uv(
                uvs=mesh.uvs,
                faces_unwrapped=mesh.faces_unwrapped,
                barycentric_coord=barycentric_coord,
                indices=indices,
            )
        else:
            logger.warning(f"Unknown pick result: {data}, skipping")
            return None

        # ---- sample texture map
        texture_map = mesh.prepared_quantities[mesh.QUANTITY_NAME]
        h, w = texture_map.shape[:2]

        u, v = uv_coords[0]
        j = int(np.clip(u * (w - 1), 0, w - 1))  # x axis
        i = int(np.clip((1.0 - v) * (h - 1), 0, h - 1))  # y axis with flip

        # --- draw cross overlay for visualization (10px thick)
        indicator = einx.rearrange("h w -> h w 3", mesh.uv_mask).copy()

        thickness = 10  # pixels half-width of the line

        # horizontal band (thickness in vertical axis)
        i_min = max(i - thickness // 2, 0)
        i_max = min(i + thickness // 2 + 1, h)
        indicator[i_min:i_max, :, :] = [1, 0, 0]

        # vertical band (thickness in horizontal axis)
        j_min = max(j - thickness // 2, 0)
        j_max = min(j + thickness // 2 + 1, w)
        indicator[:, j_min:j_max, :] = [1, 0, 0]

        ps.add_color_image_quantity("Picked Cross", indicator)

        logger.debug(f"uv: {fmt2([u, v])}, hw: {h, w}, raster index: {i, j}")

        texture_value = texture_map[i, j]
        msg = f"Texture value: {texture_value:.4g}"
        if inv_transform:
            msg += f" (inverse transformed: {inv_transform(texture_value):.4g})"

        logger.debug(msg)
        self._picker_msgs.append(msg)

        return np.float64(texture_value)

    def _ui_colorbar_controls(self):
        """Colorbar controls UI"""
        with ui_tree_node("Colormap Controls") as expanded:
            if not expanded:
                return

            needs_update = False

            # Colormap selection using the yumo helper
            with ui_combo("Colormap", self.context.cmap) as combo_expanded:
                if combo_expanded:
                    for cmap_name in get_cmaps():
                        selected, _ = psim.Selectable(cmap_name, self.context.cmap == cmap_name)
                        if selected and cmap_name != self.context.cmap:
                            self.context.cmap = cmap_name
                            needs_update = True
                            logger.debug(f"Selected colormap: {cmap_name}")

            data_range = self.context.max_value - self.context.min_value
            v_speed = data_range / 1000.0 if data_range > 0 else 0.01

            with ui_item_width(100):
                # Min/Max value controls
                changed_min, new_min = psim.DragFloat(
                    "Min Value", self.context.color_min, v_speed, self.context.min_value, self.context.max_value, "%.4g"
                )

                psim.SameLine()

                if changed_min:
                    self.context.color_min = new_min
                    needs_update = True

                changed_max, new_max = psim.DragFloat(
                    "Max Value", self.context.color_max, v_speed, self.context.min_value, self.context.max_value, "%.4g"
                )

                psim.SameLine()

                if changed_max:
                    self.context.color_max = new_max
                    needs_update = True

                self.context.color_max = max(self.context.color_min, self.context.color_max)

                if psim.Button("Reset Range"):
                    self.context.color_min = self.context.min_value
                    self.context.color_max = self.context.max_value
                    needs_update = True

                changed, fontsize = psim.DragInt("Font Size", self._colorbar_fontsize, 1, 1, 100)
                if changed:
                    self._colorbar_fontsize = fontsize
                    # no need for needs_update, as this only changes the font size

                if needs_update:
                    self.update_all_scalar_quantities_colormap()

        psim.Separator()

    def _ui_colorbar_display(self):
        """Add colorbar image"""
        colorbar_img = generate_colorbar_image(
            colorbar_height=300,
            colorbar_width=120,
            cmap=self.context.cmap,
            c_min=self.context.color_min,
            c_max=self.context.color_max,
            method=self.context.data_preprocess_method,
            loaded_cmaps=self._loaded_cmaps,
            font_size=self._colorbar_fontsize,
        )
        ps.add_color_image_quantity(
            "colorbar",
            colorbar_img,
            image_origin="upper_left",
            show_in_imgui_window=True,
            enabled=True,
        )

    # --- Main Loop ---

    def callback(self) -> None:
        """The main callback loop for Polyscope."""
        # Phase 1: Register Geometries (runs only once internally per structure)
        for structure in self.structures.values():
            structure.register()

        # Phase 2: Add Scalar Quantities (runs only once via the flag)
        if self._should_init_quantities:
            # Prepare quantities (expensive, one-time calculations)
            for structure in self.structures.values():
                structure.prepare_quantities()

            # Add quantities to Polyscope structures
            for structure in self.structures.values():
                structure.add_prepared_quantities()
            self._should_init_quantities = False  # Prevent this from running again

        # Other callbacks
        for structure in self.structures.values():
            structure.callback()

        self.slices.callback()

        # Build the UI
        self._ui_top_text_brief()
        self._ui_view_controls()
        self._ui_colorbar_controls()
        self._ui_colorbar_display()
        self._ui_coord_picker()

        for structure in self.structures.values():
            structure.ui()

        self.slices.ui()

    def run(self):
        """Initialize and run the Polyscope application."""
        if self.config.camera_view_path:
            logger.info(f"Loading initial camera view from: {self.config.camera_view_path}")
            with open(self.config.camera_view_path) as f:
                lines = f.readlines()
            json_str = "".join(lines)
            view_mat = load_camera_view(json_str)
            ps.set_camera_view_matrix(view_mat)

            self._default_view_mat = view_mat

        ps.set_user_callback(self.callback)
        ps.show()

callback()

The main callback loop for Polyscope.

Source code in yumo/app.py
def callback(self) -> None:
    """The main callback loop for Polyscope."""
    # Phase 1: Register Geometries (runs only once internally per structure)
    for structure in self.structures.values():
        structure.register()

    # Phase 2: Add Scalar Quantities (runs only once via the flag)
    if self._should_init_quantities:
        # Prepare quantities (expensive, one-time calculations)
        for structure in self.structures.values():
            structure.prepare_quantities()

        # Add quantities to Polyscope structures
        for structure in self.structures.values():
            structure.add_prepared_quantities()
        self._should_init_quantities = False  # Prevent this from running again

    # Other callbacks
    for structure in self.structures.values():
        structure.callback()

    self.slices.callback()

    # Build the UI
    self._ui_top_text_brief()
    self._ui_view_controls()
    self._ui_colorbar_controls()
    self._ui_colorbar_display()
    self._ui_coord_picker()

    for structure in self.structures.values():
        structure.ui()

    self.slices.ui()

prepare_data_and_init_structures()

Load data from files, create structures.

Source code in yumo/app.py
def prepare_data_and_init_structures(self):
    """Load data from files, create structures."""
    # 1. Load raw data
    logger.info(f"Loading data from {self.config.data_path}")
    points = parse_plt_file(self.config.data_path, skip_zeros=self.config.skip_zeros)
    if self.config.sample_rate < 1.0:
        logger.info(
            f"Downsampling points from {points.shape[0]:,} to {int(points.shape[0] * self.config.sample_rate):,}"
        )
        indices = np.random.choice(
            points.shape[0], size=int(points.shape[0] * self.config.sample_rate), replace=False
        )
        points = points[indices]

    points = data_transform(points, method=self.config.data_preprocess_method)

    self.context.points = points

    self.context.center = np.mean(points[:, :3], axis=0)
    self.context.bbox_min = np.min(points[:, :3], axis=0)
    self.context.bbox_max = np.max(points[:, :3], axis=0)

    self.context.points_densest_distance = estimate_densest_point_distance(
        points[:, :3],
        k=5000,  # TODO: hard-coded
        quantile=0.01,
    )

    if self.config.mesh_path:
        logger.info(f"Loading mesh from {self.config.mesh_path}")
        self.context.mesh_vertices, self.context.mesh_faces = load_mesh(str(self.config.mesh_path))  # type: ignore[misc]

    # 2. Calculate statistics and set initial context
    self.context.min_value = np.min(points[:, 3])
    self.context.max_value = np.max(points[:, 3])
    self.context.color_min = self.context.min_value
    self.context.color_max = self.context.max_value

    # 3. Instantiate structures
    self.structures["points"] = PointCloudStructure("points", self.context, self.context.points, enabled=False)

    if self.context.mesh_vertices is not None and self.context.mesh_faces is not None:
        self.structures["mesh"] = MeshStructure(
            "mesh", self.context, self.context.mesh_vertices, self.context.mesh_faces, enabled=True
        )

run()

Initialize and run the Polyscope application.

Source code in yumo/app.py
def run(self):
    """Initialize and run the Polyscope application."""
    if self.config.camera_view_path:
        logger.info(f"Loading initial camera view from: {self.config.camera_view_path}")
        with open(self.config.camera_view_path) as f:
            lines = f.readlines()
        json_str = "".join(lines)
        view_mat = load_camera_view(json_str)
        ps.set_camera_view_matrix(view_mat)

        self._default_view_mat = view_mat

    ps.set_user_callback(self.callback)
    ps.show()

update_all_scalar_quantities_colormap()

Update colormaps on all structures (including slices).

Source code in yumo/app.py
def update_all_scalar_quantities_colormap(self):
    """Update colormaps on all structures (including slices)."""
    for structure in self.structures.values():
        structure.update_all_quantities_colormap()

    self.slices.update_all_quantities_colormap()

Structure

Bases: ABC

Abstract base class for a visualizable structure in Polyscope.

Source code in yumo/base_structure.py
class Structure(ABC):
    """Abstract base class for a visualizable structure in Polyscope."""

    def __init__(self, name: str, app_context: "Context", enabled: bool = True):
        self.name = name
        self.app_context = app_context
        self.enabled = enabled

        self._is_registered = False
        self._quantities_added = False
        self.prepared_quantities: dict[str, np.ndarray] = {}

    def register(self, force: bool = False):
        """Registers the structure's geometry with Polyscope. (Called every frame, but runs once)."""
        if not self.is_valid():
            return
        if self._is_registered and not force:
            return
        self._do_register()
        if self.polyscope_structure:
            self.polyscope_structure.set_enabled(self.enabled)
            self._is_registered = True

    def add_prepared_quantities(self):
        """Adds all prepared scalar quantities to the registered Polyscope structure."""
        logger.debug(f"Updating quantities for structure: '{self.name}'")

        if not self._is_registered:
            raise RuntimeError("Structure must be registered before adding quantities.")

        struct = self.polyscope_structure
        if not struct:
            return

        for name, values in self.prepared_quantities.items():
            struct.add_scalar_quantity(
                name,
                values,
                enabled=True,
                cmap=self.app_context.cmap,
                vminmax=(self.app_context.color_min, self.app_context.color_max),
            )
        self._quantities_added = True

    def update_all_quantities_colormap(self):
        """Updates the colormap and range for all managed quantities."""
        self.add_prepared_quantities()  # in Polyscope, re-adding quantities overwrites existing quantities

    def set_enabled(self, enabled: bool):
        """Enable or disable the structure in the UI."""
        self.enabled = enabled
        if self.polyscope_structure:
            self.polyscope_structure.set_enabled(self.enabled)

    @property
    @abstractmethod
    def polyscope_structure(self):
        """Get the underlying Polyscope structure object."""
        pass

    @abstractmethod
    def prepare_quantities(self):
        """Subclass-specific logic to calculate and prepare scalar data arrays."""
        pass

    @abstractmethod
    def _do_register(self):
        """Subclass-specific geometry registration logic."""
        pass

    @abstractmethod
    def is_valid(self) -> bool:
        """Check if the structure has valid data to be registered."""
        pass

    @abstractmethod
    def ui(self):
        """Update structure related UI"""
        pass

    @abstractmethod
    def callback(self):
        """Update structure related callback"""
        pass

polyscope_structure abstractmethod property

Get the underlying Polyscope structure object.

add_prepared_quantities()

Adds all prepared scalar quantities to the registered Polyscope structure.

Source code in yumo/base_structure.py
def add_prepared_quantities(self):
    """Adds all prepared scalar quantities to the registered Polyscope structure."""
    logger.debug(f"Updating quantities for structure: '{self.name}'")

    if not self._is_registered:
        raise RuntimeError("Structure must be registered before adding quantities.")

    struct = self.polyscope_structure
    if not struct:
        return

    for name, values in self.prepared_quantities.items():
        struct.add_scalar_quantity(
            name,
            values,
            enabled=True,
            cmap=self.app_context.cmap,
            vminmax=(self.app_context.color_min, self.app_context.color_max),
        )
    self._quantities_added = True

callback() abstractmethod

Update structure related callback

Source code in yumo/base_structure.py
@abstractmethod
def callback(self):
    """Update structure related callback"""
    pass

is_valid() abstractmethod

Check if the structure has valid data to be registered.

Source code in yumo/base_structure.py
@abstractmethod
def is_valid(self) -> bool:
    """Check if the structure has valid data to be registered."""
    pass

prepare_quantities() abstractmethod

Subclass-specific logic to calculate and prepare scalar data arrays.

Source code in yumo/base_structure.py
@abstractmethod
def prepare_quantities(self):
    """Subclass-specific logic to calculate and prepare scalar data arrays."""
    pass

register(force=False)

Registers the structure's geometry with Polyscope. (Called every frame, but runs once).

Source code in yumo/base_structure.py
def register(self, force: bool = False):
    """Registers the structure's geometry with Polyscope. (Called every frame, but runs once)."""
    if not self.is_valid():
        return
    if self._is_registered and not force:
        return
    self._do_register()
    if self.polyscope_structure:
        self.polyscope_structure.set_enabled(self.enabled)
        self._is_registered = True

set_enabled(enabled)

Enable or disable the structure in the UI.

Source code in yumo/base_structure.py
def set_enabled(self, enabled: bool):
    """Enable or disable the structure in the UI."""
    self.enabled = enabled
    if self.polyscope_structure:
        self.polyscope_structure.set_enabled(self.enabled)

ui() abstractmethod

Update structure related UI

Source code in yumo/base_structure.py
@abstractmethod
def ui(self):
    """Update structure related UI"""
    pass

update_all_quantities_colormap()

Updates the colormap and range for all managed quantities.

Source code in yumo/base_structure.py
def update_all_quantities_colormap(self):
    """Updates the colormap and range for all managed quantities."""
    self.add_prepared_quantities()  # in Polyscope, re-adding quantities overwrites existing quantities

bake_to_texture(sample_uvs, values, H, W)

Bake scalar values into a texture map using scatter-add.

UV space (0,0 bottom-left) is converted to image space (0,0 top-left).

Source code in yumo/geometry_utils.py
@profiler(profiler_logger=logger)
def bake_to_texture(
    sample_uvs: np.ndarray,
    values: np.ndarray,
    H: int,
    W: int,
):
    """
    Bake scalar values into a texture map using scatter-add.

    UV space (0,0 bottom-left) is converted to image space (0,0 top-left).
    """
    tex_sum = np.zeros((H, W), dtype=float)
    tex_count = np.zeros((H, W), dtype=int)

    # UV -> pixel coords (with vertical flip to align with image convention)
    us = np.clip((sample_uvs[:, 0] * (W - 1)).astype(int), 0, W - 1)
    vs = np.clip(((1.0 - sample_uvs[:, 1]) * (H - 1)).astype(int), 0, H - 1)

    # Scatter values
    np.add.at(tex_sum, (vs, us), values)
    np.add.at(tex_count, (vs, us), 1)

    # Normalize
    mask = tex_count > 0
    tex_sum[mask] /= tex_count[mask]

    return tex_sum

blur(texture, sigma=1.0, mask=None, **kwargs)

Apply Gaussian blur to a texture while ignoring zero-padding.

This function performs a mask-aware Gaussian filter: both the texture and a binary mask are smoothed, and then the results are normalized to prevent padded zeros from biasing the blur.

Parameters:

Name Type Description Default
texture ndarray

Input texture array. Padding regions should be zeros. Can be 2D (H, W) or 3D (H, W, C).

required
sigma float

Standard deviation of the Gaussian kernel. Defaults to 1.0.

1.0
mask ndarray

(0) Padding (1) Islands.

None
**kwargs

Useless. For compatibility only.

{}

Returns:

Type Description
ndarray

np.ndarray: Blurred texture with padding ignored. Same shape as input.

Source code in yumo/geometry_utils.py
def blur(
    texture: np.ndarray,
    sigma: float = 1.0,
    mask: np.ndarray = None,  # type: ignore[assignment]
    **kwargs,
) -> np.ndarray:
    """Apply Gaussian blur to a texture while ignoring zero-padding.

    This function performs a mask-aware Gaussian filter: both the texture
    and a binary mask are smoothed, and then the results are normalized
    to prevent padded zeros from biasing the blur.

    Args:
        texture (np.ndarray): Input texture array. Padding regions should be zeros.
            Can be 2D (H, W) or 3D (H, W, C).
        sigma (float, optional): Standard deviation of the Gaussian kernel. Defaults to 1.0.
        mask (np.ndarray): (0) Padding (1) Islands.
        **kwargs: Useless. For compatibility only.

    Returns:
        np.ndarray: Blurred texture with padding ignored. Same shape as input.
    """
    # Build binary mask
    zeros_mask = texture != 0  # we don't & mask here, as holes inside islands must be normalized too
    zeros_mask = zeros_mask.astype(np.float64)

    # Apply Gaussian filter
    blurred_tex = gaussian_filter(texture.astype(np.float64), sigma=sigma)
    blurred_mask = gaussian_filter(zeros_mask.astype(np.float64), sigma=sigma)

    # Normalize
    eps = 1e-8
    result = blurred_tex / (blurred_mask + eps)

    # Set padding back to zero
    result[mask == 0] = 0

    return result  # type: ignore[no-any-return]

denoise_texture(texture, method='nearest_and_gaussian', **kwargs)

Fill missing (zero) values in a sparse 2D texture map using interpolation.

Parameters:

Name Type Description Default
texture ndarray

A 2D NumPy array representing the texture map. Zero entries are treated as missing data to be filled.

required
method str

Interpolation method to use. Options are: - "gaussian": Simple gaussian filter. - "nearest_and_gaussian": Fill using nearest-neighbour then gaussian blur. - "nearest": Fill using nearest-neighbor interpolation. Defaults to "linear".

'nearest_and_gaussian'

Returns:

Type Description

numpy.ndarray: A 2D NumPy array of the same shape as texture, with missing (zero) values replaced by interpolated values.

Raises:

Type Description
ValueError

If method is not one of {"linear", "nearest"}.

Source code in yumo/geometry_utils.py
@profiler(profiler_logger=logger)
def denoise_texture(texture, method="nearest_and_gaussian", **kwargs):
    """
    Fill missing (zero) values in a sparse 2D texture map using interpolation.

    Args:
        texture (numpy.ndarray):
            A 2D NumPy array representing the texture map.
            Zero entries are treated as missing data to be filled.
        method (str, optional):
            Interpolation method to use. Options are:
            - "gaussian": Simple gaussian filter.
            - "nearest_and_gaussian": Fill using nearest-neighbour then gaussian blur.
            - "nearest": Fill using nearest-neighbor interpolation.
            Defaults to "linear".

    Returns:
        numpy.ndarray:
            A 2D NumPy array of the same shape as `texture`,
            with missing (zero) values replaced by interpolated values.

    Raises:
        ValueError: If `method` is not one of {"linear", "nearest"}.
    """
    if method == "gaussian":
        return blur(texture, **kwargs)
    elif method == "nearest_and_gaussian":
        return nearest_and_blur(texture, **kwargs)
    elif method == "nearest":
        return nearest_fill(texture, **kwargs)
    else:
        raise ValueError(f"Invalid method: {method}. Must be one of 'linear' or 'nearest'.")

generate_slice_mesh(center, h, w, rh, rw)

Generate a slice plane mesh in the XY-plane, centered on center.

Parameters:

Name Type Description Default
center ndarray

3D coordinate where the center of the mesh will be placed (shape (3,))

required
h float

physical height of the plane

required
w float

physical width of the plane

required
rh int

resolution along height (number of vertices)

required
rw int

resolution along width (number of vertices)

required

Returns:

Type Description
tuple[ndarray, ndarray]

tuple[np.ndarray, np.ndarray]: vertices: (N, 3) array of 3D coordinates faces: (M, 3) array of integer indices into vertices

Source code in yumo/geometry_utils.py
def generate_slice_mesh(center: np.ndarray, h: float, w: float, rh: int, rw: int) -> tuple[np.ndarray, np.ndarray]:
    """
    Generate a slice plane mesh in the XY-plane, centered on `center`.

    Args:
        center (np.ndarray): 3D coordinate where the center of the mesh will be placed (shape (3,))
        h (float): physical height of the plane
        w (float): physical width of the plane
        rh (int): resolution along height (number of vertices)
        rw (int): resolution along width (number of vertices)

    Returns:
        tuple[np.ndarray, np.ndarray]:
            vertices: (N, 3) array of 3D coordinates
            faces: (M, 3) array of integer indices into vertices
    """
    # Generate grid in local XY-plane
    y = np.linspace(-h / 2, h / 2, rh)
    x = np.linspace(-w / 2, w / 2, rw)
    xx, yy = np.meshgrid(x, y)

    vertices = np.stack([xx.ravel(), yy.ravel(), np.zeros(xx.size)], axis=1) + center

    # Vectorized face construction
    # indices grid
    idx = np.arange(rh * rw).reshape(rh, rw)

    # Lower-left corners of each quad
    v0 = idx[:-1, :-1].ravel()
    v1 = idx[:-1, 1:].ravel()
    v2 = idx[1:, :-1].ravel()
    v3 = idx[1:, 1:].ravel()

    # 2 triangles per quad
    faces = np.stack([np.column_stack([v0, v1, v2]), np.column_stack([v1, v3, v2])], axis=1).reshape(-1, 3)

    return vertices, faces

map_to_uv(uvs, faces_unwrapped, barycentric_coord, indices)

Vectorized barycentric interpolation in UV space.

Parameters:

Name Type Description Default
uvs ndarray

(V, 2) UV coordinates.

required
faces_unwrapped ndarray

(M, 3) indices into uvs.

required
barycentric_coord ndarray

(L, 3) barycentric weights.

required
indices ndarray

(L,) face index ids.

required

Returns:

Name Type Description
sample_uvs ndarray

(L, 2).

Source code in yumo/geometry_utils.py
def map_to_uv(
    uvs: np.ndarray,
    faces_unwrapped: np.ndarray,
    barycentric_coord: np.ndarray,
    indices: np.ndarray,
) -> np.ndarray:
    """
    Vectorized barycentric interpolation in UV space.

    Args:
        uvs (np.ndarray): (V, 2) UV coordinates.
        faces_unwrapped (np.ndarray): (M, 3) indices into uvs.
        barycentric_coord (np.ndarray): (L, 3) barycentric weights.
        indices (np.ndarray): (L,) face index ids.

    Returns:
        sample_uvs (np.ndarray): (L, 2).
    """
    # Triangle uv coords (M,3,2)
    tri_uvs = uvs[faces_unwrapped]

    # Gather triangles for samples (L,3,2)
    sampled_tris = tri_uvs[indices]

    # Weighted sum -> (L,2)
    sample_uvs = np.einsum("lj,ljk->lk", barycentric_coord, sampled_tris)
    return sample_uvs  # type: ignore[no-any-return]

nearest_and_blur(texture, blur_sigma=1.0, max_dist=16, mask=None, **kwargs)

Parameters:

Name Type Description Default
texture
required
blur_sigma
1.0
max_dist
16
mask ndarray

(0) Padding (1) Islands.

None
**kwargs
{}

Returns:

Source code in yumo/geometry_utils.py
def nearest_and_blur(
    texture,
    blur_sigma=1.0,
    max_dist=16,
    mask: np.ndarray = None,  # type: ignore[assignment]
    **kwargs,
) -> np.ndarray:  # max dist should be smaller than the padding in unwrap_uv
    """

    Args:
        texture:
        blur_sigma:
        max_dist:
        mask: (0) Padding (1) Islands.
        **kwargs:

    Returns:

    """
    nn_result = nearest_fill(texture, max_dist=max_dist, mask=mask, **kwargs)
    kwargs.pop("mask", None)
    kwargs.pop("sigma", None)
    return blur(nn_result, sigma=blur_sigma, mask=mask, **kwargs)

nearest_fill(texture, max_dist=16, mask=None, **kwargs)

Parameters:

Name Type Description Default
texture
required
max_dist
16
mask ndarray

(0) Padding (1) Islands.

None
**kwargs
{}

Returns:

Source code in yumo/geometry_utils.py
def nearest_fill(
    texture,
    max_dist=16,
    mask: np.ndarray = None,  # type: ignore[assignment]
    **kwargs,
):  # kwargs for compatibility
    """

    Args:
        texture:
        max_dist:
        mask: (0) Padding (1) Islands.
        **kwargs:

    Returns:

    """
    # mask: 1 for missing (0), 0 for valid
    zeros_mask = texture == 0
    if mask is not None:
        zeros_mask = zeros_mask & mask.astype(bool)  # only fill islands

    dist, indices = distance_transform_edt(zeros_mask, return_indices=True)

    # Fill with nearest value
    filled = texture[tuple(indices)]
    filled[dist > max_dist] = 0  # drop far-away fills

    return filled

query_scalar_field(points_coord, data_points, cache=True)

Query scalar field f(x,y,z) in vectorized form.

Parameters:

Name Type Description Default
points_coord ndarray

(L, 3).

required
data_points ndarray

(data_len, 4) xyz,value

required
cache bool

Enable cache for cKDTree construction.

True

Returns:

Name Type Description
values ndarray

(L,).

Source code in yumo/geometry_utils.py
@profiler(profiler_logger=logger)
def query_scalar_field(points_coord: np.ndarray, data_points: np.ndarray, cache: bool = True) -> np.ndarray:
    """
    Query scalar field f(x,y,z) in vectorized form.

    Args:
        points_coord (np.ndarray): (L, 3).
        data_points (np.ndarray): (data_len, 4) xyz,value
        cache (bool): Enable cache for cKDTree construction.

    Returns:
        values (np.ndarray): (L,).
    """
    kdtree = get_tree(data_points) if cache else cKDTree(data_points[:, :3])

    _, nearest_indices = kdtree.query(points_coord, k=1)

    interpolated_values = data_points[nearest_indices, 3]
    return interpolated_values  # type: ignore[no-any-return]

sample_surface(vertices, faces, points_per_area=10.0, rng=None)

Vectorized surface sampling on a triangular mesh.

Parameters:

Name Type Description Default
vertices ndarray

(N, 3). Vertex positions.

required
faces ndarray

(M, 3). Triangle vertex indices.

required
points_per_area float

Density (points per unit area).

10.0
rng Generator

Random generator to use. If None, defaults to np.random.

None

Returns:

Name Type Description
points_coord ndarray

(L, 3). Sampled 3D points.

barycentric_coord ndarray

(L, 3). Barycentric coords.

indices ndarray

(L,). Face index for each point.

Source code in yumo/geometry_utils.py
def sample_surface(
    vertices: np.ndarray,
    faces: np.ndarray,
    points_per_area: float = 10.0,
    rng: np.random.Generator | None = None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """
    Vectorized surface sampling on a triangular mesh.

    Args:
        vertices (np.ndarray): (N, 3). Vertex positions.
        faces (np.ndarray): (M, 3). Triangle vertex indices.
        points_per_area (float): Density (points per unit area).
        rng (np.random.Generator, optional): Random generator to use.
            If None, defaults to np.random.

    Returns:
        points_coord (np.ndarray): (L, 3). Sampled 3D points.
        barycentric_coord (np.ndarray): (L, 3). Barycentric coords.
        indices (np.ndarray): (L,). Face index for each point.
    """
    if rng is None:
        rng = np.random.default_rng(42)

    # Triangle vertices (M,3,3)
    tri_vertices = vertices[faces]

    areas = triangle_areas(tri_vertices)

    # Number of samples per face
    n_samples_per_face = np.ceil(areas * points_per_area).astype(int)
    total_samples = n_samples_per_face.sum()
    if total_samples == 0:
        return (
            np.zeros((0, 3)),
            np.zeros((0, 3)),
            np.zeros((0,), dtype=int),
        )

    # Assign each sample a face id (L,)
    indices = np.repeat(np.arange(len(faces)), n_samples_per_face)

    # Random barycentric (L,2) -> convert to (L,3)
    u = rng.random(total_samples)
    v = rng.random(total_samples)
    mask = u + v > 1
    u[mask], v[mask] = 1 - u[mask], 1 - v[mask]
    w = 1 - (u + v)
    barycentric_coord = np.stack([w, u, v], axis=1)

    # Gather triangle vertices for each sample (L,3,3)
    sampled_tris = tri_vertices[indices]

    # Weighted sum -> points (L,3)
    points_coord = np.einsum("lj,ljk->lk", barycentric_coord, sampled_tris)

    return points_coord, barycentric_coord, indices

unwrap_uv(vertices, faces, padding=16, brute_force=False)

Performs UV unwrapping for a given 3D mesh using the xatlas library with padding settings to reduce UV bleeding.

Parameters:

Name Type Description Default
vertices ndarray

(N, 3) float array of mesh vertex positions.

required
faces ndarray

(M, 3) int array of triangular face indices.

required
padding int

Padding in pixels between UV islands (default=16px).

16
brute_force bool

Slower, but gives the best result. If false, use random chart placement.

False

Returns:

Type Description
ndarray

Tuple containing:

int
  • param_corner (np.ndarray): (M*3, 2) UV coords per face corner.
int
  • texture_height (int): Atlas height in pixels.
ndarray
  • texture_width (int): Atlas width in pixels.
ndarray
  • vmapping (np.ndarray): (V,) mapping of unwrapped vertex -> original vertex.
ndarray
  • faces_unwrapped (np.ndarray): (M, 3) face indices into unwrapped verts.
ndarray
  • uvs (np.ndarray): (V, 2) UV coords ∈ [0, 1].
tuple[ndarray, int, int, ndarray, ndarray, ndarray, ndarray]
  • vertices_unwrapped (np.ndarray): (V, 3) unwrapped vertex positions.
Source code in yumo/geometry_utils.py
def unwrap_uv(
    vertices: np.ndarray, faces: np.ndarray, padding: int = 16, brute_force: bool = False
) -> tuple[np.ndarray, int, int, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """
    Performs UV unwrapping for a given 3D mesh using the xatlas library with
    padding settings to reduce UV bleeding.

    Args:
        vertices (np.ndarray): (N, 3) float array of mesh vertex positions.
        faces (np.ndarray): (M, 3) int array of triangular face indices.
        padding (int): Padding in pixels between UV islands (default=16px).
        brute_force (bool): Slower, but gives the best result. If false, use random chart placement.

    Returns:
        Tuple containing:
        - param_corner (np.ndarray): (M*3, 2) UV coords per face corner.
        - texture_height (int): Atlas height in pixels.
        - texture_width (int): Atlas width in pixels.
        - vmapping (np.ndarray): (V,) mapping of unwrapped vertex -> original vertex.
        - faces_unwrapped (np.ndarray): (M, 3) face indices into unwrapped verts.
        - uvs (np.ndarray): (V, 2) UV coords ∈ [0, 1].
        - vertices_unwrapped (np.ndarray): (V, 3) unwrapped vertex positions.
    """
    atlas = xatlas.Atlas()
    atlas.add_mesh(vertices, faces)

    # Chart options (optional - default usually works fine)
    chart_options = xatlas.ChartOptions()

    # Pack with resolution & padding to avoid bleeding
    pack_options = xatlas.PackOptions()
    pack_options.padding = padding
    pack_options.bilinear = True
    pack_options.rotate_charts = True
    pack_options.bruteForce = brute_force
    atlas.generate(chart_options=chart_options, pack_options=pack_options)

    # Get unwrapped data
    vmapping, faces_unwrapped, uvs = atlas[0]  # [N], [M, 3], [N, 2]
    vertices_unwrapped = vertices[vmapping]

    # Flatten per-corner UVs for APIs like OpenGL
    param_corner = uvs[faces_unwrapped].reshape(-1, 2)

    texture_height, texture_width = atlas.height, atlas.width

    return (
        param_corner,
        texture_height,
        texture_width,
        vmapping,
        faces_unwrapped,
        uvs,
        vertices_unwrapped,
    )

uv_mask(uvs, faces_unwrapped, texture_width, texture_height, dilation=2, supersample=4)

Creates a binary (or soft) mask indicating which parts of the texture atlas are occupied by the mesh (True) and which are empty (False).

Parameters:

Name Type Description Default
uvs ndarray

(N,2) UV coordinates in [0,1].

required
faces_unwrapped ndarray

(F,3) triangle indices into uvs.

required
texture_width int

target texture width in pixels.

required
texture_height int

target texture height in pixels.

required
dilation int

number of pixels to expand the mask after rasterization.

2
supersample int

supersampling factor for more accurate rasterization.

4
Source code in yumo/geometry_utils.py
def uv_mask(
    uvs: np.ndarray,
    faces_unwrapped: np.ndarray,
    texture_width: int,
    texture_height: int,
    dilation: int = 2,
    supersample: int = 4,
) -> np.ndarray:
    """
    Creates a binary (or soft) mask indicating which parts of the texture atlas
    are occupied by the mesh (True) and which are empty (False).

    Args:
        uvs: (N,2) UV coordinates in [0,1].
        faces_unwrapped: (F,3) triangle indices into uvs.
        texture_width: target texture width in pixels.
        texture_height: target texture height in pixels.
        dilation: number of pixels to expand the mask after rasterization.
        supersample: supersampling factor for more accurate rasterization.
    """
    # --- supersample resolution for smoother edges ---
    hi_w = texture_width * supersample
    hi_h = texture_height * supersample

    # Convert UVs to high-res pixel coords
    uv_pixels = np.zeros_like(uvs, dtype=np.float64)
    uv_pixels[:, 0] = uvs[:, 0] * (hi_w - 1)
    uv_pixels[:, 1] = (1.0 - uvs[:, 1]) * (hi_h - 1)  # flip Y

    # Initialize high-res mask
    hi_mask = np.zeros((hi_h, hi_w), dtype=np.uint8)

    # Rasterize triangles
    for face in faces_unwrapped:
        pts = uv_pixels[face].astype(np.int32).reshape((-1, 1, 2))
        cv2.fillConvexPoly(hi_mask, pts, 255)  # type: ignore[call-overload]

    # Downsample back to target resolution with area interpolation
    mask = cv2.resize(hi_mask, (texture_width, texture_height), interpolation=cv2.INTER_AREA)

    # Normalize to [0,1] float mask (soft edges preserved)
    mask = mask.astype(np.float64) / 255.0

    # Optional dilation to pad seams (applied on final resolution)
    if dilation > 0:
        kernel = np.ones((dilation, dilation), np.uint8)
        hard_mask = (mask > 0).astype(np.uint8) * 255
        dilated = cv2.dilate(hard_mask, kernel, iterations=1)
        mask = np.maximum(mask, dilated.astype(np.float64) / 255.0)

    return mask

MeshStructure

Bases: Structure

Represents a surface mesh structure.

Source code in yumo/mesh.py
class MeshStructure(Structure):
    """Represents a surface mesh structure."""

    QUANTITY_NAME: ClassVar[str] = "mesh_texture_values"

    def __init__(self, name: str, app_context: "Context", vertices: np.ndarray, faces: np.ndarray, **kwargs):
        super().__init__(name, app_context, **kwargs)
        self.vertices = vertices
        self.faces = faces

        self.points_per_area = 1000

        # texture related
        self.param_corner: np.ndarray = None  # type: ignore[assignment]
        self.texture_height: int = None  # type: ignore[assignment]
        self.texture_width: int = None  # type: ignore[assignment]
        self.vmapping: np.ndarray = None  # type: ignore[assignment]
        self.faces_unwrapped: np.ndarray = None  # type: ignore[assignment]
        self.uvs: np.ndarray = None  # type: ignore[assignment]
        self.vertices_unwrapped: np.ndarray = None  # type: ignore[assignment]

        # mask indicating which parts of the texture atlas are occupied by the mesh (1) and which are empty (0).
        self.uv_mask: np.ndarray = None  # type: ignore[assignment]

        self.raw_texture: np.ndarray = None  # type: ignore[assignment]

        self.inv_vmapping: np.ndarray = None  # type: ignore[assignment]

        self.mesh_surface_area = triangle_areas(self.vertices[self.faces]).sum()

        materials = get_materials()
        default = "0.30_clay_0.70_flat"
        self._material = default if default in materials else materials[0]

        self._need_update = False
        self._need_rebake = True
        self._display_mode = "preview"  # one of "preview", "baked"

        self._enable_denoise = True
        self._denoise_method = DENOISE_METHODS[0]  # one of DENOISE_METHODS
        self._denoise_kwargs = {}  # type: ignore[var-annotated]

    @property
    def polyscope_structure(self):
        return ps.get_surface_mesh(self.name)

    def is_valid(self) -> bool:
        return self.vertices is not None and self.faces is not None

    def prepare_quantities(self):
        """
        We preview the resolution first before proceeding to actual data sampling in update_data_texture.
        """
        self.update_resolution_preview()

    def bake_texture(
        self,
        sampler_func: Callable[[np.ndarray], np.ndarray],
    ):
        """
        Args:
            sampler_func: Takes in sample query points (N, 3), outputs values (N,)

        Returns:

        """
        # -- 1. Sample surface --
        points, bary, indices = sample_surface(
            self.vertices_unwrapped,
            self.faces_unwrapped,
            points_per_area=self.points_per_area,
        )

        # -- 2. Map samples to UV space --
        sample_uvs = map_to_uv(self.uvs, self.faces_unwrapped, bary, indices)

        # -- 3. Sample scalar field --
        values = sampler_func(points)

        # -- 4. Bake to texture --
        tex = bake_to_texture(sample_uvs, values, self.texture_height, self.texture_width)

        return tex

    def update_resolution_preview(self):
        """Add a preview quantity of the surface sampling resolution using bake_texture"""
        self.raw_texture = self.bake_texture(
            sampler_func=lambda p: np.ones(p.shape[0]) * self.app_context.color_max,  # 0 (no fill); color_max (filled)
        )
        tex = self.raw_texture
        tex = tex * self.uv_mask
        self.prepared_quantities[self.QUANTITY_NAME] = tex

    def update_data_texture(self):
        """Sample the data points and update texture map"""
        if self._need_rebake:
            logger.debug(f"Rebaking data texture for structure: '{self.name}'")
            self._need_rebake = False
            self.raw_texture = self.bake_texture(
                sampler_func=functools.partial(query_scalar_field, data_points=self.app_context.points),
            )

        if self._enable_denoise:
            self._denoise_kwargs["mask"] = self.uv_mask
            tex = denoise_texture(self.raw_texture, method=self._denoise_method, **self._denoise_kwargs)
        else:
            tex = self.raw_texture.copy()

        tex = tex * self.uv_mask  # mask out unsampled areas
        self.prepared_quantities[self.QUANTITY_NAME] = tex

    def update_texture(self):
        if self._display_mode == "baked":
            self.update_data_texture()
        elif self._display_mode == "preview":
            self.update_resolution_preview()
        else:
            raise ValueError(f"Invalid display mode: {self._display_mode}")

    def add_prepared_quantities(self):
        """Adds all prepared scalar quantities to the registered Polyscope structure."""
        logger.debug(f"Updating quantities for structure: '{self.name}'")

        if not self._is_registered:
            raise RuntimeError("Structure must be registered before adding quantities.")

        struct = self.polyscope_structure
        if not struct:
            return

        for name, values in self.prepared_quantities.items():
            struct.add_scalar_quantity(
                name,
                values,
                enabled=True,
                defined_on="texture",  # Use texture coordinates
                param_name="uv",
                cmap=self.app_context.cmap,
                vminmax=(self.app_context.color_min, self.app_context.color_max),
            )
        self._quantities_added = True

    def _do_register(self):
        """Register only the mesh geometry."""
        logger.debug(f"Registering mesh geometry: '{self.name}'")

        # add uv parameterization
        (
            self.param_corner,
            self.texture_height,
            self.texture_width,
            self.vmapping,
            self.faces_unwrapped,
            self.uvs,
            self.vertices_unwrapped,
        ) = unwrap_uv(self.vertices, self.faces)

        mesh = ps.register_surface_mesh(self.name, self.vertices_unwrapped, self.faces_unwrapped)
        mesh.set_material(self._material)
        mesh.set_color([0.7, 0.7, 0.7])
        mesh.set_selection_mode(
            "faces_only"
        )  # only allow face selection (as the uv coord for face selection yields higher precision)

        self.uv_mask = uv_mask(
            uvs=self.uvs,
            faces_unwrapped=self.faces_unwrapped,
            texture_width=self.texture_width,
            texture_height=self.texture_height,
        )

        mesh.add_parameterization_quantity("uv", self.param_corner, defined_on="corners", enabled=True)

    def _ui_texture_map_display(self):
        """Add texture map image"""
        tex = self.prepared_quantities[self.QUANTITY_NAME]
        ps.add_scalar_image_quantity(
            "texture_map",
            tex,
            vminmax=(self.app_context.color_min, self.app_context.color_max),
            cmap=self.app_context.cmap,
            image_origin="upper_left",
            show_in_imgui_window=True,
            enabled=True,
        )

        ps.add_scalar_image_quantity(
            "raw_texture",
            self.raw_texture,
            vminmax=(self.app_context.color_min, self.app_context.color_max),
            cmap=self.app_context.cmap,
            image_origin="upper_left",
            show_in_imgui_window=True,
            enabled=True,
        )

    @ui_item_width(180)
    def _ui_material_controls(self):
        with ui_combo("Material", self._material) as expanded:
            if expanded:
                for material in get_materials():
                    selected, _ = psim.Selectable(material, material == self._material)
                    if selected and material != self._material:
                        self._material = material
                        self.polyscope_structure.set_material(material)

    def _ui_texture_denoise_method(self):
        """Texture denoise method selection"""
        changed, denoise_enabled = psim.Checkbox("Enable Denoise", self._enable_denoise)
        if changed:
            self._enable_denoise = denoise_enabled
            self._need_update = self._display_mode == "baked"

        if self._enable_denoise:
            psim.SameLine()

            with ui_combo("Denoise Method", self._denoise_method) as expanded:
                if expanded:
                    for method in DENOISE_METHODS:
                        selected, _ = psim.Selectable(method, method == self._denoise_method)
                        if selected and method != self._denoise_method:
                            self._denoise_method = method
                            self._need_update = self._display_mode == "baked"

            changed, max_dist = psim.DragFloat(
                "Max Dist for Nearest Neighbour", self._denoise_kwargs.get("max_dist", 16), v_min=1, v_max=16
            )
            if changed:
                self._denoise_kwargs["max_dist"] = max_dist
                self._need_update = self._display_mode == "baked"

            if self._denoise_method in ["nearest_and_gaussian", "gaussian"]:
                changed, sigma = psim.DragFloat("Sigma", self._denoise_kwargs.get("sigma", 1.0), v_min=0.0, v_max=16.0)
                if changed:
                    self._denoise_kwargs["sigma"] = sigma
                    self._need_update = self._display_mode == "baked"

    def ui(self):
        """Mesh related UI"""
        with ui_tree_node("Mesh", open_first_time=True) as expanded:
            if not expanded:
                return

            psim.Text(f"Mesh Surface Area: {self.mesh_surface_area:.2f}")
            psim.Text(f"Texture Width: {self.texture_width:.2f}")
            psim.SameLine()
            psim.Text(f"Texture Height: {self.texture_height:.2f}")

            with ui_item_width(120):
                changed, show = psim.Checkbox("Show", self.enabled)
                if changed:
                    self.set_enabled(show)

                psim.SameLine()

                changed, resolution = psim.DragFloat(
                    "Points / Unit Area", self.points_per_area, v_min=1.0, v_max=10000.0
                )
                if changed:
                    self.points_per_area = resolution
                    self._display_mode = "preview"
                    self._need_update = True

                psim.SameLine()

                if psim.Button("Bake"):
                    self._need_update = True
                    self._need_rebake = True
                    self._display_mode = "baked"

                self._ui_material_controls()

                self._ui_texture_denoise_method()

        psim.Separator()

        self._ui_texture_map_display()

    def callback(self):
        if self._need_update:
            self._need_update = False
            self.update_texture()  # update texture
            self.add_prepared_quantities()  # update polyscope quantity

add_prepared_quantities()

Adds all prepared scalar quantities to the registered Polyscope structure.

Source code in yumo/mesh.py
def add_prepared_quantities(self):
    """Adds all prepared scalar quantities to the registered Polyscope structure."""
    logger.debug(f"Updating quantities for structure: '{self.name}'")

    if not self._is_registered:
        raise RuntimeError("Structure must be registered before adding quantities.")

    struct = self.polyscope_structure
    if not struct:
        return

    for name, values in self.prepared_quantities.items():
        struct.add_scalar_quantity(
            name,
            values,
            enabled=True,
            defined_on="texture",  # Use texture coordinates
            param_name="uv",
            cmap=self.app_context.cmap,
            vminmax=(self.app_context.color_min, self.app_context.color_max),
        )
    self._quantities_added = True

bake_texture(sampler_func)

Parameters:

Name Type Description Default
sampler_func Callable[[ndarray], ndarray]

Takes in sample query points (N, 3), outputs values (N,)

required

Returns:

Source code in yumo/mesh.py
def bake_texture(
    self,
    sampler_func: Callable[[np.ndarray], np.ndarray],
):
    """
    Args:
        sampler_func: Takes in sample query points (N, 3), outputs values (N,)

    Returns:

    """
    # -- 1. Sample surface --
    points, bary, indices = sample_surface(
        self.vertices_unwrapped,
        self.faces_unwrapped,
        points_per_area=self.points_per_area,
    )

    # -- 2. Map samples to UV space --
    sample_uvs = map_to_uv(self.uvs, self.faces_unwrapped, bary, indices)

    # -- 3. Sample scalar field --
    values = sampler_func(points)

    # -- 4. Bake to texture --
    tex = bake_to_texture(sample_uvs, values, self.texture_height, self.texture_width)

    return tex

prepare_quantities()

We preview the resolution first before proceeding to actual data sampling in update_data_texture.

Source code in yumo/mesh.py
def prepare_quantities(self):
    """
    We preview the resolution first before proceeding to actual data sampling in update_data_texture.
    """
    self.update_resolution_preview()

ui()

Mesh related UI

Source code in yumo/mesh.py
def ui(self):
    """Mesh related UI"""
    with ui_tree_node("Mesh", open_first_time=True) as expanded:
        if not expanded:
            return

        psim.Text(f"Mesh Surface Area: {self.mesh_surface_area:.2f}")
        psim.Text(f"Texture Width: {self.texture_width:.2f}")
        psim.SameLine()
        psim.Text(f"Texture Height: {self.texture_height:.2f}")

        with ui_item_width(120):
            changed, show = psim.Checkbox("Show", self.enabled)
            if changed:
                self.set_enabled(show)

            psim.SameLine()

            changed, resolution = psim.DragFloat(
                "Points / Unit Area", self.points_per_area, v_min=1.0, v_max=10000.0
            )
            if changed:
                self.points_per_area = resolution
                self._display_mode = "preview"
                self._need_update = True

            psim.SameLine()

            if psim.Button("Bake"):
                self._need_update = True
                self._need_rebake = True
                self._display_mode = "baked"

            self._ui_material_controls()

            self._ui_texture_denoise_method()

    psim.Separator()

    self._ui_texture_map_display()

update_data_texture()

Sample the data points and update texture map

Source code in yumo/mesh.py
def update_data_texture(self):
    """Sample the data points and update texture map"""
    if self._need_rebake:
        logger.debug(f"Rebaking data texture for structure: '{self.name}'")
        self._need_rebake = False
        self.raw_texture = self.bake_texture(
            sampler_func=functools.partial(query_scalar_field, data_points=self.app_context.points),
        )

    if self._enable_denoise:
        self._denoise_kwargs["mask"] = self.uv_mask
        tex = denoise_texture(self.raw_texture, method=self._denoise_method, **self._denoise_kwargs)
    else:
        tex = self.raw_texture.copy()

    tex = tex * self.uv_mask  # mask out unsampled areas
    self.prepared_quantities[self.QUANTITY_NAME] = tex

update_resolution_preview()

Add a preview quantity of the surface sampling resolution using bake_texture

Source code in yumo/mesh.py
def update_resolution_preview(self):
    """Add a preview quantity of the surface sampling resolution using bake_texture"""
    self.raw_texture = self.bake_texture(
        sampler_func=lambda p: np.ones(p.shape[0]) * self.app_context.color_max,  # 0 (no fill); color_max (filled)
    )
    tex = self.raw_texture
    tex = tex * self.uv_mask
    self.prepared_quantities[self.QUANTITY_NAME] = tex

PointCloudStructure

Bases: Structure

Represents a point cloud structure.

Source code in yumo/point_cloud.py
class PointCloudStructure(Structure):
    """Represents a point cloud structure."""

    QUANTITY_NAME: ClassVar[str] = "point_values"

    def __init__(self, name: str, app_context: "Context", points: np.ndarray, **kwargs):
        super().__init__(name, app_context, **kwargs)
        self.points = points

        # initialize threshold at top 10% of scalar values
        self.visualize_threshold_min = np.float64(np.percentile(points[:, 3], 90))
        self.visualize_threshold_max = points[:, 3].max()

        # 10% of the densest point distance
        self._points_radius = 0.1 * self.app_context.points_densest_distance
        self._points_render_mode = "sphere"  # one of "sphere" or "quad"

    @property
    def polyscope_structure(self):
        return ps.get_point_cloud(self.name)

    def is_valid(self) -> bool:
        return self.points is not None and self.points.shape[0] > 0

    def get_filtered_points(self):
        """Filter points above threshold."""
        if not self.is_valid():
            return np.empty((0, 4))
        return self.points[
            (self.points[:, 3] >= self.visualize_threshold_min) & (self.points[:, 3] <= self.visualize_threshold_max)
        ]

    def prepare_quantities(self):
        """Prepare scalar data only for filtered points."""
        filtered = self.get_filtered_points()
        if filtered.shape[0] > 0:
            self.prepared_quantities[self.QUANTITY_NAME] = filtered[:, 3]
        else:
            self.prepared_quantities[self.QUANTITY_NAME] = np.array([])

    def _do_register(self):
        """Register only the point cloud geometry (XYZ coordinates)."""
        logger.debug(
            f"Registering point cloud geometry (threshold [{self.visualize_threshold_min:.3f}, {self.visualize_threshold_max:.3f}]): '{self.name}'"
        )

        filtered = self.get_filtered_points()
        if filtered.shape[0] == 0:
            logger.debug("No points left after threshold filtering")
            return

        p = ps.register_point_cloud(self.name, filtered[:, :3])
        p.set_radius(self._points_radius, relative=False)
        p.set_point_render_mode(self._points_render_mode)

        # Register scalar values
        self.prepare_quantities()
        if len(self.prepared_quantities[self.QUANTITY_NAME]) > 0:
            p.add_scalar_quantity(
                self.QUANTITY_NAME,
                self.prepared_quantities[self.QUANTITY_NAME],
                enabled=True,
            )

    def set_point_render_mode(self, mode: str):
        if self.polyscope_structure:
            self.polyscope_structure.set_point_render_mode(mode)

    def set_radius(self, radius: float, relative: bool = False):
        if self.polyscope_structure:
            self.polyscope_structure.set_radius(radius, relative=relative)

    def ui(self):
        """Points related UI"""
        with ui_tree_node("Points", open_first_time=True) as expanded:
            if not expanded:
                return

            self._draw_basic_controls()

            if self.is_valid():
                self._draw_threshold_controls()

            psim.Separator()

    def _draw_basic_controls(self):
        with ui_item_width(100):
            thresh_min_changed, show = psim.Checkbox("Show", self.enabled)
            if thresh_min_changed:
                self.set_enabled(show)

            psim.SameLine()

            radius_changed, radius = psim.SliderFloat(
                "Radius",
                self._points_radius,
                v_min=self.app_context.points_densest_distance * 0.01,
                v_max=self.app_context.points_densest_distance * 0.20,
                format="%.4g",
            )
            if radius_changed:
                self._points_radius = radius
                self.set_radius(radius)

            psim.SameLine()

            with ui_combo("Render Mode", self._points_render_mode) as expanded:
                if expanded:
                    for mode in ["sphere", "quad"]:
                        selected, _ = psim.Selectable(mode, mode == self._points_render_mode)
                        if selected and mode != self._points_render_mode:
                            self._points_render_mode = mode
                            self.set_point_render_mode(mode)

    def _draw_threshold_controls(self):
        with ui_item_width(100):
            q_values = self.points[:, 3]
            min_val, max_val = np.float64(q_values.min()), np.float64(q_values.max())

            min_changed, new_min = psim.DragFloat(
                "Threshold Min",
                self.visualize_threshold_min,
                v_speed=(max_val - min_val) / 10000.0,
                v_min=min_val,
                v_max=self.visualize_threshold_max,
                format="%.4g",
            )
            if min_changed:
                self.visualize_threshold_min = new_min

            psim.SameLine()

            max_changed, new_max = psim.DragFloat(
                "Threshold Max",
                self.visualize_threshold_max,
                v_speed=(max_val - min_val) / 10000.0,
                v_min=self.visualize_threshold_min,
                v_max=max_val,
                format="%.4g",
            )
            if max_changed:
                self.visualize_threshold_max = new_max

            if min_changed or max_changed:
                self.register(force=True)
                self.update_all_quantities_colormap()

    def callback(self):
        pass

get_filtered_points()

Filter points above threshold.

Source code in yumo/point_cloud.py
def get_filtered_points(self):
    """Filter points above threshold."""
    if not self.is_valid():
        return np.empty((0, 4))
    return self.points[
        (self.points[:, 3] >= self.visualize_threshold_min) & (self.points[:, 3] <= self.visualize_threshold_max)
    ]

prepare_quantities()

Prepare scalar data only for filtered points.

Source code in yumo/point_cloud.py
def prepare_quantities(self):
    """Prepare scalar data only for filtered points."""
    filtered = self.get_filtered_points()
    if filtered.shape[0] > 0:
        self.prepared_quantities[self.QUANTITY_NAME] = filtered[:, 3]
    else:
        self.prepared_quantities[self.QUANTITY_NAME] = np.array([])

ui()

Points related UI

Source code in yumo/point_cloud.py
def ui(self):
    """Points related UI"""
    with ui_tree_node("Points", open_first_time=True) as expanded:
        if not expanded:
            return

        self._draw_basic_controls()

        if self.is_valid():
            self._draw_threshold_controls()

        psim.Separator()

Slice

Bases: Structure

Source code in yumo/slices.py
class Slice(Structure):
    QUANTITY_NAME: ClassVar[str] = "slice"

    def __init__(self, name: str, app_context: "Context", group: ps.Group, **kwargs):
        super().__init__(name, app_context, **kwargs)

        self.group = group
        self.app_context = app_context

        self._vertices, self.faces = None, None

        bbox_min, bbox_max = app_context.bbox_min, app_context.bbox_max

        diag = np.linalg.norm(bbox_max - bbox_min)

        self.width: float = diag / 1.73  # type: ignore[assignment]
        self.height: float = diag / 1.73  # type: ignore[assignment]

        self.resolution_w: int = int(self.width)
        self.resolution_h: int = int(self.height)

        self.plane_transform: np.ndarray = None  # type: ignore[assignment]

        self._need_update_quant = False
        self._live = False
        self._gizmo_enabled = True
        self._should_destroy = False

    @property
    def polyscope_structure(self):
        return ps.get_surface_mesh(self.name)

    def _do_register(self):
        """Register the slice mesh"""
        logger.debug(f"Registering slice mesh: '{self.name}'")
        self._vertices, self.faces = generate_slice_mesh(  # type: ignore[assignment]
            center=np.zeros(3),
            h=self.height,
            w=self.width,
            rh=self.resolution_h,
            rw=self.resolution_w,
        )

        p = ps.register_surface_mesh(self.name, self._vertices, self.faces)
        p.set_transparency(0.8)
        p.set_material("flat")
        p.set_transform_gizmo_enabled(True)
        p.add_to_group(self.group)

        self.plane_transform = p.get_transform()  # shape: (4, 4), should be an eye matrix, as it is being initialized

    def is_valid(self) -> bool:
        return not self._should_destroy

    def prepare_quantities(self):
        """Prepare the scalar data from the 4th column of the points array."""
        if self.is_valid():
            self.prepared_quantities[self.QUANTITY_NAME] = query_scalar_field(
                points_coord=self.vertices_transformed,
                data_points=self.app_context.points,
            )

    def callback(self):
        current_transform = self.polyscope_structure.get_transform()
        if (self._live or self._need_update_quant) and not np.allclose(
            current_transform,
            self.plane_transform,
        ):  # plane moved and should refresh
            self.plane_transform = current_transform
            self._need_update_quant = True

        if self._need_update_quant:
            self.prepare_quantities()  # query the field
            self.add_prepared_quantities()  # update the quantity
            self._need_update_quant = False  # reset

    @property
    def vertices_transformed(self):
        # Transform vertices
        local_verts = self._vertices  # shape: (N, 3)
        n_verts = local_verts.shape[0]  # type: ignore[attr-defined]

        # Convert to homogeneous coordinates: (N, 4)
        homogeneous_verts = np.hstack([local_verts, np.ones((n_verts, 1))])  # type: ignore[list-item]

        # Apply transform (assuming 4x4 matrix)
        transformed_homogeneous = (self.plane_transform @ homogeneous_verts.T).T

        # Divide by w to get back to [x, y, z]
        vertices = transformed_homogeneous[:, :3] / transformed_homogeneous[:, 3][:, np.newaxis]

        return vertices

    def ui(self):
        with ui_tree_node(f"Slice {self.name}") as expanded:
            if not expanded:
                return

            with ui_item_width(100):
                self._ui_visibility_controls()
                psim.SameLine()
                self._ui_live_mode_checkbox()
                psim.SameLine()
                self._ui_gizmo_controls()
                psim.SameLine()
                self._ui_action_buttons()
                self._ui_transparency_controls()

            with ui_item_width(120):
                need_update_structure = self._ui_dimension_inputs()

            if need_update_structure:
                self.register(force=True)

        psim.Separator()

    def _ui_visibility_controls(self):
        changed, show = psim.Checkbox("Show", self.enabled)
        if changed:
            self.set_enabled(show)

    def _ui_gizmo_controls(self):
        changed, enable = psim.Checkbox("Gizmo", self._gizmo_enabled)
        if changed:
            self._gizmo_enabled = enable
            self.polyscope_structure.set_transform_gizmo_enabled(enable)

    def _ui_transparency_controls(self):
        changed, transparency = psim.SliderFloat("Transparency", self.polyscope_structure.get_transparency(), 0.0, 1.0)
        if changed:
            self.polyscope_structure.set_transparency(transparency)

    def _ui_action_buttons(self):
        if psim.Button("Destroy"):
            self._should_destroy = True
        psim.SameLine()

        if psim.Button("Compute"):
            self._need_update_quant = True

    def _ui_live_mode_checkbox(self):
        changed, live = psim.Checkbox("Live", self._live)
        if changed:
            self._live = live
            self._need_update_quant = live is True

    def _ui_dimension_inputs(self):
        step = 1

        changed_h, new_h = psim.InputFloat("Height", self.height, step)
        psim.SameLine()
        changed_w, new_w = psim.InputFloat("Width", self.width, step)

        changed_rh, new_rh = psim.InputInt("Resolution H", self.resolution_h, step)
        psim.SameLine()
        changed_rw, new_rw = psim.InputInt("Resolution W", self.resolution_w, step)

        if changed_h:
            self.height = max(0.1, new_h)
        if changed_w:
            self.width = max(0.1, new_w)
        if changed_rh:
            self.resolution_h = max(4, new_rh)
        if changed_rw:
            self.resolution_w = max(4, new_rw)

        return changed_h or changed_w or changed_rh or changed_rw

prepare_quantities()

Prepare the scalar data from the 4th column of the points array.

Source code in yumo/slices.py
def prepare_quantities(self):
    """Prepare the scalar data from the 4th column of the points array."""
    if self.is_valid():
        self.prepared_quantities[self.QUANTITY_NAME] = query_scalar_field(
            points_coord=self.vertices_transformed,
            data_points=self.app_context.points,
        )

ui_combo(label, current_value)

A context manager for creating an ImGui combo box.

Source code in yumo/ui.py
@contextmanager
def ui_combo(label, current_value):
    """
    A context manager for creating an ImGui combo box.
    """
    expanded = psim.BeginCombo(label, current_value)

    try:
        yield expanded
    finally:
        if expanded:
            psim.EndCombo()

ui_tree_node(label, open_first_time=True)

A context manager for creating a collapsible ImGui tree node.

This correctly handles the ImGui pattern of checking the return of TreeNode and calling TreePop conditionally, while ensuring the context manager protocol is always followed.

Source code in yumo/ui.py
@contextmanager
def ui_tree_node(label, open_first_time=True):
    """
    A context manager for creating a collapsible ImGui tree node.

    This correctly handles the ImGui pattern of checking the return of TreeNode
    and calling TreePop conditionally, while ensuring the context manager
    protocol is always followed.
    """
    psim.SetNextItemOpen(open_first_time, psim.ImGuiCond_FirstUseEver)
    expanded = psim.TreeNode(label)

    try:
        yield expanded
    finally:
        if expanded:
            psim.TreePop()

convert_power_of_10_to_scientific(x)

Convert 10^x to scientific notation a*10^b where b is an integer

Source code in yumo/utils.py
def convert_power_of_10_to_scientific(x):
    """Convert 10^x to scientific notation a*10^b where b is an integer"""
    exponent = int(np.floor(x))
    coefficient = 10 ** (x - exponent)
    return coefficient, exponent

data_transform(points, method)

Preprocess the last column of (N, 4) points array using data transform.

Parameters:

Name Type Description Default
points ndarray

Array of shape (N, 4). The first three columns are coordinates, the last column is the scalar value to be transformed.

required
method str

Preprocessing method. One of {"identity", "log_e", "log_10"}.

required

Returns:

Type Description
ndarray

np.ndarray: Preprocessed points with the same shape.

Source code in yumo/utils.py
def data_transform(points: np.ndarray, method: str) -> np.ndarray:
    """
    Preprocess the last column of (N, 4) points array using data transform.

    Args:
        points (np.ndarray): Array of shape (N, 4). The first three columns are coordinates,
                             the last column is the scalar value to be transformed.
        method (str): Preprocessing method. One of {"identity", "log_e", "log_10"}.

    Returns:
        np.ndarray: Preprocessed points with the same shape.
    """
    if method == "identity":
        return points

    elif method in ("log_e", "log_10"):
        transformed = points.copy()

        # 1. Select base
        log_fn = np.log if method == "log_e" else np.log10

        # 2. Mask positive values
        nonzero_mask = transformed[:, 3] > 0

        if not np.any(nonzero_mask):
            raise ValueError(f"No positive values found for {method} transform.")

        # 3. Apply log only to positive entries
        transformed[nonzero_mask, 3] = log_fn(transformed[nonzero_mask, 3])

        # 4. Find min among transformed positives
        min_value = np.min(transformed[nonzero_mask, 3])

        # 5. Replace originally non-positive values with min_value
        transformed[~nonzero_mask, 3] = min_value

        return transformed

    else:
        raise ValueError(f"Unknown data preprocess method: {method}")

estimate_densest_point_distance(points, k=1000, quantile=0.01)

Estimate the densest distance between points and their nearest neighbors.

This function samples k points from the input dataset, finds their nearest neighbors, and calculates the average distance after filtering outliers.

Parameters:

Name Type Description Default
points ndarray

Array of shape (n, d) containing n points in d-dimensional space.

required
k int

Number of points to sample for the estimation. Default is 1000.

1000
quantile float

Quantile threshold for outlier removal. Default is 0.01. Only distances in the range [min, quantile] are considered.

0.01

Returns:

Name Type Description
float float64

Estimated densest distance to nearest neighbor after outlier filtering.

Raises:

Type Description
ValueError

If points is empty or not a 2D array.

Source code in yumo/utils.py
def estimate_densest_point_distance(points: np.ndarray, k: int = 1000, quantile: float = 0.01) -> np.float64:
    """
    Estimate the densest distance between points and their nearest neighbors.

    This function samples k points from the input dataset, finds their nearest
    neighbors, and calculates the average distance after filtering outliers.

    Args:
        points: Array of shape (n, d) containing n points in d-dimensional space.
        k: Number of points to sample for the estimation. Default is 1000.
        quantile: Quantile threshold for outlier removal. Default is 0.01.
            Only distances in the range [min, quantile] are considered.

    Returns:
        float: Estimated densest distance to nearest neighbor after outlier filtering.

    Raises:
        ValueError: If points is empty or not a 2D array.
    """
    # Input validation
    if points.ndim != 2 or points.size == 0:
        raise ValueError("Input 'points' must be a non-empty 2D array")

    n = points.shape[0]

    # Handle case where number of points is less than k
    sample_size = min(n, k)
    sample_indices = np.random.choice(n, size=sample_size, replace=False) if n > 1 else np.array([0])
    sampled_points = points[sample_indices]

    # Handle edge case of a single point
    if n == 1:
        return np.float64(0.0)

    # Build KD-tree for efficient nearest neighbor search
    kdtree = KDTree(points)

    # Find distance to nearest neighbor for each sampled point
    # k=2 returns the point itself (distance 0) and the nearest neighbor
    distances, _ = kdtree.query(sampled_points, k=2)

    # Take the second column (nearest non-self neighbor)
    nearest_distances = distances[:, 1]

    # Apply outlier filtering using the quantile parameter
    if len(nearest_distances) > 1:
        threshold = np.quantile(nearest_distances, quantile)
        filtered_distances = nearest_distances[nearest_distances <= threshold]
        # Use original distances if filtering removed everything
        if len(filtered_distances) == 0:
            filtered_distances = nearest_distances
    else:
        filtered_distances = nearest_distances

    return np.float64(np.mean(filtered_distances))

export_camera_view(view_mat)

Export a 4x4 camera view matrix to a JSON string (list of lists).

Parameters:

Name Type Description Default
view_mat ndarray

A 4x4 numpy matrix.

required

Returns:

Name Type Description
str str

JSON-formatted string representing the matrix.

Source code in yumo/utils.py
def export_camera_view(view_mat: np.ndarray) -> str:
    """
    Export a 4x4 camera view matrix to a JSON string (list of lists).

    Args:
        view_mat (np.ndarray): A 4x4 numpy matrix.

    Returns:
        str: JSON-formatted string representing the matrix.
    """
    if view_mat.shape != (4, 4):
        raise ValueError(f"Expected (4,4) matrix, got {view_mat.shape}")

    # Convert numpy array to nested list
    mat_list = view_mat.tolist()
    return json.dumps(mat_list)

fmtn(vec, n, precision=2)

Format the first n components of a vector with fixed precision.

Parameters:

Name Type Description Default
vec ndarray | list[float] | tuple[float, ...]

Vector (numpy array, list, or tuple of floats).

required
n int

Number of components to format.

required
precision int

Decimal precision.

2

Returns:

Type Description
str

A string like "(x1, x2, ..., xn)" with formatted floats.

Source code in yumo/utils.py
def fmtn(vec: np.ndarray | list[float] | tuple[float, ...], n: int, precision: int = 2) -> str:
    """Format the first n components of a vector with fixed precision.

    Args:
        vec: Vector (numpy array, list, or tuple of floats).
        n: Number of components to format.
        precision: Decimal precision.

    Returns:
        A string like "(x1, x2, ..., xn)" with formatted floats.
    """
    formatted = [f"{vec[i]:.{precision}f}" for i in range(n)]
    return f"({', '.join(formatted)})"

format_scientific(x)

Format 10^x as aa.bbec where c is an integer

Source code in yumo/utils.py
def format_scientific(x):
    """Format 10^x as aa.bbec where c is an integer"""
    coefficient, exponent = convert_power_of_10_to_scientific(x)
    return f"{coefficient:.2f}e{exponent}"

generate_colorbar_image(colorbar_height, colorbar_width, cmap, c_min, c_max, method='identity', loaded_cmaps=None, font='Arial.ttf', font_size=12)

Generate a colorbar image as a numpy array with different labeling methods.

Source code in yumo/utils.py
def generate_colorbar_image(
    colorbar_height: int,
    colorbar_width: int,
    cmap: str,
    c_min: float,
    c_max: float,
    method: str = "identity",
    loaded_cmaps: dict[str, str] | None = None,
    font: str = "Arial.ttf",
    font_size: int = 12,
) -> np.ndarray:
    """
    Generate a colorbar image as a numpy array with different labeling methods.
    """
    h, w = colorbar_height, colorbar_width

    # --- font ---
    font_obj = _load_font(font, font_size)

    # --- constants ---
    bar_width = 25
    text_padding = 15
    tick_spacing = 10

    # --- labels ---
    num_ticks = 7
    tick_values = np.linspace(c_max, c_min, num_ticks)
    labels = _make_labels(tick_values, method)

    # --- adjust width ---
    required_width = _required_width(labels, font_obj, bar_width, tick_spacing, text_padding)
    w = max(w, required_width)

    # --- canvas ---
    img = Image.new("RGB", (w, h), "white")
    draw = ImageDraw.Draw(img)

    bar_x_pos = text_padding
    bar_start_y = text_padding
    bar_end_y = h - text_padding
    bar_height = bar_end_y - bar_start_y
    text_x_pos = bar_x_pos + bar_width + tick_spacing

    # --- colormap ---
    colormap = _get_cmap(cmap, loaded_cmaps)
    gradient = np.linspace(1, 0, bar_height)
    bar_colors_rgba = colormap(gradient)
    bar_colors_rgb = (bar_colors_rgba[:, :3] * 255).astype(np.uint8)

    # --- draw bar ---
    for i in range(bar_height):
        y_pos = bar_start_y + i
        draw.line(
            [(bar_x_pos, y_pos), (bar_x_pos + bar_width, y_pos)],
            fill=tuple(bar_colors_rgb[i]),
        )

    # --- ticks and labels ---
    tick_positions = np.linspace(bar_start_y, bar_end_y, num_ticks)
    for label, pos in zip(labels, tick_positions, strict=False):
        draw.line(
            [(bar_x_pos + bar_width, pos), (bar_x_pos + bar_width + 5, pos)],
            fill="black",
        )
        _, _, _, text_height = font_obj.getbbox(label)
        text_y = int(pos - text_height / 2)
        draw.text((text_x_pos, text_y), label, fill="black", font=font_obj)

    arr = np.array(img).astype(np.float32) / 255.0
    return arr[:, :, :3]

inverse_data_transform(points_or_values, method)

Invert the preprocessing applied by data_transform on the last column or on a single scalar value.

Parameters:

Name Type Description Default
points_or_values ndarray or float

Either - Array of shape (N, 4) that has been transformed, or - A single transformed scalar value.

required
method str

One of {"identity", "log_e", "log_10"}.

required

Returns:

Type Description
ndarray | float

np.ndarray or float: Inverse-transformed array or scalar.

Source code in yumo/utils.py
def inverse_data_transform(points_or_values: np.ndarray | float, method: str) -> np.ndarray | float:
    """
    Invert the preprocessing applied by data_transform on the last column
    or on a single scalar value.

    Args:
        points_or_values (np.ndarray or float): Either
            - Array of shape (N, 4) that has been transformed, or
            - A single transformed scalar value.
        method (str): One of {"identity", "log_e", "log_10"}.

    Returns:
        np.ndarray or float: Inverse-transformed array or scalar.
    """
    if method == "identity":
        return points_or_values

    elif method in ("log_e", "log_10"):
        exp_fn = np.exp if method == "log_e" else (lambda x: np.power(10.0, x))

        if np.isscalar(points_or_values) or np.ndim(points_or_values) == 0:
            return exp_fn(points_or_values)

        inv = points_or_values.copy()  # type: ignore[union-attr]
        inv[:, 3] = exp_fn(inv[:, 3])
        return inv

    else:
        raise ValueError(f"Unknown data preprocess method: {method}")

load_camera_view(json_str)

Load a 4x4 camera view matrix from a JSON string.

Parameters:

Name Type Description Default
json_str str

JSON string representing the matrix.

required

Returns:

Type Description
ndarray

np.ndarray: A 4x4 numpy matrix.

Source code in yumo/utils.py
def load_camera_view(json_str: str) -> np.ndarray:
    """
    Load a 4x4 camera view matrix from a JSON string.

    Args:
        json_str (str): JSON string representing the matrix.

    Returns:
        np.ndarray: A 4x4 numpy matrix.
    """
    mat_list = json.loads(json_str)
    mat = np.array(mat_list, dtype=float)

    if mat.shape != (4, 4):
        raise ValueError(f"Expected (4,4) matrix, got {mat.shape}")

    return mat

write_plt_file(path, points)

Write points to a Tecplot ASCII .plt file (FEPOINT format).

Source code in yumo/utils.py
def write_plt_file(path: Path, points: np.ndarray):
    """
    Write points to a Tecplot ASCII .plt file (FEPOINT format).
    """
    n = len(points)
    with open(path, "w") as f:
        f.write("variables = x, y, z, Value(m-3)\n")
        f.write(f"zone N={n}, E=0, F=FEPOINT, ET=POINT\n")
        np.savetxt(f, points, fmt="%.6e")