From ec60ebe62fd656a8e71c528bc234d9a1c374d7a0 Mon Sep 17 00:00:00 2001 From: Tomas Hynek <47698644+tomashynek@users.noreply.github.com> Date: Mon, 2 Dec 2024 09:15:28 +0100 Subject: [PATCH] Publish pyopf (#12) Co-authored-by: pix4d_concourse_developers --- CHANGELOG.md | 7 +++ examples/compute_reprojection_error.py | 73 ++++++++++++++++------- pyproject.toml | 31 +++++++++- src/opf_tools/crop/cropper.py | 8 +-- src/pyopf/ext/pix4d_region_of_interest.py | 23 ++++--- src/pyopf/pointcloud/pcl.py | 3 +- 6 files changed, 107 insertions(+), 38 deletions(-) mode change 100644 => 100755 examples/compute_reprojection_error.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f458c1..8368499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## 1.3.1 + +### Fixed + +- `height` in ROI extension was fixed to `thickness` to comply with OPF specification +- Fix bug causing GlTFPointCloud instances to inherit previous instance nodes + ## 1.3.0 ### Added diff --git a/examples/compute_reprojection_error.py b/examples/compute_reprojection_error.py old mode 100644 new mode 100755 index 3663b54..4e38ab6 --- a/examples/compute_reprojection_error.py +++ b/examples/compute_reprojection_error.py @@ -185,10 +185,28 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument( - "opf_path", type=str, help="[REQUIRED] The path to your project.opf file." + "--opf_path", type=str, help="[REQUIRED] The path to your project.opf file." ) - return parser.parse_args() + parser.add_argument( + "--point_type", + type=str, + choices=["mtps", "gcps"], + help="[REQUIRED] Wheter to use MTPs or GCPs", + ) + + parser.add_argument( + "--use_input_3d_coordinates", + action="store_true", + help="Use input 3d coordinates instead of calibrated ones. Only applicable if point_type is set to gcps", + ) + + args = parser.parse_args() + + if args.use_input_3d_coordinates and args.point_type == "mtps": + raise ValueError("MTPs have no input 3d coordinates") + + return args def main(): @@ -198,25 +216,37 @@ def main(): project = pyopf.resolve.resolve(pyopf.io.load(args.opf_path)) - input_gcps = project.input_control_points.gcps - projected_gcps = project.projected_control_points.projected_gcps + if args.point_type == "mtps": + input_points = project.input_control_points.mtps + else: + input_points = project.input_control_points.gcps + + if args.use_input_3d_coordinates: + projected_input_points = project.projected_control_points.projected_gcps - # alternatively, we can also use the optimized coordinates for the GCPs - # projected_gcps = project.calibration.calibrated_control_points.points + calibrated_control_points = project.calibration.calibrated_control_points.points calibrated_cameras = project.calibration.calibrated_cameras.cameras sensors = project.calibration.calibrated_cameras.sensors - # == for all gcps, compute the reprojection error of all marks and the mean == + # == for all points, compute the reprojection error of all marks and the mean == - for gcp in input_gcps: + for point in input_points: - # get the corresponding projected gcp - scene_gcp = find_object_with_given_id(projected_gcps, gcp.id) + if args.use_input_3d_coordinates: + scene_point = find_object_with_given_id(projected_input_points, point.id) + else: + scene_point = find_object_with_given_id(calibrated_control_points, point.id) + + if scene_point is None: + print(point.id, "not calibrated") + continue + + scene_point_3d_coordinates = scene_point.coordinates all_reprojection_errors = [] - for mark in gcp.marks: + for mark in point.marks: # find the corresponding calibrated camera calibrated_camera = find_object_with_given_id( @@ -230,22 +260,25 @@ def main(): internal_parameters = calibrated_sensor.internals # project the 3d point on the image - gcp_on_image = project_point( - calibrated_camera, internal_parameters, scene_gcp.coordinates + point_on_image = project_point( + calibrated_camera, internal_parameters, scene_point_3d_coordinates ) # compute reprojection error - reprojection_error = gcp_on_image - mark.position_px + reprojection_error = point_on_image - mark.position_px all_reprojection_errors.append(reprojection_error) - # compute the mean of the norm of the reprojection errors - all_reprojection_errors = np.array(all_reprojection_errors) - mean_reprojection_error = np.mean( - np.apply_along_axis(np.linalg.norm, 1, all_reprojection_errors) - ) + if len(all_reprojection_errors) > 0: + # compute the mean of the norm of the reprojection errors + all_reprojection_errors = np.array(all_reprojection_errors) + mean_reprojection_error = np.mean( + np.apply_along_axis(np.linalg.norm, 1, all_reprojection_errors) + ) - print(gcp.id, mean_reprojection_error) + print(point.id, mean_reprojection_error) + else: + print(point.id, "no marks") if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 7bea81c..89d19df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "pyopf" -version = "1.3.0" +version = "1.3.1" description = "Python library for I/O and manipulation of projects under the Open Photogrammetry Format (OPF)" authors = [ "Pix4D", @@ -39,6 +39,35 @@ pygltflib = "*" python-dateutil = "*" simplejson = "*" +[tool.poetry.dependencies.laspy] +version = "2.4.1" +optional = true + +[tool.poetry.dependencies.plyfile] +version = "0.9" +optional = true + +[tool.poetry.dependencies.pyproj] +version = "3.6.0" +optional = true + +[tool.poetry.dependencies.shapely] +version = "*" +optional = true + +[tool.poetry.dependencies.tqdm] +version = "^4.65.0" +optional = true + +[tool.poetry.extras] +tools = [ + "laspy", + "plyfile", + "pyproj", + "shapely", + "tqdm", +] + [tool.poetry.scripts] opf_crop = "opf_tools.crop.cropper:main" opf_undistort = "opf_tools.undistort.undistorter:main" diff --git a/src/opf_tools/crop/cropper.py b/src/opf_tools/crop/cropper.py index 1b4c427..ae3ecad 100644 --- a/src/opf_tools/crop/cropper.py +++ b/src/opf_tools/crop/cropper.py @@ -47,7 +47,7 @@ class RoiPolygons: roi: Pix4DRegionOfInterest outer_boundary: Polygon inner_boundaries: list[Polygon] - height: Optional[float] + thickness: Optional[float] def __init__(self, roi: Pix4DRegionOfInterest, matrix: Optional[np.ndarray] = None): """Construct a RoiPolygons wrapper for a region of interest. @@ -73,7 +73,7 @@ def __init__(self, roi: Pix4DRegionOfInterest, matrix: Optional[np.ndarray] = No _make_polygon(boundary, roi) for boundary in roi.plane.inner_boundaries ] - self.height = roi.height + self.thickness = roi.thickness self.matrix = matrix self.roi = roi @@ -94,14 +94,14 @@ def _is_inside_elevation_bounds(self, point: np.ndarray) -> bool: """Check if a point is within the elevation bounds of the region of interest. The point must be in the same system of coordinates as the boudnaries. """ - if self.height is None: + if self.thickness is None: return True elevation_difference = point[2] - self.roi.plane.vertices3d[0][2] elevation_along_normal = elevation_difference * self.roi.plane.normal_vector[2] - return elevation_along_normal > 0 and elevation_along_normal < self.height + return elevation_along_normal > 0 and elevation_along_normal < self.thickness def is_inside(self, point: np.ndarray) -> bool: """Check if a point is inside the ROI. diff --git a/src/pyopf/ext/pix4d_region_of_interest.py b/src/pyopf/ext/pix4d_region_of_interest.py index e5fe94b..7f39ebb 100644 --- a/src/pyopf/ext/pix4d_region_of_interest.py +++ b/src/pyopf/ext/pix4d_region_of_interest.py @@ -11,23 +11,22 @@ class Pix4DRegionOfInterest(ExtensionItem): - """Definition of a region of interest: a planar polygon with holes and an optional - height, defined as a the distance from the plane in the normal direction. All the + thickness, defined as a the distance from the plane in the normal direction. All the points on the hemispace where the normal lies that project inside the polygon and is at a - distance less than the height of the ROI, is considered to be within. + distance less than the thickness of the ROI, is considered to be within. """ plane: Plane - """The height of the ROI volume, defined as a limit distance from the plane in the normal - direction. If not specified, the height is assumed to be infinite. + thickness: Optional[float] + """The thickness of the ROI volume, defined as a limit distance from the plane in the normal + direction. If not specified, the thickness is assumed to be infinite. """ - height: Optional[float] def __init__( self, plane: Plane, - height: Optional[float], + thickness: Optional[float], pformat: ExtensionFormat = format, version: VersionInfo = version, ) -> None: @@ -35,22 +34,22 @@ def __init__( assert self.format == format self.plane = plane - self.height = height + self.thickness = thickness @staticmethod def from_dict(obj: Any) -> "Pix4DRegionOfInterest": base = ExtensionItem.from_dict(obj) plane = Plane.from_dict(obj["plane"]) - height = from_union([from_float, from_none], obj.get("height")) - result = Pix4DRegionOfInterest(plane, height, base.format, base.version) + thickness = from_union([from_float, from_none], obj.get("thickness")) + result = Pix4DRegionOfInterest(plane, thickness, base.format, base.version) result._extract_unknown_properties_and_extensions(obj) return result def to_dict(self) -> dict: result = super(Pix4DRegionOfInterest, self).to_dict() result["plane"] = to_class(Plane, self.plane) - if self.height is not None: - result["height"] = from_union([to_float, from_none], self.height) + if self.thickness is not None: + result["thickness"] = from_union([to_float, from_none], self.thickness) return result diff --git a/src/pyopf/pointcloud/pcl.py b/src/pyopf/pointcloud/pcl.py index 230bda7..b8a6274 100644 --- a/src/pyopf/pointcloud/pcl.py +++ b/src/pyopf/pointcloud/pcl.py @@ -499,7 +499,7 @@ class GlTFPointCloud: _format: CoreFormat _version: VersionInfo - nodes: list[Node] = [] + nodes: list[Node] metadata: Optional["Metadata"] # noqa: F821 # type: ignore mode_type = Literal[ @@ -560,6 +560,7 @@ def _open_accessors( return accessors def __init__(self): + self.nodes = [] self._format = CoreFormat.GLTF_MODEL self._version = FormatVersion.GLTF_OPF_ASSET