From 3d15de48edeebe0babfcf30784aac9a487044f4f Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Tue, 26 Aug 2025 16:42:04 +0200 Subject: [PATCH 01/42] [panorama] first tets of photo phase --- src/instrumentman/__init__.py | 2 + src/instrumentman/panorama/__init__.py | 41 ++++++++ src/instrumentman/panorama/measure.py | 138 +++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 src/instrumentman/panorama/__init__.py create mode 100644 src/instrumentman/panorama/measure.py diff --git a/src/instrumentman/__init__.py b/src/instrumentman/__init__.py index 6ad721e..11281da 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 @@ -173,6 +174,7 @@ 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) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py new file mode 100644 index 0000000..e1f08c5 --- /dev/null +++ b/src/instrumentman/panorama/__init__.py @@ -0,0 +1,41 @@ +from typing import Any + +from click_extra import ( + extra_command, + argument, + option, + File, + Choice +) + +from ..utils import ( + com_port_argument, + com_option_group +) + + +@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" +) +def cli_measure(**kwargs: Any) -> None: + """ + Take pictures with the instrument camera for later panormaic stitching + """ + from .measure import main + + main(**kwargs) diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py new file mode 100644 index 0000000..839c5f8 --- /dev/null +++ b/src/instrumentman/panorama/measure.py @@ -0,0 +1,138 @@ +from typing import TextIO +import math +from logging import getLogger, Logger + +from click_extra import pause +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 echo_red + + +def run_panorama( + tps: GeoCom, + file: TextIO, + zoom: Zoom, + logger: Logger +) -> None: + + pause("Aim the instrument at the starting corner, then press any key...") + resp_start = tps.tmc.get_angle() + if resp_start.error != GeoComCode.OK or resp_start.params is None: + echo_red("Could not retrieve starting corner angles") + logger.critical("Could not retrieve starting corner angles") + exit(1) + + pause("Aim the instrument at the finishing corner, then press any key...") + resp_end = tps.tmc.get_angle() + if resp_end.error != GeoComCode.OK or resp_end.params is None: + echo_red("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 + + tps.edm.switch_laserpointer(False) + + resp_zoom = tps.cam.set_zoom(zoom) + if resp_zoom.error != GeoComCode.OK: + echo_red("Could set retrieve camera zoom factor") + logger.critical("Could set retrieve camera zoom factor") + exit(1) + + resp_fov = tps.cam.get_camera_fov(zoom=zoom) + if resp_fov.params is None: + echo_red("Could not retrieve camera FOV") + logger.critical("Could not retrieve camera FOV") + exit(1) + + fov_hz, fov_v = resp_fov.params + + delta_hz = to_hz - from_hz + delta_v = to_v - from_v + + cols = math.ceil(abs(float(delta_hz)) / float(fov_hz)) + 1 + rows = math.ceil(abs(float(delta_v)) / float(fov_v)) + 1 + + print( + "image", + "fov_hz_rad", + "fov_v_rad", + "pos_x_m", + "pos_y_m", + "pos_z_m", + "dir_x_m", + "dir_y_m", + "dir_z_m", + sep=",", + file=file + ) + + counter = 1 + for i in range(rows): + for j in range(cols): + tps.aut.turn_to( + (from_hz + delta_hz * j / cols).normalized(), + (from_v + delta_v * i / rows).normalized() + ) + tps.cam.set_actual_image_name( + "panorama", + counter + ) + tps.cam.take_image() + resp_cam_pos = tps.cam.get_camera_position() + if resp_cam_pos.params is None: + echo_red("Could not retrieve camera position") + logger.critical("Could not retrieve camera position") + exit(1) + + pos = resp_cam_pos.params + + resp_cam_dir = tps.cam.get_camera_direction(100) + if resp_cam_dir.params is None: + echo_red("Could not retrieve camera direction") + logger.critical("Could not retrieve camera direction") + exit(1) + + vec = resp_cam_dir.params + + print( + f"panorama{counter:05d}.jpg", + float(fov_hz), + float(fov_v), + pos.x, + pos.y, + pos.z, + vec.x, + vec.y, + vec.z, + sep=",", + file=file + ) + + counter += 1 + + +def main( + port: str, + metadata: TextIO, + baud: int = 9600, + timeout: int = 15, + retry: int = 1, + sync_after_timeout: bool = False, + zoom: str = "x1" +) -> 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")) + run_panorama(tps, metadata, Zoom[zoom.upper()], logger) From 83e5af7db52c51f3bf7ced992f5efd3a095f99c1 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Tue, 26 Aug 2025 18:22:58 +0200 Subject: [PATCH 02/42] [panorama] fixes to measurement --- src/instrumentman/panorama/measure.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py index 839c5f8..330e3ff 100644 --- a/src/instrumentman/panorama/measure.py +++ b/src/instrumentman/panorama/measure.py @@ -51,8 +51,8 @@ def run_panorama( fov_hz, fov_v = resp_fov.params - delta_hz = to_hz - from_hz - delta_v = to_v - from_v + delta_hz = to_hz.relative_to(from_hz) + delta_v = to_v.relative_to(from_v) cols = math.ceil(abs(float(delta_hz)) / float(fov_hz)) + 1 rows = math.ceil(abs(float(delta_v)) / float(fov_v)) + 1 @@ -75,8 +75,8 @@ def run_panorama( for i in range(rows): for j in range(cols): tps.aut.turn_to( - (from_hz + delta_hz * j / cols).normalized(), - (from_v + delta_v * i / rows).normalized() + (from_hz + delta_hz * j / (cols - 1)).normalized(), + (from_v + delta_v * i / (rows - 1)).normalized() ) tps.cam.set_actual_image_name( "panorama", @@ -91,7 +91,7 @@ def run_panorama( pos = resp_cam_pos.params - resp_cam_dir = tps.cam.get_camera_direction(100) + resp_cam_dir = tps.cam.get_camera_direction(1) if resp_cam_dir.params is None: echo_red("Could not retrieve camera direction") logger.critical("Could not retrieve camera direction") From 529b3878c791aad855cf5f1cc9c6e7cc5b8d90b6 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Tue, 26 Aug 2025 20:02:05 +0200 Subject: [PATCH 03/42] [panorama] added panorama annotation --- src/instrumentman/__init__.py | 3 +- src/instrumentman/panorama/__init__.py | 86 +++++++++- src/instrumentman/panorama/process.py | 222 +++++++++++++++++++++++++ 3 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 src/instrumentman/panorama/process.py diff --git a/src/instrumentman/__init__.py b/src/instrumentman/__init__.py index 11281da..6ce9960 100644 --- a/src/instrumentman/__init__.py +++ b/src/instrumentman/__init__.py @@ -118,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.""" @@ -178,6 +178,7 @@ def cli_startup() -> None: 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 index e1f08c5..a112c91 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -4,9 +4,13 @@ extra_command, argument, option, + option_group, File, - Choice + file_path, + Choice, + IntRange ) +from cloup.constraints import constraint, If, Equal, Not, require_all from ..utils import ( com_port_argument, @@ -39,3 +43,83 @@ def cli_measure(**kwargs: Any) -> None: 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( + "image", + help="Panorama image part", + type=file_path(exists=True), + nargs=-1, + required=True +) +@option( + "--action", + help="Processing to perform", + type=Choice(("annotate",), case_sensitive=False), + default="annotate" +) +@option_group( + "Point list file options", + option( + "--points", + help="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( + "--rgb", + help="Color in RGB8 notation", + type=(IntRange(0, 255), IntRange(0, 255), IntRange(0, 255)), + default=(0, 0, 0) + ), + option( + "--fontsize", + help="Text size in pixels", + type=IntRange(1), + default=50 + ), + option( + "--marker", + help="Point marker shape", + type=Choice(("cross", "dot"), case_sensitive=False), + default="cross" + ), + option( + "--markersize", + help="Point marker size in pixels", + type=IntRange(1), + default=50 + ) +) +@constraint( + If(Not(Equal("action", "stitch")), require_all), + ["action", "points"] +) +def cli_calc(**kwargs: Any) -> None: + from .process import main + + main(**kwargs) diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py new file mode 100644 index 0000000..d39cb41 --- /dev/null +++ b/src/instrumentman/panorama/process.py @@ -0,0 +1,222 @@ +from pathlib import Path +import math +from typing import Callable + +from PIL import Image, ImageDraw, ImageFont +from geocompy.data import Coordinate, Angle, Vector + + +def read_points( + path: Path, + skip: int = 0, + delimiter: str = ";" +) -> list[tuple[str, Coordinate]]: + points: list[tuple[str, Coordinate]] = [] + with path.open("rt", encoding="utf8") as file: + for i in range(skip): + next(file) + + for line in file: + pt, x, y, z = line.strip().split(delimiter) + points.append( + ( + pt, + Coordinate( + float(x), + float(y), + float(z) + ) + ) + ) + + return points + + +def read_metadata( + path: Path +) -> dict[str, tuple[Angle, Angle, Coordinate, Vector]]: + pictures: dict[str, tuple[Angle, Angle, Coordinate, Vector]] = {} + with path.open("rt", encoding="utf8") as file: + next(file) + for line in file: + ( + img, + fov_hz, + fov_v, + pos_x, + pos_y, + pos_z, + dir_x, + dir_y, + dir_z + ) = line.strip().split(",") + + pictures[img] = ( + Angle.parse(fov_hz), + Angle.parse(fov_v), + Coordinate( + float(pos_x), + float(pos_y), + float(pos_z) + ), + Vector( + float(dir_x), + float(dir_y), + float(dir_z) + ) + ) + + return pictures + + +def draw_marker_dot( + draw: ImageDraw.ImageDraw, + x: float, + y: float, + text: str, + font: ImageFont.FreeTypeFont, + markersize: float, + rgb: tuple[int, int, int] +) -> None: + draw.circle((x, y), markersize / 2, rgb) + draw.text( + ( + x + markersize / 3, + y + markersize / 3 + ), text, rgb, font, anchor="la" + ) + + +def draw_marker_cross( + draw: ImageDraw.ImageDraw, + x: float, + y: float, + text: str, + font: ImageFont.FreeTypeFont, + markersize: float, + rgb: tuple[int, int, int] +) -> None: + leg = markersize / 2 + draw.line( + ( + x - leg, y, + x + leg, y + ), + rgb, + round(markersize / 10) + ) + draw.line( + ( + x, y - leg, + x, y + leg + ), + rgb, + round(markersize / 10) + ) + draw.text( + ( + x + markersize / 4, + y + markersize / 4 + ), text, rgb, font, anchor="la" + ) + + +def annotate_image( + imgpath: Path, + info: tuple[Angle, Angle, Coordinate, Vector], + points: list[tuple[str, Coordinate]], + markerdrawer: Callable[[ImageDraw.ImageDraw, float, float, str], None] +) -> None: + image = Image.open(imgpath) + draw = ImageDraw.Draw(image) + fov_hz, fov_v, pos, vec = info + half_width = image.width / 2 + half_height = image.height / 2 + half_fov_hz = fov_hz / 2 + half_fov_v = fov_v / 2 + + img_hz, img_v, _ = (Coordinate.from_vector(vec) - pos).to_polar() + + for pt, coord in points: + pt_hz, pt_v, _ = (coord - pos).to_polar() + alpha = pt_hz.relative_to(img_hz) + beta = pt_v.relative_to(img_v) + + x = round(half_width + math.tan(alpha) * + half_width / math.tan(half_fov_hz)) + y = round(half_height + math.tan(beta) * + half_height / math.tan(half_fov_v)) + + markerdrawer(draw, x, y, pt) + + image.save( + imgpath.parent.joinpath( + imgpath.stem + "_annotated" + imgpath.suffix + ) + ) + + +def run_annotate( + meta: dict[str, tuple[Angle, Angle, Coordinate, Vector]], + images: tuple[Path], + points: list[tuple[str, Coordinate]], + rgb: tuple[int, int, int] = (0, 0, 0), + fontsize: int = 50, + marker: str = "cross", + markersize: int = 50 +) -> None: + font = ImageFont.truetype("arial.ttf", fontsize) + match marker: + case "cross": + markerdrawer = draw_marker_cross + case "dot": + markerdrawer = draw_marker_dot + + for path in images: + info = meta.get(path.stem + path.suffix) + if info is None: + continue + + annotate_image( + path, + info, + points, + lambda draw, x, y, text: markerdrawer( + draw, + x, + y, + text, + font, + markersize, + rgb + ) + ) + + +def main( + metadata: Path, + image: tuple[Path], + action: str = "annotate", + points: Path | None = None, + skip: int = 0, + delimiter: str = ",", + rgb: tuple[int, int, int] = (0, 0, 0), + fontsize: int = 50, + marker: str = "cross", + markersize: int = 50 +) -> None: + meta = read_metadata(metadata) + match action: + case "annotate" if points is not None: + point = read_points(points, skip, delimiter) + run_annotate( + meta, + image, + point, + rgb, + fontsize, + marker, + markersize + ) + case _: + raise ValueError(f"Unknown action '{action}'") From d6744c6c53da9df8e3652d4e56f5a2f5d8aa7b8a Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Tue, 26 Aug 2025 20:08:36 +0200 Subject: [PATCH 04/42] [panorama] small corrections --- pyproject.toml | 1 + src/instrumentman/panorama/process.py | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8f8cb64..02998bc 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" ] diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index d39cb41..49cb7eb 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -3,7 +3,7 @@ from typing import Callable from PIL import Image, ImageDraw, ImageFont -from geocompy.data import Coordinate, Angle, Vector +from geocompy.data import Coordinate, Angle def read_points( @@ -34,8 +34,8 @@ def read_points( def read_metadata( path: Path -) -> dict[str, tuple[Angle, Angle, Coordinate, Vector]]: - pictures: dict[str, tuple[Angle, Angle, Coordinate, Vector]] = {} +) -> dict[str, tuple[Angle, Angle, Coordinate, Coordinate]]: + pictures: dict[str, tuple[Angle, Angle, Coordinate, Coordinate]] = {} with path.open("rt", encoding="utf8") as file: next(file) for line in file: @@ -59,7 +59,7 @@ def read_metadata( float(pos_y), float(pos_z) ), - Vector( + Coordinate( float(dir_x), float(dir_y), float(dir_z) @@ -123,7 +123,7 @@ def draw_marker_cross( def annotate_image( imgpath: Path, - info: tuple[Angle, Angle, Coordinate, Vector], + info: tuple[Angle, Angle, Coordinate, Coordinate], points: list[tuple[str, Coordinate]], markerdrawer: Callable[[ImageDraw.ImageDraw, float, float, str], None] ) -> None: @@ -135,7 +135,7 @@ def annotate_image( half_fov_hz = fov_hz / 2 half_fov_v = fov_v / 2 - img_hz, img_v, _ = (Coordinate.from_vector(vec) - pos).to_polar() + img_hz, img_v, _ = (vec - pos).to_polar() for pt, coord in points: pt_hz, pt_v, _ = (coord - pos).to_polar() @@ -157,7 +157,7 @@ def annotate_image( def run_annotate( - meta: dict[str, tuple[Angle, Angle, Coordinate, Vector]], + meta: dict[str, tuple[Angle, Angle, Coordinate, Coordinate]], images: tuple[Path], points: list[tuple[str, Coordinate]], rgb: tuple[int, int, int] = (0, 0, 0), From 32fcd6342fd390cb0fe5460b9797ea5fbecbe077 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Wed, 27 Aug 2025 00:58:38 +0200 Subject: [PATCH 05/42] [panorama] added overlap option to measurement --- src/instrumentman/panorama/__init__.py | 6 ++++++ src/instrumentman/panorama/measure.py | 13 ++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index a112c91..5dfd957 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -36,6 +36,12 @@ type=Choice(("x1", "x2", "x4", "x8"), case_sensitive=False), default="x1" ) +@option( + "--overlap", + help="Minimum horizontal and vertical overlap between images (percentage)", + type=(IntRange(5, 95), IntRange(5, 95)), + default=(30, 30) +) def cli_measure(**kwargs: Any) -> None: """ Take pictures with the instrument camera for later panormaic stitching diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py index 330e3ff..d7efdea 100644 --- a/src/instrumentman/panorama/measure.py +++ b/src/instrumentman/panorama/measure.py @@ -15,9 +15,9 @@ def run_panorama( tps: GeoCom, file: TextIO, zoom: Zoom, + overlap: tuple[int, int], logger: Logger ) -> None: - pause("Aim the instrument at the starting corner, then press any key...") resp_start = tps.tmc.get_angle() if resp_start.error != GeoComCode.OK or resp_start.params is None: @@ -50,12 +50,14 @@ def run_panorama( exit(1) fov_hz, fov_v = resp_fov.params + reduced_fov_hz = float(fov_hz) * (1 - overlap[0] / 100) + reduced_fov_v = float(fov_hz) * (1 - overlap[1] / 100) delta_hz = to_hz.relative_to(from_hz) delta_v = to_v.relative_to(from_v) - cols = math.ceil(abs(float(delta_hz)) / float(fov_hz)) + 1 - rows = math.ceil(abs(float(delta_v)) / float(fov_v)) + 1 + cols = math.ceil(abs(float(delta_hz)) / reduced_fov_hz) + 1 + rows = math.ceil(abs(float(delta_v)) / reduced_fov_v) + 1 print( "image", @@ -123,7 +125,8 @@ def main( timeout: int = 15, retry: int = 1, sync_after_timeout: bool = False, - zoom: str = "x1" + zoom: str = "x1", + overlap: tuple[int, int] = (30, 30) ) -> None: logger = getLogger("iman.panorama.measure") with open_serial( @@ -135,4 +138,4 @@ def main( logger=logger.getChild("com") ) as com: tps = GeoCom(com, logger.getChild("instrument")) - run_panorama(tps, metadata, Zoom[zoom.upper()], logger) + run_panorama(tps, metadata, Zoom[zoom.upper()], overlap, logger) From fd07a393a3587e517a92de4529f539c991291873 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Wed, 27 Aug 2025 20:09:44 +0200 Subject: [PATCH 06/42] [panorama] iterated on panorama measurement --- src/instrumentman/panorama/__init__.py | 6 + src/instrumentman/panorama/measure.py | 193 ++++++++++++++++++------- 2 files changed, 149 insertions(+), 50 deletions(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index 5dfd957..064b9c7 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -119,6 +119,12 @@ def cli_measure(**kwargs: Any) -> None: help="Point marker size in pixels", type=IntRange(1), default=50 + ), + option( + "--prefix", + help="Prefix to add to the annotated files names", + type=str, + default="annotated_" ) ) @constraint( diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py index d7efdea..b5418bc 100644 --- a/src/instrumentman/panorama/measure.py +++ b/src/instrumentman/panorama/measure.py @@ -1,14 +1,48 @@ -from typing import TextIO +from typing import TextIO, Generator import math from logging import getLogger, Logger -from click_extra import pause +from rich.console import Console +from rich.progress import ( + Progress, + TextColumn, + BarColumn, + TimeRemainingColumn, + TimeElapsedColumn, + MofNCompleteColumn +) +from click_extra import pause, 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 echo_red +from ..utils import echo_red, echo_yellow + + +def image_positions( + from_hz: Angle, + from_v: Angle, + delta_hz: Angle, + delta_v: Angle, + cols: int, + rows: int +) -> Generator[tuple[int, Angle, Angle], None, None]: + counter = 0 + to_hz = from_hz + delta_hz + for i in range(rows): + for j in range(cols): + counter += 1 + yield ( + counter, + ( + (from_hz + delta_hz * j / (cols - 1)) + if i % 2 == 0 + else (to_hz - delta_hz * j / (cols - 1)) + ).normalized(), + (from_v + delta_v * i / (rows - 1)).normalized() + ) def run_panorama( @@ -16,16 +50,21 @@ def run_panorama( file: TextIO, zoom: Zoom, overlap: tuple[int, int], + prefix: str, logger: Logger ) -> None: - pause("Aim the instrument at the starting corner, then press any key...") + pause( + "Aim the instrument at the left starting corner, then press any key..." + ) resp_start = tps.tmc.get_angle() if resp_start.error != GeoComCode.OK or resp_start.params is None: echo_red("Could not retrieve starting corner angles") logger.critical("Could not retrieve starting corner angles") exit(1) - pause("Aim the instrument at the finishing corner, then press any key...") + pause( + "Aim the instrument at the right finish corner, then press any key..." + ) resp_end = tps.tmc.get_angle() if resp_end.error != GeoComCode.OK or resp_end.params is None: echo_red("Could not retrieve finishing corner angles") @@ -49,16 +88,32 @@ def run_panorama( 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: + echo_red("Could not retrieve station coordinates") + logger.critical("Could not retrieve station coordinates") + exit(1) + + station, hi = resp_station.params + center = station + Coordinate(0, 0, hi) + fov_hz, fov_v = resp_fov.params reduced_fov_hz = float(fov_hz) * (1 - overlap[0] / 100) reduced_fov_v = float(fov_hz) * (1 - overlap[1] / 100) - delta_hz = to_hz.relative_to(from_hz) + delta_hz = (to_hz - from_hz).normalized() delta_v = to_v.relative_to(from_v) cols = math.ceil(abs(float(delta_hz)) / reduced_fov_hz) + 1 rows = math.ceil(abs(float(delta_v)) / reduced_fov_v) + 1 + if not confirm( + f"Start capturing images in {rows} row(s) and {cols} column(s)", + True + ): + echo_yellow("Program cancelled") + exit() + print( "image", "fov_hz_rad", @@ -73,49 +128,79 @@ def run_panorama( file=file ) - counter = 1 - for i in range(rows): - for j in range(cols): - tps.aut.turn_to( - (from_hz + delta_hz * j / (cols - 1)).normalized(), - (from_v + delta_v * i / (rows - 1)).normalized() - ) - tps.cam.set_actual_image_name( - "panorama", - counter - ) - tps.cam.take_image() - resp_cam_pos = tps.cam.get_camera_position() - if resp_cam_pos.params is None: - echo_red("Could not retrieve camera position") - logger.critical("Could not retrieve camera position") - exit(1) - - pos = resp_cam_pos.params - - resp_cam_dir = tps.cam.get_camera_direction(1) - if resp_cam_dir.params is None: - echo_red("Could not retrieve camera direction") - logger.critical("Could not retrieve camera direction") - exit(1) - - vec = resp_cam_dir.params - - print( - f"panorama{counter:05d}.jpg", - float(fov_hz), - float(fov_v), - pos.x, - pos.y, - pos.z, - vec.x, - vec.y, - vec.z, - sep=",", - file=file - ) + console = Console() + progress = Progress( + TextColumn("[progress.description]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + TimeRemainingColumn(), + TimeElapsedColumn(), + console=console + ) + progress.start() + task = progress.add_task( + "Capturing panorama", + total=rows*cols + ) - counter += 1 + for idx, hz, v in image_positions( + from_hz, + from_v, + delta_hz, + delta_v, + cols, + rows + ): + resp_turn = tps.aut.turn_to(hz, v) + if resp_turn.error != GeoComCode.OK: + echo_yellow("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: + echo_yellow("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: + echo_yellow("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: + echo_yellow("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: + echo_yellow("Could not retrieve camera direction") + logger.critical("Could not retrieve camera direction") + continue + + pos = resp_cam_pos.params + center + vec = resp_cam_dir.params + + print( + f"{prefix}{idx:05d}.jpg", + float(fov_hz), + float(fov_v), + pos.x, + pos.y, + pos.z, + vec.x, + vec.y, + vec.z, + sep=",", + file=file + ) + + progress.update(task, advance=1) + + progress.stop() def main( @@ -126,7 +211,8 @@ def main( retry: int = 1, sync_after_timeout: bool = False, zoom: str = "x1", - overlap: tuple[int, int] = (30, 30) + overlap: tuple[int, int] = (30, 30), + prefix: str = "panorama_" ) -> None: logger = getLogger("iman.panorama.measure") with open_serial( @@ -138,4 +224,11 @@ def main( logger=logger.getChild("com") ) as com: tps = GeoCom(com, logger.getChild("instrument")) - run_panorama(tps, metadata, Zoom[zoom.upper()], overlap, logger) + run_panorama( + tps, + metadata, + Zoom[zoom.upper()], + overlap, + prefix, + logger + ) From fc20ef04aca12c24cdfc1bba937a93c58cb37f73 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Wed, 27 Aug 2025 20:51:03 +0200 Subject: [PATCH 07/42] [panorama] improvements and fixes to annotation --- src/instrumentman/panorama/__init__.py | 6 +++ src/instrumentman/panorama/process.py | 57 ++++++++++++++++---------- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index 064b9c7..9325fd3 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -42,6 +42,12 @@ type=(IntRange(5, 95), IntRange(5, 95)), default=(30, 30) ) +@option( + "--prefix", + help="Image prefix before number", + type=str, + default="panorama_" +) def cli_measure(**kwargs: Any) -> None: """ Take pictures with the instrument camera for later panormaic stitching diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index 49cb7eb..ef11e3d 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -5,6 +5,8 @@ from PIL import Image, ImageDraw, ImageFont from geocompy.data import Coordinate, Angle +from ..utils import echo_yellow + def read_points( path: Path, @@ -122,39 +124,37 @@ def draw_marker_cross( def annotate_image( - imgpath: Path, + image: Image.Image, info: tuple[Angle, Angle, Coordinate, Coordinate], points: list[tuple[str, Coordinate]], markerdrawer: Callable[[ImageDraw.ImageDraw, float, float, str], None] ) -> None: - image = Image.open(imgpath) - draw = ImageDraw.Draw(image) fov_hz, fov_v, pos, vec = info half_width = image.width / 2 half_height = image.height / 2 - half_fov_hz = fov_hz / 2 - half_fov_v = fov_v / 2 + half_fov_hz = float(fov_hz / 2) + half_fov_v = float(fov_v / 2) - img_hz, img_v, _ = (vec - pos).to_polar() + img_hz, img_v, _ = vec.to_polar() + coef_hz = half_width / math.tan(half_fov_hz) + coef_v = half_height / math.tan(half_fov_v) + draw = ImageDraw.Draw(image) for pt, coord in points: pt_hz, pt_v, _ = (coord - pos).to_polar() - alpha = pt_hz.relative_to(img_hz) - beta = pt_v.relative_to(img_v) + alpha = float(pt_hz.relative_to(img_hz)) + beta = float(pt_v.relative_to(img_v)) - x = round(half_width + math.tan(alpha) * - half_width / math.tan(half_fov_hz)) - y = round(half_height + math.tan(beta) * - half_height / math.tan(half_fov_v)) + if ( + abs(alpha) > half_fov_hz + or abs(beta) > half_fov_v + ): + continue + x = (half_width + math.tan(alpha) * coef_hz) + y = (half_height + math.tan(beta) * coef_v) markerdrawer(draw, x, y, pt) - image.save( - imgpath.parent.joinpath( - imgpath.stem + "_annotated" + imgpath.suffix - ) - ) - def run_annotate( meta: dict[str, tuple[Angle, Angle, Coordinate, Coordinate]], @@ -163,7 +163,8 @@ def run_annotate( rgb: tuple[int, int, int] = (0, 0, 0), fontsize: int = 50, marker: str = "cross", - markersize: int = 50 + markersize: int = 50, + prefix: str = "annotated_" ) -> None: font = ImageFont.truetype("arial.ttf", fontsize) match marker: @@ -177,8 +178,14 @@ def run_annotate( if info is None: continue + try: + image = Image.open(path) + except Exception: + echo_yellow(f"Could not open '{path}'") + continue + annotate_image( - path, + image, info, points, lambda draw, x, y, text: markerdrawer( @@ -192,6 +199,10 @@ def run_annotate( ) ) + image.save( + path.with_stem(prefix + path.stem) + ) + def main( metadata: Path, @@ -203,7 +214,8 @@ def main( rgb: tuple[int, int, int] = (0, 0, 0), fontsize: int = 50, marker: str = "cross", - markersize: int = 50 + markersize: int = 50, + prefix: str = "annotated_" ) -> None: meta = read_metadata(metadata) match action: @@ -216,7 +228,8 @@ def main( rgb, fontsize, marker, - markersize + markersize, + prefix ) case _: raise ValueError(f"Unknown action '{action}'") From 2217e33f8680ad119280e5d75ca28ce46a92d49e Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Sun, 31 Aug 2025 21:37:52 +0200 Subject: [PATCH 08/42] [panorama] improved image position generator --- src/instrumentman/panorama/measure.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py index b5418bc..f950efd 100644 --- a/src/instrumentman/panorama/measure.py +++ b/src/instrumentman/panorama/measure.py @@ -29,19 +29,33 @@ def image_positions( cols: int, rows: int ) -> Generator[tuple[int, Angle, Angle], None, None]: - counter = 0 + if cols < 1 or rows < 1: + raise ValueError( + "Cannot generate positions for less than " + f"1 ({rows})row and/or 1 ({cols}) column" + ) + to_hz = from_hz + delta_hz + if rows == 1: + from_v = from_v + delta_v / 2 + if cols == 1: + from_hz = (from_hz + delta_hz / 2).normalized() + + colstep = (delta_hz / (cols - 1)) if cols > 1 else Angle(0) + rowstep = (delta_v / (rows - 1)) if rows > 1 else Angle(0) + + counter = 0 for i in range(rows): for j in range(cols): counter += 1 yield ( counter, ( - (from_hz + delta_hz * j / (cols - 1)) + (from_hz + colstep * j) if i % 2 == 0 - else (to_hz - delta_hz * j / (cols - 1)) + else (to_hz - colstep * j) ).normalized(), - (from_v + delta_v * i / (rows - 1)).normalized() + (from_v + rowstep * i).normalized() ) From 807d72eb23886acd2cffe8d70eb0898dcd84c8f5 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Sun, 31 Aug 2025 21:40:47 +0200 Subject: [PATCH 09/42] [panorama] added center coordinates to metadata writing --- src/instrumentman/panorama/measure.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py index f950efd..ba08a2e 100644 --- a/src/instrumentman/panorama/measure.py +++ b/src/instrumentman/panorama/measure.py @@ -132,6 +132,9 @@ def run_panorama( "image", "fov_hz_rad", "fov_v_rad", + "center_x_m", + "center_y_m", + "center_z_m", "pos_x_m", "pos_y_m", "pos_z_m", @@ -202,6 +205,9 @@ def run_panorama( f"{prefix}{idx:05d}.jpg", float(fov_hz), float(fov_v), + center.x, + center.y, + center.z, pos.x, pos.y, pos.z, From f2993c5389c0368a90829b9fae12fc1ffbd67fd9 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Sun, 31 Aug 2025 22:05:10 +0200 Subject: [PATCH 10/42] [panorama] added handling for single rows/columns --- src/instrumentman/panorama/measure.py | 29 ++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py index ba08a2e..64ad445 100644 --- a/src/instrumentman/panorama/measure.py +++ b/src/instrumentman/panorama/measure.py @@ -87,7 +87,23 @@ def run_panorama( from_hz, from_v = resp_start.params to_hz, to_v = resp_end.params + if to_v == from_v or to_hz == from_hz: + echo_red("Cannot capture panorama in a single row/column") + logger.critical("Cannot capture panorama in a single row/column") + exit(1) + elif ( + not (0 < from_v < math.pi) + or not (0 < to_v < math.pi) + ): + echo_red("Cannot capture panorama in face 2") + logger.critical("Cannot capture panorama in face 2") + exit(1) + 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) @@ -116,10 +132,17 @@ def run_panorama( reduced_fov_v = float(fov_hz) * (1 - overlap[1] / 100) delta_hz = (to_hz - from_hz).normalized() - delta_v = to_v.relative_to(from_v) + delta_v = to_v - from_v + + if abs(float(delta_hz)) < reduced_fov_hz: + cols = 1 + else: + cols = math.ceil(abs(float(delta_hz)) / reduced_fov_hz) + 1 - cols = math.ceil(abs(float(delta_hz)) / reduced_fov_hz) + 1 - rows = math.ceil(abs(float(delta_v)) / reduced_fov_v) + 1 + if abs(float(delta_v)) < reduced_fov_v: + rows = 1 + else: + rows = math.ceil(abs(float(delta_v)) / reduced_fov_v) + 1 if not confirm( f"Start capturing images in {rows} row(s) and {cols} column(s)", From 227774c53b149d86c91e4dc71b53a270e1ee2de6 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Mon, 1 Sep 2025 11:44:53 +0200 Subject: [PATCH 11/42] [panorama] changed metadata format to JSON --- src/instrumentman/panorama/measure.py | 50 ++++++++++---------------- src/instrumentman/panorama/metadata.py | 15 ++++++++ 2 files changed, 33 insertions(+), 32 deletions(-) create mode 100644 src/instrumentman/panorama/metadata.py diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py index 64ad445..600dba2 100644 --- a/src/instrumentman/panorama/measure.py +++ b/src/instrumentman/panorama/measure.py @@ -1,6 +1,7 @@ from typing import TextIO, Generator import math from logging import getLogger, Logger +import json from rich.console import Console from rich.progress import ( @@ -19,6 +20,7 @@ from geocompy.geo.gctypes import GeoComCode from ..utils import echo_red, echo_yellow +from .metadata import PanoramaMetadata, PanoramaFrameMetadata def image_positions( @@ -151,22 +153,7 @@ def run_panorama( echo_yellow("Program cancelled") exit() - print( - "image", - "fov_hz_rad", - "fov_v_rad", - "center_x_m", - "center_y_m", - "center_z_m", - "pos_x_m", - "pos_y_m", - "pos_z_m", - "dir_x_m", - "dir_y_m", - "dir_z_m", - sep=",", - file=file - ) + images: list[PanoramaFrameMetadata] = [] console = Console() progress = Progress( @@ -224,27 +211,26 @@ def run_panorama( pos = resp_cam_pos.params + center vec = resp_cam_dir.params - print( - f"{prefix}{idx:05d}.jpg", - float(fov_hz), - float(fov_v), - center.x, - center.y, - center.z, - pos.x, - pos.y, - pos.z, - vec.x, - vec.y, - vec.z, - sep=",", - file=file - ) + 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() + metadata: PanoramaMetadata = { + "grid": (cols, rows), + "fov": (float(fov_hz), float(fov_v)), + "center": (center.x, center.y, center.z), + "images": images + } + json.dump(metadata, file, indent=4) + def main( port: str, diff --git a/src/instrumentman/panorama/metadata.py b/src/instrumentman/panorama/metadata.py new file mode 100644 index 0000000..8aa8c3b --- /dev/null +++ b/src/instrumentman/panorama/metadata.py @@ -0,0 +1,15 @@ +from typing import TypedDict + + +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): + grid: tuple[int, int] # columns, rows + fov: tuple[float, float] # horizontal, vertical + center: tuple[float, float, float] + images: list[PanoramaFrameMetadata] From dbd4e39eb131146c7c74a356eb15459392ff05a8 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Mon, 1 Sep 2025 20:15:40 +0200 Subject: [PATCH 12/42] [code] added opencv optional dependency --- .github/workflows/run-tests.yml | 4 ++-- .readthedocs.yaml | 2 +- pyproject.toml | 6 ++++++ 3 files changed, 9 insertions(+), 3 deletions(-) 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/pyproject.toml b/pyproject.toml index 02998bc..8833ba6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,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" From 67065a97ed10e9092274ea6b3259d524ba638d2a Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Mon, 1 Sep 2025 20:16:32 +0200 Subject: [PATCH 13/42] [panorama] added new panorama processing Replaces the image-by-image annotation with stitching and optional annotation --- src/instrumentman/panorama/__init__.py | 127 ++++-- src/instrumentman/panorama/metadata.py | 11 +- src/instrumentman/panorama/process.py | 523 ++++++++++++++++--------- 3 files changed, 460 insertions(+), 201 deletions(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index 9325fd3..68a09f8 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -8,9 +8,10 @@ File, file_path, Choice, - IntRange + IntRange, + FloatRange ) -from cloup.constraints import constraint, If, Equal, Not, require_all +from cloup.constraints import mutually_exclusive from ..utils import ( com_port_argument, @@ -67,6 +68,11 @@ def cli_measure(**kwargs: Any) -> None: 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", @@ -74,16 +80,35 @@ def cli_measure(**kwargs: Any) -> None: nargs=-1, required=True ) -@option( - "--action", - help="Processing to perform", - type=Choice(("annotate",), case_sensitive=False), - default="annotate" +@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, min_open=True) + ), + option( + "--width", + help="Width of complete sphere panorama in pixels", + type=IntRange(0, min_open=True) + ), + option( + "--height", + help="Height of complete sphere panorama in pixels", + type=IntRange(0, min_open=True) + ), + constraint=mutually_exclusive ) @option_group( "Point list file options", option( - "--points", + "--annotate", help="Coordinate list of points to annotate on the images", type=file_path(exists=True) ), @@ -103,40 +128,98 @@ def cli_measure(**kwargs: Any) -> None: @option_group( "Annotation options", option( - "--rgb", + "--color", help="Color in RGB8 notation", type=(IntRange(0, 255), IntRange(0, 255), IntRange(0, 255)), default=(0, 0, 0) ), option( "--fontsize", - help="Text size in pixels", - type=IntRange(1), - default=50 + 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", "dot"), case_sensitive=False), + 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=50 + default=10 ), option( - "--prefix", - help="Prefix to add to the annotated files names", - type=str, - default="annotated_" + "--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-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" ) ) -@constraint( - If(Not(Equal("action", "stitch")), require_all), - ["action", "points"] -) def cli_calc(**kwargs: Any) -> None: from .process import main diff --git a/src/instrumentman/panorama/metadata.py b/src/instrumentman/panorama/metadata.py index 8aa8c3b..0764b3e 100644 --- a/src/instrumentman/panorama/metadata.py +++ b/src/instrumentman/panorama/metadata.py @@ -1,4 +1,6 @@ -from typing import TypedDict +from pathlib import Path +from typing import TypedDict, cast +import json class PanoramaFrameMetadata(TypedDict): @@ -13,3 +15,10 @@ class PanoramaMetadata(TypedDict): fov: tuple[float, float] # horizontal, vertical center: tuple[float, float, float] images: list[PanoramaFrameMetadata] + + +def read_metadata( + path: Path +) -> PanoramaMetadata: + with path.open("rt", encoding="utf8") as file: + return cast(PanoramaMetadata, json.load(file)) diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index ef11e3d..3d2324a 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -1,25 +1,80 @@ from pathlib import Path -import math -from typing import Callable +from typing import Sequence -from PIL import Image, ImageDraw, ImageFont from geocompy.data import Coordinate, Angle +import numpy as np +import numpy.typing as npt + +try: + import cv2 as cv + import cv2.typing as cvt +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 echo_yellow +from .metadata import read_metadata, PanoramaMetadata + + +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]]: - points: list[tuple[str, Coordinate]] = [] + 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: - pt, x, y, z = line.strip().split(delimiter) + 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, @@ -27,209 +82,321 @@ def read_points( float(x), float(y), float(z) - ) + ), + label ) ) return points -def read_metadata( - path: Path -) -> dict[str, tuple[Angle, Angle, Coordinate, Coordinate]]: - pictures: dict[str, tuple[Angle, Angle, Coordinate, Coordinate]] = {} - with path.open("rt", encoding="utf8") as file: - next(file) - for line in file: - ( - img, - fov_hz, - fov_v, - pos_x, - pos_y, - pos_z, - dir_x, - dir_y, - dir_z - ) = line.strip().split(",") - - pictures[img] = ( - Angle.parse(fov_hz), - Angle.parse(fov_v), - Coordinate( - float(pos_x), - float(pos_y), - float(pos_z) - ), - Coordinate( - float(dir_x), - float(dir_y), - float(dir_z) - ) - ) - - return pictures - - -def draw_marker_dot( - draw: ImageDraw.ImageDraw, - x: float, - y: float, +def text_pos( text: str, - font: ImageFont.FreeTypeFont, - markersize: float, - rgb: tuple[int, int, int] -) -> None: - draw.circle((x, y), markersize / 2, rgb) - draw.text( - ( - x + markersize / 3, - y + markersize / 3 - ), text, rgb, font, anchor="la" + point: tuple[float, float], + offset: tuple[float, float], + fontscale: float, + thickness: int, + justify: str +) -> tuple[int, int]: + (w, h), _ = cv.getTextSize( + text, + cv.FONT_HERSHEY_PLAIN, + fontscale, + thickness ) + x, y = point + ox, oy = offset -def draw_marker_cross( - draw: ImageDraw.ImageDraw, - x: float, - y: float, - text: str, - font: ImageFont.FreeTypeFont, - markersize: float, - rgb: tuple[int, int, int] -) -> None: - leg = markersize / 2 - draw.line( - ( - x - leg, y, - x + leg, y - ), - rgb, - round(markersize / 10) - ) - draw.line( - ( - x, y - leg, - x, y + leg - ), - rgb, - round(markersize / 10) - ) - draw.text( - ( - x + markersize / 4, - y + markersize / 4 - ), text, rgb, font, anchor="la" - ) - + match justify[0]: + case "t": + y += h + case "m": + y += h / 2 -def annotate_image( - image: Image.Image, - info: tuple[Angle, Angle, Coordinate, Coordinate], - points: list[tuple[str, Coordinate]], - markerdrawer: Callable[[ImageDraw.ImageDraw, float, float, str], None] -) -> None: - fov_hz, fov_v, pos, vec = info - half_width = image.width / 2 - half_height = image.height / 2 - half_fov_hz = float(fov_hz / 2) - half_fov_v = float(fov_v / 2) - - img_hz, img_v, _ = vec.to_polar() - coef_hz = half_width / math.tan(half_fov_hz) - coef_v = half_height / math.tan(half_fov_v) - - draw = ImageDraw.Draw(image) - for pt, coord in points: - pt_hz, pt_v, _ = (coord - pos).to_polar() - alpha = float(pt_hz.relative_to(img_hz)) - beta = float(pt_v.relative_to(img_v)) - - if ( - abs(alpha) > half_fov_hz - or abs(beta) > half_fov_v - ): - continue + match justify[1]: + case "c": + x -= w / 2 + case "r": + x -= w - x = (half_width + math.tan(alpha) * coef_hz) - y = (half_height + math.tan(beta) * coef_v) - markerdrawer(draw, x, y, pt) + return round(x + ox), round(y + oy) def run_annotate( - meta: dict[str, tuple[Angle, Angle, Coordinate, Coordinate]], - images: tuple[Path], - points: list[tuple[str, Coordinate]], - rgb: tuple[int, int, int] = (0, 0, 0), - fontsize: int = 50, - marker: str = "cross", - markersize: int = 50, - prefix: str = "annotated_" + meta: PanoramaMetadata, + output: Path, + images: dict[str, Path], + scale: float | None = None, + points: list[tuple[str, Coordinate, str]] = [], + color: tuple[int, int, int] = (0, 0, 0), + fontscale: float = 1, + thickness: int = 2, + marker: int = cv.MARKER_CROSS, + markersize: int = 10, + offset: tuple[int, int] = (10, -10), + justify: str = "bl", + 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: - font = ImageFont.truetype("arial.ttf", fontsize) - match marker: - case "cross": - markerdrawer = draw_marker_cross - case "dot": - markerdrawer = draw_marker_dot - - for path in images: - info = meta.get(path.stem + path.suffix) - if info is None: + corners: list[Sequence[int]] = [] + centers: list[tuple[int, int, Angle, Angle]] = [] + images_warped: list[cvt.MatLike] = [] + masks_warped: list[cvt.MatLike] = [] + + fov_w, fov_h = meta["fov"] + + for data in meta["images"]: + vec = Coordinate(*data["vector"]) + path = images.get(data["filename"]) + if path is None: + echo_yellow(f"Could not find '{data['filename']}'") continue - try: - image = Image.open(path) - except Exception: - echo_yellow(f"Could not open '{path}'") + img = cv.imread(str(path)) + if img is None: + echo_yellow(f"Could not load '{data['filename']}'") continue - annotate_image( - image, - info, - points, - lambda draw, x, y, text: markerdrawer( - draw, - x, - y, - text, - font, - markersize, - rgb + hz, v, _ = vec.to_polar() + height: int + width: int + height, width, _ = img.shape + f_w: float = width / 2 / np.tan(fov_w / 2) + f_h: float = height / 2 / np.tan(fov_h / 2) + if scale is None: + scale = (f_w + f_h) / 2 + + instrinsics: npt.NDArray[np.float32] = np.array( + ( + (f_w, 0.0, width/2), + (0.0, f_h, height/2), + (0.0, 0.0, 1.0) + ) + ).astype("float32") + rot: npt.NDArray[np.float32] = ( + rot_y(float(hz)) + @ rot_x(np.pi / 2 - float(v)) + ).astype("float32") + + warper = cv.PyRotationWarper("spherical", scale) + corner, image_warped = warper.warp( + img, + instrinsics, + rot, + cv.INTER_LINEAR, + cv.BORDER_REPLICATE + ) + + _, mask_warped = warper.warp( + np.full((height, width), 255, "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) - image.save( - path.with_stem(prefix + path.stem) + blender = cv.detail.Blender.createDefault(cv.detail.BLENDER_MULTI_BAND) + blender.prepare( + corners, + [(i.shape[1], i.shape[0]) for i in images_warped] + ) + for corner, img, msk in zip(corners, images_warped, masks_warped): + dilated_mask = cv.dilate(msk, None) # type: ignore[call-overload] + seam_mask = cv.resize( + dilated_mask, + (msk.shape[1], msk.shape[0]), + None, + 0, + 0, + cv.INTER_LINEAR_EXACT ) + msk_warped = cv.bitwise_and(seam_mask, msk) + blender.feed(img.astype("int16"), msk_warped, corner) + + result: cvt.MatLike + result, _ = blender.blend( + None, None + ) # type: ignore[call-overload] + + center = Coordinate(*meta["center"]) + origin_x, origin_y, _, _ = cv.detail.resultRoi( + corners, + [(i.shape[1], i.shape[0]) for i in images_warped] + ) + + # Top left image center point for reference + tl_x, tl_y, tl_hz, tl_v = centers[0] + tl_x -= origin_x + tl_y -= origin_y + + if scale is None: + scale = 1000 + + full_360 = round(scale * np.pi * 2) + cam_offset = Coordinate(0, 0, 0.06) + for pt, coord, label in points: + pt_hz, pt_v, _ = (coord - (center + cam_offset)).to_polar() + 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 + ) + + cv.putText( + result, + pt, + text_pos(pt, (pt_x, pt_y), offset, fontscale, thickness, justify), + cv.FONT_HERSHEY_PLAIN, + fontscale, + color, + bottomLeftOrigin=False + ) + if label == "": + continue + + cv.putText( + result, + label, + text_pos( + label, + (pt_x, pt_y), + label_offset, + label_fontscale, + label_thickness, + label_justify + ), + cv.FONT_HERSHEY_PLAIN, + label_fontscale, + label_color, + bottomLeftOrigin=False + ) + + # 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) + ) + + +_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 +} def main( metadata: Path, + output: Path, image: tuple[Path], - action: str = "annotate", - points: Path | None = None, + scale: float | None = None, + width: int | None = None, + height: int | None = None, + annotate: Path | None = None, skip: int = 0, delimiter: str = ",", - rgb: tuple[int, int, int] = (0, 0, 0), - fontsize: int = 50, + color: tuple[int, int, int] = (0, 0, 0), + fontsize: int = 10, + thickness: int = 1, marker: str = "cross", markersize: int = 50, - prefix: str = "annotated_" + offset: tuple[int, int] | None = (10, -10), + justify: str = "bl", + 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: meta = read_metadata(metadata) - match action: - case "annotate" if points is not None: - point = read_points(points, skip, delimiter) - run_annotate( - meta, - image, - point, - rgb, - fontsize, - marker, - markersize, - prefix - ) - case _: - raise ValueError(f"Unknown action '{action}'") + 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( + cv.FONT_HERSHEY_PLAIN, + fontsize, + thickness + ) + + if label_thickness is None: + label_thickness = thickness + + if label_fontsize is None: + label_fontsize = fontsize + + label_fontscale = cv.getFontScaleFromHeight( + cv.FONT_HERSHEY_PLAIN, + 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) + + run_annotate( + meta, + output, + image_map, + scale, + points, + color, + fontscale, + thickness, + _MARKER_MAP[marker], + markersize, + offset, + justify, + label_fontscale, + label_thickness, + label_color, + label_offset, + label_justify + ) From 3890f121bc5d10d69cc956220ebe1dddc581929a Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Mon, 1 Sep 2025 21:02:28 +0200 Subject: [PATCH 14/42] [panorama] added metadata schema --- src/instrumentman/panorama/metadata.py | 19 ++++- src/instrumentman/panorama/process.py | 14 +++- .../panorama/schema_metadata.json | 76 +++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 src/instrumentman/panorama/schema_metadata.json diff --git a/src/instrumentman/panorama/metadata.py b/src/instrumentman/panorama/metadata.py index 0764b3e..bc289e0 100644 --- a/src/instrumentman/panorama/metadata.py +++ b/src/instrumentman/panorama/metadata.py @@ -1,6 +1,9 @@ from pathlib import Path from typing import TypedDict, cast import json +import os + +from jsonschema import validate class PanoramaFrameMetadata(TypedDict): @@ -21,4 +24,18 @@ def read_metadata( path: Path ) -> PanoramaMetadata: with path.open("rt", encoding="utf8") as file: - return cast(PanoramaMetadata, json.load(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 index 3d2324a..726cbd9 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -1,6 +1,8 @@ from pathlib import Path from typing import Sequence +from json import JSONDecodeError +from jsonschema import ValidationError from geocompy.data import Coordinate, Angle import numpy as np import numpy.typing as npt @@ -22,7 +24,7 @@ ) exit(1) -from ..utils import echo_yellow +from ..utils import echo_yellow, echo_red from .metadata import read_metadata, PanoramaMetadata @@ -338,7 +340,15 @@ def main( label_offset: tuple[int, int] | None = (10, 10), label_justify: str = "bl" ) -> None: - meta = read_metadata(metadata) + try: + meta = read_metadata(metadata) + except (ValidationError, JSONDecodeError): + echo_red( + "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: diff --git a/src/instrumentman/panorama/schema_metadata.json b/src/instrumentman/panorama/schema_metadata.json new file mode 100644 index 0000000..9db4790 --- /dev/null +++ b/src/instrumentman/panorama/schema_metadata.json @@ -0,0 +1,76 @@ +{ + "title": "Instrumentman panorama metadata schema", + "description": "Additional data needed for panorama stitching and annotation", + "type": "object", + "additionalProperties": false, + "required": ["grid", "fov", "center", "images"], + "properties": { + "grid": { + "description": "Panorama frame grid columns and rows", + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "number", + "unevaluatedItems": false + } + }, + "fov": { + "description": "Horizontal and vertical field of view in radians", + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "number", + "unevaluatedItems": false + } + }, + "center": { + "description": "Instrument center position", + "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 From 068b4f12ca051a8461afd99e6c2415fc7738b481 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Mon, 1 Sep 2025 22:04:12 +0200 Subject: [PATCH 15/42] [panorama] added camera offset estimation --- src/instrumentman/panorama/__init__.py | 5 ++ src/instrumentman/panorama/process.py | 72 ++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index 68a09f8..206d7ee 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -80,6 +80,11 @@ def cli_measure(**kwargs: Any) -> None: nargs=-1, required=True ) +@option( + "--camera-offset", + help="Axis-aligned camera offset from the instrument center", + type=(float, float, float) +) @option_group( "Output size options", ( diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index 726cbd9..17ddca6 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -92,6 +92,28 @@ def read_points( 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], @@ -131,6 +153,7 @@ def run_annotate( images: dict[str, Path], scale: float | None = None, points: list[tuple[str, Coordinate, str]] = [], + camera_offset: Coordinate | None = None, color: tuple[int, int, int] = (0, 0, 0), fontscale: float = 1, thickness: int = 2, @@ -148,10 +171,13 @@ def run_annotate( centers: list[tuple[int, int, Angle, Angle]] = [] images_warped: list[cvt.MatLike] = [] masks_warped: list[cvt.MatLike] = [] + cam_offsets: list[Coordinate] = [] fov_w, fov_h = meta["fov"] + center = Coordinate(*meta["center"]) for data in meta["images"]: + pos = Coordinate(*data["position"]) vec = Coordinate(*data["vector"]) path = images.get(data["filename"]) if path is None: @@ -212,6 +238,19 @@ def run_annotate( images_warped.append(image_warped) masks_warped.append(mask_warped) + # The de-rotation of the camera offset is not completely accurate + # since the optical axis of the camera might not be parallel to the + # axis of the telescope (which results in some angle deviation), + # but it is good enough estimation in case the offset is not precisely + # known beforehand. + # + # The matrix use in the spherical warp cannot be reused here, because + # OpenCV uses a different axis orientation order. + offset_rot = rot_z(float(hz)) @ rot_x(np.pi / 2 - float(v)) + cam_offsets.append( + apply_rotation(pos - center, np.linalg.inv(offset_rot)) + ) + blender = cv.detail.Blender.createDefault(cv.detail.BLENDER_MULTI_BAND) blender.prepare( corners, @@ -235,13 +274,11 @@ def run_annotate( None, None ) # type: ignore[call-overload] - center = Coordinate(*meta["center"]) + # 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] ) - - # Top left image center point for reference tl_x, tl_y, tl_hz, tl_v = centers[0] tl_x -= origin_x tl_y -= origin_y @@ -250,9 +287,23 @@ def run_annotate( scale = 1000 full_360 = round(scale * np.pi * 2) - cam_offset = Coordinate(0, 0, 0.06) + + if camera_offset is None: + camera_offset = mean_coordinate(cam_offsets) + for pt, coord, label in points: - pt_hz, pt_v, _ = (coord - (center + cam_offset)).to_polar() + # 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)) @ rot_x(np.pi / 2 - float(prelim_v)) + ) + pt_hz, pt_v, _ = ( + coord + - (center + apply_rotation(camera_offset, offset_rot)) + ).to_polar() + 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 @@ -321,6 +372,7 @@ def main( metadata: Path, output: Path, image: tuple[Path], + camera_offset: tuple[float, float, float] | None = None, scale: float | None = None, width: int | None = None, height: int | None = None, @@ -361,6 +413,15 @@ def main( elif height is not None: scale = height / np.pi + if camera_offset is not None: + cam_offset = Coordinate( + camera_offset[0], + camera_offset[1], + camera_offset[2] + ) + else: + cam_offset = None + color = (color[2], color[1], color[0]) if label_color is None: label_color = color @@ -397,6 +458,7 @@ def main( image_map, scale, points, + cam_offset, color, fontscale, thickness, From b32f275d42e49d608fcb2631dc04dd8361dc9d45 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Mon, 1 Sep 2025 22:51:59 +0200 Subject: [PATCH 16/42] [panorama] added progress reporting to processing --- src/instrumentman/panorama/process.py | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index 17ddca6..9400133 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -2,6 +2,14 @@ from typing import Sequence from json import JSONDecodeError +from rich.console import Console +from rich.progress import ( + Progress, + TextColumn, + BarColumn, + MofNCompleteColumn, + TimeRemainingColumn +) from jsonschema import ValidationError from geocompy.data import Coordinate, Angle import numpy as np @@ -175,6 +183,19 @@ def run_annotate( fov_w, fov_h = meta["fov"] center = Coordinate(*meta["center"]) + console = Console() + progress = Progress( + TextColumn("[progress.description]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + TimeRemainingColumn(), + console=console + ) + progress.start() + task_images = progress.add_task( + "Preprocessing images", + total=len(meta["images"]) + ) for data in meta["images"]: pos = Coordinate(*data["position"]) @@ -182,11 +203,13 @@ def run_annotate( path = images.get(data["filename"]) if path is None: echo_yellow(f"Could not find '{data['filename']}'") + progress.update(task_images, advance=1) continue img = cv.imread(str(path)) if img is None: echo_yellow(f"Could not load '{data['filename']}'") + progress.update(task_images, advance=1) continue hz, v, _ = vec.to_polar() @@ -251,6 +274,11 @@ def run_annotate( apply_rotation(pos - center, np.linalg.inv(offset_rot)) ) + progress.update(task_images, advance=1) + + progress.stop() + console.print("Merging images... ", end="") + blender = cv.detail.Blender.createDefault(cv.detail.BLENDER_MULTI_BAND) blender.prepare( corners, @@ -274,6 +302,9 @@ def run_annotate( None, None ) # type: ignore[call-overload] + console.print("Done") + console.print("Annotating points... ", end="") + # Top left image center point for reference origin_x, origin_y, _, _ = cv.detail.resultRoi( corners, @@ -346,6 +377,8 @@ def run_annotate( bottomLeftOrigin=False ) + console.print("Done") + console.print("Saving final image... ", end="") # 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 @@ -356,6 +389,9 @@ def run_annotate( result.astype(np.uint8) ) + console.print("Done") + console.print("Panorama complete", style="green") + _MARKER_MAP = { "cross": cv.MARKER_CROSS, From 75ea9279908d5e6cd023dc2d912dcb462f439888 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Mon, 1 Sep 2025 23:50:37 +0200 Subject: [PATCH 17/42] :rage: :gun: --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8833ba6..3a8fa5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ Changelog = "https://instrumentman.readthedocs.io/latest/changelog" iman = "instrumentman:cli" [project.optional-dependencies] -all = ["instrumentman[panorama"] +all = ["instrumentman[panorama]"] panorama = [ "opencv-python~=4.8" ] From ba03c3fbcd6abcd2c9bdb19d221bc361c49ca6e5 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Tue, 2 Sep 2025 00:47:01 +0200 Subject: [PATCH 18/42] [panorama] added font and thickness options to annotation --- src/instrumentman/panorama/__init__.py | 50 ++++++++++++++++++++------ src/instrumentman/panorama/process.py | 43 ++++++++++++++++++---- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index 206d7ee..b3ec569 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -138,18 +138,32 @@ def cli_measure(**kwargs: Any) -> None: 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( + "--thickness", + help="Font line thickness", + type=IntRange(0, min_open=True), + default=1 + ), option( "--marker", help="Point marker shape", @@ -191,16 +205,30 @@ def cli_measure(**kwargs: Any) -> None: ), 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_thickness", + help="Label text line thickness", + type=IntRange(0, min_open=True) + ), option( "--label-color", help="Color in RGB8 notation", diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index 9400133..88a7b23 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -126,13 +126,14 @@ 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, - cv.FONT_HERSHEY_PLAIN, + font, fontscale, thickness ) @@ -163,12 +164,14 @@ def run_annotate( points: list[tuple[str, Coordinate, str]] = [], camera_offset: Coordinate | None = None, 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), @@ -345,16 +348,26 @@ def run_annotate( (pt_x, pt_y), color, marker, - markersize + markersize, + thickness ) cv.putText( result, pt, - text_pos(pt, (pt_x, pt_y), offset, fontscale, thickness, justify), - cv.FONT_HERSHEY_PLAIN, + text_pos( + pt, + (pt_x, pt_y), + offset, + font, + fontscale, + thickness, + justify + ), + font, fontscale, color, + thickness, bottomLeftOrigin=False ) if label == "": @@ -367,13 +380,15 @@ def run_annotate( label, (pt_x, pt_y), label_offset, + label_font, label_fontscale, label_thickness, label_justify ), - cv.FONT_HERSHEY_PLAIN, + label_font, label_fontscale, label_color, + label_thickness, bottomLeftOrigin=False ) @@ -403,6 +418,13 @@ def run_annotate( "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 +} + def main( metadata: Path, @@ -416,12 +438,14 @@ def main( 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, @@ -465,7 +489,7 @@ def main( label_color = (label_color[2], label_color[1], label_color[0]) fontscale = cv.getFontScaleFromHeight( - cv.FONT_HERSHEY_PLAIN, + _FONT_MAP[font], fontsize, thickness ) @@ -476,8 +500,11 @@ def main( if label_fontsize is None: label_fontsize = fontsize + if label_font is None: + label_font = font + label_fontscale = cv.getFontScaleFromHeight( - cv.FONT_HERSHEY_PLAIN, + _FONT_MAP[label_font], label_fontsize, label_thickness ) @@ -496,12 +523,14 @@ def main( points, cam_offset, color, + _FONT_MAP[font], fontscale, thickness, _MARKER_MAP[marker], markersize, offset, justify, + _FONT_MAP[label_font], label_fontscale, label_thickness, label_color, From 1dca38604cd9df6d8eb7fa1c1e95a971c02c3028 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Sun, 7 Sep 2025 22:21:26 +0200 Subject: [PATCH 19/42] [panorama] added docstring to processing --- src/instrumentman/panorama/__init__.py | 28 +++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index b3ec569..f3deaa5 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -114,7 +114,7 @@ def cli_measure(**kwargs: Any) -> None: "Point list file options", option( "--annotate", - help="Coordinate list of points to annotate on the images", + help="CSV coordinate list of points to annotate on the images", type=file_path(exists=True) ), option( @@ -254,6 +254,32 @@ def cli_measure(**kwargs: Any) -> None: ) ) def cli_calc(**kwargs: Any) -> None: + """ + Merge previously captured panorama frames and optionally annotate measured + points on the resulting panorama for documentation purposes. + + 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 the OpenCV provided by the 'opencv-python' package, 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, 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 scale should be + set to maximum 5215 (32767 / (2 * pi)). + """ from .process import main main(**kwargs) From 92d119d3f050e092198a8079f4bfaad9d9e092b9 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Sun, 7 Sep 2025 22:23:10 +0200 Subject: [PATCH 20/42] [panorama] overhauled measurement --- src/instrumentman/panorama/__init__.py | 107 +++++- src/instrumentman/panorama/measure.py | 324 +++++++++++++----- src/instrumentman/panorama/metadata.py | 1 - .../panorama/schema_metadata.json | 12 +- 4 files changed, 335 insertions(+), 109 deletions(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index f3deaa5..57a8169 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -11,11 +11,19 @@ IntRange, FloatRange ) -from cloup.constraints import mutually_exclusive +from cloup.constraints import ( + constraint, + mutually_exclusive, + accept_none, + require_all, + If, + Equal +) from ..utils import ( com_port_argument, - com_option_group + com_option_group, + Angle ) @@ -37,21 +45,102 @@ type=Choice(("x1", "x2", "x4", "x8"), case_sensitive=False), default="x1" ) -@option( - "--overlap", - help="Minimum horizontal and vertical overlap between images (percentage)", - type=(IntRange(5, 95), IntRange(5, 95)), - default=(30, 30) -) @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( + "--shape", + help="Panorama area type", + type=Choice( + ( + "region", + "strip", + "sphere" + ), + case_sensitive=False + ), + default="region" +) +@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 instrument camera for later panormaic stitching + 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 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. """ from .measure import main diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py index 600dba2..7c54307 100644 --- a/src/instrumentman/panorama/measure.py +++ b/src/instrumentman/panorama/measure.py @@ -1,4 +1,4 @@ -from typing import TextIO, Generator +from typing import TextIO import math from logging import getLogger, Logger import json @@ -23,54 +23,111 @@ from .metadata import PanoramaMetadata, PanoramaFrameMetadata -def image_positions( +def image_positions_optimal( from_hz: Angle, from_v: Angle, - delta_hz: Angle, - delta_v: Angle, - cols: int, - rows: int -) -> Generator[tuple[int, Angle, Angle], None, None]: - if cols < 1 or rows < 1: - raise ValueError( - "Cannot generate positions for less than " - f"1 ({rows})row and/or 1 ({cols}) column" - ) - - to_hz = from_hz + delta_hz - if rows == 1: - from_v = from_v + delta_v / 2 - if cols == 1: - from_hz = (from_hz + delta_hz / 2).normalized() - - colstep = (delta_hz / (cols - 1)) if cols > 1 else Angle(0) - rowstep = (delta_v / (rows - 1)) if rows > 1 else Angle(0) - - counter = 0 - for i in range(rows): - for j in range(cols): - counter += 1 - yield ( - counter, - ( - (from_hz + colstep * j) - if i % 2 == 0 - else (to_hz - colstep * j) - ).normalized(), - (from_v + rowstep * i).normalized() + to_hz: Angle, + to_v: Angle, + fov_hz: Angle, + fov_v: Angle, + overlap_hz: int, + overlap_v: int +) -> 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) + + 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 + + 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)) + row_delta_hz = delta_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)) ) + cols = math.ceil(float(row_delta_hz) / float(row_redfov_hz)) -def run_panorama( + 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, - file: TextIO, - zoom: Zoom, - overlap: tuple[int, int], - prefix: str, + horizontal: tuple[Angle, Angle] | None, + vertical: tuple[Angle, Angle] | None, logger: Logger -) -> None: +) -> 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 + pause( - "Aim the instrument at the left starting corner, then press any key..." + "Aim the instrument at the left starting corner, " + "then press any key..." ) resp_start = tps.tmc.get_angle() if resp_start.error != GeoComCode.OK or resp_start.params is None: @@ -79,7 +136,8 @@ def run_panorama( exit(1) pause( - "Aim the instrument at the right finish corner, then press any key..." + "Aim the instrument at the right finish corner, " + "then press any key..." ) resp_end = tps.tmc.get_angle() if resp_end.error != GeoComCode.OK or resp_end.params is None: @@ -89,18 +147,81 @@ def run_panorama( from_hz, from_v = resp_start.params to_hz, to_v = resp_end.params - if to_v == from_v or to_hz == from_hz: - echo_red("Cannot capture panorama in a single row/column") - logger.critical("Cannot capture panorama in a single row/column") + + 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 + + pause( + "Aim the instrument at the top of the strip, " + "then press any key..." + ) + resp_start = tps.tmc.get_angle() + if resp_start.error != GeoComCode.OK or resp_start.params is None: + echo_red("Could not retrieve strip top angles") + logger.critical("Could not retrieve strip top angles") exit(1) - elif ( - not (0 < from_v < math.pi) - or not (0 < to_v < math.pi) - ): - echo_red("Cannot capture panorama in face 2") - logger.critical("Cannot capture panorama in face 2") + + pause( + "Aim the instrument at the bottom of the strip, " + "then press any key..." + ) + resp_end = tps.tmc.get_angle() + if resp_end.error != GeoComCode.OK or resp_end.params is None: + echo_red("Could not retrieve strip bottom angles") + logger.critical("Could not retrieve strip bottom 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_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, + 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 @@ -110,8 +231,8 @@ def run_panorama( resp_zoom = tps.cam.set_zoom(zoom) if resp_zoom.error != GeoComCode.OK: - echo_red("Could set retrieve camera zoom factor") - logger.critical("Could set retrieve camera zoom factor") + echo_red("Could set camera zoom factor") + logger.critical("Could set camera zoom factor") exit(1) resp_fov = tps.cam.get_camera_fov(zoom=zoom) @@ -130,24 +251,20 @@ def run_panorama( center = station + Coordinate(0, 0, hi) fov_hz, fov_v = resp_fov.params - reduced_fov_hz = float(fov_hz) * (1 - overlap[0] / 100) - reduced_fov_v = float(fov_hz) * (1 - overlap[1] / 100) - - delta_hz = (to_hz - from_hz).normalized() - delta_v = to_v - from_v - if abs(float(delta_hz)) < reduced_fov_hz: - cols = 1 - else: - cols = math.ceil(abs(float(delta_hz)) / reduced_fov_hz) + 1 - - if abs(float(delta_v)) < reduced_fov_v: - rows = 1 - else: - rows = math.ceil(abs(float(delta_v)) / reduced_fov_v) + 1 + positions = image_positions_optimal( + from_hz, + from_v, + to_hz, + to_v, + fov_hz, + fov_v, + overlap[0], + overlap[1] + ) if not confirm( - f"Start capturing images in {rows} row(s) and {cols} column(s)", + f"Start capturing panorama in {len(positions)} frame(s)", True ): echo_yellow("Program cancelled") @@ -167,17 +284,10 @@ def run_panorama( progress.start() task = progress.add_task( "Capturing panorama", - total=rows*cols + total=len(positions) ) - for idx, hz, v in image_positions( - from_hz, - from_v, - delta_hz, - delta_v, - cols, - rows - ): + for idx, (hz, v) in enumerate(positions): resp_turn = tps.aut.turn_to(hz, v) if resp_turn.error != GeoComCode.OK: echo_yellow("Could not turn to position") @@ -223,8 +333,9 @@ def run_panorama( progress.stop() + tps.aut.turn_to(0, math.pi) + metadata: PanoramaMetadata = { - "grid": (cols, rows), "fov": (float(fov_hz), float(fov_v)), "center": (center.x, center.y, center.z), "images": images @@ -240,8 +351,13 @@ def main( retry: int = 1, sync_after_timeout: bool = False, zoom: str = "x1", - overlap: tuple[int, int] = (30, 30), - prefix: str = "panorama_" + overlap: tuple[int, int] = (5, 10), + prefix: str = "panorama_", + whitebalance: str | None = None, + increase_tolerance: bool = False, + shape: str = "region", + horizontal: tuple[str, str] | None = None, + vertical: tuple[str, str] | None = None ) -> None: logger = getLogger("iman.panorama.measure") with open_serial( @@ -253,11 +369,43 @@ def main( logger=logger.getChild("com") ) as com: tps = GeoCom(com, logger.getChild("instrument")) - run_panorama( - tps, - metadata, - Zoom[zoom.upper()], - overlap, - prefix, - logger - ) + 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, + ( + 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 index bc289e0..e7b4a95 100644 --- a/src/instrumentman/panorama/metadata.py +++ b/src/instrumentman/panorama/metadata.py @@ -14,7 +14,6 @@ class PanoramaFrameMetadata(TypedDict): class PanoramaMetadata(TypedDict): - grid: tuple[int, int] # columns, rows fov: tuple[float, float] # horizontal, vertical center: tuple[float, float, float] images: list[PanoramaFrameMetadata] diff --git a/src/instrumentman/panorama/schema_metadata.json b/src/instrumentman/panorama/schema_metadata.json index 9db4790..39013ab 100644 --- a/src/instrumentman/panorama/schema_metadata.json +++ b/src/instrumentman/panorama/schema_metadata.json @@ -3,18 +3,8 @@ "description": "Additional data needed for panorama stitching and annotation", "type": "object", "additionalProperties": false, - "required": ["grid", "fov", "center", "images"], + "required": ["fov", "center", "images"], "properties": { - "grid": { - "description": "Panorama frame grid columns and rows", - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "number", - "unevaluatedItems": false - } - }, "fov": { "description": "Horizontal and vertical field of view in radians", "type": "array", From e88a4ae51f418b76216591863d98e6a6b6d4b495 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Mon, 8 Sep 2025 00:30:07 +0200 Subject: [PATCH 21/42] [panorama] added docs --- docs/commands/panorama/image_positions.png | Bin 0 -> 107697 bytes docs/commands/panorama/index.rst | 18 ++++++ docs/commands/panorama/measure.rst | 61 +++++++++++++++++++++ docs/commands/panorama/process.rst | 58 ++++++++++++++++++++ docs/index.rst | 1 + docs/latexindex.rst | 1 + src/instrumentman/__init__.py | 2 +- 7 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 docs/commands/panorama/image_positions.png create mode 100644 docs/commands/panorama/index.rst create mode 100644 docs/commands/panorama/measure.rst create mode 100644 docs/commands/panorama/process.rst diff --git a/docs/commands/panorama/image_positions.png b/docs/commands/panorama/image_positions.png new file mode 100644 index 0000000000000000000000000000000000000000..ffa17f87826c6f713559fa7ce4bc60c7d4f5acdc GIT binary patch literal 107697 zcmeFZbyQUU*FQRlfC2&roeCDIgh+#hilQjpD&5jOC@K~q3P>Z}-Ho6~4&5n^Lk&X+ zLv#1hPkf)d?sI?Z{&Cm3>vx}7OJQI*Gv|Hwd++^LWx454&AtZwjJ$XlrjOl#%(o9*%O*?z)x(3Pt+>`A3o}mtu}0L7{G3QBZY_ znjbwF<&WLlTfzioT^ekPxN_oVpQnzsK{;;5w-4BGdWs;l=z#zh{zV0MVbR~uj&rxGP_OvIuQqSt6YPW#@aPj|n;`~Us8 zA!JZV`}Y(0I&o-U?Y~baj_&t?kM-B1_VfW1>EBOKK6mI)$g=$Ph~+|2|NT7*C98m9 z`uCaVQ`EVCpRMmFG5q)0;=%uU!@pPKf0p!rg66MS_@8a@zfY!_cw`#mmh6FT=+!W= zR?AQ?x*}%U&Su${8P5K=;c#eh@WT}xrsPVIDTbqBrCloPUee7_Ap_aDj!&1fcXz#r zqqr`n8^#I3uTSnoGV0nX+}gX1M2Y;^p9DPrUhmTdHV_PLQ=C>a? z?tz!}-@TA;P(ITeKi=P2qe9VgNDs8lXByOazrg5wE?pfiv5h?$k8KGX=0tB#2IP4V z7ec3H_qH9RetvwUo6GY1k2N2X|IYgBnsrafM)^)t9y??BoRU8ON}~1T#TjM z?)sGXUZ_cEv1NbzPPVXdb1BvxPJcEuY4`gZAz@FKzD%vY`RolIwH5DR(7RL1_Z2j-br`w5C~Qpm z8%Ckx7NMGv0NG75%dtwg;yn|a**iSgs@>?B?v&v>ayuVl8k@K5*uUwfd`6$vrEwE; zp4ZfHdCed&8=zzEI-8cVrep8*^)0jW&T79-Q}D@AV!7kY!#%hzp*1UWIW@)C{8_Xn zKR?G0JPIu8*Y~ilAy^F+HicN1X0y4BJYM_89cc7XjYBzOwqtR!KFfV|cAmMIVi50bjDw0uC6ew+ozH8fo6wQk};?3 zC(Cf|gUg{#KOwT6#x+K}+;SXDhl{PZ$nrQ{C!YNJmFhhwJznNu8tTD2w;5kJ!=&iX zf|X^JaWAY$o{8!pr0P`^`!k(?SX1}{?Ki8k?vnbpesy+-IPN0HXPr(Vx4l?@Jc$$H zS$_H9K{F(-4_Xq&ssyKY36CoTO>aLSfuZ|5BS?a}IB{@zO$*Y{B-Z8!6vi`O?_%8pj~L^vCJEEl&0hC zw?spH(DBP7d3-)8@&!*NCc~51rJNlVzK&ISRKx{&kEg_X&p!PbPFiOF`QYgRI6YfZ z3Yx;X?22}>+eumlCR!XBPb(?!@(E>Et!bAS74Fl3E9FOZ%~XF(Q{fU>tWD+8HAYWQ zIOoyxv#!UCKCs%9){S`)^)vz~?aodo)};90&!$8LV;`Tno;I;pPb+5yJEx!}EZ?}L z93#6mFQRdZ(&VgR%HRi#^vc_4=OJ^!)F=r~RZfeZ)JboRpIpv~QGKzkH^T(+Yp2yE z_O=K$+1sDpVkOS7AL89>5v*u!m%yw?lgqM_rh^1f!)~oSo#2($$t-2C#Ea=`t>CL=K6z-HK z-=R4~qcTxpJzTt?omyM48#B%1xV*sH0;)e3SaU=H_^6@0qmcuJcuUiF6x<@ zhand4PVa6J?w|=vjhrrMm(R}*&Je7}t7TL()CGvUzh=5eJs^Kk^`@$D_mn$X689d! zHlu=SCeQ=dm^4|XMzMzQ@ln7*>#<6SogG;1;i|o!`*+G>UGPKny!VrDr7A~DE?oR9 zyS;dsSoMUIA{qDGo8yAj0Lk8{Nc+Y5s?Q%9H$9qHT0h{_(p7KlX}QG{WY;HrnXCKL z?w{5mui4$S-1OMghhid9Nt_ClD<{Uf{S5y_e9b7tG25G=ai-++2C-d=VB}iky|XD; z1BVWp9B+d?l&TaaI73LYnI;%G4;o8s6799o`3cf;OASX0Ze1=_zPdZsYJ5a?>^d)N zNavFg`xb#uP&J;f%?~K<|X??n1#B*(w=xw|wDnp#b?>pWFsPdd@zERV! zSR5vnP~8t#j(WUeXKi?ND;B-G;k_XNMPVo4c*a!M>QBhKZHxNrE}mQXk{QD7Xh|`d z9rX5+;^tPuHUFWUcC6?Q5;(z(O7g$s$SgvOF?sNHgg{F8lZal1Wa6)IlcMM5>JMDM zMw%S4Oil}3?see~W9>WgCE{_amdgWe1~rkD6Xq`#gle|Gn~cu5>_A3YNMde?uuj7| za)*_-1(@(!I$M1-p|+U$87Q|qup{lVpez1fLuB2N?Ex+pT@7imMRs$Vn^<5%Woc{B z*4Jt?I9Azp9*c<`Z!7mtWDa<}p&TPEQNI<@lzGywURiR6r=-x%s4O)~RLXvJw%1Ko zwvAt{%_szaH7`qt|+D8-1>Yk>2X3x?4N>{7hPRzHHC1UmZxFVCq7kkyqRZeq# z3nML+``y(=bu@7fA5F00HX|1=M|>8+UoCjHN)R>gYS@WV8#KulxtS7Rx$?7Z0k=q4 z7%m}r<6b?(44!K{r5+*tMmoE&P}D^?LM_X)4-?*Ug^%OeS^xOK_6V=&jh%1#&DNU; z%;w?zgN2WT?u&b;aH5kVYG~!cig1#;blV8WpuUi=#9PX-K@pxDoKumLR73bNG29>H{XZG zsnMw`4ZjF$>Z`8R@2p8m3g9N~o5a554iw`TUOblUV`x-1%~W(cW9^liopKAUB6*6v zhvy(RB;Y(-T;MVN6WfNr7nz)f4Z_Ms%PRWx(6HEwZ0lg}RkBhsrsMXsGf|-#?X){m z;g~~YK39ENXjEymyEJ_%$*Gxg#|a(wb6PtAQHeJ`aK1CYm#fwCV6IZ-oa)}Sb=Q2} z;no?Ap_5VGy_XL$eDiGUSZ=2LW-5CpKf_l$S!aWOtVO#q2^&=)ncdo$I2Suz**yE7 z3%K^1lL*Qc-4YciC@##T%jnbJx`Lcw7ta)mw-dYn4}CbK3CyHe1BIe1K(TqP;g zyr*4ANLfqo9jm;Vv$oxHtNHyM*};bbr?K(OF%b%BUMg}MKaPgQQW{wj49Nr%1nk$Q zp1A6vc`B;Z#4h*o4MnOK#H(?NUX&NYmt=(Fh7vAlW7DNOGv}lTl_D(!-UA+S_6Kk? z_>%D$?7U_Zo1u(qy6;e|ria#?Zl7BQy5@G|1+7qPX=#XpOzlPyrgwKz_aQ9swN74Z?G|Kxsh% zv5YY9l6{h}XG{H=MNYM#(n^jeE=gbEa-t7Rl(o<(1Rhv@hbvf8nJ`MFp zI*@$lc|&AU7}L=$n-oqw+-r5YDy3HGNA*Xc>h*^&2x4X(wPa-+9`olJr<<*IHdiGi zv!QowBU9xj^`|U4F4uAF1|Wt$B)~zl{YB4%A5YWBn0dzLFP9MyixE= zR7A62o#vX-L2=u$ksYr#I@O7*1;QS`rr*hu-cvd;w}MoD_ONOq(kW=YMXKIr<~0H4 zj9MN1P!T@2p2ndCdb2Qnck|-C>Zc7u2C5_`WN+x{Y(`2Shi(O1kf)iv9CN_P*wFR@ zkSMF2ZFqHb^xn5gtxk!g+LE;-d{5f@Ej?{_b&Tt-7CJt!|(U`7y_z?I?M=bc8m z=`=rZUWSf8;!~>OhqY&F9hwRe_Soh!RQl5k5;ECjR1Bq6?(}qODCH+5(B@0;NKwYk z;67dU(}u1~H)!oMm5}3;2V}NaJ)ZGi%*5(KZ{x&>>f8N#oOH zmKh4zZb6#&E`|5+{3hBaYtU{ zE&Za#IXx0pWV0R+{!{blT(vJ;3|{N&)lU?&=vm}hr$T;$8#1w(|04-qkSc|2FL|#ixkI@`G~C; zjg~tt$%_8RD@{OKM*blJmtCeTlhVHf4g`m%;Q*8BuxVjR2QYn91>hcTfygFZs#_0op zDS=@b3PxrQPeyK+Zg&{>K>Szo2!@E8*e>s%27}L(fL^B!rveGNZp#A z$GjY^a<4^b25aInXWH(V&`d4K4KpIz6< zR(+Mq&iD6WnjQ{k%Y^21OfKvV${tqu@);-3c8p)MgGJQLusiv7OKwVzx6M$Yxo#kG zssB8h-Z0n7N0~|BATqWb` zEMTu@`*Umox8(u8`?~eJg#JEqP$I}cj~%;jiRQ-gy0AlsWF zEGiIxI#XZ$tpTPj$?~A&1=~KJuOBM5wxs7(&HeD&A4`#Izkmd!)>APEizO5HV%ABFo`ppyq zW%GItIiS$VW0c18Pe_7}0mWzavCwVq^9N0KV|LbVGv6gDr)h+W({k;iQ8DlI>=$7Bt&Qw z51}r8n3F)&#`Zfn6~6E#NBR7yj3JCx^nVRtpHp|}xYq$cxdiHA(7D3-plk6aMWR>N zWxT4LN8O2B_lm6ssmD7pz@5Kp%G-xp_Ljj^vWcR&Hga&<;VE}wWr0{q(TbLIdgz>q zLfK~Hs2Bwc5!x6wmXY{~ov+TP6xeX*WgnvC^$!D9;CVa9cVR*Nh}g>ISF(GP81zGT zUEsZSF{?5tpPFpk5S1&OflVK*&_*^+%(AZ#Q2j7K^-O5O`haZGsdRmOE07Tt}4{*@?iT2v5Q|4O7yc@x5h zM4o^2LM>DCe6u12>gCtfO2fKf=aI*aWwDSr4IO^A=0WS(45TVOHeo-?^%eAJKF?H= z%_iBr2p^Ap8M*we(7aoiUo0hdFpL}(jB}6aCXk@MbEovrbSW~}*BJ;L-2@f_Sn1w) zZ!46~Kq01gJl_G=euj-6#TD_9gc^07$v-_Lclis|a6}=y)`HUehbGw9jLze8!zDR7 zFQ%b6D}whQ1iBW9b4Va17uh6G6L)iPd%IgXJJ1o%#AZR8A|Rmw@d8E0{#GC>%Fe8R zrJs}2(6(wd^mf$6PN0x2;L1IYWfamMd425ckrl|xY(uM_pWoD55#4ZQ&g+nx=i9`` z3!y3hoU9zp;+H^<`a$+PzPQoV$5?xcoj2Oel7jMchWW zHINx)H{HKd)1Ol!s8?R<<%0pbv1Gg8X^ntR@!Jj9hs&pseJD0fA^&u^(yf5?c67P7 zZwQ~-i7`J?SBvI&RaR;9yrkqE+dvk7)!zE+um`8yVY#EG+-Xp|wX1BX?I&{*PmxTn zo-yvKBT-qvnh4xmo-5}OI*0u}Pk64Pn7m6|48ED<44BOo&)a!)n3bLI*Ca#nFlOUW z6STS*l3LEv814g*f9H{_cGrE~OuNdcl9m(|r1;lwNqyw(WGH+L4QUPkNzSPM52%Sx z&U}P0FR~u)1Fq5V?Afyy9(;aA9qV`LI8tsxgbchwI_k)KC%J16{K8^OBvcKAaMN_Sr@fzS}l9FoFvvrwQ z|2-*rdqDmTny{U9<6NNVGlx3Fh*^e;Sr3U0*JwZw%m-2R;@{Ft_e#d+$(eQNm++vH z+}?A8v$}xZ_2g5Df2NkLJGl94<`J#zdK^%@^<>W|sF@6r0!qU!*|1vr&w>PvK+vh2 z0~QaV8oJQHaOG?jSoUYHHS^^RM7xZcLrYPF2nrXhhm4@xD>-{VSrb&CDc7FAPZM?B z`Ru2=XO0nkQ9&Ns7s_3izK8z1hyHq%{D}2%QKQ8wt1X|OOEIu!#rLmpqkLvr>EwAMvs4!Ez-USLLC#=W-mS}c3h>EVd+7SkP{ zva{#Pj-i&n=SuHbgA7}>b?Jh~muosD#(p7ZwFg+#_#SMmBqgG&o#xa*o1%f6TxV>uB(F7<4td@SEssbc;6n<>`@pOV#WAV>1Lm8II+CFl4f zrQPyF1oegvRZ9bhKIJvwlY)0B(g0DY;8!fI)Y}EC^_&>Lv_m2mJ!hn565oHfk3wxf zmpX-FYxr9@>jX(b2$eG;&lme%mZ^uIm!&0EceY! z-y82wmH7;U;>gsPKv9{78~5JIs!&Fl^%;?qC)AO)6p^ ze`S7rQJYJ0RhQHq$TlNbc;!7^-*Y52GJd=hIv@{U+QS-iBBcs>!Q$%xyt=3f9SB#c8qL4(I>9ofed|B#G88tw`eLK-@j5Hc(DuA&e^rg zdR6X4ROEbSaSGIFY1Hda;Xfaup`Qm?rF1jby?&2HQ#M^I)e?!eQL9tQyU zC)-c7|_j`0{AbDcWmvC<~4cl!N4vOt+NCKA&1;?ZS@ zo-*cz2pRJAG-y^!Fv=3xV;<|1exrslNX~QrHT{5FG2rdY(!S12+(}C@=Yo+UP7COj z@NiL_eci{rx9}f1gy`DbWoIIvJGTgwlwNTE*_(>u5}Iz>=VO&QoL>)Jn|C&DQYO4F zTQ73bYp2!w_Vr}u-DVKTi1Q`VK z0(?T=t!8dxx-(&PEOqD(?V^rM&UkK~fL^(1y~oga6=Yl$Nnx&C%qNz_GGN2|l_|FG zVK5R~Y&xZOpV}aM?sg49{s3&MZ}v3-$J*Ta z^#{D}&>nxseYZ%+pT+#-%@4VV8Vu~f=yfan*|U=rPGqeg*$>u0WE|gsT~hA#?HC;g zTU)B*V?dc33xunIKXZ3kyC`OZLTcTyAO4XT)Z}CwerT)wF}g*cqRvz4LXrc`|xbo&ae3%T~}I_U7!q+PtFw?6+Obm>I84*V+E_*Z=2( zc>tjbty znenpKJ{@Z?J*jTa%+4SSjXn(lN4e@kiHIr> zL5K+#u;&;<-(4IoVrsG><-9Or4xCQ=;vY&B`STM%62w~pf6|w7hf@k)*qw>E7^;8> zKwNEjNTAJ&qrXiq`NJBe(9zE1K1;FHir}-lqX1g!ma!CE1a{o`1zo> zwHNyPPf=V_aE9?*$0*At27M?Z$>C(DY$B;jqkYfJ;qD$*JwoyfwXXDyG8Q8e{g`OO3;@fh-ZO44}YJ zx_XvHmUdD8>Rf;08M2qgZhq!7J4lXp;{QC3%~GD~>FN2^4~|iKUv5R|W)HZ|eU)ww z;;FbdLe;s5LbO#jbZ^nXO);k5y=r^8C7WC83F&%HWzmVQ4`=UNwSGKr6Q>BcN>_dz zdhVAhlyn)(eJi<=A?Pi0)=ltXe*iJv18Yy_v>pmt~su zEx+==myi8( zEUu(jm<>#TmKk7==^gD}jRr@}*Rxkq_Pi$(MHu9}27%&6Q-!C9I3=5Fj@8(>wZOEI}D024Kp{h-8jRE&i7kuGk7z zKZAPu0%*$M_`Khous^-l`{Mel91QJqo88%u6Q3IPuvM1*f+aj5`)Vx`Kclf(Gz}Tq z<;@GFQvtt}udZ)xtK)(>ZuN$b6qw#Y9G=Gln3e=>qlY=Ye!bJ_{RB8VyH%{icLIHG zVO*P66^nU$WG~bO@l;tE9Qxih0Q!GQh5RRzkXi$5l><(hX!>LJ5X5N#=ssn=?m;71 zcKywwE3fuwlwq$EM)`%?7HPU~apGQ>mVg?a_nlZ%eeZe0Le6(<`>W^kTbM|GPA%`k(vN!IpEc{M z9@cJ8R!zGX!y$4cp_0?>$6;b_T9x0#-g4iveNNR{MC9Sr{Qha9HikzBdG|typt+B5 zfS^j_gxv+x_5fH6r$d8O%j(DRMW8JA;uXIJf7%rwfT{c|ek()9hl+uJy4^v5Givnh zQ65`2KVLNBa}*&JlfO?~ge%k{Kn(aHV0);qD_trj+BqV!R|o-_ILDuLePA4Rimfxi z`WuD;KeA^{E4}Cg!myKyq&?3iHz*Y=+34FjAc<7$is|73*~cy6%$XXtRMr& zaEc#EsX8^ZM|{nfZCGQ<8CJ5dcE~}wz{g1rd`OL>vW`Rfq`SPf0$AjlRxpo6s5wF% ztzaH!sZ9#6y*{?wCy|j<1ZbShB2M+Au3Hu6lkgNe={UP?R@p6TZ0%YUvJ{|q^u`d{ksSbmO<)>W6q?%T zdSe$g+|W>S1aot=PVzpgats)E9=28oM|fZW!z*WRMM+3Tqx0T)h5hJ|l4dnrp=8Jj zbJ{vsX^}gPFB@cQoU9GX9nWq(-7Kvvu^qPomZ${56MeZD7@TKjM9p2OoawZ?eoT9vluL|}mr zw=M%)^M=(dr&qfWS3$m6TZ7FK=tYdU>gH~+4x`lzye_C${-|?4&(xF69$=~&X?Ay) zTZC-X6~I9%bMX?7g`27Pd;ZEjrtSMthii#$*?HYFnU~DMMY?{->0FmeI9j0JHg^)Cgk$s zQ`U;1Tk2+c{q|p|DhTF*wciE}fJC)>!X%#2%eMRrV1rISA2V#b~U;NnrRuG;WXkR3*<=H?XXd{)U= zK6qBk2ho$JmSjXH**T15PF3IL8h)=*O7+GrjHs;kaG7<)`#xGY$TFSPQF~-$f`2*qh|gt$tIe6*%$Tk{Lk@a@5$$k zBda<}4e3>H8m83TGT!H*_jcEiEDLp6k8T}&yAB@SDT&X}tlRg-5QX6o>xEVU(oBU2 zy>iDq5>oPWOyUPywnob7^S3L2@+$^&{?Ja%+KZl0eoB6`(2wM^GLSU+N$}mv>alNz zOer>LwpGRk_2%kH(E~4e95G}5OJ1U-@;3q~m@+hY3+6q*tmNBp6kyQ^9vVd&D8-K#yTE;yx@i9>OUmfJ`zXzfIX8_cW3#wp& zC&06RA3ov$b^dw!|I2B{D|IgqbI&KfSI!5fp-cmkk@fPArkPm6Kwdp0IYfN;tw&2b z&zpgI;*Ti&e?7Q15Ud&Zf8z(Rj|DC|>jF3(J#f)3^DxF!2=;?vr1d4#{{!aagOv_G zzx2tKb3fgFgzoxl%qtVp=-SUn+w#3I_z(jJPe&PoeX#Jx?W_D zy}43wnETqmjnmOTLUA2R1u3WR#6<0uJ;iG1IfWpq8-g*VupjQvgK+R>?mR^vzxUpb z3&7E)wX$kjj?p~HLHFO=cR zmp_8?O1d0md3*#tp9xs<~;sO2FvD}5OnLVUpbv9=E9JyO)EaJJSB9K?uzD}xKGUTCJm zcR%?pGP4pYs24FQ1v5h>05dyAMN~~dicG%sU6n!F_w+TbMd}z$+;jM!$+%9$^?Nky zXOW9^2B15i>Es`u3Xp0CGtq?2Tll;Mh~GelA1;VxO#r)0!)(F4*{R2+2`9F1I(rbG zTm4)f zoIg=9kb}A1Uy;*GRQc?Zi`I`qX`}VU$%VS3OH$%Jf-HdJcD7eA7o28Q`;W(2h8DNV z&o@7W6Iob=QHVisUHvT`48S}ZYyYqPtCa)A){zzHabOZ2-+F@nk;HFPwWOe9`CTH&cQda3+~B5WPqypW|xe@@ab2(J(zMA(1vhpU{xqJodKnB2-uPD zA9&HBjdjhJT*>de&7WYF&TFTNsXLm&|IBo;p%kt7otf=r&W9j(J=B$GNk-9%OA3bm z`rbf|j=Z$*y!>H}M7=%_geZwkuOU)RC54piaYO9QkX$2?e>hL)#2Sri*9S=UFG{;F z+fsA?FL%ydR8PJCwMEPQ_8+g!$^jwQ2XOXdVx7>kHbZ!{=W>&c=(>_n)v?~GwS>yh z=h>Vf7!@KTD=!rBciaT1{AIHZYN|a36y{q_WXyd~$Cg6Rt?P#d#5CGR&64(ZeN2Rb zUvq-I3GsT^n4J5r!9eM%=Y|c*p85$U-8Zv&TmBlxFluTrpk*3u422EjJ^Y1h)3_6X zm{EW74rA6`JD}%&%t{>c(`WeYHhR^Qm&xoH>$&`j%4{a;0Ig5oO)|L7g1gJ5&^mybcy*s4e2JQ)23gj}%}-%L%g}ybwwOKt!%bl+xBftwK1hdJabLWAix>vn zU;=Fk@A5Xa*d;Y|A3vuWN>4~ZoLgR-j^dk@khW|#^5PtMQ_puI#wGuQe}PnPbM=ma zyhnO|iOV-4#4JsKqj6ATviMaZ+aGLgv3R`L`0$NYtwYi#bYH6Z!IppvjnYWa!TGJx z%T2u%nFlsJ;PK7iSPk44a7;e{BV#@?y1C_J;(EN?_?@2H83NMWctEb8DIFqz=z;0j zia4a3r7b2NNw&mCb1nBI$*1~3?8Yc}izBcT2)1#n#j4U7N9KHSj#yRnvo)(yq{C(Il2Om0k1QsfD|0sXYi3*lfK zS~$$l-tphpFE$-!;Qh+nJvdwgcDp0EPp8N?r|VXL>2ML5ql?ZDA`S5i{ng`iqs8;Y zF7YkQ2#tAVhpmnET~7GOj+2mWJc#>)P#7+?^M)EGWP4*YPHC#G&n z1IdqlZb(ubsd;W{$94W(CF{$lz5*2qDlIG|Ng5rk`gxCQ-Wk2?dE0^8)b|Cp?z*}^ zj|V`dEU4i&5{I2SsF}{y;uE$9O=HVCyoi;vw_Nhkhqyc;s(g4 zA-))7IiS$e-3LR`wCoGwcceSLvUf;VC(`0J zzrEv5h#;?7jOPd2Sjeu7Q=GX`wMh;4n(<>W^p+8YU3e@-*Fj%rex9&)ItTM67oo-E z263X2#>XRyLe$)joxRr}^UE}*GfC~@?n>-lsQ?Szu*2oy2h(Z^*A9tSD0xU&kiU~$ zJq+a(z-*Cu_g7>>xj9b$$vzbs=bBf2GNemq-ymEWqJ;0$eMfu?qayi^4~`MW?ZLY` z2F&Of^*$2|inK##^eH%;o zj~O2Oy+8-1n5Dgmwq$5Ga*89Qmrs#2sD{Tsr`_;i(M$7+`|ROr#IYJJhu-~l_~Xml zT1GJ&W7&FA;`cb=>cc<_Vob4`=}KajH2~HQu<)0HN2^k>4(HlLU(fT%JGPw2mlnPF z5W&^dpK}(SsfW-EOETh@J_UIg6D8s`7$P<@YG0GzG5a7Zl6w?aDeu{Qsm}1w(~j1x zb43|FuQ1+iooA`^_IlkiNzTmj_r)v@cD}vu#53nHWPbz0zL+h2lT&Nj=XWRYXBX%o zyWpqW;jbdD+Y95;zND{wfx&y2u2OVF9cqrw`(o}Euaoy$Gj)Odoc!{jc&lMe>6g}iUc~vVh zF>wHBlLs(~YM-zKg}@f^s0e7~H~3R4&NpA;)>~b1!$sq=@3=2^?urks>_9{P`f~cV z9>ZqLnyV!6o(250ZAY3deuh>@z7HUEb+IvvEd;+rSzLl7@80H|5UZqPYGhK@3RxcC z8=!31H;4Fc1B&Hkb(-dOwmyp&$_N{}A1`A8{;zQ%mG~=f@6(UucQo&ZnV+mx{BS?k zBk$oC#DOljp3zG=v9qJoaxI-2EF;sio~}8@Adne93os5Nx=!Ob%eS6tN1O8f4!+#F&)bZKFl zsnhpxHZ#BA)M|#2_iydL93V*ouI4!w2ZSrMH0yrCHk^N;d-sC&!qw=emj+tEbvBZH z{uP%%aktG-C55_${Yx>wXr!`7!i?u#(?R5}0-=%-qUQHnyN^E0?;wgEoi^-0+pss% zL499I0K-G4u3>tK1B^#liV6+sece?KT z{Ih65Hqe@a2RtE{56cioruGFb7E3#_OWsAg(~HJP@ICy1tda^6jbl5k@()1@ZkGiq zSc`>4E95sFAL1!On#_N*sO~+3j>XX4T0qBj5wTN4JrCSMHRJh@P#C|vc;-!mf))=$ zGxy`W46l6iud{u)`aO>S2o?74U(qz5c)Xs*|DUwAPm#I{$4h?^wcbDTo4h`?Bc_t8fSLD#}uJuC3a1SKVf_Vhw04e{4 zTNaOy82ZTk^Av@u{U6Bu52X8lc-JWT4+!#r;r2}Mea{f^{~f}9{EwRZ2Vt9mQgh)i zubFTq8XEAS#2fGD!NZsbmADILTC9;Z74YTv!1`v;n&wEF*BSXdEz zP$u}Yvz31jY=Q;$3%rk*kXv6<_|`&&ZH+?-Fr_oGD^XK=`pcQ`iJm_s8iysD90FY2 zn7%y&OYnG2ga`%im&zF1Xs)1nOLyBbvnI3d|zUh47^s= z`s4N{{Wvp0zeWZn5UZmXv^EP{AY3B0&T?t+ZEHT#pa~6=V76O?S+=r(c9?Y?hFhil zAZPOg5tTtyqN!zm`7umqBDZuQ&Vu4jrlmidNM1ROfI=+*w|E$GwWGc5P5^vY6xTGj zF$(u_ami7kcrO|KD=ujc2NNO4gbo9bn?z0h3tG+v4rO2d9H)DT+Y6E$dJ_XZL@Aky z{>r`R6O$h8kmw%yu{(c6ruv>(Qed&$*{W&5Ny*73>oHH+ka1)7IEDB9l7mS~jEu)N z!E2NWve1ItwI?lL3LOF#(n=vXtP*aC!-tV9`__WARDWEOP6E_Z5~p39mgU`=;94M; zVJv1`8t3Zb?YETqzC`jYeE&X78i&fejAD9u#QzF1LW+CV)~UjeRSj2hCac`F z=$R#-_CqcHx6aA}$%`jsesx^7QO}i>fsnwOSe;?YR56T4Ex|(LC2yQqX(!kRhaLGv zLZOc}cHTHMsl{sGx%3>Ccg_eVFN9kex>9YT_rR@@gdZ$8-9n*O(`>1aH11)$do0ok znBGsBmZ{4Z&Mdoow`^~$;E_8{JbAl}-3!}O_13zL}!})W~wpJMM0^Jf%=Hh><{n^&JE4MY?HklGGMm`H!Ld0MRJk(tVD!S zDnlf%RH_c2%kZGYNR;KgAH*gtLU8|yYh=+}ys0pfZaMEiI|PWkH964GNl|B@;tl5q z^1#+6%_?m4%{$h7rtm2l)ezjUC}eWykJkgCwO|Us;e|&0g?+$ohkZKCW!dFi3PxNL z7#4MMOYU6Mrqnj?O>F?hF$tYwH~HCyX8^XlwZgl|%C~HGbTfveG*3^YxP|MyrczlJlTXMnA;kir*iT(Gv{gh)^YC!OIDT)*XHURj^)0^JRnEB;N2)UX1SW? z+xpiY)hf`fEJMm{zQ8%D7u`A!__P=yF6s~1)~rYh0fcaK5$IL&NJGQDX%;GJ_vN(P z`pfs#rxc$!a6)JYR0Xg!$!92ceA*93sZG%{>4S=of40mqiJ0;^=(ZX4rNnf6`*-M$2e zXeW8dr+K-4m2u`6Qq>zz4DGIy~x4yv7q{KQW zObS)Z3x}mB4AMf(Nq`B}ruGTPvq}iW!pfdn|45FS?&|ezk>&F*obL6XZ+qJ*k@FRO z*EKqfL-7FfBBU`jXz-_w7FYP;LABN{`7y!kmQj-xt`7mfO6NkZFZs zOli87Rkx6^2)(nExuEX0?bL)0AmjUp6XXd~=8x4KvUC%0C6X4c#E&69f8A)p_&vGN$kcGSVOZz)-sd`F@&qq4`aHBJt);}sOs;zyg^ zh(VSc;-UhINh)KMLkwr`){YJUAwMk(%4?bq<>eMmQ^kaN?y!SOR}k3SOBA&ik$w~h zsP_WgxXlA@fn^273aL%~FS}B;1i}&x%l7@ymgG5L9}!mYv;9@Gk8d0nn=Mu|B*)rk z8X2gaerM|ddANp?a8ww}2KcyG`LqjDBV`ijVE09bO@YHPANLzKDd!NzS*%hA zLlKdRYbjrO76Z9|5OIRY_Upf;+_kvT^p0CGV%*Iw>xb597mWXd<`l&EJ9_`vvF{SN zj^*F8incEYStP^?2`a|`0Jd%7D%+5kOl?IVjHmO?MD zYkyZ#N_sxt|LuI+PXX}O4bvw7!A#`V!hH7&?P97^1_fv7UL_8a#UFk%d)>T0JDN;E zd1X1O0F%cTe{O=y%uTEGri{nVR8FRMQbK-fV;lv#D)W7_lK)HF(efhJp7_Bq1*SUo ztjbn?aJOmvv`jN_d+a?Yo6y<#PBeZCxvd>C>OpsZ#!2QmZ$^L1lTdHB#$b+MZXYuu3b4wz zI<$Y1Lo5C~3H$X3lIB)~MPeN6cdNct_GHe0(qXzDWy)?lYt#{gtPNm#@?8mvmnT85 zV@v&%)P7VZHGL_aVn5zGcpe$tKqfQA%%$>2Xit1;ZKfBRuTD9c<@6{A=CN6iXPn#+ zUA`-#ZVU!SoBxBmw~nfE?b^jL5DT#bBoq;l+R}{(qI7qtbfdxoX$4zQ>24NX!lKin zbI}dbjimIMH@bcI`+nyezw!IyoHNGf7~72^Yd!0U`=0ZfS1bkaP8imQPWa0=m(XZ% z@}hgns5Mo0ZE+%gfS7xpmE}FX;%8|UX&ePXdStVv@FiuS9p4RwOKV6XQp7$b!fK2;sFQ(=2< zW2p*i7Pqow-o%#CcnZ5&VHf0TFH4S!+IAPk;xBl~=Z)@$wRDMn&-x{C1|z#<6{^of9z+b&Mnc+3}V_{A~{ zyTyL!(99aSjZTX=+{wkRL|bqhV|_ogH4uETVfR*w3I8?8KWQd9`=@B!=np3$y%Z;Q zBZT*pI_b9~!&tjYNvjX<`!2B*i|9DGJ1rfvpCDhrlMTnfH|9PB^iffwZ@es8IJF$Ag4+ zbxq5vb5>wL>Gu7eeU@IuvDKab7{X^$ftb<{O|zJ1c&)&p%?yNe(mPsf514gch-&0cHRep#H(3L)CA;3Z8a>gaTH>P(LG1ygDdH zwF~oLSi1s$qtUo1)*f7o5o-^nsuV9i)STtEULss+au^>#>l)K1iwKOg8VBiMQ z;B07(DIV`~E_kP3vNK+AG6!@t$kebmD>od=Yd3o_(CE4Q2=ohP(@Du`naJg3st#Nn zb?1s#!{HOS_1!)yT6151qmE$j{eL}@|K7Vd1hG}D%=U(CFT$KP1?Ysxa8?thUW^IeG9EN=97q+51|1E)XswCzK5syVo|@1Icx#!z4b; z5K&&G!}&WgrinhXi9Awyh#7O|HDlog*ApEw6i^Sqz{=Bk!?Rn2_s(p;Aud znCCbJ2cQW{5l6tDz-8c8UnS!17tWPfw+B)6lv;ap0hzc6xuV#D(!Jo*H(9~8ShI5g zaHr312&b@_E+StQFjcz;({Ahj(h+~no?`mKr-Z`15jmEo2X~fHj@!t(?4R`_p|u@`Bwoj-liwG5(YE~V=AgupxgS5^rIO~hgwr69VAL&!_FHp8?Sxfs1zl1Km zk7F-;fzY@;bcVdSM^%GZ4WK$QoZa)?gRMRTsSMnFxjI{e5R~ERt8_claKz-RZ6=&4 zUlN!U=fiZsnz>`(oK9=VUxb_-ieh2sNDYE(i|WY^37ucN^m7W+_w(twRl45vY$aX& zF;D+lg|URaWiIt(o-IOPAKWWt+#AzM{{u9)ajtgA6R4r`0%h<>)e7x%`Q4JY!q@0d%d9#cWuvv(*zoO-rlXPM4bP+|50XzipIgV1F=mixMO zR($l6Yg09u(EMd9T(GbIqNn+(DJ{Hn^_+!nQ<)rLS$6OfmF~k^%lOzxx#P7SOrswQKqN?N?V(Y^j$)t(xb$fCm0Ni0nfIKwD1sAWXb zFt99MMOch&@-Z!7i~H9`dvUo8v4lzE?T8#z%|5d%@h1Y&UJhqvAlgA>@ujsc}=Z zrTVh|h;pB?TAc=# z&IgTMU*kE=hYfj>(blH?mf-1zYsGux_viBx;1|hpucYLjbJ>8uGH(JQb-iV zWSvr$xAIc54eL}~N#-fjw#}>Yu12;f36@2yhKe>xSl6P;#`JhWNQxtyeGu2+$PSJ_ z+Kr9W$s0XFrLW4j_>Aqd?D_@Gz|h;0XKcP$y++_Ym}vlkZqh%|88+hHJIg0&B=Q8x zIT%FO0#f+q9{X_(5@f8nGz%ny#0{y_xabrx3>O0SEwNf%PE3t=RLSg%D5?#45TfSZ zy>ygD!*;3Lsua`TbeKmtjmxX%ZIZSVUYs_6CBu-Ac1WQ_XT;NxSO6YYOIcN+lu56U zjokVmMe7H}O>Jj`)v=#kgPRg;A3i2bioE;sOrX5Wcu6Y3t;{!V0@WmyAw^@-RT|Rx zh;ue`O({m%GpBo$_|&$-4hNdK>ep?y-a9O9dS`UYwYZvW+0pbt>beKVyqLQMrmjh0 zs33rd$7-|f_R%YD!^M+(56SGg%k+uV<@xPq(+ig{v4Tjw3xkzH=LX#AMBeKym;8^y z$ii!C&ZPe$hh=7W!*`e%V5s*Yd2Cf}a*@28;^|kfW1FlxncjcTE#grNJEZ6?j|(u% zT)1p?dQ#b2lBdgGbe% z-Yt}CvN9hENNut_*t64`Z@W`v(`FnP*DVI2BUJxpox|Fz1@vzb^i)AgccT2G?w+Kb?^R@__ z-LesU?p~V25_~2sCCfF&ITa2RJvt%ww@RsSui0E>w~lwEU2@uKTA`SXXv|jICyjd) zl0zWgwvwT0AH#1Cr?&T=5OE70zkvt<`|NPnWHJ|$`hKRqKYwRGA|zH>%DRj6a>+Ux z8{4W3Gw4*GW(u8}qpwVD`Wme5e@4I@S@?rs*kbqWS3H}1&r3jU62b5J?#RtPsEVH@ z`UtQZQ?wN2gE-$DNEFOMpIMr`E!SN#->4Kfy>co3yn#8uV`U@Mw6DNluQjDeKm6g@ z83&)~qJrD>Wr@OjHT*71tuka|(BRLdK4hJkZ0u)WiZHEddxA7Kh+Sf}!~5aFZ>Xl% zvI((E^77ZPb&AIHB}k`OGpetCU*fg0u`=z*xKH)#L~B^;?34L*rj6(NYiD!?G);u= zF%3#fYIUZq+e_ zdvO(~?d}#5G$%yr8%q^&Acf(4ic6{f$gMuQ1bP=!lG`zTKe6a+g4KlRQ^PB!vOrog zD+t;xKpjbq69dudmE4|Oz2b(ZaL8Iiy6YX|s$Jt{&0XiGQyjW2Pg7_fT@LtstoaTL z)1IF&rP0h>yk><{3gV?>KPgB4L}OBBqZYPEvCnG)R+%;DIF4o?twp>IiptiHF(U*V zF^X)jz5UMFpVG0QVk2g5ayR*~DCWq8unw@Jq zOh7&2M< z1#_+TH#clIZ?fl}j^up}?pziuMe}Zs&rPcM!i17&lh|^59=%08*)TKVda~A2Ozp`l zo@u3%{H-q7u2Q8eH+LN?(e}Ho5m0jXc!vep$MD7keK(ym>C~u8iU4M)<|ub1G#rWo zl&0C|viG#gAof#lNMj(DzocS64X6$QLK z9Bi^#_3UhOP?O|Dwp}1}<%7c0pSXRyvMZUrM<^!e-TDJJC-OcR8k3FZ0xGs@%}p2F z%VsRMAhrdp#iftnZrhm3jvcdPQcvDPYq~)b6FOS=w`;%*PwSCo?TJo#NrAWFm)JEm zEOe!9GP_B1`ErDtA$QY?zw`B+D*0gOha)pESSAl z*`Ul!FRCP~Gj3IQa(HF(zGPVC;qRa~)$bmw1y^X=0I>vyJJ=%m+xLE+Y1oj84pr>x zmvnSq9&h@xl``P&(k3+#*&l-|C?h~c!(>D1j{9Sjey zuf-P0lW`Ya_E@28Zz$j-60l;Q7u$C!y>OP-ZrkL+y93u~rjjIh>OxwRRq|Kf>@7I_ zj^RV1#mr|mPL4Y_gdY$-g|f`05Qc@`wlyRyi(>9xEw@v4NUUB*fBFzS?^WyGT%5$^ zGDUshq_D&t*+7D58De3kjUJfsRp#wJ{r5#x6!tzG3p9?y(kuV*4bnAlfrYF8 z{DwE}KOF@NtCXsW()k`Fz&P=G%R`(5M4huZE z=U!(ai++}>Ig;k(6<@?n-+nwQ*Z3bt0&i$GEcu^D^1nUAE4NXk4kF4vgkrf6%YS*5 zJy6KCgh{8UgV5D+WqQJYvr+KVp!4DtF3BS_5`Qwzo`?e1=Py3SJ|cpjUMrqW;Rm`T zZ|}J~BYP%4{^?2`aPlKU_7uU)I9GNanmo#XQ)}oQ0Ll?^^7!@Z2C@pPUHG=~2hM{p z`u@k5O8og$|F=NO%53DLWxaMFgxl3`Ls954~~ zX9ds-HM*tJx$zyMV;7AfA<3@aq$&FO7F6-00gF_$_VX}c(1iLb1-xm~-ql73^}8t$ z+0OM@fP-NcNPi2aZotx!0;>NU*a42St2FL)}S`-%n!kXm|1gQ%lJ;~)=qXVt; zf=+paG+O{vEW50}1&mUyakA{U#X%NuVhR_d{ze|<7O0YK-F1M6l0_A}1MqA%GN8Ps zk}_VR2%-`)o=34XK1HKXyW9#W#$6sGB@SI)0!-gO`>ia?z^zkqU4K2eF zllX7zTb&!_AaQHttQ8lyvyyXPr#WJJ<|fNR*__3f(6)U-xy07qL@t(OqgS?Etl)Q& z%|6c1-%fy7S&SnDja=Bal<9g-#$}0PNXz@YoJxUdk++@6ZZ9aLpH9*O|xe+0R2tQ3sN8p>Qk&j`>*tmAmH)qHj$Dn)=II zZKfpVnEZ$cxS3c0%Bzz1;J)Mvy2z31jez&5?tszx{i-YJn0tUmB0h=gEH5R1hLnW} zs1qqEA@f}v0J2an8u=sAad(Mxp#U7@Ea?xB5flht7Or$h;;6!@ND2a<=c*I!UpB!q zso9kovV-6zVE;>^{iZ+$isuq2+krXVAy<}pRuTn8MYhd)B--8_@Aw!)cxek2Of%8f z^~&>}?y4<%9}juXIz(=`^)KEa*FwH7;PBIdywiE+zn+rKd&ivpw&h3II9&V5zT!f& zb08z1N;6mP8)T=juDxDjMMlB6Rn^11beRPCTlB+KRv_`tw-~>*?vt-QZb0!f-y+b5 zf8MeCFIxM7v7Gxd__@`b!A}b}gf@yT5^Uekt^!o#scqAAYh!CG6nEszW^4Webo~WS z^-b^I1xO(+;zF4#_E~0#+a9VYBu~STVt*-OUUk+Ljs@ELuSI(;?Ce~8I6^-`9CU}Y zX$3fA1zsAYt=N~27&nu%4+6yslC7nPBSmfL?2iYza7fe(tZKDKMI8u@#bWcDdQBUX zt_O@i@+NBNYyDE~&t%2|Al>?ygIrUB0Ea1NO5S}Xr`s|MdRPGg$|XA;`;jw+OL$49 z&M8!EJSgLTwFEr7!UHqNuL8_6a;czXHl7!SjDu;wwvo@2=J7=;CyO1h;+hV%uKyT0 z{0Sz-yT)!HR^kJWRxd^|ohtTjOaTYowZ&Is`UA=o3CMdFmH{K!JkDl7VvSuI+yQe}heb@AHgfOROsjsuT#b4#W}kk$^5a75 z`%FOP3uL6U$Jhe03`IBcEgokq-ELddH#;Xr4U!H{eqa7O{y9+r6SC#DQ33V?t8l4y zpIwpsvp*Bz8ZN>0dYr-E`1L|*E;03nOsi_jQ=z0WZy!OC)RU1xgQg6j4wH!>my4JB z2=#CS+$SuntGWO1gD9n(eM#tcg(og}&nrHr<|r>sbm)YCyK1}qNUsdxs(rmg>{R0j#dFP*w$W*SitJ~B7c@k%0Gs|U))zVaw0Z}oGLP88 z*`qbrA{~WUZ5+g=G$Kd(d})J6PWUcOg(v#mZ<$Cx7JIWbD6@Rw@iJ1QG7etb-tZxk|ex_OkcK_>2+-IMB5&_?=ai_vT zND0%tCOk(ItB&3C_w0{9%egf1rCfhZW>TE;a786IS@x*P2PU zHZ)CI!bvmX2@Ej%X`u6+HfV7m#VkF`HP`&%hAN8Z!Olg!qap*I7=(ya~90)!ApV zr;`m?t~hM`6>mSk*k70mz`SML9Pm>Ix}1v6qEPdjsSD{;?dFC|q8UplWwi`!IfP}Nc0 z2JGnJo&IJ8I|sGbJ%zU=^XTZ33{r3rJ}WDYI;zm!{<_7@`s2Wotpe$B{Xe%IHUnAs*;1+IAs+{@RJEvKrpVV>&Z>%3vOtJM72n zobBOf=(7J3344tQ8nB|7ndYq0K4YB6Ht2bnUafuwV7`;^z$xblK9=Tm8Nr$5gdmCn zW_}1(L>kq0m7_N)jDryWzrW60KHl%Z?Ia&fi%L-|%F#HBSS&v#y}i#R_*1lV@97@N zC3cECjm?y){PSkra7{j#TZp9NBT{B=rcXUV&&BUsU-sW_eczl*PsX8MesYl>$VF+T z@x{R{+=?+YQq*b@&MTcqH`I&)wk=t&4hIUyGt0&?PLfQCQsXMpkuUJ+XT;)7s!q-f zJ%m}BMs$q{MbUPP2!jjSNcTvGDU6 zFOt{+xp43F)zky0`u5F${N>Rn5<+q60GCB;^2Ts0i7fx3*35KL_5_33%K_PvD>?GZ zlNB9sflQDbJ2^IiI6?WTP4AJL-SamlWDRx3p2B*$xCp14k%OXnQfkBRkZORKJUaMT%Hwra8*s!KNIM zQoGN`@QS2kqPFuBUGbkz7U&#cBFT(y_~N+zQ-B0l(T&(j?iN3>XHMbfx?wKI^a^vM z#=#^aw~|O(XiOMzz*MM()ASg;#R^qHb9GXkeex;q&(=<#N=E^QFqhsVOA_UdhOqXk zyp$)3nDun9Z9JL))kDL3&Z#29HPaW51J_lRZ=a?kn%`Am zH%f2dnq{mdZKYrWeU?RM+Mj_3ymFgM7opHB7!hC}Qs(-rcOEY7%nGlB=r7&S4*Aod zwW}1{h|m;GmbN!CxHYmV%9$beC>JLWl><}L2s;Uq7_H=N!29*wvGDA1`u>`B8nRqA=j;@B1s0aFluQ#b zBc%X(f2Wu~l}|AockCh~i754{){jwU&&DsQ3ehgO(CD=85oiRLsw#mpV|6LdS=EAN z{CJz_s4|6dY`PfdltZLCv%`b@(O|P4g3H?HU|E{%NQK$wi-6fMhYrbMvuG%Cw+$y- zl*k*$9Liev70>#Zp1Q)CzHq~r2tJ^5F?-=HhUdn~&(OHt&6{XYhS=g%Q?a9+x+(^<3l3jqWSJ>ie?H;tFV!Eeg#zV|O|C z(*C_H`^&7M=Q{!b5`TBY>P+{!YpQ8l#sC^a@M0H^9ugU&8o^}EFUFOciHRvzksGu1 zLXJ^dCy%#fc(CE7^15^2Hon0@2oJY9%Qd(u4nQmA zSS!saz&cUD1_U!N!1ew3HMNX;2_T_~0$R*-!-jD3!}x5fEHHL>77iM9x#Rd!7+uIg zb9@s8yIw>si&; zw@dMHaWB$J)=ljMTI@h`6Qndl&9X26;U|Y7U!h6+$Wk##1XiE4SL>UTAru;`y~;0e z1h^9nBF?+j)r9P*ZPYnbW1*F?v-^ckDABJr2d#-F0`t~rvl?js-O&81l|_2HvkJx_ z=8~gg<}>)kJYz1L&TND+`UPmN)e_*TjRL(r^6eg*mBR{J74|2)*igMvSc-T|6t$~Fw6yht@Zac+TX+dktTth^a)O6SNfDK9$m!QhUien0rh<|;KK zcLsk=%ZD`rX5s_zOtmqc0KV7c(TzOgQfemaD6AzbFjzLs=LbG*6jQ#35S8D*UcVyv ztBj)ThLZ74J3?6~Tz=k1E%^*`-N|x2Lm;SxjLW!^=gq+J6716c{y~CYUZU*Dsx>!m z<42deDs|u<9oyCd*y6?9ICxM8fn|NAr_!7M^cjg92@62B7{#bbf_&zl9mH`ZbcmM@ z@!!s0SqcL%1sUg${xffYdl$!R0^k^B99c`=FuU82Y${{}+c z-2QbX2>!oK^#A-|87%;!fBy7;3$^<{@`n5XfE+dJ&Pi2HkoyF}L35OH8-cV@AU`nz ztwJ{SJMPcU@xQ>g$)lw)0PW4riIu!KMsDgNZPfwjUn=+s%&sEbZZohA(fi<5O$Bw2 zN{N#_64_53sJ)9uwMpfZT2v zs9jml`c2|X1#tv3M84bQfUG^3vbg|6 zes&hHg@h*Wiw}MD2CQrx{a-Bv>uATb_FH{PN4>?SN)GF98m4w<5;NFoA}pmgccy^J)PqR`sazT}iV=*Hjj()8mp-utbF@qg z$vPk)!UtObb(%U@b@bYyWb>)UD0iC6Ecg&cXGI-93)Uq`d%r&YiI8eDjja|IzvfJ7 ze;0bHHn_7ZKmI#B{L-SgpcRl|BS<;xadEXmJifHwGddBOh9kk z!q!KKN+t(P_@RfHYVcWMa8C^La-p?1#O~ zg;Z(ND9WJwutxkvE%m8SQkWiDlQV>y%A=G{^G}(y@){Al@!b42pka({(P4eCQyAp6 zPjWn-zt?^uohrD$TIEwe0L6Lh{EjQ;?bjWlCIQ&`b5!E{R$t9krypSrSDHZ={1*zhU&( zv&~zNHDRvl%zRVxOYQmXT*Wmo4o!lML8_xAtAA=*D)Lw}n`!{@8Zj$kBp!M679S@w zi42LH(;QN4hXH`Ks8Y!xl1$cm@QDzzFDcqBH(@dDoojX^)gQ~vRQ_p zFVmpr{y^E>1@UE#ur`pr$yLLy0vGS#Zprufje$VhM#V*IDLS~XZ@1eDh2;uu|BTs~ zMS3HN@*F)&{5nvbUV;J0`7WroD;o^>z(;2_3<_P^(nB2INik z+QE$Af<#rCi`XxHJdui=D;iYHamVsyW#N+QI0G*9I6$w=doL!KA5ehl$uK-PtIWEBcmX z?(@;=8p|?w=Q_5C*UAUGd%ev{l`NadI$5&+)L$1m)A#H@1hG;VTVSTyW?joKDP6-Y z@j$xk*lLMj0~6C=To7qcz(`{zuLkK9Jg+%$?D;3YR)x1s>^{xxJJ_VXR$eT zz_R0zCs$9x1VHG4lr@~mbG~%)pLOZOCp@#&u*z8nmv_8k);W?zvJP?_=ICq=X=S}9 z;R>e#51^2`#2lz`EL1XGOH=Y3av*5%x4R1CT-JrfZ!PppVpDs@!vxg*0E^;TBDRQKH=#tJ@ zs-c82XNF;jiArgIbFyQmzpnrU&ezVFcm}A&s;-h*6h^Hms=<({=TWGs5sjh-D6$7p zJS`R+%`IQ|JkV(IN5%V#GqgAx?AHZB-}a@^E2$1I+B0X6D@CE;1dk3|gUwFsB%upK zcP-AX7V`Fbj&`cOxz%ZkiG{m7-yy|?l<8B_89m%h5yo!?=03$%*Wyi~5)tgqlZIgtR3fN_QuU8*&cVnEaEkrTv3Nac7>R_Q| zB8-zU(T+L2)W*XH$p*RRdW>Iri@l&Zp+PRnT4OK!@#`=#$QPO`oD*!%$BuAimo9<> zw9gEMag|Qa;aBgV_s)(7TG-1z2sAN)OXcsaT-+M&^W*sY#T7VCyPE7N4*LcDT$-nQ zrCds6P`EmpD}J@9dHz_(XXuj3gtDXfGRNYLo5c0vIXOx{A7iFFv9}x>rgqNoV zWS#y6P&o8*a$i#<_EsXOdv7rHvLrDy7A=(dJXUbgCgsxPt9n>4bQ6U0$l2mz<1aVh zGvc`>$PM@zZ+mupG-)spiYw>UfiK-F@)cR{kw&6WX|qVq1(4MCo!8J^ zF6U7Nxn16|r$BE1x-ix-x6-{iH_5zxt@{osed<;Zj^y=Tl8-0Tm&!#x;7L=L4CA5n zv2+!jX^y|)F4hUTmsNxfx*zRK8Jn+l?Wj*Ej*ax>TlWfxFW=bMQ*kq;nG6*f@ZiQ1yDvoPwG{w*}=OETLvc+;k2Wj@GXg_`NM=7KiFh;I6 zj%ls6ys1i&>Kl`|cddd;A0^Ve>QFi4PE)!*$$&r_L`nkqg)HlyUD4zZzz0WG4Bjsb zj(n7`%45Pn9ij#vg^;2BxCr zi(FnoT^fIrR@Udy1M*ku9=E-66$-G>cf7{G&@XEOH|AKH;mIVXGdiSdVa*>k3Ir$& zl0R_+pEv`uBznV2QGV?n7oj-SFg$!P^PAY~{xxjn8%1c{Rh^-!d)E3S`HqGgduNxz z;85#FN;)PDKVdfC78m@xv7&g zDF5{%sW4eh<{8XY|D7((dAu43y3>s(tA5KOC>fR)m=&zS(J-^G2ttBfPq*_Ac>F{G(67GpQr`dh~q; zuyVq!Tz+_1HiVODOF%o~jHm)1Ll8bGsA%%>JE^ymsViX(*O-Erlg}tG6~(Wol!;`< za?zU9r7>Y6d{UYGLNZ%9)K)JN$G#n*@>BCqt=A+xNZqaAV5#sIx?=ePRzCSw*Jw?a z72%}%Td=N3kFck@Oa$<@WD?Hb-66k9Owjpnc|ZLxPJ5PL)9Ek`U=Hm2aTPf@3z=C- zVYqDpkA&cZF)>23oyvJZ;&m-@K2l6VNUm6%iEpV(TZv^?7PE@u0eJm&qyi4Pe^uJD z8SD&3j+SbM?NNZoHE@RPbaJM0em!&>!CY3e)e2b{TuTz3G`U=Zv4*p!vhB;f-g|*j zv7ax$ncC-spLD@J_;sv-kJ!756TE_!1rt}%I9dA% z{n;Xk^fci?*5-tJhwLU3!e2}!QJnU(BqYLUmmsD%p@6bu^I|ts$fDiy*Xh;Ej)jcP zu(8RHdt-TxI5d@N&cka4O<4zSr}4hgz2ra9O5rj2L0L|zU7v}E7b0o73S~yZ7%eJ7 zV>vEvD&G*_P@gg?7By)fxLB&~(>mJ7u<3}~dJ_J6X?59gQz0k#Q<}k}?4qRYcPbha zy@k1sSB)Vdq@{TR)42i`RTNJ1!dwh@+sOrn5~LHb@4w(azcSQGluogIJ}b^^$2R94 zI&R>YflE5NoD#3@j$(`2Bt(70g6sk4Rax*&9K+)7ckO3 z3z|etW0ZC#9v?6vAg4{vZY;N63Sy{Ix&H)nU&7pItOm!dnZpR=igV9ljD3n1jz@$x z%titEfV@@L&m--Ha7yHXryKdxGE7*u+|D} zg*!qb?Q_UyIM!~tq^Xe=-B9tKk`lEScCAUuQIN^pt{r0|t>O%UKQ$t-FpK9cjdG-R zZaEj*l*+{79Fzl=chsG4yK;Hqr*3J0vpq!t3^YZM*=OO^yD*H8f>e<}EL*&ZpF397 zA{wl=^K!h?^1-T7#u!=UZKtAy2yGvbc%2W(xh2yKFEa*ljL>TNNm@agkBD?I`ypr8 zoeu@5{J2~kJ>~}Hz|X%!RZ|EG%|_&dHYr_z^8XPb&N?`Sr|}{NSJT zyF4idSz-6u-&dCT&wwj- zh51#oz?H?38l!%2g}O=f$NKD!KGcp`U%op-_zI>)4eQBaYCPHXH}Jpm2_Cek zY0qSQ*|tkESsVQw17HRY;SZkn^c6c64p^boEQi`ZM}Eh0*Bu()5Zvkr4DadHP88#W zQdHoE>q1y4U@R+O^;tz z`8QF1#Qzw+Yh~62HbXT)5mUhk%HP{t7w)w?JLYg3fP;omvSw}?`NOX#X8C@ry609M z1=q)^&3f`ao&gKgzp>0U`Y`0Nwc)5<45TJK7y24hy@8ERD!`(!LtQuPE6SG!CF9BG z=S)JuyD=N8dANb;ka0N5b6EjIT7xxK9xzA2WUyzKjH%CthqePmnXFDmfScNWJENM% zs>BQJyhcCv2T*)6VfpL?6mb?G2sm;*5GZ>6w4ny+$VP?v5D{lUFYY`qY{n?FOG>B-z~x2XT5x1Riwuty{D2(UUIvU0MC=qY&D36RDLI z8JU{vLEU^j(t6Rkc8|R4?;tOLje1~CIJb^giIbixuJdVUUymyG@p-iS&+&odGvw*` zpm7U|^lDc4Q6kA0$}T=0`U+7i5USOSS}k5=^xS2|E~6*A#B^@>is$RTzCGWcLTP>x z5y#mAtTJ9Ns^$wVTX(?z`ggiCr0Ig6%qXeCZcJ7QK)pFO6AG*Iq!R#}S(9Wyip^j8>AHDDmYho922Go@ml*QIGzSAF)YNxF@i6BS(WeQE z%i1N+Nqu?R2{g>)m57I_kI>eVkD15SFL3<7_1-yPkiP+fLTw^Zn1`Cfpm?Ic$^9vQ z89>FZUA^>>!@&hb(pLJ%NN*4C!lbjHaM#+q9+~@t8#!_)O5+D-ZE-~G?S+MNG=RMt znRCO)E{E?fMhHOPd^D-w^exXo__e_43K3j`cs_h$wjNr*Q!PH8ew*oG7ed?h>l)z$ z56*-cNBC4V_Em}Vekp%XX$9P5ZU6R$$GP^pUghw6bXd8l@!aroXstL;d13LXx9nFP z94l&U%CqXw*N%>3_e`Xot;jIeSD_2#RWEA1_}EK~P=tDF2xi?Qg)8-+@aN}Nj(zxW zlfYX6k|<|=qd-R0bh2j6Vu%^;U2`MCk3Xzqe$%Ex-awD3}C>_a%8vt#gJ#=06oaPn;NE`<7${T(UCH6<9sHNtw>T$*+7 z)85rEl=61^h)#sh;8YdxbVpruAVYb>9!w;6Hn?bIj{%kZ8TjO#Sr8$m_M{NtUE1j= zB%z~E!g&s{P>Da6KfV9-9^r2X5A63nb0X{QLm?QRQ%7I^9(?u`-QhsF+CyKTB<%n6 zolf?K zEeZz9!cZ!@b60SK;X^J+naJYL6A_c1W`~3bpl?(5Ou>VF#OcRFDYVu+hOXQ#<7bEe z{W1Rg7kwU9+P$ebZ(A}n6zHK*k`=`br^e2%2a2c(tFfRAYYNKiFTxgozPs+>M5|q) zudcTgH22fv`n9$C(XST_>xZbz^M?5hc&<8+@d>`GN*Lh_AvNmYk>%Cp2mHw$5T=hU zr6*oG8R=d3_nl+Bn|(FG?n}t*qVLq+{8uN+2sn-BW1R{YMn3xA&B&}=v_7IzFt+TV ze&+P)yhPrU%@2)ZM*w6`1-3vAc*5mRq#%AYGU&g>QSSYnaTXrNECd--e0=y?UU6g^ zbRy_OwmZrS+-f~l3RlFoY!8vzMcoz~eHi>c+AwpA!=3butist;BvN>zvBSv7NPeF` zD_DV^vwGBEu>4B77Aq}DmVN$<|LZM|R_<)scHne)9wiH5Hs93S&O49t3F>)>l=w{M9Hk!naYparLRcn+ zkKiKt-3ooJg)q-=aOQpLLuH!1&AN?4O!lX&RB(^*k-a@}qso}zfZR3dil*f)%GB}L zi#vXgWzx?@&2q4&~VATA@2Sg!MU?m89aClz2+oVJS9MM{z`llXkHLNLHN zGvP@KO7NFqz>ZF%y$?93cIF!7GpqaR_Q-`+AxLfmVQokZD7APY#-(I^GFe-94eb>W zudWB}ns+u#4P#zsHJoqNTY_i>9ExLKmPpqw%+VY(0;f?rydHb^Pz$LpIAZ1^+9M7A z4F-~`&T{H9ZfpmehE6_DZ_mE^PE6%1li2dO%E(v9{niDlLqoFioc~g7=R|?l4xGyU_Y?xt z5Ki}?LXGoMdF{7xwtjKdP=nXM&M!BdU&~+TS4?2{{2J|^U%0s?Lhq08Pt;BzGVgBm zAYjuEyYGZCqf`K~+86ZaboXbHqchB5HBpT#W@!q9(Nk%D6Jb@1E(iICX-K?$-hcvT zK)!#jJ(=g)bxX#2{em_5bPF1mpi#DRG_AQJRUAaZGkm-o*0mX+06l6Rqlbq%q3Q4qSS?ebu1d?zba0)#+e9q;@dZzYoLUv69ZsN>Uc z9*u~$nffFZglHZ1R2cBRad=h@Q21P&h{7$Yt5~-B?>Og^0`;2NrUuA0r`X_>*~a*& z?410F=tbS5idXd*>1BGqCb6w)?VZZcFFVCEcB5Mro_P^CZ+dv6dY`ILnHYTQ zhp=&v6U$Dte&_f2dK|b%faOiNPn_(d`fc=V3gg4#1JdCNeCcmy)U&(`?~4b5mT5!X zOnm(2X1eQEP5C?WDTiaG7B9Z8pL8e&fW^`V8z(RcaX3H$Y3My}JVV^nqTP8srY{!; zc$Wogs!5I9ycxGs$fk{8Fexyh5v6wVgZEFX0SN9mrii_q_Xd?gQU^@P}2 z<6eHx<84ajB!WL9Ewbu3FTB03T@g!)+nqP@ssBKj&8`csMAp|IIWU&bOI&2P(3$Hi z?yXyg-gqdkClM+KM%~lPf~xOTw~{Twe)4;AHs;i=Y;pK;FYg5F$c(i@iP^END^sa2 zdpF|xI|_j=>8;pmnlM)v;5AP5+2~_6{{H5?!_=q3&}9Pw|2vdBSmShui!@0Qev0-#oN(VDX!|_ zU^COzDxI5_XXD%${Jo$&`6J)4HJNLu`j86zt52n&X4+igMvmTLV*+fFTuCU#WT&6r z@lCp~*H88I_hcJkK$drz^-^mP+ezOq&ozX^y;DK0De7#hRlbjni$Ct~JxcdOj-?2Y zk=Z?EE!E>=E_0(WVn$pi1<0I?#Pdp6u2)W+*Ul+&@kCBSI4A;@fb`(GO&bfLDE(_h=5lmJ4OS&<>#Bw$Mex9F^ zqbn83xrXdwRNa0@*i{LiRH_5H2R?e+3~*UN7+e--!m@!`nzQHjv6=SlL9>!qQ;xZ&}`>&U~Cdi>8$P{V!&ucyCe zN$__P8&!7`!O@HZfB26C1YZxpzxwm*_$l^(FzaqeH4+@vFN38w1?=bAfH;^8;~Mmw zZEwxFYA$$pukWBm0XRiNeb!%3-0lFJ_U2aKpO<^ZN&4jJ(Vq>j+j=mt$P5>7?1CzH z*k!gYH3TJyKA&r9Kuar%9z$FnO^a+la*&kffbpALDc_&hi8gziQA%+9Hb_eS!XAHr zZHoq;5oGMf_t2={8Rn?ZV_XaE<}86y7Vj9bG}Yb!c5Xz-cx?s|N|rbN&;_Z2&+mcH z1m^JOsOL{qj4z0wl?uFvungofz1xFzplL0x;gG$(eOvUcBS4cGFAw1Ac?c7G1-hEo%PM+Yw?s#Y&;!x`v=vuPfnZQD5w*3cnV$T~|G&2NEEqr(Z4 z{=&f*WGUcsYzynF72XJqlwg7r_c@}qObhtPb=zrw4)hfX>5~&?q|!*{>)WRZQ#agJ zFSyC&$(Oy(c3SN;q6Bq$e-E7w2LT{*-2yw%xvGJ4`e9dDAudSf{o0TMwy=(WUXK8K zpZMNae^QMa)-XgR(1Wc+wx(YPDDHkNcbDezN{uK?Yo{OXnf8Mz7&o8WOK8ak(x9LJ z+@F_u((~ouy{{@zi2|?>6)JYd3SjuY)U`sW1`FvUE1=FtE^$bd`Mg=$Oky1N;C9?zIen1$TSyO(Q!=cxU7xX!jg8TWDKg^Y*0XV z8w6+@LJ5!q^>a6@im3pQM!DGiIWAGve5!+P8KwI_m_T`&Nrur34OetGgJKK~pEE&~ z+Z%mTQEcpAtHOO(^1pxjX0GnCh8h0hle$TlU}zp=EhcVLNvQ2R&m&BXMN(&kiHqG8 zU}wq-hv>Xiq@rrWnJ8{RBlKlE>F<{zD7y+9!|*fUW)+?&5pZ0=fgOGK+^ju~n*$xb){dfEY~ z_06sDKR;168o37!vYB5toPW)#VJaW6)cl=xjWU|lQ-50l5DwUhnyK=EGd0gBbRG@3q(dwS{688SvUT!ZJgCGl)ngA?E>& zUhSd5^(41)u3LD}Y0IzgZ!e3v`Di!FMr6PhFB4AuwZY`W-)BOk`o7RtqDY~#jiEB1 zA3v-X{Pl-&M;bSr*=b)l#rmkgvVncx33z*vQRl(`UrT?kN`$3zPv$yeg3+eyV5OSt zw#(tjo|S{hY)Ha0>@;Wl_4i!ZntBtbv4D2L_a-!j5?HY=5X{@aJ+ojH?rxLAfWSOh z;rInB-+W*9`=!CVaQ*Ase(LLKXrv$M!p8c#2#|Ca$a68H|7>lRmU|oFYk>plnXH)6 z$N-4+(J-oC1y;1}6F#-IAim53L>z{5nMzg8LE~XO=q|6{!bG?CoEresr&L+Nkovtb z(z_KvzGnye)G=Z%gzI&Kkj2tD2w$%|-2rmTGrp0T6z-SaI z+}7;|(JK$jSssjEL;|E?`m5kp4_^>M_BxYiLKFyKl8;tz*~J9){siPuQejD(sD0jc zasZg}Mr}m`&l_@1wy9K06m};@6-?GnfSPQmH{=FwJe+)!CIz6zD)*zpZjtm|xE8ER z#HTZr>ph6xE70p*^;yx7Exr;tb{*QxQTJ0^!FkVRlYFK|Y+sfY{c(=f5-Rhf-GCE0 zfxC2>F#}wGdHfa(SFxktC6d3{CDwYH$Li?tU8q{dz^F_>xcISY&zYH-;8RIx(!f(X zTSofjXA3?k@zJxH{$|2H0L$$hB$OXzX$l;sxT++1y$UO6Cp4=knh;0Nr z!Dx7E33o&b)xj94ksQ`059l&mtAEZOhaZH}*6)~# zg0^|BynW{h&xsEn?QHpTOo0OtT)}vlK7Gow$qTyNanP9;BR__OTNrp^2J%J5+B4>h z{mOtf%|2|-X73z;ZC=ROvLCjq?@UO>cqO>EG69&&3&caLWy@hkK2(>_+Nog;cY$v! zpV8#>wSUXHUHSzYGmYt2__;vn_?YEa{Cv4ypf0`9R2~^W5R_L5VYHH1j`?)9(^NA)mc3%X>q{OMNNrz`owm zmFHYP(@~QeEO_JRZyA+-A;h0qFC)HXjV&u0`M+#@H9R_~n(;6+>f8rEwF&Dz@1UiR%a8X+y!qlcy307v;(RcHh{1P*_jY+G#{8J$az^?q z{rVKzF06uu&OPAlCr2=k**R1wCr$x#Z=#f=*8d`nw+?25yyn(NVF!UXm{32RL+%(g z{)cq#9B;9Bi(>hT-WmlIH7LT;nKhIvKyuJ?U%UgxVb-OCjC00X9 zcIv7R(e{llk5RYUZiA|Z6exuk7HK~ggEj(1%Hn^9wy@QD7timaPN$F zOVhOsB?0r}E5RQpxs9UA@X-E}N#&!APgub`$RuskZTdN$>?H1OM zpp1ML(7=v*g)HGWgrv5IEMv)JR&xlN=!jtGGjpam&r7YBu(hO~RW|^L2o_jHQ}-)> z82`oE*rp05z-k(Acm)} zpKV^?(jzJ?!h^58$ZO=ne1hAWakkv$Y5JhM?Gt_9xNwt#)-P3xGX2f@rVRz>tudN` zjS@$qq}`J<)-r-}tz)%m6P*u{ z!YJfb-v=FC%sLX6xkIr)oq$$O&8qrD5UzH6zzBkrDBO43Ec4p*5$^LOZ?|Kkk9F=4g|<4C z4(*kaC*v9{wPXj>_;AgUmXsd4j8aIiEKmE4Tr01?ujAWr7oLzltszwt3$-Rsokyv= z7BQcC(8$T#!lDd38wrVA-+Hz|89y76{!To({`q8lp_48BNb)Ob{$%e_L8+~`crfdp z)Y zF>G%cJw*SQ?WGp2eVAH5hC^{_$Fql1&oODTvuwa1v?~eb=%!VH)~C(%xUS;P2b`}d zOa#i$$|;03MTl2LJ8Rm@nWnmm*TC8j*a9QmJElU>9Mr4cTqJ?geFhFPU(`_mMG1=0Q z-S#_c?|K^yRZZg5A`vfsMdNnrnAZ7w7Cs+uZ3-fq1=|vBkF@PErHsS=>jHX4N(hcR ze^+f|8$XTS&Z{omFHSVcjh1G`Z_bLys0&h?@l(5hi&Hxc>+EomH5g-jPCEn*dv!Fm z4M}Jdzg4D@9y0?R(IgI&;Pp8+8!ysoM@i*vf+I|QbGl8Xjvxgeg;{(qaTDw`t_&a* zk8Nk1>_Wod)NkLh9M5Q+yQi9q6tOKziN|#fNdUy0K0qb7Fk|O5Lw?|Si|N{L2Mb@q zV+opwNyjFY19lqtQzvAWl>u@n^KTGdDJ~e_vwE&bSyl_O$q>fUq`M>g*1h3 z_e(mVBzzN1VL1~Ri9QjVXJg83gl!q22>xkDgv)D6|8%7YJk-t1$KOrschkL&;_F?% zk<+?!D|qdjKcuzpWZS6@S0sCK%i;D-Dv}7-uhNu~0+({vM_GTB$L+f(?(Wve6{stD zD!p6Eg>p^#;;RbKz?CF}XO}=6_s|b6ZF|!=K_AAX2e$rxG)wOD+0J^)9*efZ=NOp?wc!J^6|}*~ zlo9CVW1ILIIyN?^e;BlT4Ku!mFwuK5a85RbPaW;u*&3d^)x@e0gR&q{GW*s=sjo&i z3V<=0?4gWUi8ahq)vm({*BjudTVWYT<5X?*45~h=!NAJ&D-$IHUSr9=O7$r?uF=Xs z@@?IQ#1WgW*PYP-bWkCfv>UD)qoL|qS(N?159}cc6~hm<>p_r6tG2>@KeeTSvBx?Z zBz)eefUa+Mi<)M0QecHfsB+DA8#Z1Bj}tcn{*;nxgXBGn#U&ftuy4lD?d#R zIdZNecB+^On6}vO(dX>ADD@uvX~y&WNx1$sFh5)s4nlX~-BH!fGW-#I z-qLms*>GtH;_K!7;-2VpQa=BXacDzHZ6|2S_4iYXrrF0*NBGT_?fJtR+-lR!zN3q8 z$9+o(3lN#^eiGkvj%d5B$?b{}DvEVA-Q?P^vmrl~6K)JpMT#`-mTm5OrY;imKy{9W zFthiQY`O=i+z~U}4Q+2k`s<&knu_#&!jVMK6(wxqos`Udxg#2#-NG`v_OO%KX8k3^1@{FRnZZ zcw*jjY1Ox0-(C6FC>z~+;2NRAy^`hkqfagLMPF{8zU!>El+6S*by_w_cdU^*xM!!5 zI14W|e7BW4U%LDDLcQ|?pD$FF7iv66I@_?_*5EbjDWSiez<6c(wQV7%%SJq zK-@3ZLo>SJ3~a7wrrQT`=`aZ6BHz%A>RK{Q=Izu9!B4f8sWq&z3~-h2jXR8K*2yuG z_tbte8*)yYR`&;vTc4y9=CL^jx9^LUIw5tWL##CE5h_5+AB>!zk*!WvrasCcXfZCf zhK=C#80x$?#m|#1-nngv$l-J+JlUltyIrE4gxh<>(tFQ6+KR3YHoZA_NuN83eJC~Q zec*~zBi7c9h!eM2a#W8<4~ntNF0E7Fm4ZuGQ???;1&x6r(vz3w_OlIh4J1lc@l8T! zq;i9FL+;5Io59$R$sc846|g#1=f(rKmOQxMI3~A6x&YIlbx!-V9k)hEc*A|p)*_Mf z$yk@c3ms#9o074VJ1Ko6UnOoy@enfn&F4~ZdhnB)?vwDFN1B_tc7#bY@lzdcZm2C4 zIX2>*-tPd(3wkw=LmkKx2XtR%818lR*S+$Le!2a^DTjV(xZS1?Xp281G(u_6qonYGcnrjDYGt}k{FoxFwMJt_okDN*h zO4^0>f53Zm9Wf&-2x8BK76@3CKle6z$-+mk>t&kRjYaQElwo}%v+f})0$@G@CAZ3$ zf$=2Uz|+Kj4u9s%t?a}z0I%;~kvr(8c+~pCH0ykN^7jj;(mEc1-?K_}x*ixL^931* zaM(=I%fcKpt0-x!b_XVEBV0GHeKZ!rs>lwQz&w^~aEsjp(AXC6C+yvyj@cwBVymXoCBSqzn;Y8&(e)I0T{XiqFG;E zLL%XieEQxlCWOiE90#+A zqcIiq$2BIWC|D2#iAlrY%&j>Q-sTUsbDQ|{q!IF^SAO1H$9n4A7boMUBzZY$h}7uy zQX1BuC;+Fh-U+X7gPk=7-BV*vPf2daLdc}4%8zYBzEB}iHvUIY(ruykdKh~;qEx^; zKYsim*2jb*>UGxv2uVBxii6%fy=woJk03{3``nahJUE5G!pR;ZvKC*C*J%oRR0qVa z5`B^_bSqDTg!~RHZ>`X;S4o57?C40mOowKTAUof20^TravbjN*BBfJLdXRI9`;Fw# z_vBozr?=F!!&GM7x&agpK9$qA6xW0xt^9N#-DiJmO0Irk>y%u|^Zw*@ttxlFRi(Gb z7y9G2jlOJ8SxNGE*B6`kxoO{j$IOt9>>D`p-%vTuuGZ_X!Dw%Lz4_M6Cr@64^lU~%^2OYV?L44Q^S&heReI;4mmd)Ps6C*r*Z(P=r_5R6-j8Nq2 z6sFPn%)jWH8qR~IJAv3aD_Zkd7|}QX2h+0?M+9yLin5Y5+bsk!!a&AR`sarbec6>= zJ~>K{X^mTlXO^$~J7D3ay%Ot*OH=@bS-zfOtB9Li1emS}MKBJ-Bi|v1;0lw{>$B;< zh7q5hd z`dkJisnk{N`1SuE+Mpw;v76;zU^KTK3+w)8vky>8K!$mB6(F$f+Z7frafw}3dn8Di zDjO5c&`t~WSwT6fu=nmQCH?|%cRIj=?stIz!%pOGNAhk2NqV&5l}d4a5U)|*0YeY2 z{!au|Znf;Mk9Ig{Wmczz#&pg#h_nY6FC(<0A9)* zHhMA&3g`nJWNx*>fB9ChN_Nn~pd^|-US`_zHvnr#^9|zVJhQoWB|MCYu@W&V%!L(- zT8ITwCm%GHd%S*q6BeYsI{5@H-)I!m@_aQUjm=W93xLUZgp@BXE@n^bqk%_i zuyk}mP@y#v@j!X(Z#p%j2DNHRa%2WVt}Uf{-LyZ^FzeKK|6v`&;blp<1`Tof#*gq) z_09G9IDr7&q;B;pR9t%)(vAj0br=|@X>iMdCCvd4*E~do%%kl!xww9%2UNoBiaAi5 zCx3lhmdN`EWC!zdFPirZg8`Wps}ot&?nZfJ1^|U@9&+GgB<;UoZb=-LY5tgj8$ctW z!Sp(^BKK<>9OAJC<|G{kB}+K=bdL4w5S(_dr^1iKfVuWrunHje-@P`q>i0pIef<)I ze`XB8mQC+mgH)m{#K^%+39o-KD*<8OF5g)9;sKfj2E&y;VJ!Uw1q5=@O-5oV2_SHg zx7c+EKtj~{`6U$cf`U4L_`5=f%vQj&V%Z>06(!wH{OePiHEvKau3-t&kQs_Rthj?q z@*PaBShvDJcpvkxb|Jv^tq=0hCgbaebud5&@QG$2d@e>Wb z^)K&iV!$iK*;4$^cWepXc&Ww+rjJ3URw59H1_@LqY+Oe)Q97q1)37!14u~@ByN+->^M5fy8rtXu8GIPN zo-pk?h0SyU_n zYVaGVcmeP!v?NC=?w`HARrFd8;6BkF@38fV=eLMLf((#ij0A{n zm(dhroMgBrxz&J*2~_w5!ml$C4wG(H|N`z{}b-N1m1Y9KIM9X}Cp4>2<18+TucaI299MnevyqevYux)0G3 znc&6QTPzJe(T}0J+{N9|AMl|T(;f? zI`cUI^Gj*1t_`go5B3ve2NJ}rLXmNTUgAvPf(Wws+dn|EbWMr_A2Hzk|Imo~(nFqSJ7QpWFQrA(Cat`dD`s`O<(@CK&s_ScPZm53&*}vS;#m~ZZs>dq} z-^i2zAp-CNTcA`^chaUu(pfqs(21>#y2BEF!G>1m@k8iB1_P_n+61$hGUps`1)&#B2586aV@hTMjN6()OiIiC=nglg+;mSVqnR z{tKV(DbgT$X$6&(t;r~e$Y&DzEH>XY9>of5o44T<-T+li*w+zQu*m{YH|{<6M;&6v zOJF8K(5YguH*7+}+~rj$q?^F;xUCPOX?cS#+J+hgI1Bj$?1*6Fxt5G#?p|z4ULGrPX5NOuPuMp!n&!w4jv`JlQSL@# zE6)IE-w7GaAECr-d&ORdMMGjgqRI#`MRB)n#uJ$9c)m{?{@#x;26OYkP9~^94n!U| z?;E0QZrp)#&iHZ|J)B?@8At-sOzV?tV=wT$h9x1dKm$7e7{Ct@-$Pv>CL9HX?=uKN zK@~aeu67^v`axKgMGe_77mefeY;z+sP%?|dS+AcSK3Bni~ z7K(V^5wtoYAdB6H>)tSY6Q928Og5?6Ok6+(bfS*3V(Jt7A8O;y2K8}#-D zVBlYtk5;+ft|}i;_FX+Oleh*ZUY-G+-8-*WLCsc82p#&y-M^;)&EaG|oU(7kj))<} zdgM%^j>R^y{;oFZ-EXVLCN?%y zdS>`tUUlk?h&t<=U!FoWPFb%lwLNh4_{1ky3N5|a?SN_jYIBfAWCCqL{k5yA|RIN&yY93mYY}ICQIU$gkPwvK#vE*VNybU zdCxu0WJGPylj$qk1ZwKA7GE&^3VUtZa8aG}$=+j@F0xJlJqnsSM@!T}i_nUcY~>Oe z%)Lx)P5Gfz<5nh$)YfJjS&1F=xzR4JOu5;tbD?S>6wgr=*QCORr)={ZNr_p@P6w=T zWOxv|2M=w<(WP|vHk6|@iV46gPMv}qzoUcDM7cA=9NA3uy`FBrMiFo8PCyQChQF=T?X5_0NrD{66rUYd9%di1ZS49vlf2pq2$b2v3TWJ0IIM)Q_Z$8+ z7x3cx5h(cjji>Ly2v8KSSu_d+hq=wsZ~wWg3}9E)k(V?@7|w70cu=za2>G+vl2qxm z)U@|Nk#r4b^a(%6y??e_q>*Tp+4Ao7@`FExL$&q@p*sgc=@jov#9qwj_=_YZmv?urRWw<`1Q6E| z;xj!(i}t-_JjdJ2PRmBVLi%7tnX|5@+pI>1WIK_aa%l;0Apm#z>ASRRyFmDF4bXQo zNJ0pXbK6Xv20Z!}q2WOT6J{lsI_7_Ng9O1Cyiz`sXbPO&DDV=q(@3x(fOW{;*1oxnsL=7`on=DlPG)KiY>CZlj{(Vp|NR)W z=`*3UgnCaRzUfbCv6VJcCp)!hQi5G>O90jtJ38NPNCCutfAWZaU;S9A-n?Lo{7-PSwUYkUIg?OLeO;jOGOVXB6jQBVOK@!}SRs z4wt+9n*J`>xM2I2`8I<1_T`MG9Lk{^-1X9oz{^hT8=msgcejTaB|U{}51H9=w7`{xS1%nA$eo3BhyvPoIWfZUI?b zjGXlz2S6NJcjCLMeJE3^?#7gp%6YcpDXGovG8eo~@A<~v%zh+D8G!#BJYPki=D=NMh)fJXJxJg}ZZ~^rYmZy&oc{)spK1&euJV51 z&HJo_z*J{H4!dQ z$p=cS24mF}im!k=9u=uS#q+!8up=Om-%n_pNmTEORzPILnh(bVY1e zP($5zdo{`Rq$!QnQW(d=dowYx!_dSLsZUTiXbj+Jq9BmhZZ8eah9O_}l1?=RgP% z4>(JFmUPGah=P*TISdwo^_I)1)!)tWw9L6fmat^FqxpS~V-JakMCa7rw5^u>xG*Uq z^nB%N2{ZJqfp67($2nTZ!1wZ6r}SqSl6SA&=qU%OENtqQOIfVxvlk>zVz*;$slQid z+myAHrUOpsc%O*izel{WgJX}?F3OOL=fTMHZeKD(%dXL8LQdU@ysMzw3OQ(FcBiNL z9I)6UQDR*a-IN2pU_KH$a>vBgiEY6O#k6{Mtd3!FrgDaq*;@zi!Ci3XagPBJ|Hc#U zdVkp^{^}%>>n%aH4=xFy-w8L^*1T_E5z?U&I2Sbx2Yg2!H6dD{3q`ii%H?TLzHyqe zYBgBx$-#K*CU<8UOU>`c$WR2mi>R*fi6*voAN{@@H_h)Xsy#k>Xzwhi1ZEXL2+y~_ zfAI^XT}+B-*e(Dj&?3zd*lNPQ0B35ab}<8_bK1+D+y1|)bHpa`M2;+Czu^n@GV&utvv{5o&Tp{z<`dX zLrb3vkt^)0O<=aUjRb8D8GyQPfWV1u(7Z-AcQ&$RXDiK9HcGh!q6Ic;kIUPSgAtk@)%(&o zA?RFd!0P)1LrQp{r-xH@*-svjj$Of=zyxz20P*@6(4Vf+2_QAmzD75++5kGprt7}s zy2sfoMSlYSK!&`F6}xkGhkRS2F@SCdP{tpeGk3~0>iFvJLh*btks(_QB!bWa1SyE| z5Gc;6!gZjq*#LaqR)9i=`Kb9d0wV!P{rD0lLJTx1c1KrzTbGOlsmU6p(X$9}m}l=7 zFF}UIcr)pN9R2$Wpj^XiR}QFDx`R&WmBCoaTD+U{Rg#oyI^oJ=j=di{gP8k9PpuGF zR{}JeU^j*84i>)4f0@a^EOvPaWB`WUvm%SKNh%66uv}$h0i$KelKT-<^4sAavO5D^ zb|Q*OPs>^y6G;PH*dNDKmB0ZQ0s3p#j*yCqQ=Z8!CDYkwidZ$>l9^-Pn=RvAmNYv< zKAcVkUZ#2W#|SuzYVUccX3T5Ke0 zKwKg*71wgsZsQSQg_TQt7XBMAh>k!`gY3W^PUatfdP%T60ayj(G9f-N3Bc+P9I3nh z<{qajAN<2nD}e3mAQ?^ZV-@==2@2c(=tYTO>$wwu3v5T_G9z=zKTZ#rNIiJ+i$^nhp{ zLx2A&pP$G~3b3clc+RgcXL-H>u_iEQn}jmZQ5RtBSbF8t45093fOJZ?{TtBR3X5|T zve=J0jt&F1Mt(B`cm51ep*lVFJO1lK9!dnM077oQ9p=5Ud#|X8H83#Shvfc7JXu&q zaI7a>UMc>Z-T}zF`6n8LBo{y)6>P%ZOvreD>M!-rH#2?w8wS++mB`_Q;=}j{e-U>fO=v5KX| zqUu`-OE?FwU9>WM7yru_-C~T zsV=wf3;%}Ig}7=&Z-^M=zR*DQ4>(Q$PRhO3J&eKwLk8O~d{lqB>jj!`hd{%X%-71v zp1}7qM2dR`l7soG8VRu46ek1*-hl z64G|v!|NqlP_K&%!3_D+tK;Tmniqt|A7S6g#gU8g;4zf zzis&`)%MF9)LIR&sL;2iy~g&J!D}VptEmhna|pM>|LX&?+_W^_v$#26zk0LDk6|=| z6(qb@V@zBEA?yF$vw^?NMfX?!i6nYh3me2h*dfp^t9BW%vrYi^lIhj@5K;81=w8Ig zaE;xPC&(<%o0)?r1wxy1zsmECKv=YK&Y@`-@TX_=Z$DNTVDa5(icDK0U$ekR$kuNH zWpzBYx&s-00^|U`eKjnuTR@+4fGn(K!&On3SGh8=KIEKPwST3taEUlU+koyRx zE-4B?nIYg`%0n8D5Ec4A6D)74pJzRB5eKJ6RuOFfufxb`zTyK`Dio@J3GH?f^Qs=0 zn{ZO9r5I`ZK^2|>?%7XM6_6p7-CS&1?tv24kx^g*Sg9R+Jb%AGEHz4{PYTa#t+SL+ z7fA%F4*Cj%i3!!D&zQfyszdO?|%J?1$&0|#Lf3>vm7BE z1de%IJ&0+R1Fx7pX!h^r9EX(cY{ue7w^;+y>q* zA-4eN{*5-31Ifat0KQTy=dRE|14LLBR~0La zA&Z3lqiku_?ojAc%C6jTb5u3Vy>tr<9vY&!tbU#tPL2+k89{&*D*~j@7<_*{nD0io zL!#Kx1Kyz6DC_(V`7@obfd{+Xb;NWzBT4S#P1YW&Jq*XF#n#1&S#`r|I>CvMvYY^Y z^*R214`C~J1MYP~s?xw_I0D_3LH#`9atoO?&=W!wS*>6lnEASq`2#NZl|JSGxjHO- z{S+KRkKaPyGbt$-3)MA_I@MTlPWk z{(t;4likmHD*p1$bVCaEF=J@%KpokRM%LJM=026<0h6HRUx`V^3DhBY4#T4xBDa1q zidApnAbooVC>f(t32R%96WnJ|>XK5vs>@RVY7qtm0oVe3uVgvu*|Oj2mr;c5SZ|-r z1Q&6aNp-}eLu~riF87#?j!RaDV!*?Bh;NL(Npf^}gXFaz_VYRZ8k|^*yX7nZ?{>@~ z7Y5XWaUcWo!8TIf;m*vehk|1dVOlQA?N9}K)irLj4?m<7*y8wgsyb z%bsfcQXZo*Dt@6-Bvih9h(H!gXrXB^TDSJ-Gw$x_7dqD0p!l>1)$w=^#`Q8 z*n`JPR3q89Mi58|k$ibSy``lf{PzmSIp4Q#MEES8Atz*$DQ9p~%|E>%-KmDZ4wa5X zb8zom@jNx!ISvJ9cDzeW*g{P|Ve>vyw?}dv_y1^Rxb&8}17*$8g&d%j3j@KEevlxg zf5ajZ(DD+y6+jTo28J`Xg-Q?nj)JP*ZqV7-?R324<8Sabe*>#PtM0e$P#U>O5G~+N zgCPYS&#tMZvZo0Z1rMJ>h12e_%-0vJBBG3MiJpDVY&QGMu0MU-0hETS9HcY6+jkJ# z2#1Nbr7`8TN{>Y(7?s-Hx6Hg(B0}*ff^>{l-Mx0zDAReue03?NRuIj)L^B++z&g-bq<;o10QNKv3`SPCClj}P*T3A>>s3= zKb)?fZJu|%9X-Ee^Qh;KCtsf?&-yCE#vZO>0NAdw@(w<{7?9msxiGF_-W5bgw?Rsj z`^E>*=gKEwjC>i+r+Qnu233oBIdH}u^QEDwoUYJ8Yz6F0IT@d4QHwZ(TRRjiOkm*X zML54M!JBZ#z}G=_r3u7Fw@)nen1iI7`~;T&CAh(Rt&SNL3Vi*8NYH{hunXyy&Lj{j z{)Oku4ve>?sD1#V@x{@D43`g2&YS_V>Q1Ta@ZPF4=tj;!&vFMC;0^d7XV~R@Nt$3> z-`xgk2LtfX4Z7ZHu}FX*4Ih74(t9+)m{wO{-fVUj5%LSAgP|(9Uj!=oBluA^`+m(6 zakodE*h|=>Tz6k85w(zh2@a910|y8L2N)nB`-rH#DHsTAva^^o0@{)j0uY!dC&@fu z1r6kFfbusw6Z4$0|nBx(D*5q;T5~}C5-4XxuMFl4<}&2;aDDb2M9L1K3Lt|PZ(HE;ngsQ03uMU zbV2?y0ZbfcKl!xHm}y669Q6lBipHV_T!e!XoFb?|d;dtZ4+Y@k&SL1RV$RmwFZQSUEzPG2a4E#8mT(t*dICqN=70j8|E&`G7 z6`R@yf=-_4zk`v6K36UDg*LQ~Rly1F_}m|+nuugOU-fB<$maB$h9b?azAxdF@!hMN znoIRa7YgKY?KEr4q$=0{K;`TaFsR*IyOgLNA_g`-G7#NpBW^1Qt)KTGs;r<0Dn0HU z53{7Vutch~I=g}3tV5SlQse3^@72AXPaWmQal5?vg9_U;W}y^<6Vs4KN%UmxBcbaO zy-a|GKBwN)8A#bmwKfU|btm~|p;TeKi>7>JhbxcCT=iZN>1SxwKS^zAWqU-|D}{}PY>~N0-Ml%AJ0r-f zS9}4eKeDttsS3`4vW;s4e@_a&n+eV4i@Pe?+WN&og*ZZNMc_~?>rttnDg(o68r%tF zPE{9_AYtIv*DOJRjMICqf8enFf`Rnit%Y83-|(ejLN_I{l3aMQLiW`XPI5GQMbaoO zCn9_uRfkTwKqJ9`0y_dfmt-4MLO*yZt>s;^Zl$?Xz!2gR!?d9cs(sr}67rOIUlD!d ztAxB4Cj(zt_eIaxVF+VcnEC^7B9TyMesmih`rPfr#E_0F5OjDh`i`2kZ%Ti}yJa!7 ziX>aWVV-O#V?3~|ME19G9r&|g;wnQyoPeAjmE0t)>H-<(`?~e4mSUKj>mo4aoF$%9%K01 zoi1jbdAc5FXJpML!FSR-$1M0Pv7wE13bweA`}_9vy7Y58mD@r3;p|CZz+8Jzf*j;- z=sQ1Ft}4C)qM_cOl;#tZi`rMxLtEh)xm&cqo`>nYSY(AM!X@Bb$cJ_829WbETKRIK z=l5l!nG&S=FAI0TrV48$zv_<0hvkWb_$S7gv4E^$i6>D2Je{apd%o>kqL!lY?>$o# zmu1i~6gyQd)@2>1gPez`w{&&%v05#>K#u6cS-K{*2n_Ga3QP<7Psm4lHzW{sblX8D zlbgB@X%67lvB~ppzLfvyLyyfvoFvVf$9W;(xLBj-yt)ste4$xe*_#8g+av!ly{$qAaJn`u~==A-s=Qo*9_`-5lqgYQDG zRA%E=ICWLafN}CEM2BUE%SCWJQy?6Ww11rOLT}1m9*Di+qzP zQ!OSy9faeufQ%e%c^fXW|EhXY9;6>$bbzZ94{BCw7qy z&rb}rh4Vn0;;wB1sgAADHkS`ZC@c{1GVd$AW`a(oxrX8nKx|F3XIuJ(_TD|TuJGy` zd*FFUtsO~aA|#V~gu8)Dpoklq4J%jXF|&HtAK2BgJ=weZPK$zQ^fcL4SGeq|s*SCq zX%}pfM;XCcxco@*Zl%i85~dI67lf;L)NbSUv}GB0=(1DlP2pN#QhWKpn$Bfxg2K6* zUGXT+pyrAky6>rrFiBSFQ=Gc7BH+dm^wp3WDXe}IS;5gZJfM96dmXo@rEpkX`tAqhMhUEA{zBbdk0J-*7OI1lu z2{I26|K4o#dYG!SAt!sWK_(3MCOuy+LPitJkhfiao5-n6vktK(Vwxiprqb~`Zj%jz z5@O>MUwHdB^J=*Bv;@r71o$2tWkdZaFF26BLFb zz8W2yB_ai>0&P>z!?!+Xw}QT)F<*pa^!KC4<1Q%jM=6Nl*nnhAL0_j-tG6uqx}WzR zVcF@mNh0&I`!`A_XdL*mJD%(*_Auk{0iazpR)2?5$QR|zN>Gpvv#*@HmlICN3VD`1 zd*M5!^}ES4vXZ#ah0U&@9-IJmgWKk-1GDM%=|QIIs;T!=5OdMHQvf%sqW${(0SmQ@ z_rkrg%W)pHRz!^MlfJjmQB;mO=iJ|{E=FpOitP!lV)`P>w5H0eh2HDCn9FU1ZxALB zs7?eao6xtevX{Hn_v`qY#zK8mF>_Fwy`Xw|@%|j8e9$_0OBKWcj0ZocmM(FF55U{z|y4We?n% zC+CyAKJS;p;It3pK7pL2X}#`6L8_`~=@CNSy&{n)ruCA>`=F;PKDxL0;e9DAfNID~ z02SJVi?n=BGV+SYyuRx4-ZmM#*PK;zmas#0drR&Bdpr!TQYFWvuaecUAX?tHivM423lk|vqeLv|z41zi~6v;)S| zJ_jPIu!a?(!}C8@--|{yl&w1yAGP&l<)JM%i5FAIqW*v46|4H2qZ}dYhyk8r_f^P2 zx$xa-+`Z|i$G8Gt|MPUEZ!mKH5(MQLl9eNb-m!=5q`c_BM2)8O0yL=KR|?$0Yvzj3|X5D z95{77w|oR&drxbFZi$K4P*nD_!>zn*zEe4y1N#j@1_gE3w=Uf#qR@kssze8|;up9i zSwQxku{~7wQrVonX^qWl8vU`h#QJg5v^VKD-*=hoj>uWm1h7>d9jzaP&hc}Tg!k%N zXR&KHhfQo`C}&FH3U*N*Be(?D*|Iuc-DBi|^C5jG!4Cn!i9X+Rgs4Sxd6+w5i5)4x zqDeH>MD}$%6-_1|S13-QCV#tyA~@YoHGe5Y$pa3chH;;AZr9cC8f=K@b=O+V2vyP* zy_(MqoSJy7)`6=f7r978Ssk#@VT~7>lo+*wTaKfwro<`o?q;N_j{Pj$1b5Mw$WH<_ zvQ^hdVP1d5xFvjDqg~WiP~=Hm%V^dQIayZRB0Gl3N+xfdbgM6M8B!^Sq;gYIWK-g3 z0C|WfT<)-?>xu@Y^t6yOt~B&$J145@wy3IuB8xHR+2WvuE?&HZ zuA!CCtgQvo4Fh~^2Mp!|WRs#xsJ!_9kh9Szz~IV1!{8ryp`37eK0k3KIe zfZcS+)y06A{~y_08!x>m0KC8mu9o(|ZU}z+0T|*(GvW7uZ(IVM&%H1Z7kVvFfBiiH z?C|~sd;tt9UO{#Z^zJnO$DU$%UjjY_bkzO-SnbvE^xg(&ok}&Af4&sE;`~Qg@YKEO~5eFuy0?_f5KR{%{N;T%%l~1qk zO%6j#o|o$kQ>^Lp?Msy?sR!KvP{<7OSD;zgZXwk`*A%9=U}_N~0l=>zbT;E?ID6hv z6a}6IcWyn9PG%Suq@v_BNw9Yvp2ExU06swlS6LZt1gKvW`9HuphVWw9_12c20F4|o zPT}p_yqmuhB%mYoY`PEf42=PoTZ^Sb$Z_Xi7_U1+4{PrFUV;kQOm8Jex?>^;{lH5h zjlN^_9qBqsXOJS8Cf$Q)sAvl3S}q3YC`)K%@Z?28r-horsX06VX&00@Fkn=I0q_w$ z_DBB4qbE%g9a=bYWc#>%zrLJw6=$lz+$r8z^CRoeTtaUZLlJoO-Zw*9BSfh!i#oCv zAu6I#+1E+onkjaQq5lz$YPwE(sF~u*sN;+2HmGoJOwsD1qJIZUiI%p3`EIUwWhO{C zJHSCSN~OZNAw_bpU5^Vnq?RkqL?~)STBvl-M=q#S`~5&zwwC(*ApH;h)f>nMncVL) zB|l#feqILHrXCthEfEDT9L$_V67gca4(bk(Hg`6;I^$KItExRr_*8$**Pezwy< zIYzuDl@ADVkso9cQ2}~GGgs|lyWN|@|ECM)6D|XcI}YHAhtxiA8bt8@Twa7YE|=Cl z|6ri}C-GG0Ac;?)-OUHhaqp|>k?ZZvX99a5QQ01*Jl%`%X^J#57T?Za18Ge?5-qvRn~ zCL!1h2Gq=f%)P|5aL%q@(v!jOHr3awYx`qjhBsU_MYzwtG3{ifLxEt-w_Q$>L)x*S z67epA$7xYNOFkPEx(#lC}i{9}IslZTE~G1Y%* zkV|*kLF;lGC~Cp4@2x5{R4_9%M)wyO2*X*?5z`?YHYJjig;K_#^q-E<5U=)@ANu zJS|0X5Bl$_fe>xclo^yLXFl5hCfcM?AMKV#cDn;EWc5HPN~sPdZ!az7>MB-oEj4{r zH*EXhVH^u? zRP&pubZD1zhpkxrHwG!yeIj(4GCu)Gy8}S1j^4-ANp6sMjWq4Y;tA>y z=BxQoVNh}(ItIb@GTig!J~$BHu2fT9;j(eek>LTg*|Gb*W!#(tQ0I!y@foz%;pwW}=N-!~C@$CPX=ZL357 z-DrhYaS#k<&cn%&56=i;(f!dCsk%s%;<~%vF4C2pU&l2BrN*G(kC@-`cPX*nWNRs0 z6GW?&achDBIwEcAgZdYWqJb{&;C$x#4C3QFoIMOSn_Igskf7n_dC`<=t9rA50pkU6}+TVk-5H53GBy>^vlXKhOBmSZC1c zm88X&B=kzBl%v=oLvV}Y^0=7PO6)+Z&&C}*<9)@Du5Qpn%@Ue$>-B=j%AP`hNT0IqJ3{(l;R!%0xXE^#mcrTQthH!ubs<<&uLCuM6=*N9OGF>xO7c*^35(sdc} z$Fr(u03t6}Nk(#Sm1M9klyc?R6D3TC1!sSBc;_1_3WPHBNIHAs=dNTGeFuwB8JB&Q zXKKd))xkZh*_kq%jjT^*9kLz+B+u(*)GYcGI2a$>oqMA<(nEC)k>bv~|LA{OxEZu? z@OHr)3ab-iqSDrx#>A6+qZDlmKLRn+Bf)Pxs8w;f7Bg!?IW~=xk5S>H+rLSjI03?owM?cmI;f9b~6$hS8*;!1uWB4w{o?9f@N<8nZO z$tL_~WrbgmtB&{9XVH8ctuikUYc)78^ilCm&}&GHn+VBlH)lw0FV(RbJ{Sv%Lp>J#6`I%%mlb6}FjV~h%{v|ZKl82IX;?(zFda@2mnhC27o}6| z+jTA&`8W{@n<97uxmJLw?n3AmYM?+9P&zCw@;FsMxymN=Y-ZOB&kCdC!^oeBkU)N0!Get>8XSN7C{HU_b*exQ4xgBUV+naZu`U*Q1G#_XdNI~17h0UKja zUQzkP_Bd5U=N?L57csVIU;bVR!Mojs_Wei;Ya@8@`p0!MU`=TER>oxEvrS50%^&gC zNXQu|ao{iq>b7^)ZhbWU_ddwI7ZGC2K?N=VFy;9h*U=5&4xL~Whv|q$HB#02`f?Y0 zsIYdIYq+i|42jWmUCH*HRD~JAWVHA16*-WSR8!dD9MdsiNRFb_Odj0M1%Ch*Gk(f| z8LsLuN)54qW-c>jNGo|em*a57$=Yb5zfS~{##Yu# zPSme8^L}1XzV4!ve%6Bs1FrMDE`|J|bj6l#Oq|jA%B1$!%Lok1;Y;1#VyBW07Z(!{ z7sM}bo2_fzb4Wpm)weG03pq8Ouu{byb?!IP$**~Oxo~#f3l6te?|PQ>I*^ur@#*d5 zMCn>tm{HVm)7qtH!+(@q=s)f-n>XmJ=l8}V!lKBbX|#{4)kC=YHx zY^TZjn{>BwXxC5jSaD9<+DgriL+KiufELjsiYLxUx$*&|Rm4l=jrx^oShv-FPKr4{ zg|cPmM@YVo##!>|GvSTuY}Y8ifADEDmd$49weIC{eTU6`j>O3d&tD8J z>GfcAA(nWlKJLs_=+WN?nKXK$1{-#RMyZa_G&JN;YFb>#z47ChIjG+YJft3Gf$Z+Z zMqL5(rn16Sr{ndjvkpav6~|^Fc-0sHiTJ}8|qFy3lMRgZ@MqipuW=qj=6i4U*ZqDl&ZaWAJ%77uq*D^2q z&mUnoe!MpIDRl57Zv36V@P}_E+5|V!4@j4y|MD7G$FH2CyE!ryCUz0X%))xCA={J6VT)dm{cx8E@57|(cucD`*|#g}9cyY6>Roc*(Y(Q3=j z1sRr}ql>YaPkugZzqM%~YbEy%D_e;>~Sr+_V%b%_jIf7?fHmi|t3y`!$iXPgn#DK4HAs2FDSRFF7O zm#F8&+xH*7^u0I8V9)m{!g_XCGXqcaZ(sV+P5kD1j(e$p-eDUaRQs%Nfs6WId@@LJ zV;5Sn+<}gq?MBap!*0~&-E;c)FR2Ft>`U-Rt#O|smER8={M(ldS2MOuGss?2L!_=T={}X#Oyo%%3`yG-eJ!IWmu|aiF+j8f3sAv%O!8I>&^Yiofs7C;2P~mRa>!x55 zyTt~T@CJvyqQ!$?r~vS)$STyfy(!p7te63YIBQ?exnw;s)BLU@(;DE0O9XukzCVJHa)7A)UKlcx%PXTVh7t(;d zIa}|D1s6Ww(&ylhW4*=pEaT1Zr~vo|R-vRes|$EGFDlzhLGIuj20dgjH>!O{;2H(<5R`v+p#->kEntrVedhDQ^e zq+THGMpb-j$ix(MaHLCXPnX}^40JeuQX8^sq8D-a;fQMI`-<{(5K!^KSmvlZ^hCBn z5)1E`CEWD16DC9or;8>g?~_s7S(Qd85pf_^)G3MS-_a!X-TTH_U4SXjjo@|1B&0V$ zM^Qn{%l(Yldtyy6LBDG^_8i3q@`Ze8P1TyJJffBhRIa|#KJdd}0NDi`;MRbl>glbv znh?*6e{=VWPb2dMhA4lIKy#o5`IJwQq3{t|X|YA0%X_XHAJN&30*H+KO>4O=Z}IlB zv_ZXC5Bqi`i1gvN2p91mfdp#Oh@un=?q;VPJ}D(tUX2AxXFg&R0&8ia&j~Q4 z%_#{bGeBy76ny?s82aD>AUbyej_H82KHUDCncwUQ_hXxH*nsfBx?~Y}C&fSR*Y7K0{yEv% z;y7h=2W(*Q6EgWV&TFuRIt7%cbs*!5Et!5*1!A8&aQIwiOBQnl1^f3cvl(77W zF2{*Xk^N%rQBAyA`kS>!Qlh&8EetZ_1{J*nu>zraZonW$nn_Z#LtM$|CNTFV3EWw^ zHiLsA;6;#ME;$Ii(RVLT$`{o;IB=5T!3OGC)C3p@fhRQXfq@)EgoD7w9XZ@eV8>M* z6A!k2&01Bq2XnM&d;K61E3+~z zcTtWLvsf+wf-P<2^iex#moWo!o&$q<%IWh^MAAOueeU$&;NPUaGecH%&J*pt7GU!6 z!n*1K5?mqQxNP4b&#wUZJC~ma+12KHF9^k}K0qX(k4W%?X-|YEXl}~=!sw7Ou?Yyg zsKZ!Dm*^~3CtN`35{_AZAGG8Y-*@Mx`HKVdeyNjt7}A81b%FYfpK!0c_Ft$P9sM$5 z2oZlMwH!SSLxYgWGM$scR06MLO796q8w&kLD}yLG?q^AbeEtM!tx!wno0b5IjU?SA z_{H>=5iXYY=c;{)k?2-gPD}O9GxZgn6VF(`D6vDZmn6N1$qdCaGw86uHBLV01>8m9 zX4xufMXZ$Lm{FX@eHbCRl)5?P9i&1S-H;NU8JX#<_0j5IY(A&)w#?MVwp#IV^El2f z0V`>Vc|n4f!}u{Ghu@H;DmPds3^Aryo?%<|bl@W+y@s2(xR@qIw?uA-_0KRb`4()I z#KwRCLB4jU#VMN(kB|pR(z+%ogV)N}7o4>Et&;A8&!T>Z$IPCHIsK|fgl}N5wcnG_ z;7syM4gCOvl5cV!kptT_Gbh6%3}2%_VOGi9Q+IDf@uRI}HFT8euaXv;%cJBFZVfK4 zIe*O}>8LTw&tiv~AdF2q^EJ3uch9qCg5+DlE@jN_HK%YZzfBFs79Ff_5e+i%v%6f9 zw3oPRgH_*ege{?x-xy^I?lP7-hnN-9v+UKqYa2i?oR(*GXHOtARj%U;i{UnGx> z7*O#?L336p3wGhP-c$#lni}mmZxqMhSl0uk&}ci}4UjNi-G;{zJM^@o zd?h{^zA=A6?(sQ`@dSQWmrClf=)8Rq;UZBS5923eyM(c5vQ_MYi97U zeD?;&b}bk%sEFRsg9~hM#AKq#a(x1Z7(FCH#C}l<{B|fa4F?uE#QS(OGS>`dT9a0u*UVNkY7s^f1+&nMv0DtWon`{};C1m%^{O{q zZrUG`ci-puR4!^Bw^KJjnxUwv#@FdVyHHCtyUw&_G}zua&3dHt6CX!E#<=#&8itSF|8crHtWe7ux*i$%ElCyP*ei$&< zVaF_1ePqy`20Z_G9p{zjQsHwb;6sEvkUG}<#l4_*S1nSgoR@X!c?`up{BAYVF}1*<&LQQ793`IS2{%*GWhi0P zFp}EqK4M|kyhegFexDW{UIbKh?HPz*NzX|imlik>*e>2aTECraccHsTbBYZwOv5Z0G$h z=7U8E$4MQL(c$c+L6jxgk`eqff_bHM=lmCKwk*3o!B4(auJi4+w|<1;cv@Bk()3Rr z33d(^5|b%y5x_jQ>rlqN^s>4_>E!nDEa77GZZp`Fcsy_jrBpgH@7R#H&MMuKkUk)s zp;VUrxg2jEH}*{@HJ!P{Ev4m&g_QcqYq&`=t4WRI@FS`sA5>f+Od(6%{qAFp-4XkZ zx=n>I(l1lzziZfqhjP?gWigy*Hpk?d8(pw-=@E>VVp(76Vu4&>D&Pb13lAe_CV*tuM#dH;RxeVRzZvJZHrfhJ08s z!670;=w=u{ikZ?fX@#bTxRg91pnP2~=fqOq9>>JKce(u4iGMzSsee9y+cY)P4Vvb4 zl(d{C2-5?Z%=&3@jeDcXRhr%s#Mw9!RSGgKFJ#Bl#?J7olW;lXY^PHEh5hHfLlTDa*AYP#TgXbH+sU*e8DV5mDX;FFi_MgC;5T zUTI5;DJG&tdo*IVe#jOzmSuUN+~Njg|FltY^76T z`f}v__D7-v=brzy0y3n1JMWrl=ouR5dwEd7Xm0RPC6$f;?yQ}4598-FABzNdWawta zOq>ckU>?__UR6e5JwEHy>hv*tk_;XQJp;NS&C+k5VYy3#)2$-%Y{#}IQ9ktT@5Een zZ0tS|sE7{g1bxVzEd8l)JM3P1Rebv?#SH1!VPU#vycF~8z(z5ed=Q1GNiF^OAy% zX?v!JgQa0`_;fAfvSbsP2U4mQ&XBpOuHJVONdF*D9v?BMCM=f5xbIm5f1`kc!KQW# zb849Tzu<;t%d+;l84EA2c&b>27S3vsSpXq~r+(J@srV;Xm;-`dx3}lE#nHn0 zbJzP?lhO}rD<%#aUYn;DW{sY81es}qP8t;Y- z4Ou_2hZdcO*ZYt!vYe|$TDeijp35OJPnTaQ^S!tE3JdrFt2E_OdFr0D4&^KM$z!HY z@dpLwFn|;A(YD_n9f3g6re5wxc{F#$pgtvB1A!yo+_JxZLhyONywmv&o)^2lff{1B zkImLeWqWEW3w3$@>+7@nyZt|#Mw2l^c8xHD+$BA%6g+Kjn{2H{8_NyIgkCaOX?q;Y zNr%nQ`AkY`x!@ES5|$d@W)*xJbFK0Yi>!FSfmhPE= zyVJ)e1Klfl)-S%A+iZ|4Y2mXQeeBJ;NrJC)eBr_hU3J7p(v|ilsW2cOO8U=QU;bl2 z5>z&6JNs5S#BLZWJ|!0{!>u!m+5=LzX2fLO2JEKazxWQ26#lXoNEPCR+?Klb2<&>% zF4p?9ioPx0a}z1Ffo#Ity5t%9APEnG?}>g%nGmn*DlT-Nm({7GfvB<6{ljYS{`B+r z8s@3yq5HNYRuL zp0_$m8QH+U1cLQmQH{;CY+dV<>F1U}5SC~3MCu%MVc?!jr!ZcR&& zL0ZCn{uf4PwtnC<$wBrP^KXCpc0KTNoCGC@ZC`VUo$Vf#8I5?48VT3Ir@!9i|L@y= zuLTQJ_>VQa!sZuYkd*{}yTTKcmjo6UfE@G5(OD zX*XECO(8pHusTHF7bs)^B}nSa(VQFOH2mn z#c>~u$R{9kCKqWI-WmvvafQXpJnlk1f|ALA8MeQ_DUZf>=EZfK4B_B~5Di2XzFV05 z4wM)U;E2Dh`~enoCxCeI>$ku$&e{A3bvPdblS`KxkOC}_BkpftX6Tg1q%eEborhBZ zl3fKPV#^A96X}<`mpLLI!LI{oQrClb16@7M$b?IpIrc)z5@r$(DA#7#I(8Y(K_=ib z(;nC!x!8~?eq*66ICeS6@qA00!tgd ztWF?R^pxuG4SAvgdo-N;t2wt+3e=(?mmbAG9(7g24OgZHs~mM2a5gFzzAB_q=kGar z9v?xOtSO}N<*{do%F!R!V^>x9hASDkBaQ0)wL0zJMjRo#&Oy6Vy2T>d#*2|@ywdDgD3k!lg z2meX*4e@Pgt4Y6M+%;nftnsRK4`g>scRT7E-*>AwB_OcdvS3A`$Nf=2ho!b;3_~3q zv)kzL+J=S#xZGVj>aAI({k=9Y1GSxYawbdLy#S|KA3+h_HW@a}8hlpUq(CZX$o zhkPGzHN@DWP(uWxFTg1`%uA1vahcT;X2FM85`F zsKLFGlnZ4WE7sgV%>%h*YN$%9Bf4j_I3qcRE6WzbjlMkA;*CrpeUz*{ZMFQaA-m>N zlt@T+OuXp9SlqZtz#N zLH?m0v3zdh(rGWF$4N&a$J4kq3rs|*r5>Epd|@&&X?pKS#Fc=EagyMT%Dv*?wXD2m zqcfCpyWFlqHbh*B0{Pnjcx^`|dJd!kfySYByj*||JQ-MH==^bW>|wRTfCf6BJTBnWi2QOESAg(nEE_r}%`5&o-5jg> zs!OW1Iy~jrmtl8b=a4$!#tqQBU`)eP8nsDDa%y9OQ%CR-N6_P2>)(P|=d8(*y+R`j zyNI}m(K12(X2?;mQGvw4<-F37Ljfbu}QVumxkp6t4XsDnOZfspqzUPNp3B zSVRJU6UPZ1=afZvq5VIMuutbgw@K4 zIv}O=^GjN)JQ3$OaOT*;w&QrP!G|dA(g%d&hh6?$O@kADtdypjzs?kOc(Zy1H}nyn z7VWuj?-n9ChCY_np4N35P!a7PQc{gl>b%U$zPkd=CbuJzU;rj!w?d2IDLFGc3Ki@4 z!{z?+H%{3cD+_=BuG*RT)0iWUIA3O#_D&5j>q*ST3u*39jPf4X~1W&M}M_rOXB?e>VO9zB85bO9VgH^M^^nrGEM(js*ecq z3;3+|{Ch`hAPrt!NaM(@PK#kb*kUS5&oKCE2pQZq(mFrp84+^*>mLt7C(N-m&V1nU zAiT@lahJQ0(&D^$$oeoWG)@762Co&ftlbKbPlKattm5++6xuTd-Muptdk4x2nb$7LfE@+belL21C=9a+sIl z$YN9>%q*uPGOT~V zqvo+^mk+96o8^5oZoBuKH`HU`H9ow=WMguPjK`d%ji>5vJ=X^ zs|zCqqlT198S8#ObEs=0f0iNX#{7`hfFV`DWSfEZr)00DA8x9O586GJU_CH)jLWl| zHxTL!&hBh=Y;v@1ts_)ARpkUd3KwSfxUh)iP6cZ3Zhyi#Gh1bT}R{Qg)YJ_em9Rs>Gtzfk%*@{#B?BsR)&PTxr~fUVhI#@pqx&EDJVvwZ547?xz6V@D&+5*xR5bs364&K z88K9iw_%S;@7KB62XK-d^6HPgn+uqterFsB9>=;-aHJZoKSPg6a!&(~g)R_q9GZyI zYhf9Wif6mlDt$Hjk&dbX|7nIn)m`SVmnU5GYfpFo_b1+5f^tOj*DW7vEHiTdji%;n zg-Ns(@|i@u6+S2U>D~2@J?;h|FdM&}^5Bf?{^w`g(yk_c*%Big*-K_NsT&04!Cu@w z|Gk-9n_tiijkWFe=Tz=8ZAiTx=GGkp&2=a*!9XZdi_f3IA2s)EqFj_AukDH;(t#Fa z5?qlm!0U8dm>i>$)mc&mCk^9)Md-5(pg$MJV`ce*RazfufL`Q_!+BAQ%F=e~FITSz z2ZWw)lwnd8;C^rmt6s%u-0hfW<4jJRPhAo;z_Qd!uG$beUh+1(uJ^V60QdjsAWUg< zt1-T?!NN_vsVHh+m$&m&yTV(WBkh7p)c5I)F+j1<%b)YaHO-vP@<;Bi_{3YzF^bRc z)wQh;1MAt5QbjtgzA)`VyTuEUkxGAxc&?L~kCRnFV5pC``{#yVT!+f>h;Nk0bI;Sn zxo_Hp(@M*Cgrcmo1z8r1(Hb9yDX&R{P56gOu;UF@o2H4Cy^m}eGSYgeboA{rqb&XTH`KkXDSI;A8q&gE$>oe!B+GpPF@(WU#@2U! zs8Ay*xf(Z2U`o{_a*oC(S+>X;l+G$+HJX(yS_iqq>80Afr$3E)RN3Iq?4!XQIZU44 zJL$-|_QjFZQ-&RTFUXW@cd<>JuR9^#CRa*fHsXbGpSJi-F% ziR01Hqn=T9*vl1t_EcHsHybdq=7`tZji5p&6kvoO$umKwKeru9z3j)mu>8$4M`K?J zge}lTn`+|)x0(XM(oC-H;67*HdHT6iz=Bk?Punst15xK7)<(%a$gBRdwTB*&BVeWN z$>C@pLAI|zld$YOePr=~)pN~VseQ@2`!BOO{I$NDjc>Tiz+M_9GU<7m_SZ?D6z+_m zT;+>|wWv0`f5_mUn?0J2@x>>BsSaSoj4=BjezUR(K633uEPf-)Xa`>h;yJfPLK1l%VOk5S$k^#gVD{xgV`ScIv8Uf0 zyDnQxb&9!Y;~C*}ce{ns2y7eKO$sj%2_r~cJPGM*l8)<$TIvTwkK`Oc z54{jKZNR~+eOoa*Pe0ncu3z1yvp@aEdxGpto9N|{&dMj*e{CM5%CWM|K0PRp*cb2( zLspV#e}+=YSV)8L*Y1&Of^|H{u9|4`HuMNt{DEzkHGvyR6TWY5x;A?u+3^+!0z|`?UQoj`FAKyXm@GF_@g!gT@gFZmJL1zPTCtDUh zaLv!^6**hJsPhP^gRzn6zzFh}tcI$|U3oRiW*BR8S~Y64UKp(I_5#zLQ2-dSg#%By zAA(DtMD?w*z*I9Bx8I39XV+86YM3~&Qu;o785lY}d7KW>U6B6q5U^t@aMUt?F|qmu zPyip4vQwH2OZo>eIy`(OLV&aSmShmSaXC$_D0u0@J0T&N2~0H>YooGYCnv(TGlUHEo&5Ivf~9= zUX}%&t;Tg)7*)Y;S4cO|Q8Hms47d}nZAHYd|> zFTB#=ON8a*w--*+o9F!IX9&Waqd2;QLt9!$Lg+tw*KBa*-1c8A=xiE)DQN*qK^Ans zAeC*EJK?u~{qN8bpjrOH{oxfjtE@-iG0cYTf|#b8HO-9ND3Fud_j!aR zAg8-U11RckW(Pj8dDX+xX@SDNkaZ_aG_BdAonMh&IS1ba*;0>nO%U>bbW|x?P}I+} z3PFSCQhUI2Lam!MLjM)~L3onT?Rv&qwn)Q3;H#t?LySy2cs9BO+JHSfW~})W@;b$z zW?CIWn;E?IJ6pg9^6P}U^>9k^`g}lzLzTuNb4cPs%NBL7Q%@8a|IN6v6)Ql}r;s-3 zZ$WX;iP&>sK3JdzW`%V~Im;w|H#g6kXn<3c*AJvS{~2jQ7UW5<|Hi}|>xM%~uw;BM zh~l4p1EfaMQ|HOo{&TG(9JKK9Lsz4XUI%9LSbBSaOqiFLU|k;k7ojn?$^ETJSC6B1 zCle6cAz#%P`G*3Q^eC5w6j8^kY@FNx@npJwMa0g(f`dYzH9L5if}7mkKeq2F zT-U_V;W?N{BB0gXnh{H2alKFjSJ$kqAy-XgDYJSs#An=S61g9aF+39&? zY{!5_R)($Rv(UE`%EhDolWp@C$4%KhJkT*1u4vWVj~@qEP-fVtmqFQE-gLdKRUtux z#n8|#AX!)BPNs<>(IsN}hPlJ*8c)Z$p_+WJbvLBDS&jaSuP&MolzN?wPl~4(60bg2yX5}rVQ{m>N&V?*RKxF{}m`|fya4V2%O4uca* zWl(AM-}*zsd$sxQS^HZj3OJfqt4ttKJ4c0>$onqzX8i1Bk~`u7L)xctpd>(!1-#IrK5V?P`vm8r4n_oLh&ky!_iFf-_prC}9$2oJ#?hi}%gSHQcw zAny7{Kk1$&o?5Vf+70q8X-2rjfIG@BuEDOjvFIYTQq9z#bGw0J;D*ye&3_Ff0s}~P zT{{@XI!d`f{-_8zCO%u+fa^;O{()?I*=CA|*u(jhr=G3+t9aGOW_mVSNuNd-f@~JW zK}K|7;8e@%5PKfOs8uh8t*nyNH~L#)`y}!# zVGc!+B9>vL|ICC7)MpQYC%W4qc+681BMn}Ylqh_3DF&3gyWk8^0!B|g1@+)-kZmqb(w?kKDefUvb0mA|E&a-_NE0 zPZ2^ZiHbTC5R4ce@ z3uiH4sw_xQ`G$e%1C3;IOm9(4`fV3Rn`y~ZjXmRRS&oVp%?bVt@o96)Jc4nS*I!$% z$f&3!_Xok~08zJ!8AtIWF-|8bp>aL4xEZ0OS!-4)EfzSVO~@_;W(3}YL-qWzX12d`n62%&f3QWsH{#W$mg4an^H2ze zu9&C{Vi!SEF2UYj*5b^9;)IlnqE$gXd!*$dav-wnJ!t%0=PGiJ1~u{d#PWH7nhL#V z%2w@)5&!JyD}x-B35qlir&J!{WX2=br5x{>fPQZ5wM@DTe_zb`e6T`SKkbB~R@3O> zD+Zx~ijmpqxOpCk%i`8OmzC$dn}X58;p>kjRRkZqLv~<~12gUQ5c3|meBkf4T9A6J zpyS2?UAj2pTRx`M1cG!3(LiXUG@L=u-6|P(QI8VmzAat3Y5;U32V0Dq#J@XUB zXa+`0e6>4CvIbNq`D&j&a$OH(FkMa9P_bK6DT%qqOS`D7q3ZOJT)5--W5yfs;1{^# zpa{U>w*#2>H5=CS}`tbFneIbs}^GSEU;yi4g*t(SU7E=`n- z%55O|fwg$*2jdBcAx(WdRZ)V9aPJU(eO};esvZPE={y}hEk)rGl1Q+DoLalyc#%0* z4|bF(m}$FQ!yc>WyPq1F7Cig(ggmy&W2j`Ns$ZE#*W z(&zMQOgC>zO{FLNmeEa}|7qNBv=LZmlKi)yB~bK@Pa5GxbhKMd8Iqd5a@ z*@9y4h(&M{qU?#er@YdRmVl2uEN1ihLBoU#viqCO3=h<9$_T`XdBe{J4Hre|UwVOz zS&wnKo|DBL;@{K5#R@mzgHOj?;H#)^5IhpqZZ*}M_^kHj!2Pugo`ts$+LvGXQW1Ys zn@o)g!q>*e?edbc>3e?6&7ra}ALcT&eYKN$L=!tE^EQpNk5_=(kO_-FPD6rkQT#q- z(@gWhdr6jl^BsD!tMF^q$ZaJ3f>P4cLjhdJAU^jShqNGLYV$OdC4BEch;R+_8tW%5 zrY;(xAnVQa?Yid8Sb0h)AsXbQWV9re=boI@xjzkxbGw9Vts#j#+&W!$uWR3*g3|TQ zpFA?YD=e@Z2AyD^fZJ1YOS%bTNd1%GJMf3DJ`w`sCY{OEv`>$ThNN;0H;4=#e3p%#sp+tN^mRad zIKN1THcOr{NqOo-g8#ss*uhmJ{pQm8^38R7VrSmHK4wA5Ap7143$rM>1m(9{Nejvt z!gN<&x@`E2r=}EyE}01)AfUksQUAEZntb?7w)UVA@->OuUq?Ypd)>Hvu$m~D>Rk>F zO($YgAy9}k;N=R!>Z24M&97)`TJ>d^uEDie6P^ep=#YXv&S1mMeyWRFRleK3j&vOF z@%`I9;)-{^xHbOX6LqaGt;aKiad|V&K53YfuUI;huI6z?&P#?Er(sn?o{afgG3RuRr9Ll0e~mF+>4vC`)5yqZi6M_f`z+g@o*utvkO$akI4h`XvIav|oI21Y9L*a^)Tk1+G{ z)@GpyM`2a0x99Y0`pk%XlG4#;+S2roSEJ6>htvcor~AmaGI)Ek^n>l6>Pz?N%HU6c zqv9EQsWI25Upo{v%o>MA=&Laq3o4zbSnRoOT0TfXC%R4JG(IQ&mRGC5jDx-#M_YCV z9%D{iO7A>}$x41%oTwpxqV4sBCs#2LF2C8&G^LmZ9KE_8(Z0Vu``9*3F!N!pLs@Kv zG0FzIg3;Ff5>WT(_opw!`3YvInovg?zRRwhXVuQU3S2n%Sh*C7Q+e%_&AE7b%lLVs zfR%wZjhjWCk_J_xVAoHtkPs>k755ZQFYV4$!i!36lZXL8c1D+;(_vV# zQz?TF>>_@ac(q%cP?<`6`VyV7Ps9pMM=mG>Rt(juIAB4uj@4G{hMKN;UA^>D+es61 zy2p{)rmJS)B{w2pclMn3##%-B;V|avGJ>YFQ`Y`_>*;y^+RL2d$74kLu!ND>%%-)7 zKqIY}i_^W`m_AL?eK~zOSDIkw>DLiERLF1Be~YYk707vB?;WvUNZK`spcxQ?yrfXS zDOhAlSVKpZc2ToRMey3Fpw7=!o{(5>si#{z)O6Tef@r{GP`CDbSPknu-lkpweT18^ z)8js@E%xO@hYohn(8Q$B)|BJx8T)VQR9f4bXP!T{2c9g_x0=@D($2R{@3Cxul=8!K{pvK1!iY;A z*q4PwjgN_PwDRFuo|-2ObWG@x{iLvND18Wy@jOLzAVD+qj6`!MMzzgGtcdmsV=Qzg zM${N}qTuDK^zjoRiz$-)uBR)&J*O!Ai*s44pwWmjY0RN;dRtkg^|8H&_ycY=wXz}s z-b;<0V{wmgadC0q&sn$g)C&C#l%{9i`{^GG6z^mcV}k}Q+Pf-5>}O^+9e)#}w4?1} zD*ky~loCJH7k|K3s+%yNMg3U`=S(&aT_2wLrh`S{l{}tJ&?)B-G!#?Xzge^-1u(qF zndB%H4hX1ZWMw}WkF6?bchR`%=cOAP&%m3hApu@CdOad=+Aux|46gHagOyPv%Sb+n)_ks5zQE!hI|+Mygeh-zad1#oeQ*8QN$(g7r-Ro zNDhZS&ob%4T_fmm0b+jn7BxOTo-P&aQqfBI?3O3_5bp@tewD<;%UUkML$+9!} zA*!&Pi3_3-a8lvUM7pdo0?)<>8B^_wE|CfIsAZ%Vq+m3(siBF}`*d6~DaLB5lEwC& z4vK3peOB+wb&{7oE^wkoATgid4g3~|#b;eo$_#gQ#hH;FR#q@=BU}k6iKsuJ(TO5Z zhzvZgPgMe_nj7d7)5iQ^0dhrFd$!05c}9cMbzZ>>5Rgfj=BSR@Tkp4+k0QjD|6ygn z+25qtdETId}Q;6Lstaeov0tpK**WkF&3^5cnlHs(!}v zpl>J%=g=$gjgKRG<=3x$Da|%|@0KYzCi2HEj8vVVg7uKl3u`E`nme|YKo$?sQO?XU ztH@&yry(=@3E2hkh{AJ93*1n!R;Wfp`=sxyz<$z-**F`?aNisps-OgGgl>{Yj(1 z@o`SSZ0!H{A^fK!?cq}Xla9yVk{jfYnG#@nn)IUC;0jyjb}oY|k{^TJouHkWb@3Qe zD(n7K2fda>OHY4obN7G1#WLX7z1w|O{T4kwQtyBa7lq)O=>+C^sm>a^MG{FIq>Nj004kk49A=qI|r(`LnjEmw#Q-4 zkatAwLK8Tqxi6Oem7XpkPvtq*K?D9cc_)J|H0O8TVnBG|B=RVN9>O*7RA2WE&>h|d zv!E{M>+E2>kcS34uxyC|U58_i4$=p5p{VYIR5WG8x5NCI63(D`xhXVoDt?4QMoK$I1sCItfSl9-a~L;Wsxkz$|vUuu%x4&(=0I zuh@>ug%ppZ3{7RXsEUWUMavHt$SN+(e}44ip~R8)kS{qm+6q(EX-k?7=2H4+3SoNP6GPdHpDutPr9KAM9^5bSUNw8Rj?y zO5tJtQEmGSc~P$<1LK@_`QUVUjK|m3KWT1in>iX*OKmQzEv_Q9?Y?VJ4fHx8T(fxJ!&uvMfEJ4nJUKZDY`Oxp6)LTv z=p976K!#8Qb-?882DcEIC;Vdkt~Q9jgS#{gR!Dcr1H~j;H4=e)fSWzM zkuMAC`rcKftJHNk4~;f48{MhQ4{s@$3NSAQ;yF< zAAOg6mY{?1g)Lh@G~$+gxN7fBoYBsg-?7+#f6w;&3S1lN5GO`B5><*U2? zw;3DetZ#d|Xv}+=Yl@u5s6ZDXVB0Tc$M0zqJoFa^7a2Ok!hYT68Mp-OMA?G|W!_Os zT{OR&;Bk`5@zquN>Yc~~?_|G}s)tee?>2aHH=Z2nCq4%@?Jl@y4*~@%EdSWRiI(mg zpnRCVAP8iLip4t(wDtL5(5ZC?VPCEde?gC*+Wc9ax^IAkpIs~@t{1I_ug=`b>i<;m zIBRu=l-|B`-tJz@y6ske24~#%d&2iL%Z!)j^lH!O=?^XAcX9XraFYmk8@DtNoF8t_ zJrOwoUTscV!^Zc%`9{w74t}o6Zxr|#L8B;PGVebAU@IE@h*9Z|Z}x^6e+GjO$zj+m zF6YBEh1SylBnAI_UIFSiNpf8`o^7g@@k5GNH!PAi;2gl=5#&a))?T2LT6^)b(DAyV z&G^E_1xAL%(qtG=TRuI(T+u;8-_%_0?G0I2D)cA)B~7~rfGC@bfPRuz4bkC|laYl$ zNvM!0;*i2f)dg$%b#rAMTHr1?YrQFAymWDg6=4<;!9aR)CyP!@*D|8b8!YfFpPyRs zusP7{KEb>Ow@(h-OWj+%IMXTxm?iNaGtOP=pefpHZ~a;0yqWoOHe!BvcqfgU$$5+b z*!R_Ooa*s@?6S^h>AvmzV{!RyeaVN%kX%)hT5&+M6t{?EP{@R!c%D^21?9fg8Iz+@j9r z4QM5eq+PLj28+?3Io*~vM{=27*-Nqk_dupI-`A#o>)d^^e3}nwf<9B!1Nn^+U(zc3 zVE|#AI1F(vkq=Vd))4d6cV*}M;QAw!D8Dh&T0QZG)Hvh8!QYj!bWeHQJWR^Hmr1n5 zZ5dk(o?fodvvx%-^Ro7GjWbtzm(E6TobSFQ@iedf1(vU4ga(vF~Dq%mY|=nQj`fF7}t zC?|e;wczCod}1!sHpqw&E%S)2UJRGpc<{oSX?fdHF_4kn0aiDbQ3gX>W0_je?At&# z58ZQqv5G=3>x5n?9xB&e6uK{+^wL=nQ$t?A3$V<0p`iP|bmCWgLk{n@dFg_%u4A`c834R3T8<7!Km$g`wPp0LR}ntoEyk-sJwy> z%vb7C>ds5H=Z+RhM}s%i6ci?rhowCdoD5OWMWE>LG2#K!((Bw;GxU}s==(Q;|8pWX zd`Ukm-lqGK&~0~C_rQRiI?TD&#QK1@g@7x!&?~nt_tA*4Bk2N3!=NG_f{UEL&Xw>6 zD+52m&~7M{Sxo4%)}eHtoog(#H9$K1CKhFtAhc#u4o)D08!F5W+Q61~vH1e-b^#i) zy2q;gZauYV&!DSaf0WB>0m^7XlytQ%w4+nzlgJ$e&`d?tGP=g zg5MIt1brJUl}@Q-6{TwRR@DvLJj=GDG;4T&+~9FK3?^~3%P?XN^Rs6G=X6=c@-qU? z%e^#zCprXVH=aC4UU2Jb6A$;^MJUgjvQN|Z?kXqRWB5WiwUUlXCUeHMRijB*-IEiN z9xM!S&pBx&K*W8CZTzZzueCL267arY#d0!AnFLRg39%2>p*u2t(gJ6zjf|g+@0eF8 zsb4SXpIH2iKEB@Cb`C7E@!&Kp^MygS0s`yD~q%pTvRyTSdVZE7wea#JSr{of~K{{QEW2i&%!@!ms z&X1H6O*;|0e<<7bK&e^_7IG}#N0vxjt_tAd&Epl2h~fGv+wGtCbd8B2VnKf*gxjV0 ziGO8;k(%4DRmnUM==Y&X#zzYX1i%c+;BadMrB%XMO^RL4VvmxF^*? zWLBNB+{|E)bcjXT*~ZpFEA959kMVJrmL1y@*S+0|>seB1)XVx=o1)%j40-8-Yyx$? zFc&pC@$y(=N3n~2!E}3Kn8~=B{ZhWyFivv9y;ly|w)eK7#eN~oef1m3O6$nZ zt!!!Ffbe>7Als4zU+(UlzVZgi)xxLzDkHAF7^3S_0pWb#c0#L|h}C^!utG%4S>!j~ z?!Bq%yMKBu%ezhSQi$0~3EK0WDx3Og++y7v?dul~VU9M>zP)(^l9#79=ih*tQB_J# zSk_IM79UFkH=P8wa-5vPQZJ1^g(1v(?~(_=wup_~Z7{+IKdg?iji>WGD$Ja;;A0ign?y^7xB`Guv&bR0cJh_g>4KnML!wC2@$Uc|B2=@C-|JQR4*e z0M<>`(%_AD%I)}et?EC)l=8z_8t0&j!$8bd?U|*1`Xl3(NoE6YO7Wbt{Jm4RH|s~INNDqF?*+DQN!-s`8>y;KaD% z?#+J=?%xjwUi+^{e3GvlLJ7dG?m=$L&xVM(6qw;GKy}c)S>VEcN2q;$vr6hFk?E#A zhas2prJDBB1`5JYSFd&*b_*?Flh@I&k<_PUo(;JNMW%ZZ#n15pl_Vre$*|_BT+c(nsO9x1Q zzXIRvG2gy(&%SG{kBvcb^m5ZWunwA3J#a&_0WViOlSfkNFph7BVLI0BH*VSWcprEHM=CFsk^Tm+(HZ)sRK5DSXqx!$t;LVzv`Q2lX{ZxyNy z8?eem!L5rWV9Swzvx>qH=!s_GYLd&9np#@OF#p{1qrd*nl}Y9zS(PO;#V`9xvVk*$ zvPL66>QdIVbeDO$6C-tzhDIC;-ofZZK=lgsjB7kl~K3$ueLpFXX-9 zA^gax1Nh`97x<3T4)FUY@D+`oa9<{c-7LKN>z()RK<_+;V(-*Bg2z_j9M;dBZ`Ha7 z4U#D1KSwaYB=NfmBCEo0=lSBr7r#`m+qT`m&$|8KulL`!U3$lFp&2_K?);4yxO3;e zYybS_-n~?R{PW$RL#O_S{ey=fH{|&{bmPI$v=<9^1Y+G|Ll5{~LaJ;1OP3x(rnlhS zO1^D(2B1_|<1g*vhHzvQT#gcK#a*U<-c$K1>xx9lo5vRw z0ZYCeyfjv!;%M>s3~itdt(+#_3Bjy==pnQZ-hEwC-Fn^{up>a_{b|LX+b4I zpeDK&863RHv@fZxn}btOz&!y3lTHBOECKlsS6<`Z=K+rWZ!(=Oz%R6mii?Vh*2$Qo zNFx`f$Jas%ka47OSULr9W^DBhf$;;6eG`9E)si65XmHuBqIfP88HSr3d1% zZg_+KtNp&?OnKYp81tf(uUyF;%IxMzy~25*NT2&vX8+HS(5P&wIfLcO6YT{UUTghg z55ICPfMncZ;@wMoP#;#C_4zDs46OiAnhK!sW&Ckq7?%GTFK?K`=}%*{kY0@x~s?z6ny6$?iF5|B4uD z5aBEu#4h;4yWfjSTC|wFz#Oh?vO3G0bn-Z z)FuU^$XCC80ZH*y3p4#Q2H2B&B*?47a2prpDsM{*+XPgxXH&4%2oPXLSMqNd*JNRE z_!G|E?B7@@U^HP^iZ5w{fmn-S=ujqfg;Muz{n|Vqa%(OGm1`gJUMKR`k zZ@Fdxb#`TOW@g-R{*dfiR5`w!#+1a{V@kH>bDnRTY0JA)R;i&)#6P}UyP4Le?x_a$ z%H;5|rbd1%6(dLhFZcozoSET0t!GbHy>;bwzNwhm(8fOrfde(Zc@Iqq;#WRarm;DK z#fjq`+pokqxp%eh&>ma9DyQS&5t0%T##PLIPO zF7nyi_)~{7=B@@!qfmz9!@;9Oeo%}%)>3n*|AEc()g?v8yjr)~mXHmr%_=NU&ilT? z(2$_=<2z81x)-LpryyPB4!Pu^l0n0fiLEr_KG-&kCve4+V6OE28ZVr`^bax9c`5So zNe&l1w$4B-(HflrU)Wbn4RgI#HF(>aO>;**d6P|!)6-}>RrEqz3KsC}M1uB@K#PvU zy$aQY7sq8L5pNSL_B)~JLE?VV61neMuC+*|cOn%k13Ts;JBv#WiwC=S)cDU)6hnwn z?^>DDNPQlXVbAaVU+ukTRFv7eE?TBmTM@(nl8m$g0Z|bF5fud#6+x0j1<8^`kPK}= z5JXS}C5YslL~_zb1SBgtDyhgQP~>o)>FTz7@3Ze+g}ziXCEFzaR;4fCaIa;5Glw)KCH4erGNu2W>d$slgNbm*3|t@CLdcjVjp zByLw-y0eAujLTK;^e6d#r78vYV$7t8{e_r^0ud=hwnk zUcT}u6xH=oCz$;Es6H}Wo!BlLXuDQ_HVv^sH(5ZZ7&1vKlrulS!>T)ejdHn2n2#%V zT)lOPbaM)AE{{hQ*zl8kp>kJnL_4u-P`$xV_#`kha;PkO5SZ3H}vV3xC9eQ3b5$sh7=}{~e+mCj~{}RxD zE$ZbwBS*hV(If%raa5M3TAUvAz^PNHXl?FD_iZ<-2)lUIc_OBNX5e^#TTUM!C8}d{UmA20#$o-0c@AdNaFj=0S!>{P&(T5@tMiDS3 zEFtF#JkGSsIWLw9@65UuB{4I(fsru|Jj3u}KVO7kGzlIP-6TAx(&o1eDYDOQD@|EX zWzoG8&cm5Ir^u;HPZVfdu(#^pN@hx@-AtSn(d$MN34GcT+Qh3m(-w63^)*_Is*@Op5#1Ig$ zIm@cAHr6+Ly$li`=<7DB`=mRER~JS*`(Cb0{+9C!5t_F}=osQgeaM#2TD;G&)vk)} zHU=jTL5fWv; z;n=aV6updMGTq|z7qa2{$h?-c5W!0gHq??;}1+r+hxX%@mE&Al`Q~X$u;U;}-`8uIaxV zm~T%vPE|3^EG$ab_7ifQBW#GEs^;{5r~oewAz#w4EuGFX*$3G3%BwatV?XQ_zRvMQ}! zX0HyU!rbbSXLENv>mU7FLj2dTJQ;*7IRzkEd9Vn-@yP2yxRm2~27(v6g^DM*4ZV__etWD~nVKPwk zru|A)?CmqIs7^lKLS|_KPyM&yEb*<#M)DV6d>3E2e(OJ~I3I>zrE@6Tq#HBMBmEt> zTMrzO+IRWm9pk!(*9Qdb!#N}8+SxW6ltj2a^GVJ8%5)feWpGaCnr?QDJtI@6tc#dF z3g=yF`+k4agZE#5C3Vbq@;{x};!I0A=+N3dewe;IiR~<1V+gI;s5D{T*yC3^-H^>j ztA6{>Px3!KKc{-B|MCMTmNFkTMp0>QUd{~+7DH}GHn;@cp$rUs8^9RM@<8GGa>ek> zRTyZ&E)aAFfmQ7sm`34`Fe}`nZ_A$^z_>JF7!zKzOC0;{XZLY*GjwB5(%+op0IO}gagsmZ+v<2u)<1wwNai)3vXM?qk z8x0$NyeVg35QAI6mh^p@*8)qQap^+v^%@~^;y%??)eqLOzlL)8*r=b)8bd2R&Yy3G z;w0*}{D9}(v>T0@xLebpkh%gqbO@HpB$9tjHxd{8QX2<9Dg3x(LQea&*UshJ@E-Ex zQp$A*{YRVpW~u(mB}cz_JEc)Wp#b)m14gGSNy7k&Fxc8!Zexr^kpH)+xz-TdX6`+{)Ao8!L=ODEkk8lRIbS2 zfP4`?-@2tg8$|0(STrm3^{D768gPhw&dCF(Kp6d5N1O`rSKr;4K!+^RRzG@xPx+%nx0+;i{>#Gj&H`I^@e?0H|)MRZpuwD=^snhD$1(dD;wtWj7za1>-NZS zH47y(Kge*gBINir zLHF=U0bxRa(fqF#{mc7_F&H++qXxVXo3gW9_eMj~JK}#6hA6Hj)f#K8p>lo3{2W@~ zWKgmyP_@LP!{<|4LvfJXR=0$5LfsU7v|F7?I(PPjN~Y$SxNEhtKB5|TY^7H>^=a}r}BLA2q)9uLq@RIl+!GhY9>~yRsSlryJF^zns9Ic1|1$<)Qv#&R7g6r@fe1QF+}rZ&*uPk zI)XjZcTaC~dZN@)la`F?UpMdX?aSKX(B0#Z@CzMo@7F|SkK}A>-raq{rd`GBZF^qm z9#fNkpT42B5;ckep7gQZOU7KU%HyPKuYG$B&S0N_fE&ah5L-mdJ zm4>tb6FSl1uYOCurQGv0*hI0ez->T^(kJQ0DPaf0HQ6t|Dp&TpF&~lmrptTYtDaOz zdGUr~MapUd=LJlFdXyY673<9sZ+K|lKPnqY!>vLQTxQJWo!4+lL;3+)J~ zJ5foG?U!aVjgM)+8CiC+u~OcIyr>`PKaZE4%rjoyoMjQ)JOUokNTA)$oF{(Mmm!CQ zdJOCNV)KZ=Ke@!Tm*wpDR~P&;Zg+=@@d)*23!QHJJmDG{@gsigQsULOBeR({;xXx= zEJD{$=kBpsJBJR5s3SA##XZ>iY_bPfA}$4=%6y~abbHqN4GBGYH1TRiwCz9L^a;25 z8BCa$vQS=UNM@VQDeCilx_+5D??5tSr zxdW4@(;#gvA=5(e;iVF&y1dzIOKQtylRJo>-s@Yp+-2T@2kAyiU5ki3OE9dCai-tp zwBCDOwWJ*w4)JN35J=4Sf`EMOKo69l76Qt@Z>ynJ=NbSZ(*FLS$lzgyFL5?(=LX;=CxoIGw-Im@3*_x@n)B&_SjjaOk7$w1rRQ z+tob7gG$p8`eF(3O!mtaGnxs4g|~j+{dKz;|C`dk8p7`u%W~Q(4}HRrviex@+%Fad ztBVI_GxJ=eGQ!imxmD_FseTfStCZ~*pq|K>E*{@Pc0n@07CT@Ui{QNt*KFc}jgn}y zY|^Y=8={O1j1fiK%RkWI&#TPlvh0BkurD)7=S0OY75lrab35OeN87xaFN>AoYzY5X z`J!tXkww9UT}OGb9iaG&J&JL~LFyNC-+I@`j!xjFmL7YlJV2B#NR}OXnGYdAg73-a zJfqM~3@WReZ-4M=kh(=?K%rG{$Fv*1@8A0yi-*+M;zt6PkZ?LN%OxjcD*5j%BE3j^ z*~)dt-W4xd9HROuO1rk-TTWLoCHj>*!?sP5mN{Wa$F;BjyE;^LXjsQC>akOJC&tS1 zw9aK*i*T+%ES{55R+R zxj!1tWi=tn=J%q{=!?gnOTB@44XulxcUwQ(!$7wtFt6~4r{d%oYN%|VI;6~{?Ktx= zgSXXhHw}K_@vGeHM{=4fD}@I33skoK)#YU;hp8XNsgy)I!lVcnUG*XAHL^Ry^?K<3 zilv!BhQkv$Ft31i83H9}DR6BiTq!+^;m0LB`_1W=w7%Ass(*XZ{H%efV&_Zr)6`Mr z9c|t39TL=sZQ5ewxd>S2&nhp{M6}@~_u_h#Ij0SO*|g)r*j9m&5oj{|`){-C*tuuc zIzu3dMbBWX;_j!WPn2dILDL*lHKaKF5ynCBbe)@Kj58TnOkFjfpSI}Jli9XtKcGNH zvBhB%G+wuk_NDfWpE27K2WqW@deYUCKQ`Ro-mM@&hPVV-<|%;|BfA*;5c@J-$pw5QKdzaxA#VY zQ%npdV3({nuD)u*{*QjpcpY75h>zU~&!nK89RKxywvT_bfyF6*vs0d%S6_7!{JZ&~ zY&-DxUs5RlmbyB65p75cx;?Th|5aD^*ys9GcLJ^R*q>rQYxx5k>>A@d=bZ#ZzX91W zAV_{zv#S^x-@Y|#O3{5>8?RE2WGbJpvn3@Z`W%UQGci(>bR3DV-Jj$myD*TT=|>>T z@s%6K%)keRKN}UNZTlDoC{8&&&`{odT(*moGu-+(b|=`qUj$+w8lDEUa|7*vn$>qr z;l;VJRzrAA?tmcu_|m#(OMS}U6x`II-X&|hLNEfn8Owv}Y_(1WC^zebV8Gln^ouQE z6Lk?ApdArp@#XE3X3W|0Tlw?7Fg!x%=tlgfP&PeB)5J5{gieo)@YeU?y7bBkIQK_T zs$|fDrlw|>4DwJ^AF=;@yfrjWP z&#;Gvhc=-0C+1GCdc@dlkh*Yz1fLdell}RREngwx^n2$U+eu?2sv31oqYlSbsBOHi z9c^G{z2$I={?pykX^>@3M70ynHuXm1r!17Nq|?Qbc?DkcK_XcBb<;kpES{gwyK3`) zq;1LkqaVEO@^dgsj` z>+|c0m!aw((0lsiSom{3+q-GN^sm2n$;@3zukxtv@{LY&_Whikg%6o{IR-o_mDS7s zzWS8^9s+h-p(+t zXlxNOISn9|d4ufS4|+zFZF!qqEbxh4?~*hq-W=aR)@1LRqY*&al&F?4i5- zJbsBn>3TGigEGew5%S_M6*1He!K*xkOf)wNq-^8pwzO}{G^ZY}L{)T)Y!bQfcz~3D ztF?9s9pziAua|I<>A0BUAcv_ZA3lj0q}Ygo-0)lg@UG}xJV~Q zRdQlQ<8FLbV7QD@{3{V7I!Lora&1zxT{lKtKZ)I4t+W8SQwdLqeME$D&% zs$K{K8nLzdmKv_2Nb+t!eS7yd=_VkkKhH{MgJ1DU#8+p4sBB#;P*^ulg!)rswG+!>cC{M!TD&#xZvjW@u zaiXnb_xm~u=<%i~?f$Mq$~_>+&2md96zkCA^>oCaqVoIF6)j={L8rw&(J&vYsBR^t z<5_(@)$j6aY{Yd4>9<9$MN7_8oQe;2t)NWhAFn^mopofSE}peSgW|H@*$E3@9MH$Y z_)T=3FMc!ej7I@ zch0CnIa;;cDMvoY5ylbYkMRVMgZO;mRvE=5tg@0KdE?q$gl!euL?+ad{GP0@odgUw zY|qP;nsFvP0d7+rvQrZ~)YonZ@XJhtDV|0B=9Ci$`o}`fxHUz|gbs$d@!Wi5<$bB@ zKozprzL^>=*_M%mjzsqxiY6n3OC>0pP6hu0^wNa$-l<@BmU+szOu#s(4?;LiT6NB9 zpDPq%+a~chUbE;QD$XrW(+<-fDtq9%j54)`CP-Pn?9V?s3jeW6LHTPUcIL5#q!abe znsIrnSB)pPa(-*tVADv?t7_9T2FbrtidCX2muzGH^N;oP_~kM*WZ(AMPwUCX--;`s zPy)8$ovr%iuRmPq$uIo;`adLNVvB^SJfRd7&amYcByaMxR zJlb#wG>Q+GlC2J?r7@12G$0L)1U1Hzo3Og@^OZ9k-bIQV-AayFIC~I4w7*~yG6;Qw zW+OJE8I|K8RLth9zb-CJ=6h+AHS7;#%msu!5)^@CY@*VuC`kSJlqVR-DSATPoFs)v z=n%{wSnO-yoHZi2Y5j9?tYZfI4!BYtg@=VTplvaNo~#aodeY(0(TYPaH-VX&B>Ctu z*Gv1A^v$@a(x&l${R_pj85-+w+}&(}r231^EGn=@x(y=mm#KhEBmHu2KF&%am?OC> zUhUB`q0R(Ug7)ya{?e(pzb_kJA94D%>RjRrpTuuxGqLOa`PH=|dgVk`Q1GQab-~D~ z!*JGXq*NZD#R@vD*|_H_PKXrf`WiWXRXf_+-{?O@ol$(wdyiMoq)(N1)qrw_yIU95X#Bb(bpoSD-r9#zlEaj(plL9>X--oSE^y z&K{<9&Bp65s!=5wJ3M3R*!5xR;Zx>Lu8W%PiAP=e^8U^L z!G9-0ymdc5hd8k%no!z#-UUpcOF+g8FAf$G)Ok`F7mCEGP9WwoW4WoU>=X85R#lv) zp|(p1%m1ewQo`ok!VGN}YsAzXhnBkQ|H4ZkFS2G;;E7|Jl`C(0_Dt07oIlHnbO9ef zzy1Z--|V@Rl3tMt#A^};hasEMBk((nJ^xweGO&T$^7xDePY6seDkP(~MeO*!?>T$x zfxkrJN9n`O-2KjaFER1}5JCm^CAPlgW>7H#0Vxg=++oB2hKWHZyV;uKrEfZJts|C$ zN`(INPn<;f{Tob|&))T8GBCBNl_4^Dyb z7^DzYGcV^)!~XyOywU9c@Lw=75hfj_#bn0Ui*}&2c@M{2cfT|>J0nVl7RNGogNaBb zQ}7CdwK{?5Cgc2`>Ti%yd+MOlA3Q|$HL@lw0VFVS5H=bM6ALT`ahy$`pL@f7d#I_~ z;~7AzY-Zvsg>l8~yjGd(j4e7sOY~7$HnFE&83{AjjA~Ao4?A1-kK@h$(g?3g+F(>= z@zDIZ5+4V0`Y^6_$YUgbuAA5R1>yS=! z;WK!QZPzM|J!M1W90a1K^+pdH$)RN_Q>NAAwIGCsaNb@$_IcEg2yfp_1vtE3bX_z#?XC$?7G=JO2&GVs!PDa z6YkN9gxm?`+p1i!FblEC`%*2fsqg2XwIu9LdB|5s^&foS=WBjZq50sC@0kb2E?9QD z(*$=5^UZt>ST?nSXaQav)_+1nvph2~WJnCbq|St3`WpH^*DL3Vh|g>fU!d0_k|!o0 zvCsCumgN=qG*Po|MY+L8j=XX23>#98o>4rU|K1v7E>&*cyxH{~lcA>feLxm@ z0a166T`c>OTIS7gk=el%p1zJqEs1WnTy~4O;`YUb+wC_>9U)^KUh|eHRG)0INidp3 z2{&b+(uPH5JWIx!{h)+|Bv&8dU+yCBE-2$TAoa9=Z+x@+mP4wHAu8#{FAJk4c_GEUE6oLC)5Y?g`7XeHR4>;!;}7!sahqM6fvNiK1tW)iu-E zq}AZP_i~G%T1nja97E_6!)gmE;T-hc8(l98=voZ!=@TUfYMxzYvvlp-D*7Ui(l?4$;gSx`&y2 z`quv;XeN>%u8|dN_3g_^m87e`lew8>qpepeiDi~g9AI_?%yno!prwU3<)KXT`Nzb$ zgdOK=+T#hC)`oEdVV&I4-typ5WbNM!DeQ0T-Y$u1ks1hD*t1*sp`(e{BZ#}n0 zDMQ71uFKlMz+ewp_;EPnCDd{1OGE*=3I=2S#eg*dScGaN zy$o%Q)@EH0&SE5bWu&7S@Ix<-?zIFe48|w#JPe2XWR^z9U~{@j6ygcC>33!V-E>vOVeadk_aQ}V8 z7q8cNKc(A0Ldd{&vddn70mR0ck&*EfF~TPlF&m)<8lDHpAyht~J`!2iPQ@SKP@y+Kb$HItn31?z(?>Nh%Vt8x>x_5XQ>$cN|*IA{1_L_=8#!mM7 zYLS7{Vwd(Rh`xWWZmIc6n$9=OUhsVBK#R9S?-!--r4D`zY++iG%@v8wGDX?WftO#z zDh6wG$W^YVvyHm;{_rO;8wVGenotFcZ078zQ|I<~ z%pI!loQd8}lSb-n@xi7Cw})G-<>>udT=;}@+IUkog!)u7JXrDTMwb%HvWo#TZJ}*f z{62|^@M*2r73Ygt*@n5wUu^0Pi(AP?ngb=ULd5wkv1-|AG>DT5hkTj&m0wKQ2O7%h z?(@Js8L=gJ?`E2{!faUot_$2x7IHQWjMg2gDK=%vlcSoq3RLwTWAUk_xA3eG%&Mpr z{Ovq->-@aw3tRTipv?RZiz|_j`GhsiTCVS|Pzo}C{ji#z>hG>O-aP$V%F%htinCio zH7W&9#GySJ^(2<>z=ja%kPvFt*%67a8FL#`@^B!|9t>S6!HcIPYed=fY85!(qr+Ujdb7ae~+%fpOwp}@?! z^Pzlz-%cqF@xubi&c(873D>jotn`X^{k1sklOIO#&SWj=@*R7kG4c68>#NTJZz1`* zB_KYR#?C+YtU@eHM&NoegTOAIyp~zci*&U3{OFm3JF!Fx9+8@#|GZ2Ni^3&3*+0a} zrnif?GFP)HvIt%H?m+C?3CHQ|qpaqw27BkCvJTr=iHqKlx*+h%ke}{Mv$(i2j54HcxElQ zaMV?m4n3C-AYr>yc9Fx}4=aS5=yN;{r7-iKk2Bt8s8`}XwOyQZ?Jc$FU-k%9@V6F| zq)C5fTCT)PxGV&2MCFC*w>6dWWbZ%7lvz)+w)6-@n28DzP)yHit9%55+x^7XFI!=f z?r?haFe@3nnHP1JMZkD4;!!m%;mN6UACFCaxjQVhXC|QYjQj4Jj)k$WEB1dF{PsF7 zg;jTdrQnsq-Unml$(rMzr+busoLX1@wULbHM;xUvNYvQMn?F6BzA(Cmwo#T#EA=@^ z_KHU%;0QEFWns&ho|@Yg(|JSR&Ei=TiS2dw{qrnT%9v=I=pQY z!onw{%2Q)*PVQ`7UY%C1S5dAcn;EmBAIO;M;}h&+S?hz3&cETN6-wqtl4Ot4Akq9; z#*kgFlwa*U-m4SXqj#D2BtPYQD3E|mElB~B!~1^kpi6({!OzYGLdD{d=RrTFQr?^3e2!;&RagR;? z^zJ%7=SsnIa&5?B_8K{EuqQlAB-FVu+2v3ZF@D^3BXQZ9oGHwkp0ad10BB3_ddbq) zpFwCXu0^FHC2#=(i3u!6ky+}F`t8i)j7SNsg99XB*?UHCSI15>O=evS-Tp14&sx0T?$pVnJo)ci5V={OeU`hn z=JHOVi!|pdrf8)P(RB|DGF8xr-A3f>JVjpE(FD?2$n$>@1w!efKd%5j#%*(fr!ze$lLd@Z)Zc11E=hH?VW+yPJ|55&r-pyC&1BcIv`W>`*%T_CC-e9gEkH4m8O6Cr zwcF71z@z-NPU7!n60KX`&ck7+mZSawRZZ5GMAoJmp7qB^B+-EGEExJA^uayqd+h0; z+nQTK^Cl$~sBDS-V}mykblsn8gE5R@)*^Myva`gC(ffq?Tt=_MNze#t1)7hJfAtlT z{Jd?CLSeA@eS=l8(TpLLg6i+Bc1$Yf>>DW4#CG)9KdxiE;OBY}7Y#J=*c2I5A&A-` z-FzK`A)vlvr*{hkHq;pGJ?z7CVg$T=`C@P1&3o0`agj;k zA(vlTEYGFT&YDN!YHaUsHpkfUKk(4#`0RV0E+vH3PQ;QBRheSAAZt=yh zIhlV$itcH6KI;YZUt%UbgFFHclzWA8I2ly4_V^k_`yc94c^aF1KBGIbJuRH^MiUeN z+DPX`1^Q$8g?k!Z&x%@f?#g0H=~ptE{l@f8X{^A3<>G=t@m~J0Do^|Bxg7Qa>ULcC z5A3DBywc|DWO=3dmsTftJzzc*a?SFY-kNszZ({CQ5jUz{z2mF=ap7xLo&0`}8oJJn zmR6G-UzA*N5|{a_e^7s5_>3M3XRm9&FG zjjqW5~d2H=})%%O?IX!(rqeDiQc{R&iXSvjC zo}%0o*0g5d$R4>B17iwF>#KmK*Y0WHQ8W@I`^57D%qi`wmK0ycm=orGsu`wwFl7d# z1b2o>Y4bj4u@UQdfzM69K0z%Qm3NbfTs$!u&M#t|jO7R+E(?!BQC5X5N(?0woqW79 z{~HJlDmBrf3yXm_4u($l9<=d0ALrUmiu;hUTc<$U|7s73Trhx`dwz%YJ2=#>dxIaG zLhVNv4^6buZV_oo$yFo_CC`QT5O5AUGX2nt>fkblK?kPVNJ_?*@PG$3{w4G~q@VeI z!5!4=uke0tJAC!6Qx!l+1{?Gnr+{VX#U(w;LN#E1ZimWlq>>`9rC>1!&8+TVK% zzCdTB!+rfYhko3mdmhe`&x*}AL2U(-4#WZZ^S4#&kujQ;B@!cfSlp@^2hg&KW)9s#$V{f82i&w%5jmeBGry^a@dEnlZrbiP zBTz5hYL=lv4`{zp2-+&uwX>5Wllwo!b!@DC2A%)fT#ULKJ~j>r;NB>;a;qQqe_oG3 zB6qp%z{aO}+Bs8G0kc# z@by2hrWFcO12!l_?%=2WWAH?Dh&66M5u-p{8>` zW3CzNT_Ym;ehRAXN zX*Wbg6x?TI{Tc-&^QpB21~S@dk;az2#}c|? zItDPbT!xOOsn&K4*@l(^PvmdM$UJ1cTdiq5c#qIF!Qk~GqIu`vNE_I;*bKxEqX&1GGrd z{9o+E`I1$l$;0>mK>{8%`|scc2}AI9Q2>fw<_p)kseOXcMNmuMK*yYvzd%dO zT)0ioE_5Be=IgMqUA}e|Ml-B4M3-|Ykp!I#n?U{BK9)~M9$&2X*GrPkFump*P!o#9 zHBlu--w2l62*jtC_IM5!k3z`l)xfg7Q;nMINTPf3tQ!+=8c|!r)YWM-25oK)NoWsc zOd_ca__0A_VU&p#!<}WsP8de%%s&G1@}s%xI2`b5ov__h647=T3(}OZyQdS4G?e~+ zo$8as=}8z@SX~uT4diWcN_i-YC$|ErC1Ra=w+wKy#Ox7vrAK3N#+NunIDJ#&(t^`C z$y?es^{1lH&JY^ZBMx&`!kzAiRB!UPTuujP#FHtAG>%!o)H_`p@2;fJ(#Fo+{UnKG z2-ri$Y>7=!84~E0fj;UV1S=gvVVw#zP?x!Y_|#$VNCsol3Q%4byn?CNo(J~H=$NBc;RERx<$g*;zhFomq@&8rX{z?#W?ZpPr2I{E;QC8EH?AOlfeEVTTs=FK)D9n>P?FP z9b1SvJ=;?!R&Zhj3_3CM`84KOr&tU%)zao_8LC+JU0HpJ*7nE}sIcyqNS}H}SL$a5smYeOk;=UG2!lg^J?*m};&EXuQHZb!w~iXtyr%3d#J~s9ZGuv5yzN^!qH7dTucAOZ<9?}PwQU<9nJR6fI>5To{(F7TVm)*Jp6LgSQJ=Eq-3XC5bk zp+%}AwII5hk>ADg;^}R~(-8?6mQ7%7+?CHrHtp!4)T!o&ERTAU)X!&&JeTaQB%8HV zXWXzVIK78_RE*6^u9TO=TD!Sz++sp(8yK3ewkQUS~rzH-QkhESln-Ihe2JFMt2>dPiU0yA<~u&krZ$HYdao zsmW$x!<>NAL?@$+xC_B~N*&5EH_jsCtoH6Lp*{2U(~03$L+o2=xQZ2diwm{`@Olx|5JD9q ziw_b!Xto&}Q*rgbc6b+&Ki9YE(j&$#z0TL*IJn=(g-tKj2(huo&wI)Sn^H@Un6wQp zXkeEYh%~#{m~Il}&pQp3rDcJpq48G~(lvB@+0!7dp7exEi-?Im=IzPLKXC&N5%SHC zFDIv~kURbS`oBFw=;sUl_ZG*Wzxw~Jkffh4`~QE3|NB~wpC97CwI}@i&HpB0_s@U- h|813CefMpVa?f!6#=2f>dGd&}XD*&jICb^T{{UD`pAi56 literal 0 HcmV?d00001 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..e291a60 --- /dev/null +++ b/docs/commands/panorama/measure.rst @@ -0,0 +1,61 @@ +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 very practical, and more of a proof of + concept, as capturing the full view takes impractically long time. + +Images that are taken at angles that deviate from the horizontal position +cover a wider horizontal angle area. To avoid taking unnecessary images, the +top and bottom rows have fewer images. + +.. image:: image_positions.png + +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..00bee41 --- /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_process + :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/src/instrumentman/__init__.py b/src/instrumentman/__init__.py index 6ce9960..2eece3d 100644 --- a/src/instrumentman/__init__.py +++ b/src/instrumentman/__init__.py @@ -108,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.""" From c260714a28fd6bb2c1b21b51606dadc741b8b2ed Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Mon, 8 Sep 2025 17:43:37 +0200 Subject: [PATCH 22/42] [panorama] added limit for scale factor --- src/instrumentman/panorama/__init__.py | 24 ++-- src/instrumentman/panorama/process.py | 149 +++++++++++++------------ 2 files changed, 90 insertions(+), 83 deletions(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index 57a8169..f0a0051 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -185,17 +185,17 @@ def cli_measure(**kwargs: Any) -> None: option( "--scale", help="Panorama scale in [pixels/rad]", - type=FloatRange(0, min_open=True) + type=FloatRange(0, 5215, min_open=True) ), option( "--width", - help="Width of complete sphere panorama in pixels", - type=IntRange(0, min_open=True) + help="Width of complete sphere panorama in [pixels]", + type=IntRange(0, 32767, min_open=True) ), option( "--height", - help="Height of complete sphere panorama in pixels", - type=IntRange(0, min_open=True) + help="Height of complete sphere panorama in [pixels]", + type=IntRange(0, 16383, min_open=True) ), constraint=mutually_exclusive ) @@ -360,14 +360,14 @@ def cli_calc(**kwargs: Any) -> None: approximate the offset to improve the accuracy. If the precise offset is known, it can also be provided explicitly. - A limit of the OpenCV provided by the 'opencv-python' package, 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, and 17 images vertically can be + 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 scale should be - set to maximum 5215 (32767 / (2 * pi)). + sizing options to downscale the processing resolution. (The program + automatically downscales to an appropriate size.) """ from .process import main diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index 88a7b23..1044ec6 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -36,6 +36,9 @@ from .metadata import read_metadata, PanoramaMetadata +_MAX_SCALE = np.iinfo(np.int16).max // (2 * np.pi) + + def rot_x(angle: float) -> np.typing.NDArray[np.float64]: return np.array( ( @@ -224,6 +227,8 @@ def run_annotate( if scale is None: scale = (f_w + f_h) / 2 + scale = min(scale, _MAX_SCALE) + instrinsics: npt.NDArray[np.float32] = np.array( ( (f_w, 0.0, width/2), @@ -306,93 +311,95 @@ def run_annotate( ) # type: ignore[call-overload] console.print("Done") - console.print("Annotating points... ", end="") + if len(points) > 0: + console.print("Annotating points... ", end="") - # 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 + # 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 - if scale is None: - scale = 1000 + if scale is None: + scale = 1000 - full_360 = round(scale * np.pi * 2) + full_360 = round(scale * np.pi * 2) - if camera_offset is None: - camera_offset = mean_coordinate(cam_offsets) + if camera_offset is None: + camera_offset = mean_coordinate(cam_offsets) - for pt, coord, label in 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)) @ rot_x(np.pi / 2 - float(prelim_v)) - ) - pt_hz, pt_v, _ = ( - coord - - (center + apply_rotation(camera_offset, offset_rot)) - ).to_polar() - - 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 - ) + for pt, coord, label in 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)) @ rot_x(np.pi / 2 - float(prelim_v)) + ) + pt_hz, pt_v, _ = ( + coord + - (center + apply_rotation(camera_offset, offset_rot)) + ).to_polar() + + 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( + cv.putText( + result, pt, - (pt_x, pt_y), - offset, + text_pos( + pt, + (pt_x, pt_y), + offset, + font, + fontscale, + thickness, + justify + ), font, fontscale, + color, thickness, - justify - ), - font, - fontscale, - color, - thickness, - bottomLeftOrigin=False - ) - if label == "": - continue + bottomLeftOrigin=False + ) + if label == "": + continue - cv.putText( - result, - label, - text_pos( + cv.putText( + result, label, - (pt_x, pt_y), - label_offset, + 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, - label_justify - ), - label_font, - label_fontscale, - label_color, - label_thickness, - bottomLeftOrigin=False - ) + bottomLeftOrigin=False + ) + + console.print("Done") - console.print("Done") console.print("Saving final image... ", end="") # For some reason the blending function returns the image as int16 instead # uint8, and it might contain negative values. These need to be clipped, From 03f370d2228966204f5454901da6b4cf2ef340ad Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Mon, 8 Sep 2025 22:11:53 +0200 Subject: [PATCH 23/42] [panorama] cleaned up progress reporting --- src/instrumentman/panorama/process.py | 204 +++++++++++++------------- 1 file changed, 100 insertions(+), 104 deletions(-) diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index 1044ec6..b26ff1c 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -190,101 +190,94 @@ def run_annotate( fov_w, fov_h = meta["fov"] center = Coordinate(*meta["center"]) console = Console() - progress = Progress( + with Progress( TextColumn("[progress.description]{task.description}"), BarColumn(), MofNCompleteColumn(), TimeRemainingColumn(), console=console - ) - progress.start() - task_images = progress.add_task( - "Preprocessing images", - total=len(meta["images"]) - ) + ) as progress: + for data in progress.track( + meta["images"], + description="Preprocessing images" + ): + pos = Coordinate(*data["position"]) + vec = Coordinate(*data["vector"]) + path = images.get(data["filename"]) + if path is None: + echo_yellow(f"Could not find '{data['filename']}'") + continue - for data in meta["images"]: - pos = Coordinate(*data["position"]) - vec = Coordinate(*data["vector"]) - path = images.get(data["filename"]) - if path is None: - echo_yellow(f"Could not find '{data['filename']}'") - progress.update(task_images, advance=1) - continue - - img = cv.imread(str(path)) - if img is None: - echo_yellow(f"Could not load '{data['filename']}'") - progress.update(task_images, advance=1) - continue - - hz, v, _ = vec.to_polar() - height: int - width: int - height, width, _ = img.shape - f_w: float = width / 2 / np.tan(fov_w / 2) - f_h: float = height / 2 / np.tan(fov_h / 2) - if scale is None: - scale = (f_w + f_h) / 2 + img = cv.imread(str(path)) + if img is None: + echo_yellow(f"Could not load '{data['filename']}'") + continue - scale = min(scale, _MAX_SCALE) + hz, v, _ = vec.to_polar() + height: int + width: int + height, width, _ = img.shape + f_w: float = width / 2 / np.tan(fov_w / 2) + f_h: float = height / 2 / np.tan(fov_h / 2) + if scale is None: + scale = (f_w + f_h) / 2 - instrinsics: npt.NDArray[np.float32] = np.array( - ( - (f_w, 0.0, width/2), - (0.0, f_h, height/2), - (0.0, 0.0, 1.0) - ) - ).astype("float32") - rot: npt.NDArray[np.float32] = ( - rot_y(float(hz)) - @ rot_x(np.pi / 2 - float(v)) - ).astype("float32") - - warper = cv.PyRotationWarper("spherical", scale) - corner, image_warped = warper.warp( - img, - instrinsics, - rot, - cv.INTER_LINEAR, - cv.BORDER_REPLICATE - ) + scale = min(scale, _MAX_SCALE) - _, mask_warped = warper.warp( - np.full((height, width), 255, "uint8"), - instrinsics, - rot, - cv.INTER_NEAREST, - cv.BORDER_CONSTANT - ) - cx, cy = warper.warpPoint((width / 2, height / 2), instrinsics, rot) + instrinsics: npt.NDArray[np.float32] = np.array( + ( + (f_w, 0.0, width/2), + (0.0, f_h, height/2), + (0.0, 0.0, 1.0) + ) + ).astype("float32") + rot: npt.NDArray[np.float32] = ( + rot_y(float(hz)) + @ rot_x(np.pi / 2 - float(v)) + ).astype("float32") + + warper = cv.PyRotationWarper("spherical", scale) + corner, image_warped = warper.warp( + img, + instrinsics, + rot, + cv.INTER_LINEAR, + cv.BORDER_REPLICATE + ) - centers.append( - ( - int(cx), int(cy), - hz, v + _, mask_warped = warper.warp( + np.full((height, width), 255, "uint8"), + instrinsics, + rot, + cv.INTER_NEAREST, + cv.BORDER_CONSTANT ) - ) - corners.append(corner) - images_warped.append(image_warped) - masks_warped.append(mask_warped) - - # The de-rotation of the camera offset is not completely accurate - # since the optical axis of the camera might not be parallel to the - # axis of the telescope (which results in some angle deviation), - # but it is good enough estimation in case the offset is not precisely - # known beforehand. - # - # The matrix use in the spherical warp cannot be reused here, because - # OpenCV uses a different axis orientation order. - offset_rot = rot_z(float(hz)) @ rot_x(np.pi / 2 - float(v)) - cam_offsets.append( - apply_rotation(pos - center, np.linalg.inv(offset_rot)) - ) + cx, cy = warper.warpPoint( + (width / 2, height / 2), instrinsics, rot) - progress.update(task_images, advance=1) + centers.append( + ( + int(cx), int(cy), + hz, v + ) + ) + corners.append(corner) + images_warped.append(image_warped) + masks_warped.append(mask_warped) + + # The de-rotation of the camera offset is not completely accurate + # since the optical axis of the camera might not be parallel to the + # axis of the telescope (which results in some angle deviation), + # but it is good enough estimation in case the offset is not + # precisely known beforehand. + # + # The matrix use in the spherical warp cannot be reused here, + # because OpenCV uses a different axis orientation order. + offset_rot = rot_z(float(hz)) @ rot_x(np.pi / 2 - float(v)) + cam_offsets.append( + apply_rotation(pos - center, np.linalg.inv(offset_rot)) + ) - progress.stop() console.print("Merging images... ", end="") blender = cv.detail.Blender.createDefault(cv.detail.BLENDER_MULTI_BAND) @@ -522,25 +515,28 @@ def main( if label_offset is None: label_offset = (label_fontsize // 2, label_fontsize // 2) - run_annotate( - meta, - output, - image_map, - scale, - points, - cam_offset, - 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 - ) + try: + run_annotate( + meta, + output, + image_map, + scale, + points, + cam_offset, + 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: + echo_red(f"The process failed due to an OpenCV error ({cve.code})") From 35e23f6f8fcb4ac4c3fa7dfe23843b5551127c79 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Tue, 9 Sep 2025 12:39:39 +0200 Subject: [PATCH 24/42] [panorama] updated progress reporting in panorama processing --- src/instrumentman/panorama/process.py | 222 +++++++++++++------------- 1 file changed, 114 insertions(+), 108 deletions(-) diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index b26ff1c..de7e324 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -278,133 +278,139 @@ def run_annotate( apply_rotation(pos - center, np.linalg.inv(offset_rot)) ) - console.print("Merging images... ", end="") + with Progress(transient=True) as progress: + progress.add_task(description="Merging images...", total=None) - blender = cv.detail.Blender.createDefault(cv.detail.BLENDER_MULTI_BAND) - blender.prepare( - corners, - [(i.shape[1], i.shape[0]) for i in images_warped] - ) - for corner, img, msk in zip(corners, images_warped, masks_warped): - dilated_mask = cv.dilate(msk, None) # type: ignore[call-overload] - seam_mask = cv.resize( - dilated_mask, - (msk.shape[1], msk.shape[0]), - None, - 0, - 0, - cv.INTER_LINEAR_EXACT - ) - msk_warped = cv.bitwise_and(seam_mask, msk) - blender.feed(img.astype("int16"), msk_warped, corner) - - result: cvt.MatLike - result, _ = blender.blend( - None, None - ) # type: ignore[call-overload] - - console.print("Done") - if len(points) > 0: - console.print("Annotating points... ", end="") - - # Top left image center point for reference - origin_x, origin_y, _, _ = cv.detail.resultRoi( + blender = cv.detail.Blender.createDefault(cv.detail.BLENDER_MULTI_BAND) + blender.prepare( 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 - - if scale is None: - scale = 1000 - - full_360 = round(scale * np.pi * 2) + for corner, img, msk in zip(corners, images_warped, masks_warped): + dilated_mask = cv.dilate(msk, None) # type: ignore[call-overload] + seam_mask = cv.resize( + dilated_mask, + (msk.shape[1], msk.shape[0]), + None, + 0, + 0, + cv.INTER_LINEAR_EXACT + ) + msk_warped = cv.bitwise_and(seam_mask, msk) + blender.feed(img.astype("int16"), msk_warped, corner) - if camera_offset is None: - camera_offset = mean_coordinate(cam_offsets) + result: cvt.MatLike + result, _ = blender.blend( + None, None + ) # type: ignore[call-overload] - for pt, coord, label in 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)) @ rot_x(np.pi / 2 - float(prelim_v)) - ) - pt_hz, pt_v, _ = ( - coord - - (center + apply_rotation(camera_offset, offset_rot)) - ).to_polar() - - 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 + console.print("Merged images") + if len(points) > 0: + with Progress(transient=True) as progress: + # 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 - cv.putText( - result, - pt, - text_pos( - pt, + if scale is None: + scale = 1000 + + full_360 = round(scale * np.pi * 2) + + if camera_offset is None: + camera_offset = mean_coordinate(cam_offsets) + + 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)) + @ rot_x(np.pi / 2 - float(prelim_v)) + ) + pt_hz, pt_v, _ = ( + coord + - (center + apply_rotation(camera_offset, offset_rot)) + ).to_polar() + + 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), - offset, + color, + marker, + markersize, + thickness + ) + + cv.putText( + result, + pt, + text_pos( + pt, + (pt_x, pt_y), + offset, + font, + fontscale, + thickness, + justify + ), font, fontscale, + color, thickness, - justify - ), - font, - fontscale, - color, - thickness, - bottomLeftOrigin=False - ) - if label == "": - continue + bottomLeftOrigin=False + ) + if label == "": + continue - cv.putText( - result, - label, - text_pos( + cv.putText( + result, label, - (pt_x, pt_y), - label_offset, + 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, - label_justify - ), - label_font, - label_fontscale, - label_color, - label_thickness, - bottomLeftOrigin=False - ) + bottomLeftOrigin=False + ) - console.print("Done") - - console.print("Saving final image... ", end="") - # 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) - ) + console.print("Annotated points") + + with Progress(transient=True) as progress: + progress.add_task("Saving final image...", total=None) + # console.print("Saving final image... ", end="") + # 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) + ) - console.print("Done") + console.print("Saved final image") console.print("Panorama complete", style="green") From 7c5c5a0b64d7a89f86b8e64a098ee90eb47cf9ef Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Thu, 11 Sep 2025 00:55:25 +0200 Subject: [PATCH 25/42] [panorama] added basic image compensation and blending CLI options --- src/instrumentman/panorama/__init__.py | 18 +++++++ src/instrumentman/panorama/process.py | 74 +++++++++++++++++++------- 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index f0a0051..297c725 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -174,6 +174,24 @@ def cli_measure(**kwargs: Any) -> None: help="Axis-aligned camera offset from the instrument center", type=(float, float, float) ) +@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_group( "Output size options", ( diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index de7e324..b416dde 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -166,6 +166,8 @@ def run_annotate( scale: float | None = None, points: list[tuple[str, Coordinate, str]] = [], camera_offset: Coordinate | None = None, + compenstation_mode: int = cv.detail.EXPOSURE_COMPENSATOR_GAIN, + blending_mode: int = cv.detail.BLENDER_MULTI_BAND, color: tuple[int, int, int] = (0, 0, 0), font: int = cv.FONT_HERSHEY_PLAIN, fontscale: float = 1, @@ -195,8 +197,10 @@ def run_annotate( BarColumn(), MofNCompleteColumn(), TimeRemainingColumn(), - console=console + console=console, + transient=True ) as progress: + warper: cv.PyRotationWarper | None = None for data in progress.track( meta["images"], description="Preprocessing images" @@ -219,10 +223,13 @@ def run_annotate( height, width, _ = img.shape f_w: float = width / 2 / np.tan(fov_w / 2) f_h: float = height / 2 / np.tan(fov_h / 2) - if scale is None: - scale = (f_w + f_h) / 2 - scale = min(scale, _MAX_SCALE) + if warper is None: + if scale is None: + scale = (f_w + f_h) / 2 + + scale = min(scale, _MAX_SCALE) + warper = cv.PyRotationWarper("spherical", scale) instrinsics: npt.NDArray[np.float32] = np.array( ( @@ -236,7 +243,6 @@ def run_annotate( @ rot_x(np.pi / 2 - float(v)) ).astype("float32") - warper = cv.PyRotationWarper("spherical", scale) corner, image_warped = warper.warp( img, instrinsics, @@ -278,26 +284,37 @@ def run_annotate( apply_rotation(pos - center, np.linalg.inv(offset_rot)) ) + console.print("Preprocessed images") + with Progress(transient=True) as progress: 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] + ) - blender = cv.detail.Blender.createDefault(cv.detail.BLENDER_MULTI_BAND) + blender = cv.detail.Blender.createDefault(blending_mode) blender.prepare( corners, [(i.shape[1], i.shape[0]) for i in images_warped] ) - for corner, img, msk in zip(corners, images_warped, masks_warped): - dilated_mask = cv.dilate(msk, None) # type: ignore[call-overload] - seam_mask = cv.resize( - dilated_mask, - (msk.shape[1], msk.shape[0]), - None, - 0, - 0, - cv.INTER_LINEAR_EXACT - ) - msk_warped = cv.bitwise_and(seam_mask, msk) - blender.feed(img.astype("int16"), msk_warped, corner) + for i, (corner, img, msk) in enumerate( + zip(corners, images_warped, masks_warped) + ): + if compenstation_mode != cv.detail.EXPOSURE_COMPENSATOR_NO: + img = compensator.apply( + i, + corner, + img, + msk + ) + + blender.feed(img.astype("int16"), msk, corner) result: cvt.MatLike result, _ = blender.blend( @@ -395,7 +412,7 @@ def run_annotate( bottomLeftOrigin=False ) - console.print("Annotated points") + console.print("Annotated points") with Progress(transient=True) as progress: progress.add_task("Saving final image...", total=None) @@ -432,11 +449,27 @@ def run_annotate( } +_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 +} + + def main( metadata: Path, output: Path, image: tuple[Path], camera_offset: tuple[float, float, float] | None = None, + compensation: str = "channel", + blending: str = "multiband", scale: float | None = None, width: int | None = None, height: int | None = None, @@ -529,6 +562,8 @@ def main( scale, points, cam_offset, + _COMP_MAP[compensation], + _BLEND_MAP[blending], color, _FONT_MAP[font], fontscale, @@ -546,3 +581,4 @@ def main( ) except cv.error as cve: echo_red(f"The process failed due to an OpenCV error ({cve.code})") + echo_red(cve.err) From a504ee01e184f3f52aefc0e87a2909dfce6878d9 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Fri, 12 Sep 2025 23:38:23 +0200 Subject: [PATCH 26/42] [panorama] added shift option --- src/instrumentman/panorama/__init__.py | 8 ++++++++ src/instrumentman/panorama/process.py | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index 297c725..f6dfbaf 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -174,6 +174,14 @@ def cli_measure(**kwargs: Any) -> None: help="Axis-aligned camera offset from the instrument center", type=(float, float, float) ) +@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", diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index b416dde..1b9fdf0 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -163,6 +163,7 @@ def run_annotate( meta: PanoramaMetadata, output: Path, images: dict[str, Path], + shift: Angle, scale: float | None = None, points: list[tuple[str, Coordinate, str]] = [], camera_offset: Coordinate | None = None, @@ -218,6 +219,7 @@ def run_annotate( continue hz, v, _ = vec.to_polar() + hz = (hz - shift).normalized() height: int width: int height, width, _ = img.shape @@ -350,7 +352,7 @@ def run_annotate( # is rotated with the preliminary angles. prelim_hz, prelim_v, _ = (coord - center).to_polar() offset_rot = ( - rot_z(float(prelim_hz)) + rot_z(float((prelim_hz - shift).normalized())) @ rot_x(np.pi / 2 - float(prelim_v)) ) pt_hz, pt_v, _ = ( @@ -358,6 +360,8 @@ def run_annotate( - (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 @@ -468,6 +472,7 @@ def main( output: Path, image: tuple[Path], camera_offset: tuple[float, float, float] | None = None, + shift: str | None = None, compensation: str = "channel", blending: str = "multiband", scale: float | None = None, @@ -559,6 +564,7 @@ def main( meta, output, image_map, + Angle.from_dms(shift) if shift is not None else Angle(0), scale, points, cam_offset, From b39894adf88b98736b13e345351e7ca791fc924c Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:41:57 +0200 Subject: [PATCH 27/42] [panorama] added seam handling --- src/instrumentman/panorama/__init__.py | 18 ++++++++ src/instrumentman/panorama/process.py | 57 ++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index f6dfbaf..9accc75 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -200,6 +200,24 @@ def cli_measure(**kwargs: Any) -> None: ), 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_group( "Output size options", ( diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index 1b9fdf0..551e4cd 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Sequence +from typing import Sequence, cast from json import JSONDecodeError from rich.console import Console @@ -169,6 +169,8 @@ def run_annotate( camera_offset: Coordinate | None = None, 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, color: tuple[int, int, int] = (0, 0, 0), font: int = cv.FONT_HERSHEY_PLAIN, fontscale: float = 1, @@ -288,6 +290,20 @@ def run_annotate( console.print("Preprocessed images") + with Progress(transient=True) as progress: + progress.add_task(description="Finding seams...", total=None) + finder = cv.detail.SeamFinder.createDefault(seam_mode) + seams = finder.find( + cast(list[cv.UMat], images_warped), + corners, + cast(list[cv.UMat], masks_warped) + ) + + console.print("Identified seams") + + if scale is None: + scale = 1000 + with Progress(transient=True) as progress: progress.add_task(description="Merging images...", total=None) compensator = cv.detail.ExposureCompensator.createDefault( @@ -300,13 +316,24 @@ def run_annotate( 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) in enumerate( - zip(corners, images_warped, masks_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( @@ -316,7 +343,14 @@ def run_annotate( msk ) - blender.feed(img.astype("int16"), msk, corner) + if kernel is not None: + seam_msk = cv.dilate( + seam_msk, + kernel, + borderType=cv.BORDER_CONSTANT + ) + + blender.feed(img, cast(cvt.MatLike, seam_msk), corner) result: cvt.MatLike result, _ = blender.blend( @@ -335,9 +369,6 @@ def run_annotate( tl_x -= origin_x tl_y -= origin_y - if scale is None: - scale = 1000 - full_360 = round(scale * np.pi * 2) if camera_offset is None: @@ -420,7 +451,6 @@ def run_annotate( with Progress(transient=True) as progress: progress.add_task("Saving final image...", total=None) - # console.print("Saving final image... ", end="") # 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 @@ -467,6 +497,13 @@ def run_annotate( } +_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, @@ -475,6 +512,8 @@ def main( shift: str | None = None, compensation: str = "channel", blending: str = "multiband", + seams: str = "voronoi", + seam_overlap: int = 0, scale: float | None = None, width: int | None = None, height: int | None = None, @@ -570,6 +609,8 @@ def main( cam_offset, _COMP_MAP[compensation], _BLEND_MAP[blending], + _SEAM_MAP[seams], + seam_overlap, color, _FONT_MAP[font], fontscale, From 8a34bc5dfa31751a62d0baf32ccbe11e0588e97d Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:36:55 +0200 Subject: [PATCH 28/42] [panorama] fixed mistake in whitebalance reset --- src/instrumentman/panorama/measure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py index 7c54307..e487536 100644 --- a/src/instrumentman/panorama/measure.py +++ b/src/instrumentman/panorama/measure.py @@ -408,4 +408,4 @@ def main( tps.aut.set_tolerance(*tolerances) if whitebalance is not None: - tps.cam.set_whitebalance("auto") + tps.cam.set_whitebalance("AUTO") From 591667ad88f0d144bea2cf73dcdaa468d23051de Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:48:02 +0200 Subject: [PATCH 29/42] [panorama] restored overlap option for measurement --- src/instrumentman/panorama/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index 9accc75..4d11a1e 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -74,6 +74,15 @@ ), 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", From 9200ca22003574023a74ae92cb06ade2c1425c1b Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:54:34 +0200 Subject: [PATCH 30/42] [panorama] reintroduced uniform grid position option --- src/instrumentman/panorama/__init__.py | 13 ++++- src/instrumentman/panorama/measure.py | 70 +++++++++++++++++--------- 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index 4d11a1e..57955fe 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -96,6 +96,15 @@ ), 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", @@ -134,7 +143,9 @@ def cli_measure(**kwargs: Any) -> None: 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. + 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. diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py index e487536..c358afc 100644 --- a/src/instrumentman/panorama/measure.py +++ b/src/instrumentman/panorama/measure.py @@ -23,7 +23,7 @@ from .metadata import PanoramaMetadata, PanoramaFrameMetadata -def image_positions_optimal( +def image_positions( from_hz: Angle, from_v: Angle, to_hz: Angle, @@ -31,7 +31,8 @@ def image_positions_optimal( fov_hz: Angle, fov_v: Angle, overlap_hz: int, - overlap_v: int + overlap_v: int, + adaptive_fov: bool ) -> list[tuple[Angle, Angle]]: positions: list[tuple[Angle, Angle]] = [] delta_hz = (to_hz - from_hz).normalized() @@ -68,21 +69,24 @@ def image_positions_optimal( for r in range(rows): v = from_v + rowstep * r + row_delta_hz = delta_hz - if v <= Angle(math.pi / 2): - row_radius = math.sin(v + redfov_v / 2) - else: - row_radius = math.sin(v - redfov_v / 2) + 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)) - row_delta_hz = delta_hz + fovchord = math.sqrt(2 - 2 * math.cos(redfov_hz)) - if fovchord > 2 * row_radius: - row_redfov_hz = row_delta_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 = Angle( - math.acos(1 - fovchord**2 / (2*row_radius**2)) - ) + row_redfov_hz = redfov_hz cols = math.ceil(float(row_delta_hz) / float(row_redfov_hz)) @@ -199,6 +203,7 @@ def run_panorama( overlap: tuple[int, int], prefix: str, shape: str, + layout: str, horizontal: tuple[Angle, Angle] | None, vertical: tuple[Angle, Angle] | None, logger: Logger @@ -252,16 +257,33 @@ def run_panorama( fov_hz, fov_v = resp_fov.params - positions = image_positions_optimal( - from_hz, - from_v, - to_hz, - to_v, - fov_hz, - fov_v, - overlap[0], - overlap[1] - ) + 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( f"Start capturing panorama in {len(positions)} frame(s)", @@ -356,6 +378,7 @@ def main( 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: @@ -392,6 +415,7 @@ def main( overlap, prefix, shape, + layout, ( Angle.from_dms(horizontal[0]), Angle.from_dms(horizontal[1]) From a09e3473f1712a700138a45157f23ab9401ee761 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Wed, 17 Sep 2025 01:17:50 +0200 Subject: [PATCH 31/42] [panorama] updated docs about image positions --- docs/commands/panorama/image_positions.png | Bin 107697 -> 43589 bytes docs/commands/panorama/measure.rst | 11 +++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/commands/panorama/image_positions.png b/docs/commands/panorama/image_positions.png index ffa17f87826c6f713559fa7ce4bc60c7d4f5acdc..1f63f1292d385fbf11af3f8a9af4561940462b99 100644 GIT binary patch literal 43589 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 diff --git a/docs/commands/panorama/measure.rst b/docs/commands/panorama/measure.rst index e291a60..03518f7 100644 --- a/docs/commands/panorama/measure.rst +++ b/docs/commands/panorama/measure.rst @@ -27,15 +27,22 @@ reduce the number of angle inputs needed for specific cases: .. note:: - The complete sphere panorama is very practical, and more of a proof of + The complete sphere panorama is not very practical, and more of a proof of concept, as capturing the full view takes impractically long time. -Images that are taken at angles that deviate from the horizontal position +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 -------- From f19a28046450a0baec48ad3f89cf26f05608fee4 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:48:32 +0200 Subject: [PATCH 32/42] [panorama] added stitching debug visualization and cleaned up typing --- src/instrumentman/panorama/__init__.py | 5 ++ src/instrumentman/panorama/process.py | 74 ++++++++++++++++++++------ 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index 57955fe..8ceec33 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -238,6 +238,11 @@ def cli_measure(**kwargs: Any) -> None: 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", ( diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index 551e4cd..b43ae7d 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -1,5 +1,12 @@ +# 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, cast +from typing import Sequence from json import JSONDecodeError from rich.console import Console @@ -17,7 +24,6 @@ try: import cv2 as cv - import cv2.typing as cvt except ModuleNotFoundError: print( """ @@ -171,6 +177,7 @@ def run_annotate( 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, @@ -188,8 +195,8 @@ def run_annotate( ) -> None: corners: list[Sequence[int]] = [] centers: list[tuple[int, int, Angle, Angle]] = [] - images_warped: list[cvt.MatLike] = [] - masks_warped: list[cvt.MatLike] = [] + images_warped: list[npt.NDArray[np.uint8]] = [] + masks_warped: list[npt.NDArray[np.uint8]] = [] cam_offsets: list[Coordinate] = [] fov_w, fov_h = meta["fov"] @@ -215,7 +222,11 @@ def run_annotate( echo_yellow(f"Could not find '{data['filename']}'") continue - img = cv.imread(str(path)) + # Returns uint8 + img: npt.NDArray[np.uint8] = cv.imread( + str(path), + cv.IMREAD_COLOR_BGR + ) # type: ignore[assignment] if img is None: echo_yellow(f"Could not load '{data['filename']}'") continue @@ -228,6 +239,28 @@ def run_annotate( f_w: float = width / 2 / np.tan(fov_w / 2) f_h: float = height / 2 / np.tan(fov_h / 2) + 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 = (f_w + f_h) / 2 @@ -241,13 +274,15 @@ def run_annotate( (0.0, f_h, height/2), (0.0, 0.0, 1.0) ) - ).astype("float32") + ).astype(np.float32) rot: npt.NDArray[np.float32] = ( rot_y(float(hz)) @ rot_x(np.pi / 2 - float(v)) - ).astype("float32") + ).astype(np.float32) - corner, image_warped = warper.warp( + # Maintains input type (uint8) + image_warped: npt.NDArray[np.uint8] + corner, image_warped = warper.warp( # type: ignore[assignment] img, instrinsics, rot, @@ -255,8 +290,9 @@ def run_annotate( cv.BORDER_REPLICATE ) - _, mask_warped = warper.warp( - np.full((height, width), 255, "uint8"), + 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, @@ -281,7 +317,7 @@ def run_annotate( # but it is good enough estimation in case the offset is not # precisely known beforehand. # - # The matrix use in the spherical warp cannot be reused here, + # The matrix used in the spherical warp cannot be reused here, # because OpenCV uses a different axis orientation order. offset_rot = rot_z(float(hz)) @ rot_x(np.pi / 2 - float(v)) cam_offsets.append( @@ -294,9 +330,9 @@ def run_annotate( progress.add_task(description="Finding seams...", total=None) finder = cv.detail.SeamFinder.createDefault(seam_mode) seams = finder.find( - cast(list[cv.UMat], images_warped), + images_warped, # type: ignore[arg-type] corners, - cast(list[cv.UMat], masks_warped) + masks_warped # type: ignore[arg-type] ) console.print("Identified seams") @@ -341,7 +377,7 @@ def run_annotate( corner, img, msk - ) + ) # type: ignore[assignment] if kernel is not None: seam_msk = cv.dilate( @@ -350,9 +386,13 @@ def run_annotate( borderType=cv.BORDER_CONSTANT ) - blender.feed(img, cast(cvt.MatLike, seam_msk), corner) + blender.feed( + img.astype(np.int16), + seam_msk.get().astype(np.uint8), + corner + ) - result: cvt.MatLike + result: npt.NDArray[np.int16] result, _ = blender.blend( None, None ) # type: ignore[call-overload] @@ -514,6 +554,7 @@ def main( 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, @@ -611,6 +652,7 @@ def main( _BLEND_MAP[blending], _SEAM_MAP[seams], seam_overlap, + visualize_stitch, color, _FONT_MAP[font], fontscale, From 2d16c79fbbed6aaf32d304c48fb56a30a5d09968 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Thu, 18 Sep 2025 19:15:19 +0200 Subject: [PATCH 33/42] [panorama] fixed missing usage info for processing --- docs/commands/panorama/process.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/commands/panorama/process.rst b/docs/commands/panorama/process.rst index 00bee41..ee48090 100644 --- a/docs/commands/panorama/process.rst +++ b/docs/commands/panorama/process.rst @@ -54,5 +54,5 @@ Examples Usage ----- -.. click:: instrumentman.panorama:cli_process +.. click:: instrumentman.panorama:cli_calc :prog: iman process panorama From 8c2c425a46dc6b26e9ef054a00ee6a4c61dea04b Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Sat, 20 Sep 2025 23:16:19 +0200 Subject: [PATCH 34/42] [docs] added notes about extra dependencies --- README.md | 8 ++++++-- src/instrumentman/panorama/__init__.py | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) 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/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index 8ceec33..1031d86 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -416,6 +416,8 @@ 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. From 78aa68efc63d2c1a21df209ed1259ab4e3a2dbb5 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:08:06 +0200 Subject: [PATCH 35/42] [panorama] updated console feedback --- src/instrumentman/panorama/measure.py | 53 +++++++++++++-------------- src/instrumentman/panorama/process.py | 32 ++++++++-------- 2 files changed, 42 insertions(+), 43 deletions(-) diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py index c358afc..875aba2 100644 --- a/src/instrumentman/panorama/measure.py +++ b/src/instrumentman/panorama/measure.py @@ -3,7 +3,6 @@ from logging import getLogger, Logger import json -from rich.console import Console from rich.progress import ( Progress, TextColumn, @@ -12,14 +11,14 @@ TimeElapsedColumn, MofNCompleteColumn ) -from click_extra import pause, confirm +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 echo_red, echo_yellow +from ..utils import console, print_error, print_warning from .metadata import PanoramaMetadata, PanoramaFrameMetadata @@ -129,23 +128,23 @@ def get_extents_region( from_v, to_v = vertical return from_hz, from_v, to_hz, to_v - pause( + console.input( "Aim the instrument at the left starting corner, " - "then press any key..." + "then press ENTER..." ) resp_start = tps.tmc.get_angle() if resp_start.error != GeoComCode.OK or resp_start.params is None: - echo_red("Could not retrieve starting corner angles") + print_error("Could not retrieve starting corner angles") logger.critical("Could not retrieve starting corner angles") exit(1) - pause( + console.input( "Aim the instrument at the right finish corner, " - "then press any key..." + "then press ENTER..." ) resp_end = tps.tmc.get_angle() if resp_end.error != GeoComCode.OK or resp_end.params is None: - echo_red("Could not retrieve finishing corner angles") + print_error("Could not retrieve finishing corner angles") logger.critical("Could not retrieve finishing corner angles") exit(1) @@ -166,23 +165,23 @@ def get_extents_strip( from_v, to_v = vertical return from_hz, from_v, to_hz, to_v - pause( + console.input( "Aim the instrument at the top of the strip, " - "then press any key..." + "then press ENTER..." ) resp_start = tps.tmc.get_angle() if resp_start.error != GeoComCode.OK or resp_start.params is None: - echo_red("Could not retrieve strip top angles") + print_error("Could not retrieve strip top angles") logger.critical("Could not retrieve strip top angles") exit(1) - pause( + console.input( "Aim the instrument at the bottom of the strip, " - "then press any key..." + "then press ENTER..." ) resp_end = tps.tmc.get_angle() if resp_end.error != GeoComCode.OK or resp_end.params is None: - echo_red("Could not retrieve strip bottom angles") + print_error("Could not retrieve strip bottom angles") logger.critical("Could not retrieve strip bottom angles") exit(1) @@ -236,19 +235,19 @@ def run_panorama( resp_zoom = tps.cam.set_zoom(zoom) if resp_zoom.error != GeoComCode.OK: - echo_red("Could set camera zoom factor") + 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: - echo_red("Could not retrieve camera FOV") + 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: - echo_red("Could not retrieve station coordinates") + print_error("Could not retrieve station coordinates") logger.critical("Could not retrieve station coordinates") exit(1) @@ -285,16 +284,16 @@ def run_panorama( case _: raise ValueError("Unknown position layout") - if not confirm( + if not Confirm.ask( f"Start capturing panorama in {len(positions)} frame(s)", - True + console=console, + default=True ): - echo_yellow("Program cancelled") + print_warning("Program cancelled") exit() images: list[PanoramaFrameMetadata] = [] - console = Console() progress = Progress( TextColumn("[progress.description]{task.description}"), BarColumn(), @@ -312,31 +311,31 @@ def run_panorama( for idx, (hz, v) in enumerate(positions): resp_turn = tps.aut.turn_to(hz, v) if resp_turn.error != GeoComCode.OK: - echo_yellow("Could not turn to position") + 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: - echo_yellow("Could not set image name") + 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: - echo_yellow("Could not take image") + 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: - echo_yellow("Could not retrieve camera position") + 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: - echo_yellow("Could not retrieve camera direction") + print_warning("Could not retrieve camera direction") logger.critical("Could not retrieve camera direction") continue diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index b43ae7d..0a66ca9 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -9,7 +9,6 @@ from typing import Sequence from json import JSONDecodeError -from rich.console import Console from rich.progress import ( Progress, TextColumn, @@ -38,11 +37,11 @@ ) exit(1) -from ..utils import echo_yellow, echo_red +from ..utils import print_warning, print_error, console from .metadata import read_metadata, PanoramaMetadata -_MAX_SCALE = np.iinfo(np.int16).max // (2 * np.pi) +_MAX_SCALE = np.iinfo(np.int16).max // (2 * np.pi) - 1 def rot_x(angle: float) -> np.typing.NDArray[np.float64]: @@ -201,7 +200,6 @@ def run_annotate( fov_w, fov_h = meta["fov"] center = Coordinate(*meta["center"]) - console = Console() with Progress( TextColumn("[progress.description]{task.description}"), BarColumn(), @@ -219,7 +217,7 @@ def run_annotate( vec = Coordinate(*data["vector"]) path = images.get(data["filename"]) if path is None: - echo_yellow(f"Could not find '{data['filename']}'") + print_warning(f"Could not find '{data['filename']}'") continue # Returns uint8 @@ -228,7 +226,7 @@ def run_annotate( cv.IMREAD_COLOR_BGR ) # type: ignore[assignment] if img is None: - echo_yellow(f"Could not load '{data['filename']}'") + print_warning(f"Could not load '{data['filename']}'") continue hz, v, _ = vec.to_polar() @@ -275,6 +273,7 @@ def run_annotate( (0.0, 0.0, 1.0) ) ).astype(np.float32) + # TODO: Account for axis-wise tilt rot: npt.NDArray[np.float32] = ( rot_y(float(hz)) @ rot_x(np.pi / 2 - float(v)) @@ -326,8 +325,8 @@ def run_annotate( console.print("Preprocessed images") - with Progress(transient=True) as progress: - progress.add_task(description="Finding seams...", total=None) + with Progress(transient=True, console=console) as progress: + progress.add_task(description="Finding seams", total=None) finder = cv.detail.SeamFinder.createDefault(seam_mode) seams = finder.find( images_warped, # type: ignore[arg-type] @@ -340,8 +339,8 @@ def run_annotate( if scale is None: scale = 1000 - with Progress(transient=True) as progress: - progress.add_task(description="Merging images...", total=None) + with Progress(transient=True, console=console) as progress: + progress.add_task(description="Merging images", total=None) compensator = cv.detail.ExposureCompensator.createDefault( compenstation_mode ) @@ -399,7 +398,7 @@ def run_annotate( console.print("Merged images") if len(points) > 0: - with Progress(transient=True) as progress: + with Progress(transient=True, console=console) as progress: # Top left image center point for reference origin_x, origin_y, _, _ = cv.detail.resultRoi( corners, @@ -489,8 +488,8 @@ def run_annotate( console.print("Annotated points") - with Progress(transient=True) as progress: - progress.add_task("Saving final image...", total=None) + with Progress(transient=True, console=console) as progress: + 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 @@ -579,7 +578,7 @@ def main( try: meta = read_metadata(metadata) except (ValidationError, JSONDecodeError): - echo_red( + print_error( "The metadata file is not a valid JSON or does not follow the " "required schema" ) @@ -669,5 +668,6 @@ def main( label_justify ) except cv.error as cve: - echo_red(f"The process failed due to an OpenCV error ({cve.code})") - echo_red(cve.err) + print_error(f"The process failed due to an OpenCV error ({cve.code})") + print_error(cve.err) + raise cve From 81eba3ed84097b0e6a6b42bb748b5de27b2f67b0 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:38:38 +0200 Subject: [PATCH 36/42] [panorama] fixed critical bug with single row captures When the vertical extent was smaller than the reduced FoV (i.e. only a single row needed), the rows ended up as 0, and caused a zero divison exception. --- src/instrumentman/panorama/measure.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py index 875aba2..9367382 100644 --- a/src/instrumentman/panorama/measure.py +++ b/src/instrumentman/panorama/measure.py @@ -45,7 +45,8 @@ def image_positions( redfov_hz = fov_hz * (1 - overlap_hz / 50) redfov_v = fov_v * (1 - overlap_v / 50) - delta_v = delta_v - redfov_v + if redfov_v < delta_v: + delta_v -= redfov_v rows = math.ceil(float(delta_v) / float(redfov_v)) delta_v = redfov_v * rows @@ -185,8 +186,11 @@ def get_extents_strip( logger.critical("Could not retrieve strip bottom angles") exit(1) - from_hz, from_v = resp_start.params - to_hz, to_v = resp_end.params + _, 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 From bc2b0a82b7588ad34c733416664e354764aabf5e Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:42:02 +0200 Subject: [PATCH 37/42] [panorama] reworked processing and changed metadata fields The processing now accounts for roll-wise deviation of the camera. The metadata was also changed to explicitly contain the intrinsic and extrinsic parameters necessary. --- src/instrumentman/panorama/__init__.py | 5 -- src/instrumentman/panorama/measure.py | 25 +++++++- src/instrumentman/panorama/metadata.py | 5 +- src/instrumentman/panorama/process.py | 63 ++++++------------- .../panorama/schema_metadata.json | 36 +++++++++-- 5 files changed, 78 insertions(+), 56 deletions(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index 1031d86..adc8c98 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -189,11 +189,6 @@ def cli_measure(**kwargs: Any) -> None: nargs=-1, required=True ) -@option( - "--camera-offset", - help="Axis-aligned camera offset from the instrument center", - type=(float, float, float) -) @option( "--shift", help=( diff --git a/src/instrumentman/panorama/measure.py b/src/instrumentman/panorama/measure.py index 9367382..6e0f8e4 100644 --- a/src/instrumentman/panorama/measure.py +++ b/src/instrumentman/panorama/measure.py @@ -255,6 +255,22 @@ def run_panorama( 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) @@ -361,8 +377,15 @@ def run_panorama( tps.aut.turn_to(0, math.pi) metadata: PanoramaMetadata = { - "fov": (float(fov_hz), float(fov_v)), "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) diff --git a/src/instrumentman/panorama/metadata.py b/src/instrumentman/panorama/metadata.py index e7b4a95..e8347de 100644 --- a/src/instrumentman/panorama/metadata.py +++ b/src/instrumentman/panorama/metadata.py @@ -14,8 +14,11 @@ class PanoramaFrameMetadata(TypedDict): class PanoramaMetadata(TypedDict): - fov: tuple[float, float] # horizontal, vertical 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] diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index 0a66ca9..ef6acf5 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -171,7 +171,6 @@ def run_annotate( shift: Angle, scale: float | None = None, points: list[tuple[str, Coordinate, str]] = [], - camera_offset: Coordinate | None = None, 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, @@ -196,10 +195,23 @@ def run_annotate( centers: list[tuple[int, int, Angle, Angle]] = [] images_warped: list[npt.NDArray[np.uint8]] = [] masks_warped: list[npt.NDArray[np.uint8]] = [] - cam_offsets: list[Coordinate] = [] - fov_w, fov_h = meta["fov"] 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(), @@ -213,7 +225,6 @@ def run_annotate( meta["images"], description="Preprocessing images" ): - pos = Coordinate(*data["position"]) vec = Coordinate(*data["vector"]) path = images.get(data["filename"]) if path is None: @@ -234,8 +245,6 @@ def run_annotate( height: int width: int height, width, _ = img.shape - f_w: float = width / 2 / np.tan(fov_w / 2) - f_h: float = height / 2 / np.tan(fov_h / 2) if visualize_stitch: img = np.stack( @@ -261,22 +270,15 @@ def run_annotate( if warper is None: if scale is None: - scale = (f_w + f_h) / 2 + scale = focal scale = min(scale, _MAX_SCALE) warper = cv.PyRotationWarper("spherical", scale) - instrinsics: npt.NDArray[np.float32] = np.array( - ( - (f_w, 0.0, width/2), - (0.0, f_h, height/2), - (0.0, 0.0, 1.0) - ) - ).astype(np.float32) - # TODO: Account for axis-wise tilt 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) @@ -310,19 +312,6 @@ def run_annotate( images_warped.append(image_warped) masks_warped.append(mask_warped) - # The de-rotation of the camera offset is not completely accurate - # since the optical axis of the camera might not be parallel to the - # axis of the telescope (which results in some angle deviation), - # but it is good enough estimation in case the offset is not - # precisely known beforehand. - # - # The matrix used in the spherical warp cannot be reused here, - # because OpenCV uses a different axis orientation order. - offset_rot = rot_z(float(hz)) @ rot_x(np.pi / 2 - float(v)) - cam_offsets.append( - apply_rotation(pos - center, np.linalg.inv(offset_rot)) - ) - console.print("Preprocessed images") with Progress(transient=True, console=console) as progress: @@ -410,9 +399,6 @@ def run_annotate( full_360 = round(scale * np.pi * 2) - if camera_offset is None: - camera_offset = mean_coordinate(cam_offsets) - for pt, coord, label in progress.track( points, description="Annotating points" @@ -422,8 +408,8 @@ def run_annotate( # is rotated with the preliminary angles. prelim_hz, prelim_v, _ = (coord - center).to_polar() offset_rot = ( - rot_z(float((prelim_hz - shift).normalized())) - @ rot_x(np.pi / 2 - float(prelim_v)) + rot_z(float((prelim_hz - shift).normalized()) - camera_yaw) + @ rot_x(np.pi / 2 - float(prelim_v) - camera_pitch) ) pt_hz, pt_v, _ = ( coord @@ -547,7 +533,6 @@ def main( metadata: Path, output: Path, image: tuple[Path], - camera_offset: tuple[float, float, float] | None = None, shift: str | None = None, compensation: str = "channel", blending: str = "multiband", @@ -596,15 +581,6 @@ def main( elif height is not None: scale = height / np.pi - if camera_offset is not None: - cam_offset = Coordinate( - camera_offset[0], - camera_offset[1], - camera_offset[2] - ) - else: - cam_offset = None - color = (color[2], color[1], color[0]) if label_color is None: label_color = color @@ -646,7 +622,6 @@ def main( Angle.from_dms(shift) if shift is not None else Angle(0), scale, points, - cam_offset, _COMP_MAP[compensation], _BLEND_MAP[blending], _SEAM_MAP[seams], diff --git a/src/instrumentman/panorama/schema_metadata.json b/src/instrumentman/panorama/schema_metadata.json index 39013ab..4f47f24 100644 --- a/src/instrumentman/panorama/schema_metadata.json +++ b/src/instrumentman/panorama/schema_metadata.json @@ -3,20 +3,46 @@ "description": "Additional data needed for panorama stitching and annotation", "type": "object", "additionalProperties": false, - "required": ["fov", "center", "images"], + "required": ["center", "focal", "principal", "camera_offset", "camera_deviation", "images"], "properties": { - "fov": { - "description": "Horizontal and vertical field of view in radians", + "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 } }, - "center": { - "description": "Instrument center position", + "camera_deviation": { + "description": "Camera axis deviation (yaw-pitch-roll) in radians", "type": "array", "minItems": 3, "maxItems": 3, From d04b447a82a0ade1c50fa36bd1bedbcb579802bd Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:55:45 +0200 Subject: [PATCH 38/42] [panorama] simplified progress reporting --- src/instrumentman/panorama/process.py | 43 +++++++++++++-------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index ef6acf5..d759ff1 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -14,7 +14,8 @@ TextColumn, BarColumn, MofNCompleteColumn, - TimeRemainingColumn + TimeRemainingColumn, + TimeElapsedColumn ) from jsonschema import ValidationError from geocompy.data import Coordinate, Angle @@ -217,8 +218,8 @@ def run_annotate( BarColumn(), MofNCompleteColumn(), TimeRemainingColumn(), - console=console, - transient=True + TimeElapsedColumn(), + console=console ) as progress: warper: cv.PyRotationWarper | None = None for data in progress.track( @@ -312,10 +313,7 @@ def run_annotate( images_warped.append(image_warped) masks_warped.append(mask_warped) - console.print("Preprocessed images") - - with Progress(transient=True, console=console) as progress: - progress.add_task(description="Finding seams", total=None) + 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] @@ -323,13 +321,15 @@ def run_annotate( masks_warped # type: ignore[arg-type] ) - console.print("Identified seams") + if scale is None: + scale = 1000 - if scale is None: - scale = 1000 + progress.update(task_seams, completed=len(seams), total=len(seams)) - with Progress(transient=True, console=console) as progress: - progress.add_task(description="Merging images", total=None) + task_merge = progress.add_task( + description="Merging images", + total=None + ) compensator = cv.detail.ExposureCompensator.createDefault( compenstation_mode ) @@ -385,9 +385,13 @@ def run_annotate( None, None ) # type: ignore[call-overload] - console.print("Merged images") - if len(points) > 0: - with Progress(transient=True, console=console) as progress: + 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, @@ -472,10 +476,7 @@ def run_annotate( bottomLeftOrigin=False ) - console.print("Annotated points") - - with Progress(transient=True, console=console) as progress: - progress.add_task("Saving final image", total=None) + 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 @@ -485,9 +486,7 @@ def run_annotate( str(output), result.astype(np.uint8) ) - - console.print("Saved final image") - console.print("Panorama complete", style="green") + progress.update(task_save, completed=1, total=1) _MARKER_MAP = { From 06cfdf56f935f7b835a5d207c11affa541b4986e Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:18:00 +0200 Subject: [PATCH 39/42] [panorama] adjusted max processing scale --- src/instrumentman/panorama/__init__.py | 6 +++--- src/instrumentman/panorama/process.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index adc8c98..1d45006 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -249,17 +249,17 @@ def cli_measure(**kwargs: Any) -> None: option( "--scale", help="Panorama scale in [pixels/rad]", - type=FloatRange(0, 5215, min_open=True) + type=FloatRange(0, 5210, min_open=True) ), option( "--width", help="Width of complete sphere panorama in [pixels]", - type=IntRange(0, 32767, min_open=True) + type=IntRange(0, 32735, min_open=True) ), option( "--height", help="Height of complete sphere panorama in [pixels]", - type=IntRange(0, 16383, min_open=True) + type=IntRange(0, 16367, min_open=True) ), constraint=mutually_exclusive ) diff --git a/src/instrumentman/panorama/process.py b/src/instrumentman/panorama/process.py index d759ff1..fa5ea46 100644 --- a/src/instrumentman/panorama/process.py +++ b/src/instrumentman/panorama/process.py @@ -42,7 +42,7 @@ from .metadata import read_metadata, PanoramaMetadata -_MAX_SCALE = np.iinfo(np.int16).max // (2 * np.pi) - 1 +_MAX_SCALE = 5210 # np.iinfo(np.int16).max // (2 * np.pi) def rot_x(angle: float) -> np.typing.NDArray[np.float64]: From 3a3c66dbb84100fa1511f1ebafb439bdcb55345e Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:38:05 +0200 Subject: [PATCH 40/42] [panorama] fixed GeoCOM letter casing in docs --- docs/commands/panorama/measure.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/commands/panorama/measure.rst b/docs/commands/panorama/measure.rst index 03518f7..38cfa83 100644 --- a/docs/commands/panorama/measure.rst +++ b/docs/commands/panorama/measure.rst @@ -12,7 +12,7 @@ necessary metadata automatically being recorded as reported by the instrument. Requirements ------------ -- GeoCom capable robotic total station with overview camera and imaging license +- GeoCOM capable robotic total station with overview camera and imaging license Positions --------- From 3b3f12eae59648ced825971ac9f6258d3fe53e10 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Fri, 3 Oct 2025 01:50:17 +0200 Subject: [PATCH 41/42] [panorama] command help updated --- src/instrumentman/panorama/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/instrumentman/panorama/__init__.py b/src/instrumentman/panorama/__init__.py index 1d45006..a62e24d 100644 --- a/src/instrumentman/panorama/__init__.py +++ b/src/instrumentman/panorama/__init__.py @@ -161,6 +161,9 @@ def cli_measure(**kwargs: Any) -> None: 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 From cc7756306fa6daafef97572114a4a2835f8660b6 Mon Sep 17 00:00:00 2001 From: MrClock8163 <48679831+MrClock8163@users.noreply.github.com> Date: Tue, 7 Oct 2025 00:56:33 +0200 Subject: [PATCH 42/42] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) 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`)