Skip to content

Commit 01b2fe3

Browse files
committed
[1.3.82] 2026-08-23
## Core - Added `annotation_io.h`, a set of utilities for writing machine-learning image annotations in the standard YOLO and COCO formats. These operate on plain binary masks and bounding boxes rather than on any particular camera or renderer, so any plug-in that can produce a per-pixel object label can write annotations in the same formats. The radiation and synthetic annotation plug-ins now share this implementation. - The `nlohmann/json` header is now bundled with the core library at `core/lib/json/` and is on the include path of every plug-in, rather than being private to the radiation plug-in. Code that includes `json.hpp` is unaffected. - Fixed `loadXML()` throwing "ERROR (Context::addVoxel): Voxel has size of zero." on any file containing a `<voxel>` element. The loader constructed the placeholder voxel with a size of zero before applying its transformation matrix, so a scene containing voxels could be written by `writeXML()` but never read back. Voxel center and size now survive the round-trip. - Removed the declaration of `point_distance()` from `global.h`. It was declared but never defined anywhere in the library, so any user who called it got a link error rather than a distance. ## Radiation - Removed the deprecated overloads of `writeImageBoundingBoxes()` and `writeImageBoundingBoxes_ObjectData()` that took `(..., imagefile_base, image_path, append_label_file, frame)`. They silently captured calls written against the current `(..., image_file, classes_txt_file, image_path)` signature whenever the trailing arguments were string literals, because their by-value `uint` class-ID parameter was a better match than the current overloads' `const uint &`. The class name file was then treated as the output directory and the call failed with "Expected a directory path but got a file path for argument 'image_path'", with no deprecation warning to indicate what had happened. - Fixed the first two values of each bounding-box annotation written by `writeImageBoundingBoxes()` being formatted differently from the last two, because the fixed-precision format was applied partway through the output expression. The centre coordinates could be written in scientific notation while the width and height were not. - The COCO and YOLO writing, connected-component labeling and boundary tracing now come from the shared core annotation utilities rather than being private to `RadiationModel`. Output is unchanged. - Fixed `writeImageSegmentationMasks()` and `writeImageSegmentationMasks_ObjectData()` writing at most one annotation per label value, silently omitting every other separate region carrying that value. Any object split into more than one visible region — most commonly by an occluder in front of it, such as a leaf behind a stem — had all but one of its pieces dropped from the COCO file with no warning or error, so the output looked complete while objects plainly visible in the image were missing. Files regenerated with this version will contain more annotations than before wherever this occurred. - Fixed connected components in the segmentation mask writers being found with 4-connectivity while their boundaries were traced with 8-connectivity. A diagonally-connected region was therefore split into one component per pixel, which combined with the above to drop it from the output entirely. - `createWithBackend()`, `exportColorCorrectionMatrixXML()` and `loadColorCorrectionMatrixXML()` are now private. All three were public solely so the test suite could reach them — the first is documented as "INTERNAL TEST-ONLY … not for production use", and the latter two are implementation details of `autoCalibrateCameraImage()` that were marked "public for testing" — so none had any user-facing meaning. The tests reach them through a `RadiationModelTestHelper` friend class defined solely in the plug-in's `selfTest.cpp`, so nothing test-related is exposed to library users. ## LiDAR - Removed the public `forceBruteForceLeafArea()`. It existed only so the self-tests could A/B the brute-force leaf-area path against the fast DDA path, has no effect on results, and was documented as "not needed in normal use"; the tests now set the underlying private flag through a `LiDARTestHelper` friend class defined in the plug-in's `selfTest.cpp`. ## Visualizer - Added `enableExactColorMode()` and `disableExactColorMode()`, which control whether primitive colors are reproduced exactly in the rendered image. The fragment shader multiplies vertex-interpolated colors by 1.5 to brighten ordinary renders, and did so unconditionally -- including under `LIGHTING_NONE` -- so a color set with `Context::setPrimitiveColor()` never matched the color read back with `getWindowPixelsRGB()`, and any channel above 170 saturated to 255. This only matters when the rendered image is used to carry data rather than to be looked at; the default behavior is unchanged. - Fixed `clearGeometry()` leaving the GPU-side geometry buffers untouched. Clearing all geometry marks no individual primitive as dirty, and the buffer upload is skipped when nothing is dirty, so the previously uploaded geometry was retained and continued to be rendered. - Added `displayImageWithSegmentationMasks()`, which displays an image with the segmentation masks from a COCO JSON annotation file overlaid, each drawn as a translucent filled polygon with a solid outline and its class name on a filled chip. This is the format written by `RadiationModel::writeImageSegmentationMasks()`; class names are read from the file's own `categories` array, so no separate class name file is needed, and masks are colored per annotation rather than per class so that touching objects of the same class stay distinguishable. The fill opacity and the class labels can each be turned off with optional arguments, and the fill is computed by an even-odd scanline fill so that it is correct for the self-intersecting contours the writer produces wherever a mask narrows to a one-pixel neck. The new `readSegmentationMaskFile()` exposes the parsing on its own. - Added `displayImageWithBoundingBoxes()`, which displays an image with the bounding boxes from a YOLO-format annotation file overlaid as colored outlines, each labeled with its class name on a filled chip inside the box's top-left corner. Class names are read from a file in either the `<class_ID> <class_name>` form written by `RadiationModel::writeImageBoundingBoxes()` or the standard one-name-per-line Ultralytics form; when no class file is given, `classes.txt` beside the annotation file is used if present, and boxes are otherwise labeled with their numeric class ID. The new `readBoundingBoxFile()` and `readBoundingBoxClassNames()` expose the parsing on its own. - Added `getTextboxSize()`, which returns the extent a string would occupy if rendered by `addTextboxByCenter()`, in window-normalized units. `addTextboxByCenter()` centers text and discarded the width it computed internally, so there was previously no way to align text by any edge. - Fixed `displayImage()` and `clearGeometry()` throwing `std::out_of_range` when called after anything had already been plotted. Both clear all geometry but left the visualizer's cached identifiers for the watermark, background rectangle, background sky, coordinate axes, navigation gizmo and colorbar pointing at geometry that no longer existed; deleting by one of those afterwards looked up a map key that had been erased. The sequence `plotUpdate(); displayImage(file);` aborted instead of displaying the image. - Corrected the documentation of `COORDINATES_WINDOW_NORMALIZED`, which stated that an object at z=0.5 would be in front of an object at z=0. Smaller z is nearer the viewer, as the plug-in documentation already stated. - `createShadowFramebuffer()`, `setupOffscreenFramebuffer()`, `cleanupOffscreenFramebuffer()` and `renderToOffscreenBuffer()` are now private. They are internal framebuffer lifecycle machinery driven by headless/offscreen rendering, with no user-facing meaning; the first three had no caller outside the plug-in at all, and the tests reach the last through the existing `VisualizerTestHelper` friend class. - Fixed `Visualizer` construction changing the working directory of the host process on macOS. `Visualizer::initialize()` calls `glfwInit()`, whose `GLFW_COCOA_CHDIR_RESOURCES` init hint defaults to enabled and moves the process to the `Contents/Resources` directory of the host application's bundle; the hint is now disabled beforehand. A bare Helios executable has no bundle and was unaffected, but a `.app`-packaged host — most commonly a macOS framework-build Python interpreter, whose real executable lives inside `Python.app` — had its working directory silently relocated, after which construction failed with `ERROR (Context::readJPEG): File plugins/visualizer/textures/gradient_background.jpg could not be opened` for a file that exists and is readable, and every subsequent relative path in the host application resolved against the wrong directory. - The six remaining visualizer textures loaded by a path relative to the process working directory — the gradient and transparent backgrounds used by `setBackgroundGradient()`, `setBackgroundTransparent()` and `printWindow()`, and the three navigation gizmo bubbles — now resolve through `helios::resolvePluginAsset()`, as the shaders, fonts, skydome and watermark already did. Any host that had changed the working directory before using the visualizer, for any reason, previously failed to load them. ## Plant Architecture - Fixed `pruneBranch()` failing partway through when pruning a whole branch system. Pruning a shoot also empties every shoot descending from it, so a loop over shoot IDs reaches those descendants again later; `pruneBranch()` rejected them with `Node index 0 is out of range for shoot N`, aborting the loop and leaving the plant half-pruned. Pruning a shoot that has already been pruned away is now a no-op, while a node index that is genuinely out of range on a live shoot is still an error. - Fixed `advanceTime()` segfaulting on a plant containing a pruned branch when internode context geometry was disabled with `disableInternodeContextBuild()`. A pruned shoot's apical meristem was killed only as a side effect of deleting its internode tube object, so with no tube objects to delete the emptied shoot was still treated as growable and `advanceTime()` dereferenced its (now non-existent) last phytomer. `pruneBranch()` now terminates the apical bud of any shoot it empties. - Fixed `getShootTaper()` segfaulting when called on a pruned shoot, which has no internode geometry to read a radius from. It now throws an error naming the shoot and pointing at the new `isShootPruned()`. - Fixed `Shoot::sumShootLeafArea()` and `Shoot::sumChildVolume()` throwing `Start node index out of range` for a pruned shoot, which has no nodes for index 0 to be in range of. Both now return zero. This also affected live shoots: because a pruned shoot was left in its parent's child list, summing the leaf area of an ancestor descended into the pruned shoot and threw, so the leaf area of any branch containing a pruned shoot could not be obtained at all. - A shoot pruned away by `pruneBranch()` is now unlinked from its parent's child list and its deleted internode tube object ID is reset to the sentinel, matching `pruneGroundCollisions()`. Previously the shoot remained reachable as a child of a live shoot despite having no phytomers or geometry, so recursive traversals of the plant structure descended into it, and it retained the object ID of a tube object that had been deleted from the Context. - Added `PlantArchitecture::isShootPruned()` and `Shoot::isPruned()`, which report whether a shoot has been pruned away entirely. A pruned shoot keeps its slot in the plant's shoot tree so that shoot IDs are not renumbered by a prune, so its ID stays valid and is still returned by `getAllShootIDs()` even though the shoot no longer forms part of the plant; there was previously no way to tell the two apart. - Added `setPlantMaxAge()` and `getPlantMaxAge()` to set and query the age at which a plant stops growing. `PlantInstance::max_age` defaults to 999 days and every library builder overrides it, but a plant assembled through the manual API (`addPlantInstance()` with `addBaseStemShoot()`/`appendShoot()`/`addChildShoot()`) kept the default and so silently stopped growing after roughly 1000 days, with no message and no public way to raise the limit. - Maize ear position is now defined relative to the tassel rather than at fixed node indices, and a maize plant now bears one ear by default instead of three. `MaizePhytomerCreationFunction()` placed ears at absolute nodes 9-11, which only approximated the biology at the default `max_nodes` of 17; raising `max_nodes` left the ears stranded low in the canopy, so a taller plant produced fewer ears than a shorter one. The ear now forms at `max_nodes - 6` (node 14 of the new 20-node default, node 19 of 25), matching the observation that ear meristems form at every node except the upper six to eight and only the uppermost develops. A maize plant now bears 8 fruit objects and 2 peduncles where it previously bore 10 and 4. - Maize main-stem node count raised from 17 to 20 and the phyllochron from 2 to 3 days per node. Corn Belt grain hybrids carry 19-21 main-stem leaves (range 16-23) and add a leaf collar roughly every 3 days over most of vegetative development; the previous values produced a 17-leaf plant that finished growing about twice as fast as a field crop. Tasseling now occurs near 60 days after emergence rather than 32, within the reported 54-63 day range. A default maize plant is correspondingly taller and takes longer to reach its final size, so simulations that advanced maize by a fixed number of days may now see a less mature plant than before. - Maize `time_to_fruit_maturity` raised from 10 to 58 days. Grain fill from silking to physiological maturity takes 55-65 days in the field, so ears previously reached full size roughly five times too quickly. This value is the divisor controlling how fast the ear scales up, and it also sets the sink duration seen by the carbohydrate and nitrogen models. - Fixed the maize mainstem ignoring `ShootParameters::internode_length_max`. `buildMaizePlant()` passed a hardcoded 0.08 m to `addBaseStemShoot()`, which takes precedence over the value stored on the shoot type, so the 0.22 m set in `initializeMaizeShoots()` was never used and editing it had no effect on the plant. The builder now forwards the shoot-type value, as the bougainvillea and capsicum builders already did. A default maize plant is now about 2.2 m tall rather than 1.4 m, within the 2-3 m range of a field hybrid. - Sorghum phyllochron raised from 2 to 3.5 days per node and `time_to_fruit_maturity` from 15 to 35 days. A grain sorghum plant reaches half-bloom about 60 days after emergence and fills grain over roughly 35 days (Vanderlip, Kansas State S-3; Gerik et al., Texas A&M B-6137); the previous values completed the plant by day 30 and filled grain in 15. The main-stem node count of 16 is unchanged, being already within the 15-19 leaf range spanned by early to late maturity classes, as is plant height, which was already correct for a dwarfed grain hybrid. - Fixed the sorghum mainstem ignoring `ShootParameters::internode_length_max`, the same defect as maize: `buildSorghumPlant()` passed a hardcoded 0.06 m to `addBaseStemShoot()` in preference to the stored value. The builder now forwards the shoot-type value. Note the stored value was simultaneously corrected from 0.26 m to the 0.06 m that was actually in force, so sorghum geometry is unchanged by this fix; the previously inert 0.26 m would have produced a roughly 5.5 m plant, far outside the 0.6-1.5 m range of a grain hybrid. - Corrected the plant phenology table in the Plant Architecture documentation, which had the `time_to_fruit_maturity` values for "rice" and "sorghum" transposed (listing 15 and 10 where the library uses 10 and 15). - `writePlantStructureXML()` now writes the plant's maximum age as a `<max_age>` tag and `readPlantStructureXML()` restores it. It was previously not persisted, so any plant written and read back silently reverted to the default of 999 days regardless of the value its builder or `setPlantMaxAge()` had set. The tag is optional on read, so files written by earlier versions still load. - Fixed `duplicatePlantInstance()` producing a plant that differed from the one it copied. It rebuilt the shoot structure but carried over only the shoot-parameter snapshot, so the duplicate silently fell back to the `PlantInstance` defaults for everything not derivable from that structure: a copy of an apple tree reported its name as "custom", took a 999-day maximum age instead of 1460, and lost its phenological thresholds, epicormic-shoot probability, and carbohydrate and nitrogen parameters — so it entered dormancy, flowered and fruited on a different schedule from the original. All of these are now copied. - Fixed `duplicatePlantInstance()` attaching child shoots at the wrong node. It passed the node at which the *parent* shoot joins the grandparent where the attachment node of the shoot being copied was required, so on any plant whose branches leave the trunk at different heights every branch was relocated onto a single wrong node and the copy's architecture did not match the original's. - Fixed `duplicatePlantInstance()` returning a dormant copy of an actively-growing plant. Shoots are constructed dormant, and the duplicate's dormancy was never reconciled with its source, so the copy stalled until its dormancy broke while the plant it was copied from kept growing. Dormancy state and time since dormancy are now matched to the source. - `duplicatePlantInstance()` now translates per-plant attraction points onto the duplicate's base position. They are absolute world coordinates, so the trellis of a trained plant (for example the apple fruiting wall) was previously not carried over at all; copying them unchanged would instead have steered the duplicate's growth toward the original plant's trellis. - Removed `setPlantAge()`. Its body was commented out, so it silently did nothing when called, and it was documented as "Don't use this". `getPlantAge()` is unaffected. - Fixed `disablePlantPhenology()` corrupting fruit geometry without bound. It set `dd_to_fruit_maturity` to `-1`, matching the flower and fruit-set stages beside it, but that field is not a "skip this stage" sentinel — it is used only as a divisor during fruit growth, so the scale factor became `0.25 - 0.75 * time_counter` and went negative within the first day. A debug build then aborted on the assertion in `Phytomer::setInflorescenceScaleFraction()`, while a release build mirror-scaled the fruit by a negative factor and compounded the error on every subsequent step: a sorghum panicle reached roughly 550 m², against a mature size of 0.16 m², after 100 days. It is now `1e6`, matching the `PlantInstance` default, so fruit simply never matures. The fruit-growth block in `advanceTime()` is additionally guarded against a non-positive `dd_to_fruit_maturity`, which `setPlantPhenologicalThresholds()` accepts and `readPlantStructureXML()` restores without validation. Only plants with a fruiting bud were affected, which requires a model defining a fruit prototype. - Added `ShootParameters::inheritCustomFunctionsFrom()`, which copies the five user-defined function pointers held in a shoot type's phytomer parameters (the phytomer creation and callback hooks, and the leaf, flower, and fruit prototype functions) from another `ShootParameters`. `defineShootType()` and `updateCurrentShootParameters()` replace a shoot type entry in its entirety, which is harmless when the structure was copied but strips the species' customizations when it was reconstructed from plain values, as a scripting-language binding that serializes the parameters must do. For maize, committing such a reconstruction previously produced a tassel at nearly every node and no ears. - Corrected the documentation example for modifying the parameters of all shoot types, which assigned the result of `getCurrentShootParameters(shoot_type_label)` to a `std::map` and so did not compile. It now calls the no-argument overload that returns the map. - Fixed `ShootParameters::operator=` not copying `elongation_rate_max`. It was the only member missing from the assignment operator, so assigning a `ShootParameters` silently reset the shoot's maximum elongation rate to the default of 0.2, discarding the value set by the library or the user. Every library model sets a different value, so any path that assigned a `ShootParameters` — including reading a shoot type with `getCurrentShootParameters()` and writing it back with `updateCurrentShootParameters()` — lost it and the plant grew at the wrong rate. - `defineShootType()` and `updateCurrentShootParameters()` now store the shoot-level parameter values they are given instead of re-drawing the random ones. `ShootParameters::operator=` resampled every distributed shoot-level parameter, while defining a shoot type under a label that did not yet exist did not, so the same input produced different stored values depending only on whether the label already existed, and reading a shoot type and writing it straight back silently perturbed parameters the caller never edited. Per-shoot variation is unaffected, as it comes from the growth engine resampling when each shoot is created. The phytomer parameters nested inside `ShootParameters` still resample on assignment, which is what varies phytomers along a shoot. ## LeafOptics - `PROSPECT()` now validates its inputs, matching `getLeafSpectra()`. It is a spectrum-computing entry point in its own right but bypassed `validateProperties()` entirely, so calling it directly with a structure parameter of `numberlayers = 0` divided by zero when computing the mean absorption coefficient, and negative constituent contents contributed negative absorption — both returning non-finite or unphysical spectra with no diagnostic instead of the documented runtime error. ## Energy Balance - Fixed primitive data `surface_humidity` being applied to the air vapor pressure instead of the surface vapor pressure in the latent heat flux. The term was computed as `e_s(T_s) - e_s(T_a)*h*f_s` rather than `e_s(T_s)*f_s - e_s(T_a)*h`, so lowering `surface_humidity` below 1 to represent a drying surface increased evaporation instead of suppressing it, and could flip the sign of `latent_flux`. Results are unchanged at the default `surface_humidity` of 1, which is why this went unnoticed; any simulation that set it below 1 had incorrect latent and sensible fluxes and surface temperatures. Both the CPU and GPU paths were affected. - Fixed the reported `latent_flux` primitive data omitting `surface_humidity` entirely, so the value written out disagreed with the flux the energy balance actually solved whenever `surface_humidity` was not 1. ## Synthetic Annotation - `render()` now writes bounding boxes in the YOLO annotation format, as a single file per view named after the rendered image, with every class in the one file and a `classes.txt` beside it. This replaces the previous `rectangular_labels_<label>.txt` files, which held one label per file in a format no training pipeline reads. - `render()` now writes instance segmentation masks as a COCO JSON file per view (`instances.json`). This replaces the previous per-object `instance_segmentation_<label>_<object>.txt` pixel grids. - Both new outputs are in the formats read by `Visualizer::displayImageWithBoundingBoxes()` and `Visualizer::displayImageWithSegmentationMasks()`, so annotations can be displayed over the rendered image directly, and match what the radiation plug-in writes for its cameras. - The instance masks now describe each object's **visible** extent rather than its full un-occluded extent, which is the convention the COCO format uses. As a result the masks no longer require a separate rendering pass per object: rendering a scene with many labeled objects is substantially faster, and the cost no longer grows with the number of objects. - Fixed the plug-in failing to configure unless the visualizer plug-in also happened to be listed in the project's `PLUGINS`. It declared a build dependency on `visualizer` without causing it to be loaded, so building with only `syntheticannotation` selected aborted at the CMake configure step with "The dependency target visualizer of target syntheticannotation does not exist". The visualizer is now loaded automatically when it is not already present. - Fixed the `object_detection`, `semantic_segmentation` and `instance_segmentation` flags read from a loaded XML file being inverted in `render()`. The values were tested with `std::string::compare()` as though it returned a boolean, and it returns 0 on a match, so specifying "enabled" turned the output off and "disabled" turned it on. An unrecognized value is now a runtime error rather than being silently ignored. - Fixed `render()` writing every object of a label group to the same instance segmentation file. The output filename omitted the object counter, so the field width intended for it padded the ".txt" extension instead and each object overwrote its predecessor, leaving only the last object of each label on disk. - Fixed the label ID codes recovered from the rendered image being wrong, which made every annotation the plug-in produced meaningless. IDs are encoded as RGB colors and decoded back from the rendered pixels, but the visualizer brightened those colors by a factor of 1.5, so an object labeled 1 was recovered as 2 and IDs whose color channels exceeded 170 saturated and became indistinguishable from one another. `render()` now renders the ID pass with `Visualizer::enableExactColorMode()`. The semantic segmentation mask was the most visible symptom: no decoded ID ever matched a label group, so the mask came out uniformly blank. - Fixed each per-object instance segmentation mask containing the *previous* object rather than its own. The ID pass was rendered to a window, and the windowed pixel readback picks between the front and back buffer by sampling nine pixels and taking whichever has more non-black content; against the white background used for the ID pass both score identically and the stale buffer wins. The ID pass now renders headless, to an offscreen framebuffer that is read back directly. - Implemented `labelUnlabeledPrimitives()`, which was an empty stub that silently did nothing. Every primitive not already labeled is now assigned the given label as its own object, so primitives the user expected to be annotated are no longer omitted from the output. - Implemented `addSkyDome()`, which was an empty stub. The sky dome texture used as the background of the RGB rendering is now configurable rather than hardcoded, and a path that does not exist is a runtime error rather than being silently ignored. - Added `setMinimumLabelPixels()` to set how many pixels an object must cover to be written to the annotations, replacing a hardcoded value of 10 that could not be changed. Values below 3 are rejected, since an object covering fewer pixels has no traceable outline and would be dropped from the segmentation masks without a diagnostic. - Added `disableMessages()` and `enableMessages()`. The plug-in printed progress messages unconditionally with no way to silence them, including two debug lines that printed a label name for every label on every view. - Fixed `ID_mapping.txt` being written as a zero-byte file, which left the integer object IDs appearing in `pixelID_combined.txt` and the instance masks impossible to trace back to a label. It now contains one row per object giving its object ID, label, and class index. A `classes.txt` listing class names in class-index order is also written. - Fixed the bounding-box annotations recording a class index of 0 for every object regardless of its label, which made the output unusable for training a multi-class detector. Each label now writes its own class index. - Fixed `render()` leaving the Context modified when it threw. Primitive colors are overwritten with label ID codes during rendering and were only restored at the end of the function, so an error partway through left the caller's entire scene colored in ID codes. Colors are now restored on every exit path, and the internal `object_label` primitive data the plug-in adds is removed rather than left behind. - Removed a hardcoded 5-second delay per camera view in `render()`. Rendering six views took 39 seconds, of which 30 were spent sleeping; the same run now takes 8 seconds and produces byte-identical images. - Fixed the bounding-box annotations measuring `y_center` from the bottom of the image. The YOLO format requires the origin at the top-left, so every box was vertically mirrored with respect to the rendered image it describes; an object in the upper half of the frame was annotated as being in the lower half. `getGroupRectangularBBox()` now reports the box in top-down image coordinates. - Fixed `pixelID_combined.txt` being written vertically mirrored relative to `RGB_rendering.jpeg` and the segmentation masks, so that a pixel at a given row in one file did not correspond to the same row in the others. - Fixed the instance segmentation masks, where the bounding box on the first line was measured bottom-up while the mask below it was written top-down, so the two did not describe the same rows of the image. - Fixed the first two values of each bounding-box annotation being written in a different numeric format from the last two, because the fixed-precision format was applied partway through the output expression. - The header of `semantic_segmentation_ID_mapping.txt` is now `label class_ID`, matching the order of the columns beneath it. It previously read `Element Label`, which named the columns in the opposite order to the data. - Fixed the pixel indexing used to write the semantic and instance segmentation masks in `render()`. The offset was computed such that the first row read addressed a full row past the end of the pixel buffer while the last row was never read at all, so the written masks were shifted by one row and their first row was filled from out-of-bounds memory.
1 parent df16118 commit 01b2fe3

