diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index ed12db2..11be27c 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -23,7 +23,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Build and install package - run: python -m pip install . + run: python -m pip install .[all] - name: Test with pytest run: | @@ -47,7 +47,7 @@ jobs: python-version: 3.x - name: Build and install package - run: python -m pip install . + run: python -m pip install .[all] - name: Running ${{ matrix.tool }} run: | diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 6e0417b..7118b9e 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,7 +8,7 @@ build: pre_build: - python -m pip install --upgrade pip - python -m pip install --group documentation - - python -m pip install . + - python -m pip install .[all] sphinx: configuration: docs/conf.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a859584..9a7c52b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ The project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Added panorama capturing (`capture panorama`) +- Added panorama processing (`process panorama`) - Added GeoCOM shutdown utility (`shutdown geocom`) - Added GeoCOM startup utility (`startup geocom`) - Added GSI Online DNA shutdown utility (`shutdown gsidna`) diff --git a/README.md b/README.md index d72753a..f4a5d1e 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,11 @@ cd Instrumentman python -m pip install . ``` +Some commands require additional dependencies, that are not installed by +default with I-man. These are indicated in the documentations of the specific +commands. + ## License -I-man is free and open source software, and it is distributed under the terms of the -[MIT License](https://opensource.org/license/mit). +I-man is free and open source software, and it is distributed under the terms +of the [MIT License](https://opensource.org/license/mit). diff --git a/docs/commands/panorama/image_positions.png b/docs/commands/panorama/image_positions.png new file mode 100644 index 0000000..1f63f12 Binary files /dev/null and b/docs/commands/panorama/image_positions.png differ diff --git a/docs/commands/panorama/index.rst b/docs/commands/panorama/index.rst new file mode 100644 index 0000000..d09c918 --- /dev/null +++ b/docs/commands/panorama/index.rst @@ -0,0 +1,18 @@ +:icon: material/panorama-variant-outline + +Panorama +======== + +For documentation purposes it might be useful to have an image of the measured +area with the measured points annotated. Total stations with integrated cameras +usually have a panorama capture on-board program, but it provides to way to +annotate the recorded points. + +The panorama commands provide a way to capture frames of a panorama, and +merge them together with optional point annotations. + +.. toctree:: + :maxdepth: 1 + + measure + process diff --git a/docs/commands/panorama/measure.rst b/docs/commands/panorama/measure.rst new file mode 100644 index 0000000..38cfa83 --- /dev/null +++ b/docs/commands/panorama/measure.rst @@ -0,0 +1,68 @@ +Measuring +========= + +To create a panorama image from different images taken from a common center +point, the orientatiobs of the images have to be known among other camera +properties. The necessary data can be derived from independent calibration and +screenshots during the on-board panorama program. + +To simplify the process, this command can be used to capture images with the +necessary metadata automatically being recorded as reported by the instrument. + +Requirements +------------ + +- GeoCOM capable robotic total station with overview camera and imaging license + +Positions +--------- + +The program takes images in a region defined by a horizontal and vertical angle +range. Two convenience settings exists (in addition to the default), that +reduce the number of angle inputs needed for specific cases: + +- region: horizontal and vertical range is required (this is the default) +- strip: 360 degree horizontal coverage with given vertical range +- sphere: complete spherical panorama + +.. note:: + + The complete sphere panorama is not very practical, and more of a proof of + concept, as capturing the full view takes impractically long time. + +If adaptive FoV is enabled for the position layout generation, images that are +taken at angles that deviate from the horizontal position are considered to +cover a wider horizontal angle area. To avoid taking unnecessary images, the +top and bottom rows have fewer images. + +.. image:: image_positions.png + +To reduce the parallax errors of close range objects caused by the camera +offset, it is possible to increase the overlap between images (effectively +reducing the motion between images), but this can significantly increase the +required number of images and time (e.g. full sphere panorama with 30% +overlap requires approximately 1500 images). + +Examples +-------- + +.. code-block:: shell + :caption: Capturing panorama with interactive region definition and default settings + + iman capture panorama COM1 metadata.json + +.. code-block:: shell + :caption: Capturing full sphere panorama with custom file prefix + + iman capture panorama --shape sphere --prefix panosphere_ COM1 metadata.json + +.. code-block:: shell + :caption: Capturing predefined 360 panorama strip + + iman capture panorama --strip strip --vertical 70-00-00 110-00-00 COM1 metadata.json + +Usage +----- + +.. click:: instrumentman.panorama:cli_measure + :prog: iman capture panorama diff --git a/docs/commands/panorama/process.rst b/docs/commands/panorama/process.rst new file mode 100644 index 0000000..ee48090 --- /dev/null +++ b/docs/commands/panorama/process.rst @@ -0,0 +1,58 @@ +Processing +========== + +.. caution:: + :class: warning + + The panorama image processing requires extra dependencies. + + - opencv-python + + Install them manually, or install instrumentman with the 'panorama' extra: + + .. code-block:: shell + + python -m pip install instrumentman[panorama] + +The processing command can be used to merge individual frames of a panorama +capture into a single image, and optionally annotate points on it. + +The accuracy of the annotation is usually a few centimeters. + +Requirements +------------ + +- Image metadata JSON file +- Images downloaded from the instrument + +Examples +-------- + +.. code-block:: shell + :caption: Merging images + + iman process panorama metadata.json merged_panorama.jpg panorama*.jpg + + +.. code-block:: shell + :caption: Merging images and annotating points + + iman process panorama --annotate points.csv --fontsize 50 metadata.json merged_panorama.jpg panorama*.jpg + +.. code-block:: shell + :caption: Merging full sphere panorama with downscaling to fit into OpenCV limits + + iman process panorama --scale 2000 metadata.json merged_panorama.jpg panorama*.jpg + +.. code-block:: text + :caption: Example points file for annotations (with the optional label column present) + + P0001,1.0,1.0,0.0,BENCHMARK + P0002,1.0,2.0,1.0,BENCHMARK + P0003,1.0,2.0,3.0,TOPO + +Usage +----- + +.. click:: instrumentman.panorama:cli_calc + :prog: iman process panorama diff --git a/docs/index.rst b/docs/index.rst index ad33bd7..2c84e5d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -41,6 +41,7 @@ Content commands/targets/index commands/sets/index commands/inclination/index + commands/panorama/index commands/protocoltest/index commands/files/index commands/data/index diff --git a/docs/latexindex.rst b/docs/latexindex.rst index ae63435..70847dc 100644 --- a/docs/latexindex.rst +++ b/docs/latexindex.rst @@ -26,6 +26,7 @@ Applications commands/targets/index commands/sets/index commands/inclination/index + commands/panorama/index commands/protocoltest/index commands/files/index commands/data/index diff --git a/pyproject.toml b/pyproject.toml index 8f8cb64..3a8fa5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "toml ~= 0.10.2", "PyYAML ~= 6.0.2", "numpy ~= 2.0", + "pillow ~= 11.0", "cloup ~= 3.0.7", "click-extra ~= 5.0.2" ] @@ -59,6 +60,12 @@ Changelog = "https://instrumentman.readthedocs.io/latest/changelog" [project.scripts] iman = "instrumentman:cli" +[project.optional-dependencies] +all = ["instrumentman[panorama]"] +panorama = [ + "opencv-python~=4.8" +] + [dependency-groups] testing = [ "pytest ~= 8.4.1" diff --git a/src/instrumentman/__init__.py b/src/instrumentman/__init__.py index 6ad721e..2eece3d 100644 --- a/src/instrumentman/__init__.py +++ b/src/instrumentman/__init__.py @@ -22,6 +22,7 @@ from . import station from . import protocoltest from . import inclination +from . import panorama from . import filetransfer from . import jobs from . import datatransfer @@ -107,7 +108,7 @@ def cli( ) -@cli.group("measure") # type: ignore[misc] +@cli.group("measure", aliases=["capture"]) # type: ignore[misc] def cli_measure() -> None: """Conduct measurements.""" @@ -117,7 +118,7 @@ def cli_convert() -> None: """Convert between various file formats.""" -@cli.group("calculate", aliases=["calc"]) # type: ignore[misc] +@cli.group("calculate", aliases=["calc", "process"]) # type: ignore[misc] def cli_calc() -> None: """Preform calculations from measurement results.""" @@ -173,9 +174,11 @@ def cli_startup() -> None: cli_measure.add_command(setmeasurement.cli_measure) cli_measure.add_command(setup.cli_measure) cli_measure.add_command(inclination.cli_measure) +cli_measure.add_command(panorama.cli_measure) cli_calc.add_command(setmeasurement.cli_calc) cli_calc.add_command(inclination.cli_calc) cli_calc.add_command(station.cli_calc) +cli_calc.add_command(panorama.cli_calc) cli_test.add_command(protocoltest.cli_geocom) cli_test.add_command(protocoltest.cli_gsidna) cli_merge.add_command(setmeasurement.cli_merge) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py new file mode 100644 index 0000000..a62e24d --- /dev/null +++ b/src/instrumentman/panorama/__init__.py @@ -0,0 +1,443 @@ +from typing import Any + +from click_extra import ( + extra_command, + argument, + option, + option_group, + File, + file_path, + Choice, + IntRange, + FloatRange +) +from cloup.constraints import ( + constraint, + mutually_exclusive, + accept_none, + require_all, + If, + Equal +) + +from ..utils import ( + com_port_argument, + com_option_group, + Angle +) + + +@extra_command( + "panorama", + params=None, + context_settings={"auto_envvar_prefix": None} +) # type: ignore[misc] +@com_port_argument() +@argument( + "metadata", + help="File to write image metadata to", + type=File("wt", encoding="utf8", lazy=True) +) +@com_option_group() +@option( + "--zoom", + help="Camera zoom factor", + type=Choice(("x1", "x2", "x4", "x8"), case_sensitive=False), + default="x1" +) +@option( + "--prefix", + help="Image prefix before number", + type=str, + default="panorama_" +) +@option( + "--whitebalance", + help=( + "Set white balance mode for the capture " + "(mode is reset to auto after the program is finished)" + ), + type=Choice( + ( + "auto", + "indoor", + "outdoor" + ), + case_sensitive=False + ) +) +@option( + "--increase-tolerance", + help=( + "Increase the positioning tolerances for the duration of the program. " + "USE WITH CAUTION!" + ), + is_flag=True +) +@option( + "--overlap", + help=( + "Overlap between images within a row, and overlap between rows " + "(percentage)" + ), + type=(IntRange(5, 95), IntRange(10, 95)), + default=(5, 10) +) +@option( + "--shape", + help="Panorama area type", + type=Choice( + ( + "region", + "strip", + "sphere" + ), + case_sensitive=False + ), + default="region" +) +@option( + "--layout", + help="Image positions layout", + type=Choice( + ("grid", "adaptive-fov"), + case_sensitive=False + ), + default="adaptive-fov" +) +@option( + "--horizontal", + help="Horizontal start (left) and end (right) bearing", + type=(Angle(), Angle()) +) +@option( + "--vertical", + help="Vertical start (top) and end (bottom) zenith angle", + type=(Angle(), Angle()) +) +@constraint( + If(Equal("shape", "sphere"), accept_none), + ["horizontal", "vertical"] +) +@constraint( + If(Equal("shape", "strip"), accept_none), + ["horizontal"] +) +@constraint( + If("horizontal", require_all), + ["vertical"] +) +def cli_measure(**kwargs: Any) -> None: + """ + Take pictures with the overview camera of a total station for later + panoramic processing. + + The angular area to cover can be set in the command line, or recorded + with the instrument at the start of the program. To use the point + annotation feature during later processing, the instrument should be + properly set up and oriented in the local coordinate system when running + this program. + + The acquisition positions are calculated, so that there is at least 5% + overlap between images in a row, and 10% overlap between rows. When the + defined panorama area covers the full range (360 degrees horiztal + and/or 180 degrees vertical) the overlap will be usually larger, otherwise + the program will opt to capture a slightly wider/taller area to keep the + overlap close to the nominal values. The default position layout take into + account, that images taken farther from horizon cover more and more + horizontal area. + + The metadata required for later processing is saved on the controlling + computer, the images themselves have to be downloaded from the instrument. + The images are typically saved to the SD card (if available), in the + 'Data/Geocom/Images/Wide-angle' directory. + + Time required for the whole process mainly depends on the number of images, + which in turn is dependent on the acquisition area. A full sphere panorama + at 1x zoom on a non-piezo motorized instrument takes around 25-30 minutes + to capture with around 350 images. New instruments with piezo motors might + be faster, but the main limiting factor is the camera, not the motors. + + Enabling increased positioning tolerances might sligtly reduce the time, + but if an unexpected error occurs, the program might not be able to restore + the original tolerances, so USE WITH CAUTION. + + This command requires a GeoCOM capable robotic total station with overview + camera imaging functions. + """ + from .measure import main + + main(**kwargs) + + +@extra_command( + "panorama", + params=None, + context_settings={"auto_envvar_prefix": None} +) # type: ignore[misc] +@argument( + "metadata", + help="Metadata file produced by the measurement program", + type=file_path(exists=True) +) +@argument( + "output", + help="Output image file path", + type=file_path(readable=False) +) +@argument( + "image", + help="Panorama image part", + type=file_path(exists=True), + nargs=-1, + required=True +) +@option( + "--shift", + help=( + "Shift bearing of panorama center to reorient view and potentially " + "remove black gaps (only exact for strip and sphere)" + ), + type=Angle() +) +@option( + "--compensation", + help="Basic exposure compensation method", + type=Choice( + ("none", "channels", "gain"), + case_sensitive=False + ), + default="channels" +) +@option( + "--blending", + help="Overlap blending method", + type=Choice( + ("none", "multiband", "feather"), + case_sensitive=False + ), + default="multiband" +) +@option( + "--seams", + help="Method to delineate individual frames at overlaps", + type=Choice( + ("none", "voronoi", "dynamic-programming"), + case_sensitive=False + ), + default="voronoi" +) +@option( + "--seam-overlap", + help=( + "Pixel dilation of seam masks to provide blending overlap " + "(set to -1 for automatic calculation)" + ), + type=IntRange(-1), + default=0 +) +@option( + "--visualize-stitch", + help="Debug option to show individual frames with random colors", + is_flag=True +) +@option_group( + "Output size options", + ( + "The width and height options set the size, that a complete spherical " + "panorama would be saved with (fractional panoramas will be " + "proportionally smaller). Leave all options unset for automatic " + "calculation." + ), + option( + "--scale", + help="Panorama scale in [pixels/rad]", + type=FloatRange(0, 5210, min_open=True) + ), + option( + "--width", + help="Width of complete sphere panorama in [pixels]", + type=IntRange(0, 32735, min_open=True) + ), + option( + "--height", + help="Height of complete sphere panorama in [pixels]", + type=IntRange(0, 16367, min_open=True) + ), + constraint=mutually_exclusive +) +@option_group( + "Point list file options", + option( + "--annotate", + help="CSV coordinate list of points to annotate on the images", + type=file_path(exists=True) + ), + option( + "--skip", + help="Number of header rows to skip", + type=IntRange(0), + default=0 + ), + option( + "--delimiter", + help="Column delimiter", + type=str, + default="," + ) +) +@option_group( + "Annotation options", + option( + "--color", + help="Color in RGB8 notation", + type=(IntRange(0, 255), IntRange(0, 255), IntRange(0, 255)), + default=(0, 0, 0) + ), + option( + "--font", + help="Font face type", + type=Choice( + ( + "plain", + "simplex", + "duplex", + "complex" + ), + case_sensitive=False + ), + default="plain" + ), + option( + "--fontsize", + help="Font size in pixels", + type=IntRange(0, min_open=True), + default=10 + ), + option( + "--thickness", + help="Font line thickness", + type=IntRange(0, min_open=True), + default=1 + ), + option( + "--marker", + help="Point marker shape", + type=Choice( + ( + "cross", + "x", + "star", + "diamond", + "square", + "uptriangle", + "downtriangle" + ), + case_sensitive=False + ), + default="cross" + ), + option( + "--markersize", + help="Point marker size in pixels", + type=IntRange(1), + default=10 + ), + option( + "--offset", + help="Point name offset in pixels", + type=(int, int) + ), + option( + "--justify", + help="Point name justification", + type=Choice( + ( + "tl", "tc", "tr", + "ml", "mc", "mr", + "bl", "bc", "br", + ), + case_sensitive=False + ), + default="bl" + ), + option( + "--label-font", + help="Label font face type", + type=Choice( + ( + "plain", + "simplex", + "duplex", + "complex" + ), + case_sensitive=False + ), + default="plain" + ), + option( + "--label-fontsize", + help="Label font size in pixels", + type=IntRange(0, min_open=True) + ), + option( + "--label_thickness", + help="Label text line thickness", + type=IntRange(0, min_open=True) + ), + option( + "--label-color", + help="Color in RGB8 notation", + type=(IntRange(0, 255), IntRange(0, 255), IntRange(0, 255)) + ), + option( + "--label-offset", + help="Label text offset in pixels", + type=(int, int) + ), + option( + "--label-justify", + help="Label text justification", + type=Choice( + ( + "tl", "tc", "tr", + "ml", "mc", "mr", + "bl", "bc", "br", + ), + case_sensitive=False + ), + default="tl" + ) +) +def cli_calc(**kwargs: Any) -> None: + """ + Merge previously captured panorama frames and optionally annotate measured + points on the resulting panorama for documentation purposes. + + IMPORTANT: This command requires an extra dependency: 'opencv-python' + + The individual images are transformed into an equirectangular projection + based on the orientation metadata saved at the time of acquisition. The + projected images are then merged into a single panorama image. + + On the merged panorama it is possible to annotate measured points given + with their 3D coordinates in a CSV file. The file is expected to contain + point name, easting, northing and height columns in this order (and + optionally a label column as last). The accuracy of the annotation is + usually a few centimeters. This is due to errors introduced by the offset + and the distortions of the overview camera. The program will try to + approximate the offset to improve the accuracy. If the precise offset is + known, it can also be provided explicitly. + + A limit of OpenCV is, that larger panoramas cannot be processed at full + resolution. The maximum size that OpenCV in this configuration can handle + is defined by the maximum of a 16-bit signed integer (32767). This means, + that at full resolution (2560 x 1920) only 12 images horizontally (for near + vertical views not even 1 image), and 17 images vertically can be + processed. This can be solved by setting the scale or one of the other + sizing options to downscale the processing resolution. (The program + automatically downscales to an appropriate size.) + """ + from .process import main + + main(**kwargs) diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py new file mode 100644 index 0000000..6e0f8e4 --- /dev/null +++ b/src/instrumentman/panorama/measure.py @@ -0,0 +1,461 @@ +from typing import TextIO +import math +from logging import getLogger, Logger +import json + +from rich.progress import ( + Progress, + TextColumn, + BarColumn, + TimeRemainingColumn, + TimeElapsedColumn, + MofNCompleteColumn +) +from rich.prompt import Confirm +from geocompy.data import Coordinate, Angle +from geocompy.communication import open_serial +from geocompy.geo import GeoCom +from geocompy.geo.gcdata import Zoom +from geocompy.geo.gctypes import GeoComCode + +from ..utils import console, print_error, print_warning +from .metadata import PanoramaMetadata, PanoramaFrameMetadata + + +def image_positions( + from_hz: Angle, + from_v: Angle, + to_hz: Angle, + to_v: Angle, + fov_hz: Angle, + fov_v: Angle, + overlap_hz: int, + overlap_v: int, + adaptive_fov: bool +) -> list[tuple[Angle, Angle]]: + positions: list[tuple[Angle, Angle]] = [] + delta_hz = (to_hz - from_hz).normalized() + delta_v = (to_v - from_v).normalized() + + center_hz = (from_hz + delta_hz / 2).normalized() + center_v = (from_v + delta_v / 2).normalized() + + # FOV has to be reduced by twice the overlap percent, because overlap + # occurs on both sides of the view. + redfov_hz = fov_hz * (1 - overlap_hz / 50) + redfov_v = fov_v * (1 - overlap_v / 50) + + if redfov_v < delta_v: + delta_v -= redfov_v + + rows = math.ceil(float(delta_v) / float(redfov_v)) + delta_v = redfov_v * rows + + if delta_v > math.pi: + delta_v = Angle(math.pi) + from_v = Angle(0) + rows = math.ceil(math.pi / float(redfov_v)) + else: + from_v = center_v - delta_v / 2 + + if from_v < 0: + from_v = Angle(0) + + elif to_v > math.pi: + from_v = Angle(math.pi) - delta_v + + rowstep = delta_v / rows + from_v = from_v + rowstep / 2 + + for r in range(rows): + v = from_v + rowstep * r + row_delta_hz = delta_hz + + if adaptive_fov: + if v <= Angle(math.pi / 2): + row_radius = math.sin(v + redfov_v / 2) + else: + row_radius = math.sin(v - redfov_v / 2) + + fovchord = math.sqrt(2 - 2 * math.cos(redfov_hz)) + + if fovchord > 2 * row_radius: + row_redfov_hz = row_delta_hz + else: + row_redfov_hz = Angle( + math.acos(1 - fovchord**2 / (2*row_radius**2)) + ) + else: + row_redfov_hz = redfov_hz + + cols = math.ceil(float(row_delta_hz) / float(row_redfov_hz)) + + row_delta_hz = row_redfov_hz * cols + + if row_delta_hz > math.pi * 2: + row_delta_hz = Angle(math.pi * 2) + + colstep = row_delta_hz / (cols) + row_from_hz = ( + center_hz + - row_delta_hz / 2 + + colstep / 2 + ).normalized() + row_to_hz = ( + center_hz + + row_delta_hz / 2 + - colstep / 2 + ).normalized() + + for c in range(cols): + if r % 2 == 0: + hz = (row_from_hz + colstep * c).normalized() + else: + hz = (row_to_hz - colstep * c).normalized() + + positions.append((hz, v)) + + return positions + + +def get_extents_region( + tps: GeoCom, + horizontal: tuple[Angle, Angle] | None, + vertical: tuple[Angle, Angle] | None, + logger: Logger +) -> tuple[Angle, Angle, Angle, Angle]: + if horizontal is not None and vertical is not None: + from_hz, to_hz = horizontal + from_v, to_v = vertical + return from_hz, from_v, to_hz, to_v + + console.input( + "Aim the instrument at the left starting corner, " + "then press ENTER..." + ) + resp_start = tps.tmc.get_angle() + if resp_start.error != GeoComCode.OK or resp_start.params is None: + print_error("Could not retrieve starting corner angles") + logger.critical("Could not retrieve starting corner angles") + exit(1) + + console.input( + "Aim the instrument at the right finish corner, " + "then press ENTER..." + ) + resp_end = tps.tmc.get_angle() + if resp_end.error != GeoComCode.OK or resp_end.params is None: + print_error("Could not retrieve finishing corner angles") + logger.critical("Could not retrieve finishing corner angles") + exit(1) + + from_hz, from_v = resp_start.params + to_hz, to_v = resp_end.params + + return from_hz, from_v, to_hz, to_v + + +def get_extents_strip( + tps: GeoCom, + vertical: tuple[Angle, Angle] | None, + logger: Logger +) -> tuple[Angle, Angle, Angle, Angle]: + from_hz = Angle(0) + to_hz = Angle(2 * math.pi - 1e-5) + if vertical is not None: + from_v, to_v = vertical + return from_hz, from_v, to_hz, to_v + + console.input( + "Aim the instrument at the top of the strip, " + "then press ENTER..." + ) + resp_start = tps.tmc.get_angle() + if resp_start.error != GeoComCode.OK or resp_start.params is None: + print_error("Could not retrieve strip top angles") + logger.critical("Could not retrieve strip top angles") + exit(1) + + console.input( + "Aim the instrument at the bottom of the strip, " + "then press ENTER..." + ) + resp_end = tps.tmc.get_angle() + if resp_end.error != GeoComCode.OK or resp_end.params is None: + print_error("Could not retrieve strip bottom angles") + logger.critical("Could not retrieve strip bottom angles") + exit(1) + + _, from_v = resp_start.params + _, to_v = resp_end.params + + from_hz = Angle(0) + to_hz = Angle.from_dms("359-59-59") + + return from_hz, from_v, to_hz, to_v + + +def get_extents_sphere() -> tuple[Angle, Angle, Angle, Angle]: + return Angle(0), Angle(0), Angle(2 * math.pi - 1e-5), Angle(math.pi) + + +def run_panorama( + tps: GeoCom, + file: TextIO, + zoom: Zoom, + overlap: tuple[int, int], + prefix: str, + shape: str, + layout: str, + horizontal: tuple[Angle, Angle] | None, + vertical: tuple[Angle, Angle] | None, + logger: Logger +) -> None: + match shape: + case "sphere": + from_hz, from_v, to_hz, to_v = get_extents_sphere() + case "strip": + from_hz, from_v, to_hz, to_v = get_extents_strip( + tps, + vertical, + logger + ) + case "region": + from_hz, from_v, to_hz, to_v = get_extents_region( + tps, + vertical, + horizontal, + logger + ) + case _: + raise ValueError(f"Unknown capture area shape '{shape}'") + + if to_v < from_v: + to_v, from_v = from_v, to_v + + # If the pointer is left active by accident, it will show up on every + # image. + tps.edm.switch_laserpointer(False) + + resp_zoom = tps.cam.set_zoom(zoom) + if resp_zoom.error != GeoComCode.OK: + print_error("Could set camera zoom factor") + logger.critical("Could set camera zoom factor") + exit(1) + + resp_fov = tps.cam.get_camera_fov(zoom=zoom) + if resp_fov.params is None: + print_error("Could not retrieve camera FOV") + logger.critical("Could not retrieve camera FOV") + exit(1) + + resp_station = tps.tmc.get_station() + if resp_station.error != GeoComCode.OK or resp_station.params is None: + print_error("Could not retrieve station coordinates") + logger.critical("Could not retrieve station coordinates") + exit(1) + + resp_intrinsic = tps.cam.get_overview_interior_orientation() + if resp_intrinsic.error != GeoComCode.OK or resp_intrinsic.params is None: + print_error("Could not retrieve camera intrinsics") + logger.critical("Could not retrieve camera intrinsics") + exit(1) + + cx, cy, focal, pixelsize = resp_intrinsic.params + + resp_extrinsic = tps.cam.get_overview_exterior_orientation() + if resp_extrinsic.error != GeoComCode.OK or resp_extrinsic.params is None: + print_error("Could not retrieve camera extrinsics") + logger.critical("Could not retrieve camera extrinsics") + exit(1) + + offset, yaw, pitch, roll = resp_extrinsic.params + + station, hi = resp_station.params + center = station + Coordinate(0, 0, hi) + + fov_hz, fov_v = resp_fov.params + + match layout: + case "grid": + positions = image_positions( + from_hz, + from_v, + to_hz, + to_v, + fov_hz, + fov_v, + overlap[0], + overlap[1], + False + ) + case "adaptive-fov": + positions = image_positions( + from_hz, + from_v, + to_hz, + to_v, + fov_hz, + fov_v, + overlap[0], + overlap[1], + True + ) + case _: + raise ValueError("Unknown position layout") + + if not Confirm.ask( + f"Start capturing panorama in {len(positions)} frame(s)", + console=console, + default=True + ): + print_warning("Program cancelled") + exit() + + images: list[PanoramaFrameMetadata] = [] + + progress = Progress( + TextColumn("[progress.description]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + TimeRemainingColumn(), + TimeElapsedColumn(), + console=console + ) + progress.start() + task = progress.add_task( + "Capturing panorama", + total=len(positions) + ) + + for idx, (hz, v) in enumerate(positions): + resp_turn = tps.aut.turn_to(hz, v) + if resp_turn.error != GeoComCode.OK: + print_warning("Could not turn to position") + logger.error("Could not turn to position") + continue + + resp_name = tps.cam.set_actual_image_name(prefix, idx) + if resp_name.error != GeoComCode.OK: + print_warning("Could not set image name") + logger.error("Could not set image name") + continue + + resp_img = tps.cam.take_image() + if resp_img.error != GeoComCode.OK: + print_warning("Could not take image") + logger.error("Could not take image") + continue + + resp_cam_pos = tps.cam.get_camera_position() + if resp_cam_pos.params is None: + print_warning("Could not retrieve camera position") + logger.error("Could not retrieve camera position") + continue + + resp_cam_dir = tps.cam.get_camera_direction(1) + if resp_cam_dir.params is None: + print_warning("Could not retrieve camera direction") + logger.critical("Could not retrieve camera direction") + continue + + pos = resp_cam_pos.params + center + vec = resp_cam_dir.params + + meta: PanoramaFrameMetadata = { + "filename": f"{prefix}{idx:05d}.jpg", + "position": (pos.x, pos.y, pos.z), + "vector": (vec.x, vec.y, vec.z) + } + + images.append(meta) + + progress.update(task, advance=1) + + progress.stop() + + tps.aut.turn_to(0, math.pi) + + metadata: PanoramaMetadata = { + "center": (center.x, center.y, center.z), + "focal": focal / pixelsize, + "principal": (cx, cy), + "camera_offset": (offset.x, offset.y, offset.z), + "camera_deviation": ( + float(yaw), + float(pitch), + float(roll) + ), + "images": images + } + json.dump(metadata, file, indent=4) + + +def main( + port: str, + metadata: TextIO, + baud: int = 9600, + timeout: int = 15, + retry: int = 1, + sync_after_timeout: bool = False, + zoom: str = "x1", + overlap: tuple[int, int] = (5, 10), + prefix: str = "panorama_", + whitebalance: str | None = None, + increase_tolerance: bool = False, + shape: str = "region", + layout: str = "adaptive-fov", + horizontal: tuple[str, str] | None = None, + vertical: tuple[str, str] | None = None +) -> None: + logger = getLogger("iman.panorama.measure") + with open_serial( + port, + retry=retry, + sync_after_timeout=sync_after_timeout, + speed=baud, + timeout=timeout, + logger=logger.getChild("com") + ) as com: + tps = GeoCom(com, logger.getChild("instrument")) + tolerances: tuple[Angle, Angle] | None = None + try: + resp_tol = tps.aut.get_tolerance() + if ( + increase_tolerance + and resp_tol.error == GeoComCode.OK + and resp_tol.params is not None + ): + print("Set reduced tolerances") + tolerances = resp_tol.params + tps.aut.set_tolerance( + Angle.from_dms("0-30-00"), + Angle.from_dms("0-30-00") + ) + if whitebalance is not None: + tps.cam.set_whitebalance(whitebalance.upper()) + run_panorama( + tps, + metadata, + Zoom[zoom.upper()], + overlap, + prefix, + shape, + layout, + ( + Angle.from_dms(horizontal[0]), + Angle.from_dms(horizontal[1]) + ) if horizontal is not None else None, + ( + Angle.from_dms(vertical[0]), + Angle.from_dms(vertical[1]) + ) if vertical is not None else None, + logger + ) + finally: + if tolerances is not None: + print("Restored reduced tolerances") + tps.aut.set_tolerance(*tolerances) + + if whitebalance is not None: + tps.cam.set_whitebalance("AUTO") diff --git a/src/instrumentman/panorama/metadata.py b/src/instrumentman/panorama/metadata.py new file mode 100644 index 0000000..e8347de --- /dev/null +++ b/src/instrumentman/panorama/metadata.py @@ -0,0 +1,43 @@ +from pathlib import Path +from typing import TypedDict, cast +import json +import os + +from jsonschema import validate + + +class PanoramaFrameMetadata(TypedDict): + filename: str + # grid: tuple[int, int] # position in grid + position: tuple[float, float, float] + vector: tuple[float, float, float] + + +class PanoramaMetadata(TypedDict): + center: tuple[float, float, float] + focal: float + principal: tuple[float, float] + camera_offset: tuple[float, float, float] + camera_deviation: tuple[float, float, float] + images: list[PanoramaFrameMetadata] + + +def read_metadata( + path: Path +) -> PanoramaMetadata: + with path.open("rt", encoding="utf8") as file: + data = json.load(file) + + with open( + os.path.join( + os.path.dirname(__file__), + "schema_metadata.json" + ), + "rt", + encoding="utf8" + ) as file_schema: + schema = json.load(file_schema) + + validate(data, schema) + + return cast(PanoramaMetadata, data) diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py new file mode 100644 index 0000000..fa5ea46 --- /dev/null +++ b/src/instrumentman/panorama/process.py @@ -0,0 +1,647 @@ +# While the OpenCV Python binding package has some measure of type hints, +# these are often not reliable. To provide more accurate information to the +# reader, typing information provided by 'opencv-python' is overruled (and/or +# ignored) in many places in this module, with types that are closer to the +# actual behavior of the functions in the context of this program (and to +# correct problems in the 'opencv-python' type hints). + +from pathlib import Path +from typing import Sequence +from json import JSONDecodeError + +from rich.progress import ( + Progress, + TextColumn, + BarColumn, + MofNCompleteColumn, + TimeRemainingColumn, + TimeElapsedColumn +) +from jsonschema import ValidationError +from geocompy.data import Coordinate, Angle +import numpy as np +import numpy.typing as npt + +try: + import cv2 as cv +except ModuleNotFoundError: + print( + """ +The panorama image processing requires extra dependencies. + +- opencv-python + +Install them manually, or install instrumentman with the 'panorama' extra: + +python -m pip install instrumentman[panorama] +""" + ) + exit(1) + +from ..utils import print_warning, print_error, console +from .metadata import read_metadata, PanoramaMetadata + + +_MAX_SCALE = 5210 # np.iinfo(np.int16).max // (2 * np.pi) + + +def rot_x(angle: float) -> np.typing.NDArray[np.float64]: + return np.array( + ( + (1, 0, 0), + (0, np.cos(angle), -np.sin(angle)), + (0, np.sin(angle), np.cos(angle)) + ) + ) + + +def rot_y(angle: float) -> np.typing.NDArray[np.float64]: + return np.array( + ( + (np.cos(angle), 0, np.sin(angle)), + (0, 1, 0), + (-np.sin(angle), 0, np.cos(angle)) + ) + ) + + +def rot_z(angle: float) -> np.typing.NDArray[np.float64]: + return np.array( + ( + (np.cos(angle), -np.sin(angle), 0), + (np.sin(angle), np.cos(angle), 0), + (0, 0, 1) + ) + ) + + +def read_points( + path: Path, + skip: int = 0, + delimiter: str = "," +) -> list[tuple[str, Coordinate, str]]: + points: list[tuple[str, Coordinate, str]] = [] + with path.open("rt", encoding="utf8") as file: + for i in range(skip): + next(file) + + for line in file: + fields = line.strip().split(delimiter) + if len(fields) == 4: + pt, x, y, z = fields + label = "" + else: + pt, x, y, z = fields[:4] + label = fields[4] + + points.append( + ( + pt, + Coordinate( + float(x), + float(y), + float(z) + ), + label + ) + ) + + return points + + +def apply_rotation( + coord: Coordinate, + mat: npt.NDArray[np.floating] +) -> Coordinate: + vector = np.array((coord.x, coord.y, coord.z)) + vector @= mat + + return Coordinate( + vector[0], + vector[1], + vector[2] + ) + + +def mean_coordinate(coords: list[Coordinate]) -> Coordinate: + x: float = np.mean(np.array([c.x for c in coords])) + y: float = np.mean(np.array([c.y for c in coords])) + z: float = np.mean(np.array([c.z for c in coords])) + + return Coordinate(x, y, z) + + +def text_pos( + text: str, + point: tuple[float, float], + offset: tuple[float, float], + font: int, + fontscale: float, + thickness: int, + justify: str +) -> tuple[int, int]: + (w, h), _ = cv.getTextSize( + text, + font, + fontscale, + thickness + ) + + x, y = point + ox, oy = offset + + match justify[0]: + case "t": + y += h + case "m": + y += h / 2 + + match justify[1]: + case "c": + x -= w / 2 + case "r": + x -= w + + return round(x + ox), round(y + oy) + + +def run_annotate( + meta: PanoramaMetadata, + output: Path, + images: dict[str, Path], + shift: Angle, + scale: float | None = None, + points: list[tuple[str, Coordinate, str]] = [], + compenstation_mode: int = cv.detail.EXPOSURE_COMPENSATOR_GAIN, + blending_mode: int = cv.detail.BLENDER_MULTI_BAND, + seam_mode: int = cv.detail.SEAM_FINDER_VORONOI_SEAM, + seam_overlap: int = 0, + visualize_stitch: bool = False, + color: tuple[int, int, int] = (0, 0, 0), + font: int = cv.FONT_HERSHEY_PLAIN, + fontscale: float = 1, + thickness: int = 2, + marker: int = cv.MARKER_CROSS, + markersize: int = 10, + offset: tuple[int, int] = (10, -10), + justify: str = "bl", + label_font: int = cv.FONT_HERSHEY_PLAIN, + label_fontscale: float = 1, + label_thickness: int = 2, + label_color: tuple[int, int, int] = (0, 0, 0), + label_offset: tuple[int, int] = (10, 10), + label_justify: str = "tl", +) -> None: + corners: list[Sequence[int]] = [] + centers: list[tuple[int, int, Angle, Angle]] = [] + images_warped: list[npt.NDArray[np.uint8]] = [] + masks_warped: list[npt.NDArray[np.uint8]] = [] + + center = Coordinate(*meta["center"]) + focal = meta["focal"] + principal_x, principal_y = meta["principal"] + camera_offset = Coordinate(*meta["camera_offset"]) + camera_yaw = meta["camera_deviation"][0] + camera_pitch = meta["camera_deviation"][1] + camera_roll = meta["camera_deviation"][2] + + instrinsics: npt.NDArray[np.float32] = np.array( + ( + (focal, 0.0, principal_x), + (0.0, focal, principal_y), + (0.0, 0.0, 1.0) + ) + ).astype(np.float32) + + with Progress( + TextColumn("[progress.description]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + TimeRemainingColumn(), + TimeElapsedColumn(), + console=console + ) as progress: + warper: cv.PyRotationWarper | None = None + for data in progress.track( + meta["images"], + description="Preprocessing images" + ): + vec = Coordinate(*data["vector"]) + path = images.get(data["filename"]) + if path is None: + print_warning(f"Could not find '{data['filename']}'") + continue + + # Returns uint8 + img: npt.NDArray[np.uint8] = cv.imread( + str(path), + cv.IMREAD_COLOR_BGR + ) # type: ignore[assignment] + if img is None: + print_warning(f"Could not load '{data['filename']}'") + continue + + hz, v, _ = vec.to_polar() + hz = (hz - shift).normalized() + height: int + width: int + height, width, _ = img.shape + + if visualize_stitch: + img = np.stack( + ( + np.full( + (height, width), + np.random.randint(0, 255), + np.uint8 + ), + np.full( + (height, width), + np.random.randint(0, 255), + np.uint8 + ), + np.full( + (height, width), + np.random.randint(0, 255), + np.uint8 + ) + ), + axis=2 + ) + + if warper is None: + if scale is None: + scale = focal + + scale = min(scale, _MAX_SCALE) + warper = cv.PyRotationWarper("spherical", scale) + + rot: npt.NDArray[np.float32] = ( + rot_y(float(hz)) + @ rot_x(np.pi / 2 - float(v)) + @ rot_z(-camera_roll) + ).astype(np.float32) + + # Maintains input type (uint8) + image_warped: npt.NDArray[np.uint8] + corner, image_warped = warper.warp( # type: ignore[assignment] + img, + instrinsics, + rot, + cv.INTER_LINEAR, + cv.BORDER_REPLICATE + ) + + mask_warped: npt.NDArray[np.uint8] + _, mask_warped = warper.warp( # type: ignore[assignment] + np.full((height, width), 255, np.uint8), + instrinsics, + rot, + cv.INTER_NEAREST, + cv.BORDER_CONSTANT + ) + cx, cy = warper.warpPoint( + (width / 2, height / 2), instrinsics, rot) + + centers.append( + ( + int(cx), int(cy), + hz, v + ) + ) + corners.append(corner) + images_warped.append(image_warped) + masks_warped.append(mask_warped) + + task_seams = progress.add_task(description="Finding seams", total=None) + finder = cv.detail.SeamFinder.createDefault(seam_mode) + seams = finder.find( + images_warped, # type: ignore[arg-type] + corners, + masks_warped # type: ignore[arg-type] + ) + + if scale is None: + scale = 1000 + + progress.update(task_seams, completed=len(seams), total=len(seams)) + + task_merge = progress.add_task( + description="Merging images", + total=None + ) + compensator = cv.detail.ExposureCompensator.createDefault( + compenstation_mode + ) + if compenstation_mode != cv.detail.EXPOSURE_COMPENSATOR_NO: + compensator.feed( + corners, + images_warped, # type: ignore[arg-type] + masks_warped # type: ignore[arg-type] + ) + + if seam_overlap == -1: + seam_overlap = round(scale / 100) + + if seam_overlap > 0 and seam_mode != cv.detail.SEAM_FINDER_NO: + kernel_size = 1 + 2 * seam_overlap + kernel = cv.UMat( + np.ones((kernel_size, kernel_size), np.uint8) + ) # type: ignore[call-overload] + else: + kernel = None + + blender = cv.detail.Blender.createDefault(blending_mode) + blender.prepare( + corners, + [(i.shape[1], i.shape[0]) for i in images_warped] + ) + for i, (corner, img, msk, seam_msk) in enumerate( + zip(corners, images_warped, masks_warped, seams) + ): + if compenstation_mode != cv.detail.EXPOSURE_COMPENSATOR_NO: + img = compensator.apply( + i, + corner, + img, + msk + ) # type: ignore[assignment] + + if kernel is not None: + seam_msk = cv.dilate( + seam_msk, + kernel, + borderType=cv.BORDER_CONSTANT + ) + + blender.feed( + img.astype(np.int16), + seam_msk.get().astype(np.uint8), + corner + ) + + result: npt.NDArray[np.int16] + result, _ = blender.blend( + None, None + ) # type: ignore[call-overload] + + progress.update( + task_merge, + completed=len(images_warped), + total=len(images_warped) + ) + + if len(points) > 0: + # Top left image center point for reference + origin_x, origin_y, _, _ = cv.detail.resultRoi( + corners, + [(i.shape[1], i.shape[0]) for i in images_warped] + ) + tl_x, tl_y, tl_hz, tl_v = centers[0] + tl_x -= origin_x + tl_y -= origin_y + + full_360 = round(scale * np.pi * 2) + + for pt, coord, label in progress.track( + points, + description="Annotating points" + ): + # To calculate the approximate "telescope" rotation, a + # preliminary polar position is needed. Then the camera offset + # is rotated with the preliminary angles. + prelim_hz, prelim_v, _ = (coord - center).to_polar() + offset_rot = ( + rot_z(float((prelim_hz - shift).normalized()) - camera_yaw) + @ rot_x(np.pi / 2 - float(prelim_v) - camera_pitch) + ) + pt_hz, pt_v, _ = ( + coord + - (center + apply_rotation(camera_offset, offset_rot)) + ).to_polar() + + pt_hz = (pt_hz - shift).normalized() + + pt_hz_f = float(pt_hz - tl_hz) + pt_v_f = float(pt_v - tl_v) + pt_x = round(tl_x + pt_hz_f * scale) % full_360 + pt_y = round(tl_y + pt_v_f * scale) % full_360 + + cv.drawMarker( + result, + (pt_x, pt_y), + color, + marker, + markersize, + thickness + ) + + cv.putText( + result, + pt, + text_pos( + pt, + (pt_x, pt_y), + offset, + font, + fontscale, + thickness, + justify + ), + font, + fontscale, + color, + thickness, + bottomLeftOrigin=False + ) + if label == "": + continue + + cv.putText( + result, + label, + text_pos( + label, + (pt_x, pt_y), + label_offset, + label_font, + label_fontscale, + label_thickness, + label_justify + ), + label_font, + label_fontscale, + label_color, + label_thickness, + bottomLeftOrigin=False + ) + + task_save = progress.add_task("Saving final image", total=None) + # For some reason the blending function returns the image as int16 + # instead uint8, and it might contain negative values. These need to be + # clipped, otherwise the type conversion will result in color artifacts + # due to the integer underflow. + result = np.clip(result, 0, 255) + cv.imwrite( + str(output), + result.astype(np.uint8) + ) + progress.update(task_save, completed=1, total=1) + + +_MARKER_MAP = { + "cross": cv.MARKER_CROSS, + "x": cv.MARKER_TILTED_CROSS, + "star": cv.MARKER_STAR, + "diamond": cv.MARKER_DIAMOND, + "square": cv.MARKER_SQUARE, + "uptriangle": cv.MARKER_TRIANGLE_UP, + "downtriangle": cv.MARKER_TRIANGLE_DOWN +} + +_FONT_MAP = { + "plain": cv.FONT_HERSHEY_PLAIN, + "simplex": cv.FONT_HERSHEY_SIMPLEX, + "duplex": cv.FONT_HERSHEY_DUPLEX, + "complex": cv.FONT_HERSHEY_COMPLEX +} + + +_COMP_MAP = { + "none": cv.detail.EXPOSURE_COMPENSATOR_NO, + "channels": cv.detail.EXPOSURE_COMPENSATOR_CHANNELS, + "gain": cv.detail.EXPOSURE_COMPENSATOR_GAIN +} + + +_BLEND_MAP = { + "none": cv.detail.BLENDER_NO, + "multiband": cv.detail.BLENDER_MULTI_BAND, + "feather": cv.detail.BLENDER_FEATHER +} + + +_SEAM_MAP = { + "none": cv.detail.SEAM_FINDER_NO, + "voronoi": cv.detail.SEAM_FINDER_VORONOI_SEAM, + "dynamic-programming": cv.detail.SEAM_FINDER_DP_SEAM +} + + +def main( + metadata: Path, + output: Path, + image: tuple[Path], + shift: str | None = None, + compensation: str = "channel", + blending: str = "multiband", + seams: str = "voronoi", + seam_overlap: int = 0, + visualize_stitch: bool = False, + scale: float | None = None, + width: int | None = None, + height: int | None = None, + annotate: Path | None = None, + skip: int = 0, + delimiter: str = ",", + color: tuple[int, int, int] = (0, 0, 0), + font: str = "plain", + fontsize: int = 10, + thickness: int = 1, + marker: str = "cross", + markersize: int = 50, + offset: tuple[int, int] | None = (10, -10), + justify: str = "bl", + label_font: str | None = None, + label_fontsize: int | None = None, + label_color: tuple[int, int, int] | None = None, + label_thickness: int | None = None, + label_offset: tuple[int, int] | None = (10, 10), + label_justify: str = "bl" +) -> None: + try: + meta = read_metadata(metadata) + except (ValidationError, JSONDecodeError): + print_error( + "The metadata file is not a valid JSON or does not follow the " + "required schema" + ) + exit(1) + + if annotate is not None: + points = read_points(annotate, skip, delimiter) + else: + points = [] + + image_map: dict[str, Path] = {p.stem + p.suffix: p for p in image} + + if width is not None: + scale = width / (2 * np.pi) + elif height is not None: + scale = height / np.pi + + color = (color[2], color[1], color[0]) + if label_color is None: + label_color = color + else: + label_color = (label_color[2], label_color[1], label_color[0]) + + fontscale = cv.getFontScaleFromHeight( + _FONT_MAP[font], + fontsize, + thickness + ) + + if label_thickness is None: + label_thickness = thickness + + if label_fontsize is None: + label_fontsize = fontsize + + if label_font is None: + label_font = font + + label_fontscale = cv.getFontScaleFromHeight( + _FONT_MAP[label_font], + label_fontsize, + label_thickness + ) + + if offset is None: + offset = (fontsize // 2, -fontsize // 2) + + if label_offset is None: + label_offset = (label_fontsize // 2, label_fontsize // 2) + + try: + run_annotate( + meta, + output, + image_map, + Angle.from_dms(shift) if shift is not None else Angle(0), + scale, + points, + _COMP_MAP[compensation], + _BLEND_MAP[blending], + _SEAM_MAP[seams], + seam_overlap, + visualize_stitch, + color, + _FONT_MAP[font], + fontscale, + thickness, + _MARKER_MAP[marker], + markersize, + offset, + justify, + _FONT_MAP[label_font], + label_fontscale, + label_thickness, + label_color, + label_offset, + label_justify + ) + except cv.error as cve: + print_error(f"The process failed due to an OpenCV error ({cve.code})") + print_error(cve.err) + raise cve diff --git a/src/instrumentman/panorama/schema_metadata.json b/src/instrumentman/panorama/schema_metadata.json new file mode 100644 index 0000000..4f47f24 --- /dev/null +++ b/src/instrumentman/panorama/schema_metadata.json @@ -0,0 +1,92 @@ +{ + "title": "Instrumentman panorama metadata schema", + "description": "Additional data needed for panorama stitching and annotation", + "type": "object", + "additionalProperties": false, + "required": ["center", "focal", "principal", "camera_offset", "camera_deviation", "images"], + "properties": { + "center": { + "description": "Instrument center position", + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "number", + "unevaluatedItems": false + } + }, + "focal": { + "description": "Focal length in pixels", + "type": "number", + "minimum": 1.0 + }, + "principal": { + "description": "Image coordinates of principal point (x-y)", + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "number", + "unevaluatedItems": false, + "minimum": 0.0 + } + }, + "camera_offset": { + "description": "Camera center offset from center (x-y-z)", + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "number", + "unevaluatedItems": false + } + }, + "camera_deviation": { + "description": "Camera axis deviation (yaw-pitch-roll) in radians", + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "number", + "unevaluatedItems": false + } + }, + "images": { + "description": "Panorama frames", + "type": "array", + "items": { + "type": "object", + "unevaluatedItems": false, + "additionalProperties": false, + "required": ["filename", "position", "vector"], + "properties": { + "filename": { + "description": "Image file name", + "type": "string", + "minLength": 1 + }, + "position": { + "description": "Camera position", + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "number", + "unevaluatedItems": false + } + }, + "vector": { + "description": "Camera view vector", + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "number", + "unevaluatedItems": false + } + } + } + } + } + } +} \ No newline at end of file