Skip to content

Commit

Permalink
Publish pyopf (#12)
Browse files Browse the repository at this point in the history
Co-authored-by: pix4d_concourse_developers <[email protected]>
  • Loading branch information
tomashynek and pix4d_concourse_developers authored Dec 2, 2024
1 parent 770ce65 commit ec60ebe
Show file tree
Hide file tree
Showing 6 changed files with 107 additions and 38 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 53 additions & 20 deletions examples/compute_reprojection_error.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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(
Expand All @@ -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__":
Expand Down
31 changes: 30 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 4 additions & 4 deletions src/opf_tools/crop/cropper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down
23 changes: 11 additions & 12 deletions src/pyopf/ext/pix4d_region_of_interest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,46 +11,45 @@


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:
super(Pix4DRegionOfInterest, self).__init__(format=pformat, version=version)

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


Expand Down
3 changes: 2 additions & 1 deletion src/pyopf/pointcloud/pcl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down Expand Up @@ -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

Expand Down

0 comments on commit ec60ebe

Please sign in to comment.