72 files changed

Lines changed: 8220 additions & 1719 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

core/CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ set(HELIOS_SOURCES
1313
src/Context_object.cpp
1414
src/Context_fileIO.cpp
1515
src/Context_data.cpp
16+
src/annotation_io.cpp
1617
src/exif_writer.cpp
1718
src/global.cpp
1819
tests/selfTest.cpp
@@ -27,9 +28,11 @@ target_include_directories(helios
2728
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
2829
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/lib/pugixml>
2930
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/lib/doctest>
31+
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/lib/json>
3032
$<INSTALL_INTERFACE:include>
3133
$<INSTALL_INTERFACE:lib/pugixml>
3234
$<INSTALL_INTERFACE:lib/doctest>
35+
$<INSTALL_INTERFACE:lib/json>
3336
)
3437

3538
# External libraries

core/include/annotation_io.h

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/** \file "annotation_io.h" Writing of machine-learning image annotation files (YOLO and COCO).
2+
3+
Copyright (C) 2016-2026 Brian Bailey
4+
5+
This program is free software: you can redistribute it and/or modify
6+
it under the terms of the GNU General Public License as published by
7+
the Free Software Foundation, version 2.
8+
9+
This program is distributed in the hope that it will be useful,
10+
but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
GNU General Public License for more details.
13+
14+
*/
15+
16+
#ifndef HELIOS_ANNOTATION_IO
17+
#define HELIOS_ANNOTATION_IO
18+
19+
#include "helios_vector_types.h"
20+
#include "json.hpp"
21+
#include <map>
22+
#include <string>
23+
#include <utility>
24+
#include <vector>
25+
26+
//! Writing of image annotation files in the standard formats used to train machine-learning models
27+
/**
28+
* These routines are deliberately independent of how the image was produced. Every entry point
29+
* takes plain pixel data -- binary masks, bounding boxes -- rather than a camera or a renderer, so
30+
* that any plug-in that can produce a per-pixel object label can write annotations in the same
31+
* formats. The radiation plug-in supplies its masks from ray-traced camera pixel labels; the
32+
* synthetic annotation plug-in supplies them from a rasterized ID rendering.
33+
*
34+
* The image coordinate convention throughout is the one the annotation formats require: the origin
35+
* is the TOP-LEFT corner of the image, with y increasing downward. Callers whose pixel buffer is
36+
* stored in a different orientation must apply the flip when building their masks, not here.
37+
*/
38+
namespace helios::annotation {
39+
40+
//! A single bounding box in the normalized form used by the YOLO annotation format
41+
struct YOLOBox {
42+
//! Zero-based class index of the object
43+
uint class_ID = 0;
44+
//! Center of the box, normalized to [0,1] by the image dimensions, origin at the top-left
45+
helios::vec2 center;
46+
//! Width and height of the box, normalized to [0,1] by the image dimensions
47+
helios::vec2 size;
48+
};
49+
50+
//! Find a pixel of a binary mask that lies on the region boundary, to start a boundary trace from
51+
/**
52+
* \param[in] mask Binary mask, indexed [row][column].
53+
* \param[in] resolution Image dimensions in pixels.
54+
* \return Coordinates of a boundary pixel, or (-1,-1) if the mask holds no region.
55+
*/
56+
[[nodiscard]] std::pair<int, int> findStartingBoundaryPixel(const std::vector<std::vector<bool>> &mask, const helios::int2 &resolution);
57+
58+
//! Isolate one connected component into its own full-resolution mask
59+
/**
60+
* Tracing against this isolated mask rather than the whole label mask keeps the trace from
61+
* wandering into a different component that happens to touch this one diagonally.
62+
*
63+
* \param[in] component_pixels Pixels making up the component.
64+
* \param[in] resolution Image dimensions in pixels.
65+
* \return Mask holding only the given component.
66+
*/
67+
[[nodiscard]] std::vector<std::vector<bool>> buildComponentMask(const std::vector<std::pair<int, int>> &component_pixels, const helios::int2 &resolution);
68+
69+
//! Trace a region outline using the Moore neighborhood algorithm
70+
/**
71+
* \param[in] mask Binary mask holding the region to trace, indexed [row][column].
72+
* \param[in] start_x Column of the boundary pixel to start from.
73+
* \param[in] start_y Row of the boundary pixel to start from.
74+
* \param[in] resolution Image dimensions in pixels.
75+
* \return Ordered outline of the region.
76+
*/
77+
[[nodiscard]] std::vector<std::pair<int, int>> traceBoundaryMoore(const std::vector<std::vector<bool>> &mask, int start_x, int start_y, const helios::int2 &resolution);
78+
79+
//! Collect the boundary pixels of a region by breadth-first search
80+
/**
81+
* Used as a fallback when the Moore trace returns too few points to form a usable outline. The
82+
* points come back in queue order rather than as an ordered walk around the region.
83+
*
84+
* \param[in] mask Binary mask holding the region, indexed [row][column].
85+
* \param[in] start_x Column of the boundary pixel to start from.
86+
* \param[in] start_y Row of the boundary pixel to start from.
87+
* \param[in] resolution Image dimensions in pixels.
88+
* \return Boundary pixels of the region.
89+
*/
90+
[[nodiscard]] std::vector<std::pair<int, int>> traceBoundarySimple(const std::vector<std::vector<bool>> &mask, int start_x, int start_y, const helios::int2 &resolution);
91+
92+
//! Trace the outline of every connected region in a set of binary masks and convert them to COCO annotations
93+
/**
94+
* Each mask is scanned for 8-connected components, and the outline of each component is traced
95+
* to a polygon. A component that yields fewer than three boundary points is discarded, since it
96+
* cannot form a polygon.
97+
*
98+
* \param[in] label_masks Binary mask per label value. Each mask is indexed [row][column] with row 0 at the top of the image.
99+
* \param[in] object_class_ID Class index recorded on every annotation produced.
100+
* \param[in] resolution Image dimensions in pixels.
101+
* \param[in] image_id Identifier of the image these annotations belong to, matching an entry in the COCO "images" array.
102+
* \return One annotation per connected component, with keys "id", "image_id", "category_id", "bbox", "area", "iscrowd" and "segmentation".
103+
*/
104+
[[nodiscard]] std::vector<std::map<std::string, std::vector<float>>> maskToAnnotations(const std::map<int, std::vector<std::vector<bool>>> &label_masks, uint object_class_ID, const helios::int2 &resolution, int image_id);
105+
106+
//! Load an existing COCO JSON file or create a new one, and get the image ID to annotate against
107+
/**
108+
* If the image is already present in the file its existing ID is returned, so that repeated
109+
* calls for the same image accumulate annotations rather than duplicating the image entry.
110+
*
111+
* \param[in] filename Path of the COCO JSON file.
112+
* \param[in] append If true, an existing file at this path is loaded and added to. If false, a new document is started.
113+
* \param[in] resolution Image dimensions in pixels.
114+
* \param[in] image_file Path of the image being annotated. Only its file name is recorded.
115+
* \return The COCO document and the image ID to use for annotations of this image.
116+
*/
117+
[[nodiscard]] std::pair<nlohmann::json, int> initializeCOCOJson(const std::string &filename, bool append, const helios::int2 &resolution, const std::string &image_file);
118+
119+
//! Add class definitions to a COCO document, ignoring any that are already defined
120+
/**
121+
* \param[inout] coco_json COCO document to add the categories to.
122+
* \param[in] class_IDs Class index of each category.
123+
* \param[in] class_names Name of each category, in the same order as class_IDs.
124+
*/
125+
void addCOCOCategory(nlohmann::json &coco_json, const std::vector<uint> &class_IDs, const std::vector<std::string> &class_names);
126+
127+
//! Write a COCO document to file
128+
/**
129+
* \param[in] coco_json COCO document to write.
130+
* \param[in] filename Path of the file to write.
131+
*/
132+
void writeCOCOJson(const nlohmann::json &coco_json, const std::string &filename);
133+
134+
//! Write bounding boxes to a file in the YOLO annotation format
135+
/**
136+
* Each box is written as "class_ID x_center y_center width height", with the four geometric
137+
* values normalized to [0,1]. Boxes of zero width or height are skipped, since they describe no
138+
* region and are rejected by readers.
139+
*
140+
* \param[in] boxes Bounding boxes to write.
141+
* \param[in] filename Path of the file to write.
142+
*/
143+
void writeYOLOBoxes(const std::vector<YOLOBox> &boxes, const std::string &filename);
144+
145+
//! Write a class name file listing one class name per line, in class index order
146+
/**
147+
* This is the form expected by most detection training pipelines, in which a class's index is
148+
* its line number counting from zero. The class indices supplied must therefore be contiguous
149+
* and start at zero.
150+
*
151+
* \param[in] class_names Class name of each class index.
152+
* \param[in] filename Path of the file to write.
153+
*/
154+
void writeYOLOClassNames(const std::map<uint, std::string> &class_names, const std::string &filename);
155+
156+
} // namespace helios::annotation
157+
158+
#endif

core/include/global.h

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,14 +1125,6 @@ namespace helios {
11251125
*/
11261126
[[nodiscard]] float interp1(const std::vector<helios::vec2> &points, float x);
11271127

1128-
//! Function to calculate the distance between two points
1129-
/**
1130-
* \param[in] p1 first point (vec3)
1131-
* \param[in] p2 second point (vec3)
1132-
* \return distance between p1 and p2 in three dimensions
1133-
*/
1134-
[[nodiscard]] float point_distance(const helios::vec3 &p1, const helios::vec3 &p2);
1135-
11361128
//! Generate linearly spaced values between two endpoints
11371129
/**
11381130
* \param[in] start Starting value

core/src/Context_fileIO.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1801,7 +1801,7 @@ std::vector<uint> Context::loadXML(const char *filename, bool quiet) {
18011801

18021802
if (has_material_vox) {
18031803
// Material format: create voxel with default color, will assign material below
1804-
ID = addVoxel(make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0, make_RGBAcolor(0, 0, 0, 1));
1804+
ID = addVoxel(make_vec3(0, 0, 0), make_vec3(1, 1, 1), 0, make_RGBAcolor(0, 0, 0, 1));
18051805
} else {
18061806
// Legacy format: parse color
18071807
RGBAcolor color;
@@ -1815,7 +1815,7 @@ std::vector<uint> Context::loadXML(const char *filename, bool quiet) {
18151815
}
18161816

18171817
// * Add the Voxel * //
1818-
ID = addVoxel(make_vec3(0, 0, 0), make_vec3(0, 0, 0), 0, color);
1818+
ID = addVoxel(make_vec3(0, 0, 0), make_vec3(1, 1, 1), 0, color);
18191819
}
18201820

18211821
getPrimitivePointer_private(ID)->setTransformationMatrix(transform);

0 commit comments

Comments
 (0)