diff --git a/.gitignore b/.gitignore
index b58f5346..4c2b7b59 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,4 +29,8 @@ actual_output/
**/test/data/output/
# documentation generation
-doc/html/
\ No newline at end of file
+doc/html/
+
+# user error logs and lookfast details
+de430t.bsp
+error_logs/
diff --git a/contrib/app/LookFast/2026_06_24_SNL_LookFast_default_end_to_end_example_settings.ini b/contrib/app/LookFast/2026_06_24_SNL_LookFast_default_end_to_end_example_settings.ini
new file mode 100644
index 00000000..57a72acd
--- /dev/null
+++ b/contrib/app/LookFast/2026_06_24_SNL_LookFast_default_end_to_end_example_settings.ini
@@ -0,0 +1,107 @@
+[DEFAULT]
+#####
+# File Paths should be represented as r-strings, or a python representation using forward slashes
+# r-string r"C:\this\is\my\windows\file\path\to\something.stuff"
+# forward slashed "C:/this/is/my/windows/file/path/to/something.stuff"
+#####
+
+# Top Level Output Directory
+# Input file path or None to interactively select during first processing
+primary_folder = r"\\snl\Collaborative\NSTTF_Optics\OpenCSP_Releases\Releases\2026-06-xx_LookfastExampleData_V1.0_UNDER_CONSTRUCTION\LookfastExampleData_V1.0\LookfastExample_01\output_expected"
+
+# Input Source Video
+# Input file path to original video or None to interactively select during first processing
+# Original video is never edited, a copy is made within the primary output folder structure and this copy is referenced.
+og_video_path = r"\\snl\Collaborative\NSTTF_Optics\OpenCSP_Releases\Releases\2026-06-xx_LookfastExampleData_V1.0_UNDER_CONSTRUCTION\LookfastExampleData_V1.0\LookfastExample_01\input\2025_06_05_LookFast_Cam2_Data.MOV"
+
+# Input Camera Calibration file
+file_camera = r"\\snl\Collaborative\NSTTF_Optics\OpenCSP_Releases\Releases\2026-06-xx_LookfastExampleData_V1.0_UNDER_CONSTRUCTION\LookfastExampleData_V1.0\LookfastExample_01\input\camera_object_lab_example.h5"
+
+# Location of astronomy database de430t.bsp necessary for processing. If not specified, multiple automatic downloads will trigger during multiprocessing of celestial body analysis.
+ephem_path = r"C:\Users\ajspiel\Documents\OpenCSP\de430t.bsp"
+
+# File Path to figure management specification for sofast-backend plots (not implement yet)
+
+# Code Inputs for Analysis
+cam_intensity_fractions = [0.5, 0.4]
+analysis_fractions = [0.5]
+celestial_object = "sun"
+timezone = "America/Denver"
+
+# Initial frame for analysis if selecting a subset of video data, mark as None if the interactive video exploration is desired.
+start_frame = 14800
+# final frame for analysis if selecting a subset of video data, mark as None if the interactive video exploration is desired.
+end_frame = 32700
+# reference pixel location in (row, column) that is illuminated during data preview as close to reference distance measurement pixel as possible.
+# camera distortion and coordinate system corrections assume that the subject of this pixel with data represents the target latitude, longitude, and elevation provided for celestial vector analysis
+reference_pixel = (500, 900)
+
+# (Hours, minutes, seconds) negative values if the camera time is ahead of data acquisition time
+camera_time_shift = (0,0,0)
+
+# distance in meters from the camera to optic reference pixel
+cam_to_optic_reference_distance = 99.94392
+
+# Define observer location Example ≈NSTTF Tower 260 Level Balcony West Side
+
+# (degree, minute, second) negative degree for south
+observer_lat = (34, 57, 44.56)
+
+# (degree, minute, second) negative degree for west
+observer_long = (-106, 30, 34.90)
+
+# elevation above sea level in meters
+observer_elevation = 1756.8672
+
+# Define target location Example ≈Sun Data Marker 2 In front of 5E8
+
+# (degree, minute, second) negative degree for south
+target_lat = (34, 57, 45.92)
+
+# (degree, minute, second) negative degree for west
+target_long = (-106, 30, 31.88)
+
+# elevation above sea level in meters
+target_elevation = 1706.88
+
+# Pointing Estimate Details
+# Input the ideal time that the spot is supossed to be centered in its travers across the camera (year, month, day, hour, minute, second). This needs to be in the same timezone specified as "timezone" in this initilization file.
+ideal_time_spot_cross_camera = (2025, 6, 5, 15, 18, 20)
+
+# Input ideal sun spot parameters to define the elliptical shape that traverses across camera and its orientation for pointing estimate model
+# coordinates in meters from center of camera
+ideal_spot_center = (0,0)
+ideal_spot_axis_ratio = 1 # ratio of length between major and minor axis of the elliptical shape ratio of 1 yields a circle
+ideal_spot_axis_orientation_angle = 0 # orientation of major axis of elliptical shape with respect to x-axis of planar traverse model
+ideal_spot_traverse_direction = 0 # angle between x-axis and direction of travel across the elliptical shape where travel starts at the first point on the shape exterior as the ellipse sweeps right to left
+sun_diameter_pad_multi = 1.2 # temporary multiplication value to correct simple ideal spot size and traverse time model to better reflect initial test data for function
+
+# Sections to run, noted if not mandatory for primary functionality
+extracted_video_frames = True
+
+# mandatory for primary function, will not re-run if already completed for an analysis unless new cam_intensity_fraction(s) specified, then new levels will generate output
+coverage_maps = True
+
+# Useful but not mandatory
+accelerated_video = True
+
+# mandatory for primary function, will not re-run if already completed for an analysis unless new cam_intensity_fraction(s) specified, then new levels will generate output
+time_history = True
+
+# mandatory for primary function, will not re-run if already completed for an analysis unless new analysis_fraction(s) specified, then new levels will generate output
+pixel_transitions = True
+
+# Useful but not mandatory, plotting takes a long time for many active pixels
+pixel_transition_plots = True
+
+# mandatory for primary function, will not re-run if already completed for an analysis unless new analysis_fraction(s) specified, then new levels will generate output
+celestial_vectors_data = True
+
+# Useful but not mandatory, plotting takes a long time for many active pixels
+celestial_vectors_plots = True
+
+# mandatory for primary function, will not re-run if already completed for an analysis unless new analysis_fraction(s) specified, then new levels will generate output
+lookfast_camera_adjust = True
+
+# mandatory for primary function, will not re-run if already completed for an analysis unless new analysis_fraction(s) specified, then new levels will generate output
+pointing_estimate = True
diff --git a/contrib/app/LookFast/celestial_vectors.py b/contrib/app/LookFast/celestial_vectors.py
new file mode 100644
index 00000000..b613a9bc
--- /dev/null
+++ b/contrib/app/LookFast/celestial_vectors.py
@@ -0,0 +1,785 @@
+import os
+from logging import DEBUG, ERROR
+from datetime import datetime, timedelta
+from zoneinfo import ZoneInfo
+from multiprocessing import Pool, Manager
+
+import skyfield.api as skf
+import numpy as np
+from tqdm import tqdm
+import matplotlib
+
+matplotlib.use('Agg')
+import matplotlib.pyplot as plt
+
+import opencsp.common.lib.tool.file_tools as ft
+
+# import opencsp.app.lookback.lookback_tools as lbt
+import contrib.app.LookFast.lookback_tools as lbt
+
+# Specify the folder where the log file should be saved
+logger = lbt.logging_setup(
+ log_folder=os.path.join(os.getcwd(), "error_logs"),
+ log_file_name="error_log_celestial_vectors_debug.txt",
+ log_type=ERROR,
+)
+
+
+def load_de430_ephemeris(ephemeris_path=None, filename="de430t.bsp"):
+ """
+ Load the DE430 ephemeris, optionally from a user-specified location.
+
+ Behavior:
+ 1) If ephemeris_path is provided:
+ - If it's a directory: looks for
/
+ - If it's a file: uses it directly
+ If found, loads from that explicit path.
+ 2) Otherwise (or if not found there), falls back to the default Skyfield load:
+ skf.load(filename)
+
+ Parameters
+ ----------
+ ephemeris_path : str | pathlib.Path | None
+ Path to a directory containing the ephemeris file, or a full path to the file.
+ filename : str
+ Ephemeris filename to look for (default: "de430t.bsp").
+
+ Returns
+ -------
+ eph
+ Loaded ephemeris object from skyfield.api.Loader.load().
+
+ Raises
+ ------
+ FileNotFoundError
+ If an explicit path was provided but doesn't exist / isn't valid, and fallback also fails.
+ """
+ # 1) Try user-provided location (if any)
+ if ephemeris_path is not None:
+ p = ft.norm_path(ephemeris_path)
+
+ # If user provided a directory, append filename
+ if os.path.isdir(p):
+ candidate = ft.join(p, filename)
+ else:
+ candidate = p
+
+ if ft.file_exists(candidate, error_if_exists_as_dir=True):
+ # Use string path for widest compatibility with loaders
+ return skf.load(str(candidate))
+ # If not found, fall through to default behavior
+
+ # 2) Fallback to Skyfield's normal lookup/download/cache behavior
+ return skf.load(filename)
+
+
+def calculate_vectors_celestial_observer(
+ celestial_object_name, target_location, observer_location, observation_time, ephemeris_file_path
+):
+ """
+ Calculates vectors from a celestial object to a target point and from the target point to an observer.
+
+ Parameters:
+ celestial_object_name (str): Name of the celestial object (e.g., "moon", "sun").
+ target_location (tuple): Latitude, longitude, and elevation of the target point (degrees, meters).
+ observation_time (tuple): Observation time as (year, month, day, hour, minute, second).
+
+ Returns:
+ dict: A dictionary containing the vectors:
+ - "celestial_to_target": Vector from the celestial object to the target point (normalized).
+ - "target_to_observer": Vector from the target point to the observer (normalized).
+ """
+ # Load ephemeris data (DE430 dataset)
+ # eph = skf.load("de430t.bsp") # DE430 ephemeris file
+ eph = load_de430_ephemeris(ephemeris_file_path)
+
+ # Define celestial object
+ celestial_object = eph[celestial_object_name]
+
+ # Define target location
+ target = skf.Topos(
+ latitude_degrees=target_location[0], longitude_degrees=target_location[1], elevation_m=target_location[2]
+ )
+ # Define observer location
+ observer = skf.Topos(
+ latitude_degrees=observer_location[0], longitude_degrees=observer_location[1], elevation_m=observer_location[2]
+ )
+
+ # Define observation time
+ ts = skf.load.timescale()
+ if isinstance(observation_time, skf.Time):
+ pass
+ elif isinstance(observation_time, datetime):
+ # Extract components from the datetime object
+ observation_time = ts.utc(
+ observation_time.year,
+ observation_time.month,
+ observation_time.day,
+ observation_time.hour,
+ observation_time.minute,
+ observation_time.second,
+ )
+ else:
+ # If observation_time is already a tuple, unpack it directly
+ observation_time = ts.utc(*observation_time)
+
+ # Calculate position of the celestial object relative to the target
+ earth = eph["earth"]
+ target_position_vec = earth + target
+ target_position_bary = target_position_vec.at(observation_time)
+ target_obsv_celest = target_position_bary.observe(celestial_object)
+ target_obsv_celest_apparent = target_obsv_celest.apparent()
+
+ celestial_to_target = target_position_vec.at(observation_time).observe(celestial_object).position.km
+ celestial_to_target_normalized = celestial_to_target / np.linalg.norm(celestial_to_target)
+
+ # Calculate position of the target relative to the observer
+ observer_position = earth + observer
+ target_to_observer = observer_position.at(observation_time).observe(target_position_vec)
+
+ earth_target_bary = earth.at(observation_time) # Barycentric Coordinate System
+ earth_obsv_target = earth_target_bary.observe(target_position_vec) # Astrometric Position
+ earth_obsv_target_apparent = earth_obsv_target.apparent() # Apparent Observer reference frame
+
+ NSTTF_coord_toca = [
+ target_obsv_celest_apparent.frame_xyz(target).km[1],
+ target_obsv_celest_apparent.frame_xyz(target).km[0],
+ target_obsv_celest_apparent.frame_xyz(target).km[2],
+ ]
+ NSTTF_coord_eota = [
+ earth_obsv_target_apparent.frame_xyz(target).km[1],
+ earth_obsv_target_apparent.frame_xyz(target).km[0],
+ earth_obsv_target_apparent.frame_xyz(target).km[2],
+ ]
+ NSTTF_coord_otta = [
+ target_to_observer.frame_xyz(target).km[1],
+ target_to_observer.frame_xyz(target).km[0] * -1,
+ target_to_observer.frame_xyz(target).km[2] * -1,
+ ]
+ # Calculate angular size in radians
+ distance_to_celestial_object = target_obsv_celest_apparent.distance().km # Distance in kilometers
+
+ if celestial_object_name.lower() == 'moon':
+ radius_of_celestial_object = 1737.4 # Radius in kilometers of the Moon
+ elif celestial_object_name.lower() == 'sun':
+ radius_of_celestial_object = 1391400 / 2 # Radius in kilometers of the Sun
+ else:
+ radius_of_celestial_object = 1737.4 # Radius in kilometers of the Moon
+
+ angular_size_radians = 2 * np.arctan(radius_of_celestial_object / distance_to_celestial_object)
+
+ # Apparent function returns a tuple with (Altitude, Azimuth, and Distance)
+ # https://rhodesmill.org/skyfield/positions.html#reference-frames
+ # Altitude measures the angle above or below the horizon. The zenith is at +90°, an object on the horizon’s great circle is at 0°, and the nadir beneath your feet is at −90°.
+ # Azimuth measures the angle around the sky from the north pole: 0° means exactly north, 90° is east, 180° is south, and 270° is west.
+ # "earth_to_target_spherical": earth_obsv_target_apparent.altaz()
+ # Cartesian for horizonal skyfield coordinates is Left Handed {x points north, y points east, z points to zenith}
+ # NSTTF coordinates is Right Handed {x points east, y points north, z points to zenith}
+ return {
+ "celestial_to_target": celestial_to_target_normalized,
+ "cel_to_target_cartesian": NSTTF_coord_toca / np.linalg.norm(NSTTF_coord_toca),
+ "earth_to_target_cartesian": NSTTF_coord_eota / np.linalg.norm(NSTTF_coord_eota),
+ "target_to_observer": NSTTF_coord_otta / np.linalg.norm(NSTTF_coord_otta),
+ "angular_size_radians": angular_size_radians,
+ }
+
+
+def process_pixel(args):
+ (
+ pixel,
+ transitions,
+ video_metadata,
+ video_start_time,
+ camera_time_shift,
+ celestial_object_name,
+ target_location,
+ observer_location,
+ tzone,
+ ephem_path,
+ ) = args
+
+ checkpoint_data = {"processed_pixels": [], "pixels_with_data": [], "pixels_without_data": []}
+
+ frame_diff = 0
+ if len(transitions) == 0 or len(transitions) < 2:
+ checkpoint_data["processed_pixels"].append(pixel)
+ checkpoint_data["pixels_without_data"].append(pixel)
+ # lbt.save_checkpoint(checkpoint_folder, checkpoint_file, checkpoint_data, print_path=False)
+ return pixel, None, checkpoint_data
+
+ if len(transitions) == 2:
+ frame_range = []
+ for transition in transitions:
+ if transition["transition"] == "bright":
+ frame_range.append(tuple((1, lbt.frame_number_from_img_name(transition['to_frame']))))
+ elif transition["transition"] == "dark":
+ frame_range.append(tuple((0, lbt.frame_number_from_img_name(transition['to_frame']))))
+ frame_diff = frame_range[1][1] - frame_range[0][1]
+ elif len(transitions) > 2:
+ frame_range_all = []
+ for transition in transitions:
+ if transition["transition"] == "bright":
+ frame_range_all.append(tuple((1, lbt.frame_number_from_img_name(transition['to_frame']))))
+ elif transition["transition"] == "dark":
+ frame_range_all.append(tuple((0, lbt.frame_number_from_img_name(transition['to_frame']))))
+ frame_range, frame_diff = lbt.maximum_frame_range(frame_ranges=frame_range_all)
+
+ elapsed_time_bright = frame_diff / video_metadata['frame_rate']
+ elapsed_time_start = frame_range[0][1] / video_metadata['frame_rate']
+
+ bright_start_time = video_start_time + timedelta(seconds=elapsed_time_start)
+ bright_end_time = video_start_time + timedelta(seconds=elapsed_time_start) + timedelta(seconds=elapsed_time_bright)
+
+ obsv_time_utc_start = lbt.define_observation_time_skyfield(bright_start_time, camera_time_shift)
+ obsv_time_utc_end = lbt.define_observation_time_skyfield(bright_end_time, camera_time_shift)
+
+ start_vector = calculate_vectors_celestial_observer(
+ celestial_object_name=celestial_object_name,
+ target_location=target_location,
+ observer_location=observer_location,
+ observation_time=obsv_time_utc_start,
+ ephemeris_file_path=ephem_path,
+ )
+ end_vector = calculate_vectors_celestial_observer(
+ celestial_object_name=celestial_object_name,
+ target_location=target_location,
+ observer_location=observer_location,
+ observation_time=obsv_time_utc_end,
+ ephemeris_file_path=ephem_path,
+ )
+ points = np.array([[0, 0, 0], start_vector['cel_to_target_cartesian'], end_vector['cel_to_target_cartesian']])
+ radii = np.array([1, start_vector['angular_size_radians'] / 2, end_vector['angular_size_radians'] / 2])
+
+ pixel_data = {
+ "start_time_Local": obsv_time_utc_start.astimezone(tzone),
+ "start_time_UTC": obsv_time_utc_start.utc_datetime(),
+ "start_vector": start_vector,
+ "end_time_Local": obsv_time_utc_end.astimezone(tzone),
+ "end_time_UTC": obsv_time_utc_end.utc_datetime(),
+ "end_vector": end_vector,
+ "intersection_1": [],
+ "intersection_2": [],
+ "slope_1": [],
+ "slope_2": [],
+ "observer_vector": (start_vector['target_to_observer'] + end_vector['target_to_observer']) / 2,
+ }
+
+ try:
+ intersection_1, intersection_2 = trilaterate(points, radii, raise_on_no_solution=False)
+
+ observer_vec, slope_1, slope_2 = safe_calculate_slope(
+ start_vector['target_to_observer'], end_vector['target_to_observer'], intersection_1, intersection_2
+ )
+
+ pixel_data = {
+ "start_time_Local": obsv_time_utc_start.astimezone(tzone),
+ "start_time_UTC": obsv_time_utc_start.utc_datetime(),
+ "start_vector": start_vector,
+ "end_time_Local": obsv_time_utc_end.astimezone(tzone),
+ "end_time_UTC": obsv_time_utc_end.utc_datetime(),
+ "end_vector": end_vector,
+ "intersection_1": intersection_1,
+ "intersection_2": intersection_2,
+ "slope_1": slope_1,
+ "slope_2": slope_2,
+ "observer_vector": observer_vec,
+ }
+
+ checkpoint_data["processed_pixels"].append(pixel)
+ checkpoint_data["pixels_with_data"].append(pixel)
+ # lbt.save_checkpoint(checkpoint_folder, checkpoint_file, checkpoint_data, print_path=False)
+ # except ValueError:
+ except Exception:
+ logger.debug("process_pixel Error pixel %s", pixel, exc_info=True)
+
+ try:
+ observer_vec, slope_1, slope_2 = safe_calculate_slope(
+ start_vector['target_to_observer'], end_vector['target_to_observer'], intersection_1, intersection_2
+ )
+
+ pixel_data = {
+ "start_time_Local": obsv_time_utc_start.astimezone(tzone),
+ "start_time_UTC": obsv_time_utc_start.utc_datetime(),
+ "start_vector": start_vector,
+ "end_time_Local": obsv_time_utc_end.astimezone(tzone),
+ "end_time_UTC": obsv_time_utc_end.utc_datetime(),
+ "end_vector": end_vector,
+ "intersection_1": intersection_1,
+ "intersection_2": intersection_2,
+ "slope_1": slope_1,
+ "slope_2": slope_2,
+ "observer_vector": observer_vec,
+ }
+ checkpoint_data["processed_pixels"].append(pixel)
+ checkpoint_data["pixels_with_data"].append(pixel)
+ except Exception:
+ logger.debug("process_pixel Error pixel %s", pixel, exc_info=True)
+ checkpoint_data["processed_pixels"].append(pixel)
+ checkpoint_data["pixels_without_data"].append(pixel)
+ # lbt.save_checkpoint(checkpoint_folder, checkpoint_file, checkpoint_data, print_path=False)
+ return pixel, pixel_data, checkpoint_data
+
+ return pixel, pixel_data, checkpoint_data
+
+
+def extract_pixel_timing_and_celestial_vectors_parallel(
+ celestial_object_name,
+ target_location,
+ observer_location,
+ camera_time_shift,
+ data_time_zone,
+ data_location,
+ video_metadata,
+ output_folder,
+ output_json_name,
+ checkpoint_folder,
+ checkpoint_file,
+ batch_size=5000, # Number of pixels to process in each batch
+ ephem_path=None,
+):
+ if os.path.exists(os.path.join(output_folder, output_json_name)):
+ logger.info("Compiled output file already exists, skipping code block")
+ return
+
+ video_start_time = datetime.strptime(video_metadata['create_date'], "%Y:%m:%d %H:%M:%S")
+ timezone = ZoneInfo(data_time_zone)
+ video_start_time = video_start_time.replace(tzinfo=timezone)
+
+ checkpoint_data = lbt.load_checkpoint(checkpoint_folder, checkpoint_file)
+ if checkpoint_data is None:
+ checkpoint_data = {"processed_pixels": [], "pixels_with_data": [], "pixels_without_data": []}
+
+ # Ensure the output folder exists
+ os.makedirs(output_folder, exist_ok=True)
+
+ # Read in compressed json with lookback tools function
+ data = lbt.read_compressed_json(data_location)
+
+ # Convert data to a list of items for batching
+ data_items = list(data.items())
+ total_pixels = len(data_items)
+
+ # Process data in batches
+ for batch_start in range(0, total_pixels, batch_size):
+ batch_end = min(batch_start + batch_size, total_pixels)
+ batch_data = data_items[batch_start:batch_end]
+ # Filter batch_data to exclude already processed pixels
+ filtered_batch_data = [
+ (pixel, transitions)
+ for pixel, transitions in batch_data
+ if pixel not in checkpoint_data["processed_pixels"]
+ ]
+ # Use multiprocessing to parallelize the pixel processing
+ with Manager() as manager:
+ args_list = [
+ (
+ pixel,
+ transitions,
+ video_metadata,
+ video_start_time,
+ camera_time_shift,
+ celestial_object_name,
+ target_location,
+ observer_location,
+ timezone,
+ ephem_path,
+ )
+ for pixel, transitions in filtered_batch_data
+ ]
+
+ # with Pool(processes=1) as pool: # Maybe look into hyperthreading
+ with Pool(processes=os.cpu_count()) as pool: # Use all available CPU cores
+ results = list(
+ tqdm(
+ pool.imap(process_pixel, args_list),
+ total=len(filtered_batch_data),
+ desc=f"Processing Batch {batch_start}-{batch_end}",
+ )
+ )
+
+ # Aggregate checkpoint data
+ for _, _, pixel_checkpoint_data in results:
+ ppixel = pixel_checkpoint_data["processed_pixels"]
+ dpixel = pixel_checkpoint_data["pixels_with_data"]
+ wpixel = pixel_checkpoint_data["pixels_without_data"]
+ checkpoint_data["processed_pixels"].append(ppixel[0])
+ if dpixel:
+ checkpoint_data["pixels_with_data"].append(dpixel[0])
+ if wpixel:
+ checkpoint_data["pixels_without_data"].append(wpixel[0])
+
+ # Save checkpoint data serially after each batch
+ lbt.save_checkpoint(checkpoint_folder, checkpoint_file, checkpoint_data, print_path=False)
+
+ # Update the data with processed results
+ for pixel, pixel_data, _ in results:
+ if pixel_data is not None:
+ data[pixel] = pixel_data
+
+ # Save intermediate results to avoid data loss
+ if results:
+ base_name = os.path.splitext(os.path.basename(output_json_name))[0]
+ lbt.write_compressed_json(
+ data=data,
+ file_path=os.path.join(output_folder, f"{base_name}_batch_{batch_start}_{batch_end}.json.gz"),
+ print_path=False,
+ )
+
+ # Save final results
+ if os.path.exists(os.path.join(output_folder, output_json_name)):
+ pass
+ else:
+ lbt.write_compressed_json(data=data, file_path=os.path.join(output_folder, output_json_name), print_path=True)
+
+
+def calculate_slope(observer_vec_start, observer_vec_end, inter_1, inter_2):
+ observer_vec = (observer_vec_start + observer_vec_end) / 2
+ slope_1 = (observer_vec + inter_1) / np.linalg.norm(observer_vec + inter_1)
+ slope_2 = (observer_vec + inter_2) / np.linalg.norm(observer_vec + inter_2)
+ return observer_vec, slope_1, slope_2
+
+
+def safe_calculate_slope(start_vector, end_vector, intersection_1, intersection_2):
+ """
+ Wrapper for calculate_slope with error handling and logging.
+ """
+ try:
+ # Call the calculate_slope function
+ observer_vec, slope_1, slope_2 = calculate_slope(start_vector, end_vector, intersection_1, intersection_2)
+ return observer_vec, slope_1, slope_2
+ except KeyError as e:
+ # Handle missing keys in dictionaries
+ logger.debug("KeyError in calculate_slope: %s", e, exc_info=True)
+ return None, None, None
+ except TypeError as e:
+ # Handle type-related issues (e.g., NoneType or invalid types)
+ logger.debug("TypeError in calculate_slope: %s", e, exc_info=True)
+ return None, None, None
+ except Exception as e:
+ # Catch any other unexpected errors
+ logger.debug("Unexpected error in calculate_slope: %s", e, exc_info=True)
+ return None, None, None
+
+
+def plot_pixel_batch(batch_pixels, data, checkpoint_data, output_folder_img, celestial_object):
+ """
+ Process a batch of pixels and return the list of successfully plotted pixels.
+ """
+ plotted_pixels = []
+
+ for pixel in batch_pixels:
+ height, _ = eval(pixel)
+ plot_file = os.path.normpath(os.path.join(output_folder_img, str(height), f"pixel_{pixel}_sky_plot_slope.png"))
+
+ if pixel in checkpoint_data["pixels_without_data"]:
+ logger.info("Skipping 3D plot for pixel %s without data.", pixel)
+ continue
+
+ if isinstance(data[pixel], list):
+ logger.info("Pixel %s incorrectly saved as having data", pixel)
+ continue
+
+ if os.path.exists(plot_file):
+ logger.info("Pixel %s 3D plot already exists, skipping", pixel)
+ continue
+
+ start_vector = data[pixel]['start_vector']
+ end_vector = data[pixel]['end_vector']
+ local_time_start = data[pixel]['start_time_Local']
+ local_time_end = data[pixel]['end_time_Local']
+ inter_1 = data[pixel]['intersection_1']
+ inter_2 = data[pixel]['intersection_2']
+ slope_1 = data[pixel]['slope_1']
+ slope_2 = data[pixel]['slope_2']
+ observer_vec = data[pixel]['observer_vector']
+
+ # Create the Positive Zenith side of the south half of a unit sphere for visualization of the horizon
+ u = np.linspace(np.pi, 2 * np.pi, 100)
+ v = np.linspace(0, np.pi / 2, 100)
+ x = np.outer(np.cos(u), np.sin(v))
+ y = np.outer(np.sin(u), np.sin(v))
+ z = np.outer(np.ones(np.size(u)), np.cos(v))
+
+ # Initialize 3D plot
+ fig = plt.figure(figsize=(12, 10))
+ ax = fig.add_subplot(111, projection='3d')
+
+ # Plot the unit sphere
+ ax.plot_surface(x, y, z, color='lightblue', alpha=0.15)
+
+ # Initialize inset 3D plot
+ inset_ax = fig.add_axes([0.02, 0.05, 0.3, 0.3], projection='3d')
+
+ # Plot the unit sphere on the inset plot
+ inset_ax.plot_surface(x, y, z, color='lightblue', alpha=0.15)
+
+ ins_x_lim = []
+ ins_y_lim = []
+ ins_z_lim = []
+
+ # Calculate azimuthal angle (azim)
+ azim = np.degrees(np.arctan2(slope_1[1], slope_1[0]))
+
+ # Calculate elevation angle (elev)
+ if slope_1[0] == 0 and slope_1[1] == 0:
+ elev = 90 if slope_1[2] > 0 else -90
+ else:
+ elev = np.degrees(np.arctan2(slope_1[2], np.sqrt(slope_1[0] ** 2 + slope_1[1] ** 2)))
+
+ inset_ax.view_init(elev=elev, azim=azim)
+
+ # Plot the projections for each interpolated time
+ for i, vector in enumerate([start_vector, end_vector]):
+ angular_radius = vector['angular_size_radians'] / 2
+ num_points = 360
+ theta = np.linspace(0, 2 * np.pi, num_points)
+
+ arbitrary_vector = (
+ np.array([1, 0, 0])
+ if not np.allclose(vector['cel_to_target_cartesian'], [1, 0, 0])
+ else np.array([0, 1, 0])
+ )
+ basis1 = np.cross(vector['cel_to_target_cartesian'], arbitrary_vector)
+ basis1 /= np.linalg.norm(basis1)
+
+ basis2 = np.cross(vector['cel_to_target_cartesian'], basis1)
+ basis2 /= np.linalg.norm(basis2)
+
+ circle_points = []
+ for angle in theta:
+ point = np.cos(angular_radius) * vector['cel_to_target_cartesian'] + np.sin(angular_radius) * (
+ np.cos(angle) * basis1 + np.sin(angle) * basis2
+ )
+ circle_points.append(point)
+ circle_points = np.array(circle_points)
+
+ ins_x_lim.append([np.min(circle_points[:, 0]), np.max(circle_points[:, 0])])
+ ins_y_lim.append([np.min(circle_points[:, 1]), np.max(circle_points[:, 1])])
+ ins_z_lim.append([np.min(circle_points[:, 2]), np.max(circle_points[:, 2])])
+
+ ax.plot(circle_points[:, 0], circle_points[:, 1], circle_points[:, 2], color='green' if i == 0 else 'red')
+ inset_ax.plot(
+ circle_points[:, 0], circle_points[:, 1], circle_points[:, 2], color='green' if i == 0 else 'red'
+ )
+
+ if i == 0:
+ ax.quiver(
+ 0,
+ 0,
+ 0,
+ vector['cel_to_target_cartesian'][0],
+ vector['cel_to_target_cartesian'][1],
+ vector['cel_to_target_cartesian'][2],
+ color='green',
+ label=f"Start Time Local: {local_time_start}",
+ arrow_length_ratio=0.1,
+ )
+ ax.quiver(
+ 0,
+ 0,
+ 0,
+ observer_vec[0],
+ observer_vec[1],
+ observer_vec[2],
+ color='black',
+ label="Observer to Target",
+ arrow_length_ratio=0.1,
+ )
+ ax.quiver(
+ 0,
+ 0,
+ 0,
+ slope_1[0],
+ slope_1[1],
+ slope_1[2],
+ color='royalblue',
+ label="Slope Option 1",
+ arrow_length_ratio=0.1,
+ )
+ inset_ax.scatter(inter_1[0], inter_1[1], inter_1[2], c='royalblue', label="Intersection 1")
+ else:
+ ax.quiver(
+ 0,
+ 0,
+ 0,
+ vector['cel_to_target_cartesian'][0],
+ vector['cel_to_target_cartesian'][1],
+ vector['cel_to_target_cartesian'][2],
+ color='red',
+ label=f"End Time Local: {local_time_end}",
+ arrow_length_ratio=0.1,
+ )
+ ax.quiver(
+ 0,
+ 0,
+ 0,
+ slope_2[0],
+ slope_2[1],
+ slope_2[2],
+ color='darkorange',
+ label="Slope Option 2",
+ arrow_length_ratio=0.1,
+ )
+ inset_ax.scatter(inter_2[0], inter_2[1], inter_2[2], c='darkorange', label="Intersection 2")
+
+ ax.set_xlim([-1, 1])
+ ax.set_ylim([-1, 0])
+ ax.set_zlim([0, 1])
+ ax.set_xlabel('X - East is Positive')
+ ax.set_ylabel('Y - North is Positive')
+ ax.set_zlabel('Z - Zenith is Positive')
+ ax.set_title(f'Mixel {pixel} Bright to Dark on Unit Sphere with {celestial_object.capitalize()}')
+ ax.set_aspect('equal')
+ ax.legend(loc='lower center')
+
+ inset_ax.set_xlim([np.min(ins_x_lim) - 0.01, np.max(ins_x_lim) + 0.01])
+ inset_ax.set_ylim([np.min(ins_y_lim) - 0.01, np.max(ins_y_lim) + 0.01])
+ inset_ax.set_zlim([np.min(ins_z_lim) - 0.01, np.max(ins_z_lim) + 0.01])
+ inset_ax.set_xticks([np.round(np.min(ins_x_lim), 2), np.round(np.max(ins_x_lim), 2)])
+ inset_ax.set_yticks([np.round(np.min(ins_y_lim), 2), np.round(np.max(ins_y_lim), 2)])
+ inset_ax.set_zticks([np.round(np.min(ins_z_lim), 2), np.round(np.max(ins_z_lim), 2)])
+ inset_ax.set_title("Zoomed Projection")
+ inset_ax.set_xlabel("X")
+ inset_ax.set_ylabel("Y")
+ inset_ax.set_zlabel("Z")
+
+ if os.path.isdir(os.path.join(output_folder_img, str(height))):
+ plt.savefig(plot_file)
+ plt.close()
+ else:
+ os.makedirs(os.path.join(output_folder_img, str(height)), exist_ok=True)
+ plt.savefig(plot_file)
+ plt.close()
+
+ plotted_pixels.append(pixel)
+
+ return plotted_pixels
+
+
+def plotting_pixel_transition_vectors_decoupled_mp(
+ data_location,
+ checkpoint_folder,
+ checkpoint_data_file,
+ checkpoint_plot_file,
+ celestial_object,
+ output_folder_img,
+ batch_size=5000,
+):
+ """
+ Function to process pixels in batches, skipping already processed pixels.
+ """
+ os.makedirs(output_folder_img, exist_ok=True)
+
+ # Load checkpoint data and plots
+ checkpoint_data = lbt.load_checkpoint(checkpoint_folder, checkpoint_data_file)
+ checkpoint_plots = lbt.load_checkpoint(checkpoint_folder, checkpoint_plot_file)
+ if checkpoint_plots is None:
+ checkpoint_plots = {"plotted_pixels": []}
+ data = lbt.read_compressed_json(data_location)
+
+ # Get the list of all pixels and filter out already processed ones
+ all_pixels = list(data.keys())
+ processed_pixels = checkpoint_plots["plotted_pixels"]
+ unprocessed_pixels = [pixel for pixel in all_pixels if pixel not in processed_pixels]
+ total_pixels = len(all_pixels)
+
+ # Initialize progress bar
+ progress_bar = tqdm(total=total_pixels, desc="Creating 3D Pixel Vector Plots")
+ progress_bar.update(len(processed_pixels)) # Update progress bar for already processed pixels
+
+ # Process pixels in batches
+ for i in range(0, len(unprocessed_pixels), batch_size):
+ batch_pixels = unprocessed_pixels[i : i + batch_size]
+
+ with Pool() as pool:
+ results = pool.starmap(
+ plot_pixel_batch, [(batch_pixels, data, checkpoint_data, output_folder_img, celestial_object)]
+ )
+
+ for plotted_pixels in results:
+ checkpoint_plots["plotted_pixels"].extend(plotted_pixels)
+ lbt.save_checkpoint(checkpoint_folder, checkpoint_plot_file, checkpoint_plots)
+ progress_bar.update(len(plotted_pixels))
+
+ progress_bar.close()
+
+
+def trilaterate(positions: np.ndarray, radii: np.ndarray, raise_on_no_solution: bool = False) -> np.ndarray:
+ """
+ Trilateration algorithm to find the intersection points of three spheres in 3D space.
+
+ :param positions: A 3x3 array where each row represents the (x, y, z) coordinates of a sphere's center.
+ :param radii: A 3-element array representing the radius of each sphere.
+ :param raise_on_no_solution: If True, raises an error when no exact solution exists. If False, returns a close-enough solution.
+ :return: A 2x3 array of intersection points (two solutions), or a 1x3 array if there is only one solution or no exact solution.
+
+ Notes:
+ - This implementation is based on the mathematical derivation of trilateration.
+ - Modified from https://stackoverflow.com/a/18654302/313768
+ """
+ # Extract radii and positions
+ radius1, radius2, radius3 = radii
+ center1, center2, center3 = positions
+
+ # Step 1: Compute inter-point vectors
+ vector21 = center2 - center1 # Vector from center1 to center2
+ vector31 = center3 - center1 # Vector from center1 to center3
+ distance12 = np.linalg.norm(vector21) # Distance between center1 and center2
+ distance13 = np.linalg.norm(vector31) # Distance between center1 and center3
+ distance23 = np.linalg.norm(center3 - center2) # Distance between center2 and center3
+
+ # Step 2: Check for degenerate cases (e.g., overlapping spheres)
+ if np.allclose(center1, center2) and np.isclose(radius1, radius2):
+ raise ValueError("Two spheres have the same center and radius, resulting in an ambiguous or infinite solution.")
+ if np.allclose(center1, center3) and np.isclose(radius1, radius3):
+ raise ValueError("Two spheres have the same center and radius, resulting in an ambiguous or infinite solution.")
+ if np.allclose(center2, center3) and np.isclose(radius2, radius3):
+ raise ValueError("Two spheres have the same center and radius, resulting in an ambiguous or infinite solution.")
+
+ # Check for non-overlapping spheres
+ if distance12 > radius1 + radius2 or distance12 < abs(radius1 - radius2):
+ raise ValueError("Spheres 1 and 2 do not overlap, no solution exists.")
+ if distance13 > radius1 + radius3 or distance13 < abs(radius1 - radius3):
+ raise ValueError("Spheres 1 and 3 do not overlap, no solution exists.")
+ if distance23 > radius2 + radius3 or distance23 < abs(radius2 - radius3):
+ # Compute the closest point on the unit sphere to the line segment connecting centers of spheres 2 and 3
+ vector23 = center3 - center2
+ unit_vector23 = vector23 / np.linalg.norm(vector23) # Unit vector along vector23
+ midpoint = center2 + unit_vector23 * (distance23 / 2) # Midpoint of the line segment
+ closest_point_on_unit_sphere = midpoint / np.linalg.norm(midpoint) # Project midpoint onto the unit sphere
+ logger.info(
+ "Spheres 2 and 3 do not overlap. A single solution between the two spheres is returned for both solutions"
+ )
+ return np.stack(
+ (closest_point_on_unit_sphere, closest_point_on_unit_sphere)
+ ) # Return as a single point for both intersections
+ # raise ValueError("Spheres 2 and 3 do not overlap, no solution exists.")
+
+ # Step 3: Compute basis vectors for the coordinate system
+ unit_vector_u = vector21 / distance12 # Unit vector along vector21
+ projection_i = unit_vector_u.dot(vector31) # Projection of vector31 onto unit_vector_u
+ vector_v = vector31 - unit_vector_u * projection_i # Orthogonal component of vector31
+ vector_v /= np.linalg.norm(vector_v) # Normalize vector_v
+ projection_j = vector_v.dot(vector31) # Projection of vector31 onto vector_v
+ unit_vector_w = np.cross(unit_vector_u, vector_v) # Unit vector orthogonal to both unit_vector_u and vector_v
+
+ # Step 4: Solve for the x and y coordinates in the projected space
+ x = 0.5 / distance12 * (radius1**2 - radius2**2 + distance12**2)
+ y = 0.5 / projection_j * (radius1**2 - radius3**2 - 2 * projection_i * x + projection_i**2 + projection_j**2)
+ radicand = radius1**2 - x**2 - y**2 # Compute the radicand for the z-coordinate
+
+ # Step 5: Handle cases where the radicand is negative (no exact solution)
+ if radicand < 0:
+ logger.debug("Negative radicand %f: no exact solution exists", radicand)
+ if raise_on_no_solution:
+ raise ValueError(f"Negative radicand {radicand}: no exact solutions exist.")
+ return (center1 + unit_vector_u * x + vector_v * y)[np.newaxis, :] # Return a close-enough solution
+
+ # Step 6: Compute the z-coordinate and intersection points
+ z = np.sqrt(radicand) # Compute the z-coordinate
+ offset_z = unit_vector_w * z # Offset in the z-direction
+ solution_a = center1 + unit_vector_u * x + vector_v * y + offset_z # First intersection point
+ solution_b = center1 + unit_vector_u * x + vector_v * y - offset_z # Second intersection point
+
+ # Step 7: Return the solutions
+ return np.stack((solution_a, solution_b)) # Return both intersection points as a 2x3 array
diff --git a/contrib/app/LookFast/coverage_map_mp.py b/contrib/app/LookFast/coverage_map_mp.py
new file mode 100644
index 00000000..5df250c6
--- /dev/null
+++ b/contrib/app/LookFast/coverage_map_mp.py
@@ -0,0 +1,374 @@
+import os
+import gc
+from logging import INFO
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+import numpy as np
+import imageio.v2 as imageio
+from PIL import Image
+import rawpy # Import rawpy for processing RAW image files
+from tqdm import tqdm
+
+# import opencsp.app.lookback.lookback_tools as lbt
+import contrib.app.LookFast.lookback_tools as lbt
+
+# Specify the folder where the log file should be saved
+logger = lbt.logging_setup(
+ log_folder=os.path.join(os.getcwd(), "error_logs"),
+ log_file_name="error_log_coverage_map_mp_debug.txt",
+ log_type=INFO,
+)
+
+
+def process_image(image_file, threshold_fractions, max_intensity, is_raw):
+ """
+ Processes a single image to create binary maps for the given thresholds.
+
+ Parameters:
+ image_file (str): Path to the image file.
+ threshold_fractions (list of float): List of fractions of the maximum intensity value to use as thresholds.
+ max_intensity (float): Maximum possible intensity value for the image format.
+ is_raw (bool): Whether the image is a RAW file.
+
+ Returns:
+ dict: A dictionary where keys are threshold fractions and values are binary maps for the image.
+ """
+ try:
+ # Read the image
+ if is_raw:
+ with rawpy.imread(image_file) as raw:
+ image = raw.postprocess()
+ else:
+ image = imageio.imread(image_file)
+
+ # Handle multi-channel images (e.g., RGB)
+ if len(image.shape) == 3: # Multi-channel image
+ # Calculate pixel intensity as the average across channels
+ pixel_intensity = np.mean(image, axis=-1)
+ else: # Grayscale image
+ pixel_intensity = image
+
+ # Create binary maps for each threshold
+ binary_maps = {}
+ for threshold_fraction in threshold_fractions:
+ intensity_threshold = threshold_fraction * max_intensity
+ binary_maps[threshold_fraction] = (pixel_intensity > intensity_threshold).astype(bool)
+
+ return binary_maps
+
+ except Exception as e:
+ logger.error("Error processing %s : %s", image_file, e, exc_info=True)
+ return None
+
+
+def compile_binary_maps(output_folder, threshold_fractions, prefix):
+ """
+ Compiles all binary maps from batches into a single final output for each threshold.
+
+ Parameters:
+ output_folder (str): Path to the folder containing the binary map outputs.
+ threshold_fractions (list of float): List of fractions of the maximum intensity value to use as thresholds.
+ prefix (str): Prefix for identifying binary maps (e.g., "traditional" or "raw").
+
+ Returns:
+ None
+ """
+ # Initialize a dictionary to store the compiled binary maps
+ compiled_binary_maps = {}
+
+ # Iterate over each threshold fraction
+ for threshold_fraction in threshold_fractions:
+ compiled_map = None
+ # Find all batch files for the current threshold
+ batch_files = [
+ os.path.join(output_folder, f)
+ for f in os.listdir(output_folder)
+ if f.startswith(f"{prefix}_binary_map_{int(threshold_fraction * 100):02d}")
+ ]
+
+ # Combine all batch files
+ for batch_file in batch_files:
+ binary_map = imageio.imread(batch_file) // 255 # Convert back to binary (0 or 1)
+ if compiled_map is None:
+ compiled_map = binary_map
+ else:
+ compiled_map = np.maximum(compiled_map, binary_map) # Combine using logical OR
+
+ # Store the compiled map
+ compiled_binary_maps[threshold_fraction] = compiled_map
+
+ # Save the compiled map to disk
+ output_path = os.path.join(
+ output_folder, f"{prefix}_compiled_binary_map_{int(threshold_fraction * 100):02d}.png"
+ )
+ imageio.imwrite(output_path, compiled_map * 255) # Scale binary map to 0-255 for saving as an image
+ print(f"Compiled binary map for threshold {threshold_fraction} saved to {output_path}")
+
+
+def _close_memmap(arr):
+ # arr is a numpy.memmap
+ try:
+ arr.flush()
+ except Exception:
+ pass
+ # Close underlying mmap handle if present (important on Windows)
+ mm = getattr(arr, "_mmap", None)
+ if mm is not None:
+ try:
+ mm.close()
+ except Exception:
+ pass
+
+
+def process_images_in_batches(
+ image_files, threshold_fractions, max_intensity, is_raw, batch_num, output_folder, prefix
+):
+ """
+ Processes images in batches to create binary maps for the given thresholds.
+
+ Parameters:
+ image_files (list of str): List of image file paths to process.
+ threshold_fractions (list of float): List of fractions of the maximum intensity value to use as thresholds.
+ max_intensity (float): Maximum possible intensity value for the image format.
+ is_raw (bool): Whether the images are RAW files.
+ batch_num (int): Batch Number.
+ output_folder (str): Path to location for binary map output.
+ prefix (str): Prefix for output filenames.
+
+ Returns:
+ None
+ """
+ # Initialize binary maps for each threshold
+ first_image = imageio.imread(image_files[0]) if not is_raw else rawpy.imread(image_files[0]).postprocess()
+ H, W = first_image.shape[0], first_image.shape[1]
+
+ # IMPORTANT: back memmaps with a non-PNG file
+ binary_maps = {}
+ memmap_paths = {}
+ for threshold in threshold_fractions:
+ mm_path = os.path.join(output_folder, f"{prefix}_binary_map_{int(threshold * 100):02d}_{batch_num}.dat")
+ memmap_paths[threshold] = mm_path
+ binary_maps[threshold] = np.memmap(mm_path, dtype=np.bool_, mode="w+", shape=(H, W))
+ binary_maps[threshold].fill(False)
+
+ with ThreadPoolExecutor(max_workers=4) as executor:
+ futures = [
+ executor.submit(process_image, os.path.normpath(f), threshold_fractions, max_intensity, is_raw)
+ for f in image_files
+ ]
+
+ with tqdm(total=len(futures), desc=f"Coverage Map Batch {batch_num}", unit="images") as pbar:
+ for fut in as_completed(futures):
+ result = None
+ try:
+ result = fut.result()
+ if result:
+ for t in threshold_fractions:
+ # In-place OR to avoid temporary arrays
+ np.maximum(binary_maps[t], result[t], out=binary_maps[t])
+ except Exception as e:
+ logger.error("Error processing image: %s", e, exc_info=True)
+ finally:
+ # ensure ref dropped even on exceptions
+ result = None
+ pbar.update(1)
+
+ # Write PNG outputs exactly as before
+ for t, binary_map in binary_maps.items():
+ output_path = os.path.join(output_folder, f"{prefix}_binary_map_{int(t * 100):02d}_{batch_num}.png")
+ bin_image = np.asarray(binary_map, dtype=np.uint8) * 255
+ Image.fromarray(bin_image).save(output_path)
+ print(f"Binary map for threshold {t} saved to {output_path}")
+
+ # Explicitly flush/close and remove memmap backing files
+ for t, mm in binary_maps.items():
+ _close_memmap(mm)
+ # Remove the .dat file to keep behavior similar (only PNG outputs remain)
+ try:
+ os.remove(memmap_paths[t])
+ except OSError:
+ pass
+
+ binary_maps.clear()
+ gc.collect()
+
+
+def construct_binary_maps_parallel(
+ image_folder,
+ output_folder,
+ checkpoint_folder,
+ threshold_fractions,
+ batch_size=10,
+ max_workers=4,
+ checkpoint_file="coverage_map_checkpoint.json",
+):
+ """
+ Constructs a series of binary maps of pixels that exceeded intensity thresholds across all images,
+ including traditional formats using parallel processing and batching.
+ Supports restarting from a checkpoint.
+
+ Checkpoint behavior update:
+ - If new threshold fractions are requested that are NOT in checkpoint_data["completed_thresholds"],
+ the function resets per-image progress ("processed_images", "current_batch") and re-processes
+ the whole dataset for the union of (old completed + new requested) thresholds.
+ - After completion, checkpoint_data["completed_thresholds"] is extended (union) and
+ "processed_images" is repopulated to reflect the completed processing pass.
+
+ Notes/assumptions:
+ - This is the "minimal change" approach: when new thresholds are requested, we re-run the
+ batch processing for *all* images (not just the new thresholds), because the per-image
+ products are threshold-dependent.
+ - completed_thresholds is treated as a *set* of fraction values (with float tolerance via rounding).
+
+ Parameters:
+ image_folder (str): Path to the folder containing the sequence of images.
+ output_folder (str): Path to location for binary map output.
+ checkpoint_folder (str): Path to the checkpoint folder.
+ threshold_fractions (list of float): List of fractions of the maximum intensity value to use as thresholds.
+ batch_size (int): Number of images to process in each batch.
+ max_workers (int): Maximum number of parallel threads to use.
+ checkpoint_file (str): Checkpoint file name.
+
+ Returns:
+ None
+ """
+
+ # ----------------------------
+ # helpers
+ # ----------------------------
+ def _flatten(seq):
+ """Flatten arbitrarily nested lists/tuples/sets (but not strings/bytes)."""
+ if seq is None:
+ return
+ if isinstance(seq, (list, tuple, set)):
+ for item in seq:
+ yield from _flatten(item)
+ else:
+ yield seq
+
+ # Load checkpoint if it exists
+ checkpoint_data = lbt.load_checkpoint(checkpoint_folder, checkpoint_file)
+
+ if checkpoint_data is None:
+ checkpoint_data = {"processed_images": [], "current_batch": 0, "completed_thresholds": [], "prefix": None}
+ else:
+ checkpoint_data["processed_images"] = [os.path.basename(p) for p in checkpoint_data.get("processed_images", [])]
+
+ requested_thr = _flatten(threshold_fractions)
+ completed_thr = _flatten(checkpoint_data.get("completed_thresholds", []))
+
+ requested_set = set(requested_thr)
+ completed_set = set(completed_thr)
+
+ new_thresholds = sorted(requested_set - completed_set)
+ needs_rerun = len(new_thresholds) > 0
+
+ # If anything new was requested, wipe progress and rerun using union thresholds
+ if needs_rerun:
+ checkpoint_data["processed_images"] = []
+ checkpoint_data["current_batch"] = 0
+ # keep completed_thresholds as-is for now; only update after successful compile
+ lbt.save_checkpoint(checkpoint_folder, checkpoint_file, checkpoint_data)
+ else:
+ union_thresholds = requested_thr # nothing new; run normally / continue if interrupted
+
+ # List all image files in the folder
+ image_files_all = os.listdir(image_folder)
+ image_files = []
+ image_files_raw = []
+ for f in image_files_all:
+ if f.endswith((".png", ".jpg", ".jpeg", ".bmp", ".PNG", ".JPG", ".JPEG", ".BMP")):
+ image_files.append(os.path.join(image_folder, f))
+ elif f.endswith((".nef", ".cr2", ".arw", ".dng", ".NEF", ".CR2", ".ARW", ".DNG")):
+ image_files_raw.append(os.path.join(image_folder, f))
+
+ if not image_files and not image_files_raw:
+ raise ValueError("No image files (traditional or RAW) found in the specified folder.")
+
+ # Ensure the output folder exists
+ os.makedirs(output_folder, exist_ok=True)
+
+ # Process traditional images
+ if image_files:
+ # Determine the maximum possible intensity value for traditional images
+ first_image = imageio.imread(image_files[0])
+ max_intensity = np.iinfo(first_image.dtype).max if np.issubdtype(first_image.dtype, np.integer) else 1.0
+
+ # If we did not wipe progress, continue from checkpoint; otherwise run from scratch
+ unprocessed_images = [
+ img for img in image_files if os.path.basename(img) not in checkpoint_data["processed_images"]
+ ]
+
+ # Process images in batches
+ for batch_start in range(checkpoint_data["current_batch"], len(unprocessed_images), batch_size):
+ batch_end = min(batch_start + batch_size, len(unprocessed_images))
+ batch_files = unprocessed_images[batch_start:batch_end]
+ # Extract file names
+ # file_names = [os.path.basename(file) for file in batch_files]
+
+ process_images_in_batches(
+ batch_files,
+ new_thresholds,
+ max_intensity,
+ is_raw=False,
+ batch_num=batch_start // batch_size + 1,
+ output_folder=output_folder,
+ prefix="traditional",
+ )
+
+ # Update checkpoint
+ checkpoint_data["processed_images"].extend(os.path.basename(f) for f in batch_files)
+ checkpoint_data["current_batch"] = batch_start + batch_size
+ checkpoint_data["prefix"] = "traditional"
+ lbt.save_checkpoint(checkpoint_folder, checkpoint_file, checkpoint_data)
+
+ # Compile binary maps for traditional images
+ if new_thresholds not in checkpoint_data["completed_thresholds"]:
+ compile_binary_maps(output_folder, new_thresholds, prefix="traditional")
+ checkpoint_data["completed_thresholds"].extend(new_thresholds)
+ lbt.save_checkpoint(checkpoint_folder, checkpoint_file, checkpoint_data)
+
+
+'''
+ # Process RAW images
+ if image_files_raw:
+ # Determine the maximum possible intensity value for RAW images
+ with rawpy.imread(image_files_raw[0]) as raw:
+ first_image_raw = raw.postprocess()
+ max_intensity_raw = (
+ np.iinfo(first_image_raw.dtype).max if np.issubdtype(first_image_raw.dtype, np.integer) else 1.0
+ )
+
+ # Filter out already processed images
+ unprocessed_images_raw = [img for img in image_files_raw if img not in checkpoint_data["processed_images"]]
+
+ # Process images in batches
+ for batch_start in range(checkpoint_data["current_batch"], len(unprocessed_images_raw), batch_size):
+ batch_end = min(batch_start + batch_size, len(unprocessed_images_raw))
+ batch_files = unprocessed_images_raw[batch_start:batch_end]
+ # Extract file names
+ file_names = [os.path.basename(file) for file in batch_files]
+
+ process_images_in_batches(
+ batch_files,
+ threshold_fractions,
+ max_intensity_raw,
+ is_raw=True,
+ batch_num=batch_start // batch_size + 1,
+ output_folder=output_folder,
+ prefix="raw",
+ )
+
+ # Update checkpoint
+ checkpoint_data["processed_images"].extend(batch_files)
+ checkpoint_data["current_batch"] = batch_start + batch_size
+ checkpoint_data["prefix"] = "raw"
+ lbt.save_checkpoint(checkpoint_folder, checkpoint_file, checkpoint_data)
+
+ # Compile binary maps for RAW images
+ if "raw" not in checkpoint_data["completed_thresholds"]:
+ compile_binary_maps(output_folder, threshold_fractions, prefix="raw")
+ checkpoint_data["completed_thresholds"].append("raw")
+ lbt.save_checkpoint(checkpoint_folder, checkpoint_file, checkpoint_data)
+
+'''
diff --git a/contrib/app/LookFast/interactive_video_info_extract_ref_pixel.py b/contrib/app/LookFast/interactive_video_info_extract_ref_pixel.py
new file mode 100644
index 00000000..43d22198
--- /dev/null
+++ b/contrib/app/LookFast/interactive_video_info_extract_ref_pixel.py
@@ -0,0 +1,348 @@
+import tkinter as tk
+from tkinter import filedialog, messagebox
+import os
+import shutil
+import cv2
+from PIL import Image, ImageTk
+
+import opencsp.common.lib.render.VideoHandler as vh
+import opencsp.common.lib.render_control.RenderControlVideoFrames as rcvf
+import opencsp.common.lib.tool.image_tools as it
+
+# import opencsp.app.lookback.lookback_tools as lbt
+import contrib.app.LookFast.lookback_tools as lbt
+
+
+class VideoScrubber:
+ def __init__(self, root, video_path=None):
+ self.root = root
+ self.root.title("Video Scrubber")
+
+ self.root.protocol("WM_Delete_Window", self.on_exit)
+
+ # Initialize variables
+ self.video_path = video_path
+ self.video_name = None
+ self.start_frame = 0
+ self.end_frame = 0
+ self.total_frames = 0
+ self.fps = None
+ self.current_frame = 0
+ self.cap = None
+ self.frame_image = None
+ self.frame_step = 1 # Default step size for advancing frames
+ self.reference_pixel = None # (x, y)
+ self.ref_pixel_id = None # Canvas id for the dot
+ self.ref_label_id = None # Canvas id for the label
+
+ # Create GUI elements
+ self.create_widgets()
+
+ def create_widgets(self):
+ # Load video button
+ self.load_button = tk.Button(self.root, text="Load Video", command=self.load_video)
+ self.load_button.pack()
+
+ # Video display
+ self.video_label = tk.Canvas(self.root, bg="black")
+ self.video_label.pack(fill="both", expand=True)
+ self.video_label.bind("", self.on_left_click) # Bind left mouse click
+ self.video_label.bind("", self.on_right_click) # Bind right mouse click
+ self.video_label.bind("", self.on_mouse_move) # Bind mouse motion
+
+ # Mouse position readout
+ self.mouse_position_label = tk.Label(self.root, text="Mouse Position: (X: 0, Y: 0)")
+ self.mouse_position_label.pack()
+
+ # Frame controls
+ self.frame_controls = tk.Frame(self.root)
+ self.frame_controls.pack()
+
+ self.start_label = tk.Label(self.frame_controls, text="Start Frame:")
+ self.start_label.grid(row=0, column=0)
+ self.start_entry = tk.Entry(self.frame_controls, width=10)
+ self.start_entry.grid(row=0, column=1)
+
+ self.end_label = tk.Label(self.frame_controls, text="End Frame:")
+ self.end_label.grid(row=1, column=0)
+ self.end_entry = tk.Entry(self.frame_controls, width=10)
+ self.end_entry.grid(row=1, column=1)
+
+ # Navigation buttons
+ self.prev_button = tk.Button(self.frame_controls, text="Previous Frame", command=self.prev_frame)
+ self.prev_button.grid(row=2, column=0)
+ self.next_button = tk.Button(self.frame_controls, text="Next Frame", command=self.next_frame)
+ self.next_button.grid(row=2, column=1)
+
+ # Current frame indicator
+ self.frame_indicator = tk.Label(self.root, text="Frame: 0 / 0")
+ self.frame_indicator.pack()
+
+ # Frame navigation slider and input
+ self.frame_navigation = tk.Frame(self.root)
+ self.frame_navigation.pack()
+
+ self.frame_slider = tk.Scale(
+ self.frame_navigation, from_=0, to=0, orient=tk.HORIZONTAL, command=self.jump_to_frame
+ )
+ self.frame_slider.pack(side=tk.LEFT, fill=tk.X, expand=True)
+
+ self.frame_input = tk.Entry(self.frame_navigation, width=10)
+ self.frame_input.pack(side=tk.LEFT)
+ self.jump_button = tk.Button(self.frame_navigation, text="Jump", command=self.jump_to_frame_input)
+ self.jump_button.pack(side=tk.LEFT)
+
+ # Frame step size controls
+ self.step_controls = tk.Frame(self.root)
+ self.step_controls.pack()
+
+ self.step_label = tk.Label(self.step_controls, text="Step Size:")
+ self.step_label.pack(side=tk.LEFT)
+ self.step_entry = tk.Entry(self.step_controls, width=10)
+
+ self.step_entry.insert(0, "1") # Default step size
+ self.step_entry.pack(side=tk.LEFT)
+ self.set_step_button = tk.Button(self.step_controls, text="Set Step", command=self.set_step_size)
+ self.set_step_button.pack(side=tk.LEFT)
+
+ # Start and End Frames button
+ self.start_to_end = tk.Button(self.root, text="Return Start and End Frames", command=self.confirm_frame_range)
+ self.start_to_end.pack()
+
+ def load_video(self):
+ # Open file dialog to select video
+ if self.video_path is None:
+ self.video_path = filedialog.askopenfilename(
+ title="Select Video File", filetypes=[("Video Files", "*.mp4 *.avi *.mkv *.mov")]
+ )
+ if not self.video_path:
+ return
+
+ # Open video using OpenCV
+ self.cap = cv2.VideoCapture(self.video_path)
+ if not self.cap.isOpened():
+ messagebox.showerror("Error", "Failed to open video file.")
+ return
+
+ # Read the first frame
+ ret, frame = self.cap.read()
+ if not ret:
+ messagebox.showerror("Error", "Failed to read the first frame.")
+ return
+
+ # Get total frames
+ self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
+ self.current_frame = 0
+
+ # Update slider and frame indicator
+ self.frame_slider.config(to=self.total_frames - 1)
+ self.update_frame_indicator()
+
+ # Dynamically resize the canvas to match the frame dimensions
+ frame_height, frame_width, _ = frame.shape
+ self.video_label.config(width=frame_width, height=frame_height)
+
+ # Display the first frame
+ self.display_frame(self.current_frame)
+
+ def update_frame_indicator(self):
+ self.frame_indicator.config(text=f"Frame: {self.current_frame} / {self.total_frames - 1}")
+ self.frame_slider.set(self.current_frame)
+
+ def on_left_click(self, event):
+ # Set the reference pixel coordinate
+ self.reference_pixel = (event.x, event.y)
+ self.display_frame(self.current_frame) # Refresh frame to show dot and label
+
+ def on_right_click(self, event):
+ # Reset the reference pixel selection
+ self.reference_pixel = None
+ self.display_frame(self.current_frame) # Refresh frame to clear dot and label
+
+ def on_mouse_move(self, event):
+ # Update the mouse position readout
+ self.mouse_position_label.config(text=f"Mouse Position: (X: {event.x}, Y: {event.y})")
+
+ def display_frame(self, frame_number):
+ if self.cap is None:
+ return
+
+ # Set the video to the specified frame
+ self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
+ ret, frame = self.cap.read()
+ if not ret:
+ messagebox.showerror("Error", "Failed to read frame.")
+ return
+
+ # Convert the frame to a format suitable for Tkinter
+ frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
+ image = Image.fromarray(frame)
+ self.frame_image = ImageTk.PhotoImage(image)
+
+ # Clear canvas and display the frame image
+ self.video_label.delete("all")
+ self.video_label.create_image(0, 0, anchor=tk.NW, image=self.frame_image)
+
+ # Draw the reference pixel dot and label if set
+ if self.reference_pixel is not None:
+ x, y = self.reference_pixel
+ radius = 3
+ # Draw a small circle (oval) as the dot
+ self.ref_pixel_id = self.video_label.create_oval(
+ x - radius, y - radius, x + radius, y + radius, fill="red", outline="red"
+ )
+ # Draw the label next to the dot
+ label_text = f"({x}, {y})"
+ self.ref_label_id = self.video_label.create_text(
+ x + 10, y - 10, text=label_text, fill="red", font=("Arial", 10, "bold")
+ )
+
+ # Update the frame indicator
+ self.update_frame_indicator()
+
+ def prev_frame(self):
+ # Move backward by the step size
+ self.current_frame = max(0, self.current_frame - self.frame_step)
+ self.update_frame_indicator()
+
+ def next_frame(self):
+ # Move forward by the step size
+ self.current_frame = min(self.total_frames - 1, self.current_frame + self.frame_step)
+ self.update_frame_indicator()
+
+ def set_step_size(self):
+ # Set the step size for advancing frames
+ try:
+ step_size = int(self.step_entry.get())
+ if step_size > 0:
+ self.frame_step = step_size
+ else:
+ tk.messagebox.showerror("Error", "Step size must be positive.")
+ except ValueError:
+ tk.messagebox.showerror("Error", "Invalid step size.")
+
+ def jump_to_frame(self, frame_number):
+ # Jump to the frame specified by the slider
+ self.current_frame = int(frame_number)
+ self.display_frame(self.current_frame)
+
+ def jump_to_frame_input(self):
+ # Jump to the frame specified by the input field
+ try:
+ frame_number = int(self.frame_input.get())
+ if 0 <= frame_number < self.total_frames:
+ self.current_frame = frame_number
+ self.display_frame(self.current_frame)
+ else:
+ tk.messagebox.showerror("Error", "Frame number out of range.")
+ except ValueError:
+ tk.messagebox.showerror("Error", "Invalid frame number.")
+
+ def confirm_frame_range(self):
+ # Get start and end frames from the user
+ try:
+ self.start_frame = int(self.start_entry.get())
+ self.end_frame = int(self.end_entry.get())
+ except ValueError:
+ messagebox.showerror("Error", "Invalid frame numbers.")
+ return
+
+ if self.start_frame < 0 or self.end_frame >= self.total_frames or self.start_frame >= self.end_frame:
+ messagebox.showerror("Error", "Frame range is invalid.")
+ return
+ self.fps = self.cap.get(cv2.CAP_PROP_FPS)
+
+ def on_exit(self):
+
+ self.confirm_frame_range()
+ self.root.destroy()
+
+
+def extract_all_frames(source_vid_path, frame_dest_path):
+ # sync_obj = ss.ServerSynchronizer
+ frame_control = rcvf.RenderControlVideoFrames(outframe_name="-%05d", outframe_format="png")
+ vid_handler = vh.VideoHandler.VideoExtractor(source_vid_path, frame_dest_path, None, frame_control)
+
+ # parallel_video_to_frames(num_servers=4, server_index=0, video_handler=vid_handler, server_synchronizer=sync_obj)
+ vid_handler.extract_frames(start_time=None, end_time=None)
+ print(f"All Frames of {source_vid_path} Extracted to {frame_dest_path}")
+
+
+def shuttil_frames(image_folder, destination_folder, start_frame, end_frame):
+ frame_names_exts = it.image_files_in_directory(image_folder)
+ for name in frame_names_exts:
+ frame_num = lbt.frame_number_from_img_name(name)
+
+ if frame_num < start_frame:
+ continue
+ elif frame_num >= start_frame and frame_num <= end_frame:
+ shutil.copyfile(os.path.join(image_folder, name), os.path.join(destination_folder, name))
+ elif frame_num > end_frame:
+ continue
+
+
+def interactive_video_select(
+ video_path,
+ dest_path,
+ frame_subset_dir,
+ start_frame=None,
+ end_frame=None,
+ reference_pixel=None, # expected as (col, row) to match your existing app storage
+):
+ is_video = os.path.exists(video_path)
+ if not is_video:
+ raise ValueError("Invalid Path to Video File")
+
+ os.makedirs(dest_path, exist_ok=True)
+ os.makedirs(frame_subset_dir, exist_ok=True)
+
+ # Decide whether to skip interaction
+ have_all_inputs = start_frame is not None and end_frame is not None and reference_pixel is not None
+ if have_all_inputs:
+ # Basic validation (tighten as needed)
+ start_frame = int(start_frame)
+ end_frame = int(end_frame)
+ if start_frame < 0 or end_frame < 0:
+ raise ValueError("start_frame/end_frame must be >= 0")
+ if end_frame < start_frame:
+ raise ValueError("end_frame must be >= start_frame")
+ if not isinstance(reference_pixel, (tuple, list)) or len(reference_pixel) != 2:
+ raise ValueError("reference_pixel must be a 2-tuple like (col, row)")
+
+ chosen_start = start_frame
+ chosen_end = end_frame
+ chosen_ref_pixel = f"({reference_pixel[0]}, {reference_pixel[1]})" # (row, col) from input
+ chosen_video_path = video_path
+ else:
+
+ root = tk.Tk()
+ vid_scrub_app = VideoScrubber(root, video_path=video_path)
+ root.title("LookFast Video Scrubber")
+ root.mainloop()
+
+ chosen_start = int(vid_scrub_app.start_frame)
+ chosen_end = int(vid_scrub_app.end_frame)
+ chosen_ref_pixel = f"({vid_scrub_app.reference_pixel[1]}, {vid_scrub_app.reference_pixel[0]})" # (row, col)
+ chosen_video_path = vid_scrub_app.video_path
+
+ extract_all_frames(source_vid_path=video_path, frame_dest_path=dest_path)
+ shuttil_frames(
+ image_folder=dest_path, destination_folder=frame_subset_dir, start_frame=chosen_start, end_frame=chosen_end
+ )
+ return {
+ "start_frame": chosen_start,
+ "end_frame": chosen_end,
+ "video_path": chosen_video_path,
+ "reference_pixel": chosen_ref_pixel,
+ }
+
+
+if __name__ == "__main__":
+ # Create the main window
+ # pass
+
+ root = tk.Tk()
+ app = VideoScrubber(root)
+ root.title("LookFast Pre Processing")
+ root.mainloop()
+ print("Completed Stand Alone Script")
diff --git a/contrib/app/LookFast/lookback_tools.py b/contrib/app/LookFast/lookback_tools.py
new file mode 100644
index 00000000..59bb7905
--- /dev/null
+++ b/contrib/app/LookFast/lookback_tools.py
@@ -0,0 +1,871 @@
+import re
+import os
+import json
+import gzip
+import time
+import subprocess
+from datetime import datetime, timezone
+from logging import DEBUG, ERROR
+
+import h5py
+import skyfield.api as skf
+import numpy as np
+from scipy.spatial.transform import Rotation
+
+from opencsp.common.lib.tool.log_tools import multiprocessing_logger
+
+
+def logging_setup(log_folder: str, log_file_name: str = "error_log.txt", log_type=DEBUG):
+ """
+ Sets up a logger for error logging with robust handling of file names and extensions.
+
+ Parameters:
+ log_folder (str): The folder where the log file should be saved.
+ log_file_name (str): The name of the log file. If it does not have a '.txt' extension, it will be added.
+ log_type: The logging level (e.g., logging.DEBUG, logging.ERROR).
+
+ Returns:
+ Logger: Configured logger instance.
+ """
+ # Default folder path if none is provided
+ if not log_folder:
+ # log_folder = "//snl/Collaborative/NSTTF_Optics/Projects/_Directories/NSTTF_Optics_LookbackExEx/Experiments/2025-06_05_NsttfTunedFacetScan1dof/3_Post/DSC_0025/0_checkpoints/error_logs"
+ log_folder = os.getcwd()
+
+ # Ensure the folder exists
+ if not os.path.exists(log_folder):
+ os.makedirs(log_folder)
+
+ # Ensure the file name has a '.txt' extension
+ base_name, extension = os.path.splitext(log_file_name)
+ if extension.lower() != ".txt":
+ log_file_name = f"{base_name}.txt"
+
+ # Construct the full path to the log file
+ log_file_path = os.path.join(log_folder, log_file_name)
+
+ # Create the logger using the multiprocessing_logger function
+ try:
+ logger = multiprocessing_logger(log_dir_body_ext=log_file_path, level=log_type)
+ except Exception as e:
+ raise RuntimeError(f"Failed to create logger: {e}")
+
+ return logger
+
+
+def frame_number_from_img_name(image_name_str):
+ """
+ Extract the frame number from an image filename/path.
+
+ Expected examples:
+ DSC_2832-09170.png -> 9170
+ 2025_06_05_LookFast_Cam2_Data-00001.png -> 1
+
+ The frame number is assumed to be the digits after the final hyphen
+ before the file extension, but the regular expression search will work without a file extension as well.
+ """
+ _, tail = os.path.split(image_name_str)
+
+ match = re.search(r'-(\d+)(?:\.[^.]+)?$', tail)
+
+ if not match:
+ raise ValueError(f"Could not extract frame number from image name: {tail}")
+
+ return int(match.group(1))
+
+
+def maximum_frame_range(frame_ranges):
+ """
+ Calculates the maximum range (duration) between sequential frames
+ where the transition type alternates between bright (1) and dark (0).
+
+ Parameters:
+ frame_ranges (list of tuples): Each tuple contains (transition_type, frame_number).
+ transition_type is 1 for bright and 0 for dark.
+ frame_number is an integer representing the frame number.
+
+ Returns:
+ tuple: A tuple containing:
+ - max_range_frames (list): The two tuples representing the start and end of the maximum range.
+ - max_duration (int): The maximum duration between sequential frames.
+ """
+ # Ensure the input list is sorted by frame_number
+ frame_ranges = sorted(frame_ranges, key=lambda x: x[1])
+
+ # Initialize variables to track the maximum duration and corresponding frame range
+ max_duration = 0
+ max_range_frames = None
+
+ # Iterate through the sorted list to calculate differences between sequential frames
+ for i in range(len(frame_ranges) - 1):
+ current_frame = frame_ranges[i]
+ next_frame = frame_ranges[i + 1]
+
+ # Check if the transition types alternate (bright -> dark or dark -> bright)
+ if current_frame[0] != next_frame[0]:
+ # Calculate the duration between the current and next frame
+ duration = abs(next_frame[1] - current_frame[1])
+
+ # Update the maximum duration and corresponding frame range if needed
+ if duration > max_duration:
+ max_duration = duration
+ max_range_frames = [current_frame, next_frame]
+
+ return max_range_frames, max_duration
+
+
+def lat_long_to_decimal(input):
+ decimal = input[0] + input[1] / 60 + input[2] / 3600
+ return decimal
+
+
+def define_observation_time_skyfield(source_time, time_offset):
+ source_time = source_time + time_offset
+ observation_time = source_time.astimezone(timezone.utc)
+ # Define observation time
+ ts = skf.load.timescale()
+ if isinstance(observation_time, skf.Time):
+ pass
+ elif isinstance(observation_time, datetime):
+ # Extract components from the datetime object
+ observation_time = ts.utc(
+ observation_time.year,
+ observation_time.month,
+ observation_time.day,
+ observation_time.hour,
+ observation_time.minute,
+ observation_time.second,
+ )
+ else:
+ # If observation_time is already a tuple, unpack it directly
+ observation_time = ts.utc(*observation_time)
+ return observation_time
+
+
+def save_checkpoint(checkpoint_folder, checkpoint_file_name, checkpoint_data, print_path=True):
+ """
+ Saves the checkpoint data to a JSON file.
+
+ Parameters:
+ checkpoint_file_name (str): Path to the checkpoint file.
+ checkpoint_data (dict): Dictionary containing checkpoint information.
+
+ Returns:
+ None
+ """
+ raw_path = os.path.join(checkpoint_folder, checkpoint_file_name)
+ norm_path = os.path.normpath(raw_path)
+
+ success = False
+ for i in range(10):
+ try:
+ with open(norm_path, 'w', encoding='utf-8') as f:
+ json.dump(checkpoint_data, f, indent=4)
+ success = True
+ break
+ except Exception:
+ logger.error("Unable to save checkpoint", exc_info=True)
+ time.sleep(0.01)
+
+ if success:
+ if print_path and i == 0:
+ logger.info("Checkpoint saved to %s", raw_path)
+ if i > 0:
+ logger.info("Checkpoint saved to %s after %d attempts", raw_path, i)
+
+
+def load_checkpoint(checkpoint_folder, checkpoint_file_name):
+ """
+ Loads the checkpoint data from a JSON file.
+
+ Parameters:
+ checkpoint_folder (str): Path to the folder containing the checkpoint file.
+ checkpoint_file_name (str): Name of the checkpoint file.
+
+ Returns:
+ dict: Dictionary containing checkpoint information, or None if the file is empty or does not exist.
+ """
+ norm_path = os.path.normpath(os.path.join(checkpoint_folder, checkpoint_file_name))
+
+ if os.path.exists(norm_path):
+ # Check if the file is empty
+ if os.path.getsize(norm_path) == 0:
+ print(f"Checkpoint file {norm_path} is empty.")
+ return None
+
+ # Attempt to load the JSON data
+ with open(norm_path, 'r', encoding='utf-8') as f:
+ try:
+ checkpoint_data = json.load(f)
+ # Check if the loaded JSON is empty (e.g., {} or [])
+ if not checkpoint_data:
+ print(f"Checkpoint file {norm_path} contains empty JSON data.")
+ return None
+ print(f"Checkpoint loaded from {norm_path}")
+ return checkpoint_data
+ except json.JSONDecodeError:
+ logger.error("Invalid Json File", exc_info=True)
+ print(f"Checkpoint file {norm_path} contains invalid JSON.")
+ return None
+ else:
+ print(f"Checkpoint file {norm_path} does not exist.")
+ return None
+
+
+def custom_serializer(obj):
+ """
+ Custom serializer to handle numpy arrays, datetime objects, floats, integers, and strings.
+ Converts unsupported types into JSON-compatible formats.
+ """
+ try:
+ # Handle numpy arrays
+ if isinstance(obj, np.ndarray):
+ return obj.tolist() # Convert numpy array to list
+
+ # Handle datetime objects
+ elif isinstance(obj, datetime):
+ return obj.isoformat() # Convert datetime to ISO 8601 string
+
+ # Handle floats
+ elif isinstance(obj, float):
+ return float(obj) # Ensure it's a float (JSON-compatible)
+
+ # Handle integers
+ elif isinstance(obj, int):
+ return int(obj) # Ensure it's an integer (JSON-compatible)
+
+ # Handle strings
+ elif isinstance(obj, str):
+ return str(obj) # Ensure it's a string (JSON-compatible)
+
+ # Handle sets (convert to list for JSON compatibility)
+ elif isinstance(obj, set):
+ return list(obj) # Convert set to list
+
+ # Handle Rotation objects
+ elif isinstance(obj, Rotation):
+ return {"__rotation__": True, "quat": obj.as_quat().tolist()} # Serialize as quaternion
+
+ # Unsupported type
+ else:
+ # logger.error("Unsupported type encountered: %r", type(obj), exc_info=True)
+ raise TypeError(f"Type {type(obj)} not serializable")
+
+ except Exception:
+ # logger.error("Error during serialization", exc_info=True)
+ raise
+
+
+def custom_deserializer(obj):
+ """
+ Custom deserializer to handle strings, datetime strings, numpy arrays, floats, integers, and sets.
+ Converts JSON-compatible formats back into their original types.
+ """
+ rotation = False
+ for key, value in obj.items():
+ if rotation:
+ if key == 'quat' and isinstance(value, list):
+ rot_temp = Rotation.from_quat(value) # Deserialize from quaternion
+ return rot_temp
+
+ # Handle Rotation objects
+ if isinstance(obj, dict) and "__rotation__" in key and value is True:
+ rotation = True
+
+ # Handle numpy arrays (lists of numbers)
+ elif isinstance(value, list) and all(isinstance(i, (int, float)) for i in value):
+ try:
+ obj[key] = np.array(value) # Convert list back to numpy array
+ except ValueError:
+ # If conversion fails, leave as list
+ pass
+
+ # Handle sets (convert lists back to sets)
+ elif isinstance(value, list) and all(isinstance(i, (int, float, str)) for i in value):
+ obj[key] = set(value) # Convert list back to set
+
+ # Handle datetime strings
+ elif isinstance(value, str):
+ try:
+ obj[key] = datetime.fromisoformat(value) # Convert ISO 8601 string back to datetime
+ except ValueError:
+ # If conversion fails, leave as string
+ obj[key] = str(value)
+
+ # Handle floats explicitly
+ elif isinstance(value, float):
+ obj[key] = float(value) # Ensure it's a float (redundant but explicit)
+
+ # Handle integers explicitly
+ elif isinstance(value, int) and value is not bool:
+ obj[key] = int(value) # Ensure it's an integer (redundant but explicit)
+
+ return obj
+
+
+def read_json(file_path):
+ """
+ Reads a regular JSON file and returns the data.
+
+ Parameters:
+ file_path (str): Path to the .json file.
+
+ Returns:
+ dict or list: Data from the file.
+ """
+ norm_path = os.path.normpath(file_path)
+ try:
+ with open(norm_path, 'r', encoding='utf-8') as f:
+ data = json.load(f, object_hook=custom_deserializer)
+ return data
+ except Exception:
+ logger.error("read_json Error", exc_info=True)
+ return None
+
+
+def write_json(data, file_path):
+ """
+ Writes data to a regular JSON file.
+
+ Parameters:
+ data (dict or list): Data to write to the file.
+ file_path (str): Path to the .json file.
+
+ Returns:
+ None
+ """
+ norm_path = os.path.normpath(file_path)
+ try:
+ with open(norm_path, 'w', encoding='utf-8') as f:
+ json.dump(data, f, indent=4, default=custom_serializer)
+ print(f"Data written to {norm_path}")
+ except Exception:
+ logger.error("write_json Error", exc_info=True)
+
+
+def read_compressed_json(file_path):
+ """
+ Reads a compressed JSON file (.json.gz) and returns the data.
+
+ Parameters:
+ file_path (str): Path to the .json.gz file.
+
+ Returns:
+ list: List of dictionaries containing the data from the file.
+ """
+ norm_path = os.path.normpath(file_path)
+ try:
+ with gzip.open(norm_path, 'rt', encoding='utf-8') as f:
+ data = json.load(f, object_hook=custom_deserializer)
+ return data
+ except Exception:
+ logger.error("read_compressed_json Error", exc_info=True)
+ return None
+
+
+def write_compressed_json(data, file_path, print_path=True):
+ """
+ Writes data to a compressed JSON file (.json.gz).
+
+ Parameters:
+ data (dict or list): Data to write to the file.
+ file_path (str): Path to the .json.gz file.
+
+ Returns:
+ None
+ """
+ norm_path = os.path.normpath(file_path)
+ success = False
+ for i in range(10):
+ try:
+ with gzip.open(norm_path, 'wt', encoding='utf-8') as f:
+ try:
+ json.dump(data, f, indent=4, default=custom_serializer)
+ success = True
+ break
+ except Exception:
+ logger.error("write_compressed_json Error", exc_info=True)
+ time.sleep(0.01)
+ except Exception:
+ logger.error("write_compressed_json Error", exc_info=True)
+
+ if success:
+ if print_path and i == 0:
+ logger.info("File Saved to %s", norm_path)
+ if i > 0:
+ logger.info("File saved to %s after %d attempts", norm_path, i)
+
+
+def save_batch_to_npz(batch_data, output_path):
+ """
+ Saves batch data to a .npz file.
+
+ Parameters:
+ batch_data (list of dict): List of dictionaries containing image names and binary arrays.
+ output_path (str): Path to save the .npz file.
+
+ Returns:
+ None
+ """
+ try:
+ # Prepare data for saving
+ data_dict = {item["image_name"]: np.array(item["binary_array"], dtype=np.uint8) for item in batch_data}
+
+ # Save data to .npz format
+ np.savez_compressed(output_path, allow_pickle=True, **data_dict)
+ logger.info("Batch data saved successfully to: %s", output_path)
+ print()
+ except Exception as e:
+ logger.error("An error occurred while saving batch data: ", exc_info=True)
+
+
+def read_batch_from_npz(input_path):
+ """
+ Reads batch data from a .npz file.
+
+ Parameters:
+ input_path (str): Path to the .npz file.
+
+ Returns:
+ list of dict: List of dictionaries containing image names and binary arrays.
+ """
+ try:
+ # Load data from .npz file
+ data_dict = np.load(input_path)
+
+ # Convert data back to list of dictionaries
+ batch_data = [{"image_name": key, "binary_array": data_dict[key].tolist()} for key in data_dict.files]
+ logger.info("Batch data loaded successfully from: %s", input_path)
+ return batch_data
+ except Exception as e:
+ logger.error("An error occurred while reading batch data: %s", e, exc_info=True)
+ return None
+
+
+def extract_detailed_video_metadata(video_path):
+ """
+ Extracts detailed metadata from a video file using ExifTool, including general video metadata,
+ camera metadata, and date/time metadata.
+
+ Parameters:
+ video_path (str): Path to the video file.
+
+ Returns:
+ dict: A dictionary containing metadata fields. Missing fields will have a value of None.
+ """
+ try:
+ # Run ExifTool to extract metadata
+ result = subprocess.run(
+ [
+ "exiftool",
+ "-FileName",
+ "-FileSize",
+ "-FileFormat",
+ "-Duration",
+ "-VideoFrameRate",
+ "-ImageWidth",
+ "-ImageHeight",
+ "-AspectRatio",
+ "-VideoBitrate",
+ "-Compression",
+ "-Make",
+ "-Model",
+ "-SerialNumber",
+ "-LensMake",
+ "-LensModel",
+ "-FocalLength",
+ "-Aperture",
+ "-ISO",
+ "-ShutterSpeed",
+ "-WhiteBalance",
+ "-ExposureMode",
+ "-MeteringMode",
+ "-FocusMode",
+ "-ImageStabilization",
+ "-CreateDate",
+ "-ModifyDate",
+ "-MediaCreateDate",
+ video_path,
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+
+ # Initialize metadata dictionary with all fields set to None
+ metadata = {
+ # General Video Metadata
+ 'file_name': None,
+ 'file_size': None,
+ 'file_format': None,
+ 'duration': None,
+ 'frame_rate': None,
+ 'resolution': None,
+ 'aspect_ratio': None,
+ 'bitrate': None,
+ 'compression': None,
+ # Camera Metadata
+ 'camera_make': None,
+ 'camera_model': None,
+ 'camera_serial_number': None,
+ 'lens_make': None,
+ 'lens_model': None,
+ 'focal_length': None,
+ 'aperture': None,
+ 'iso': None,
+ 'shutter_speed': None,
+ 'white_balance': None,
+ 'exposure_mode': None,
+ 'metering_mode': None,
+ 'focus_mode': None,
+ 'image_stabilization': None,
+ # Date and Time Metadata
+ 'create_date': None,
+ 'modify_date': None,
+ 'media_create_date': None,
+ }
+
+ # Parse the output to populate the metadata dictionary
+ for line in result.stdout.splitlines():
+ if "File Name" in line:
+ metadata['file_name'] = line.split(": ", 1)[1].strip()
+ elif "File Size" in line:
+ metadata['file_size'] = line.split(": ", 1)[1].strip()
+ elif "File Format" in line:
+ metadata['file_format'] = line.split(": ", 1)[1].strip()
+ elif "Duration" in line:
+ duration_str = line.split(": ", 1)[1].strip()
+ parts = duration_str.split(":")
+ if len(parts) == 3: # Format is hours:minutes:seconds
+ hours, minutes, seconds = map(float, parts)
+ metadata['duration'] = hours * 3600 + minutes * 60 + seconds
+ elif len(parts) == 2: # Format is minutes:seconds
+ minutes, seconds = map(float, parts)
+ metadata['duration'] = minutes * 60 + seconds
+ elif "Video Frame Rate" in line:
+ metadata['frame_rate'] = float(line.split(": ", 1)[1].strip().split(" ")[0])
+ elif "Image Width" in line:
+ width = int(line.split(": ", 1)[1].strip())
+ metadata['resolution'] = f"{width}x" # Start resolution string
+ elif "Image Height" in line:
+ height = int(line.split(": ", 1)[1].strip())
+ if metadata['resolution']:
+ metadata['resolution'] += f"{height}" # Complete resolution string
+ elif "Aspect Ratio" in line:
+ metadata['aspect_ratio'] = line.split(": ", 1)[1].strip()
+ elif "Video Bitrate" in line:
+ metadata['bitrate'] = line.split(": ", 1)[1].strip()
+ elif "Compression" in line:
+ metadata['compression'] = line.split(": ", 1)[1].strip()
+ elif "Make" in line:
+ metadata['camera_make'] = line.split(": ", 1)[1].strip()
+ elif "Model" in line:
+ metadata['camera_model'] = line.split(": ", 1)[1].strip()
+ elif "Serial Number" in line:
+ metadata['camera_serial_number'] = line.split(": ", 1)[1].strip()
+ elif "Lens Make" in line:
+ metadata['lens_make'] = line.split(": ", 1)[1].strip()
+ elif "Lens Model" in line:
+ metadata['lens_model'] = line.split(": ", 1)[1].strip()
+ elif "Focal Length" in line:
+ metadata['focal_length'] = line.split(": ", 1)[1].strip()
+ elif "Aperture" in line:
+ metadata['aperture'] = line.split(": ", 1)[1].strip()
+ elif "ISO" in line:
+ metadata['iso'] = int(line.split(": ", 1)[1].strip())
+ elif "Shutter Speed" in line:
+ metadata['shutter_speed'] = line.split(": ", 1)[1].strip()
+ elif "White Balance" in line:
+ metadata['white_balance'] = line.split(": ", 1)[1].strip()
+ elif "Exposure Mode" in line:
+ metadata['exposure_mode'] = line.split(": ", 1)[1].strip()
+ elif "Metering Mode" in line:
+ metadata['metering_mode'] = line.split(": ", 1)[1].strip()
+ elif "Focus Mode" in line:
+ metadata['focus_mode'] = line.split(": ", 1)[1].strip()
+ elif "Image Stabilization" in line:
+ metadata['image_stabilization'] = line.split(": ", 1)[1].strip()
+ elif "Create Date" in line:
+ metadata['create_date'] = line.split(": ", 1)[1].strip()
+ elif "Modify Date" in line:
+ metadata['modify_date'] = line.split(": ", 1)[1].strip()
+ elif "Media Create Date" in line:
+ metadata['media_create_date'] = line.split(": ", 1)[1].strip()
+
+ return metadata
+ except Exception as e:
+ print(f"Error extracting metadata with ExifTool: {e}")
+ return None
+
+
+def accelerate_video_ffmpeg_no_audio(input_path, output_path, playback_speed):
+ """
+ Accelerates the playback speed of a video using ffmpeg, removes audio, and saves the output.
+
+ Parameters:
+ input_path (str): Path to the input video file.
+ output_path (str): Path to save the output video file.
+ playback_speed (float): Factor by which to accelerate the video (e.g., 3.0 for triple speed).
+ """
+ try:
+ if playback_speed <= 0:
+ raise ValueError("Playback speed must be a positive number.")
+
+ # Calculate the video filter for speed adjustment
+ video_filter = f"setpts={1/playback_speed}*PTS"
+
+ # Build the ffmpeg command to remove audio and adjust video speed
+ command = [
+ "ffmpeg",
+ "-i",
+ input_path, # Input file
+ "-filter:v",
+ video_filter, # Video speed adjustment
+ "-an", # Remove audio
+ "-preset",
+ "fast", # Encoding preset for faster processing
+ output_path, # Output file
+ ]
+
+ # Run the ffmpeg command
+ subprocess.run(command, check=True)
+ logger.info("Video saved successfully at %s", output_path)
+ except subprocess.CalledProcessError as e:
+ logger.error("An error occurred while processing the video: %s", e, exc_info=True)
+ except Exception as e:
+ logger.error("An error occurred: %s", e, exc_info=True)
+
+
+def write_to_hdf5(data, filename):
+ """
+ Write a dictionary, list of dictionaries, or set of dictionaries to an HDF5 file.
+
+ Parameters:
+ data (dict, list, set): The data to write to the HDF5 file.
+ filename (str): The name of the HDF5 file to write to.
+ """
+
+ def write_data(group, key, value):
+ if isinstance(value, dict):
+ subgroup = group.create_group(key)
+ for subkey, subvalue in value.items():
+ write_data(subgroup, subkey, subvalue)
+ elif isinstance(value, list):
+ subgroup = group.create_group(key)
+ for i, item in enumerate(value):
+ write_data(subgroup, str(i), item)
+ elif isinstance(value, set):
+ subgroup = group.create_group(key)
+ for i, item in enumerate(sorted(value)): # Convert set to sorted list for consistent storage
+ write_data(subgroup, str(i), item)
+ elif isinstance(value, np.ndarray):
+ group.create_dataset(key, data=value)
+ elif isinstance(value, datetime):
+ group.create_dataset(key, data=value.isoformat())
+ elif isinstance(value, (float, int, str)):
+ group.create_dataset(key, data=value)
+ else:
+ raise TypeError(f"Unsupported data type: {type(value)}")
+
+ with h5py.File(filename, 'w') as hdf5_file:
+ write_data(hdf5_file, 'root', data)
+
+
+def read_from_hdf5(filename):
+ """
+ Read data from an HDF5 file and reconstruct it as a dictionary, list of dictionaries, or set of dictionaries.
+
+ Parameters:
+ filename (str): The name of the HDF5 file to read from.
+
+ Returns:
+ dict, list, set: The reconstructed data.
+ """
+
+ def read_data(group):
+ if isinstance(group, h5py.Dataset):
+ data = group[()]
+ if isinstance(data, bytes): # Handle string decoding
+ data = data.decode('utf-8')
+ try:
+ # Attempt to parse datetime strings
+ return datetime.fromisoformat(data)
+ except (ValueError, TypeError):
+ return data
+ elif isinstance(group, h5py.Group):
+ if all(key.isdigit() for key in group.keys()): # Check if keys are numeric (list-like structure)
+ items = [read_data(group[key]) for key in sorted(group.keys(), key=int)]
+ return set(items) if isinstance(items[0], set) else items
+ else:
+ return {key: read_data(group[key]) for key in group.keys()}
+ else:
+ raise TypeError(f"Unsupported HDF5 group type: {type(group)}")
+
+ with h5py.File(filename, 'r') as hdf5_file:
+ return read_data(hdf5_file['root'])
+
+
+def save_hdf5_datasets_compressed(
+ data: list | dict, datasets: list, file: str, compression: str = "gzip", compression_level: int = 4
+):
+ """
+ Saves data to HDF5 file in a compressed format.
+
+ Parameters:
+ data (list or dict): List of data arrays to save or dictionary that can be a single level nested dictionary.
+ datasets (list): List of dataset names corresponding to the data.
+ file (str): Path to the HDF5 file.
+ compression (str): Compression algorithm to use (default: "gzip").
+ compression_level (int): Compression level (default: 4).
+ """
+
+ def sanitize_name(name):
+ return str(name).replace("(", "").replace(")", "").replace(",", "_").replace(" ", "_")
+
+ def save_dict_to_group(group, data):
+ for key, value in data.items():
+ sanitized_key = sanitize_name(key)
+ if isinstance(value, dict):
+ subgroup = group.create_group(sanitized_key)
+ save_dict_to_group(subgroup, value)
+ elif isinstance(value, (int, float, str)):
+ group.create_dataset(sanitized_key, data=value)
+ else:
+ group.create_dataset(
+ sanitized_key, data=value, compression=compression, compression_opts=compression_level, chunks=True
+ )
+
+ try:
+ if isinstance(data, dict):
+ with h5py.File(file, "w") as hf:
+ if isinstance(data, dict):
+ for pixel_coord, pixel_data in data.items():
+ if not pixel_data:
+ continue
+ group_name = sanitize_name(pixel_coord)
+ group = hf.create_group(group_name)
+ if isinstance(pixel_data, list) and all(isinstance(item, dict) for item in pixel_data):
+ for idx, dict_data in enumerate(pixel_data):
+ subgroup_name = f"entry_{idx}"
+ subgroup = group.create_group(subgroup_name)
+ save_dict_to_group(subgroup, dict_data)
+ elif isinstance(pixel_data, dict):
+ save_dict_to_group(group, pixel_data)
+ elif isinstance(data, list) and all(isinstance(item, dict) for item in data):
+ for idx, dict_data in enumerate(data):
+ group_name = f"group_{idx}"
+ group = hf.create_group(group_name)
+ save_dict_to_group(group, dict_data)
+ elif isinstance(data, list):
+ for d, dataset in zip(data, datasets):
+ if dataset in hf:
+ del hf[dataset]
+ hf.create_dataset(
+ dataset, data=d, compression=compression, compression_opts=compression_level, chunks=True
+ )
+
+ elif isinstance(data, list) and all(isinstance(item, dict) for item in data):
+ # Handle list of dictionaries
+ with h5py.File(file, "w") as hf:
+ for idx, dict_data in enumerate(data):
+ group_name = f"group_{idx}" # Create a unique group name for each dictionary
+ group = hf.create_group(group_name)
+ for dataset_name, dataset_value in dict_data.items():
+ # Check if dataset_value is scalar or array-like
+ if isinstance(dataset_value, (int, float, str)):
+ # Handle scalar data
+ group.create_dataset(dataset_name, data=dataset_value)
+ else:
+ # Handle array-like data
+ group.create_dataset(
+ dataset_name,
+ data=dataset_value,
+ compression=compression,
+ compression_opts=compression_level,
+ )
+
+ elif isinstance(data, list):
+ with h5py.File(file, "a") as hf:
+ # Loop through datasets
+ for d, dataset in zip(data, datasets):
+ if dataset in hf:
+ # Delete dataset if it already exists
+ del hf[dataset]
+ # Write dataset with compression
+ hf.create_dataset(
+ dataset, data=d, compression=compression, compression_opts=compression_level, chunks=True
+ )
+
+ hf.close()
+
+ except Exception as e:
+ logger.error("Error writing to hdf5 file %s", e, exc_info=True)
+
+
+def read_hdf5_datasets(file: str, datasets: list = None):
+ """
+ Reads data from a compressed HDF5 file, including nested groups.
+
+ Parameters:
+ file (str): Path to the HDF5 file.
+ datasets (list): List of dataset names to read. If empty or None, reads all datasets.
+
+ Returns:
+ dict: A dictionary where keys are dataset names or group names, and values are the corresponding data arrays.
+ """
+
+ def read_group(group):
+ """Recursively reads datasets and groups."""
+ group_data = {}
+ for key in group.keys():
+ item = group[key]
+ if isinstance(item, h5py.Group):
+ # Recursively read nested groups
+ group_data[key] = read_group(item)
+ else:
+ # Check if dataset_value is scalar or array-like
+ if isinstance(item[()], (int, float, str)):
+ group_data[key] = item[()]
+ elif isinstance(item[()], bytes):
+ group_data[key] = item[()].decode('utf-8')
+ else:
+ # Read dataset
+ group_data[key] = item[:]
+ return group_data
+
+ data_dict = {}
+ with h5py.File(file, "r") as f:
+ if datasets:
+ # Read specific datasets
+ for dataset in datasets:
+ if dataset in f:
+ data_dict[dataset] = f[dataset][:]
+ else:
+ raise KeyError(f"Dataset '{dataset}' not found in file '{file}'.")
+ else:
+ # Read all datasets and groups
+ data_dict = read_group(f)
+
+ return data_dict
+
+
+logger = logging_setup(
+ log_folder=os.path.join(os.getcwd(), "error_logs"), log_file_name="error_log_lookback_tools.txt", log_type=ERROR
+)
+
+
+'''
+def main():
+ pass
+
+
+if __name__ == "__main__":
+ checkpoint_folder = os.getcwd()
+ logger = logging_setup(
+ log_folder=os.path.join(checkpoint_folder, "error_logs"),
+ log_file_name="error_log_lookback_tools.txt",
+ log_type=ERROR,
+ )
+ main()
+
+'''
diff --git a/contrib/app/LookFast/lookfast_camera_adjust_V2_module.py b/contrib/app/LookFast/lookfast_camera_adjust_V2_module.py
new file mode 100644
index 00000000..07a8dd06
--- /dev/null
+++ b/contrib/app/LookFast/lookfast_camera_adjust_V2_module.py
@@ -0,0 +1,3167 @@
+import os
+import ast
+from logging import DEBUG, ERROR
+
+import numpy as np
+import cv2
+import matplotlib.pyplot as plt
+from mpl_toolkits.mplot3d import Axes3D
+from scipy.spatial.transform import Rotation
+from scipy.interpolate import griddata
+
+
+from opencsp.common.lib.camera.Camera import Camera
+from opencsp.common.lib.csp.StandardPlotOutput import StandardPlotOutput
+from opencsp.common.lib.csp.LightSourceSun import LightSourceSun
+import opencsp.common.lib.tool.file_tools as ft
+from opencsp.common.lib.geometry.Vxy import Vxy
+from opencsp.common.lib.geometry.Vxyz import Vxyz
+from opencsp.common.lib.geometry.Uxyz import Uxyz
+from opencsp.common.lib.geometry.Pxyz import Pxyz
+from opencsp.common.lib.geometry.LoopXY import LoopXY
+from opencsp.common.lib.geometry.RegionXY import RegionXY
+from opencsp.common.lib.csp.MirrorPoint import MirrorPoint
+from opencsp.common.lib.csp.MirrorParametric import MirrorParametric
+import opencsp.app.sofast.lib.image_processing as imgp
+import opencsp.app.sofast.lib.spatial_processing as sp
+
+# import opencsp.app.lookback.lookback_tools as lbt
+import contrib.app.LookFast.lookback_tools as lbt
+
+# Specify the folder where the log file should be saved
+logger = lbt.logging_setup(
+ log_folder=os.path.join(os.getcwd(), "error_logs"),
+ log_file_name="error_log_lookfast_camera_adjust.txt",
+ log_type=DEBUG,
+)
+
+
+def rotation_matrix_scipy(vec1, vec2):
+ """
+ Find the rotation matrix that aligns vec1 to vec2 using SciPy.
+ """
+ # SciPy works with 1D arrays for single vectors
+ a = vec1.reshape(-1)
+ b = vec2.reshape(-1)
+
+ # The align_vectors method returns a Rotation object and a rmsd value
+ rotation_object, rssd = Rotation.align_vectors(b, a) # Note: order may need adjustment based on specific use case
+
+ # Convert the Rotation object to a 3x3 matrix
+ # rotation_matrix = rotation.as_matrix()
+
+ return rotation_object, rssd
+
+
+def rotate_vector(vector, axis, degrees):
+ """
+ Rotates a vector about a specified axis by a given number of degrees.
+
+ Parameters:
+ vector (numpy.ndarray): The vector to rotate (1D array of shape (3,)).
+ axis (numpy.ndarray): The axis to rotate about (1D array of shape (3,)).
+ degrees (float): The angle in degrees to rotate the vector.
+
+ Returns:
+ numpy.ndarray: The rotated vector (1D array of shape (3,)).
+ """
+ try:
+ # Validate inputs
+ if not isinstance(vector, np.ndarray) or vector.shape != (3,):
+ logger.error("Invalid vector: Must be a numpy array of shape (3,).")
+ raise ValueError("Vector must be a numpy array of shape (3,).")
+
+ if not isinstance(axis, np.ndarray) or axis.shape != (3,):
+ logger.error("Invalid axis: Must be a numpy array of shape (3,).")
+ raise ValueError("Axis must be a numpy array of shape (3,).")
+
+ if not isinstance(degrees, (int, float)):
+ logger.error("Invalid degrees: Must be a numeric value.")
+ raise ValueError("Degrees must be a numeric value.")
+
+ # Normalize the axis
+ axis_norm = np.linalg.norm(axis)
+ if axis_norm == 0:
+ logger.error("Invalid axis: Cannot be a zero vector.")
+ raise ValueError("Axis cannot be a zero vector.")
+ axis = axis / axis_norm
+
+ # Convert degrees to radians
+ radians = np.deg2rad(degrees)
+
+ # Compute rotation matrix using Rodrigues' rotation formula
+ cos_theta = np.cos(radians)
+ sin_theta = np.sin(radians)
+ one_minus_cos = 1 - cos_theta
+
+ # Outer product of axis with itself
+ axis_outer = np.outer(axis, axis)
+
+ # Cross-product matrix of axis
+ axis_cross = np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]])
+
+ # Rotation matrix
+ rotation_matrix = cos_theta * np.eye(3) + one_minus_cos * axis_outer + sin_theta * axis_cross
+
+ # Rotate the vector
+ rotated_vector = np.dot(rotation_matrix, vector)
+ return rotated_vector
+
+ except Exception as e:
+ logger.exception("An error occurred while rotating the vector.")
+ raise e
+
+
+def create_rotation_object(axis, angle_degrees):
+ """
+ Create a scipy Rotation object for a rotation about a specified axis.
+
+ Parameters:
+ axis (array-like): A 3D vector specifying the axis of rotation (e.g., [1, 0, 0]).
+ angle_degrees (float): The rotation angle in degrees.
+
+ Returns:
+ scipy.spatial.transform.Rotation: A Rotation object representing the rotation.
+ """
+ # Normalize the axis to ensure it is a unit vector
+ axis = np.array(axis)
+ if np.linalg.norm(axis) == 0:
+ raise ValueError("Rotation axis cannot be the zero vector.")
+ axis_normalized = axis / np.linalg.norm(axis)
+
+ # Create the rotation object using the axis-angle representation
+ rotation = Rotation.from_rotvec(np.radians(angle_degrees) * axis_normalized)
+
+ return rotation
+
+
+'''
+def binary_search_angle(vector, axis, function, tolerance=1e-6, max_iterations=1000):
+ """
+ Performs a binary search to find the angle that minimizes the z-component of the rotated vector.
+
+ Parameters:
+ vector (numpy.ndarray): The vector to rotate (1D array of shape (3,)).
+ axis (numpy.ndarray): The axis to rotate about (1D array of shape (3,)).
+ rotate_vector (function): Function to rotate the vector.
+ tolerance (float): The tolerance for the z-component to be considered zero.
+ max_iterations (int): Maximum number of iterations for the binary search.
+
+ Returns:
+ float: The angle in degrees that minimizes the z-component of the rotated vector.
+ """
+ # Define the search range for the angle (0 to 360 degrees)
+ low = 0.0
+ high = 360.0
+
+ for iteration in range(max_iterations):
+ # Calculate the midpoint angle
+ mid = (low + high) / 2.0
+
+ # Rotate the vector at the midpoint angle
+ rotated_vector = function(vector, axis, mid)
+
+ # Check the z-component of the rotated vector
+ z_component = rotated_vector[2]
+
+ if abs(z_component) < tolerance:
+ # If the z-component is close to zero, return the angle
+ return mid
+
+ # Update the search range based on the sign of the z-component
+ if z_component > 0:
+ high = mid
+ else:
+ low = mid
+
+ # If the search did not converge, return the midpoint of the final range
+ return (low + high) / 2.0
+
+'''
+
+
+def binary_search_angle_select(
+ vector, axis, function, component_index=2, tolerance=1e-6, max_iterations=1000, num_initial_samples=36
+):
+ """
+ Performs a binary search to find the angle that minimizes the user-selected component of the rotated vector.
+
+ Parameters:
+ vector (numpy.ndarray): The vector to rotate (1D array of shape (3,)).
+ axis (numpy.ndarray): The axis to rotate about (1D array of shape (3,)).
+ function (callable): Function to rotate the vector.
+ component_index (int): The index of the component to minimize (0 for x, 1 for y, 2 for z).
+ tolerance (float): The tolerance for the selected component to be considered zero.
+ max_iterations (int): Maximum number of iterations for the binary search.
+ num_initial_samples (int): Number of initial angles to sample for the linear search.
+
+ Returns:
+ float: The angle in degrees that minimizes the specified component of the rotated vector.
+ """
+ # Initial linear search from -180 to 180 degrees
+ angles = np.linspace(-180, 180, num_initial_samples)
+ best_angle = None
+ best_value = float('inf')
+
+ for angle in angles:
+ rotated_vector = function(vector, axis, angle)
+ component_value = rotated_vector[component_index]
+
+ # Update best angle and value
+ if abs(component_value) < abs(best_value):
+ best_value = component_value
+ best_angle = angle
+
+ # Determine the two angles around the best angle for binary search
+ low = best_angle - 1 # Start just below the best angle
+ high = best_angle + 1 # Start just above the best angle
+
+ for iteration in range(max_iterations):
+ # Calculate the midpoint angle
+ mid = (low + high) / 2.0
+
+ # Rotate the vector at the midpoint angle
+ rotated_vector = function(vector, axis, mid)
+
+ # Check the specified component of the rotated vector
+ component_value = rotated_vector[component_index]
+
+ if abs(component_value) < tolerance:
+ # If the component is close to zero, return the angle
+ return mid
+
+ # Update the search range based on the sign of the component value
+ if component_value > 0:
+ high = mid
+ else:
+ low = mid
+
+ # If the search did not converge, return the midpoint of the final range
+ return (low + high) / 2.0
+
+
+def find_zero_component_angle_bisect_strict(
+ vector, axis, function, component_index=2, tolerance=1e-6, max_iterations=5000, num_initial_samples=721
+):
+ angles = np.linspace(-180.0, 180.0, num_initial_samples)
+ vals = np.array([function(vector, axis, a)[component_index] for a in angles])
+
+ # Find sign changes (root brackets)
+ s = np.sign(vals)
+ sign_changes = np.where(s[:-1] * s[1:] < 0)[0]
+ if len(sign_changes) == 0:
+ raise RuntimeError("Selected component never crosses zero; no solution exists.")
+
+ # Pick bracket that contains the smallest |value| sample (good heuristic)
+ k_best = np.argmin(np.abs(vals))
+ idx = sign_changes[np.argmin(np.abs(sign_changes - k_best))]
+
+ low, high = angles[idx], angles[idx + 1]
+ f_low, f_high = vals[idx], vals[idx + 1]
+
+ for _ in range(max_iterations):
+ mid = 0.5 * (low + high)
+ f_mid = function(vector, axis, mid)[component_index]
+
+ if abs(f_mid) < tolerance:
+ return mid
+
+ if np.sign(f_low) * np.sign(f_mid) < 0:
+ high, f_high = mid, f_mid
+ else:
+ low, f_low = mid, f_mid
+
+ # If we got here, we still have a bracket but didn't hit tolerance fast enough
+ mid = 0.5 * (low + high)
+ f_mid = function(vector, axis, mid)[component_index]
+ raise RuntimeError(f"Bisection did not reach tolerance. Final |component|={abs(f_mid)} at angle {mid} deg.")
+
+
+def _rotate_with_scipy(vec, axis, angle_degrees):
+ """Helper rotate function compatible with your binary_search_angle_select signature."""
+ r = create_rotation_object(axis, angle_degrees)
+ return r.apply(np.asarray(vec))
+
+
+def select_rotation_with_direction_check(
+ vector_to_minimize,
+ axis,
+ component_index,
+ reference_vector,
+ reference_component_index=None,
+ desired_reference_sign=+1,
+ *,
+ tolerance=1e-6,
+ max_iterations=10000,
+ num_initial_samples=721,
+ function=_rotate_with_scipy,
+):
+ """
+ Wrapper that resolves the 'two-solution' ambiguity by checking a rotated reference vector's component sign.
+
+ Steps:
+ 1) Find angle that makes vector_to_minimize[component_index] ~ 0 (your binary search).
+ 2) Consider the two equivalent solutions: theta and theta+180 (about same axis).
+ 3) Build Rotation objects for both; rotate reference_vector; pick the one whose chosen component has desired sign.
+
+ Parameters
+ ----------
+ vector_to_minimize : (3,) array-like
+ Vector whose selected component you want minimized to ~0.
+ axis : (3,) array-like
+ Rotation axis.
+ component_index : int
+ Component index (0/1/2) to drive to ~0 for vector_to_minimize.
+ reference_vector : (3,) array-like
+ A second vector used to disambiguate which of the two rotations you want.
+ reference_component_index : int or None
+ Which component of the rotated reference_vector to test. If None, uses component_index.
+ desired_reference_sign : {+1, -1}
+ Desired sign for the selected component of the rotated reference vector.
+ function : callable
+ Rotation function used by your binary_search_angle_select. Defaults to scipy-based helper.
+
+ Returns
+ -------
+ angle_degrees : float
+ Selected angle in degrees.
+ rotation : scipy.spatial.transform.Rotation
+ Rotation object for the selected angle.
+ debug : dict
+ Useful diagnostics (candidate angles and rotated vectors/components).
+ """
+ axis = np.asarray(axis, dtype=float)
+ vmin = np.asarray(vector_to_minimize, dtype=float)
+ vref = np.asarray(reference_vector, dtype=float)
+
+ if reference_component_index is None:
+ reference_component_index = component_index
+ if desired_reference_sign not in (+1, -1):
+ raise ValueError("desired_reference_sign must be +1 or -1.")
+
+ # 1) Base solution from your minimization search
+ theta = find_zero_component_angle_bisect_strict(
+ vmin,
+ axis,
+ function=function,
+ component_index=component_index,
+ tolerance=tolerance,
+ max_iterations=max_iterations,
+ num_initial_samples=num_initial_samples,
+ )
+
+ # 2) Two candidate solutions (commonly separated by 180° for this kind of constraint)
+ candidates = [theta, theta + 180.0]
+
+ # 3) Evaluate candidates using the reference-vector sign rule
+ results = []
+ for ang in candidates:
+ rot = create_rotation_object(axis, ang)
+ vmin_rot = rot.apply(vmin)
+ vref_rot = rot.apply(vref)
+
+ ref_comp = vref_rot[reference_component_index]
+ # Score: prefer correct sign; tie-break by magnitude (more positive if desired +1, more negative if desired -1)
+ sign_ok = (ref_comp >= 0) if desired_reference_sign == +1 else (ref_comp <= 0)
+ score = (1 if sign_ok else 0, desired_reference_sign * ref_comp)
+
+ results.append(
+ dict(
+ angle=ang,
+ rotation=rot,
+ vmin_rot=vmin_rot,
+ vref_rot=vref_rot,
+ ref_component=ref_comp,
+ sign_ok=sign_ok,
+ score=score,
+ )
+ )
+
+ # Pick best by (sign_ok first, then component preference)
+ best = max(results, key=lambda d: d["score"])
+
+ debug = {
+ "component_index_minimized": component_index,
+ "reference_component_index": reference_component_index,
+ "desired_reference_sign": desired_reference_sign,
+ "candidates": [
+ {
+ "angle": r["angle"],
+ "vmin_rot": r["vmin_rot"],
+ "vref_rot": r["vref_rot"],
+ "ref_component": r["ref_component"],
+ "sign_ok": r["sign_ok"],
+ }
+ for r in results
+ ],
+ }
+
+ return best["angle"], best["rotation"], debug
+
+
+def ransac_average_direction(vectors, num_iterations=100, tolerance=0.00025):
+ """
+ Compute a RANSAC-style average direction from a set of 3D vectors.
+
+ Parameters:
+ vectors (list or np.ndarray): Array of shape (N, 3) containing [x, y, z] direction components.
+ num_iterations (int): Number of RANSAC iterations to perform.
+ tolerance (float): Angular tolerance (in radians) for consensus evaluation.
+
+ Returns:
+ np.ndarray: The "average" direction vector with the highest consensus.
+ """
+ # Ensure input is a NumPy array
+ vectors = np.array(vectors)
+
+ # Normalize all vectors to unit length
+ norms = np.linalg.norm(vectors, axis=1, keepdims=True)
+ normalized_vectors = vectors / norms
+
+ best_consensus_count = 0
+ best_average_direction = None
+
+ for _ in range(num_iterations):
+ # Randomly sample a subset of vectors
+ sample_indices = np.random.choice(
+ len(normalized_vectors), size=round(len(normalized_vectors) / 3), replace=False
+ )
+ sample_vectors = normalized_vectors[sample_indices]
+
+ # Compute the average direction of the sample
+ average_direction = np.mean(sample_vectors, axis=0)
+ average_direction /= np.linalg.norm(average_direction) # Normalize to unit length
+
+ # Compute angular distance between average direction and all vectors
+ dot_products = np.dot(normalized_vectors, average_direction)
+ angular_distances = np.arccos(np.clip(dot_products, -1.0, 1.0)) # Clip to avoid numerical issues
+
+ # Count how many vectors are within the tolerance
+ consensus_count = np.sum(angular_distances < tolerance)
+
+ # Update the best result if this iteration has higher consensus
+ if consensus_count > best_consensus_count:
+ best_consensus_count = consensus_count
+ best_average_direction = average_direction
+
+ return best_average_direction
+
+
+def calculate_angle_between_vectors(vector1: np.ndarray, vector2: np.ndarray) -> float:
+ """
+ Calculates the angle (in radians) between two unit vectors in 3D space.
+
+ Parameters:
+ vector1 (np.ndarray): A 3D unit vector [x, y, z].
+ vector2 (np.ndarray): A 3D unit vector [x, y, z].
+
+ Returns:
+ float: The angle between the two vectors in radians.
+ """
+ # Ensure the input vectors are unit vectors
+ if not np.isclose(np.linalg.norm(vector1), 1.0):
+ raise ValueError("vector1 is not a unit vector.")
+ if not np.isclose(np.linalg.norm(vector2), 1.0):
+ raise ValueError("vector2 is not a unit vector.")
+
+ # Compute the dot product of the two vectors
+ dot_product = np.dot(vector1, vector2)
+
+ # Clamp the dot product to the range [-1, 1] to avoid numerical errors
+ dot_product = np.clip(dot_product, -1.0, 1.0)
+
+ # Calculate the angle using arccos
+ angle = np.arccos(dot_product)
+
+ return angle
+
+
+def calculate_slope(observer_vec_start, observer_vec_end, inter_1, inter_2):
+ observer_vec = (observer_vec_start + observer_vec_end) / 2
+ slope_1 = (observer_vec + inter_1) / np.linalg.norm(observer_vec + inter_1)
+ slope_2 = (observer_vec + inter_2) / np.linalg.norm(observer_vec + inter_2)
+ return observer_vec, slope_1, slope_2
+
+
+def calculate_slope_differences(reference_vector, sample_vectors):
+ """
+ Compare a reference vector with multiple sample vectors and return the differences and errors in all 3 dimensions.
+
+ Parameters:
+ reference_vector (list or np.ndarray): A 3D vector [x, y, z] representing the reference.
+ sample_vectors (list or np.ndarray): A list or array of 3D vectors [[x1, y1, z1], [x2, y2, z2], ...].
+
+ Returns:
+ lists: A list containing the differences and errors for each sample vector.
+ "differences": [[dx1, dy1, dz1], [dx2, dy2, dz2], ...],
+ "errors": [error1, error2, ...]
+ """
+ # Ensure inputs are numpy arrays for easier manipulation
+ reference_vector = np.array(reference_vector)
+ sample_vectors = np.array(sample_vectors)
+
+ # Validate dimensions
+ if reference_vector.shape != (3,):
+ raise ValueError("Reference vector must be a 3D vector [x, y, z].")
+ if sample_vectors.ndim != 2 or sample_vectors.shape[1] != 3:
+ raise ValueError("Sample vectors must be a list or array of 3D vectors [[x, y, z], ...].")
+
+ # Calculate differences
+ differences = sample_vectors - reference_vector
+
+ # Calculate errors (Euclidean distance)
+ errors_mag = np.linalg.norm(differences, axis=1)
+
+ return differences.tolist(), errors_mag.tolist()
+
+
+def safe_calculate_slope(observer_vector_start, observer_vector_end, intersection_1, intersection_2):
+ """
+ Wrapper for calculate_slope with error handling and logging.
+ """
+ try:
+ # Call the calculate_slope function
+ observer_vec, slope_1, slope_2 = calculate_slope(
+ observer_vector_start, observer_vector_end, intersection_1, intersection_2
+ )
+ return observer_vec, slope_1, slope_2
+ except KeyError as e:
+ # Handle missing keys in dictionaries
+ logger.debug("KeyError in calculate_slope: %s", e, exc_info=True)
+ return None, None, None
+ except TypeError as e:
+ # Handle type-related issues (e.g., NoneType or invalid types)
+ logger.debug("TypeError in calculate_slope: %s", e, exc_info=True)
+ return None, None, None
+ except Exception as e:
+ # Catch any other unexpected errors
+ logger.debug("Unexpected error in calculate_slope: %s", e, exc_info=True)
+ return None, None, None
+
+
+def get_pixel_pointing_vector(pixel_directions, row, col, imagewidth):
+ # pixel_pointing[row * light_image.shape[1] + col
+ temp = pixel_directions[int(row * imagewidth + col)]
+ return temp.data
+
+
+def plot_slope_heat_maps(data_dict):
+ """
+ Iterates through a dictionary with pixel coordinates as keys, extracts up to two sets of data,
+ and plots contour plots for each set separately.
+
+ Parameters:
+ pixel_dict (dict): A dictionary where:
+ - Keys are pixel coordinates (tuples) like (x, y).
+ - Values are either:
+ - Sub-dictionaries containing:
+ - "slope_1" or "slope_2": List of up to two sets of [nx, ny, nz].
+ - "angle_between": Float of angle between start and end vector.
+ - Empty ndarray for pixels without data.
+
+ Returns:
+ None: Displays the contour plots for both sets of data.
+ """
+ # Initialize lists to store data for the first and second sets
+ x_coords_set, y_coords_set, slope_set1, slope_set2, angle_between = [], [], [], [], []
+
+ # Iterate through the dictionary
+ for pixel, data in data_dict.items():
+ if isinstance(data, dict): # Check if the value is a sub-dictionary
+ # Extract the first set of data
+ if data["slope_1"].size < 3 or data["slope_2"].size < 3:
+ continue
+ elif len(data["slope_1"]) > 0:
+ row, col = ast.literal_eval(pixel)
+ x_coords_set.append(col)
+ y_coords_set.append(row)
+ slope_set1.append(data["slope_1"])
+ slope_set2.append(data["slope_2"])
+ angle_between.append(data["angle_between"])
+ elif isinstance(data, np.ndarray) and len(data) == 0: # Skip empty lists
+ continue
+ else:
+ raise ValueError(f"Unexpected data format for pixel {pixel}: {data}")
+
+ best_slope_1 = ransac_average_direction(np.array(slope_set1))
+ best_slope_2 = ransac_average_direction(np.array(slope_set2))
+
+ plot_angle_between_vectors(x_coords_set, y_coords_set, angle_between)
+
+ plot_heat_maps_no_comparison(x_coords_set, y_coords_set, slope_set1, "Set 1")
+
+ plot_heat_maps_no_comparison(x_coords_set, y_coords_set, slope_set2, "Set 2")
+
+ plot_heat_maps(x_coords_set, y_coords_set, slope_set1, best_slope_1, "Set 1")
+
+ plot_heat_maps(x_coords_set, y_coords_set, slope_set2, best_slope_2, "Set 2")
+
+ plot_heat_maps_radians(x_coords_set, y_coords_set, slope_set1, best_slope_1, "Set 1 Radians")
+
+ plot_heat_maps_radians(x_coords_set, y_coords_set, slope_set2, best_slope_2, "Set 2 Radians")
+
+
+def plot_slope_heat_maps_horizonal(data_dict, output_dir):
+ """
+ Iterates through a dictionary with pixel coordinates as keys, extracts up to two sets of data,
+ and plots contour plots for each set separately.
+
+ Parameters:
+ pixel_dict (dict): A dictionary where:
+ - Keys are pixel coordinates (tuples) like (x, y).
+ - Values are either:
+ - Sub-dictionaries containing:
+ - "slope_1" or "slope_2": List of up to two sets of [nx, ny, nz].
+ - "angle_between": Float of angle between start and end vector.
+ - Empty ndarray for pixels without data.
+
+ Returns:
+ None: Displays the contour plots for both sets of data.
+ """
+ # Initialize lists to store data for the first and second sets
+ x_coords_set, y_coords_set, slope_set1, slope_set2, angle_between = [], [], [], [], []
+
+ # Iterate through the dictionary
+ for pixel, data in data_dict.items():
+ if isinstance(data, dict): # Check if the value is a sub-dictionary
+ if data["intersection_1"].size > 0:
+ # Extract the first set of data
+ if data["H_slope_1_camera_corrected"].size < 3 or data["H_slope_2_camera_corrected"].size < 3:
+ continue
+ elif len(data["H_slope_1_camera_corrected"]) > 0:
+ row, col = ast.literal_eval(pixel)
+ x_coords_set.append(col)
+ y_coords_set.append(row)
+ slope_set1.append(data["H_slope_1_camera_corrected"])
+ slope_set2.append(data["H_slope_2_camera_corrected"])
+ angle_between.append(data["angle_between"])
+ else:
+ continue
+ elif isinstance(data, np.ndarray) and len(data) == 0: # Skip empty lists
+ continue
+ else:
+ raise ValueError(f"Unexpected data format for pixel {pixel}: {data}")
+
+ best_slope_1 = ransac_average_direction(np.array(slope_set1))
+ best_slope_2 = ransac_average_direction(np.array(slope_set2))
+
+ plot_angle_between_vectors(
+ x_coords_set,
+ y_coords_set,
+ angle_between,
+ set_label="Horizonal_Original_Data",
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_heat_maps_no_comparison(
+ x_coords_set,
+ y_coords_set,
+ slope_set1,
+ set_label="Set_1_Horizonal_Camera_Vector_Corrected",
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_heat_maps_no_comparison(
+ x_coords_set,
+ y_coords_set,
+ slope_set2,
+ set_label="Set_2_Horizonal_Camera_Vector_Corrected",
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_heat_maps(
+ x_coords_set,
+ y_coords_set,
+ slope_set1,
+ best_slope_1,
+ set_label="Set_1_Horizonal_Difference_Camera_Corrected",
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_heat_maps(
+ x_coords_set,
+ y_coords_set,
+ slope_set2,
+ best_slope_2,
+ set_label="Set_2_Horizonal_Difference_Camera_Corrected",
+ output_dir=output_dir,
+ render=False,
+ )
+
+ # plot_heat_maps_radians(x_coords_set, y_coords_set, slope_set1, best_slope_1, "Set 1 Horizonal Radians")
+
+ # plot_heat_maps_radians(x_coords_set, y_coords_set, slope_set2, best_slope_2, "Set 2 Horizonal Radians")
+
+
+def plot_slope_heat_maps_camera(data_dict, output_dir):
+ """
+ Iterates through a dictionary with pixel coordinates as keys, extracts up to two sets of data,
+ and plots contour plots for each set separately.
+
+ Parameters:
+ pixel_dict (dict): A dictionary where:
+ - Keys are pixel coordinates (tuples) like (x, y).
+ - Values are either:
+ - Sub-dictionaries containing:
+ - "slope_1" or "slope_2": List of up to two sets of [nx, ny, nz].
+ - "angle_between": Float of angle between start and end vector.
+ - Empty ndarray for pixels without data.
+
+ Returns:
+ None: Displays the contour plots for both sets of data.
+ """
+ # Initialize lists to store data for the first and second sets
+ x_coords_set, y_coords_set, slope_set1, slope_set2, angle_between = [], [], [], [], []
+
+ # Iterate through the dictionary
+ for pixel, data in data_dict.items():
+ if isinstance(data, dict): # Check if the value is a sub-dictionary
+ if data["intersection_1"].size > 0:
+ # Extract the first set of data
+ if data["C_slope_1_camera_corrected"].size < 3 or data["C_slope_2_camera_corrected"].size < 3:
+ continue
+ elif len(data["C_slope_1_camera_corrected"]) > 0:
+ row, col = ast.literal_eval(pixel)
+ x_coords_set.append(col)
+ y_coords_set.append(row)
+ slope_set1.append(data["C_slope_1_camera_corrected"])
+ slope_set2.append(data["C_slope_2_camera_corrected"])
+ angle_between.append(data["angle_between"])
+ else:
+ continue
+ elif isinstance(data, np.ndarray) and len(data) == 0: # Skip empty lists
+ continue
+ else:
+ raise ValueError(f"Unexpected data format for pixel {pixel}: {data}")
+
+ best_slope_1 = ransac_average_direction(np.array(slope_set1))
+ best_slope_2 = ransac_average_direction(np.array(slope_set2))
+
+ plot_angle_between_vectors(
+ x_coords_set, y_coords_set, angle_between, set_label="Camera_Original_Data", output_dir=output_dir, render=False
+ )
+
+ plot_heat_maps_no_comparison(
+ x_coords_set,
+ y_coords_set,
+ slope_set1,
+ set_label="Set_1_CamCoords_Camera_Vector_Corrected",
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_heat_maps_no_comparison(
+ x_coords_set,
+ y_coords_set,
+ slope_set2,
+ set_label="Set_2_CamCoords_Camera_Vector_Corrected",
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_heat_maps(
+ x_coords_set,
+ y_coords_set,
+ slope_set1,
+ best_slope_1,
+ set_label="Set_1_CamCoords_Difference_Camera_Corrected",
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_heat_maps(
+ x_coords_set,
+ y_coords_set,
+ slope_set2,
+ best_slope_2,
+ set_label="Set_2_CamCoords_Difference_Camera_Corrected",
+ output_dir=output_dir,
+ render=False,
+ )
+
+ # plot_heat_maps_radians(x_coords_set, y_coords_set, slope_set1, best_slope_1, "Set 1 Camera Radians")
+
+ # plot_heat_maps_radians(x_coords_set, y_coords_set, slope_set2, best_slope_2, "Set 2 Camera Radians")
+
+
+def plot_slope_heat_maps_mirror(data_dict, output_dir):
+ """
+ Iterates through a dictionary with pixel coordinates as keys, extracts up to two sets of data,
+ and plots contour plots for each set separately.
+
+ Parameters:
+ pixel_dict (dict): A dictionary where:
+ - Keys are pixel coordinates (tuples) like (x, y).
+ - Values are either:
+ - Sub-dictionaries containing:
+ - "slope_1" or "slope_2": List of up to two sets of [nx, ny, nz].
+ - "angle_between": Float of angle between start and end vector.
+ - Empty ndarray for pixels without data.
+
+ Returns:
+ None: Displays the contour plots for both sets of data.
+ """
+ # Initialize lists to store data for the first and second sets
+ x_coords_set, y_coords_set, slope_set1, slope_set2, angle_between = [], [], [], [], []
+
+ # Iterate through the dictionary
+ for pixel, data in data_dict.items():
+ if isinstance(data, dict): # Check if the value is a sub-dictionary
+ if data["intersection_1"].size > 0:
+ # Extract the first set of data
+ if data["M_slope_1_camera_corrected"].size < 3 or data["M_slope_2_camera_corrected"].size < 3:
+ continue
+ elif len(data["M_slope_1_camera_corrected"]) > 0:
+ row, col = ast.literal_eval(pixel)
+ x_coords_set.append(col)
+ y_coords_set.append(row)
+ slope_set1.append(data["M_slope_1_camera_corrected"])
+ slope_set2.append(data["M_slope_2_camera_corrected"])
+ angle_between.append(data["angle_between"])
+ else:
+ continue
+ elif isinstance(data, np.ndarray) and len(data) == 0: # Skip empty lists
+ continue
+ else:
+ raise ValueError(f"Unexpected data format for pixel {pixel}: {data}")
+
+ best_slope_1 = ransac_average_direction(np.array(slope_set1))
+ best_slope_2 = ransac_average_direction(np.array(slope_set2))
+
+ plot_angle_between_vectors(
+ x_coords_set, y_coords_set, angle_between, set_label="Mirror_Original_Data", output_dir=output_dir, render=False
+ )
+
+ plot_heat_maps_no_comparison(
+ x_coords_set,
+ y_coords_set,
+ slope_set1,
+ set_label="Set_1_Mirror_Camera_Vector_Corrected",
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_heat_maps_no_comparison(
+ x_coords_set,
+ y_coords_set,
+ slope_set2,
+ set_label="Set_2_Mirror_Camera_Vector_Corrected",
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_heat_maps(
+ x_coords_set,
+ y_coords_set,
+ slope_set1,
+ best_slope_1,
+ set_label="Set_1_Mirror_Difference_Camera_Corrected",
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_heat_maps(
+ x_coords_set,
+ y_coords_set,
+ slope_set2,
+ best_slope_2,
+ set_label="Set_2_Mirror_Difference_Camera_Corrected",
+ output_dir=output_dir,
+ render=False,
+ )
+
+ # plot_heat_maps_radians(x_coords_set, y_coords_set, slope_set1, best_slope_1, "Set 1 Mirror Radians")
+
+ # plot_heat_maps_radians(x_coords_set, y_coords_set, slope_set2, best_slope_2, "Set 2 Mirror Radians")
+
+
+def plot_angle_between_vectors(
+ x_coords, y_coords, angles_between, set_label="angle_between_vectors", output_dir=None, render=False
+):
+ if output_dir:
+ ft.create_directories_if_necessary(output_dir)
+ # Convert lists to numpy arrays
+ x_coords = np.array(x_coords)
+ y_coords = np.array(y_coords)
+ # Create a grid for contour plotting
+ grid_x, grid_y = np.meshgrid(
+ np.linspace(x_coords.min(), x_coords.max(), 500), np.linspace(y_coords.min(), y_coords.max(), 500)
+ )
+
+ angles = griddata((x_coords, y_coords), angles_between, (grid_x, grid_y), method="linear")
+
+ fig, ax = plt.subplots()
+ contour = ax.contourf(grid_x, grid_y, angles, cmap="jet", levels=250) # vmin=scale_min, vmax=scale_max
+ cbar = plt.colorbar(contour, ax=ax)
+ ax.set_xlabel("X Pixel Location")
+ ax.set_ylabel("Y Pixel Location")
+ ax.set_title("Coverage Map Type Plot Heat Map")
+ cbar.set_label("Angle Between Vectors [Radians]")
+ ax.invert_yaxis()
+ if output_dir:
+ file_path = ft.join(output_dir, set_label + ".png")
+ plt.savefig(file_path)
+ if not render:
+ plt.close()
+
+
+def plot_heat_maps_no_comparison(x_coords, y_coords, slopes, set_label, view_tup=None, output_dir=None, render=False):
+ if output_dir:
+ ft.create_directories_if_necessary(output_dir)
+ # Convert lists to numpy arrays
+ x_coords = np.array(x_coords)
+ y_coords = np.array(y_coords)
+ slopes = np.array(slopes)
+
+ # scale_max = np.max([slopes])
+ # scale_min = np.min([slopes])
+ # Create a grid for contour plotting
+ grid_x, grid_y = np.meshgrid(
+ np.linspace(x_coords.min(), x_coords.max(), 500), np.linspace(y_coords.min(), y_coords.max(), 500)
+ )
+
+ # Interpolate errors onto the grid
+ error_grid = {
+ "X Value": griddata((x_coords, y_coords), slopes[:, 0], (grid_x, grid_y), method="linear"),
+ "Y Value": griddata((x_coords, y_coords), slopes[:, 1], (grid_x, grid_y), method="linear"),
+ "Z Value": griddata((x_coords, y_coords), slopes[:, 2], (grid_x, grid_y), method="linear"),
+ }
+
+ # Plot each error type as a contour plot
+ fig, axes = plt.subplots(1, 3, figsize=(24, 7))
+ error_types = ["X Value", "Y Value", "Z Value"]
+ for ax, error_type in zip(axes.flat, error_types):
+ contour = ax.contourf(
+ grid_x, grid_y, error_grid[error_type], cmap="jet", levels=250 # , vmin=scale_min, vmax=scale_max
+ )
+ # ax.scatter(x_coords, y_coords, s=0.1, c='k', marker='.') # Actual locations where we have data
+ cbar = plt.colorbar(contour, ax=ax)
+ ax.set_title(f"{error_type} Heat Map ({set_label})", wrap=True)
+ ax.set_xlabel("X Pixel Location")
+ ax.set_ylabel("Y Pixel Location")
+ cbar.set_label(f"{error_type}")
+ ax.invert_yaxis()
+
+ if view_tup:
+ ax.view_init(elev=view_tup[0], azim=view_tup[1])
+
+ plt.tight_layout()
+ if output_dir:
+ if view_tup:
+ file_path = ft.join(output_dir, set_label + f"_el{view_tup[0]}_az{view_tup[1]}.png")
+ else:
+ file_path = ft.join(output_dir, set_label + ".png")
+ plt.savefig(file_path)
+ if not render:
+ plt.close()
+ # plt.show()
+
+
+def plot_heat_maps(x_coords, y_coords, slopes, best_slope, set_label, view_tup=None, output_dir=None, render=False):
+ if output_dir:
+ ft.create_directories_if_necessary(output_dir)
+ # Convert lists to numpy arrays
+ x_coords = np.array(x_coords)
+ y_coords = np.array(y_coords)
+ slopes = np.array(slopes)
+
+ slope_differences, slope_diff_norms = calculate_slope_differences(best_slope, slopes)
+ slope_differences = np.array(slope_differences)
+ slope_diff_norms = np.array(slope_diff_norms)
+
+ # scale_max = np.max([slope_diff_norms])
+ # scale_min = np.min([slope_diff_norms])
+ # Create a grid for contour plotting
+ grid_x, grid_y = np.meshgrid(
+ np.linspace(x_coords.min(), x_coords.max(), 500), np.linspace(y_coords.min(), y_coords.max(), 500)
+ )
+
+ # Interpolate errors onto the grid
+ error_grid = {
+ "Norm Difference": griddata((x_coords, y_coords), slope_diff_norms, (grid_x, grid_y), method="linear"),
+ "X Difference": griddata((x_coords, y_coords), slope_differences[:, 0], (grid_x, grid_y), method="linear"),
+ "Y Difference": griddata((x_coords, y_coords), slope_differences[:, 1], (grid_x, grid_y), method="linear"),
+ "Z Difference": griddata((x_coords, y_coords), slope_differences[:, 2], (grid_x, grid_y), method="linear"),
+ }
+
+ # Plot each error type as a contour plot
+ fig, axes = plt.subplots(2, 2, figsize=(12, 10))
+ error_types = ["Norm Difference", "X Difference", "Y Difference", "Z Difference"]
+ for ax, error_type in zip(axes.flat, error_types):
+ contour = ax.contourf(
+ grid_x, grid_y, error_grid[error_type], cmap="jet", levels=500 # , vmin=scale_min, vmax=scale_max
+ )
+ cbar = plt.colorbar(contour, ax=ax)
+ ax.set_title(f"{error_type} Heat Map ({set_label})", wrap=True)
+ ax.set_xlabel("X Pixel Location")
+ ax.set_ylabel("Y Pixel Location")
+ cbar.set_label(f"{error_type}")
+ ax.invert_yaxis()
+
+ if view_tup:
+ ax.view_init(elev=view_tup[0], azim=view_tup[1])
+
+ plt.tight_layout()
+ if output_dir:
+ if view_tup:
+ file_path = ft.join(output_dir, set_label + f"_el{view_tup[0]}_az{view_tup[1]}.png")
+ else:
+ file_path = ft.join(output_dir, set_label + ".png")
+ plt.savefig(file_path)
+ if not render:
+ plt.close()
+ # plt.show()
+
+
+def plot_heat_maps_radians(x_coords, y_coords, slopes, best_slope, set_label):
+ # Convert lists to numpy arrays
+ x_coords = np.array(x_coords)
+ y_coords = np.array(y_coords)
+ slopes = np.array(slopes)
+
+ slope_deviation_radians = []
+ for slope in slopes:
+ slope_deviation_radians.append(calculate_angle_between_vectors(best_slope, slope))
+
+ slope_deviation_radians = np.array(slope_deviation_radians)
+
+ # scale_max = np.max([slope_deviation_radians])
+ # scale_min = np.min([slope_deviation_radians])
+ # Create a grid for contour plotting
+ grid_x, grid_y = np.meshgrid(
+ np.linspace(x_coords.min(), x_coords.max(), 500), np.linspace(y_coords.min(), y_coords.max(), 500)
+ )
+
+ # Interpolate errors onto the grid
+ error_grid = {
+ "Radian Difference": griddata((x_coords, y_coords), slope_deviation_radians, (grid_x, grid_y), method="linear")
+ }
+
+ # Plot each error type as a contour plot
+ fig, ax = plt.subplots(1, 1, figsize=(12, 10))
+ contour = ax.contourf(
+ grid_x, grid_y, error_grid["Radian Difference"], cmap="jet", levels=500 # , vmin=scale_min, vmax=scale_max
+ )
+ cbar = plt.colorbar(contour, ax=ax)
+ ax.set_title(f"Difference Heat Map ({set_label})")
+ ax.set_xlabel("X Pixel Location")
+ ax.set_ylabel("Y Pixel Location")
+ cbar.set_label("Radian Difference")
+ ax.invert_yaxis()
+
+ plt.tight_layout()
+ # plt.show()
+
+
+def plot_scatter_heat_map(coordinates, slopes, set_label, view_tup=None, output_dir=None, render=False, invert_y=None):
+
+ x_coords = coordinates[:, 0]
+ y_coords = coordinates[:, 1]
+ z_coords = coordinates[:, 2]
+ x_vec = slopes[:, 0]
+ y_vec = slopes[:, 1]
+ z_vec = slopes[:, 2]
+
+ # Create subplots
+ fig = plt.figure(figsize=(18, 6))
+
+ # Scatter plot for x_vec
+ ax1 = fig.add_subplot(131, projection='3d')
+ scatter_x = ax1.scatter(x_coords, y_coords, z_coords, c=x_vec, cmap='jet', s=1)
+ ax1.set_title("X " + set_label)
+ ax1.set_xlabel('X Coordinate')
+ ax1.set_ylabel('Y Coordinate')
+ ax1.set_zlabel('Z Coordinate')
+ fig.colorbar(scatter_x, ax=ax1)
+ if invert_y is not None:
+ ax1.invert_yaxis()
+
+ # Scatter plot for y_vec
+ ax2 = fig.add_subplot(132, projection='3d')
+ scatter_y = ax2.scatter(x_coords, y_coords, z_coords, c=y_vec, cmap='jet', s=1)
+ ax2.set_title("Y " + set_label)
+ ax2.set_xlabel('X Coordinate')
+ ax2.set_ylabel('Y Coordinate')
+ ax2.set_zlabel('Z Coordinate')
+ fig.colorbar(scatter_y, ax=ax2)
+ if invert_y is not None:
+ ax2.invert_yaxis()
+
+ # Scatter plot for z_vec
+ ax3 = fig.add_subplot(133, projection='3d')
+ scatter_z = ax3.scatter(x_coords, y_coords, z_coords, c=z_vec, cmap='jet', s=1)
+ ax3.set_title("Z " + set_label)
+ ax3.set_xlabel('X Coordinate')
+ ax3.set_ylabel('Y Coordinate')
+ ax3.set_zlabel('Z Coordinate')
+ fig.colorbar(scatter_z, ax=ax3)
+ if invert_y is not None:
+ ax3.invert_yaxis()
+
+ if view_tup:
+ ax1.view_init(elev=view_tup[0], azim=view_tup[1])
+ ax2.view_init(elev=view_tup[0], azim=view_tup[1])
+ ax3.view_init(elev=view_tup[0], azim=view_tup[1])
+
+ # Adjust layout
+ plt.tight_layout()
+ if output_dir:
+ if view_tup:
+ file_path = ft.join(output_dir, set_label + f"_el{view_tup[0]}_az{view_tup[1]}.png")
+ else:
+ file_path = ft.join(output_dir, set_label + ".png")
+ plt.savefig(file_path)
+ if not render:
+ plt.close()
+
+
+def plot_scatter3d(coordinates, color, set_label, add_pts=False, view_tup=None, output_dir=None, render=False):
+ """
+ Plot (or add to) a 3D scatter plot.
+
+ Parameters
+ ----------
+ coordinates : (N,3) array-like
+ color : matplotlib color or array-like
+ set_label : str
+ add_pts : bool
+ If True, add points to an existing axes (ax or current figure's 3D axes).
+ view_tup : (elev, azim) or None
+ output_dir : str or None
+ render : bool
+ If False, close the figure after saving (or after plotting).
+ """
+ coords = np.asarray(coordinates)
+ if coords.ndim != 2 or coords.shape[1] != 3:
+ raise ValueError("coordinates must be an (N, 3) array-like")
+
+ x, y, z = coords[:, 0], coords[:, 1], coords[:, 2]
+
+ # One-figure-at-a-time convention:
+ # - if add_pts: reuse current figure/axes
+ # - else: make a fresh figure/axes
+ if add_pts:
+ fig = plt.gcf()
+ # If there's no axes yet, create one; otherwise use the first axes.
+ if fig.get_axes():
+ ax = fig.get_axes()[0]
+ else:
+ ax = fig.add_subplot(111, projection="3d")
+ else:
+ fig = plt.figure()
+ ax = fig.add_subplot(111, projection="3d")
+
+ ax.scatter(x, y, z, c=color, s=1)
+ ax.set_title(set_label)
+ ax.set_xlabel("X Coordinate")
+ ax.set_ylabel("Y Coordinate")
+ ax.set_zlabel("Z Coordinate")
+
+ if view_tup is not None:
+ ax.view_init(elev=view_tup[0], azim=view_tup[1])
+
+ # Adjust layout
+ plt.tight_layout()
+ if output_dir:
+ if view_tup:
+ file_path = ft.join(output_dir, set_label + f"_el{view_tup[0]}_az{view_tup[1]}.png")
+ else:
+ file_path = ft.join(output_dir, set_label + ".png")
+ plt.savefig(file_path, bbox_inches="tight")
+ if not render:
+ plt.close()
+
+
+def plot_heat_maps_horizonal_looking_up(vector_data, output_dir):
+ x_coords_set, y_coords_set, slope_set1, slope_set2 = [], [], [], []
+ for pixel, details in vector_data.items():
+ if isinstance(details, dict):
+ if details["intersection_1"].size > 0:
+ row, col = ast.literal_eval(pixel)
+
+ x_coords_set.append(col)
+ y_coords_set.append(row)
+ slope_set1.append(vector_data[pixel]["H_slope_1_camera_corrected"])
+ slope_set2.append(vector_data[pixel]["H_slope_2_camera_corrected"])
+
+ else:
+ continue
+ else:
+ pass
+
+ mean_direction_1 = np.mean(np.array(slope_set1), axis=0)
+ mean_direction_2 = np.mean(np.array(slope_set2), axis=0)
+ H_look_up_rot_1, _ = rotation_matrix_scipy(mean_direction_1, np.array([0, 0, 1]))
+ H_look_up_rot_2, _ = rotation_matrix_scipy(mean_direction_2, np.array([0, 0, 1]))
+ H_slope_look_up_1, H_slope_look_up_2 = [], []
+
+ H_slope_look_up_1 = H_look_up_rot_1.apply(np.array(slope_set1))
+ H_slope_look_up_2 = H_look_up_rot_2.apply(np.array(slope_set2))
+
+ plot_heat_maps_no_comparison(
+ x_coords_set,
+ y_coords_set,
+ H_slope_look_up_1,
+ set_label="Set_1_Horz_Mean_Vec_Rot_to_UP",
+ output_dir=output_dir,
+ render=False,
+ )
+ plot_heat_maps_no_comparison(
+ x_coords_set,
+ y_coords_set,
+ H_slope_look_up_2,
+ set_label="Set_2_Horz_Mean_Vec_Rot_to_UP",
+ output_dir=output_dir,
+ render=False,
+ )
+
+
+def plot_heat_maps_camera_looking_up_coords(vector_data, control, output_dir, debug_plots=True):
+ x_pix_set, y_pix_set, C_coords, slope_set1, slope_set2 = [], [], [], [], []
+ for pixel, details in vector_data.items():
+ if isinstance(details, dict):
+ if details["intersection_1"].size > 0:
+ row, col = ast.literal_eval(pixel)
+
+ x_pix_set.append(col)
+ y_pix_set.append(row)
+ C_coords.append(vector_data[pixel]["C_point_location"])
+ slope_set1.append(vector_data[pixel]["C_slope_1_camera_corrected"])
+ slope_set2.append(vector_data[pixel]["C_slope_2_camera_corrected"])
+
+ else:
+ continue
+ else:
+ pass
+
+ C_coords = np.array(C_coords).squeeze(axis=1)
+ C_xyz_axis = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
+ mean_direction_1 = np.mean(np.array(slope_set1), axis=0)
+ mean_direction_1 = mean_direction_1 / np.linalg.norm(mean_direction_1)
+ mean_direction_2 = np.mean(np.array(slope_set2), axis=0)
+ mean_direction_2 = mean_direction_2 / np.linalg.norm(mean_direction_2)
+ C_look_axis_rot_1, _ = rotation_matrix_scipy(mean_direction_1, np.array([0, 0, -1])) # Rotation
+ C_look_axis_rot_2, _ = rotation_matrix_scipy(mean_direction_2, np.array([0, 0, -1])) # Rotation
+ mean_direction_r_1 = C_look_axis_rot_1.apply(mean_direction_1)
+ mean_direction_r_2 = C_look_axis_rot_2.apply(mean_direction_2)
+ C_xyz_axis_r_1 = C_look_axis_rot_1.apply(C_xyz_axis)
+ C_xyz_axis_r_2 = C_look_axis_rot_2.apply(C_xyz_axis)
+ ctrl_r_1 = apply_rt_to_control(control, C_look_axis_rot_1, None)
+ ctrl_r_2 = apply_rt_to_control(control, C_look_axis_rot_2, None)
+ C_slope_look_axis_1, C_slope_look_axis_2, C_coords_look_axis_1, C_coords_look_axis_2 = [], [], [], []
+
+ C_slope_look_axis_1 = C_look_axis_rot_1.apply(np.array(slope_set1))
+ C_coords_look_axis_1 = C_look_axis_rot_1.apply(C_coords)
+ C_slope_look_axis_2 = C_look_axis_rot_2.apply(np.array(slope_set2))
+ C_coords_look_axis_2 = C_look_axis_rot_2.apply(C_coords)
+
+ if debug_plots:
+ plot_scatter_heat_map(
+ C_coords,
+ np.array(slope_set1),
+ set_label="Set_1_Cam_Vec_Coords",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_3d_axes_to_plot(
+ C_coords[0],
+ C_xyz_axis,
+ color_sequence=None,
+ arrow_length=2,
+ set_label="Set_1_Cam_Vec_Coords",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ C_coords[round(len(C_coords) / 2)],
+ mean_direction_1,
+ color='orange',
+ arrow_length=2,
+ set_label="Set_1_Cam_Vec_Coords",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ C_coords[round(len(C_coords) / 2)],
+ mean_direction_r_1,
+ color='black',
+ arrow_length=2,
+ set_label="Set_1_Cam_Vec_Coords",
+ # view_tup=(30, 135),
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_scatter_heat_map(
+ C_coords,
+ np.array(slope_set2),
+ set_label="Set_2_Cam_Vec_Coords",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_3d_axes_to_plot(
+ C_coords[0],
+ C_xyz_axis,
+ color_sequence=None,
+ arrow_length=2,
+ set_label="Set_2_Cam_Vec_Coords",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ C_coords[round(len(C_coords) / 2)],
+ mean_direction_2,
+ color='orange',
+ arrow_length=2,
+ set_label="Set_2_Cam_Vec_Coords",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ C_coords[round(len(C_coords) / 2)],
+ mean_direction_r_2,
+ color='black',
+ arrow_length=2,
+ set_label="Set_2_Cam_Vec_Coords",
+ # view_tup=(30, 135),
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_scatter_heat_map(
+ C_coords_look_axis_1,
+ C_slope_look_axis_1,
+ set_label="Set_1_Cam_Look_Opt_Axis",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_3d_axes_to_plot(
+ C_coords_look_axis_1[0],
+ C_xyz_axis,
+ color_sequence=None,
+ arrow_length=2,
+ set_label="Set_1_Cam_Look_Opt_Axis",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_3d_axes_to_plot(
+ C_coords_look_axis_1[0],
+ C_xyz_axis_r_1,
+ color_sequence=["y", "m", "c"],
+ arrow_length=2,
+ set_label="Set_1_Cam_Look_Opt_Axis",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ C_coords_look_axis_1[round(len(C_coords_look_axis_1) / 2)],
+ mean_direction_1,
+ color='orange',
+ arrow_length=2,
+ set_label="Set_1_Cam_Look_Opt_Axis",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ C_coords_look_axis_1[round(len(C_coords_look_axis_1) / 2)],
+ mean_direction_r_1,
+ color='black',
+ arrow_length=2,
+ set_label="Set_1_Cam_Look_Opt_Axis",
+ # view_tup=(30, 135),
+ output_dir=output_dir,
+ render=True,
+ )
+
+ plot_scatter_heat_map(
+ C_coords_look_axis_2,
+ C_slope_look_axis_2,
+ set_label="Set_2_Cam_Look_Opt_Axis",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_3d_axes_to_plot(
+ C_coords_look_axis_2[0],
+ C_xyz_axis,
+ color_sequence=None,
+ arrow_length=2,
+ set_label="Set_2_Cam_Look_Opt_Axis",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_3d_axes_to_plot(
+ C_coords_look_axis_2[0],
+ C_xyz_axis_r_2,
+ color_sequence=["y", "m", "c"],
+ arrow_length=2,
+ set_label="Set_2_Cam_Look_Opt_Axis",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ C_coords_look_axis_2[round(len(C_coords_look_axis_2) / 2)],
+ mean_direction_2,
+ color='orange',
+ arrow_length=2,
+ set_label="Set_2_Cam_Look_Opt_Axis",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ C_coords_look_axis_2[round(len(C_coords_look_axis_2) / 2)],
+ mean_direction_r_2,
+ color='black',
+ arrow_length=2,
+ set_label="Set_2_Cam_Look_Opt_Axis",
+ # view_tup=(30, 135),
+ output_dir=output_dir,
+ render=False,
+ )
+
+ C_coords_look_axis_1_translate = C_coords_look_axis_1 - np.mean(C_coords_look_axis_1, axis=0) # Translation
+ C_coords_look_axis_2_translate = C_coords_look_axis_2 - np.mean(C_coords_look_axis_2, axis=0) # Translation
+
+ ctrl_rt_1 = apply_rt_to_control(ctrl_r_1, Rotation.identity(), -1 * np.mean(C_coords_look_axis_1, axis=0))
+ ctrl_rt_2 = apply_rt_to_control(ctrl_r_2, Rotation.identity(), -1 * np.mean(C_coords_look_axis_2, axis=0))
+
+ C_coords_xy_rot_1 = transform_to_xy_plane(
+ [
+ C_coords_look_axis_1_translate[0, :],
+ C_coords_look_axis_1_translate[-1, :],
+ C_coords_look_axis_1_translate[1000, :],
+ ],
+ z_orientation=-1,
+ reference_normal=ctrl_rt_1["normal"],
+ ) # Rotation for Coordinates Only
+ C_coords_xy_rot_2 = transform_to_xy_plane(
+ [
+ C_coords_look_axis_2_translate[0, :],
+ C_coords_look_axis_2_translate[-1, :],
+ C_coords_look_axis_2_translate[1000, :],
+ ],
+ z_orientation=-1,
+ reference_normal=ctrl_rt_2["normal"],
+ ) # Rotation for Coordinates Only
+
+ ctrl_xy_1 = apply_rt_to_control(ctrl_rt_1, C_coords_xy_rot_1, None)
+ ctrl_xy_2 = apply_rt_to_control(ctrl_rt_2, C_coords_xy_rot_2, None)
+
+ C_xyz_axis_rr_1 = C_coords_xy_rot_1.apply(C_xyz_axis_r_1)
+ C_xyz_axis_rr_2 = C_coords_xy_rot_2.apply(C_xyz_axis_r_2)
+
+ C_coords_xy_1 = C_coords_xy_rot_1.apply(C_coords_look_axis_1)
+ # C_slopes_xy_1 = C_coords_xy_rot_1.apply(C_slope_look_axis_1)
+ C_coords_xy_2 = C_coords_xy_rot_2.apply(C_coords_look_axis_2)
+ # C_slopes_xy_2 = C_coords_xy_rot_2.apply(C_slope_look_axis_2)
+ '''
+ plot_scatter_heat_map(C_coords_xy_1, C_slopes_xy_1, "Set 1 Camera Looking Anti Optical Axis XY Plane")
+ add_3d_axes_to_plot(C_coords_xy_1[0], C_xyz_axis)
+ add_3d_axes_to_plot(C_coords_xy_1[0], C_xyz_axis_rr_1, color_sequence=["y", "m", "c"])
+
+
+ x_align_angle_1 = binary_search_angle_select(
+ C_xyz_axis_rr_1[0],
+ C_xyz_axis[2],
+ rotate_vector,
+ component_index=1,
+ tolerance=1e-6,
+ max_iterations=5000,
+ num_initial_samples=36,
+ )
+ x_align_rot_obj_1 = create_rotation_object(axis=C_xyz_axis[2], angle_degrees=x_align_angle_1) # Rotation
+
+ ctrl_xy_align_1 = apply_rt_to_control(ctrl_xy_1, x_align_rot_obj_1, None)
+ C_xyz_axis_rrr_1 = x_align_rot_obj_1.apply(C_xyz_axis_rr_1)
+
+ # add_3d_axes_to_plot(C_coords_xy_1[0], C_xyz_axis_rrr_1, color_sequence=["black", "gray", "purple"], arrow_length=3)
+
+ plot_scatter_heat_map(C_coords_xy_2, C_slopes_xy_2, "Set 2 Camera Looking Anti Optical Axis XY Plane")
+ add_3d_axes_to_plot(C_coords_xy_2[0], C_xyz_axis)
+ add_3d_axes_to_plot(C_coords_xy_2[0], C_xyz_axis_rr_2, color_sequence=["y", "m", "c"])
+
+
+ x_align_angle_2 = binary_search_angle_select(
+ C_xyz_axis_rr_2[0],
+ C_xyz_axis[2],
+ rotate_vector,
+ component_index=1,
+ tolerance=1e-6,
+ max_iterations=5000,
+ num_initial_samples=36,
+ )
+ x_align_rot_obj_2 = create_rotation_object(axis=C_xyz_axis[2], angle_degrees=x_align_angle_2) # Rotation
+
+ ctrl_xy_align_2 = apply_rt_to_control(ctrl_xy_2, x_align_rot_obj_2, None)
+ C_xyz_axis_rrr_2 = x_align_rot_obj_2.apply(C_xyz_axis_rr_1)
+ # add_3d_axes_to_plot(C_coords_xy_1[0], C_xyz_axis_rrr_1, color_sequence=["black", "gray", "purple"], arrow_length=3)
+
+
+ C_coords_xy_align_1 = x_align_rot_obj_1.apply(C_coords_xy_1)
+ # C_slopes_xy_align_1 = x_align_rot_obj_1.apply(C_slopes_xy_1)
+ C_slopes_xy_align_1 = x_align_rot_obj_1.apply(C_slope_look_axis_1)
+
+ C_coords_xy_align_2 = x_align_rot_obj_2.apply(C_coords_xy_2)
+ # C_slopes_xy_align_2 = x_align_rot_obj_2.apply(C_slopes_xy_2)
+ C_slopes_xy_align_2 = x_align_rot_obj_2.apply(C_slope_look_axis_2)
+
+ if debug_plots:
+ plot_scatter_heat_map(
+ C_coords_xy_align_1, C_slopes_xy_align_1, set_label="Set_1_Cam_XY_Aligned", view_tup=(90, 270), render=True
+ )
+ add_3d_axes_to_plot(
+ C_coords_xy_align_1[0],
+ C_xyz_axis,
+ color_sequence=None,
+ set_label="Set_1_Cam_XY_Aligned",
+ view_tup=(90, 270),
+ render=True,
+ )
+ add_3d_axes_to_plot(
+ C_coords_xy_align_1[0],
+ C_xyz_axis_rrr_1,
+ color_sequence=["black", "gray", "purple"],
+ arrow_length=3,
+ set_label="Set_1_Cam_XY_Aligned",
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_scatter_heat_map(
+ C_coords_xy_align_2, C_slopes_xy_align_2, set_label="Set_2_Cam_XY_Aligned", view_tup=(90, 270), render=True
+ )
+ add_3d_axes_to_plot(
+ C_coords_xy_align_2[0],
+ C_xyz_axis,
+ color_sequence=None,
+ set_label="Set_2_Cam_XY_Aligned",
+ view_tup=(90, 270),
+ render=True,
+ )
+ add_3d_axes_to_plot(
+ C_coords_xy_align_2[0],
+ C_xyz_axis_rrr_2,
+ color_sequence=["black", "gray", "purple"],
+ arrow_length=3,
+ set_label="Set_2_Cam_XY_Aligned",
+ output_dir=output_dir,
+ render=False,
+ )
+ '''
+
+ final_xy_align_rot_obj_1 = axis_aligned_mirror_points(
+ C_coords_xy_1, angular_tol=0.05, refine=True, refine_span_deg=1, refine_step_deg=0.01
+ )
+ final_xy_align_rot_obj_2 = axis_aligned_mirror_points(
+ C_coords_xy_2, angular_tol=0.05, refine=True, refine_span_deg=1, refine_step_deg=0.01
+ )
+
+ ctrl_xy_align_final_1 = apply_rt_to_control(ctrl_xy_1, final_xy_align_rot_obj_1, None)
+ C_coords_xy_final_1 = final_xy_align_rot_obj_1.apply(C_coords_xy_1)
+ C_slopes_xy_final_1 = final_xy_align_rot_obj_1.apply(C_slope_look_axis_1)
+
+ ctrl_xy_align_final_2 = apply_rt_to_control(ctrl_xy_2, final_xy_align_rot_obj_2, None)
+ C_coords_xy_final_2 = final_xy_align_rot_obj_2.apply(C_coords_xy_2)
+ C_slopes_xy_final_2 = final_xy_align_rot_obj_2.apply(C_slope_look_axis_2)
+
+ # check final alignment with control points and normal vector and ensure that it lines up with expectations.
+
+ if debug_plots:
+ plot_scatter_heat_map(
+ C_coords_xy_final_1,
+ C_slopes_xy_final_1,
+ "Set_1_Cam_XY_Align_Final",
+ view_tup=(90, 270),
+ output_dir=output_dir,
+ render=False,
+ invert_y=True,
+ )
+ plot_scatter_heat_map(
+ C_coords_xy_final_2,
+ C_slopes_xy_final_2,
+ "Set_2_Cam_XY_Align_Final",
+ view_tup=(90, 270),
+ output_dir=output_dir,
+ render=False,
+ invert_y=True,
+ )
+
+ set_1_results = {
+ "slopes_rotation_combined": final_xy_align_rot_obj_1 * C_look_axis_rot_1,
+ "coords_rotation_combined": final_xy_align_rot_obj_1 * C_coords_xy_rot_1 * C_look_axis_rot_1,
+ "coords_translation_combined": -1 * np.mean(C_coords_look_axis_1, axis=0),
+ "coords": C_coords_xy_final_1,
+ "slopes": C_slopes_xy_final_1,
+ }
+
+ set_2_results = {
+ "slopes_rotation_combined": final_xy_align_rot_obj_2 * C_look_axis_rot_2,
+ "coords_rotation_combined": final_xy_align_rot_obj_2 * C_coords_xy_rot_2 * C_look_axis_rot_2,
+ "coords_translation_combined": -1 * np.mean(C_coords_look_axis_2, axis=0),
+ "coords": C_coords_xy_final_2,
+ "slopes": C_slopes_xy_final_2,
+ }
+
+ # sofast_plotting(output_directory=os.path.join(output_dir, "set_1"), solution_set=set_1_results)
+ # sofast_plotting(output_directory=os.path.join(output_dir, "set_2"), solution_set=set_2_results)
+
+ print("cam_plotting.....")
+
+ return set_1_results, set_2_results
+
+
+def plot_heat_maps_mirror_looking_up_coords(vector_data, output_dir, debug_plots=True):
+ x_pix_set, y_pix_set, M_coords, slope_set1, slope_set2 = [], [], [], [], []
+ for pixel, details in vector_data.items():
+ if isinstance(details, dict):
+ if details["intersection_1"].size > 0:
+ row, col = ast.literal_eval(pixel)
+
+ x_pix_set.append(col)
+ y_pix_set.append(row)
+ M_coords.append(vector_data[pixel]["M_point_location"])
+ slope_set1.append(vector_data[pixel]["M_slope_1_camera_corrected"])
+ slope_set2.append(vector_data[pixel]["M_slope_2_camera_corrected"])
+
+ else:
+ continue
+ else:
+ pass
+
+ M_coords = np.array(M_coords).squeeze(axis=1)
+ mean_direction_1 = np.mean(np.array(slope_set1), axis=0)
+ mean_direction_1 = mean_direction_1 / np.linalg.norm(mean_direction_1)
+ mean_direction_2 = np.mean(np.array(slope_set2), axis=0)
+ mean_direction_2 = mean_direction_2 / np.linalg.norm(mean_direction_2)
+
+ m_pt_min = np.min(np.array(M_coords), axis=0).reshape(3)
+ m_pt_max = np.max(np.array(M_coords), axis=0).reshape(3)
+ m_pt_avg = np.mean(np.array(M_coords), axis=0).reshape(3)
+
+ control_pts = np.array(
+ [
+ [m_pt_min[0], m_pt_min[1], m_pt_avg[2] / 2],
+ [m_pt_max[0], m_pt_max[1], m_pt_avg[2] / 2],
+ [m_pt_min[0], m_pt_max[1], m_pt_avg[2] / 2],
+ ]
+ )
+ ctrl_n = _triangle_normal(control_pts)
+
+ if np.dot(ctrl_n, mean_direction_1) < 0:
+ ctrl_n = -ctrl_n
+
+ control = {"points": control_pts, "normal": ctrl_n}
+
+ M_xyz_axis = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
+ M_look_axis_rot_1, _ = rotation_matrix_scipy(mean_direction_1, np.array([0, 0, 1])) # Rotation
+ M_look_axis_rot_2, _ = rotation_matrix_scipy(mean_direction_2, np.array([0, 0, 1])) # Rotation
+ mean_direction_r_1 = M_look_axis_rot_1.apply(mean_direction_1)
+ mean_direction_r_2 = M_look_axis_rot_2.apply(mean_direction_2)
+ M_xyz_axis_r_1 = M_look_axis_rot_1.apply(M_xyz_axis)
+ M_xyz_axis_r_2 = M_look_axis_rot_2.apply(M_xyz_axis)
+ ctrl_r_1 = apply_rt_to_control(control, M_look_axis_rot_1, None)
+ ctrl_r_2 = apply_rt_to_control(control, M_look_axis_rot_2, None)
+ M_slope_look_axis_1, M_slope_look_axis_2, M_coords_look_axis_1, M_coords_look_axis_2 = [], [], [], []
+
+ M_slope_look_axis_1 = M_look_axis_rot_1.apply(np.array(slope_set1))
+ M_coords_look_axis_1 = M_look_axis_rot_1.apply(M_coords)
+ M_slope_look_axis_2 = M_look_axis_rot_2.apply(np.array(slope_set2))
+ M_coords_look_axis_2 = M_look_axis_rot_2.apply(M_coords)
+
+ if debug_plots:
+ plot_scatter_heat_map(
+ M_coords,
+ np.array(slope_set1),
+ set_label="Set_1_Mir_Vec_Coords",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_3d_axes_to_plot(
+ M_coords[0],
+ M_xyz_axis,
+ color_sequence=None,
+ arrow_length=2,
+ set_label="Set_1_Mir_Vec_Coords",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ M_coords[round(len(M_coords) / 2)],
+ mean_direction_1,
+ color='orange',
+ arrow_length=2,
+ set_label="Set_1_Mir_Vec_Coords",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ M_coords[round(len(M_coords) / 2)],
+ mean_direction_r_1,
+ color='black',
+ arrow_length=2,
+ set_label="Set_1_Mir_Vec_Coords",
+ output_dir=output_dir,
+ # view_tup=(30, 135),
+ render=False,
+ )
+
+ plot_scatter_heat_map(
+ M_coords,
+ np.array(slope_set2),
+ set_label="Set_2_Mir_Vec_Coords",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_3d_axes_to_plot(
+ M_coords[0],
+ M_xyz_axis,
+ color_sequence=None,
+ arrow_length=2,
+ set_label="Set_2_Mir_Vec_Coords",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ M_coords[round(len(M_coords) / 2)],
+ mean_direction_2,
+ color='orange',
+ arrow_length=2,
+ set_label="Set_2_Mir_Vec_Coords",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ M_coords[round(len(M_coords) / 2)],
+ mean_direction_r_2,
+ color='black',
+ arrow_length=2,
+ set_label="Set_2_Mir_Vec_Coords",
+ output_dir=output_dir,
+ # view_tup=(30, 135),
+ render=False,
+ )
+
+ plot_scatter_heat_map(
+ M_coords_look_axis_1,
+ M_slope_look_axis_1,
+ set_label="Set_1_Mir_Look_Up",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_3d_axes_to_plot(
+ M_coords_look_axis_1[0],
+ M_xyz_axis,
+ color_sequence=None,
+ arrow_length=2,
+ set_label="Set_1_Mir_Look_Up",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ M_coords_look_axis_1[round(len(M_coords_look_axis_1) / 2)],
+ mean_direction_1,
+ color='orange',
+ arrow_length=2,
+ set_label="Set_1_Mir_Look_Up",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ M_coords_look_axis_1[round(len(M_coords_look_axis_1) / 2)],
+ mean_direction_r_1,
+ color='black',
+ arrow_length=2,
+ set_label="Set_1_Mir_Look_Up",
+ # view_tup=(30, 135),
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_scatter_heat_map(
+ M_coords_look_axis_2,
+ M_slope_look_axis_2,
+ set_label="Set_2_Mir_Look_Up",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_3d_axes_to_plot(
+ M_coords_look_axis_2[0],
+ M_xyz_axis,
+ color_sequence=None,
+ arrow_length=2,
+ set_label="Set_2_Mir_Look_Up",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ M_coords_look_axis_2[round(len(M_coords_look_axis_2) / 2)],
+ mean_direction_2,
+ color='orange',
+ arrow_length=2,
+ set_label="Set_2_Mir_Look_Up",
+ # view_tup=(30, 135),
+ render=True,
+ )
+ add_vector_to_plot(
+ M_coords_look_axis_2[round(len(M_coords_look_axis_2) / 2)],
+ mean_direction_r_2,
+ color='black',
+ arrow_length=2,
+ set_label="Set_2_Mir_Look_Up",
+ # view_tup=(30, 135),
+ output_dir=output_dir,
+ render=False,
+ )
+
+ M_coords_look_axis_1_translate = M_coords_look_axis_1 - np.mean(M_coords_look_axis_1, axis=0) # Translation
+ M_coords_look_axis_2_translate = M_coords_look_axis_2 - np.mean(M_coords_look_axis_2, axis=0) # Translation
+
+ ctrl_rt_1 = apply_rt_to_control(ctrl_r_1, Rotation.identity(), -1 * np.mean(M_coords_look_axis_1, axis=0))
+ ctrl_rt_2 = apply_rt_to_control(ctrl_r_2, Rotation.identity(), -1 * np.mean(M_coords_look_axis_2, axis=0))
+
+ M_coords_xy_rot_1 = transform_to_xy_plane(
+ [
+ M_coords_look_axis_1_translate[0, :],
+ M_coords_look_axis_1_translate[-1, :],
+ M_coords_look_axis_1_translate[1000, :],
+ ],
+ z_orientation=1,
+ reference_normal=ctrl_rt_1["normal"],
+ ) # Rotation for Coordinates Only
+ M_coords_xy_rot_2 = transform_to_xy_plane(
+ [
+ M_coords_look_axis_2_translate[0, :],
+ M_coords_look_axis_2_translate[-1, :],
+ M_coords_look_axis_2_translate[1000, :],
+ ],
+ z_orientation=1,
+ reference_normal=ctrl_rt_2["normal"],
+ ) # Rotation for Coordinates Only
+
+ ctrl_xy_1 = apply_rt_to_control(ctrl_rt_1, M_coords_xy_rot_1, None)
+ ctrl_xy_2 = apply_rt_to_control(ctrl_rt_2, M_coords_xy_rot_2, None)
+
+ M_xyz_axis_rr_1 = M_coords_xy_rot_1.apply(M_xyz_axis_r_1)
+ M_xyz_axis_rr_2 = M_coords_xy_rot_2.apply(M_xyz_axis_r_2)
+
+ M_coords_xy_1 = M_coords_xy_rot_1.apply(M_coords_look_axis_1)
+ # M_slopes_xy_1 = M_coords_xy_rot_1.apply(M_slope_look_axis_1)
+ M_coords_xy_2 = M_coords_xy_rot_2.apply(M_coords_look_axis_2)
+ # M_slopes_xy_2 = M_coords_xy_rot_2.apply(M_slope_look_axis_2)
+
+ M_coords_xy_centered_1 = M_coords_xy_1 - np.mean(M_coords_xy_1, axis=0) # Translation
+ M_coords_xy_centered_2 = M_coords_xy_2 - np.mean(M_coords_xy_2, axis=0) # Translation
+
+ '''
+ plot_scatter_heat_map(M_coords_xy_1, M_slopes_xy_1, "Set 1 Mirror Looking Up XY Plane")
+ add_3d_axes_to_plot(M_coords_xy_1[0], M_xyz_axis)
+ add_3d_axes_to_plot(M_coords_xy_1[0], M_xyz_axis_rr_1, color_sequence=["y", "m", "c"])
+
+
+ x_align_angle_1 = binary_search_angle_select(
+ M_xyz_axis_rr_1[0],
+ M_xyz_axis[2],
+ rotate_vector,
+ component_index=1,
+ tolerance=1e-6,
+ max_iterations=5000,
+ num_initial_samples=36,
+ )
+ x_align_rot_obj_1 = create_rotation_object(axis=M_xyz_axis[2], angle_degrees=x_align_angle_1) # Rotation
+
+ M_xyz_axis_rrr_1 = x_align_rot_obj_1.apply(M_xyz_axis_rr_1)
+ # add_3d_axes_to_plot(M_coords_xy_1[0], M_xyz_axis_rrr_1, color_sequence=["black", "gray", "purple"], arrow_length=3)
+
+ plot_scatter_heat_map(M_coords_xy_2, M_slopes_xy_2, "Set 2 Mirror Looking Up XY Plane")
+ add_3d_axes_to_plot(M_coords_xy_2[0], M_xyz_axis)
+ add_3d_axes_to_plot(M_coords_xy_2[0], M_xyz_axis_rr_2, color_sequence=["y", "m", "c"])
+
+
+ x_align_angle_2 = binary_search_angle_select(
+ M_xyz_axis_rr_2[0],
+ M_xyz_axis[2],
+ rotate_vector,
+ component_index=1,
+ tolerance=1e-6,
+ max_iterations=5000,
+ num_initial_samples=36,
+ )
+ x_align_rot_obj_2 = create_rotation_object(axis=M_xyz_axis[2], angle_degrees=x_align_angle_2) # Rotation
+
+ M_xyz_axis_rrr_2 = x_align_rot_obj_2.apply(M_xyz_axis_rr_1)
+ # add_3d_axes_to_plot(M_coords_xy_1[0], M_xyz_axis_rrr_1, color_sequence=["black", "gray", "purple"], arrow_length=3)
+
+ M_coords_xy_align_1 = x_align_rot_obj_1.apply(M_coords_xy_1)
+ # M_slopes_xy_align_1 = x_align_rot_obj_1.apply(M_slopes_xy_1)
+ M_slopes_xy_align_1 = x_align_rot_obj_1.apply(M_slope_look_axis_1)
+
+ M_coords_xy_align_2 = x_align_rot_obj_2.apply(M_coords_xy_2)
+ # M_slopes_xy_align_2 = x_align_rot_obj_2.apply(M_slopes_xy_2)
+ M_slopes_xy_align_2 = x_align_rot_obj_2.apply(M_slope_look_axis_2)
+
+ if debug_plots:
+ plot_scatter_heat_map(
+ M_coords_xy_align_1, M_slopes_xy_align_1, set_label="Set_1_Mir_XY_Aligned", view_tup=(90, 270), render=True
+ )
+ add_3d_axes_to_plot(
+ M_coords_xy_align_1[0], M_xyz_axis, set_label="Set_1_Mir_XY_Aligned", view_tup=(90, 270), render=True
+ )
+ add_3d_axes_to_plot(
+ M_coords_xy_align_1[0],
+ M_xyz_axis_rrr_1,
+ color_sequence=["black", "gray", "purple"],
+ arrow_length=3,
+ set_label="Set_1_Mir_XY_Aligned",
+ view_tup=(90, 270),
+ output_dir=output_dir,
+ render=False,
+ )
+
+ plot_scatter_heat_map(
+ M_coords_xy_align_2, M_slopes_xy_align_2, set_label="Set_2_Mir_XY_Aligned", view_tup=(90, 270), render=True
+ )
+ add_3d_axes_to_plot(
+ M_coords_xy_align_2[0], M_xyz_axis, set_label="Set_2_Mir_XY_Aligned", view_tup=(90, 270), render=True
+ )
+ add_3d_axes_to_plot(
+ M_coords_xy_align_2[0],
+ M_xyz_axis_rrr_2,
+ color_sequence=["black", "gray", "purple"],
+ arrow_length=3,
+ set_label="Set_2_Mir_XY_Aligned",
+ view_tup=(90, 270),
+ output_dir=output_dir,
+ render=False,
+ )
+
+ '''
+
+ final_xy_align_rot_obj_1 = axis_aligned_mirror_points(
+ M_coords_xy_centered_1, angular_tol=0.05, refine=True, refine_span_deg=1, refine_step_deg=0.01
+ )
+ final_xy_align_rot_obj_2 = axis_aligned_mirror_points(
+ M_coords_xy_centered_2, angular_tol=0.05, refine=True, refine_span_deg=1, refine_step_deg=0.01
+ )
+
+ ctrl_xy_align_final_1 = apply_rt_to_control(ctrl_xy_1, final_xy_align_rot_obj_1, None)
+ M_coords_xy_final_1 = final_xy_align_rot_obj_1.apply(M_coords_xy_centered_1)
+ M_slopes_xy_final_1 = final_xy_align_rot_obj_1.apply(M_slope_look_axis_1)
+
+ ctrl_xy_align_final_2 = apply_rt_to_control(ctrl_xy_2, final_xy_align_rot_obj_2, None)
+ M_coords_xy_final_2 = final_xy_align_rot_obj_2.apply(M_coords_xy_centered_2)
+ M_slopes_xy_final_2 = final_xy_align_rot_obj_2.apply(M_slope_look_axis_2)
+
+ if debug_plots:
+ plot_scatter_heat_map(
+ M_coords_xy_final_1,
+ M_slopes_xy_final_1,
+ set_label="Set_1_Mir_XY_Align_Final",
+ view_tup=(90, 270),
+ render=False,
+ output_dir=output_dir,
+ )
+ plot_scatter_heat_map(
+ M_coords_xy_final_2,
+ M_slopes_xy_final_2,
+ set_label="Set_2_Mir_XY_Align_Final",
+ view_tup=(90, 270),
+ render=False,
+ output_dir=output_dir,
+ )
+
+ set_1_results = {
+ "slopes_rotation_combined": final_xy_align_rot_obj_1 * M_look_axis_rot_1,
+ "coords_rotation_combined": final_xy_align_rot_obj_1 * M_coords_xy_rot_1 * M_look_axis_rot_1,
+ "coords_translation_combined": -1 * np.mean(M_coords_look_axis_1, axis=0)
+ + -1 * -np.mean(M_coords_xy_1, axis=0),
+ "coords": M_coords_xy_final_1,
+ "slopes": M_slopes_xy_final_1,
+ }
+ lbt.write_compressed_json(set_1_results, ft.join(output_dir, "set_1_final_RT.json.gz"))
+ # foo = lbt.read_compressed_json(ft.join(output_dir, "set_1_final_RT.json.gz"))
+ set_2_results = {
+ "slopes_rotation_combined": final_xy_align_rot_obj_2 * M_look_axis_rot_2,
+ "coords_rotation_combined": final_xy_align_rot_obj_2 * M_coords_xy_rot_2 * M_look_axis_rot_2,
+ "coords_translation_combined": -1 * np.mean(M_coords_look_axis_2, axis=0)
+ + -1 * -np.mean(M_coords_xy_2, axis=0),
+ "coords": M_coords_xy_final_2,
+ "slopes": M_slopes_xy_final_2,
+ }
+ lbt.write_compressed_json(set_2_results, ft.join(output_dir, "set_2_final_RT.json.gz"))
+
+ sofast_plotting(output_directory=ft.join(output_dir, "set_1"), solution_set=set_1_results)
+ sofast_plotting(output_directory=ft.join(output_dir, "set_2"), solution_set=set_2_results)
+
+ print("mirror plotting....")
+
+ return set_1_results, set_2_results
+
+
+def _triangle_normal(pts3):
+ """Return unit normal of triangle (p0,p1,p2) using right-hand rule."""
+ p0, p1, p2 = np.asarray(pts3, float)
+ n = np.cross(p1 - p0, p2 - p0)
+ nn = np.linalg.norm(n)
+ if nn == 0:
+ raise ValueError("Degenerate control triangle (zero area).")
+ return n / nn
+
+
+def _ensure_normal_points_toward_camera(tri_pts, normal, camera_origin=np.zeros(3)):
+ """
+ Ensure normal points from triangle toward camera_origin.
+ If not, swap p1/p2 (flips winding) and invert normal.
+ """
+ tri_pts = np.asarray(tri_pts, float)
+ normal = np.asarray(normal, float)
+
+ centroid = tri_pts.mean(axis=0)
+ to_cam = camera_origin - centroid # vector from triangle to camera
+ if np.dot(normal, to_cam) < 0: # normal points away; flip it
+ tri_pts = tri_pts.copy()
+ tri_pts[[1, 2]] = tri_pts[[2, 1]]
+ normal = -normal
+ return tri_pts, normal
+
+
+def estimate_pixel_to_camera_coords_3D(camera, data_dict, ref_distance, rot_obj, t_vec):
+ cam_coords = []
+ K_inv = np.linalg.inv(camera.intrinsic_mat)
+ for pixel, details in data_dict.items():
+ if isinstance(details, dict):
+ if details["intersection_1"].size > 0:
+ row, col = ast.literal_eval(pixel)
+ normalized_point = K_inv @ np.array([col, row, 1])
+ point_camera = normalized_point * ref_distance
+ C_point = rot_obj.as_matrix().T @ (point_camera - t_vec.data.T).T
+ data_dict[pixel]["C_point_location"] = C_point.T
+ cam_coords.append(data_dict[pixel]["C_point_location"])
+ else:
+ continue
+ else:
+ pass
+
+ c_pt_min = np.min(np.array(cam_coords), axis=0).reshape(3)
+ c_pt_max = np.max(np.array(cam_coords), axis=0).reshape(3)
+ c_pt_avg = np.mean(np.array(cam_coords), axis=0).reshape(3)
+
+ control_pts = np.array(
+ [
+ [c_pt_min[0], c_pt_min[1], c_pt_avg[2] / 2],
+ [c_pt_max[0], c_pt_max[1], c_pt_avg[2] / 2],
+ [c_pt_min[0], c_pt_max[1], c_pt_avg[2] / 2],
+ ]
+ )
+ ctrl_n = _triangle_normal(control_pts)
+ control_pts, ctrl_n = _ensure_normal_points_toward_camera(control_pts, ctrl_n)
+
+ control = {"points": control_pts, "normal": ctrl_n}
+
+ return data_dict, control
+
+
+def apply_rt_to_control(control, rot=None, trans=None):
+ """
+ Apply rotation (and optional translation) to control triangle.
+ trans: (3,) translation applied as p' = rot.apply(p + trans) or p' = rot.apply(p) + trans,
+ depending on your convention. Here we use p' = rot.apply(p) + trans (standard).
+ """
+ pts = np.asarray(control["points"], float)
+ n = np.asarray(control["normal"], float)
+ n_r = None
+
+ if rot is not None:
+ pts_r = rot.apply(pts)
+ n_r = rot.apply(n)
+
+ if trans is not None:
+ trans = np.asarray(trans, float).reshape(3)
+ pts_r = pts_r + trans # normals do NOT translate
+
+ return {"points": pts_r, "normal": n_r if n_r is not None else n}
+
+
+def add_3d_axes_to_plot(
+ point,
+ xyz_vec_array,
+ color_sequence=None,
+ arrow_length=2,
+ set_label="added_3d_axes",
+ view_tup=None,
+ output_dir=None,
+ render=False,
+):
+ if output_dir:
+ ft.create_directories_if_necessary(output_dir)
+ # Access the current figure and cycle through each axis
+ fig = plt.gcf()
+ for ax in fig.get_axes():
+ if isinstance(ax, Axes3D):
+ # Plot arrows using quiver
+ if color_sequence:
+ for index, color in enumerate(color_sequence):
+ ax.quiver(
+ point[0],
+ point[1],
+ point[2],
+ xyz_vec_array[index, 0],
+ xyz_vec_array[index, 1],
+ xyz_vec_array[index, 2],
+ color=color,
+ length=arrow_length,
+ normalize=True,
+ )
+ else:
+ for index, color in enumerate(["r", "g", "b"]):
+ ax.quiver(
+ point[0],
+ point[1],
+ point[2],
+ xyz_vec_array[index, 0],
+ xyz_vec_array[index, 1],
+ xyz_vec_array[index, 2],
+ color=color,
+ length=arrow_length,
+ normalize=True,
+ )
+ if output_dir:
+ if view_tup:
+ file_path = ft.join(output_dir, set_label + f"_el{view_tup[0]}_az{view_tup[1]}.png")
+ else:
+ file_path = ft.join(output_dir, set_label + ".png")
+ plt.savefig(file_path)
+ if not render:
+ plt.close()
+
+
+def add_vector_to_plot(
+ point,
+ xyz_vec_array,
+ color,
+ arrow_length=2,
+ set_label="added_3d_vector",
+ view_tup=None,
+ output_dir=None,
+ render=False,
+):
+ if output_dir:
+ ft.create_directories_if_necessary(output_dir)
+ # Access the current figure and cycle through each axis
+ fig = plt.gcf()
+ for ax in fig.get_axes():
+ if isinstance(ax, Axes3D):
+ # Plot arrows using quiver
+ ax.quiver(
+ point[0],
+ point[1],
+ point[2],
+ xyz_vec_array[0],
+ xyz_vec_array[1],
+ xyz_vec_array[2],
+ color=color,
+ length=arrow_length,
+ normalize=True,
+ )
+
+ if output_dir:
+ if view_tup:
+ file_path = ft.join(output_dir, set_label + f"_el{view_tup[0]}_az{view_tup[1]}.png")
+ else:
+ file_path = ft.join(output_dir, set_label + ".png")
+ plt.savefig(file_path)
+ if not render:
+ plt.close()
+
+
+def plot_pixel_camera_and_mirror_coords(data_dict, skip_num=50, view_tup=None, output_dir=None, render=False):
+ if output_dir:
+ ft.create_directories_if_necessary(output_dir)
+ cam_coords, mir_coords = [], []
+ for pixel, details in data_dict.items():
+ if isinstance(details, dict):
+ if details["intersection_1"].size > 0:
+ cam_coords.append(data_dict[pixel]["C_point_location"])
+ mir_coords.append(data_dict[pixel]["M_point_location"])
+ else:
+ continue
+ else:
+ pass
+
+ cam_coords = np.array(cam_coords).squeeze(axis=1)
+ mir_coords = np.array(mir_coords).squeeze(axis=1)
+ fig = plt.figure(figsize=plt.figaspect(0.5))
+ ax1 = fig.add_subplot(1, 2, 1, projection='3d')
+ ax2 = fig.add_subplot(1, 2, 2, projection='3d')
+
+ # skip_num = 50
+ ax1.scatter(cam_coords[::skip_num, 0], cam_coords[::skip_num, 1], cam_coords[::skip_num, 2], c='k', s=1)
+ ax1.set_xlabel('X axis Camera')
+ ax1.set_ylabel('Y axis Camera')
+ ax1.set_zlabel('Z axis Camera')
+ ax1.set_title("Camera Coordinates")
+ if view_tup:
+ ax1.view_init(elev=view_tup[0], azim=view_tup[1])
+
+ ax2.scatter(mir_coords[::skip_num, 0], mir_coords[::skip_num, 1], mir_coords[::skip_num, 2], c='g', s=1)
+ ax2.set_xlabel('X axis Mirror')
+ ax2.set_ylabel('Y axis Mirror')
+ ax2.set_zlabel('Z axis Mirror')
+ ax2.set_title("Mirror Coordinates")
+ if view_tup:
+ ax2.view_init(elev=view_tup[0], azim=view_tup[1])
+
+ if output_dir:
+ if view_tup:
+ file_path = ft.join(output_dir, "projected_coordinates" + f"_el{view_tup[0]}_az{view_tup[1]}.png")
+ else:
+ file_path = ft.join(output_dir, "projected_coordinates" + ".png")
+ plt.savefig(file_path)
+ if not render:
+ plt.close()
+
+
+def transform_to_xy_plane(points, z_orientation=1, reference_normal=None):
+ points = np.asarray(points)
+ if points.shape[0] < 3:
+ raise ValueError("At least three points are required to define a plane.")
+
+ v1 = points[1] - points[0]
+ v2 = points[2] - points[0]
+ normal = np.cross(v1, v2)
+ normal = normal / np.linalg.norm(normal)
+
+ z_axis = np.array([0, 0, z_orientation])
+
+ # If provided, ensure chosen normal has desired relationship to reference_normal.
+ if reference_normal is not None:
+ reference_normal = np.asarray(reference_normal, float)
+ # Choose between normal and -normal
+ if np.dot(normal, reference_normal) < 0:
+ normal = -normal
+
+ rotation, _ = rotation_matrix_scipy(normal, z_axis)
+ return rotation
+
+
+'''
+def axis_aligned_mirror_points(points_xyz, angular_tol, step_size):
+ points_og = np.array(points_xyz)
+ points_og_xy = np.array(points_og[:, 0:2])
+
+ rect = cv2.minAreaRect(np.float32(points_og_xy))
+ box = cv2.boxPoints(rect)
+ # In test, points of box came out in CCW starting with the top left
+
+ vec_v_1 = box[0] - box[1]
+ vec_v_2 = box[3] - box[2]
+
+ vec_h_1 = box[2] - box[1]
+ vec_h_2 = box[3] - box[0]
+
+ rot_obj_v_1, _ = rotation_matrix_scipy(np.append(vec_v_1, 0), np.array([0, 1, 0]))
+ rot_obj_v_2, _ = rotation_matrix_scipy(np.append(vec_v_2, 0), np.array([0, 1, 0]))
+ rot_obj_h_1, _ = rotation_matrix_scipy(np.append(vec_h_1, 0), np.array([1, 0, 0]))
+ rot_obj_h_2, _ = rotation_matrix_scipy(np.append(vec_h_2, 0), np.array([1, 0, 0]))
+
+ obj_list = [rot_obj_v_1, rot_obj_v_2, rot_obj_h_1, rot_obj_h_2]
+ min_rot = None
+ test_pts = np.empty_like(points_og)
+ for index, thing in enumerate(obj_list):
+ test_pts = thing.apply(points_og)
+ rect_test = cv2.minAreaRect(np.float32(test_pts[:, 0:2]))
+
+ if min_rot is None:
+ min_rot = (rect_test[2], index)
+ else:
+ if rect_test[2] < min_rot[0]:
+ min_rot = (rect_test[2], index)
+
+ rot_angle = min_rot[0]
+ refine_obj = obj_list[min_rot[1]]
+ foo = refine_obj.as_rotvec()
+ while abs(rot_angle) > angular_tol:
+
+ foo_up = np.array([foo[0], foo[1], foo[2] + step_size])
+ foo_down = np.array([foo[0], foo[1], foo[2] - step_size])
+
+ foo_up = Rotation.from_rotvec(foo_up)
+ foo_down = Rotation.from_rotvec(foo_down)
+
+ refine_up = foo_up.apply(points_og)
+ refine_down = foo_down.apply(points_og)
+
+ refine_up_rect = cv2.minAreaRect(np.float32(refine_up[:, 0:2]))
+ refine_down_rect = cv2.minAreaRect(np.float32(refine_down[:, 0:2]))
+
+ min_index = np.argmin(np.array([abs(refine_up_rect[2]), abs(refine_down_rect[2])]))
+
+ if min_index == 0:
+ rot_angle = refine_up_rect[2]
+ foo = foo_up
+ elif min_index == 1:
+ rot_angle = refine_down_rect[2]
+ foo = foo_down
+ else:
+ break
+
+ if isinstance(foo, np.ndarray):
+ rot_obj = Rotation.from_rotvec(foo)
+ return rot_obj
+ elif isinstance(foo, Rotation):
+ return foo
+ else:
+ raise ValueError("Output was not the correct data type... Need to Debug")
+'''
+
+
+def _wrap_to_90(deg):
+ """Map angle (deg) to [-45, 45] by exploiting 90-deg symmetry."""
+ return ((deg + 45.0) % 90.0) - 45.0
+
+
+def _rect_yaw_deg(points_xy):
+ """
+ Compute a robust yaw (deg) that would axis-align the minAreaRect of points_xy.
+ Returns yaw in degrees to ROTATE points by (about +Z) to align.
+ """
+ rect = cv2.minAreaRect(points_xy.astype(np.float32))
+ (cx, cy), (w, h), a = rect # a is in degrees, OpenCV convention
+
+ # OpenCV convention: angle is the rotation of the rectangle's width side
+ # but varies by version; a is typically in [-90, 0).
+ # If width < height, the "width side" is actually the short side; adjust by 90.
+ if w < h:
+ a = a + 90.0
+
+ # We want to rotate by -a to align the long side with +X (or nearest axis)
+ yaw = -a
+
+ # Because aligning to X or Y is equivalent up to 90 degrees, reduce ambiguity:
+ yaw = _wrap_to_90(yaw)
+ return yaw
+
+
+def _objective_abs_angle(points_xy):
+ """Objective: |minAreaRect angle| after wrapping to nearest axis."""
+ rect = cv2.minAreaRect(points_xy.astype(np.float32))
+ (_, _), (w, h), a = rect
+ if w < h:
+ a = a + 90.0
+ # a is angle of long side relative to +X (after adjustment)
+ # We want that angle near 0 (axis-aligned), modulo 90.
+ return abs(_wrap_to_90(a))
+
+
+def axis_aligned_mirror_points(points_xyz, angular_tol=0.25, refine=True, refine_span_deg=2.0, refine_step_deg=0.05):
+ """
+ Robustly compute a Rotation that (approximately) yaw-rotates points so their
+ min-area bounding rectangle becomes axis-aligned in XY.
+
+ Parameters
+ ----------
+ points_xyz : (N,3) array-like
+ angular_tol : float
+ Tolerance in degrees for final rectangle misalignment (after wrap-to-90).
+ refine : bool
+ If True, do a local 1D search around the closed-form yaw for extra robustness.
+ refine_span_deg : float
+ Half-span of local search window in degrees.
+ refine_step_deg : float
+ Step size (deg) for local search.
+
+ Returns
+ -------
+ scipy.spatial.transform.Rotation
+ """
+ pts = np.asarray(points_xyz, dtype=float)
+ if pts.ndim != 2 or pts.shape[1] != 3:
+ raise ValueError("points_xyz must be an (N,3) array")
+
+ pts_xy = pts[:, :2]
+
+ # Closed-form yaw from minAreaRect
+ yaw0 = _rect_yaw_deg(pts_xy)
+ rot0 = Rotation.from_euler('z', yaw0, degrees=True)
+
+ if not refine:
+ return rot0
+
+ # Local search around yaw0 to minimize axis-misalignment objective
+ # (still avoids corner-order assumptions)
+ best_yaw = yaw0
+ best_val = _objective_abs_angle(rot0.apply(pts)[:, :2])
+
+ # If already good enough, stop
+ if best_val <= angular_tol:
+ return rot0
+
+ # 1D brute-force local search (simple + robust)
+ yaws = np.arange(yaw0 - refine_span_deg, yaw0 + refine_span_deg + 1e-12, refine_step_deg)
+ for yaw in yaws:
+ r = Rotation.from_euler('z', yaw, degrees=True)
+ val = _objective_abs_angle(r.apply(pts)[:, :2])
+ if val < best_val:
+ best_val = val
+ best_yaw = yaw
+ if best_val <= angular_tol:
+ break
+
+ return Rotation.from_euler('z', best_yaw, degrees=True)
+
+
+def sofast_plotting(output_directory, solution_set):
+ # dir_save_cur = os.path.join(output_directory, "lookfast_processed_data")
+ ft.create_directories_if_necessary(output_directory)
+
+ coords_centroid = np.mean(np.array(solution_set["coords"]), axis=0)
+ centered_coords = np.array(solution_set["coords"]) - coords_centroid
+ coords_centroid = np.mean(centered_coords, axis=0)
+
+ rect = cv2.minAreaRect(np.float32(centered_coords[:, 0:2]))
+ box = cv2.boxPoints(rect) # In test, points of box came out in CCW starting with the top left
+
+ expected_facet_corners = Vxy(
+ list(zip(box[3], box[2], box[1], box[0])), dtype=float
+ ) # Counterclockwise Starting from Top Right Corner in [row, column] SOFAST Example uses this convention
+ '''
+ coords_max = np.max(centered_coords, axis=0)
+ coords_min = np.min(centered_coords, axis=0)
+
+ expected_facet_corners = Vxy(
+ list(
+ zip(
+ [coords_max[0], coords_max[1]],
+ [coords_min[0], coords_max[1]],
+ [coords_min[0], coords_min[1]],
+ [coords_max[1], coords_min[1]],
+ )
+ ),
+ dtype=float,
+ ) # Counterclockwise Starting from Top Right Corner in [row, column] SOFAST Example uses this convention
+ '''
+
+ loop = LoopXY.from_vertices(expected_facet_corners)
+ region = RegionXY(loop)
+
+ # Get measured and reference optics
+ # mirror_measured = sofast.get_optic().mirror.no_parent_copy()
+ mirror_measured = MirrorPoint(
+ surface_points=Pxyz(centered_coords.T),
+ normal_vectors=Uxyz(solution_set["slopes"].T),
+ shape=region,
+ interpolation_type="nearest",
+ )
+
+ mirror_reference = MirrorParametric.generate_symmetric_paraboloid(100, mirror_measured.region)
+
+ # Save optic objects
+ plots = StandardPlotOutput()
+ plots.optic_measured = mirror_measured # MirrorPoint object
+ plots.optic_reference = mirror_reference
+
+ plots.options_file_output.to_save = True
+ plots.options_file_output.number_in_name = False
+ plots.options_file_output.output_dir = output_directory
+ plots.options_file_output.save_dpi = 200
+ plots.options_file_output.save_format = "png"
+ plots.options_file_output.close_after_save = True
+
+ # Update visualization parameters
+ plots.options_slope_vis.to_plot = True
+ plots.options_slope_vis.clim = 20
+ plots.options_slope_vis.resolution = 0.01
+ plots.options_slope_vis.quiver_density = None # default 0.1
+ plots.options_slope_vis.quiver_scale = 3
+ plots.options_slope_vis.quiver_color = "white"
+
+ plots.options_slope_deviation_vis.to_plot = True
+ plots.options_slope_deviation_vis.clim = 1.5
+ plots.options_slope_deviation_vis.resolution = 0.01
+ plots.options_slope_deviation_vis.quiver_density = None # default 0.1
+ plots.options_slope_deviation_vis.quiver_scale = 3
+ plots.options_slope_deviation_vis.quiver_color = "white"
+
+ plots.options_curvature_vis.to_plot = True
+ plots.options_curvature_vis.clim = 50
+ plots.options_curvature_vis.resolution = 0.001
+ # plots.options_curvature_vis.processing = # Leave Default for now
+ plots.options_curvature_vis.smooth_kernel_width = 1
+
+ plots.options_ray_trace_vis.to_plot = True
+ plots.options_ray_trace_vis.ray_trace_optic_res = 0.05
+ plots.options_ray_trace_vis.hist_bin_res = 0.07
+ plots.options_ray_trace_vis.hist_extent = 3
+ plots.options_ray_trace_vis.enclosed_energy_max_semi_width = 1
+
+ # Define viewing/illumination geometry
+ v_target_center = Vxyz((0, 0, 100))
+ v_target_normal = Vxyz((0, 0, -1))
+ source = LightSourceSun.from_given_sun_position(Uxyz((0, 0, -1)), resolution=40)
+
+ # Define ray trace parameters
+ plots.params_ray_trace.source = source
+ plots.params_ray_trace.v_target_center = v_target_center
+ plots.params_ray_trace.v_target_normal = v_target_normal
+
+ # Create standard output plots
+ plots.plot()
+
+
+def camera_and_pixel_pointing(cam_obj, light_mask_path):
+
+ ##### reading in masks for light, dark, and all pixels
+ light_image = cv2.imread(light_mask_path, cv2.IMREAD_GRAYSCALE)
+ dark_image = np.zeros(shape=light_image.shape, dtype=np.uint8)
+ all_pixels = np.ones(shape=light_image.shape, dtype=bool)
+
+ ##### calculate pixel pointing vectors for all pixels
+ pixel_pointing = imgp.calculate_active_pixels_vectors(mask=all_pixels, camera=cam_obj)
+
+ ##### assigning 9 pixel pointing vectors as the corners, edge midpoints and center to show camera model alignment
+ pyramid_pixel_vectors = [
+ pixel_pointing[int(0 * light_image.shape[1] + 0)],
+ pixel_pointing[int(0 * light_image.shape[1] + light_image.shape[1] / 2)],
+ pixel_pointing[int(0 * light_image.shape[1] + light_image.shape[1]) - 1],
+ pixel_pointing[int((light_image.shape[0] / 2) * light_image.shape[1] + 0)],
+ pixel_pointing[int((light_image.shape[0] / 2) * light_image.shape[1] + light_image.shape[1] / 2)],
+ pixel_pointing[int((light_image.shape[0] / 2) * light_image.shape[1] + light_image.shape[1]) - 1],
+ pixel_pointing[int((light_image.shape[0] - 1) * light_image.shape[1] + 0)],
+ pixel_pointing[int((light_image.shape[0] - 1) * light_image.shape[1] + light_image.shape[1] / 2)],
+ pixel_pointing[int((light_image.shape[0] - 1) * light_image.shape[1] + light_image.shape[1]) - 1],
+ ]
+
+ ##### setting initial mask location
+ mask_raw = imgp.calc_mask_raw(
+ np.concatenate((dark_image[:, :, np.newaxis], light_image[:, :, np.newaxis]), axis=2),
+ hist_thresh=0.5,
+ filt_width=9,
+ filt_thresh=4,
+ thresh_active_pixels=0.01,
+ )
+ mask = imgp.keep_largest_mask_area(mask_raw)
+ v_mask_centroid_image = imgp.centroid_mask(mask)
+ v_edges_image = imgp.edges_from_mask(mask)
+
+ # mask_image = mask.astype(np.uint8) * 255
+
+ mask_pts = np.array(np.where(mask), dtype=np.float32)
+
+ mask_rect = cv2.minAreaRect(mask_pts.T)
+ mask_corners = cv2.boxPoints(mask_rect) # provided in (row, column pairs) ≈ (y, x) CCW from top left
+
+ ##### setting expected corners in mirror coordinates
+ expected_corners_facet_coords_manual = Vxyz(
+ list(zip([0.606, 0.606, 0], [-0.606, 0.606, 0], [-0.606, -0.606, 0], [0.606, -0.606, 0])), dtype=float
+ ) # Counterclockwise Starting from Top Right Corner in [row, column] SOFAST Example uses this convention
+
+ ##### setting expected corners
+ '''
+ expected_corners_manual = Vxy(
+ list(zip([860, 444], [840, 643], [1047, 657], [1062, 455])), dtype=int
+ ) # Counterclockwise Starting from Bottom Left Corner in [row, column]
+ '''
+ expected_corners_manual = Vxy(
+ list(zip(mask_corners[1][::-1], mask_corners[2][::-1], mask_corners[3][::-1], mask_corners[0][::-1])),
+ dtype=float,
+ ) # Counterclockwise Starting from Bottom Left Corner in [row, column]
+
+ ##### refine corners
+ v_corners_image = imgp.refine_facet_corners(
+ Puv_facet_corns_exp=expected_corners_manual,
+ Puv_cent=v_mask_centroid_image,
+ Puv_edges=v_edges_image,
+ step=20,
+ d_perp=20,
+ frac_keep=1,
+ )
+
+ ##### estimate camera pose from refined pixel corners and expected mirror coordinate corners
+ r_optic_cam_refine_1, v_cam_optic_cam_refine_1 = sp.calc_rt_from_img_pts(
+ pts_image=v_corners_image.vertices, pts_object=expected_corners_facet_coords_manual, camera=cam_obj
+ )
+
+ return pixel_pointing, r_optic_cam_refine_1, v_cam_optic_cam_refine_1, mask
+
+
+def main(
+ camera_obj,
+ light_mask_path,
+ vec_data_path,
+ reference_distance_m,
+ reference_pixel_key,
+ output_directory,
+ checkpoint_directory,
+ checkpoint_file,
+):
+
+ # Load checkpoint data and plots
+ checkpoint_data = lbt.load_checkpoint(checkpoint_directory, checkpoint_file)
+ if checkpoint_data is None:
+ checkpoint_data = {"Lookfast_RT_Alignment": []}
+
+ pixel_pointing, r_optic_cam_refine_1, v_cam_optic_cam_refine_1, mask = camera_and_pixel_pointing(
+ camera_obj, light_mask_path
+ )
+ vector_data = lbt.read_compressed_json(vec_data_path)
+
+ ##### estimate mirror coordinates in 3D space based on pose estimation
+ vector_data, control_pts_cam = estimate_pixel_to_camera_coords_3D(
+ camera_obj,
+ vector_data,
+ ref_distance=reference_distance_m,
+ rot_obj=r_optic_cam_refine_1,
+ t_vec=v_cam_optic_cam_refine_1,
+ )
+
+ ##### pick arbitraty pixel for a horizon reference vector
+ reference_vector_horizon = Uxyz(vector_data[reference_pixel_key]["observer_vector"] * -1)
+
+ oa_row, oa_col = ast.literal_eval(reference_pixel_key)
+
+ temp_vec = get_pixel_pointing_vector(
+ pixel_directions=pixel_pointing, row=oa_row, col=oa_col, imagewidth=mask.shape[1]
+ )
+ cam_vec_reference = Uxyz(temp_vec)
+
+ ##### first rotation from "optical axis pointing vector" and reference observer_to_optic_h vector
+ rot_obj_no_roll, rssd = rotation_matrix_scipy(
+ np.array([cam_vec_reference.x[0], cam_vec_reference.y[0], cam_vec_reference.z[0]]),
+ np.array([reference_vector_horizon.x[0], reference_vector_horizon.y[0], reference_vector_horizon.z[0]]),
+ )
+
+ ##### apply first rotation, resulting in an arbitrary rotation of x and y axis about cam optical axis / horizonal reference vector
+ cam_x_axis = Uxyz(np.array([1, 0, 0]))
+ cam_y_axis = Uxyz(np.array([0, 1, 0]))
+ cam_xyz_t = rot_obj_no_roll.apply(
+ np.array([cam_x_axis.data, cam_y_axis.data, cam_vec_reference.data]).reshape(3, 3)
+ ) # The original camera vector that corresponded to the horizonal reference vectors are aligned.
+
+ ##### binary search to find which additional rotation about rotated camera optical axis (now in horizonal coordinates)
+ ##### results in the x axis of transformed camera coordinates to have a minimal z-component. i.e. x axis of new camera coordinates in the horizonal XY plane
+ ##### this assumes that the orientation of the data collection is landscape mode. Function needs to be updated if changing to portrait orientation.
+ '''
+ roll_control_angle = binary_search_angle(
+ vector=cam_xyz_t[0], axis=cam_xyz_t[2], function=rotate_vector, tolerance=1e-6, max_iterations=1000
+ )
+ roll_control_angle = (180 - roll_control_angle) * -1
+
+ roll_control_obj = create_rotation_object(axis=cam_xyz_t[2], angle_degrees=roll_control_angle)
+ '''
+
+ roll_control_angle, roll_control_obj, dbg_info = select_rotation_with_direction_check(
+ vector_to_minimize=cam_xyz_t[0],
+ axis=cam_xyz_t[2],
+ component_index=2,
+ reference_vector=cam_xyz_t[1],
+ reference_component_index=2,
+ desired_reference_sign=-1,
+ )
+
+ ##### apply the roll control roation
+ # The rotation to align the transformed camera-to-horizonal axis vectors such that direction of the x component transformed camera-to-horizonal set has a minimized z component.
+ # i.e. The x component of that vector (now in the horizonal coordinate system) must lie in the plane created by the X and Y horizonal vectors (East-West and North South)
+ cam_xyz_tr = roll_control_obj.apply(cam_xyz_t)
+
+ ##### combine rotation objects for camera to horizonal coordinates and camera to mirror coordinates transform
+ cam_horizon_transform = roll_control_obj * rot_obj_no_roll
+
+ cam_horizon_pose_transform = roll_control_obj * rot_obj_no_roll * r_optic_cam_refine_1
+
+ ##### extract data, apply sets of rotations (inverse) to convert horizonal data to camera coordinates and camera coordinates to mirror coordinates
+ for pixel, details in vector_data.items():
+ if isinstance(details, dict):
+ if details["intersection_1"].size > 0:
+ row, col = ast.literal_eval(pixel)
+ cam_vec_original = get_pixel_pointing_vector(
+ pixel_directions=pixel_pointing, row=row, col=col, imagewidth=mask.shape[1]
+ )
+ cam_vec_horizon = cam_horizon_transform.apply(cam_vec_original.reshape(3))
+ _, slope_1_corr, slope_2_corr = safe_calculate_slope(
+ cam_vec_horizon.reshape(3) * -1,
+ cam_vec_horizon.reshape(3) * -1,
+ details["intersection_1"],
+ details["intersection_2"],
+ )
+ angle_between = calculate_angle_between_vectors(
+ details['start_vector']['celestial_to_target'], details['end_vector']['celestial_to_target']
+ )
+
+ vector_data[pixel]["angle_between"] = angle_between
+ vector_data[pixel]["observer_vector_camera_corrected"] = cam_vec_horizon
+ vector_data[pixel]["H_slope_1_camera_corrected"] = slope_1_corr
+ vector_data[pixel]["H_slope_2_camera_corrected"] = slope_2_corr
+ vector_data[pixel]["C_slope_1_camera_corrected"] = cam_horizon_transform.inv().apply(slope_1_corr)
+ vector_data[pixel]["C_slope_2_camera_corrected"] = cam_horizon_transform.inv().apply(slope_2_corr)
+ vector_data[pixel]["M_point_location"] = (
+ r_optic_cam_refine_1.inv().apply(vector_data[pixel]["C_point_location"])
+ - v_cam_optic_cam_refine_1.data.T
+ )
+ vector_data[pixel]["M_slope_1_camera_corrected"] = r_optic_cam_refine_1.inv().apply(
+ vector_data[pixel]["C_slope_1_camera_corrected"]
+ )
+ vector_data[pixel]["M_slope_2_camera_corrected"] = r_optic_cam_refine_1.inv().apply(
+ vector_data[pixel]["C_slope_2_camera_corrected"]
+ )
+ else:
+ continue
+ else:
+ pass
+
+ ##### take extracted coords and slopes, rotate and align with mirror coordinate system and feed to sofast plotting
+ if "horizonal_og_cam_corrected" not in checkpoint_data["Lookfast_RT_Alignment"]:
+ plot_slope_heat_maps_horizonal(data_dict=vector_data, output_dir=ft.join(output_directory, "horz"))
+ checkpoint_data["Lookfast_RT_Alignment"].append("horizonal_og_cam_corrected")
+ lbt.save_checkpoint(checkpoint_directory, checkpoint_file, checkpoint_data, print_path=False)
+
+ if "camera_og_cam_corrected" not in checkpoint_data["Lookfast_RT_Alignment"]:
+ plot_slope_heat_maps_camera(data_dict=vector_data, output_dir=ft.join(output_directory, "cam"))
+ checkpoint_data["Lookfast_RT_Alignment"].append("camera_og_cam_corrected")
+ lbt.save_checkpoint(checkpoint_directory, checkpoint_file, checkpoint_data, print_path=False)
+
+ if "mirror_og_cam_corrected" not in checkpoint_data["Lookfast_RT_Alignment"]:
+ plot_slope_heat_maps_mirror(data_dict=vector_data, output_dir=ft.join(output_directory, "mir"))
+ checkpoint_data["Lookfast_RT_Alignment"].append("mirror_og_cam_corrected")
+ lbt.save_checkpoint(checkpoint_directory, checkpoint_file, checkpoint_data, print_path=False)
+
+ if "horizonal_cam_corrected_zenith_adjust" not in checkpoint_data["Lookfast_RT_Alignment"]:
+ plot_heat_maps_horizonal_looking_up(vector_data, output_dir=ft.join(output_directory, "horz_zen_adj"))
+ checkpoint_data["Lookfast_RT_Alignment"].append("horizonal_cam_corrected_zenith_adjust")
+ lbt.save_checkpoint(checkpoint_directory, checkpoint_file, checkpoint_data, print_path=False)
+
+ if "projected_cam_mirror_coordinates" not in checkpoint_data["Lookfast_RT_Alignment"]:
+ plot_pixel_camera_and_mirror_coords(
+ vector_data, skip_num=50, output_dir=ft.join(output_directory, "proj_coords")
+ )
+ checkpoint_data["Lookfast_RT_Alignment"].append("projected_cam_mirror_coordinates")
+ lbt.save_checkpoint(checkpoint_directory, checkpoint_file, checkpoint_data, print_path=False)
+
+ if "camera_RT_align" not in checkpoint_data["Lookfast_RT_Alignment"]:
+ cam_solution_set_1, cam_solution_set_2 = plot_heat_maps_camera_looking_up_coords(
+ vector_data, control_pts_cam, output_dir=ft.join(output_directory, "camera_RT_align"), debug_plots=True
+ )
+ lbt.write_compressed_json(
+ data=cam_solution_set_1,
+ file_path=ft.join(output_directory, "camera_RT_align", "cam_solution_set_1.json.gz"),
+ )
+ lbt.write_compressed_json(
+ data=cam_solution_set_2,
+ file_path=ft.join(output_directory, "camera_RT_align", "cam_solution_set_2.json.gz"),
+ )
+ checkpoint_data["Lookfast_RT_Alignment"].append("camera_RT_align")
+ lbt.save_checkpoint(checkpoint_directory, checkpoint_file, checkpoint_data, print_path=False)
+
+ if "mirror_RT_align" not in checkpoint_data["Lookfast_RT_Alignment"]:
+ mir_solution_set_1, mir_solution_set_2 = plot_heat_maps_mirror_looking_up_coords(
+ vector_data, output_dir=ft.join(output_directory, "mirror_RT_align"), debug_plots=True
+ )
+ lbt.write_compressed_json(
+ data=mir_solution_set_1,
+ file_path=ft.join(output_directory, "mirror_RT_align", "mir_solution_set_1.json.gz"),
+ )
+ lbt.write_compressed_json(
+ data=mir_solution_set_2,
+ file_path=ft.join(output_directory, "mirror_RT_align", "mir_solution_set_2.json.gz"),
+ )
+ checkpoint_data["Lookfast_RT_Alignment"].append("mirror_RT_align")
+ lbt.save_checkpoint(checkpoint_directory, checkpoint_file, checkpoint_data, print_path=False)
+
+
+def main_original_script():
+ primary_folder = "//snl/Collaborative/NSTTF_Optics/Projects/_Directories/NSTTF_Optics_LookbackExEx/Experiments/2025-06_05_NsttfTunedFacetScan1dof/3_Post/DSC_0025_lookfast"
+ video_name = "DSC_0025.MOV"
+ checkpoint_folder = os.path.join(primary_folder, "0_checkpoints")
+ checkpoint_main_name = "lookback_main_checkpoint.json"
+
+ plotting = True
+ function_testing = True
+
+ ##### Setting camera object
+ # Sofast Example Camera Intrinsic matrix
+ K_intrin = np.array([[5492.064314084441, 0, 1920 / 2], [0, 5486.2706013814895, 1080 / 2], [0, 0, 1]])
+ # Sofast Example Camera Distortion coefficients
+ D_coeff = np.array([-0.144160742602367, 1.609744377391114, 2.503498158416561e-5, -0.001899042260179])
+
+ cam = Camera(
+ intrinsic_mat=K_intrin, distortion_coef=D_coeff, image_shape_xy=tuple[1920, 1080], name="Arbitrary_Example"
+ )
+
+ ##### reading in vector data
+ original_data_location = "//snl/Collaborative/NSTTF_Optics/Projects/_Directories/NSTTF_Optics_LookbackExEx/Experiments/2025-06_05_NsttfTunedFacetScan1dof/3_Post/DSC_0025_Final_ExEx/8_pixel_vector_information/debug/pixel_vector_information_wslope_debug.json.gz"
+ vector_data = lbt.read_compressed_json(original_data_location)
+
+ ##### pick arbitraty pixel for a horizon reference vector
+ reference_vector_horizon = Uxyz(vector_data["(500, 900)"]["observer_vector"] * -1)
+ reference_vector_horizon_camera = Uxyz(
+ [reference_vector_horizon.x[0], reference_vector_horizon.z[0] * -1, reference_vector_horizon.y[0]]
+ )
+
+ ##### reading in masks for light, dark, and all pixels
+ light_image = cv2.imread(os.path.join(primary_folder, "light_mask_test.png"), cv2.IMREAD_GRAYSCALE)
+ dark_image = cv2.imread(os.path.join(primary_folder, "dark_mask_test.png"), cv2.IMREAD_GRAYSCALE)
+ all_pixels = np.ones(shape=light_image.shape, dtype=bool)
+
+ ##### calculate pixel pointing vectors for all pixels
+ pixel_pointing = imgp.calculate_active_pixels_vectors(mask=all_pixels, camera=cam)
+ # pixel_pointing_2 = imgp.calculate_active_pixels_vectors(mask=all_pixels, camera=cam2)
+
+ ##### central pixel vector (with better camera model needs to pick optical axis not just the center)
+ central_pixel_vector = pixel_pointing[
+ int((light_image.shape[0] / 2) * light_image.shape[1]) + int(light_image.shape[1] / 2)
+ ]
+
+ ##### assigning 9 pixel pointing vectors as the corners, edge midpoints and center to show camera model alignment
+ pyramid_pixel_vectors = [
+ pixel_pointing[int(0 * light_image.shape[1] + 0)],
+ pixel_pointing[int(0 * light_image.shape[1] + light_image.shape[1] / 2)],
+ pixel_pointing[int(0 * light_image.shape[1] + light_image.shape[1]) - 1],
+ pixel_pointing[int((light_image.shape[0] / 2) * light_image.shape[1] + 0)],
+ pixel_pointing[int((light_image.shape[0] / 2) * light_image.shape[1] + light_image.shape[1] / 2)],
+ pixel_pointing[int((light_image.shape[0] / 2) * light_image.shape[1] + light_image.shape[1]) - 1],
+ pixel_pointing[int((light_image.shape[0] - 1) * light_image.shape[1] + 0)],
+ pixel_pointing[int((light_image.shape[0] - 1) * light_image.shape[1] + light_image.shape[1] / 2)],
+ pixel_pointing[int((light_image.shape[0] - 1) * light_image.shape[1] + light_image.shape[1]) - 1],
+ ]
+
+ ##### setting initial mask location
+ mask_raw = imgp.calc_mask_raw(
+ np.concatenate((dark_image[:, :, np.newaxis], light_image[:, :, np.newaxis]), axis=2),
+ hist_thresh=0.5,
+ filt_width=9,
+ filt_thresh=4,
+ thresh_active_pixels=0.01,
+ )
+ mask = imgp.keep_largest_mask_area(mask_raw)
+ v_mask_centroid_image = imgp.centroid_mask(mask)
+ v_edges_image = imgp.edges_from_mask(mask)
+
+ mask_image = mask.astype(np.uint8) * 255
+
+ ##### setting expected corners
+ expected_corners_manual = Vxy(
+ list(zip([860, 444], [840, 643], [1047, 657], [1062, 455])), dtype=int
+ ) # Counterclockwise Starting from Bottom Left Corner in [row, column]
+
+ ##### setting expected corners in mirror coordinates
+ expected_corners_facet_coords_manual = Vxyz(
+ list(zip([0.606, 0.606, 0], [-0.606, 0.606, 0], [-0.606, -0.606, 0], [0.606, -0.606, 0])), dtype=float
+ ) # Counterclockwise Starting from Top Right Corner in [row, column] SOFAST Example uses this convention
+
+ ##### refine corners
+ v_corners_image = imgp.refine_facet_corners(
+ Puv_facet_corns_exp=expected_corners_manual,
+ Puv_cent=v_mask_centroid_image,
+ Puv_edges=v_edges_image,
+ step=20,
+ d_perp=20,
+ frac_keep=1,
+ )
+
+ ##### estimate camera pose from refined pixel corners and expected mirror coordinate corners
+ r_optic_cam_refine_1, v_cam_optic_cam_refine_1 = sp.calc_rt_from_img_pts(
+ pts_image=v_corners_image.vertices, pts_object=expected_corners_facet_coords_manual, camera=cam
+ )
+
+ ##### estimate mirror coordinates in 3D space based on pose estimation
+ vector_data = estimate_pixel_to_camera_coords_3D(
+ cam, vector_data, ref_distance=99.94392, rot_obj=r_optic_cam_refine_1, t_vec=v_cam_optic_cam_refine_1
+ )
+
+ ##### first rotation from "optical axis pointing vector" and reference observer_to_optic_h vector
+ rot_obj_no_roll, rssd = rotation_matrix_scipy(
+ np.array([central_pixel_vector.x[0], central_pixel_vector.y[0], central_pixel_vector.z[0]]),
+ np.array([reference_vector_horizon.x[0], reference_vector_horizon.y[0], reference_vector_horizon.z[0]]),
+ )
+
+ ##### apply first rotation, resulting in an arbitrary rotation of x and y axis about cam optical axis / horizonal reference vector
+ cam_x_axis = Uxyz(np.array([1, 0, 0]))
+ cam_y_axis = Uxyz(np.array([0, 1, 0]))
+ cam_xyz_t = rot_obj_no_roll.apply(
+ np.array([cam_x_axis.data, cam_y_axis.data, central_pixel_vector.data]).reshape(3, 3)
+ ) # The original camera vector that corresponded to the horizonal reference vectors are aligned.
+
+ ##### binary search to find which additional rotation about rotated camera optical axis (now in horizonal coordinates)
+ ##### results in the x axis of transformed camera coordinates to have a minimal z-component. i.e. x axis of new camera coordinates in the horizonal XY plane
+ roll_control_angle = binary_search_angle(
+ vector=cam_xyz_t[0], axis=cam_xyz_t[2], function=rotate_vector, tolerance=1e-6, max_iterations=1000
+ )
+ roll_control_angle = (180 - roll_control_angle) * -1
+
+ roll_control_obj = create_rotation_object(axis=cam_xyz_t[2], angle_degrees=roll_control_angle)
+
+ ##### test first rotation for sanity check with function and output
+ if function_testing:
+
+ test_vectors = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 1, 1]])
+ test_output_x = []
+ test_output_y = []
+ test_output_z = []
+ for item in test_vectors:
+ test_output_x.append(rotate_vector(item, np.array([1, 0, 0]), degrees=90))
+ test_output_y.append(rotate_vector(item, np.array([0, 1, 0]), degrees=90))
+ test_output_z.append(rotate_vector(item, np.array([0, 0, 1]), degrees=90))
+ test_output_x.append(rotate_vector(item, np.array([1, 0, 0]), degrees=45))
+ test_output_y.append(rotate_vector(item, np.array([0, 1, 0]), degrees=45))
+ test_output_z.append(rotate_vector(item, np.array([0, 0, 1]), degrees=45))
+
+ ##### apply the roll control roation
+ # The rotation to align the transformed camera-to-horizonal axis vectors such that direction of the x component transformed camera-to-horizonal set has a minimized z component.
+ # i.e. The x component of that vector (now in the horizonal coordinate system) must lie in the plane created by the X and Y horizonal vectors (East-West and North South)
+ cam_xyz_tr = roll_control_obj.apply(cam_xyz_t)
+
+ ##### combine rotation objects for camera to horizonal coordinates and camera to mirror coordinates transform
+ cam_horizon_transform = roll_control_obj * rot_obj_no_roll
+
+ cam_horizon_pose_transform = roll_control_obj * rot_obj_no_roll * r_optic_cam_refine_1
+
+ ##### vector plot of original camera coords, first rotation applied, and roll control rotation applied
+ if plotting:
+ fig = plt.figure()
+ ax = fig.add_subplot(111, projection='3d')
+ vectors_data = [
+ [0, 0, 0, cam_x_axis.x[0], cam_x_axis.y[0], cam_x_axis.z[0], "Cam X Cam1", "r"],
+ [0, 0, 0, cam_y_axis.x[0], cam_y_axis.y[0], cam_y_axis.z[0], "Cam Y Cam1", "g"],
+ [
+ 0,
+ 0,
+ 0,
+ central_pixel_vector.x[0],
+ central_pixel_vector.y[0],
+ central_pixel_vector.z[0],
+ "Cam Z (Central Pixel Pointing) Cam1",
+ "b",
+ ],
+ [
+ 0,
+ 0,
+ 0,
+ reference_vector_horizon.x[0],
+ reference_vector_horizon.y[0],
+ reference_vector_horizon.z[0],
+ "Horizon Reference Horz",
+ "darkorange",
+ ],
+ [
+ 0,
+ 0,
+ 0,
+ reference_vector_horizon_camera.x[0],
+ reference_vector_horizon_camera.y[0],
+ reference_vector_horizon_camera.z[0],
+ "Horizon Reference Camera Cam1",
+ "tan",
+ ],
+ [
+ 0,
+ 0,
+ 0,
+ cam_xyz_t[0][0] * 0.75,
+ cam_xyz_t[0][1] * 0.75,
+ cam_xyz_t[0][2] * 0.75,
+ "Cam X Transformed Horz",
+ "maroon",
+ ],
+ [
+ 0,
+ 0,
+ 0,
+ cam_xyz_t[1][0] * 0.75,
+ cam_xyz_t[1][1] * 0.75,
+ cam_xyz_t[1][2] * 0.75,
+ "Cam Y Transformed Horz",
+ "lime",
+ ],
+ [
+ 0,
+ 0,
+ 0,
+ cam_xyz_t[2][0] * 0.75,
+ cam_xyz_t[2][1] * 0.75,
+ cam_xyz_t[2][2] * 0.75,
+ "Cam Z Transformed Horz",
+ "midnightblue",
+ ],
+ [
+ 0,
+ 0,
+ 0,
+ cam_xyz_tr[0][0] * 0.5,
+ cam_xyz_tr[0][1] * 0.5,
+ cam_xyz_tr[0][2] * 0.5,
+ "Cam X Transformed Roll Horz",
+ "aqua",
+ ],
+ [
+ 0,
+ 0,
+ 0,
+ cam_xyz_tr[1][0] * 0.5,
+ cam_xyz_tr[1][1] * 0.5,
+ cam_xyz_tr[1][2] * 0.5,
+ "Cam Y Transformed Roll Horz",
+ "blueviolet",
+ ],
+ [
+ 0,
+ 0,
+ 0,
+ cam_xyz_tr[2][0] * 0.5,
+ cam_xyz_tr[2][1] * 0.5,
+ cam_xyz_tr[2][2] * 0.5,
+ "Cam Z Transformed Roll Horz",
+ "deeppink",
+ ],
+ ]
+
+ for ox, oy, oz, dx, dy, dz, label, color in vectors_data:
+ # Plot the vector using quiver
+ ax.quiver(ox, oy, oz, dx, dy, dz, color=color, arrow_length_ratio=0.1, label=label)
+
+ # fig_pyr = plt.figure()
+ # ax_pyr = fig_pyr.add_subplot(111, projection='3d')
+ pix_pyr_t = []
+ for index, vec in enumerate(pyramid_pixel_vectors):
+ if index == 0:
+ ax.quiver(
+ 0,
+ 0,
+ 0,
+ vec.x * 1.15,
+ vec.y * 1.15,
+ vec.z * 1.15,
+ arrow_length_ratio=0.1,
+ color="olivedrab",
+ label="Untransformed Camera Vec Sample",
+ )
+ pix_pyr_t.append(cam_horizon_pose_transform.apply(vec.data.reshape(3)))
+ ax.quiver(
+ 0,
+ 0,
+ 0,
+ pix_pyr_t[index][0] * 1.15,
+ pix_pyr_t[index][1] * 1.15,
+ pix_pyr_t[index][2] * 1.15,
+ arrow_length_ratio=0.1,
+ color="black",
+ label="Transformed Camera Vec Sample",
+ )
+ else:
+ ax.quiver(
+ 0,
+ 0,
+ 0,
+ vec.x * 1.15,
+ vec.y * 1.15,
+ vec.z * 1.15,
+ arrow_length_ratio=0.1,
+ color="olivedrab",
+ label=None,
+ )
+ pix_pyr_t.append(cam_horizon_pose_transform.apply(vec.data.reshape(3)))
+ ax.quiver(
+ 0,
+ 0,
+ 0,
+ pix_pyr_t[index][0] * 1.15,
+ pix_pyr_t[index][1] * 1.15,
+ pix_pyr_t[index][2] * 1.15,
+ arrow_length_ratio=0.1,
+ color="black",
+ label=None,
+ )
+
+ '''
+ for index, vec in enumerate(pyramid_pixel_vectors_2):
+ if index == 0:
+ ax.quiver(
+ 0,
+ 0,
+ 0,
+ vec.x * 1.5,
+ vec.y * 1.5,
+ vec.z * 1.5,
+ arrow_length_ratio=0.1,
+ color="midnightblue",
+ label="New Cam Matrix",
+ )
+ else:
+ ax.quiver(
+ 0,
+ 0,
+ 0,
+ vec.x * 1.5,
+ vec.y * 1.5,
+ vec.z * 1.5,
+ arrow_length_ratio=0.1,
+ color="midnightblue",
+ label=None,
+ )
+ '''
+
+ ax.set_xlabel('X-axis')
+ ax.set_ylabel('Y-axis')
+ ax.set_zlabel('Z-axis')
+ ax.set_xlim([-1.25, 1.25])
+ ax.set_ylim([-1.25, 1.25])
+ ax.set_zlim([-1.25, 1.25])
+ ax.set_aspect('equal')
+ ax.legend()
+
+ ##### extract data, apply sets of rotations (inverse) to convert horizonal data to camera coordinates and camera coordinates to mirror coordinates
+ for pixel, details in vector_data.items():
+ if isinstance(details, dict):
+ if details["intersection_1"].size > 0:
+ row, col = ast.literal_eval(pixel)
+ cam_vec_original = get_pixel_pointing_vector(
+ pixel_directions=pixel_pointing, row=row, col=col, imagewidth=mask.shape[1]
+ )
+ cam_vec_horizon = cam_horizon_transform.apply(cam_vec_original.reshape(3))
+ _, slope_1_corr, slope_2_corr = safe_calculate_slope(
+ cam_vec_horizon.reshape(3) * -1,
+ cam_vec_horizon.reshape(3) * -1,
+ details["intersection_1"],
+ details["intersection_2"],
+ )
+ angle_between = calculate_angle_between_vectors(
+ details['start_vector']['celestial_to_target'], details['end_vector']['celestial_to_target']
+ )
+
+ vector_data[pixel]["angle_between"] = angle_between
+ vector_data[pixel]["observer_vector_camera_corrected"] = cam_vec_horizon
+ vector_data[pixel]["H_slope_1_camera_corrected"] = slope_1_corr
+ vector_data[pixel]["H_slope_2_camera_corrected"] = slope_2_corr
+ vector_data[pixel]["C_slope_1_camera_corrected"] = cam_horizon_transform.inv().apply(slope_1_corr)
+ vector_data[pixel]["C_slope_2_camera_corrected"] = cam_horizon_transform.inv().apply(slope_2_corr)
+ vector_data[pixel]["M_point_location"] = (
+ r_optic_cam_refine_1.inv().apply(vector_data[pixel]["C_point_location"])
+ - v_cam_optic_cam_refine_1.data.T
+ )
+ vector_data[pixel]["M_slope_1_camera_corrected"] = r_optic_cam_refine_1.inv().apply(
+ vector_data[pixel]["C_slope_1_camera_corrected"]
+ )
+ vector_data[pixel]["M_slope_2_camera_corrected"] = r_optic_cam_refine_1.inv().apply(
+ vector_data[pixel]["C_slope_2_camera_corrected"]
+ )
+ else:
+ continue
+ else:
+ pass
+
+ ##### take extracted coords and slopes, rotate and align with mirror coordinate system and feed to sofast plotting
+ # plot_heat_maps_horizonal_looking_up(vector_data)
+ # plot_pixel_camera_and_mirror_coords(vector_data)
+ # plot_heat_maps_camera_looking_up_coords(vector_data)
+ plot_heat_maps_mirror_looking_up_coords(
+ vector_data, output_dir=os.path.join(primary_folder, "9_sofast_data_compare"), debug_plots=False
+ )
+ # plot_slope_heat_maps_horizonal(data_dict=vector_data)
+ # plot_slope_heat_maps_camera(data_dict=vector_data)
+ # plot_slope_heat_maps_mirror(data_dict=vector_data)
+ print("done")
+
+
+if __name__ == "__main__":
+ print("not intended to be run as a script any more. See lookfast_camera_adjust_V2.py instead.")
+ main_original_script()
diff --git a/contrib/app/LookFast/lookfast_main_VCLI_argparse.py b/contrib/app/LookFast/lookfast_main_VCLI_argparse.py
new file mode 100644
index 00000000..f36b451b
--- /dev/null
+++ b/contrib/app/LookFast/lookfast_main_VCLI_argparse.py
@@ -0,0 +1,1377 @@
+import os
+import sys
+import time
+from datetime import datetime, timedelta
+from zoneinfo import ZoneInfo
+import argparse
+import configparser
+import ast
+from pathlib import Path
+from logging import DEBUG, ERROR
+import tkinter as tk
+from tkinter import filedialog
+
+import numpy as np
+import cv2
+
+# import opencsp.app.sofast.lib.image_processing as imgp
+import opencsp.common.lib.tool.file_tools as ft
+from opencsp.common.lib.camera.Camera import Camera
+
+# import opencsp.app.lookback.lookback_tools as lbt
+import contrib.app.LookFast.lookback_tools as lbt
+
+# from opencsp.app.lookback.interactive_video_info_extract_ref_pixel import interactive_video_select
+from contrib.app.LookFast.interactive_video_info_extract_ref_pixel import interactive_video_select
+
+# import opencsp.app.lookback.coverage_map_mp as cvg_map
+import contrib.app.LookFast.coverage_map_mp as cvg_map
+
+# import opencsp.app.lookback.time_history_array_mp_npz as time_hist
+import contrib.app.LookFast.time_history_array_mp_npz as time_hist
+
+# import opencsp.app.lookback.time_history_transitions_npz_mp as transitions
+import contrib.app.LookFast.time_history_transitions_npz_mp as transitions
+
+# import opencsp.app.lookback.celestial_vectors as astro_math
+import contrib.app.LookFast.celestial_vectors as astro_math
+
+# import opencsp.app.lookback.lookfast_camera_adjust_V2_module as rt_cam_adjust
+import contrib.app.LookFast.lookfast_camera_adjust_V2_module as rt_cam_adjust
+
+# import opencsp.app.lookback.lookfast_pointing_estimate_V1 as pointing_math
+import contrib.app.LookFast.lookfast_pointing_estimate_V1 as pointing_math
+
+# Specify the folder where the log file should be saved
+logger = lbt.logging_setup(
+ log_folder=ft.join(os.getcwd(), "error_logs"), log_file_name="error_log_lookfast_main_debug.txt", log_type=DEBUG
+)
+
+PATH_KEYS = {"primary_folder", "og_video_path", "file_camera", "video_path", "ephem_path"}
+
+
+def select_file(window_title="Select a File", file_types=None):
+ """
+ Opens a file dialog to select a file, with customizable window title and file types.
+
+ Parameters:
+ window_title (str): The title of the file dialog window (default is "Select a File").
+ file_types (str): A string of file types in the format "*.ext1 *.ext2", e.g., "*.jpg *.png".
+ If None, defaults to allowing all files.
+
+ Returns:
+ str: The file path of the selected file, or None if no file was selected.
+ """
+ # Create a hidden root window
+ root = tk.Tk()
+ root.withdraw() # Hide the root window
+
+ # Parse file types into the required format for filedialog
+ if file_types:
+ file_types_list = [("Custom File Types", file_types.lower().split()), ("All Files", "*.*")]
+ else:
+ file_types_list = [("All Files", "*.*")]
+
+ # Open the file dialog
+ file_path = filedialog.askopenfilename(title=window_title, filetypes=file_types_list)
+ if file_path:
+ logger.info("File Selected: %s", file_path)
+ else:
+ logger.info("File Selected: None")
+
+ root.destroy()
+ # Return the selected file path
+ return os.path.normpath(file_path) if file_path else None
+
+
+def select_dir(window_title="Select a Directory"):
+ """
+ Opens a file dialog to select a Directory,
+ Parameters:
+ window_title (str): The title of the file dialog window (default is "Select a File").
+
+ Returns:
+ str: The file path of the selected directory, or None if no directory was selected.
+ """
+ # Create a hidden root window
+ root = tk.Tk()
+ root.withdraw() # Hide the root window
+
+ # Open the file dialog
+ dir_path = filedialog.askdirectory(title=window_title, initialdir=os.path.normpath(os.getcwd()))
+ if dir_path:
+ logger.info("Directory Selected: %s", dir_path)
+ else:
+ logger.info("Directory Selected: None")
+
+ root.destroy()
+ # Return the selected file path
+ return os.path.normpath(dir_path) if dir_path else None
+
+
+def normalize_path(value):
+ """
+ Normalize file paths, handling UNC and drive-letter paths.
+ Convert backslashes to forward slashes for consistency.
+ """
+ if isinstance(value, str):
+ # Detect UNC path (starts with \\ or //)
+ if value.startswith(('\\\\', '//')):
+ # Normalize but keep UNC prefix
+ p = Path(value)
+ return str(p.as_posix())
+ # Detect Windows drive letter path (e.g., C:\)
+ elif len(value) > 1 and value[1] == ':':
+ p = Path(value)
+ return str(p.as_posix())
+ return value
+
+
+def serialize_value(value):
+ """
+ Convert Python objects to strings suitable for writing to ini files.
+ Paths are converted to forward slashes.
+ """
+ if isinstance(value, (list, tuple)):
+ # Convert each element recursively and format as Python literal
+ return str(tuple(value)) if isinstance(value, tuple) else str(list(value))
+ elif isinstance(value, bool):
+ return str(value)
+ elif isinstance(value, (int, float)):
+ return str(value)
+ elif isinstance(value, str):
+ # Normalize path slashes
+ return normalize_path(value)
+ else:
+ # Fallback to string conversion
+ return str(value)
+
+
+def write_ini_file_from_args(args_namespace, additional_values, output_path, section_name="DEFAULT"):
+ """
+ Write an INI file combining argparse inputs and additional values, compatible with configparser.
+
+ Parameters:
+ args_namespace (argparse.Namespace): Parsed CLI arguments.
+ additional_values (dict): Additional key-value pairs to include.
+ output_path (str): Full path to write the ini file.
+ section_name (str): Section name in the INI file (default: "DEFAULT").
+
+ Returns:
+ None
+ """
+ # Convert Namespace to dict
+ args_dict = vars(args_namespace).copy()
+ # Merge additional values (overwrites if keys overlap)
+ combined_dict = {**args_dict, **additional_values}
+
+ # Serialize all values to strings
+ str_dict = {k: serialize_value(v) for k, v in combined_dict.items()}
+
+ config = configparser.ConfigParser()
+ config[section_name] = str_dict
+
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
+
+ with open(output_path, "w") as configfile:
+ config.write(configfile)
+
+ print(f"INI file written to: {output_path}")
+
+
+def fraction_to_key(values):
+ """
+ Convert level specifications to a 2-digit percent key string ("00".."100").
+
+ Accepts:
+ - single value or list/tuple/set
+ - values may be:
+ * fraction floats/ints: 0.5, 1.0, 0
+ * percent values as ints/floats: 50, 95, 100
+ * percent keys as strings: "50", "05", "100"
+ * fraction strings: "0.5", "1", "0.05"
+
+ Returns:
+ - str if input is scalar
+ - list[str] if input is list/tuple/set
+
+ Raises:
+ - ValueError if a value cannot be parsed or is out of range.
+ """
+
+ def one_to_key(v):
+ # parse strings
+ if isinstance(v, str):
+ s = v.strip()
+ if s == "":
+ raise ValueError("Empty string is not a valid level.")
+ # pure digits -> interpret as percent key/value
+ if s.isdigit():
+ p = int(s)
+ if not (0 <= p <= 100):
+ raise ValueError(f"Percent key {p} out of range [0, 100].")
+ return f"{p:02d}"
+ # otherwise interpret as numeric (fraction-like)
+ try:
+ v = float(s)
+ except ValueError as e:
+ raise ValueError(f"Could not parse level value {v!r}.") from e
+
+ # parse numbers
+ if isinstance(v, (int, float)):
+ x = float(v)
+ # Heuristic matches normalize_to_fractions:
+ # x <= 1 -> fraction
+ # x > 1 -> percent
+ if x <= 1.0:
+ if x < 0.0:
+ raise ValueError(f"Fraction {x} out of range [0, 1].")
+ p = int(round(x * 100))
+ else:
+ if not (0.0 <= x <= 100.0):
+ raise ValueError(f"Percent value {x} out of range [0, 100].")
+ p = int(round(x))
+
+ # validate after rounding
+ if not (0 <= p <= 100):
+ raise ValueError(f"Percent key {p} out of range [0, 100].")
+ return f"{p:02d}"
+
+ raise ValueError(f"Unsupported type for level value: {type(v).__name__}")
+
+ is_iterable = isinstance(values, (list, tuple, set))
+ if is_iterable:
+ return [one_to_key(v) for v in values]
+ return one_to_key(values)
+
+
+def normalize_to_fractions(values):
+ """
+ Normalize level specifications to fractions in [0, 1].
+
+ Accepts:
+ - single value or list/tuple/set
+ - values may be:
+ * fraction floats/ints: 0.5, 1.0, 0
+ * percent keys as ints: 50, 95, 100
+ * percent keys as strings: "50", "05", "100"
+ * fraction strings: "0.5", "1", "0.05"
+
+ Returns:
+ - list[float]: fractions in [0, 1] (order preserved for list-like input)
+
+ Raises:
+ - ValueError if a value cannot be parsed or is out of range.
+ """
+
+ def one_to_fraction(v):
+ # parse strings
+ if isinstance(v, str):
+ s = v.strip()
+ if s == "":
+ raise ValueError("Empty string is not a valid level.")
+ # pure digits -> interpret as percent key (e.g., "50" => 0.5)
+ if s.isdigit():
+ p = int(s)
+ if not (0 <= p <= 100):
+ raise ValueError(f"Percent key {p} out of range [0, 100].")
+ return p / 100.0
+ # otherwise interpret as numeric (fraction-like)
+ try:
+ v = float(s)
+ except ValueError as e:
+ raise ValueError(f"Could not parse level value {v!r}.") from e
+
+ # parse numbers
+ if isinstance(v, (int, float)):
+ x = float(v)
+ # Heuristic:
+ # x <= 1 -> already a fraction
+ # x > 1 -> treat as percent key (e.g., 50 -> 0.5)
+ if x <= 1.0:
+ if x < 0.0:
+ raise ValueError(f"Fraction {x} out of range [0, 1].")
+ return x
+ else:
+ if not (0.0 <= x <= 100.0):
+ raise ValueError(f"Percent value {x} out of range [0, 100].")
+ return x / 100.0
+
+ raise ValueError(f"Unsupported type for level value: {type(v).__name__}")
+
+ # accept scalar or iterable
+ if isinstance(values, (list, tuple, set)):
+ out = [one_to_fraction(v) for v in values]
+ else:
+ out = [one_to_fraction(values)]
+
+ # final clamp check (no silent clamping; just validate)
+ for f in out:
+ if not (0.0 <= f <= 1.0):
+ raise ValueError(f"Normalized fraction {f} out of range [0, 1].")
+
+ return out
+
+
+def checkpoint_has_new_levels(
+ checkpoint_main_data: dict,
+ cam_intensity_fractions,
+ analysis_fractions,
+ *,
+ key_func=None,
+ require_subset: bool = True,
+):
+ """
+ Compare requested fraction levels (from args/ini) against levels recorded in a checkpoint.
+
+ Handles checkpoint lists that may be nested, e.g. [['50','60']] or [[0.5, 0.6]].
+
+ Returns:
+ (flags, details)
+
+ flags (dict[str, bool]):
+ Per-section boolean: True means this section has NEW requested levels not present
+ in the checkpoint list and therefore should run.
+
+ details (dict[str, dict]):
+ Per-section info: requested, completed, missing (all as sorted lists of level keys).
+ """
+
+ def default_key_func(x):
+ def _one(v):
+ # Normalize: accept "50", 50, 0.5, "0.5"
+ if isinstance(v, str):
+ s = v.strip()
+ if s.isdigit():
+ return f"{int(s):02d}"
+ try:
+ v = float(s)
+ except ValueError:
+ return s # fallback: raw string compare
+ if isinstance(v, (int, float)):
+ xf = float(v)
+ # treat <= 1 as fraction, > 1 as percent key
+ if xf <= 1.0:
+ return f"{int(round(xf * 100)):02d}"
+ return f"{int(round(xf)):02d}"
+ return str(v)
+
+ if isinstance(x, (list, tuple, set)):
+ return {_one(v) for v in x}
+ else:
+ return _one(x)
+
+ key_func = default_key_func if key_func is None else key_func
+
+ def _flatten(seq):
+ """Flatten arbitrarily nested lists/tuples/sets (but not strings/bytes)."""
+ if seq is None:
+ return
+ if isinstance(seq, (list, tuple, set)):
+ for item in seq:
+ yield from _flatten(item)
+ else:
+ yield seq
+
+ def to_key_set(seq):
+ """
+ Convert scalars, lists, or nested-lists into a flat set of normalized level keys.
+ Examples:
+ "50" -> {"50"}
+ ["50","60"] -> {"50","60"}
+ [["50","60"]] -> {"50","60"}
+ [0.5, 0.6] -> {"50","60"}
+ """
+ if seq is None:
+ return set()
+
+ # Treat non-iterable scalar as one item
+ if not isinstance(seq, (list, tuple, set)):
+ seq = [seq]
+
+ out = set()
+ for v in _flatten(seq):
+ kv = key_func(v)
+ # key_func may return a set if v is iterable (defensive)
+ if isinstance(kv, set):
+ out |= kv
+ else:
+ out.add(kv)
+ return out
+
+ cam_req = to_key_set(cam_intensity_fractions)
+ ana_req = to_key_set(analysis_fractions)
+
+ if require_subset and not ana_req.issubset(cam_req):
+ raise ValueError(
+ f"analysis_fractions levels {sorted(ana_req)} must be a subset of "
+ f"cam_intensity_fractions levels {sorted(cam_req)}."
+ )
+
+ section_map = {
+ "coverage_maps_levels": cam_req,
+ "time_history_levels": cam_req,
+ "pixel_transition_levels": ana_req,
+ "pixel_transition_plot_levels": ana_req,
+ "celestial_vectors_data_levels": ana_req,
+ "celestial_vectors_plots_levels": ana_req,
+ "lookfast_camera_adjust_levels": ana_req,
+ "pointing_estimate_levels": ana_req,
+ }
+
+ flags = {}
+ details = {}
+
+ for ckpt_key, requested in section_map.items():
+ completed = to_key_set(checkpoint_main_data.get(ckpt_key, []))
+ missing = requested - completed
+
+ flags[ckpt_key] = len(missing) > 0
+ details[ckpt_key] = {"requested": sorted(requested), "completed": sorted(completed), "missing": sorted(missing)}
+
+ return flags, details
+
+
+def main(args):
+
+ if args.primary_folder is None:
+ args.primary_folder = select_dir(window_title="Select Primary Output Directory")
+ primary_folder = args.primary_folder
+ else:
+ primary_folder = args.primary_folder
+ ft.create_directories_if_necessary(primary_folder)
+
+ if args.og_video_path is None:
+ args.og_video_path = select_file(
+ window_title="Select Original Video to Copy and Process", file_types="*.mov *.mp4"
+ )
+ ft.copy_file(input_dir_body_ext=args.og_video_path, output_dir=args.primary_folder)
+ og_vid_dir, og_vid_name, og_vid_ext = ft.path_components(args.og_video_path)
+ elif os.path.exists(args.og_video_path):
+ og_vid_dir, og_vid_name, og_vid_ext = ft.path_components(args.og_video_path)
+ if ft.file_exists(input_dir_body_ext=ft.join(args.primary_folder, og_vid_name + og_vid_ext)):
+ pass
+ else:
+ ft.copy_file(input_dir_body_ext=args.og_video_path, output_dir=args.primary_folder)
+ else:
+ og_vid_dir, og_vid_name, og_vid_ext = ft.path_components(args.og_video_path)
+
+ if args.file_camera is None:
+ args.file_camera = select_file(
+ window_title="Select OpenCSP camera calibration file for dataset", file_types="*.h5"
+ )
+
+ data_output_folders = [
+ r'0_checkpoints',
+ r'1_video_frames',
+ r'2_video_frames_cropped',
+ r'3_specific_cropped_frames',
+ r'4_coverage_map',
+ r'5_accelerated_test_video',
+ r'6_time_history_output',
+ r'7_pixel_timing_interrogation',
+ r'8_pixel_vector_information',
+ r'9_sofast_data_compare',
+ r'10_pointing_estimate',
+ r'reference_images',
+ ]
+
+ for folder in data_output_folders:
+ ft.create_directories_if_necessary(ft.join(primary_folder, folder))
+
+ checkpoint_folder = ft.join(primary_folder, "0_checkpoints")
+ checkpoint_main_name = "lookback_main_checkpoint.json"
+
+ # Load checkpoint if it exists
+ checkpoint_main_data = lbt.load_checkpoint(checkpoint_folder, checkpoint_main_name)
+ if checkpoint_main_data is None:
+ checkpoint_main_data = {
+ "Completed_Steps": [],
+ "coverage_maps_levels": [],
+ "time_history_levels": [],
+ "pixel_transition_levels": [],
+ "pixel_transition_plot_levels": [],
+ "celestial_vectors_data_levels": [],
+ "celestial_vectors_plots_levels": [],
+ "lookfast_camera_adjust_levels": [],
+ "pointing_estimate_levels": [],
+ }
+
+ start_time = time.time()
+ logger.info("Code Start Time: %s", str(time.ctime(start_time)))
+
+ # fractions = [0.01, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99]
+ fractions = args.cam_intensity_fractions
+ analysis_fractions = args.analysis_fractions
+
+ cam_level_keys = [fraction_to_key(f) for f in args.cam_intensity_fractions]
+ analysis_level_keys = [fraction_to_key(f) for f in args.analysis_fractions]
+
+ has_new, chkpt_args_diff = checkpoint_has_new_levels(
+ checkpoint_main_data=checkpoint_main_data,
+ cam_intensity_fractions=cam_level_keys,
+ analysis_fractions=analysis_level_keys,
+ require_subset=True,
+ )
+
+ celestial_database_path = args.ephem_path
+ celestial_object = args.celestial_object
+ video_metadata = lbt.extract_detailed_video_metadata(ft.join(primary_folder, og_vid_name + og_vid_ext))
+ timezone = args.timezone
+ # camera_time_shift = timedelta(hours=0, minutes=0, seconds=0)
+ camera_time_shift = timedelta(args.camera_time_shift[0], args.camera_time_shift[1], args.camera_time_shift[2])
+ # Define observer location Example ≈NSTTF Tower 260 Level Balcony West Side
+ observer_lat = (
+ args.observer_lat[0],
+ args.observer_lat[1],
+ args.observer_lat[2],
+ ) # (degree, minute, second) negative degree for south
+ observer_long = (
+ args.observer_long[0],
+ args.observer_long[1],
+ args.observer_long[2],
+ ) # (degree, minute, second) negative degree for west
+ observer_elevation = args.observer_elevation
+ observer_loc = (lbt.lat_long_to_decimal(observer_lat), lbt.lat_long_to_decimal(observer_long), observer_elevation)
+
+ # Define target location Example ≈Sun Data Marker 2 In front of 5E8
+ target_lat = (
+ args.target_lat[0],
+ args.target_lat[1],
+ args.target_lat[2],
+ ) # (degree, minute, second) negative degree for south
+ target_long = (
+ args.target_long[0],
+ args.target_long[1],
+ args.target_long[2],
+ ) # (degree, minute, second) negative degree for west
+ target_elevation = args.target_elevation
+ target_loc = (lbt.lat_long_to_decimal(target_lat), lbt.lat_long_to_decimal(target_long), target_elevation)
+
+ ideal_spot_cross_time = datetime(*args.ideal_time_spot_cross_camera, tzinfo=ZoneInfo(timezone))
+ ideal_spot_details = {
+ "center": args.ideal_spot_center,
+ "axis_ratio": args.ideal_spot_axis_ratio,
+ "axis_orientation_angle": args.ideal_spot_axis_orientation_angle,
+ "traverse_direction": args.ideal_spot_traverse_direction,
+ }
+ sun_diam_pad_mul = args.sun_diameter_pad_multi
+
+ ##### Setting camera object
+
+ cam = Camera.load_from_hdf(args.file_camera)
+ cam_to_optic_reference_distance = args.cam_to_optic_reference_distance # meters
+
+ if "extracted_video_frames" in checkpoint_main_data["Completed_Steps"] or args.extract_video_frames is False:
+ logger.info("Skipped Already Completed Video Frame Extraction : %s", str(time.time() - start_time))
+ if ft.file_exists(input_dir_body_ext=ft.join(primary_folder, "interactive_scrubber_selections.json")):
+ scrubber_details = lbt.read_json(ft.join(primary_folder, "interactive_scrubber_selections.json"))
+ reference_pixel_key = scrubber_details['reference_pixel']
+ else:
+
+ scrubber_details = interactive_video_select(
+ video_path=ft.join(primary_folder, og_vid_name + og_vid_ext),
+ dest_path=ft.join(primary_folder, "1_video_frames"),
+ frame_subset_dir=ft.join(primary_folder, "3_specific_cropped_frames"),
+ start_frame=args.start_frame if args.start_frame else None,
+ end_frame=args.end_frame if args.end_frame else None,
+ reference_pixel=args.reference_pixel if args.reference_pixel else None,
+ )
+ reference_pixel_key = scrubber_details['reference_pixel']
+ lbt.write_json(scrubber_details, ft.join(primary_folder, "interactive_scrubber_selections.json"))
+
+ checkpoint_main_data["Completed_Steps"].append("extracted_video_frames")
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+ logger.info("Time to Complete Video Frame Extraction: %s", str(time.time() - start_time))
+
+ write_ini_file_from_args(args, scrubber_details, ft.join(primary_folder, "full_processing_settings.ini"))
+
+ if ("coverage_maps" in checkpoint_main_data["Completed_Steps"] or args.coverage_maps is False) and has_new[
+ "coverage_maps_levels"
+ ] is False:
+ logger.info("Skipped Already Completed Coverage Maps : %s", str(time.time() - start_time))
+ if ft.file_exists(ft.join(primary_folder, "threshold_bmap_mask_paths.json")):
+ thresh_maps_paths = lbt.read_json(ft.join(primary_folder, "threshold_bmap_mask_paths.json"))
+ else:
+ missing_levels = fractions
+ if has_new["coverage_maps_levels"] is True:
+ missing_levels = chkpt_args_diff["coverage_maps_levels"]["missing"]
+
+ cvg_map.construct_binary_maps_parallel(
+ image_folder=ft.join(primary_folder, "3_specific_cropped_frames"),
+ output_folder=ft.join(primary_folder, "4_coverage_map"),
+ checkpoint_folder=checkpoint_folder,
+ threshold_fractions=normalize_to_fractions(missing_levels),
+ batch_size=1000, # I think I fixed memory leaks, so this could be increased.
+ max_workers=4, # I think I fixed memory leaks, so this could be increased.
+ checkpoint_file="coverage_map_mp_checkpoint.json",
+ )
+ binary_maps = ft.files_in_directory(
+ input_dir=ft.join(primary_folder, "4_coverage_map"), sort=True, files_only=True, recursive=False
+ )
+ thresh_maps_compiled = [f for f in binary_maps if "compiled" in f]
+ thresh_maps_paths = [ft.join(primary_folder, "4_coverage_map", f) for f in thresh_maps_compiled]
+ lbt.write_json(thresh_maps_paths, ft.join(primary_folder, "threshold_bmap_mask_paths.json"))
+
+ checkpoint_main_data["Completed_Steps"].append("coverage_maps")
+ checkpoint_main_data["coverage_maps_levels"].extend(missing_levels)
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+ logger.info("Time to Complete Coverage Maps: %s", str(time.time() - start_time))
+
+ if "accelerated_video" in checkpoint_main_data["Completed_Steps"] or args.accelerated_video is False:
+ logger.info("Skipped Already Completed Accelerated Video: %s", str(time.time() - start_time))
+ else:
+ accel_factor = 10
+ lbt.accelerate_video_ffmpeg_no_audio(
+ input_path=ft.join(primary_folder, og_vid_name + og_vid_ext),
+ output_path=ft.join(
+ primary_folder, "5_accelerated_test_video", f"{accel_factor}x_accelerated_test_video.MOV"
+ ),
+ playback_speed=accel_factor,
+ )
+ checkpoint_main_data["Completed_Steps"].append("accelerated_video")
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+ logger.info("Time to Complete Accelerated Video: %s", str(time.time() - start_time))
+
+ if ("time_history" in checkpoint_main_data["Completed_Steps"] or args.time_history is False) and has_new[
+ "time_history_levels"
+ ] is False:
+ logger.info("Skipped Already Completed Time History: %s", str(time.time() - start_time))
+ else:
+ missing_levels = fractions
+ if has_new["time_history_levels"] is True:
+ missing_levels = chkpt_args_diff["time_history_levels"]["missing"]
+ # This function is still memory hungry, room for improvement here...
+ time_hist.create_binary_pixel_array_parallel_with_multiprocessing(
+ image_folder_path=ft.join(primary_folder, "3_specific_cropped_frames"),
+ percentages=normalize_to_fractions(missing_levels),
+ output_folder=ft.join(primary_folder, "6_time_history_output"),
+ checkpoint_folder=checkpoint_folder,
+ batch_size=500,
+ overlap=1,
+ checkpoint_file="time_history_array_checkpoint_mp.json",
+ num_workers=4,
+ )
+ checkpoint_main_data["Completed_Steps"].append("time_history")
+ checkpoint_main_data["time_history_levels"].extend(missing_levels)
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+ logger.info("Time to Complete Time History Arrays: %s", str(time.time() - start_time))
+
+ if ("pixel_transitions" in checkpoint_main_data["Completed_Steps"] or args.pixel_transitions is False) and has_new[
+ "pixel_transition_levels"
+ ] is False:
+ logger.info("Skipped Already Completed Transition History: %s", str(time.time() - start_time))
+ else:
+ missing_levels = analysis_level_keys
+ if has_new["pixel_transition_levels"] is True:
+ missing_levels = chkpt_args_diff["pixel_transition_levels"]["missing"]
+
+ for mask_key in missing_levels:
+ # mask_key = fraction_to_key(mask)
+ mask_file_path = [fp for fp in thresh_maps_paths if mask_key in os.path.basename(fp)]
+
+ if mask_key in checkpoint_main_data["pixel_transition_levels"]:
+ logger.info("Skipping completed analysis fraction pixel transitions: %s", str(mask_key))
+ continue
+
+ if len(mask_file_path) > 1:
+ raise ValueError("Too many binary map mask files match the analysis fraction key")
+ else:
+ mask_raw = cv2.imread(mask_file_path[0], cv2.IMREAD_GRAYSCALE)
+ bright_pixels = np.argwhere(mask_raw == np.max(mask_raw))
+ pixel_locs = [(y, x) for y, x in bright_pixels]
+
+ transitions.analyze_pixel_brightness_parallel_npz(
+ npz_folder=ft.join(primary_folder, "6_time_history_output", mask_key),
+ pixel_locations=pixel_locs,
+ output_folder=ft.join(primary_folder, "7_pixel_timing_interrogation", mask_key),
+ final_output_file=f"time_history_transition_parallel_{mask_key}_final.json.gz",
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name="time_history_transition_checkpoint_mp.json",
+ )
+
+ checkpoint_main_data["pixel_transition_levels"].extend([mask_key])
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+ logger.info("Time to Complete Time History Transitions: %s", str(time.time() - start_time))
+ checkpoint_main_data["Completed_Steps"].append("pixel_transitions")
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+
+ if (
+ "pixel_transition_plots" in checkpoint_main_data["Completed_Steps"] or args.pixel_transition_plots is False
+ ) and has_new["pixel_transition_plot_levels"] is False:
+ logger.info("Skipped Already Completed Transition Plots: %s", str(time.time() - start_time))
+ else:
+ missing_levels = analysis_level_keys
+ if "pixel_transition_plot_levels" in has_new:
+ missing_levels = chkpt_args_diff["pixel_transition_plot_levels"]["missing"]
+
+ for mask_key in missing_levels:
+ # mask_key = fraction_to_key(level)
+
+ if mask_key in checkpoint_main_data["pixel_transition_plot_levels"]:
+ logger.info("Skipping completed analysis fraction pixel transition plots: %s", str(mask_key))
+ continue
+
+ transitions.create_timing_plots_json_parallel(
+ compiled_json=ft.join(
+ primary_folder,
+ "7_pixel_timing_interrogation",
+ mask_key,
+ f"time_history_transition_parallel_{mask_key}_final.json.gz",
+ ),
+ output_folder=ft.join(primary_folder, "7_pixel_timing_interrogation", "pixel_timing_plots", mask_key),
+ source_image_folder=ft.join(primary_folder, "3_specific_cropped_frames"),
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file="pixel_timing_plots_checkpoint_mp.json",
+ )
+
+ checkpoint_main_data["pixel_transition_plot_levels"].extend([mask_key])
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+ logger.info("Time to Complete Timing Plots: %s", str(time.time() - start_time))
+ checkpoint_main_data["Completed_Steps"].append("pixel_transition_plots")
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+
+ if (
+ "celestial_vectors_data" in checkpoint_main_data["Completed_Steps"] or args.celestial_vectors_data is False
+ ) and has_new["celestial_vectors_data_levels"] is False:
+ logger.info("Skipped Already Completed Celestial Vector Processing: %s", str(time.time() - start_time))
+ else:
+ missing_levels = analysis_level_keys
+ if "celestial_vectors_data_levels" in has_new:
+ missing_levels = chkpt_args_diff["celestial_vectors_data_levels"]["missing"]
+
+ for mask_key in missing_levels:
+ # mask_key = fraction_to_key(level)
+ mask_file_path = [fp for fp in thresh_maps_paths if mask_key in os.path.basename(fp)]
+
+ if mask_key in checkpoint_main_data["celestial_vectors_data_levels"]:
+ logger.info("Skipping completed analysis fraction celestial vectors data: %s", str(mask_key))
+ continue
+
+ if len(mask_file_path) > 1:
+ raise ValueError("Too many binary map mask files match the analysis fraction key")
+ else:
+
+ astro_math.extract_pixel_timing_and_celestial_vectors_parallel(
+ celestial_object_name=celestial_object,
+ target_location=target_loc,
+ observer_location=observer_loc,
+ camera_time_shift=camera_time_shift,
+ data_time_zone=timezone,
+ data_location=ft.join(
+ primary_folder,
+ "7_pixel_timing_interrogation",
+ mask_key,
+ f"time_history_transition_parallel_{mask_key}_final.json.gz",
+ ),
+ video_metadata=video_metadata,
+ output_folder=ft.join(primary_folder, "8_pixel_vector_information", mask_key),
+ output_json_name=f"celestial_vector_data_mask_{mask_key}.json.gz",
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file="celestial_vector_data_checkpoint_mp.json",
+ batch_size=5000,
+ ephem_path=celestial_database_path,
+ )
+
+ checkpoint_main_data["celestial_vectors_data_levels"].extend([mask_key])
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+ logger.info("Time to Complete Celestial Vector Calculations: %s", str(time.time() - start_time))
+
+ checkpoint_main_data["Completed_Steps"].append("celestial_vectors_data")
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+
+ if (
+ "celestial_vectors_plots" in checkpoint_main_data["Completed_Steps"] or args.celestial_vectors_plots is False
+ ) and has_new["celestial_vectors_plots_levels"] is False:
+ logger.info("Skipped Already Completed Celestial Vector Plotting: %s", str(time.time() - start_time))
+ else:
+ missing_levels = analysis_level_keys
+ if "celestial_vectors_plots_levels" in has_new:
+ missing_levels = chkpt_args_diff["celestial_vectors_plots_levels"]["missing"]
+
+ for mask_key in missing_levels:
+ # mask_key = fraction_to_key(level)
+ mask_file_path = [fp for fp in thresh_maps_paths if mask_key in os.path.basename(fp)]
+
+ if mask_key in checkpoint_main_data["celestial_vectors_plots_levels"]:
+ logger.info("Skipping completed analysis fraction celestial vectors plots: %s", str(mask_key))
+ continue
+
+ if len(mask_file_path) > 1:
+ raise ValueError("Too many binary map mask files match the analysis fraction key")
+ else:
+
+ astro_math.plotting_pixel_transition_vectors_decoupled_mp(
+ data_location=ft.join(
+ primary_folder,
+ "8_pixel_vector_information",
+ mask_key,
+ f"celestial_vector_data_mask_{mask_key}.json.gz",
+ ),
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_data_file="celestial_vector_data_checkpoint_mp.json",
+ checkpoint_plot_file="celestial_vector_plots_checkpoint_mp.json",
+ celestial_object=celestial_object,
+ output_folder_img=ft.join(
+ primary_folder, "8_pixel_vector_information", mask_key, "celestial_vector_plots"
+ ),
+ batch_size=1000,
+ )
+
+ checkpoint_main_data["celestial_vectors_plots_levels"].extend([mask_key])
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+ logger.info("Time to Complete Celestial Vector Plots: %s", str(time.time() - start_time))
+
+ checkpoint_main_data["Completed_Steps"].append("celestial_vectors_plots")
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+
+ if (
+ "lookfast_camera_adjust" in checkpoint_main_data["Completed_Steps"] or args.lookfast_camera_adjust is False
+ ) and has_new["lookfast_camera_adjust_levels"] is False:
+ logger.info("Skipped Already Completed Lookfast Camera Adjustments: %s", str(time.time() - start_time))
+ else:
+ missing_levels = analysis_level_keys
+ if "lookfast_camera_adjust_levels" in has_new:
+ missing_levels = chkpt_args_diff["lookfast_camera_adjust_levels"]["missing"]
+
+ for mask_key in missing_levels:
+ # mask_key = fraction_to_key(level)
+ mask_file_path = [fp for fp in thresh_maps_paths if mask_key in os.path.basename(fp)]
+
+ if mask_key in checkpoint_main_data["lookfast_camera_adjust_levels"]:
+ logger.info("Skipping completed analysis fraction lookfast camera adjust levels: %s", str(mask_key))
+ continue
+
+ if len(mask_file_path) > 1:
+ raise ValueError("Too many binary map mask files match the analysis fraction key")
+ else:
+
+ rt_cam_adjust.main(
+ camera_obj=cam,
+ light_mask_path=mask_file_path[0],
+ vec_data_path=ft.join(
+ primary_folder,
+ "8_pixel_vector_information",
+ mask_key,
+ f"celestial_vector_data_mask_{mask_key}.json.gz",
+ ),
+ reference_distance_m=cam_to_optic_reference_distance,
+ reference_pixel_key=reference_pixel_key,
+ output_directory=ft.join(primary_folder, "9_sofast_data_compare", mask_key),
+ checkpoint_directory=checkpoint_folder,
+ checkpoint_file="rotate_translate_camera_adjust_checkpoint.json",
+ )
+
+ checkpoint_main_data["lookfast_camera_adjust_levels"].extend([mask_key])
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+ logger.info(
+ "Time to Complete Rotate/Translate Adjustment Calculations: %s", str(time.time() - start_time)
+ )
+
+ checkpoint_main_data["Completed_Steps"].append("lookfast_camera_adjust")
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+
+ if ("pointing_estimate" in checkpoint_main_data["Completed_Steps"] or args.pointing_estimate is False) and has_new[
+ "pointing_estimate_levels"
+ ] is False:
+ logger.info("Skipped Already Completed Pointing Estimate Level: %s", str(time.time() - start_time))
+ else:
+ missing_levels = analysis_level_keys
+ if "pointing_estimate_levels" in has_new:
+ missing_levels = chkpt_args_diff["pointing_estimate_levels"]["missing"]
+
+ for mask_key in missing_levels:
+ # mask_key = fraction_to_key(level)
+ mask_file_path = [fp for fp in thresh_maps_paths if mask_key in os.path.basename(fp)]
+
+ if mask_key in checkpoint_main_data["pointing_estimate_levels"]:
+ logger.info("Skipping completed analysis fraction pointing estimate level: %s", str(mask_key))
+ continue
+
+ if len(mask_file_path) > 1:
+ raise ValueError("Too many binary map mask files match the analysis fraction key")
+ else:
+
+ pointing_math.pointing_estimate(
+ timing_data_path=ft.join(
+ primary_folder,
+ "7_pixel_timing_interrogation",
+ mask_key,
+ f"time_history_transition_parallel_{mask_key}_final.json.gz",
+ ),
+ video_path=ft.join(primary_folder, og_vid_name + og_vid_ext),
+ output_dir=ft.join(primary_folder, "10_pointing_estimate", mask_key),
+ camera_time_shift=camera_time_shift,
+ ideal_time_local=ideal_spot_cross_time,
+ ref_distance=cam_to_optic_reference_distance,
+ timezone=timezone,
+ ideal_spot_parameters=ideal_spot_details,
+ sun_diam_pad_mul=sun_diam_pad_mul,
+ )
+
+ checkpoint_main_data["pointing_estimate_levels"].extend([mask_key])
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+ logger.info("Time to Complete Celestial Vector Calculations: %s", str(time.time() - start_time))
+
+ checkpoint_main_data["Completed_Steps"].append("pointing_estimate")
+ lbt.save_checkpoint(
+ checkpoint_folder=checkpoint_folder,
+ checkpoint_file_name=checkpoint_main_name,
+ checkpoint_data=checkpoint_main_data,
+ )
+
+ print("Finished LookFast Processing Script")
+ print(time.strftime("%H:%M:%S", time.localtime()))
+
+
+def parse_value(value):
+ """
+ Parse a string value from the ini file into a Python object if possible.
+ If parsing fails, return the original string.
+ """
+ try:
+ # Attempt to parse lists, tuples, numbers, bools, etc.
+ parsed = ast.literal_eval(value)
+ return parsed
+ except (ValueError, SyntaxError):
+ # Return as string if not a Python literal
+ return value
+
+
+def parse_config(config_file_path):
+ config = configparser.ConfigParser()
+ read_ok = config.read(config_file_path)
+ if not read_ok:
+ raise FileNotFoundError(f"Could not read config file: {config_file_path}")
+
+ out = {}
+ for section in config.sections():
+ for key, value in config.items(section):
+ v = parse_value(value)
+ if key in PATH_KEYS and isinstance(v, str):
+ v = normalize_path(v)
+ out[key] = v
+
+ # also include [DEFAULT] if you use it
+ for key, value in config.defaults().items():
+ if key not in out:
+ v = parse_value(value)
+ if key in PATH_KEYS and isinstance(v, str):
+ v = normalize_path(v)
+ out[key] = v
+
+ return out
+
+
+def build_parser():
+ p = argparse.ArgumentParser(
+ prog=Path(__file__).stem,
+ description='Analyze LookFast Source Video, image processing, celestial analysis, and analysis plots custom to Lookfast and through Sofast standard plot output.',
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+
+ p.add_argument(
+ "-s",
+ "--settings",
+ dest="settings_path",
+ default=None,
+ help="Settings file full path defining run parameters (input/output directories, etc).",
+ )
+ p.add_argument(
+ "--verbose",
+ action=argparse.BooleanOptionalAction,
+ default=None,
+ help="Not Implemented Yet...Output detailed information reporting run progress and calculations.",
+ )
+
+ # Define EVERYTHING main() expects:
+ # File Paths
+ p.add_argument(
+ "--primary-folder",
+ dest="primary_folder",
+ default=None,
+ help="Directory location where original video and all output will be organized into including checkpoint files.",
+ )
+ p.add_argument(
+ "--og-video-path",
+ dest="og_video_path",
+ default=None,
+ help="Original video data path, all analysis will be done on an automatically generated copy of this video.",
+ )
+ p.add_argument(
+ "-cam",
+ "--file-camera",
+ dest="file_camera",
+ default=None,
+ help="File path of the camera definition within OpenCSP camera calibration file conventions.",
+ )
+ p.add_argument(
+ "-video-path",
+ "--analysis-video-path",
+ dest="video_path",
+ default=None,
+ help="File path of copied video, may be automatically added to ini file when run interactively. Not necessary to specify.",
+ )
+ p.add_argument(
+ "-eph_path",
+ "--ephemeris_database_path",
+ dest="ephem_path",
+ default=None,
+ help="File path of de430t.bsp ephemeris file for celestial body references.",
+ )
+
+ p.add_argument(
+ "-ifile",
+ "--interactive-file_name",
+ dest="ui_input_file_name",
+ default="interactive_selections.json",
+ help="Name of json file containing interactive file selection information.",
+ )
+ p.add_argument(
+ "-sat-levels",
+ "--cam-intensity-fractions",
+ dest="cam_intensity_fractions",
+ default=None,
+ help="List of fractions ranging from 0.0 (unsaturated) to 1.0 (saturated) for camera pixel saturation thresholding for processing.",
+ )
+ p.add_argument(
+ "-ana-levels",
+ "--analysis-fractions",
+ dest="analysis_fractions",
+ default=None,
+ help="Sublist of fractions as listed in '--cam-intensity-fractions' that will be caried forward for analysis after basic pixel thresholding. This list must contain at least one identical fraction as '--cam-intensity-fractions'.",
+ )
+ p.add_argument(
+ "-astro-body",
+ "--celestial-object",
+ dest="celestial_object",
+ default=None,
+ help="Celestial body for analysis, currently supported are 'sun' and 'moon'.",
+ )
+ p.add_argument(
+ "-tz",
+ "--timezone",
+ dest="timezone",
+ default=None,
+ help="timezone of location of data collection. 'America/Denver' for analysis at NSTTF.",
+ )
+ p.add_argument(
+ "-c-shift",
+ "--cam-time-offset",
+ dest="camera_time_shift",
+ default=None,
+ help="Tuple of (hours, minutes, seconds) to shift video creation time to align with real world time. Negative for shifting backwards.",
+ )
+ p.add_argument(
+ "-ref-dist",
+ "--cam-optic_distance",
+ dest="cam_to_optic_reference_distance",
+ default=None,
+ help="Reference distance between the camera and mirror system as close to mirror reference pixel as possible.",
+ )
+ p.add_argument(
+ "-obs-lat",
+ "--observer-latitude",
+ dest="observer_lat",
+ default=None,
+ help="Tuple of (deg, min, sec) latitude for the observer (camera) location",
+ )
+ p.add_argument(
+ "-obs-long",
+ "--observer-longitude",
+ dest="observer_long",
+ default=None,
+ help="Tuple of (deg, min, sec) longitude for the observer (camera) location",
+ )
+ p.add_argument(
+ "-obs-ele",
+ "--observer-elevation",
+ dest="observer_elevation",
+ default=None,
+ help="Elevation above sea level for the observer (camera) location",
+ )
+ p.add_argument(
+ "-tgt-lat",
+ "--target-latitude",
+ dest="target_lat",
+ default=None,
+ help="Tuple of (deg, min, sec) latitude for the target (mirror) location",
+ )
+ p.add_argument(
+ "-tgt-long",
+ "--target-longitude",
+ dest="target_long",
+ default=None,
+ help="Tuple of (deg, min, sec) longitude for the target (mirror) location",
+ )
+ p.add_argument(
+ "-tgt-ele",
+ "--target-elevation",
+ dest="target_elevation",
+ default=None,
+ help="Elevation above sea level for the target (mirror) location",
+ )
+ p.add_argument(
+ "-ispot_cross_time",
+ "--ideal-time-spot-cross-camera",
+ dest="ideal_time_spot_cross_camera",
+ default=None,
+ help="Tuple of (year, month, day, hour, minute, second) time value for ideal time that the spot crosses the camera for pointing estimate",
+ )
+ p.add_argument(
+ "-ispot_center",
+ "--ideal-spot-center",
+ dest="ideal_spot_center",
+ default=None,
+ help="Tuple of (x,y) location in meters for the center of the ideal spot crossing the camera for pointing estimate",
+ )
+ p.add_argument(
+ "-ispot_ax_ratio",
+ "--ideal-spot-axis-ratio",
+ dest="ideal_spot_axis_ratio",
+ default=None,
+ help="ratio of length between major and minor axis of the elliptical shape ratio of 1 yields a circle",
+ )
+ p.add_argument(
+ "-ispot_ori_angle",
+ "--ideal-spot-axis-orientation-angle",
+ dest="ideal_spot_axis_orientation_angle",
+ default=None,
+ help="orientation of major axis of elliptical shape with respect to x-axis of planar traverse model",
+ )
+ p.add_argument(
+ "-ispot_trav_dir",
+ "--ideal-spot-traverse-direction",
+ dest="ideal_spot_traverse_direction",
+ default=None,
+ help="angle between x-axis and direction of travel across the elliptical shape where travel starts at the first point on the shape exterior as the ellipse sweeps right to left",
+ )
+ p.add_argument(
+ "-sun_diam_mul",
+ "--idal-spot-sun-diam-multi",
+ dest="sun_diameter_pad_multi",
+ default=None,
+ help="temporary multiplication value to correct simple ideal spot size and traverse time model to better reflect initial test data for function",
+ )
+ p.add_argument(
+ "-startf",
+ "--start-frame",
+ dest="start_frame",
+ default=None,
+ help="Integer frame number to start the analysis for pixel behavior and celetial vectors.",
+ )
+ p.add_argument(
+ "-endf",
+ "--end-frame",
+ dest="end_frame",
+ default=None,
+ help="Integer frame number to end the analysis for pixel behavior and celetial vectors.",
+ )
+ p.add_argument(
+ "-px-ref",
+ "--reference-pixel",
+ dest="reference_pixel",
+ default=None,
+ help="Reference pixel closest to measured point with data to align coordinate systems. Input as (row, column) which is measured with the origin at the top left corner of the image and row increasing going down and column increasing going to the right.",
+ )
+
+ # code sections to run
+ p.add_argument(
+ "--run-extract-frames",
+ dest="extract_video_frames",
+ action=argparse.BooleanOptionalAction,
+ default=None,
+ help="Processing section to extract all frames from copy of original video.",
+ )
+ p.add_argument(
+ "--run-coverage-maps",
+ dest="coverage_maps",
+ action=argparse.BooleanOptionalAction,
+ default=None,
+ help="Processing section to produce coverage maps of video frames at specified intensity fractions. Helpful for understanding the extent of data avaliability at different levels of saturation.",
+ )
+ p.add_argument(
+ "--run-accelerated-video",
+ dest="accelerated_video",
+ action=argparse.BooleanOptionalAction,
+ default=None,
+ help="Processing section to create an accelerated video of test for presentations and qualitative analysis of data quality.",
+ )
+ p.add_argument(
+ "--run-time-history",
+ dest="time_history",
+ action=argparse.BooleanOptionalAction,
+ default=None,
+ help="Processing section to create time history structure for pixels within primary mask region for all data analysis.",
+ )
+ p.add_argument(
+ "--run-pixel-transitions",
+ dest="pixel_transitions",
+ action=argparse.BooleanOptionalAction,
+ default=None,
+ help="Processing section to create dictionary of transitions for pixels within the mask region.",
+ )
+ p.add_argument(
+ "--run-pixel-transition-plots",
+ dest="pixel_transition_plots",
+ action=argparse.BooleanOptionalAction,
+ default=None,
+ help="Processing section to create timing plots for all pixels within mask region.",
+ )
+ p.add_argument(
+ "--run-celestial-vectors",
+ dest="celestial_vectors_data",
+ action=argparse.BooleanOptionalAction,
+ default=None,
+ help="Processing section to create celestial vector set of solutions for each pixel's timing data.",
+ )
+ p.add_argument(
+ "--run-celestial-vector-plots",
+ dest="celestial_vectors_plots",
+ action=argparse.BooleanOptionalAction,
+ default=None,
+ help="Processing section to create plots for each pixel's celestial analysis.",
+ )
+ p.add_argument(
+ "--run-lookfast-camera-adjustments",
+ dest="lookfast_camera_adjust",
+ action=argparse.BooleanOptionalAction,
+ default=None,
+ help="Processing section to apply camera model, align coordinate systems, align data with OpenCSP conventions, and plot data.",
+ )
+ p.add_argument(
+ "--run-pointing-estimate",
+ dest="pointing_estimate",
+ action=argparse.BooleanOptionalAction,
+ default=None,
+ help="Processing section to apply time based pointing estimate for beamlets. Currently a work in progress.",
+ )
+
+ return p
+
+
+def validate_args(args):
+ required = [
+ "primary_folder",
+ "og_video_path",
+ "file_camera",
+ "cam_intensity_fractions",
+ "analysis_fractions",
+ "celestial_object",
+ "timezone",
+ "observer_lat",
+ "observer_long",
+ "observer_elevation",
+ "target_lat",
+ "target_long",
+ "target_elevation",
+ "cam_to_optic_reference_distance",
+ ]
+
+ if any((f < 0 or f > 1) for f in args.cam_intensity_fractions):
+ raise SystemExit("cam_intensity_fractions must be in [0,1].")
+
+ if not set(args.analysis_fractions).issubset(set(args.cam_intensity_fractions)):
+ raise SystemExit("analysis_fractions must be a subset of cam_intensity_fractions.")
+
+ for name in ("observer_lat", "observer_long", "target_lat", "target_long"):
+ t = getattr(args, name)
+ if not (isinstance(t, (list, tuple)) and len(t) == 3):
+ raise SystemExit(f"{name} must be a 3-tuple like (deg,min,sec).")
+
+ missing = [k for k in required if getattr(args, k, None) in (None, "")]
+ if missing:
+ raise SystemExit(f"Missing required settings: {missing}")
+
+
+if __name__ == "__main__":
+ # Build the full parser (should include ALL arguments main() expects)
+ parser = build_parser()
+
+ # Pass 1: parse only to learn whether a settings file was provided
+ pre_args, _unknown = parser.parse_known_args()
+
+ if pre_args.settings_path is None:
+ if not sys.stdin.isatty():
+ raise SystemExit("Error: --settings_path (-s) is required when running non-interactively.")
+
+ settings_file_path = select_file(
+ window_title="Select a Setting.ini file for LookFast Processing", file_types="*.ini"
+ )
+ if not settings_file_path:
+ raise SystemExit("A settings file must be specified.")
+
+ pre_args.settings_path = settings_file_path
+
+ # Load INI and set as defaults (so CLI can override)
+ ini_settings = parse_config(pre_args.settings_path)
+ parser.set_defaults(**ini_settings)
+
+ # Pass 2: parse again with defaults populated from INI
+ args = parser.parse_args()
+ args.settings_path = pre_args.settings_path # ensure it’s always present/consistent
+
+ # Validate and run
+ validate_args(args)
+
+ main(args)
diff --git a/contrib/app/LookFast/lookfast_pointing_estimate_V1.py b/contrib/app/LookFast/lookfast_pointing_estimate_V1.py
new file mode 100644
index 00000000..64cd1778
--- /dev/null
+++ b/contrib/app/LookFast/lookfast_pointing_estimate_V1.py
@@ -0,0 +1,2060 @@
+'''
+Utilize the time intervals from the main analysis to determine where the beamlet from each mixel (assumed to be an elipse)
+crosses the camera system.
+
+A perfectly focused and made optic would have each and every beamlet produce the maximum crossing time.
+'''
+
+import os
+import math
+from typing import Dict, Any, Tuple, Optional, List
+from zoneinfo import ZoneInfo
+from dataclasses import dataclass
+from concurrent.futures import ProcessPoolExecutor, as_completed
+from logging import DEBUG, ERROR
+from datetime import datetime, timedelta, timezone
+
+from tqdm import tqdm
+import numpy as np
+import matplotlib.pyplot as plt
+import matplotlib.dates as mdates
+
+
+import opencsp.common.lib.tool.file_tools as ft
+
+# import opencsp.app.lookback.lookback_tools as lbt
+import contrib.app.LookFast.lookback_tools as lbt
+
+
+@dataclass(frozen=True)
+class EllipticalSunSpot:
+ """
+ Rotated ellipse on a 2D wall.
+
+ a_m, b_m: semi-axes (m)
+ orientation_deg: rotation of +a axis CCW from +x (deg)
+ center_xy: (cx, cy) in wall coordinates (m)
+ """
+
+ distance_m: float
+ angular_diameter_rad: float
+ diameter_small_angle: float
+ a_m: float
+ b_m: float
+ orientation_deg: float = 0.0
+ center_xy: Tuple[float, float] = (0.0, 0.0)
+
+
+def reflected_sun_spot_ellipse(
+ distance_m: float,
+ *,
+ angular_diameter_rad: float = 9.3e-3,
+ axis_ratio: float = 1.0, # b/a
+ orientation_deg: float = 0.0,
+ center_xy: Tuple[float, float] = (0.0, 0.0),
+) -> EllipticalSunSpot:
+ if distance_m <= 0:
+ raise ValueError("distance_m must be > 0")
+ if angular_diameter_rad <= 0:
+ raise ValueError("angular_diameter_rad must be > 0")
+ if axis_ratio <= 0:
+ raise ValueError("axis_ratio must be > 0")
+
+ diameter = distance_m * angular_diameter_rad # small-angle approximation
+ a = 0.5 * diameter
+ b = a * axis_ratio
+
+ return EllipticalSunSpot(
+ distance_m=distance_m,
+ angular_diameter_rad=angular_diameter_rad,
+ a_m=a,
+ b_m=b,
+ orientation_deg=orientation_deg,
+ center_xy=center_xy,
+ diameter_small_angle=diameter,
+ )
+
+
+def ellipse_boundary_points(
+ a_m: float, b_m: float, *, orientation_deg: float = 0.0, center_xy: Tuple[float, float] = (0.0, 0.0), n: int = 400
+) -> Tuple[np.ndarray, np.ndarray]:
+ """
+ Return (x, y) points for a rotated ellipse boundary.
+
+ Ellipse local param:
+ x' = a cos t
+ y' = b sin t
+ Then rotate by orientation_deg CCW and translate by center_xy.
+ """
+ if a_m <= 0 or b_m <= 0:
+ raise ValueError("a_m and b_m must be > 0")
+ if n < 10:
+ raise ValueError("n must be >= 10")
+
+ t = np.linspace(0.0, 2.0 * math.pi, n, endpoint=True)
+ xp = a_m * np.cos(t)
+ yp = b_m * np.sin(t)
+
+ th = math.radians(orientation_deg)
+ c, s = math.cos(th), math.sin(th)
+
+ x = c * xp - s * yp + center_xy[0]
+ y = s * xp + c * yp + center_xy[1]
+ return x, y
+
+
+def _to_local_rotation(orientation_deg: float) -> Tuple[float, float]:
+ """Return cos(theta), sin(theta) for theta=orientation."""
+ th = math.radians(orientation_deg)
+ return math.cos(th), math.sin(th)
+
+
+def _global_to_local_vec(x: float, y: float, orientation_deg: float) -> Tuple[float, float]:
+ """
+ Rotate a vector from global coords into ellipse-local coords:
+ v' = R(-theta) v
+ """
+ c, s = _to_local_rotation(orientation_deg)
+ xp = c * x + s * y
+ yp = -s * x + c * y
+ return xp, yp
+
+
+def _global_to_local_point(
+ x: float, y: float, *, center_xy: Tuple[float, float], orientation_deg: float
+) -> Tuple[float, float]:
+ """
+ Convert a global point to ellipse-local coordinates:
+ p' = R(-theta) (p - center)
+ """
+ dx = x - center_xy[0]
+ dy = y - center_xy[1]
+ return _global_to_local_vec(dx, dy, orientation_deg)
+
+
+# -----------------------------
+# Intersections / chord length
+# -----------------------------
+def ellipse_line_intersections(
+ a_m: float,
+ b_m: float,
+ *,
+ center_xy: Tuple[float, float] = (0.0, 0.0),
+ orientation_deg: float = 0.0,
+ direction_deg: float,
+ eps: float = 1e-12,
+) -> Optional[Tuple[Tuple[float, float], Tuple[float, float]]]:
+ """
+ Intersections between the ellipse and the infinite line that passes through the origin with
+ direction `direction_deg`.
+
+ Line: p(t) = t * u, where u = (cosφ, sinφ), φ in degrees.
+
+ Returns
+ -------
+ - ((x1, y1), (x2, y2)) for two distinct intersection points
+ - None if tangent (single intersection) or no intersection
+
+ """
+ if a_m <= 0 or b_m <= 0:
+ raise ValueError("a_m and b_m must be > 0")
+
+ phi = math.radians(direction_deg)
+ ux, uy = math.cos(phi), math.sin(phi)
+
+ # Convert the line into ellipse-local coordinates:
+ # local ellipse: (x'/a)^2 + (y'/b)^2 = 1
+ # local line: p'(t) = p0' + t*u', where p0' is origin expressed in local coords.
+ p0x, p0y = _global_to_local_point(0.0, 0.0, center_xy=center_xy, orientation_deg=orientation_deg)
+ uxp, uyp = _global_to_local_vec(ux, uy, orientation_deg)
+
+ # Solve quadratic:
+ # ((p0x + t*uxp)/a)^2 + ((p0y + t*uyp)/b)^2 = 1
+ A = (uxp * uxp) / (a_m * a_m) + (uyp * uyp) / (b_m * b_m)
+ B = 2.0 * (p0x * uxp) / (a_m * a_m) + 2.0 * (p0y * uyp) / (b_m * b_m)
+ C = (p0x * p0x) / (a_m * a_m) + (p0y * p0y) / (b_m * b_m) - 1.0
+
+ D = B * B - 4.0 * A * C
+
+ if D < -eps:
+ return None # no intersection
+ if abs(D) <= eps:
+ return None # tangent: single intersection requested to return None
+
+ sqrtD = math.sqrt(D)
+ t1 = (-B - sqrtD) / (2.0 * A)
+ t2 = (-B + sqrtD) / (2.0 * A)
+
+ p1 = (t1 * ux, t1 * uy)
+ p2 = (t2 * ux, t2 * uy)
+ return p1, p2
+
+
+def ellipse_chord_length_along_direction(
+ a_m: float,
+ b_m: float,
+ *,
+ center_xy: Tuple[float, float] = (0.0, 0.0),
+ orientation_deg: float = 0.0,
+ direction_deg: float,
+ eps: float = 1e-12,
+) -> Optional[float]:
+ """
+ Chord length created by intersecting the ellipse with the infinite line through the origin
+ at angle `direction_deg`.
+
+ Returns
+ -------
+ - float chord length if there are two distinct intersection points
+ - None if tangent or no intersection
+ """
+ pts = ellipse_line_intersections(
+ a_m, b_m, center_xy=center_xy, orientation_deg=orientation_deg, direction_deg=direction_deg, eps=eps
+ )
+ if pts is None:
+ return None
+
+ (x1, y1), (x2, y2) = pts
+ return math.hypot(x2 - x1, y2 - y1)
+
+
+def time_for_ellipse_to_pass_chord(chord_length_m: float, *, distance_m: float, angular_speed_rad_s: float) -> float:
+ """
+ Convert the trajectory chord length into a time using small-angle mapping:
+ v ≈ distance_m * angular_speed_rad_s
+ T = chord / v
+ """
+ if chord_length_m < 0:
+ raise ValueError("chord_length_m must be >= 0")
+ if distance_m <= 0:
+ raise ValueError("distance_m must be > 0")
+ if angular_speed_rad_s <= 0:
+ raise ValueError("angular_speed_rad_s must be > 0")
+
+ v = distance_m * angular_speed_rad_s
+ return chord_length_m / v
+
+
+def chord_length_from_transit_time(transit_time_s: float, *, distance_m: float, angular_speed_rad_s: float) -> float:
+ """
+ Inverse of time_for_ellipse_to_pass_chord().
+
+ Using the same small-angle mapping:
+ v ≈ distance_m * angular_speed_rad_s
+ T = chord / v
+ so:
+ chord = T * v = T * distance_m * angular_speed_rad_s
+ """
+ if transit_time_s < 0:
+ raise ValueError("transit_time_s must be >= 0")
+ if distance_m <= 0:
+ raise ValueError("distance_m must be > 0")
+ if angular_speed_rad_s <= 0:
+ raise ValueError("angular_speed_rad_s must be > 0")
+
+ v = distance_m * angular_speed_rad_s
+ return transit_time_s * v
+
+
+def fit_y_shifts_for_chord_length(
+ spot: EllipticalSunSpot,
+ target_chord_length_m: float,
+ *,
+ direction_deg: float = 0.0,
+ y_bounds: Optional[Tuple[float, float]] = None,
+ n_grid: int = 4001,
+ refine_factor: int = 25,
+ refine_half_window_pts: int = 20,
+ show: bool = False,
+) -> Dict[str, Any]:
+ """
+ Find y-shifts (changing only ellipse center y) that make the chord length along `direction_deg`
+ match `target_chord_length_m`.
+
+ Algorithm
+ ---------
+ 1) Coarse scan: chord(y) over a grid in y.
+ 2) Take the two grid points with smallest |chord(y) - target|.
+ 3) For each candidate, do a local refinement scan in a smaller y-window around that y.
+ 4) Deduplicate nearly-identical solutions.
+ 5) Return exactly two shifts: (best, second_or_None), ordered by increasing |shift|.
+
+ Notes
+ -----
+ - chord(y) is treated as invalid if ellipse_line_intersections yields 0 or 1 intersection
+ (i.e., ellipse_chord_length_along_direction returns None). Those ys are ignored.
+ - If *no* valid chord exists in the whole scan range, returns (None, None).
+
+ Returns
+ -------
+ dict with:
+ shifts: (s1, s2_or_None)
+ y_solutions: (y1, y2_or_None)
+ chord_at_solutions: (L1, L2_or_None)
+ errors: (e1, e2_or_None) where e=abs(L-target)
+ n_solutions_found: int
+ scan_coarse: (ys, chords, diffs)
+ scan_refined: list of per-candidate scans [(ys_ref, chords_ref, diffs_ref), ...]
+ bounds: (ymin, ymax)
+ """
+
+ def chord_at(y: Optional[float]) -> Optional[float]:
+ if y is None:
+ return None
+ L = ellipse_chord_length_along_direction(
+ spot.a_m,
+ spot.b_m,
+ center_xy=(cx, float(y)),
+ orientation_deg=spot.orientation_deg,
+ direction_deg=direction_deg,
+ )
+ return None if L is None else float(L)
+
+ if target_chord_length_m < 0:
+ raise ValueError("target_chord_length_m must be >= 0")
+ if n_grid < 200:
+ raise ValueError("n_grid should be reasonably large (>=200)")
+ if refine_factor < 2:
+ raise ValueError("refine_factor must be >= 2")
+ if refine_half_window_pts < 2:
+ raise ValueError("refine_half_window_pts must be >= 2")
+
+ cx, cy0 = spot.center_xy
+
+ if y_bounds is None:
+ R = 3.0 * max(spot.a_m, spot.b_m)
+ ymin, ymax = cy0 - R, cy0 + R
+ else:
+ ymin, ymax = y_bounds
+ if ymin >= ymax:
+ raise ValueError("y_bounds must be (ymin, ymax) with ymin < ymax")
+
+ # ---- coarse scan ----
+ ys = np.linspace(ymin, ymax, n_grid)
+ chords = np.full_like(ys, np.nan, dtype=float)
+
+ for i, y in enumerate(ys):
+ L = chord_at(float(y))
+ chords[i] = np.nan if L is None else float(L)
+
+ valid = np.isfinite(chords)
+ diffs = np.full_like(chords, np.nan, dtype=float)
+ diffs[valid] = np.abs(chords[valid] - target_chord_length_m)
+
+ if not np.any(valid):
+ return {
+ "shifts": (None, None),
+ "y_solutions": (None, None),
+ "chord_at_solutions": (None, None),
+ "errors": (None, None),
+ "n_solutions_found": 0,
+ "scan_coarse": (ys, chords, diffs),
+ "scan_refined": [],
+ "bounds": (ymin, ymax),
+ }
+
+ # Find up to two best coarse indices (unique)
+ order = np.argsort(diffs[valid])
+ idx_valid = np.where(valid)[0]
+ best_idxs = []
+ for j in order:
+ ii = int(idx_valid[j])
+ best_idxs.append(ii)
+ if len(best_idxs) == 2:
+ break
+
+ # ---- local refinement around each coarse candidate ----
+ dy = float(ys[1] - ys[0])
+ refined_scans = []
+ refined_candidates: List[Tuple[float, float, float]] = [] # (y, chord, diff)
+
+ for ii in best_idxs:
+ y0 = float(ys[ii])
+
+ # window around y0, but keep within global bounds
+ half_window = refine_half_window_pts * dy
+ a = max(ymin, y0 - half_window)
+ b = min(ymax, y0 + half_window)
+
+ # refined step size
+ n_ref = max(200, int(refine_factor * (refine_half_window_pts * 2 + 1)))
+ ys_ref = np.linspace(a, b, n_ref)
+
+ chords_ref = np.full_like(ys_ref, np.nan, dtype=float)
+ diffs_ref = np.full_like(ys_ref, np.nan, dtype=float)
+
+ for k, y in enumerate(ys_ref):
+ L = chord_at(float(y))
+ if L is None:
+ continue
+ chords_ref[k] = L
+ diffs_ref[k] = abs(L - target_chord_length_m)
+
+ refined_scans.append((ys_ref, chords_ref, diffs_ref))
+
+ vref = np.isfinite(diffs_ref)
+ if np.any(vref):
+ kbest = int(np.nanargmin(diffs_ref))
+ refined_candidates.append((float(ys_ref[kbest]), float(chords_ref[kbest]), float(diffs_ref[kbest])))
+
+ # If refinement produced nothing (e.g., windows had no valid chords), fall back to coarse best points
+ if not refined_candidates:
+ for ii in best_idxs:
+ refined_candidates.append((float(ys[ii]), float(chords[ii]), float(diffs[ii])))
+
+ y1 = refined_candidates[0][0] if len(refined_candidates) >= 1 else None
+ y2 = refined_candidates[1][0] if len(refined_candidates) >= 2 else None
+
+ s1 = (y1 - cy0) if y1 is not None else None
+ s2 = (y2 - cy0) if y2 is not None else None
+
+ L1 = refined_candidates[0][1] if len(refined_candidates) >= 1 else None
+ L2 = refined_candidates[1][1] if len(refined_candidates) >= 2 else None
+
+ e1 = refined_candidates[0][2] if len(refined_candidates) >= 1 else None
+ e2 = refined_candidates[1][2] if len(refined_candidates) >= 2 else None
+
+ if show:
+ spot_adj1 = reflected_sun_spot_ellipse(
+ distance_m=spot.distance_m,
+ angular_diameter_rad=spot.angular_diameter_rad,
+ axis_ratio=spot.b_m / spot.a_m,
+ orientation_deg=spot.orientation_deg,
+ center_xy=(spot.center_xy[0], spot.center_xy[1] + s1),
+ )
+ spot_adj2 = reflected_sun_spot_ellipse(
+ distance_m=spot.distance_m,
+ angular_diameter_rad=spot.angular_diameter_rad,
+ axis_ratio=spot.b_m / spot.a_m,
+ orientation_deg=spot.orientation_deg,
+ center_xy=(spot.center_xy[0], spot.center_xy[1] + s2),
+ )
+ fig, ax, details = plot_ellipse_and_center_chord(spot_obj=spot, direction_deg=direction_deg, show=False)
+ fig, ax, details = plot_ellipse_and_center_chord(
+ spot_obj=spot_adj1, ax=ax, direction_deg=direction_deg, show=False
+ )
+ fig, ax, details = plot_ellipse_and_center_chord(
+ spot_obj=spot_adj2, ax=ax, direction_deg=direction_deg, show=True
+ )
+
+ return {
+ "shifts": (s1, s2),
+ "y_solutions": (y1, y2),
+ "chord_at_solutions": (L1, L2),
+ "chord_length_diffs": (e1, e2),
+ "n_solutions_found": int(min(2, len(refined_candidates))),
+ "scan_coarse": (ys, chords, diffs),
+ "scan_refined": refined_scans,
+ "bounds": (ymin, ymax),
+ }
+
+
+def plot_ellipse_and_center_chord(
+ a_m: Optional[float] = None,
+ b_m: Optional[float] = None,
+ spot_obj: Optional[EllipticalSunSpot] = None,
+ *,
+ center_xy: Tuple[float, float] = (0.0, 0.0),
+ orientation_deg: float = 0.0,
+ direction_deg: float = 0.0,
+ ax: Optional[plt.Axes] = None,
+ n: int = 600,
+ ray_span_factor: float = 1.25,
+ show: bool = True,
+ output_dir: str = None,
+ figure_name: str = None,
+) -> Tuple[plt.Figure, plt.Axes, Dict[str, Any]]:
+ """
+ Plot:
+ - ellipse boundary
+ - the line (vector) through the origin at direction_deg
+ - intersection points (if they exist)
+ - chord segment (if two intersections exist)
+
+ Returns meta containing intersection points and chord length (or None).
+ """
+ if ax is None:
+ fig, ax = plt.subplots(figsize=(7, 7), constrained_layout=True)
+ adding_shapes = False
+ else:
+ fig = ax.figure
+ adding_shapes = True
+
+ if spot_obj:
+ a_m = spot_obj.a_m
+ b_m = spot_obj.b_m
+ center_xy = spot_obj.center_xy
+ orientation_deg = spot_obj.orientation_deg
+ elif None in [a_m, b_m, center_xy, orientation_deg]:
+ raise ValueError("Either EllipticalSunSpot object needs to be provided or individual parameters")
+
+ # ellipse boundary
+ ex, ey = ellipse_boundary_points(a_m, b_m, orientation_deg=orientation_deg, center_xy=center_xy, n=n)
+ # ax.plot(ex, ey, lw=2.0, color="C0", label="Ellipse boundary")
+ ax.plot(ex, ey, lw=2.0, label="Ellipse boundary")
+
+ # origin + ellipse center markers
+ ax.plot([0.0], [0.0], "ko", ms=6, label="Origin")
+ ax.plot([center_xy[0]], [center_xy[1]], "C3x", ms=8, mew=2, label="Ellipse center")
+
+ # direction vector line (draw a long segment both directions)
+ phi = math.radians(direction_deg)
+ ux, uy = math.cos(phi), math.sin(phi)
+
+ lim = ray_span_factor * (max(a_m, b_m) + math.hypot(center_xy[0], center_xy[1]) + 1e-9)
+ x_line = np.array([-lim, lim]) * ux
+ y_line = np.array([-lim, lim]) * uy
+ ax.plot(x_line, y_line, "C1--", lw=1.8, label=f"Direction line ({direction_deg:g}°)")
+
+ # intersections + chord
+ pts = ellipse_line_intersections(
+ a_m, b_m, center_xy=center_xy, orientation_deg=orientation_deg, direction_deg=direction_deg
+ )
+ chord_len = None
+ if pts is not None:
+ (x1, y1), (x2, y2) = pts
+ ax.plot([x1, x2], [y1, y2], "C2-", lw=4.0, alpha=0.5, label="Chord")
+ # ax.plot([x1, x2], [y1, y2], lw=4.0, alpha=0.5, label="Chord")
+ ax.plot([x1, x2], [y1, y2], "C2o", ms=7, alpha=0.5, label="Intersections")
+ # ax.plot([x1, x2], [y1, y2], ms=7, alpha=0.2, label="Intersections")
+ chord_len = math.hypot(x2 - x1, y2 - y1)
+ else:
+ # Still label that there are no intersections/tangent
+ ax.text(0.02, 0.98, "No chord: 0 or 1 intersection", transform=ax.transAxes, va="top", ha="left")
+
+ ax.set_aspect("equal", adjustable="box")
+ ax.set_xlabel("x (m)")
+ ax.set_ylabel("y (m)")
+ if adding_shapes:
+ ax.set_title("Ellipse / direction intersections\n" f"a={a_m:.4g} m, b={b_m:.4g} m, dir={direction_deg:g}°")
+ else:
+ ax.set_title(
+ "Ellipse / direction intersections\n"
+ f"a={a_m:.4g} m, b={b_m:.4g} m, orient={orientation_deg:g}°, center={center_xy}, dir={direction_deg:g}°"
+ )
+ ax.grid(True, alpha=0.3)
+ if ax.get_legend():
+ pass
+ else:
+ ax.legend(loc="upper right")
+
+ # set view to include ellipse nicely
+ # (rough bound: ellipse extent plus center offset)
+ view = 1.2 * (max(a_m, b_m) + math.hypot(center_xy[0], center_xy[1]) + 1e-9)
+ ax.set_xlim(-view, view)
+ ax.set_ylim(-view, view)
+
+ if show:
+ plt.show()
+
+ if output_dir:
+ if not ft.directory_exists(output_dir):
+ ft.create_directories_if_necessary(output_dir)
+ file_path = ft.join(output_dir, figure_name)
+ plt.savefig(file_path)
+
+ return fig, ax, {"intersections": pts, "chord_length_m": chord_len}
+
+
+def _parse_pixel_key(k: str) -> Tuple[int, int]:
+ """
+ Parse a key like "(row, col)" into (row, col) ints.
+ Handles whitespace variations.
+ """
+ s = k.strip()
+ if s.startswith("(") and s.endswith(")"):
+ s = s[1:-1]
+ r_str, c_str = s.split(",")
+ return int(r_str.strip()), int(c_str.strip())
+
+
+def ensure_tz(dt: datetime, tz: ZoneInfo) -> datetime:
+ """
+ Return tz-aware datetime in timezone tz.
+ - If dt is naive: interpret it as wall time in tz.
+ - If dt is aware: convert to tz.
+ """
+ if dt is None:
+ return None
+ if dt.tzinfo is None:
+ return dt.replace(tzinfo=tz)
+ return dt.astimezone(tz)
+
+
+def dt_to_mpl_num_local(dt: datetime, tz: ZoneInfo) -> float:
+ """
+ Convert datetime to Matplotlib date number after normalizing to tz.
+ """
+ if dt is None:
+ return float("nan")
+ return mdates.date2num(ensure_tz(dt, tz))
+
+
+def _to_epoch_seconds(dt: datetime, tz: ZoneInfo) -> float:
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=tz) # interpret naive as local
+ else:
+ dt = dt.astimezone(tz)
+ return dt.timestamp()
+
+
+def _cropped_bbox_from_keys(pixel_dict: Dict[str, Dict[str, Any]], pad: int) -> Tuple[int, int, int, int]:
+ parsed_keys = [_parse_pixel_key(k) for k in pixel_dict.keys()]
+ if not parsed_keys:
+ raise ValueError("pixel_dict is empty.")
+ rows = np.array([r for r, _ in parsed_keys], dtype=int)
+ cols = np.array([c for _, c in parsed_keys], dtype=int)
+
+ rmin, rmax = int(rows.min()), int(rows.max())
+ cmin, cmax = int(cols.min()), int(cols.max())
+ return (rmin - pad, rmax + pad, cmin - pad, cmax + pad) # rmin_p, rmax_p, cmin_p, cmax_p
+
+
+def _extent_from_bbox(rmin_p: int, rmax_p: int, cmin_p: int, cmax_p: int, origin: str):
+ return (
+ [cmin_p - 0.5, cmax_p + 0.5, rmax_p + 0.5, rmin_p - 0.5]
+ if origin == "upper"
+ else [cmin_p - 0.5, cmax_p + 0.5, rmin_p - 0.5, rmax_p + 0.5]
+ )
+
+
+def plot_light_maps_cropped(
+ pixel_dict: Dict[str, Dict[str, Any]],
+ *,
+ tz: ZoneInfo = ZoneInfo("America/Denver"),
+ pad: int = 0, # extra pixels around the bounding box
+ origin: str = "upper",
+ cmap_duration: str = "viridis",
+ cmap_time: str = "cool",
+ nan_color: str = "white",
+ duration_unit: str = "s",
+ show: bool = True,
+):
+ """
+ Like plot_light_maps(), but crops the plotted arrays to the bounding box
+ of pixels present in pixel_dict (optionally padded).
+ """
+ # Parse keys once
+ parsed = [(_parse_pixel_key(k), v) for k, v in pixel_dict.items()]
+ if not parsed:
+ raise ValueError("pixel_dict is empty.")
+
+ rows = np.array([rc[0] for rc, _ in parsed], dtype=int)
+ cols = np.array([rc[1] for rc, _ in parsed], dtype=int)
+
+ rmin, rmax = rows.min(), rows.max()
+ cmin, cmax = cols.min(), cols.max()
+
+ # Apply padding
+ rmin_p = rmin - pad
+ cmin_p = cmin - pad
+ rmax_p = rmax + pad
+ cmax_p = cmax + pad
+
+ # Local array shape for the cropped region
+ nrows = (rmax_p - rmin_p) + 1
+ ncols = (cmax_p - cmin_p) + 1
+
+ dur = np.full((nrows, ncols), np.nan, dtype=float)
+ t0 = np.full((nrows, ncols), np.nan, dtype=float)
+ t1 = np.full((nrows, ncols), np.nan, dtype=float)
+
+ # Fill into cropped coordinates
+ for (r, c), info in parsed:
+ rr = r - rmin_p
+ cc = c - cmin_p
+ dur[rr, cc] = info.get("light_duration", np.nan)
+ t0[rr, cc] = dt_to_mpl_num_local(info.get("light_start_time", None), tz)
+ t1[rr, cc] = dt_to_mpl_num_local(info.get("light_end_time", None), tz)
+
+ # Colormaps with defined NaN color
+ cmap_d = plt.colormaps[cmap_duration].copy()
+ cmap_t = plt.colormaps[cmap_time].copy()
+ cmap_d.set_bad(nan_color)
+ cmap_t.set_bad(nan_color)
+
+ fig, axes = plt.subplots(1, 3, figsize=(18, 6), constrained_layout=True)
+
+ # Use extent so axes show original pixel coordinates (not 0..cropped-1)
+ # For imshow, extent is [xmin, xmax, ymin, ymax] in data coords.
+ extent = (
+ [cmin_p - 0.5, cmax_p + 0.5, rmax_p + 0.5, rmin_p - 0.5]
+ if origin == "upper"
+ else [cmin_p - 0.5, cmax_p + 0.5, rmin_p - 0.5, rmax_p + 0.5]
+ )
+
+ # Duration
+ im0 = axes[0].imshow(dur, origin=origin, cmap=cmap_d, extent=extent)
+ axes[0].set_title(f"Light duration ({duration_unit})")
+ axes[0].set_xlabel("col")
+ axes[0].set_ylabel("row")
+ cb0 = fig.colorbar(im0, ax=axes[0], fraction=0.046, pad=0.04)
+ cb0.set_label(f"Duration ({duration_unit})")
+
+ # Start time
+ im1 = axes[1].imshow(t0, origin=origin, cmap=cmap_t, extent=extent)
+ axes[1].set_title("Light start time")
+ axes[1].set_xlabel("col")
+ axes[1].set_ylabel("row")
+ cb1 = fig.colorbar(im1, ax=axes[1], fraction=0.046, pad=0.04)
+ cb1.set_label("Datetime")
+ cb1.formatter = mdates.DateFormatter("%Y-%m-%d\n%H:%M:%S", tz=tz)
+ cb1.update_ticks()
+
+ # End time
+ im2 = axes[2].imshow(t1, origin=origin, cmap=cmap_t, extent=extent)
+ axes[2].set_title("Light end time")
+ axes[2].set_xlabel("col")
+ axes[2].set_ylabel("row")
+ cb2 = fig.colorbar(im2, ax=axes[2], fraction=0.046, pad=0.04)
+ cb2.set_label("Datetime")
+ cb2.formatter = mdates.DateFormatter("%Y-%m-%d\n%H:%M:%S", tz=tz)
+ cb2.update_ticks()
+
+ # Tighten view to the cropped region bounds (optional; extent already does this)
+ for ax in axes:
+ ax.set_xlim(cmin_p - 0.5, cmax_p + 0.5)
+ ax.set_ylim(rmax_p + 0.5, rmin_p - 0.5) if origin == "upper" else ax.set_ylim(rmin_p - 0.5, rmax_p + 0.5)
+
+ if show:
+ plt.show()
+
+ bbox = {"rmin": rmin, "rmax": rmax, "cmin": cmin, "cmax": cmax, "pad": pad}
+ return fig, axes, {"duration": dur, "start_num": t0, "end_num": t1, "bbox": bbox}
+
+
+def plot_scalar_map_cropped(
+ pixel_dict: Dict[str, Dict[str, Any]],
+ *,
+ value_key: str,
+ title: Optional[str] = None,
+ cbar_label: Optional[str] = None,
+ pad: int = 0,
+ origin: str = "upper",
+ cmap: str = "viridis",
+ data_range: tuple = (None, None),
+ nan_color: str = "white",
+ show: bool = True,
+ ax: Optional[plt.Axes] = None,
+) -> Tuple[plt.Figure, plt.Axes, Dict[str, Any]]:
+ """
+ Generic cropped scalar (float) map plot.
+
+ Parameters
+ ----------
+ value_key:
+ Key inside each pixel's info dict to plot (e.g., "light_duration").
+ title, cbar_label:
+ Plot title / colorbar label. If None, defaults are derived from value_key.
+ """
+ rmin_p, rmax_p, cmin_p, cmax_p = _cropped_bbox_from_keys(pixel_dict, pad=pad)
+ nrows = (rmax_p - rmin_p) + 1
+ ncols = (cmax_p - cmin_p) + 1
+
+ arr = np.full((nrows, ncols), np.nan, dtype=float)
+
+ for k, info in pixel_dict.items():
+ r, c = _parse_pixel_key(k)
+ rr, cc = r - rmin_p, c - cmin_p
+ v = info.get(value_key, np.nan)
+ arr[rr, cc] = np.nan if v is None else float(v)
+
+ cm = plt.colormaps[cmap].copy()
+ cm.set_bad(nan_color)
+
+ extent = _extent_from_bbox(rmin_p, rmax_p, cmin_p, cmax_p, origin)
+
+ if ax is None:
+ fig, ax = plt.subplots(figsize=(7, 6), constrained_layout=True)
+ else:
+ fig = ax.figure
+
+ im = ax.imshow(
+ arr,
+ origin=origin,
+ cmap=cm,
+ extent=extent,
+ vmin=data_range[0] if data_range[0] else None,
+ vmax=data_range[1] if data_range[1] else None,
+ )
+ ax.set_title(title if title is not None else value_key)
+ ax.set_xlabel("col")
+ ax.set_ylabel("row")
+
+ cb = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
+ cb.set_label(cbar_label if cbar_label is not None else value_key)
+
+ # tighten bounds
+ ax.set_xlim(cmin_p - 0.5, cmax_p + 0.5)
+ if origin == "upper":
+ ax.set_ylim(rmax_p + 0.5, rmin_p - 0.5)
+ else:
+ ax.set_ylim(rmin_p - 0.5, rmax_p + 0.5)
+
+ if show:
+ plt.show()
+
+ meta = {
+ "bbox": {"rmin": rmin_p + pad, "rmax": rmax_p - pad, "cmin": cmin_p + pad, "cmax": cmax_p - pad, "pad": pad}
+ }
+ return fig, ax, {"array": arr, "extent": extent, **meta}
+
+
+def plot_time_map_cropped(
+ pixel_dict: Dict[str, Dict[str, Any]],
+ *,
+ time_key: str,
+ tz: ZoneInfo = ZoneInfo("America/Denver"),
+ title: Optional[str] = None,
+ cbar_label: str = "Datetime",
+ pad: int = 0,
+ origin: str = "upper",
+ cmap: str = "cool",
+ nan_color: str = "white",
+ time_fmt: str = "%Y-%m-%d\n%H:%M:%S",
+ show: bool = True,
+ ax: Optional[plt.Axes] = None,
+) -> Tuple[plt.Figure, plt.Axes, Dict[str, Any]]:
+ """
+ Generic cropped datetime map plot.
+
+ Converts datetimes to matplotlib date floats for imshow,
+ and formats the colorbar as datetimes in the specified timezone.
+ """
+ rmin_p, rmax_p, cmin_p, cmax_p = _cropped_bbox_from_keys(pixel_dict, pad=pad)
+ nrows = (rmax_p - rmin_p) + 1
+ ncols = (cmax_p - cmin_p) + 1
+
+ arr = np.full((nrows, ncols), np.nan, dtype=float)
+
+ for k, info in pixel_dict.items():
+ r, c = _parse_pixel_key(k)
+ rr, cc = r - rmin_p, c - cmin_p
+ arr[rr, cc] = dt_to_mpl_num_local(info.get(time_key, None), tz)
+
+ cm = plt.colormaps[cmap].copy()
+ cm.set_bad(nan_color)
+
+ extent = _extent_from_bbox(rmin_p, rmax_p, cmin_p, cmax_p, origin)
+
+ if ax is None:
+ fig, ax = plt.subplots(figsize=(7, 6), constrained_layout=True)
+ else:
+ fig = ax.figure
+
+ im = ax.imshow(arr, origin=origin, cmap=cm, extent=extent)
+ ax.set_title(title if title is not None else time_key)
+ ax.set_xlabel("col")
+ ax.set_ylabel("row")
+
+ cb = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
+ cb.set_label(cbar_label)
+ cb.formatter = mdates.DateFormatter(time_fmt, tz=tz)
+ cb.update_ticks()
+
+ # tighten bounds
+ ax.set_xlim(cmin_p - 0.5, cmax_p + 0.5)
+ if origin == "upper":
+ ax.set_ylim(rmax_p + 0.5, rmin_p - 0.5)
+ else:
+ ax.set_ylim(rmin_p - 0.5, rmax_p + 0.5)
+
+ if show:
+ plt.show()
+
+ meta = {
+ "bbox": {"rmin": rmin_p + pad, "rmax": rmax_p - pad, "cmin": cmin_p + pad, "cmax": cmax_p - pad, "pad": pad}
+ }
+ return fig, ax, {"array_mpl_dates": arr, "extent": extent, **meta}
+
+
+def plot_midpoint_offset_heatmap_tz(
+ pixel_dict: Dict[str, Dict[str, Any]],
+ ideal_mid_time: datetime,
+ *,
+ tz: ZoneInfo = ZoneInfo("America/Denver"),
+ pad: int = 0,
+ origin: str = "upper",
+ cmap: str = "coolwarm",
+ nan_color: str = "white",
+ units: str = "seconds", # "seconds" | "minutes" | "hours"
+ robust: bool = True, # use percentiles for color limits (less outlier-sensitive)
+ robust_pct: Tuple[float, float] = (2, 98),
+ show: bool = True,
+):
+ """
+ For each pixel:
+ midpoint = light_start_time + (light_end_time - light_start_time)/2
+ delta = midpoint - ideal_mid_time
+
+ Timezone handling:
+ - start/end/ideal are coerced to `tz`
+ - naive datetimes are treated as local in `tz`
+ """
+ if units not in {"seconds", "minutes", "hours"}:
+ raise ValueError("units must be one of: 'seconds', 'minutes', 'hours'")
+
+ # Coerce ideal to tz (also makes naive-safe)
+ ideal_mid_time = ensure_tz(ideal_mid_time, tz)
+ if ideal_mid_time is None:
+ raise ValueError("ideal_mid_time cannot be None")
+
+ # Parse keys
+ parsed = [(_parse_pixel_key(k), v) for k, v in pixel_dict.items()]
+ if not parsed:
+ raise ValueError("pixel_dict is empty.")
+
+ rows = np.array([rc[0] for rc, _ in parsed], dtype=int)
+ cols = np.array([rc[1] for rc, _ in parsed], dtype=int)
+ rmin, rmax = int(rows.min()), int(rows.max())
+ cmin, cmax = int(cols.min()), int(cols.max())
+
+ rmin_p, rmax_p = rmin - pad, rmax + pad
+ cmin_p, cmax_p = cmin - pad, cmax + pad
+
+ nrows = (rmax_p - rmin_p) + 1
+ ncols = (cmax_p - cmin_p) + 1
+ delta = np.full((nrows, ncols), np.nan, dtype=float)
+
+ scale = {"seconds": 1.0, "minutes": 60.0, "hours": 3600.0}[units]
+
+ # Fill
+ for (r, c), info in parsed:
+ start = ensure_tz(info.get("light_start_time", None), tz)
+ end = ensure_tz(info.get("light_end_time", None), tz)
+ if start is None or end is None:
+ continue
+
+ # Midpoint (timezone-aware timedeltas are fine)
+ mid = start + (end - start) / 2
+
+ # Signed offset: positive means midpoint is AFTER ideal_mid_time
+ dsec = (mid - ideal_mid_time).total_seconds()
+
+ rr, cc = r - rmin_p, c - cmin_p
+ delta[rr, cc] = dsec / scale
+
+ # Colormap (NaNs)
+ cm = plt.colormaps[cmap].copy()
+ cm.set_bad(nan_color)
+
+ # Symmetric color limits around 0
+ finite = delta[np.isfinite(delta)]
+ if finite.size:
+ if robust and finite.size >= 10:
+ lo, hi = np.percentile(finite, robust_pct)
+ m = max(abs(lo), abs(hi))
+ else:
+ m = float(np.nanmax(np.abs(finite)))
+ vmin, vmax = -m, m
+ else:
+ vmin, vmax = -1.0, 1.0
+
+ # Pixel-coordinate extent
+ extent = (
+ [cmin_p - 0.5, cmax_p + 0.5, rmax_p + 0.5, rmin_p - 0.5]
+ if origin == "upper"
+ else [cmin_p - 0.5, cmax_p + 0.5, rmin_p - 0.5, rmax_p + 0.5]
+ )
+
+ fig, ax = plt.subplots(1, 1, figsize=(7, 6), constrained_layout=True)
+ im = ax.imshow(delta, origin=origin, cmap=cm, extent=extent, vmin=vmin, vmax=vmax)
+ ax.set_title(f"Midpoint offset vs ideal ({units})\nideal = {ideal_mid_time.isoformat()}")
+ ax.set_xlabel("col")
+ ax.set_ylabel("row")
+
+ cb = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
+ cb.set_label(f"midpoint - ideal ({units})")
+
+ if show:
+ plt.show()
+
+ return (
+ fig,
+ ax,
+ {
+ "delta": delta,
+ "ideal_mid_time": ideal_mid_time,
+ "bbox": {"rmin": rmin, "rmax": rmax, "cmin": cmin, "cmax": cmax, "pad": pad},
+ },
+ )
+
+
+def plot_midpoint_offset_heatmap_autoideal(
+ pixel_dict: Dict[str, Dict[str, Any]],
+ *,
+ tz: ZoneInfo = ZoneInfo("America/Denver"),
+ pad: int = 0,
+ origin: str = "upper",
+ cmap: str = "coolwarm",
+ nan_color: str = "white",
+ units: str = "seconds", # "seconds" | "minutes" | "hours"
+ robust: bool = True,
+ robust_pct: Tuple[float, float] = (2, 98),
+ show: bool = True,
+):
+ """
+ 1) Compute midpoint for every pixel with valid start/end.
+ 2) Set ideal_mid_time = median(midpoints) (median in epoch seconds).
+ 3) Heatmap shows signed delta: midpoint - ideal_mid_time, in requested units.
+
+ Returns the ideal time used (timezone-converted to `tz`) along with arrays.
+ """
+ if units not in {"seconds", "minutes", "hours"}:
+ raise ValueError("units must be one of: 'seconds', 'minutes', 'hours'")
+
+ parsed = [(_parse_pixel_key(k), v) for k, v in pixel_dict.items()]
+ if not parsed:
+ raise ValueError("pixel_dict is empty.")
+
+ # Bounding box based on provided keys
+ rows = np.array([rc[0] for rc, _ in parsed], dtype=int)
+ cols = np.array([rc[1] for rc, _ in parsed], dtype=int)
+ rmin, rmax = rows.min(), rows.max()
+ cmin, cmax = cols.min(), cols.max()
+
+ rmin_p, rmax_p = rmin - pad, rmax + pad
+ cmin_p, cmax_p = cmin - pad, cmax + pad
+ nrows = (rmax_p - rmin_p) + 1
+ ncols = (cmax_p - cmin_p) + 1
+
+ # First pass: collect midpoint times (epoch seconds)
+ mids_epoch = []
+ for (_, _), info in parsed:
+ start = info.get("light_start_time")
+ end = info.get("light_end_time")
+ if start is None or end is None:
+ continue
+ # Ensure both are tz-aware for safe arithmetic; if naive, treat as tz
+ if start.tzinfo is None:
+ start = start.replace(tzinfo=tz)
+ if end.tzinfo is None:
+ end = end.replace(tzinfo=tz)
+
+ mid = start + (end - start) / 2
+ mids_epoch.append(_to_epoch_seconds(mid, tz))
+
+ if not mids_epoch:
+ raise ValueError("No valid (light_start_time, light_end_time) pairs found.")
+
+ ideal_epoch = float(np.median(np.array(mids_epoch, dtype=float)))
+ ideal_mid_time = datetime.fromtimestamp(ideal_epoch, tz=timezone.utc).astimezone(tz)
+
+ # Second pass: fill delta grid
+ delta = np.full((nrows, ncols), np.nan, dtype=float)
+ scale = {"seconds": 1.0, "minutes": 60.0, "hours": 3600.0}[units]
+
+ for (r, c), info in parsed:
+ start = info.get("light_start_time")
+ end = info.get("light_end_time")
+ if start is None or end is None:
+ continue
+ if start.tzinfo is None:
+ start = start.replace(tzinfo=tz)
+ if end.tzinfo is None:
+ end = end.replace(tzinfo=tz)
+
+ mid = start + (end - start) / 2
+ dsec = _to_epoch_seconds(mid, tz) - ideal_epoch
+ rr, cc = r - rmin_p, c - cmin_p
+ delta[rr, cc] = dsec / scale
+
+ # Colormap with NaN color
+ cm = plt.colormaps[cmap].copy()
+ cm.set_bad(nan_color)
+
+ finite = delta[np.isfinite(delta)]
+ if finite.size:
+ if robust and finite.size >= 10:
+ lo, hi = np.percentile(finite, robust_pct)
+ m = max(abs(lo), abs(hi))
+ else:
+ m = np.nanmax(np.abs(finite))
+ vmin, vmax = -m, m
+ else:
+ vmin, vmax = -1, 1
+
+ extent = (
+ [cmin_p - 0.5, cmax_p + 0.5, rmax_p + 0.5, rmin_p - 0.5]
+ if origin == "upper"
+ else [cmin_p - 0.5, cmax_p + 0.5, rmin_p - 0.5, rmax_p + 0.5]
+ )
+
+ fig, ax = plt.subplots(1, 1, figsize=(7, 6), constrained_layout=True)
+ im = ax.imshow(delta, origin=origin, cmap=cm, extent=extent, vmin=vmin, vmax=vmax)
+ ax.set_title(f"Midpoint offset vs median midpoint ({units})\nmedian = {ideal_mid_time.isoformat()}")
+ ax.set_xlabel("col")
+ ax.set_ylabel("row")
+
+ cb = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
+ cb.set_label(f"midpoint - median(midpoints) ({units})")
+
+ if show:
+ plt.show()
+
+ return fig, ax, {"delta": delta, "ideal_mid_time": ideal_mid_time, "ideal_epoch": ideal_epoch}
+
+
+def scatter_start_time_vs_duration(
+ pixel_dict: Dict[str, Dict[str, Any]],
+ *,
+ tz: ZoneInfo = ZoneInfo("America/Denver"),
+ ax: Optional[plt.Axes] = None,
+ duration_key: str = "light_duration",
+ start_key: str = "light_start_time",
+ duration_units: str = "s",
+ alpha: float = 0.4,
+ s: float = 12,
+ color: str = "C0",
+ show: bool = True,
+) -> Tuple[plt.Figure, plt.Axes]:
+ """
+ Scatter plot of illumination duration vs start time across all pixels.
+
+ x-axis: light_start_time (datetime; tz-aware OK)
+ y-axis: light_duration (assumed seconds unless your data says otherwise)
+ """
+ xs, ys = [], []
+ for info in pixel_dict.values():
+ start = info.get(start_key, None)
+ dur = info.get(duration_key, None)
+ if start is None or dur is None:
+ continue
+ if not isinstance(start, datetime):
+ raise TypeError(f"{start_key} must be datetime; got {type(start)}")
+
+ start = ensure_tz(start, tz)
+
+ xs.append(start)
+ ys.append(float(dur))
+
+ if ax is None:
+ fig, ax = plt.subplots(figsize=(9, 5), constrained_layout=True)
+ else:
+ fig = ax.figure
+
+ ax.scatter(xs, ys, s=s, alpha=alpha, c=color, edgecolors="none", label=duration_key)
+
+ ax.set_xlabel("Start time")
+ ax.set_ylabel(f"Illumination duration ({duration_units})")
+ ax.set_title("Illumination duration vs start time")
+
+ # Date formatting on x-axis
+ ax.xaxis.set_major_locator(mdates.AutoDateLocator(tz=tz))
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(mdates.AutoDateLocator(tz=tz), tz=tz))
+ fig.autofmt_xdate()
+
+ ax.grid(True, alpha=0.25)
+ ax.legend()
+
+ if show:
+ plt.show()
+
+ return fig, ax
+
+
+def pointing_info_extract(timing_data_location, video_metadata, camera_time_shift, data_time_zone, ideal_midpoint_time):
+
+ video_start_time = datetime.strptime(video_metadata['create_date'], "%Y:%m:%d %H:%M:%S")
+ tz = ZoneInfo(data_time_zone)
+ # interpret create_date as local time in tz (if that's what it is)
+ video_start_time = video_start_time.replace(tzinfo=tz)
+
+ # Coerce ideal to tz (also makes naive-safe)
+ ideal_mid_time = ensure_tz(ideal_midpoint_time, tz)
+ if ideal_mid_time is None:
+ raise ValueError("ideal_mid_time cannot be None")
+
+ # apply camera shift if intended (positive shift moves timestamps forward)
+ video_start_time = video_start_time + camera_time_shift
+
+ data = lbt.read_compressed_json(timing_data_location)
+ details = {}
+
+ for pix, trans in list(data.items()):
+ frame_range_pixel = []
+ if len(trans) < 1:
+ continue
+ else:
+ for transition in trans:
+ if transition["transition"] == "bright":
+ frame_range_pixel.append(tuple((1, lbt.frame_number_from_img_name(transition['to_frame']))))
+ elif transition["transition"] == "dark":
+ frame_range_pixel.append(tuple((0, lbt.frame_number_from_img_name(transition['to_frame']))))
+
+ first_light = min([item for item in frame_range_pixel if item[0] == 1])
+ last_light = max([item for item in frame_range_pixel if item[0] == 0])
+ frame_diff = last_light[1] - first_light[1]
+ light_duration = frame_diff / video_metadata['frame_rate']
+ light_start_time_shift = first_light[1] / video_metadata['frame_rate']
+ light_end_time_shift = last_light[1] / video_metadata['frame_rate']
+ light_start_time = video_start_time + timedelta(seconds=light_start_time_shift)
+ light_end_time = video_start_time + timedelta(seconds=light_end_time_shift)
+
+ cel_vec_max_range_frames, cel_vec_max_frame_span = lbt.maximum_frame_range(frame_ranges=frame_range_pixel)
+ cel_vec_bright_start_time = video_start_time + timedelta(
+ seconds=cel_vec_max_range_frames[0][1] / video_metadata['frame_rate']
+ )
+ cel_vec_bright_end_time = (
+ video_start_time
+ + timedelta(seconds=cel_vec_max_range_frames[0][1] / video_metadata['frame_rate'])
+ + timedelta(seconds=cel_vec_max_frame_span / video_metadata['frame_rate'])
+ )
+ cel_vec_bright_duration = cel_vec_max_frame_span / video_metadata['frame_rate']
+
+ details[pix] = {
+ "first_light": first_light,
+ "last_light": last_light,
+ "cel_vec_start_end": cel_vec_max_range_frames,
+ "light_duration": light_duration,
+ "light_duration_analysis": cel_vec_bright_duration,
+ "light_duration_delta": light_duration - cel_vec_bright_duration,
+ "light_start_time": light_start_time,
+ "light_end_time": light_end_time,
+ "light_midpoint": light_start_time + (light_end_time - light_start_time) / 2,
+ "light_midpoint_delta": (light_start_time + (light_end_time - light_start_time) / 2) - ideal_mid_time,
+ "cel_vec_light_start_time": cel_vec_bright_start_time,
+ "cel_vec_light_end_time": cel_vec_bright_end_time,
+ "cel_vec_light_midpoint": cel_vec_bright_start_time
+ + (cel_vec_bright_end_time - cel_vec_bright_start_time) / 2,
+ "cel_vec_light_midpoint_delta": (
+ cel_vec_bright_start_time + (cel_vec_bright_end_time - cel_vec_bright_start_time) / 2
+ )
+ - ideal_mid_time,
+ "ideal_midpoint_time": ideal_mid_time,
+ }
+
+ return details
+
+
+def _per_pixel_vertical_shift_task(args):
+ """
+ Returns (rr, cc, ch_len_float_or_nan, (shift0, shift1) or (nan, nan))
+ """
+ k, info, value_key, rmin_p, cmin_p, distance_ref, sun_trav_rad_s, ideal_spot, trav_direction = args
+
+ r, c = _parse_pixel_key(k)
+ rr, cc = r - rmin_p, c - cmin_p
+
+ v = info.get(value_key, np.nan)
+
+ ch_len = chord_length_from_transit_time(
+ transit_time_s=v, distance_m=distance_ref, angular_speed_rad_s=sun_trav_rad_s
+ )
+ ch_len_out = np.nan if ch_len is None else float(ch_len)
+
+ temp = fit_y_shifts_for_chord_length(
+ spot=ideal_spot,
+ target_chord_length_m=ch_len,
+ direction_deg=trav_direction,
+ show=False,
+ n_grid=5001,
+ refine_factor=25,
+ refine_half_window_pts=20,
+ )
+
+ shifts = temp.get("shifts", (np.nan, np.nan))
+ if shifts is None or (isinstance(shifts, (list, tuple)) and any(s is None for s in shifts)):
+ shifts_out = (np.nan, np.nan)
+ else:
+ # ensure tuple of floats
+ shifts_out = (float(shifts[0]), float(shifts[1]))
+
+ return rr, cc, ch_len_out, shifts_out
+
+
+def extract_vertical_shift_parallel(
+ pixel_dict,
+ value_key,
+ ideal_spot,
+ distance_ref,
+ sun_trav_rad_s,
+ pad,
+ trav_direction=0,
+ render=False,
+ cmap="coolwarm",
+ nan_color="white",
+ max_workers=None, # None -> os.cpu_count()
+ show=False,
+):
+ rmin_p, rmax_p, cmin_p, cmax_p = _cropped_bbox_from_keys(pixel_dict, pad=pad)
+ nrows = (rmax_p - rmin_p) + 1
+ ncols = (cmax_p - cmin_p) + 1
+
+ ch_lengths = np.full((nrows, ncols), np.nan, dtype=float)
+ shifts_map = np.full((nrows, ncols), np.nan, dtype=object) # store tuples
+
+ # Build tasks (avoid sending huge structures; we send per-item info)
+ items = list(pixel_dict.items())
+ tasks = [
+ (k, info, value_key, rmin_p, cmin_p, distance_ref, sun_trav_rad_s, ideal_spot, trav_direction)
+ for (k, info) in items
+ ]
+
+ # Use processes for CPU-bound work
+ if max_workers is None:
+ max_workers = os.cpu_count() or 1
+
+ with ProcessPoolExecutor(max_workers=max_workers) as ex:
+ # Submit tasks; for very large lists you can submit in batches,
+ # but this is fine for many use cases.
+ futures = [ex.submit(_per_pixel_vertical_shift_task, t) for t in tasks]
+
+ for fut in tqdm(
+ as_completed(futures), total=len(futures), desc="Extracting Vertical Pointing Shifts", unit="pixel"
+ ):
+ rr, cc, ch_len_out, shifts_out = fut.result()
+ ch_lengths[rr, cc] = ch_len_out
+ shifts_map[rr, cc] = shifts_out
+
+ # Build shift0/shift1 maps (vectorized-ish)
+ shift0 = np.full((nrows, ncols), np.nan, dtype=float)
+ shift1 = np.full((nrows, ncols), np.nan, dtype=float)
+ shiftmin = np.full((nrows, ncols), np.nan, dtype=float)
+ shiftmax = np.full((nrows, ncols), np.nan, dtype=float)
+
+ for i in range(nrows):
+ for j in range(ncols):
+ t = shifts_map[i, j]
+ if isinstance(t, tuple) and len(t) >= 2:
+ shift0[i, j] = t[0]
+ shift1[i, j] = t[1]
+ shiftmin[i, j] = np.min(t)
+ shiftmax[i, j] = np.max(t)
+
+ vmin = np.nanmin(shiftmin)
+ vmax = np.nanmax(shiftmax)
+
+ if render:
+ extent = _extent_from_bbox(rmin_p, rmax_p, cmin_p, cmax_p, "upper")
+ cm = plt.colormaps[cmap].copy()
+ cm.set_bad(nan_color)
+
+ fig, ((ax0, ax1), (ax2, ax3)) = plt.subplots(2, 2, figsize=(8, 8))
+
+ im0 = ax0.imshow(shift0, origin="upper", cmap=cm, extent=extent, aspect="equal", vmin=vmin, vmax=vmax)
+ ax0.set_title(value_key + " Shift map First")
+ ax0.set_xlabel("col")
+ ax0.set_ylabel("row")
+ ax0.set_xlim(cmin_p - 0.5, cmax_p + 0.5)
+ ax0.set_ylim(rmax_p + 0.5, rmin_p - 0.5)
+ fig.colorbar(im0, ax=ax0, shrink=0.9)
+
+ im1 = ax1.imshow(shift1, origin="upper", cmap=cm, extent=extent, aspect="equal", vmin=vmin, vmax=vmax)
+ ax1.set_title(value_key + " Shift map Second")
+ ax1.set_xlabel("col")
+ ax1.set_ylabel("row")
+ ax1.set_xlim(cmin_p - 0.5, cmax_p + 0.5)
+ ax1.set_ylim(rmax_p + 0.5, rmin_p - 0.5)
+ fig.colorbar(im1, ax=ax1, shrink=0.9)
+
+ im2 = ax2.imshow(shiftmin, origin="upper", cmap=cm, extent=extent, aspect="equal", vmin=vmin, vmax=vmax)
+ ax2.set_title(value_key + " Shift map Min")
+ ax2.set_xlabel("col")
+ ax2.set_ylabel("row")
+ ax2.set_xlim(cmin_p - 0.5, cmax_p + 0.5)
+ ax2.set_ylim(rmax_p + 0.5, rmin_p - 0.5)
+ fig.colorbar(im2, ax=ax2, shrink=0.9)
+
+ im3 = ax3.imshow(shiftmax, origin="upper", cmap=cm, extent=extent, aspect="equal", vmin=vmin, vmax=vmax)
+ ax3.set_title(value_key + " Shift map Max")
+ ax3.set_xlabel("col")
+ ax3.set_ylabel("row")
+ ax3.set_xlim(cmin_p - 0.5, cmax_p + 0.5)
+ ax3.set_ylim(rmax_p + 0.5, rmin_p - 0.5)
+ fig.colorbar(im3, ax=ax3, shrink=0.9)
+
+ if show:
+ plt.show()
+
+ return fig if fig else None, ch_lengths, shifts_map, shift0, shift1, shiftmin, shiftmax
+
+
+def extract_vertical_shift(
+ pixel_dict,
+ value_key,
+ ideal_spot,
+ distance_ref,
+ sun_trav_rad_s,
+ pad,
+ trav_direction=0,
+ show_full_maps=False,
+ cmap="coolwarm",
+ nan_color="white",
+):
+
+ rmin_p, rmax_p, cmin_p, cmax_p = _cropped_bbox_from_keys(pixel_dict, pad=pad)
+ nrows = (rmax_p - rmin_p) + 1
+ ncols = (cmax_p - cmin_p) + 1
+
+ ch_lengths = np.full((nrows, ncols), np.nan, dtype=float)
+ shifts_map = np.full((nrows, ncols), np.nan, dtype=tuple)
+
+ for k, info in tqdm(
+ pixel_dict.items(), total=len(pixel_dict), desc="Extracting Vertical Pointing Shifts", unit="pixel"
+ ):
+ r, c = _parse_pixel_key(k)
+ rr, cc = r - rmin_p, c - cmin_p
+ v = info.get(value_key, np.nan)
+ ch_len = chord_length_from_transit_time(
+ transit_time_s=v, distance_m=distance_ref, angular_speed_rad_s=sun_trav_rad_s
+ )
+ ch_lengths[rr, cc] = np.nan if ch_len is None else float(ch_len)
+ temp = fit_y_shifts_for_chord_length(
+ spot=ideal_spot,
+ target_chord_length_m=ch_len,
+ direction_deg=trav_direction,
+ show=False,
+ n_grid=5001,
+ refine_factor=25,
+ refine_half_window_pts=20,
+ )
+ shifts_map[rr, cc] = (np.nan, np.nan) if None in temp["shifts"] else temp["shifts"]
+
+ shift0 = np.full((nrows, ncols), np.nan, dtype=float)
+ shift1 = np.full((nrows, ncols), np.nan, dtype=float)
+
+ for i in range(nrows):
+ for j in range(ncols):
+ t = shifts_map[i, j]
+ if isinstance(t, tuple) and len(t) >= 2:
+ shift0[i, j] = t[0]
+ shift1[i, j] = t[1]
+
+ if show_full_maps:
+ extent = _extent_from_bbox(rmin_p, rmax_p, cmin_p, cmax_p, "upper")
+ cm = plt.colormaps[cmap].copy()
+ cm.set_bad(nan_color)
+
+ fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(12, 5), constrained_layout=True)
+
+ im0 = ax0.imshow(shift0, origin="upper", cmap=cm, extent=extent, aspect="equal")
+ ax0.set_title("Shift map (tuple[0])")
+ ax0.set_xlabel("col")
+ ax0.set_ylabel("row")
+ ax0.set_xlim(cmin_p - 0.5, cmax_p + 0.5)
+ ax0.set_ylim(rmax_p + 0.5, rmin_p - 0.5)
+ fig.colorbar(im0, ax=ax0, shrink=0.9)
+
+ im1 = ax1.imshow(shift1, origin="upper", cmap=cm, extent=extent, aspect="equal")
+ ax1.set_title("Shift map (tuple[1])")
+ ax1.set_xlabel("col")
+ ax1.set_ylabel("row")
+ ax1.set_xlim(cmin_p - 0.5, cmax_p + 0.5)
+ ax1.set_ylim(rmax_p + 0.5, rmin_p - 0.5)
+ fig.colorbar(im1, ax=ax1, shrink=0.9)
+
+ plt.show()
+
+ return ch_lengths, shifts_map, shift0, shift1
+
+
+def extract_horizontal_shift(
+ distance_ref: float,
+ sun_trav_rad_s: float,
+ pixel_dict: Dict[str, Dict[str, Any]],
+ *,
+ value_key: str,
+ title: Optional[str] = None,
+ cbar_label: Optional[str] = None,
+ pad: int = 0,
+ origin: str = "upper",
+ cmap: str = "viridis",
+ data_range: tuple = (None, None),
+ nan_color: str = "white",
+ show: bool = True,
+ ax: Optional[plt.Axes] = None,
+) -> Tuple[plt.Figure, plt.Axes, Dict[str, Any]]:
+ """
+ Parameters
+ ----------
+ value_key:
+ Key inside each pixel's info dict to plot (e.g., "light_duration").
+ title, cbar_label:
+ Plot title / colorbar label. If None, defaults are derived from value_key.
+ """
+ velocity = distance_ref * sun_trav_rad_s # m/s
+
+ rmin_p, rmax_p, cmin_p, cmax_p = _cropped_bbox_from_keys(pixel_dict, pad=pad)
+ nrows = (rmax_p - rmin_p) + 1
+ ncols = (cmax_p - cmin_p) + 1
+
+ arr = np.full((nrows, ncols), np.nan, dtype=float)
+
+ for k, info in pixel_dict.items():
+ r, c = _parse_pixel_key(k)
+ rr, cc = r - rmin_p, c - cmin_p
+ v = info.get(value_key, np.nan)
+ arr[rr, cc] = np.nan if v is None else float(v.total_seconds() * velocity)
+
+ cm = plt.colormaps[cmap].copy()
+ cm.set_bad(nan_color)
+
+ extent = _extent_from_bbox(rmin_p, rmax_p, cmin_p, cmax_p, origin)
+
+ if ax is None:
+ fig, ax = plt.subplots(figsize=(7, 6), constrained_layout=True)
+ else:
+ fig = ax.figure
+
+ im = ax.imshow(
+ arr,
+ origin=origin,
+ cmap=cm,
+ extent=extent,
+ vmin=data_range[0] if data_range[0] else None,
+ vmax=data_range[1] if data_range[1] else None,
+ )
+ ax.set_title(title if title is not None else value_key)
+ ax.set_xlabel("col")
+ ax.set_ylabel("row")
+
+ cb = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
+ cb.set_label(cbar_label if cbar_label is not None else value_key)
+
+ # tighten bounds
+ ax.set_xlim(cmin_p - 0.5, cmax_p + 0.5)
+ if origin == "upper":
+ ax.set_ylim(rmax_p + 0.5, rmin_p - 0.5)
+ else:
+ ax.set_ylim(rmin_p - 0.5, rmax_p + 0.5)
+
+ if show:
+ plt.show()
+
+ meta = {
+ "bbox": {"rmin": rmin_p + pad, "rmax": rmax_p - pad, "cmin": cmin_p + pad, "cmax": cmax_p - pad, "pad": pad}
+ }
+ return fig, ax, {"array": arr, "extent": extent, **meta}
+
+
+def array_statistics(x, *, ignore_nan=True, percentiles=(1, 5, 10, 25, 50, 75, 90, 95, 99)):
+ """
+ Compute a dictionary of useful summary statistics for a numpy array.
+
+ Parameters
+ ----------
+ x : array-like
+ Input data (any shape). Will be flattened for summary stats.
+ ignore_nan : bool, default True
+ If True, use NaN-aware stats (nanmean, nanpercentile, etc.).
+ percentiles : tuple of numbers
+ Percentiles to compute (0-100).
+
+ Returns
+ -------
+ stats : dict
+ Dictionary of statistics. If all values are NaN (or input empty),
+ many fields will be NaN and counts will reflect that.
+ """
+ a = np.asarray(x).ravel()
+
+ # Handle empty input early
+ if a.size == 0:
+ return {
+ "count_total": 0,
+ "count_finite": 0,
+ "count_nan": 0,
+ "min": np.nan,
+ "max": np.nan,
+ "mean": np.nan,
+ "median": np.nan,
+ "std": np.nan,
+ "var": np.nan,
+ "iqr": np.nan,
+ "range": np.nan,
+ "percentiles": {p: np.nan for p in percentiles},
+ }
+
+ finite_mask = np.isfinite(a)
+ n_total = a.size
+ n_finite = int(finite_mask.sum())
+ n_nan = int(np.isnan(a).sum())
+
+ # Choose reduction functions
+ if ignore_nan:
+ amin = np.nanmin
+ amax = np.nanmax
+ amean = np.nanmean
+ amedian = np.nanmedian
+ astd = np.nanstd
+ avar = np.nanvar
+ aperc = np.nanpercentile
+ data_for_geom = a[finite_mask]
+ else:
+ amin = np.min
+ amax = np.max
+ amean = np.mean
+ amedian = np.median
+ astd = np.std
+ avar = np.var
+ aperc = np.percentile
+ data_for_geom = a
+
+ # If ignoring NaNs and nothing is finite, return mostly NaNs
+ if ignore_nan and n_finite == 0:
+ return {
+ "count_total": n_total,
+ "count_finite": 0,
+ "count_nan": n_nan,
+ "min": np.nan,
+ "max": np.nan,
+ "mean": np.nan,
+ "median": np.nan,
+ "std": np.nan,
+ "var": np.nan,
+ "iqr": np.nan,
+ "range": np.nan,
+ "mad": np.nan,
+ "percentiles": {p: np.nan for p in percentiles},
+ }
+
+ # Core stats
+ minv = float(amin(a))
+ maxv = float(amax(a))
+ meanv = float(amean(a))
+ medv = float(amedian(a))
+ stdv = float(astd(a))
+ varv = float(avar(a))
+
+ # Quartiles + IQR
+ q25, q50, q75 = aperc(a, [25, 50, 75])
+ iqr = float(q75 - q25)
+
+ # Median absolute deviation (robust spread). "raw" MAD (no scaling).
+ # (If you want approx std for normal data: mad_sigma ≈ 1.4826 * MAD)
+ mad = float(amean(np.abs((a if not ignore_nan else a[finite_mask]) - medv))) if False else None
+ # Use true MAD definition:
+ if ignore_nan:
+ mad = float(np.nanmedian(np.abs(a - medv)))
+ else:
+ mad = float(np.median(np.abs(a - medv)))
+
+ # Percentiles (including quartiles if included in percentiles list)
+ pvals = aperc(a, list(percentiles))
+ percentiles_dict = {float(p): float(v) for p, v in zip(percentiles, pvals)}
+
+ # A couple of commonly useful extras
+ rng = float(maxv - minv)
+ sem = float(stdv / np.sqrt(n_finite if ignore_nan else n_total))
+
+ stats = {
+ "count_total": n_total,
+ "count_finite": n_finite,
+ "count_nan": n_nan,
+ "min": minv,
+ "max": maxv,
+ "range": rng,
+ "mean": meanv,
+ "median": medv,
+ "std": stdv,
+ "var": varv,
+ "sem": sem, # standard error of the mean
+ "q25": float(q25),
+ "q50": float(q50),
+ "q75": float(q75),
+ "iqr": iqr,
+ "mad": mad, # median absolute deviation (robust)
+ "percentiles": percentiles_dict,
+ }
+
+ return stats
+
+
+def pointing_estimate(
+ timing_data_path,
+ video_path,
+ output_dir,
+ camera_time_shift,
+ ideal_time_local,
+ ref_distance,
+ timezone="America/Denver",
+ ideal_spot_parameters={"center": (0, 0), "axis_ratio": 1, "axis_orientation_angle": 0, "traverse_direction": 0.0},
+ sun_diam_pad_mul=1.2,
+):
+ ft.create_directories_if_necessary(output_dir)
+ video_metadata = lbt.extract_detailed_video_metadata(video_path)
+ camera_time_shift = camera_time_shift if camera_time_shift else timedelta(0, 0, 0)
+ timezone = timezone if timezone else "America/Denver"
+ tz = ZoneInfo(timezone)
+ # ideal_time = datetime(2025, 6, 5, 15, 18, 20, tzinfo=tz)
+ ideal_time = ideal_time_local
+
+ results = pointing_info_extract(
+ timing_data_location=timing_data_path,
+ video_metadata=video_metadata,
+ camera_time_shift=camera_time_shift,
+ data_time_zone=timezone,
+ ideal_midpoint_time=ideal_time,
+ )
+
+ # distance = 99.94392
+ distance = ref_distance
+ # traverse_direction = traverse_direction
+ omega = 2 * np.pi / 86400 # rad/s, ~solar apparent motion rate (solar day seconds is 86,400)
+
+ Ideal_spot = reflected_sun_spot_ellipse(
+ distance,
+ angular_diameter_rad=sun_diam_pad_mul * 9.3e-3,
+ axis_ratio=ideal_spot_parameters["axis_ratio"], # circle special case is ratio of 1
+ orientation_deg=ideal_spot_parameters[
+ "axis_orientation_angle"
+ ], # major axis aligned with +x (irrelevant if circle)
+ center_xy=ideal_spot_parameters["center"],
+ )
+ Ideal_spot_chord_length = ellipse_chord_length_along_direction(
+ Ideal_spot.a_m,
+ Ideal_spot.b_m,
+ center_xy=Ideal_spot.center_xy,
+ orientation_deg=Ideal_spot.orientation_deg,
+ direction_deg=ideal_spot_parameters["traverse_direction"],
+ )
+ Ideal_spot_chord_traverse_time = time_for_ellipse_to_pass_chord(
+ chord_length_m=Ideal_spot_chord_length, distance_m=distance, angular_speed_rad_s=omega
+ )
+
+ fig_ito, axes_ito, arrays_ito = plot_midpoint_offset_heatmap_tz(
+ results, ideal_mid_time=ideal_time, tz=tz, pad=2, units="seconds", show=False
+ )
+ fig_ito.savefig(ft.join(output_dir, "ideal_time_midpoint_offset_full.png"))
+
+ fig_horz_full, ax_horz_full, array_horz_full = extract_horizontal_shift(
+ distance_ref=distance,
+ sun_trav_rad_s=omega,
+ pixel_dict=results,
+ value_key="light_midpoint_delta",
+ title="Horizontal Shift Full Range",
+ cbar_label="Horizontal Shift [m]",
+ pad=2,
+ cmap="coolwarm",
+ show=False,
+ )
+ fig_horz_full.savefig(ft.join(output_dir, "ideal_time_midpoint_offset_distance_full.png"))
+
+ array_horz_full_stats = array_statistics(array_horz_full['array'])
+ lbt.write_json(array_horz_full_stats, ft.join(output_dir, "ideal_time_midpoint_offset_distance_full_stats.json"))
+
+ fig_horz_ana, ax_horz_ana, array_horz_ana = extract_horizontal_shift(
+ distance_ref=distance,
+ sun_trav_rad_s=omega,
+ pixel_dict=results,
+ value_key="cel_vec_light_midpoint_delta",
+ title="Horizontal Shift Analysis",
+ cbar_label="Horizontal Shift [m]",
+ pad=2,
+ cmap="coolwarm",
+ show=False,
+ )
+ fig_horz_ana.savefig(ft.join(output_dir, "ideal_time_midpoint_offset_distance_analysis.png"))
+ array_horz_ana_stats = array_statistics(array_horz_ana['array'])
+ lbt.write_json(array_horz_ana_stats, ft.join(output_dir, "ideal_time_midpoint_offset_distance_analysis_stats.json"))
+
+ (
+ fig_v_shift_map_full,
+ ch_len_full,
+ v_shift_map_full,
+ v_shift_full_0,
+ v_shift_full_1,
+ v_shift_full_min,
+ v_shift_full_max,
+ ) = extract_vertical_shift_parallel(
+ pixel_dict=results,
+ value_key="light_duration",
+ ideal_spot=Ideal_spot,
+ distance_ref=distance,
+ sun_trav_rad_s=omega,
+ pad=2,
+ trav_direction=ideal_spot_parameters["traverse_direction"],
+ render=True,
+ show=False,
+ )
+ fig_v_shift_map_full.savefig(ft.join(output_dir, "vertical_shift_full.png"))
+ v_shift_full_min_stats = array_statistics(v_shift_full_min)
+ lbt.write_json(v_shift_full_min_stats, ft.join(output_dir, "vertical_shift_full_minimum_stats.json"))
+ v_shift_full_max_stats = array_statistics(v_shift_full_max)
+ lbt.write_json(v_shift_full_max_stats, ft.join(output_dir, "vertical_shift_full_maximum_stats.json"))
+
+ fig_v_shift_map_ana, ch_len_ana, v_shift_map_ana, v_shift_ana_0, v_shift_ana_1, v_shift_ana_min, v_shift_ana_max = (
+ extract_vertical_shift_parallel(
+ pixel_dict=results,
+ value_key="light_duration_analysis",
+ ideal_spot=Ideal_spot,
+ distance_ref=distance,
+ sun_trav_rad_s=omega,
+ pad=2,
+ trav_direction=ideal_spot_parameters["traverse_direction"],
+ render=True,
+ show=False,
+ )
+ )
+ fig_v_shift_map_full.savefig(ft.join(output_dir, "vertical_shift_analysis.png"))
+ v_shift_ana_min_stats = array_statistics(v_shift_ana_min)
+ lbt.write_json(v_shift_ana_min_stats, ft.join(output_dir, "vertical_shift_analysis_minimum_stats.json"))
+ v_shift_ana_max_stats = array_statistics(v_shift_ana_max)
+ lbt.write_json(v_shift_ana_max_stats, ft.join(output_dir, "vertical_shift_analysis_maximum_stats.json"))
+
+ # fig_time, axes_time, arrays_time = plot_light_maps_cropped(results, tz=tz, pad=2, show=False)
+ # duration (scalar)
+ fig_mmdur, axes_mmdur, dat_mmdur = plot_scalar_map_cropped(
+ results,
+ value_key="light_duration",
+ title="Light Duration (s)",
+ cbar_label="Duration (s)",
+ pad=2,
+ cmap="viridis",
+ show=False,
+ data_range=(0, 200),
+ )
+ fig_mmdur.savefig(ft.join(output_dir, "light_duration_full.png"))
+
+ fig_mmdur_ana, axes_mmdur_ana, dat_mmdur_ana = plot_scalar_map_cropped(
+ results,
+ value_key="light_duration_analysis",
+ title="Light Duration Analysis (s)",
+ cbar_label="Duration (s)",
+ pad=2,
+ cmap="viridis",
+ show=False,
+ data_range=(0, 200),
+ )
+ fig_mmdur_ana.savefig(ft.join(output_dir, "light_duration_analysis.png"))
+
+ # start time (datetime)
+ fig_lst, ax_lst, dat_lst = plot_time_map_cropped(
+ results, time_key="light_start_time", title="Light start time", pad=2, cmap="cool", show=False
+ )
+ fig_lst.savefig(ft.join(output_dir, "light_start_time_full.png"))
+
+ # end time (datetime)
+ fig_let, ax_let, dat_let = plot_time_map_cropped(
+ results, time_key="light_end_time", title="Light end time", pad=2, cmap="cool", show=False
+ )
+ fig_let.savefig(ft.join(output_dir, "light_end_time_full.png"))
+
+ # start time (datetime)
+ fig_lst_ana, ax_lst_ana, dat_lst_ana = plot_time_map_cropped(
+ results, time_key="cel_vec_light_start_time", title="Analysis Light start time", pad=2, cmap="cool", show=False
+ )
+ fig_lst_ana.savefig(ft.join(output_dir, "light_start_time_analysis.png"))
+
+ # end time (datetime)
+ fig_let_ana, ax_let_ana, dat_let_ana = plot_time_map_cropped(
+ results, time_key="cel_vec_light_end_time", title="Analysis Light end time", pad=2, cmap="cool", show=False
+ )
+ fig_let_ana.savefig(ft.join(output_dir, "light_end_time_analysis.png"))
+
+ fig_scatter, axes_scatter = scatter_start_time_vs_duration(results, tz=tz, show=False)
+ fig_scatter.savefig(ft.join(output_dir, "light_start_time_vs_duration_full.png"))
+
+ fig_scat_ana, axes_scat_ana = scatter_start_time_vs_duration(
+ results, tz=tz, color="C3", duration_key="light_duration_analysis", show=False
+ )
+ fig_scat_ana.savefig(ft.join(output_dir, "light_start_time_vs_duration_analysis.png"))
+ fig_scat_delta, axes_scat_delta = scatter_start_time_vs_duration(
+ results, tz=tz, color="C2", duration_key="light_duration_delta", show=False
+ )
+ fig_scat_delta.savefig(ft.join(output_dir, "durations_delta_vs_start_time_full.png"))
+ print("Completed Pointing Estimate Module")
+
+
+if __name__ == "__main__":
+ timing_data_temp_location = r"\\snl\Collaborative\NSTTF_Optics\Projects\_Directories\NSTTF_Optics_LookbackExEx\Experiments\2025-06_05_NsttfTunedFacetScan1dof\3_Post\ini_script_test_3\7_pixel_timing_interrogation\50\time_history_transition_parallel_50_final.json.gz"
+ video_temp_path = r"\\snl\Collaborative\NSTTF_Optics\Projects\_Directories\NSTTF_Optics_LookbackExEx\Experiments\2025-06_05_NsttfTunedFacetScan1dof\3_Post\ini_script_test_3\DSC_0025.MOV"
+ output_dir = r"\\snl\Collaborative\NSTTF_Optics\Projects\_Directories\NSTTF_Optics_LookbackExEx\Experiments\2025-06_05_NsttfTunedFacetScan1dof\3_Post\ini_script_test_3\10_pointing_estimate"
+ video_metadata_temp = lbt.extract_detailed_video_metadata(video_temp_path)
+ camera_time_shift_temp = timedelta(0, 0, 0)
+ timezone_temp = "America/Denver"
+ tz = ZoneInfo(timezone_temp)
+ ideal_time = datetime(2025, 6, 5, 15, 18, 20, tzinfo=tz)
+
+ results = pointing_info_extract(
+ timing_data_location=timing_data_temp_location,
+ video_metadata=video_metadata_temp,
+ camera_time_shift=camera_time_shift_temp,
+ data_time_zone=timezone_temp,
+ ideal_midpoint_time=ideal_time,
+ )
+
+ distance = 99.94392
+ traverse_direction = 0
+ omega = 2 * np.pi / 86400 # rad/s, ~solar apparent motion rate (solar day seconds is 86,400)
+ sun_diam_pad_mul = 1.2
+
+ Ideal_spot = reflected_sun_spot_ellipse(
+ distance,
+ angular_diameter_rad=sun_diam_pad_mul * 9.3e-3,
+ axis_ratio=1, # circle special case is ratio of 1
+ orientation_deg=0, # major axis aligned with +x (irrelevant if circle)
+ center_xy=(0, 0),
+ )
+ Ideal_spot_chord_length = ellipse_chord_length_along_direction(
+ Ideal_spot.a_m,
+ Ideal_spot.b_m,
+ center_xy=Ideal_spot.center_xy,
+ orientation_deg=Ideal_spot.orientation_deg,
+ direction_deg=traverse_direction,
+ )
+ Ideal_spot_chord_traverse_time = time_for_ellipse_to_pass_chord(
+ chord_length_m=Ideal_spot_chord_length, distance_m=distance, angular_speed_rad_s=omega
+ )
+
+ plot_ellipse_and_center_chord(spot_obj=Ideal_spot, direction_deg=traverse_direction, show=False)
+
+ shift_test_chord_length = 1.5 * Ideal_spot.a_m
+ out = fit_y_shifts_for_chord_length(
+ Ideal_spot,
+ shift_test_chord_length,
+ n_grid=10001,
+ direction_deg=traverse_direction,
+ show=False,
+ refine_factor=50,
+ refine_half_window_pts=50,
+ )
+ fig_ito, axes_ito, arrays_ito = plot_midpoint_offset_heatmap_tz(
+ results, ideal_mid_time=ideal_time, tz=tz, pad=2, units="seconds", show=False
+ )
+ fig_ito.savefig(ft.join(output_dir, "ideal_time_midpoint_offset_full.png"))
+ # fig_mid, axes_mid, arrays_mid = plot_midpoint_offset_heatmap_autoideal(
+ # results, tz=tz, pad=2, units="seconds", show=False
+ # )
+ fig_horz_full, ax_horz_full, array_horz_full = extract_horizontal_shift(
+ distance_ref=distance,
+ sun_trav_rad_s=omega,
+ pixel_dict=results,
+ value_key="light_midpoint_delta",
+ title="Horizontal Shift Full Range",
+ cbar_label="Horizontal Shift [m]",
+ pad=2,
+ cmap="coolwarm",
+ show=False,
+ )
+ fig_horz_full.savefig(ft.join(output_dir, "ideal_time_midpoint_offset_distance_full.png"))
+
+ array_horz_full_stats = array_statistics(array_horz_full['array'])
+ lbt.write_json(array_horz_full_stats, ft.join(output_dir, "ideal_time_midpoint_offset_distance_full_stats.json"))
+
+ fig_horz_ana, ax_horz_ana, array_horz_ana = extract_horizontal_shift(
+ distance_ref=distance,
+ sun_trav_rad_s=omega,
+ pixel_dict=results,
+ value_key="cel_vec_light_midpoint_delta",
+ title="Horizontal Shift Analysis",
+ cbar_label="Horizontal Shift [m]",
+ pad=2,
+ cmap="coolwarm",
+ show=False,
+ )
+ fig_horz_ana.savefig(ft.join(output_dir, "ideal_time_midpoint_offset_distance_analysis.png"))
+ array_horz_ana_stats = array_statistics(array_horz_ana['array'])
+ lbt.write_json(array_horz_ana_stats, ft.join(output_dir, "ideal_time_midpoint_offset_distance_analysis_stats.json"))
+
+ (
+ fig_v_shift_map_full,
+ ch_len_full,
+ v_shift_map_full,
+ v_shift_full_0,
+ v_shift_full_1,
+ v_shift_full_min,
+ v_shift_full_max,
+ ) = extract_vertical_shift_parallel(
+ pixel_dict=results,
+ value_key="light_duration",
+ ideal_spot=Ideal_spot,
+ distance_ref=distance,
+ sun_trav_rad_s=omega,
+ pad=2,
+ trav_direction=traverse_direction,
+ render=True,
+ show=False,
+ )
+ fig_v_shift_map_full.savefig(ft.join(output_dir, "vertical_shift_full.png"))
+ v_shift_full_min_stats = array_statistics(v_shift_full_min)
+ lbt.write_json(v_shift_full_min_stats, ft.join(output_dir, "vertical_shift_full_minimum_stats.json"))
+ v_shift_full_max_stats = array_statistics(v_shift_full_max)
+ lbt.write_json(v_shift_full_max_stats, ft.join(output_dir, "vertical_shift_full_maximum_stats.json"))
+
+ fig_v_shift_map_ana, ch_len_ana, v_shift_map_ana, v_shift_ana_0, v_shift_ana_1, v_shift_ana_min, v_shift_ana_max = (
+ extract_vertical_shift_parallel(
+ pixel_dict=results,
+ value_key="light_duration_analysis",
+ ideal_spot=Ideal_spot,
+ distance_ref=distance,
+ sun_trav_rad_s=omega,
+ pad=2,
+ trav_direction=traverse_direction,
+ render=True,
+ show=False,
+ )
+ )
+ fig_v_shift_map_full.savefig(ft.join(output_dir, "vertical_shift_analysis.png"))
+ v_shift_ana_min_stats = array_statistics(v_shift_ana_min)
+ lbt.write_json(v_shift_ana_min_stats, ft.join(output_dir, "vertical_shift_analysis_minimum_stats.json"))
+ v_shift_ana_max_stats = array_statistics(v_shift_ana_max)
+ lbt.write_json(v_shift_ana_max_stats, ft.join(output_dir, "vertical_shift_analysis_maximum_stats.json"))
+
+ # fig_time, axes_time, arrays_time = plot_light_maps_cropped(results, tz=tz, pad=2, show=False)
+ # duration (scalar)
+ fig_mmdur, axes_mmdur, dat_mmdur = plot_scalar_map_cropped(
+ results,
+ value_key="light_duration",
+ title="Light Duration (s)",
+ cbar_label="Duration (s)",
+ pad=2,
+ cmap="viridis",
+ show=False,
+ data_range=(0, 200),
+ )
+ fig_mmdur.savefig(ft.join(output_dir, "light_duration_full.png"))
+
+ fig_mmdur_ana, axes_mmdur_ana, dat_mmdur_ana = plot_scalar_map_cropped(
+ results,
+ value_key="light_duration_analysis",
+ title="Light Duration Analysis (s)",
+ cbar_label="Duration (s)",
+ pad=2,
+ cmap="viridis",
+ show=False,
+ data_range=(0, 200),
+ )
+ fig_mmdur_ana.savefig(ft.join(output_dir, "light_duration_analysis.png"))
+
+ # start time (datetime)
+ fig_lst, ax_lst, dat_lst = plot_time_map_cropped(
+ results, time_key="light_start_time", title="Light start time", pad=2, cmap="cool", show=False
+ )
+ fig_lst.savefig(ft.join(output_dir, "light_start_time_full.png"))
+
+ # end time (datetime)
+ fig_let, ax_let, dat_let = plot_time_map_cropped(
+ results, time_key="light_end_time", title="Light end time", pad=2, cmap="cool", show=False
+ )
+ fig_let.savefig(ft.join(output_dir, "light_end_time_full.png"))
+
+ # start time (datetime)
+ fig_lst_ana, ax_lst_ana, dat_lst_ana = plot_time_map_cropped(
+ results, time_key="cel_vec_light_start_time", title="Analysis Light start time", pad=2, cmap="cool", show=False
+ )
+ fig_lst_ana.savefig(ft.join(output_dir, "light_start_time_analysis.png"))
+
+ # end time (datetime)
+ fig_let_ana, ax_let_ana, dat_let_ana = plot_time_map_cropped(
+ results, time_key="cel_vec_light_end_time", title="Analysis Light end time", pad=2, cmap="cool", show=False
+ )
+ fig_let_ana.savefig(ft.join(output_dir, "light_end_time_analysis.png"))
+
+ fig_scatter, axes_scatter = scatter_start_time_vs_duration(results, tz=tz, show=False)
+ fig_scatter.savefig(ft.join(output_dir, "light_start_time_vs_duration_full.png"))
+
+ fig_scat_ana, axes_scat_ana = scatter_start_time_vs_duration(
+ results, tz=tz, color="C3", duration_key="light_duration_analysis", show=False
+ )
+ fig_scat_ana.savefig(ft.join(output_dir, "light_start_time_vs_duration_analysis.png"))
+ fig_scat_delta, axes_scat_delta = scatter_start_time_vs_duration(
+ results, tz=tz, color="C2", duration_key="light_duration_delta", show=False
+ )
+ fig_scat_delta.savefig(
+ ft.join(output_dir, "delta_between_durations_full_and_analysis_interval_vs_start_time_full.png")
+ )
+
+ plt.show()
+ print("stop here")
diff --git a/contrib/app/LookFast/time_history_array_mp_npz.py b/contrib/app/LookFast/time_history_array_mp_npz.py
new file mode 100644
index 00000000..bcccbd4c
--- /dev/null
+++ b/contrib/app/LookFast/time_history_array_mp_npz.py
@@ -0,0 +1,154 @@
+import os
+from logging import DEBUG, ERROR
+from multiprocessing import Pool
+
+import cv2
+import numpy as np
+from tqdm import tqdm
+
+import opencsp.common.lib.tool.file_tools as ft
+
+# import opencsp.app.lookback.lookback_tools as lbt
+import contrib.app.LookFast.lookback_tools as lbt
+
+# Specify the folder where the log file should be saved
+logger = lbt.logging_setup(
+ log_folder=os.path.join(os.getcwd(), "error_logs"),
+ log_file_name="error_log_time_history_array_debug_mp.txt",
+ log_type=DEBUG,
+)
+
+
+def process_image_bit_depth_cv(image_path, fraction):
+ """
+ Processes a single image to create a binary array based on a threshold.
+ """
+ # Load the image using OpenCV and convert it to grayscale
+ image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
+
+ # Convert the image to a NumPy array
+ image_array = np.array(image)
+
+ # Determine the maximum possible pixel value based on the bit depth
+ max_intensity = np.iinfo(image.dtype).max if np.issubdtype(image.dtype, np.integer) else 255
+ # max_possible_pixel_value = 255 # OpenCV loads images as 8-bit by default
+
+ # Calculate the threshold as a percentage of the maximum possible pixel value
+ threshold = fraction * max_intensity
+
+ # Apply the threshold to create a binary array
+ binary_array = (image_array > threshold).astype(bool) # 1 for pixels > threshold, 0 otherwise
+
+ # Return the image name and binary array
+ return os.path.basename(image_path), binary_array.tolist()
+
+
+def process_batch(batch_index, batch_paths, percentage, output_folder, percentage_key):
+ """
+ Processes a single batch of images sequentially and saves the results to disk.
+
+ Parameters:
+ batch_index (int): Index of the batch being processed.
+ batch_paths (list of str): List of image file paths in the batch.
+ percentage (float): Threshold percentage for processing.
+ output_folder (str): Folder to save the structured binary arrays to disk.
+ percentage_key (str): Subfolder name for the current percentage.
+
+ Returns:
+ int: The batch index (for checkpoint updates).
+ """
+ batch_data = []
+ img_names = []
+ for image_path in batch_paths:
+ result = process_image_bit_depth_cv(image_path, percentage)
+ batch_data.append(result[1])
+ img_names.append(result[0])
+ del result
+
+ # Save the batch to disk as a compressed numpy file
+ batch_output_path = ft.join(
+ output_folder, percentage_key, f"threshold_{int(percentage * 100):03d}_batch_{batch_index:04d}.npz"
+ )
+
+ np.savez_compressed(batch_output_path, np.array(batch_data))
+ _, batch_name, _ = ft.path_components(batch_output_path)
+ lbt.write_json(img_names, ft.join(output_folder, percentage_key, batch_name + ".json"))
+ logger.info("Batch %s written to %s", str(batch_index), batch_output_path)
+ return batch_index
+
+
+def process_batch_multiprocessing(args):
+ """
+ Wrapper function for multiprocessing to process a batch of images.
+ """
+ batch_index, batch_paths, percentage, output_folder, percentage_key = args
+ return process_batch(batch_index, batch_paths, percentage, output_folder, percentage_key)
+
+
+def create_binary_pixel_array_parallel_with_multiprocessing(
+ image_folder_path,
+ percentages,
+ output_folder,
+ checkpoint_folder,
+ batch_size=1000,
+ overlap=1,
+ checkpoint_file="time_history_array_checkpoint.json",
+ num_workers=4, # Number of parallel processes
+):
+ """
+ Processes images in parallel using multiprocessing.
+ """
+ # Ensure the output folder exists
+ os.makedirs(output_folder, exist_ok=True)
+
+ # Load checkpoint if it exists
+ checkpoint_data = lbt.load_checkpoint(checkpoint_folder, checkpoint_file)
+ if checkpoint_data is None:
+ checkpoint_data = {"processed_batches": {str(int(p * 100)): [] for p in percentages}}
+
+ # Get all image file paths from the folder
+ image_paths = [
+ os.path.join(image_folder_path, file)
+ for file in os.listdir(image_folder_path)
+ if file.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.tiff'))
+ ]
+ image_paths.sort()
+
+ if not image_paths:
+ raise ValueError("No valid image files found in the specified folder.")
+
+ for percentage in percentages:
+ percentage_key = f"{int(percentage * 100):02d}"
+ os.makedirs(os.path.join(output_folder, percentage_key), exist_ok=True)
+ print(f"Processing images for threshold percentage: {int(percentage * 100):02d}%")
+
+ # Create overlapping batches
+ batches = []
+ for batch_start in range(0, len(image_paths), batch_size - overlap):
+ batch_end = min(batch_start + batch_size, len(image_paths))
+ batches.append((batch_start // (batch_size - overlap) + 1, image_paths[batch_start:batch_end]))
+
+ # Process batches in parallel using multiprocessing
+ if percentage_key in checkpoint_data["processed_batches"]:
+ processed_batches = set(checkpoint_data["processed_batches"][percentage_key])
+ else:
+ checkpoint_data["processed_batches"][percentage_key] = []
+ processed_batches = set(checkpoint_data["processed_batches"][percentage_key])
+
+ args = [
+ (batch_index, batch_paths, percentage, output_folder, percentage_key)
+ for batch_index, batch_paths in batches
+ if batch_index not in processed_batches
+ ]
+
+ with Pool(processes=num_workers) as pool:
+ for batch_index in tqdm(
+ pool.imap_unordered(process_batch_multiprocessing, args), total=len(args), desc="Time History Arrays"
+ ):
+ processed_batches.add(batch_index)
+
+ # Update checkpoint after all batches are processed
+ checkpoint_data["processed_batches"][percentage_key] = list(processed_batches)
+ lbt.save_checkpoint(checkpoint_folder, checkpoint_file, checkpoint_data)
+
+ print(f"Finished processing for threshold {int(percentage * 100):02d}%.")
diff --git a/contrib/app/LookFast/time_history_transitions_npz_mp.py b/contrib/app/LookFast/time_history_transitions_npz_mp.py
new file mode 100644
index 00000000..ce37cb55
--- /dev/null
+++ b/contrib/app/LookFast/time_history_transitions_npz_mp.py
@@ -0,0 +1,430 @@
+import os
+from logging import DEBUG, ERROR
+from concurrent.futures import ProcessPoolExecutor
+
+import numpy as np
+from tqdm import tqdm
+import matplotlib
+
+matplotlib.use('Agg')
+import matplotlib.pyplot as plt
+
+# import opencsp.app.lookback.lookback_tools as lbt
+import contrib.app.LookFast.lookback_tools as lbt
+
+# Specify the folder where the log file should be saved
+logger = lbt.logging_setup(
+ log_folder=os.path.join(os.getcwd(), "error_logs"),
+ log_file_name="error_log_time_history_transition_mp_debug.txt",
+ log_type=DEBUG,
+)
+
+
+def merge_results(output_folder, final_output_file):
+ """
+ Merges all intermediate results into a single .npz file.
+
+ Parameters:
+ output_folder (str): Path to the folder containing intermediate results.
+ final_output_file (str): Path to the final output .npz file.
+
+ Returns:
+ None
+ """
+ merged_results = {}
+
+ # Get all intermediate result files
+ result_files = sorted(
+ [os.path.join(output_folder, f) for f in os.listdir(output_folder) if f.endswith("_results.json.gz")]
+ )
+
+ # Merge results from all files
+ for result_file in result_files:
+ batch_results = lbt.read_compressed_json(file_path=result_file)
+ for pixel, transitions in batch_results.items():
+ if pixel not in merged_results:
+ merged_results[pixel] = []
+ if isinstance(transitions, list):
+ merged_results[pixel].extend(transitions)
+
+ # Write merged results to the final output file
+ # np.savez_compressed(os.path.join(output_folder, final_output_file), allow_pickle=True, **merged_results)
+ # lbt.save_batch_to_npz(batch_data=merged_results, output_path=os.path.join(output_folder, final_output_file))
+ lbt.write_compressed_json(
+ data=merged_results, file_path=os.path.join(output_folder, final_output_file), print_path=True
+ )
+
+
+def write_intermediate_results(results, output_folder, batch_index):
+ """
+ Writes intermediate results to disk as a .npz file.
+
+ Parameters:
+ results (dict): Intermediate results for pixel transitions.
+ output_folder (str): Path to the output folder.
+ batch_index (int): Index of the current batch.
+
+ Returns:
+ None
+ """
+ output_file = os.path.join(output_folder, f"batch_{batch_index+1:04d}_results.json.gz")
+ # np.savez_compressed(output_file, allow_pickle=True, **results)
+ lbt.write_compressed_json(data=results, file_path=output_file, print_path=False)
+
+
+def process_npz_file_within_batch(npz_file, json_file, pixel_locations):
+ """
+ Processes a single .npz file to analyze pixel brightness transitions within the batch.
+
+ Parameters:
+ npz_file (str): Path to the .npz file.
+ pixel_locations (list of tuple): List of pixel locations to interrogate.
+
+ Returns:
+ dict: Results for the pixel transitions within the batch.
+ """
+ # Read the .npz file
+ data_all = lbt.read_batch_from_npz(npz_file)
+ data = data_all[0]['binary_array']
+
+ img_names = lbt.read_json(json_file)
+
+ # Initialize a dictionary to store results for each pixel
+ pixel_results = {str(pixel): [] for pixel in pixel_locations} # Convert tuple keys to strings
+
+ image_names = []
+ binary_arrays = []
+ for frame, img_name in zip(data, img_names):
+ image_names.append(img_name)
+ binary_arrays.append(frame)
+
+ # Iterate over the images in the batch
+ for i in range(len(binary_arrays) - 1): # Compare each image with the next one
+ current_image_name = image_names[i]
+ next_image_name = image_names[i + 1]
+
+ current_binary_array = np.array(binary_arrays[i])
+ next_binary_array = np.array(binary_arrays[i + 1])
+
+ # Compare pixel values between the current image and the next image
+ for pixel in pixel_locations:
+ row, col = pixel # Pixel coordinates
+
+ # Current and next pixel values
+ current_pixel_value = current_binary_array[row, col]
+ next_pixel_value = next_binary_array[row, col]
+
+ # Bright transition (0 -> 1)
+ if current_pixel_value == 0 and next_pixel_value == 1:
+ pixel_results[str(pixel)].append(
+ {"transition": "bright", "from_frame": current_image_name, "to_frame": next_image_name}
+ )
+
+ # Dark transition (1 -> 0)
+ if current_pixel_value == 1 and next_pixel_value == 0:
+ pixel_results[str(pixel)].append(
+ {"transition": "dark", "from_frame": current_image_name, "to_frame": next_image_name}
+ )
+
+ return pixel_results
+
+
+def conditional_process(args):
+ batch_index, npz_file, json_file, pixel_locations, checkpoint_data = args
+ if batch_index in checkpoint_data["processed_batches"]:
+ print(f"Skipping already processed batch {npz_file}")
+ return None # Return None for already processed files
+ return process_npz_file_within_batch(npz_file, json_file, pixel_locations)
+
+
+def analyze_pixel_brightness_parallel_npz(
+ npz_folder, pixel_locations, output_folder, final_output_file, checkpoint_folder, checkpoint_file_name
+):
+ """
+ Analyzes pixel brightness transitions in parallel and writes results to disk, with checkpoint functionality.
+
+ Parameters:
+ npz_folder (str): Path to the folder containing .npz files.
+ pixel_locations (list of tuple): List of pixel locations to interrogate.
+ output_folder (str): Path to the folder for intermediate results.
+ final_output_file (str): Path to the final output .npz file.
+ checkpoint_folder (str): Path to the checkpoint file.
+ checkpoint_file_name (str): Checkpoint file name.
+
+ Returns:
+ None
+ """
+ # Check if the final output file already exists
+ if os.path.exists(os.path.join(output_folder, final_output_file)):
+ print(f"Final output file '{final_output_file}' already exists. Skipping analysis to avoid rework.")
+ return
+ # Ensure the output folder exists
+ os.makedirs(output_folder, exist_ok=True)
+
+ # Get all .npz files in the folder, sorted by batch order
+ npz_files = sorted([os.path.join(npz_folder, f) for f in os.listdir(npz_folder) if f.endswith(".npz")])
+ json_files = sorted([os.path.join(npz_folder, f) for f in os.listdir(npz_folder) if f.endswith(".json")])
+
+ if not npz_files:
+ raise ValueError("No .npz files found in the specified folder.")
+
+ # Load checkpoint if it exists
+ checkpoint_data = lbt.load_checkpoint(checkpoint_folder, checkpoint_file_name)
+ if checkpoint_data is None:
+ checkpoint_data = {"processed_batches": []}
+
+ # Process files in parallel
+ with ProcessPoolExecutor(max_workers=3) as executor:
+
+ tasks = [
+ (batch_index, npz_file, json_file, pixel_locations, checkpoint_data)
+ for batch_index, (npz_file, json_file) in enumerate(zip(npz_files, json_files))
+ ]
+
+ results = list(tqdm(executor.map(conditional_process, tasks), total=len(tasks), desc="Processing Batches"))
+
+ '''
+ futures = []
+ for batch_index, (npz_file, json_file) in enumerate(zip(npz_files, json_files)):
+ if batch_index in checkpoint_data["processed_batches"]:
+ print(f"Skipping already processed batch {npz_file}")
+ continue
+ futures.append(executor.submit(process_npz_file_within_batch, npz_file, json_file, pixel_locations))
+ # Use tqdm to track progress of futures
+ for batch_comp_index, future in enumerate(
+ tqdm(as_completed(futures), total=len(futures), desc="Processing Batches")
+ ):
+ results = future.result()
+ write_intermediate_results(results, output_folder, batch_index)
+
+ '''
+ for batch_comp_index, result in enumerate(results):
+ if result is None:
+ continue
+ write_intermediate_results(result, output_folder, batch_comp_index)
+
+ # Update checkpoint
+ checkpoint_data["processed_batches"].append(batch_comp_index)
+ lbt.save_checkpoint(checkpoint_folder, checkpoint_file_name, checkpoint_data)
+
+ # Merge intermediate results into a final output file
+ merge_results(output_folder, final_output_file)
+
+
+def create_timing_plots_json(
+ compiled_json, output_folder, source_image_folder, checkpoint_folder, checkpoint_file="timing_plots_checkpoint.json"
+):
+ """
+ Create timing plots for each pixel using Pillow and update a checkpoint file.
+
+ Parameters:
+ compiled_json (str): Path to the compiled JSON file.
+ output_folder (str): Folder to save the timing plots.
+ source_image_folder (str): Folder containing source images.
+ checkpoint_folder (str): Folder to save the checkpoint file.
+ checkpoint_file (str): Name of the checkpoint file.
+
+ Returns:
+ None
+ """
+ # Ensure the output folder exists
+ os.makedirs(output_folder, exist_ok=True)
+ os.makedirs(checkpoint_folder, exist_ok=True)
+
+ # Load checkpoint if it exists
+ checkpoint_data = lbt.load_checkpoint(checkpoint_folder, checkpoint_file)
+ if checkpoint_data is None:
+ checkpoint_data = {"processed_pixels": []}
+
+ # Get all .png files in the folder, sorted by batch order
+ image_files = sorted(
+ [os.path.join(source_image_folder, f) for f in os.listdir(source_image_folder) if f.lower().endswith(".png")]
+ )
+ if not image_files:
+ raise ValueError("No source image files (.png) found in the specified folder.")
+
+ frames = []
+ for item in image_files:
+ frames.append(lbt.frame_number_from_img_name(item))
+ frames = sorted(frames)
+
+ if not compiled_json:
+ raise ValueError("No .json.gz files found in the specified folder.")
+
+ data = lbt.read_compressed_json(compiled_json)
+ # Create a tqdm progress bar outside the loop
+ progress_bar = tqdm(total=len(data), desc="Creating Timing Plots and Writing Data")
+
+ # Iterate over each pixel in the compiled JSON data
+ for pixel, transitions in data.items():
+ if pixel in checkpoint_data["processed_pixels"]:
+ print(f"Skipping already processed pixel {pixel}.")
+ progress_bar.update(1) # Update the progress bar even if skipping
+ continue
+
+ # Initialize a binary array for the pixel
+ # binary_state = {frame: 0 for frame in frames} # Default to dark (0) for all frames
+ binary_state = np.array([frames, np.zeros(len(frames))], dtype=np.int64).T
+
+ transitions_arr = np.zeros((len(transitions), 2), dtype=np.int64)
+ for ti, transition in enumerate(transitions):
+ frame_index = lbt.frame_number_from_img_name(transition["to_frame"]) # Find the index of the frame
+ transitions_arr[ti, 0] = frame_index
+ if transition["transition"] == "bright":
+ transitions_arr[ti, 1] = 1 # Set to bright (1)
+
+ elif transition["transition"] == "dark":
+ transitions_arr[ti, 1] = 0 # Set to dark (0)
+
+ # Iterate through the transitions
+ for i in range(len(transitions_arr)):
+ frame_index = transitions_arr[i, 0]
+ transition_state = transitions_arr[i, 1]
+
+ # Find the index in the data array where the frame_index matches
+ data_index = np.where(binary_state[:, 0] == frame_index)[0]
+
+ if data_index.size > 0:
+ # Fill the values in between the current transition and the next one
+ if i < len(transitions_arr) - 1:
+ next_frame_index = transitions_arr[i + 1, 0]
+ # Fill the range between the current frame and the next frame
+ data_range = np.where(
+ (binary_state[:, 0] >= frame_index) & (binary_state[:, 0] < next_frame_index)
+ )[0]
+ binary_state[data_range, 1] = transition_state
+
+ plt.figure(figsize=(6, 4))
+ plt.plot(binary_state[:, 0], binary_state[:, 1])
+ plt.title(f"Pixel {pixel} Timing Plot")
+ plt.xlabel("Frame Number")
+ plt.ylabel("Binary State")
+
+ # Save the plot to the output folder
+ height, _ = eval(pixel)
+ if os.path.isdir(os.path.join(output_folder, str(height))):
+ plot_file = os.path.normpath(os.path.join(output_folder, str(height), f"pixel_{pixel}_timing_plot.jpg"))
+ plt.savefig(plot_file, dpi=100, bbox_inches='tight')
+ plt.close()
+ lbt.write_compressed_json(
+ binary_state, os.path.join(output_folder, str(height), f"pixel_{pixel}_timing_plot_data.json.gz")
+ )
+ else:
+ os.makedirs(os.path.join(output_folder, str(height)), exist_ok=True)
+ plot_file = os.path.normpath(os.path.join(output_folder, str(height), f"pixel_{pixel}_timing_plot.jpg"))
+ plt.savefig(plot_file, dpi=100, bbox_inches='tight')
+ plt.close()
+ lbt.write_compressed_json(
+ binary_state, os.path.join(output_folder, str(height), f"pixel_{pixel}_timing_plot_data.json.gz")
+ )
+ # Update checkpoint
+ checkpoint_data["processed_pixels"].append(pixel)
+ lbt.save_checkpoint(checkpoint_folder, checkpoint_file, checkpoint_data, print_path=False)
+ # Update the progress bar
+ progress_bar.update(1)
+
+
+def pixel_timing_plot(pixel, transitions, frames, output_folder, checkpoint_data):
+ """
+ Process a single pixel to create its timing plot and save the data.
+
+ Parameters:
+ pixel (str): The pixel identifier.
+ transitions (list): The transitions for the pixel.
+ frames (list): The list of frame numbers.
+ output_folder (str): Folder to save the timing plots.
+ checkpoint_data (dict): Checkpoint data to update.
+
+ Returns:
+ str: The pixel identifier if processed successfully, else None.
+ """
+ # Initialize a binary array for the pixel
+ binary_state = np.array([frames, np.zeros(len(frames))], dtype=np.int64).T
+
+ transitions_arr = np.zeros((len(transitions), 2), dtype=np.int64)
+ for ti, transition in enumerate(transitions):
+ frame_index = lbt.frame_number_from_img_name(transition["to_frame"]) # Find the index of the frame
+ transitions_arr[ti, 0] = frame_index
+ transitions_arr[ti, 1] = 1 if transition["transition"] == "bright" else 0
+
+ # Iterate through the transitions
+ for i in range(len(transitions_arr)):
+ frame_index = transitions_arr[i, 0]
+ transition_state = transitions_arr[i, 1]
+
+ # Find the index in the data array where the frame_index matches
+ data_index = np.where(binary_state[:, 0] == frame_index)[0]
+
+ if data_index.size > 0:
+ # Fill the values in between the current transition and the next one
+ if i < len(transitions_arr) - 1:
+ next_frame_index = transitions_arr[i + 1, 0]
+ # Fill the range between the current frame and the next frame
+ data_range = np.where((binary_state[:, 0] >= frame_index) & (binary_state[:, 0] < next_frame_index))[0]
+ binary_state[data_range, 1] = transition_state
+
+ plt.figure(figsize=(6, 4))
+ plt.plot(binary_state[:, 0], binary_state[:, 1])
+ plt.title(f"Pixel {pixel} Timing Plot")
+ plt.xlabel("Frame Number")
+ plt.ylabel("Binary State")
+
+ height, _ = eval(pixel)
+ os.makedirs(os.path.join(output_folder, str(height)), exist_ok=True)
+ plot_file = os.path.normpath(os.path.join(output_folder, str(height), f"pixel_{pixel}_timing_plot.jpg"))
+ plt.savefig(plot_file, dpi=100, bbox_inches='tight')
+ plt.close()
+ lbt.write_compressed_json(
+ binary_state, os.path.join(output_folder, str(height), f"pixel_{pixel}_timing_plot_data.json.gz")
+ )
+
+ return pixel # Return the processed pixel
+
+
+def create_timing_plots_json_parallel(
+ compiled_json, output_folder, source_image_folder, checkpoint_folder, checkpoint_file="timing_plots_checkpoint.json"
+):
+ # Ensure the output folder exists
+ os.makedirs(output_folder, exist_ok=True)
+ os.makedirs(checkpoint_folder, exist_ok=True)
+
+ # Load checkpoint if it exists
+ checkpoint_data = lbt.load_checkpoint(checkpoint_folder, checkpoint_file)
+ if checkpoint_data is None:
+ checkpoint_data = {"processed_pixels": []}
+
+ # Get all .png files in the folder, sorted by batch order
+ image_files = sorted(
+ [os.path.join(source_image_folder, f) for f in os.listdir(source_image_folder) if f.lower().endswith(".png")]
+ )
+ if not image_files:
+ raise ValueError("No source image files (.png) found in the specified folder.")
+
+ frames = sorted(lbt.frame_number_from_img_name(item) for item in image_files)
+
+ if not compiled_json:
+ raise ValueError("No .json.gz files found in the specified folder.")
+
+ data = lbt.read_compressed_json(compiled_json)
+ progress_bar = tqdm(total=len(data), desc="Creating Timing Plots and Writing Data")
+
+ with ProcessPoolExecutor() as executor:
+ futures = []
+ for pixel, transitions in data.items():
+ if pixel in checkpoint_data["processed_pixels"]:
+ print(f"Skipping already processed pixel {pixel}.")
+ progress_bar.update(1)
+ continue
+
+ futures.append(
+ executor.submit(pixel_timing_plot, pixel, transitions, frames, output_folder, checkpoint_data)
+ )
+
+ for future in futures:
+ pixel = future.result()
+ if pixel:
+ checkpoint_data["processed_pixels"].append(pixel)
+ lbt.save_checkpoint(checkpoint_folder, checkpoint_file, checkpoint_data, print_path=False)
+ progress_bar.update(1)
+
+ progress_bar.close()