Please use the following 2 scripts and update my scripts so that the individually calculated timestamp_offset is used throughout all calculations (e.g., in filtering_data, match_images_dataframes), we need to modify the way timestamps are handled in the data matching process. The main challenge here is that timestamp_offset depends on the line_number (which need the 2D coordinates of the tip), but tip comes from the coordinate data that hasn't been matched yet. This creates a circular dependency. 
Mabe you need to change especially match_images_dataframes and helping funtions, so that they do it for single images and not for all images in the paths.

Please do, that in the main first all the images are filtered, and matched with a time_offset of 0, but the so calculated data is not saved, just the line_number is calculated for the second run, where the specific time_offset is calculated with the calculated line_number and afterwards all the images are saved.

1st Script:
import os
import re
import imageio
import pandas as pd
import numpy as np
import cv2
import matplotlib.pyplot as plt
import argparse
import glob

from src.camera_param import CameraParameters
from src.transformation.projective_geometry import project_points

from src.transformation.coordinates_loader import (
add_fiducial_coordinates,
filter_blurry,
filter_quality_recorded_trackers,
match_images_dataframes,
pre_filter_unrecorded_nir,
)

from src.selector_configs import SelectedData, SelectorParameters
from src.raw_dataset_configs import MetadataFile, RawDataFolder
from src.transformation.camera_loader import load_camera_parameters
from src.transformation.projective_geometry import add_tip_coordinates
from src.data_loader import _join_2d_positions
from src.raw_dataset_configs import CoordinateDataKeys

TOOL_TIP_ESTIMATION = np.array([300, 110, 0])

def create_csv(filename, image_id, x1, y1, x2, y2):
columns = ['image_id', 'tip_x', 'tip_y', 'center_x', 'center_y']
df = pd.DataFrame(columns=columns)
df.loc[0] = [image_id, x1, y1, x2, y2]


csvFilePath = filename
if not os.path.isfile(csvFilePath):
    df.to_csv(csvFilePath, sep=',', index=False)
else:
    df.to_csv(csvFilePath, mode='a', sep=',', header=False, index=False)
import glob

def load_raw_data(
calibration_data,
data_folder: str,
timestamp_offset: float = 0.0,
verbose: bool = True,
image_list: list[str] | None = None,
) -> SelectedData:
image_folder = os.path.join(data_folder, RawDataFolder.IMAGE_FOLDER)
camera_configuration_file = os.path.join(
data_folder, RawDataFolder.CAMERA_CONFIGURATION_FILE
)


# Define the path to the logs folder where the coordinate files are located
logs_folder = os.path.join(data_folder, "logs")

# Find all coordinate files within the 'logs' subfolder
coordinate_files = glob.glob(os.path.join(logs_folder, "coord_log_led_based_*.txt"))

# Check if any files were found
if not coordinate_files:
    raise FileNotFoundError(f"No coordinate files found in {logs_folder} with pattern 'coord_log_led_based_*.txt'.")

# Load each file, skip if empty
raw_coordinates_dataframe_list = []
for coord_file in coordinate_files:
    df = pd.read_csv(coord_file, delimiter=r"\s+")
    if not df.empty:
        raw_coordinates_dataframe_list.append(df)
    else:
        print(f"Warning: {coord_file} is empty and was skipped.")

# Check if any data was loaded
if not raw_coordinates_dataframe_list:
    raise ValueError("No data loaded from any coordinate files; all files are empty.")

# Concatenate all dataframes into one
raw_coordinates_dataframe = pd.concat(raw_coordinates_dataframe_list, ignore_index=True)

if image_list is None:
    image_list = os.listdir(image_folder)
    image_list = pre_filter_unrecorded_nir(image_list, raw_coordinates_dataframe)

metadata = MetadataFile.parse_obj({
    'date': '2023-10-19:14-45-32', 
    'bur_id': '107-030', 
    'bur_name': 'match head 3.0', 
    'bur_level': '1', 
    'attachment_id': '284', 
    'tracker_id': '0', 
    'bur_offset_mm': [0, 0, -1.5]
})

tip_coordinates = np.array([
    [1, 0, 0, 0],
    [0, 1, 0, 0],
    [0, 0, 1, 0],
    [calibration_data[2], calibration_data[0], calibration_data[1], 1]
])
camera_parameters = load_camera_parameters(camera_configuration_file)

selected_data = SelectedData(
    data_folder=data_folder,
    image_folder=image_folder,
    coordinate_dataframe=match_images_dataframes(
        image_list, raw_coordinates_dataframe, timestamp_offset=timestamp_offset
    ),
    camera_parameters=camera_parameters,
    metadata=metadata,
    tip_coordinates=tip_coordinates,
)

if verbose:
    print(
        f"Found {selected_data.coordinate_dataframe.shape[0]} matches in {data_folder} across {len(coordinate_files)} files."
    )

return selected_data
def filtering_data(
calibration_data,
data_folder: str,
selector_parameters: SelectorParameters,
verbose: bool = True,
image_list: list[str] | None = None,
) -> SelectedData:
selected_data = load_raw_data(
calibration_data,
data_folder,
timestamp_offset=selector_parameters.timestamp_offset,
verbose=verbose,
image_list=image_list,
)


if selector_parameters.quality_threshold is not None:
    selected_data.coordinate_dataframe = filter_quality_recorded_trackers(
        selected_data.coordinate_dataframe,
        quality_threshold=selector_parameters.quality_threshold,
        verbose=verbose,
    )

if selector_parameters.movement_threshold_mm is not None:
    selected_data.coordinate_dataframe = filter_blurry(
        selected_data.coordinate_dataframe,
        movement_threshold_mm=selector_parameters.movement_threshold_mm,
        verbose=verbose,
    )

if selector_parameters.join_fiducials:
    add_fiducial_coordinates(selected_data, verbose=verbose)

if selector_parameters.join_tip_coordinates:
    if verbose:
        print("Joining tip coordinates...")

    add_tip_coordinates(selected_data, corrected=False)

    if selector_parameters.join_tip_correction:
        add_tip_coordinates(selected_data, corrected=True)

if selector_parameters.join_2d_positions:
    _join_2d_positions(
        selected_data,
        selector_parameters,
        verbose=verbose,
    )

return selected_data
def plot_metrics(timestamps, metrics, metric_name, output_filename):
# Convert timestamps to seconds from the first timestamp
reference_time = min(timestamps)
x_values = [(t - reference_time) / 1000.0 for t in timestamps] # Convert ms to seconds


plt.figure(figsize=(10, 6))
plt.plot(x_values, metrics, marker='o', linestyle='-', color='b')
plt.title(f'{metric_name} over Time')
plt.xlabel('Time (seconds from start)')
plt.ylabel(metric_name)
plt.grid(True)

# Save the plot as a PNG image file
plt.savefig(output_filename)
plt.close()
print(f"{metric_name} plot saved to {output_filename}")
def extract_timestamp(filename: str) -> int:
"""
Extracts the timestamp from the filename. Assumes the filename contains a large numeric timestamp in milliseconds.
Example: '106840846.png' -> 106840846 (milliseconds)
"""
pattern = re.compile(r'(\d+)') # Adjust this regex pattern to match the timestamp format
match = pattern.search(filename)
if match:
return int(match.group(1)) # Return timestamp as integer
return None

def get_blur_score(grey_image):


return cv2.Laplacian(grey_image, cv2.CV_64F).var()
def detect_blur_fft(image,size: int = 60):
height, width = image.shape
center_x, center_y = int(width / 2.0), int(height / 2.0)
fft = np.fft.fft2(image)
fft_shift = np.fft.fftshift(fft)
fft_shift[center_y - size : center_y + size, center_x - size : center_x + size] = 0
fft_shift = np.fft.ifftshift(fft_shift)
recon = np.fft.ifft2(fft_shift)
magnitude = 20 * np.log(np.abs(recon))


return np.mean(magnitude)                                                
def get_2d_position_estimation(
tool_center_3d: np.ndarray,
tool_rotation_matrix: np.ndarray,
translation_vector: np.ndarray,
camera_parameters: CameraParameters,
) -> np.ndarray:
translation_vector = translation_vector[[1, 2, 0]]


tip_coordinates = tool_rotation_matrix @ translation_vector + tool_center_3d

return project_points(tip_coordinates, camera_parameters)
The 10 pixel margin for cropping the ROI. Should be adapted based on the resolution of images.
margin = 10

def get_bounding_box_estimation_center(
tool_center_2d: np.ndarray,
tool_center_3d: np.ndarray,
tool_rotation_matrix: np.ndarray,
width: int,
height: int,
camera_parameters: CameraParameters,
TOOL_TIP_ESTIMATION,
) -> tuple[int, int, int, int]:
tip_2d_position_estimation = get_2d_position_estimation(
tool_center_3d, tool_rotation_matrix, TOOL_TIP_ESTIMATION, camera_parameters
)


points = np.stack(
    (
        np.array([tip_2d_position_estimation[0], tip_2d_position_estimation[1]]),
        np.array([tool_center_2d[0], tool_center_2d[1]]),
    )
)

x1 = int(np.min(points[:, 0]))
y1 = int(np.min(points[:, 1]))
x2 = int(np.max(points[:, 0]))
y2 = int(np.max(points[:, 1]))

h = y2 - y1
w = x2 - x1

if w > h:
    x1 = x1 - margin
    x2 = x2 + margin
    ym = int((y2 + y1)*0.5)
    y1 = ym - int(w/2) - margin
    y2 = ym + int(w / 2) + margin
else:
    y1 = y1 - margin
    y2 = y2 + margin
    xm = int((x2 + x1) * 0.5)
    x1 = xm - int(h / 2) - margin
    x2 = xm + int(h / 2) + margin

return (
         max(int(x1), 0),
         max(int(y1), 0),
         min(int(x2), width),
         min(int(y2), height)), (tip_2d_position_estimation[0], tip_2d_position_estimation[1])
def crop_frame(frame_array, bounding_box):
return frame_array[
bounding_box[1] : bounding_box[3], bounding_box[0] : bounding_box[2]
], (bounding_box[0], bounding_box[1])

def resize_frame(
frame: np.ndarray, size: tuple[int, int]
) -> tuple[np.ndarray, tuple[float, float]]:
return cv2.resize(frame, size), (size[1] / frame.shape[1], size[0] / frame.shape[0])

def _reduce_point_scale(
point: np.ndarray | None,
x_min: int,
y_min: int,
scale: tuple[float, float]
) -> np.ndarray | None:
return (point - np.array([x_min, y_min])) * np.array(scale) if point is not None else None

def reduce_labels_scale(point: np.ndarray, bouding_box: tuple[int, int, int, int], scale: tuple[float, float]):
x_min, y_min, _, _ = bouding_box
return _reduce_point_scale(point, x_min, y_min, scale)

import cv2

def save_cropped_frame(size, width, height, tool_center_2d, tool_center_3d, tool_center_rotation, bur_tip_x, bur_tip_y, camera_parameters, frame_array, image_name, quality, movement, output_subfolder):
bounding_box, tip_2d_position_estimation = get_bounding_box_estimation_center(
tool_center_2d, tool_center_3d, tool_center_rotation, width, height, camera_parameters, TOOL_TIP_ESTIMATION
)
input_size = (size, size)
cropped_frame, offset = crop_frame(frame_array, bounding_box)
resized_frame, scale = resize_frame(cropped_frame, input_size)


# Paint tip_2d_position_estimation and tool_center_2d on the resized frame
tip_color = (0, 255, 0)  # Green for tip
center_color = (255, 0, 0)  # Blue for center
radius = 3
thickness = 2

# Adjust coordinates for cropping and resizing
tip_point = reduce_labels_scale(np.array(tip_2d_position_estimation), bounding_box, scale)
center_point = reduce_labels_scale(tool_center_2d, bounding_box, scale)

# Draw circles for tip and center
cv2.circle(resized_frame, tuple(map(int, tip_point)), radius, tip_color, thickness)
cv2.circle(resized_frame, tuple(map(int, center_point)), radius, center_color, thickness)

grey_image = cv2.cvtColor(resized_frame, cv2.COLOR_BGR2GRAY)
laplacian_blur = cv2.Laplacian(grey_image, cv2.CV_64F).var()
fft_blur = detect_blur_fft(grey_image)
print(f"Laplacian Blur: {laplacian_blur:.2f}, FFT Blur: {fft_blur:.2f}")

# Prepare output path with subfolder
os.makedirs(output_subfolder, exist_ok=True)
new_image_name = f"{os.path.splitext(image_name)[0]}_q{quality:.2f}_m{movement:.2f}_lap{laplacian_blur:.2f}_fft{fft_blur:.2f}{os.path.splitext(image_name)[1]}"
imageio.v2.imwrite(os.path.join(output_subfolder, new_image_name), resized_frame)

return laplacian_blur, fft_blur
selector_parameters = SelectorParameters(
timestamp_offset= 0.059,
quality_threshold=0.5,
movement_threshold_mm=10000,
join_fiducials=False,
join_tip_coordinates=True,
join_tip_correction=True,
join_2d_positions=True,
interest_region_radius=400,
tip_inclusion_radius=200,
final_tool_radius=100,
small_component_threshold=0,
cropping_margin=5,
pixel_margin=50,
)

def main(file_path, UHD, output_size, output_folder, framerate, exposure_time):
file_list = os.listdir(file_path)
file_list.sort(key=lambda f: extract_timestamp(f))
timestamps = []
laplacian_blurs = []
fft_blurs = []
movements = []


total_lines = 2160 if UHD else 1080  # Set total lines based on UHD flag

for file in file_list:
    data_folder = os.path.join(file_path, file)
    raw_data_folder = data_folder
    camera_parameters = load_camera_parameters(
        os.path.join(data_folder, RawDataFolder.CAMERA_CONFIGURATION_FILE)
    )
    solution = [149.421361, 0, 200.342416]
    width, height = (3840, 2160) if UHD else (1920, 1080)
    selected_data = filtering_data(solution, raw_data_folder, selector_parameters, verbose=True)

    max_row = selected_data.coordinate_dataframe.shape[0]
    for row in range(max_row):
        image_name = selected_data.coordinate_dataframe.iloc[row]["image_name"]
        image_path = os.path.join(selected_data.image_folder, image_name)

        try:
            image = imageio.v2.imread(image_path)
        except Exception as e:
            print(f"Failed to read {image_name}: {e}")
            continue

        timestamp = extract_timestamp(image_name)
        if timestamp is not None:
            timestamps.append(timestamp)
            quality_value = selected_data.coordinate_dataframe.iloc[row]["quality"]
            movement_value = selected_data.coordinate_dataframe.iloc[row]["estimated_movement"]
            movements.append(movement_value)

            # Get line_number from y-coordinate of tool tip position
            line_number = int(selected_data.coordinate_dataframe.iloc[row]["tip_2d_y"])

            # Calculate timestamp_offset for this image
            timestamp_offset = (1000.0 / framerate - exposure_time / 1000.0) * (line_number / total_lines) + (exposure_time / 1000.0 / 2.0)

            tool_center_x = selected_data.coordinate_dataframe.iloc[row]["center_x"]
            tool_center_y = selected_data.coordinate_dataframe.iloc[row]["center_y"]
            bur_tip_x = selected_data.coordinate_dataframe.iloc[row]["tip_2d_x"]
            bur_tip_y = selected_data.coordinate_dataframe.iloc[row]["tip_2d_y"]
            tool_center_3d = selected_data.coordinate_dataframe.iloc[row][CoordinateDataKeys.TOOL_CENTER_3D].to_numpy()
            rotation_matrix = (
                selected_data.coordinate_dataframe.iloc[row][CoordinateDataKeys.TOOL_ROTATION_MATRIX].to_numpy().reshape(-1, 3, 3)
            ).transpose(0, 2, 1)[0]
            tool_center_2d = selected_data.coordinate_dataframe.iloc[row][CoordinateDataKeys.TOOL_CENTER_2D].to_numpy()

            # Get relative path to maintain folder structure
            relative_path = os.path.relpath(image_path, file_path)
            output_subfolder = os.path.join(output_folder, os.path.dirname(relative_path))

            try:
                print(f"Processed row {row+1}/{max_row}: {image_path}")
                laplacian_blur, fft_blur = save_cropped_frame(
                    output_size,
                    width,
                    height,
                    tool_center_2d,
                    tool_center_3d,
                    rotation_matrix,
                    bur_tip_x,
                    bur_tip_y,
                    camera_parameters,
                    image,
                    image_name,
                    quality_value,
                    movement_value,
                    output_subfolder
                )
                laplacian_blurs.append(laplacian_blur)
                fft_blurs.append(fft_blur)

                # Store the calculated timestamp_offset if needed for further processing
                print(f"Timestamp Offset for {image_name}: {timestamp_offset:.4f}")

            except Exception as e:
                print(f"Failed to write {image_name}: {e}")

results_df = pd.DataFrame({
    'timestamp': timestamps,
    'laplacian_blur': laplacian_blurs,
    'fft_blur': fft_blurs,
    'movement': movements
})
output_csv_path = os.path.join(output_folder, 'blur_analysis_results.csv')
results_df.to_csv(output_csv_path, index=False)
print(f"Results saved to {output_csv_path}")

if timestamps:
    plot_metrics(timestamps, laplacian_blurs, "Laplacian Blur", os.path.join(output_folder, "laplacian_blur_plot.png"))
    plot_metrics(timestamps, fft_blurs, "FFT Blur", os.path.join(output_folder, "fft_blur_plot.png"))
    plot_metrics(timestamps, movements, "Movement", os.path.join(output_folder, "movement_plot.png"))
if name == 'main':
parser = argparse.ArgumentParser(description="Process parameters")
parser.add_argument('--file_path', type=str, required=True, help='Path to the data files.')
parser.add_argument('--output_size', type=int, required=True, help='Input size for the MV model.')
parser.add_argument('--UHD', action='store_true', help='Flag to set UHD (4K resolution).')
parser.add_argument('--output_folder', type=str, required=True, help='Path to save output images and CSV with the same subfolder structure as file_path.')
parser.add_argument('--framerate', type=float, required=True, help='Framerate input from user.')
parser.add_argument('--exposure_time', type=float, required=True, help='Exposure time input from user.')


args = parser.parse_args()
main(args.file_path, args.UHD, args.output_size, args.output_folder, args.framerate, args.exposure_time)

2nd Sript:
import os

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt  # Importing matplotlib for plotting
from src.raw_dataset_configs import CoordinateDataKeys, RawDataFolder
from src.selector_configs import SelectedData

FPS = 10
TOOL_LENGTH_ESTIMATION = 100

def pre_filter_unrecorded_nir(image_list: list[str], raw_coordinates_dataframe: pd.DataFrame, margin: float = 0.5):
    start_timestamp = int((raw_coordinates_dataframe["timestamp"].min() + margin) * 1000000)
    end_timestamp = int((raw_coordinates_dataframe["timestamp"].max() - margin) * 1000000)
    return [image_name for image_name in image_list if start_timestamp < int(os.path.splitext(image_name)[0]) < end_timestamp]

def get_interpolated_data(image_name: str, raw_coordinates_dataframe: pd.DataFrame, offset: float = 0.0):
    timestamp = float(os.path.splitext(image_name)[0]) / 1000000 + offset
    print("Offset:", offset)
    timestamps = raw_coordinates_dataframe["timestamp"]
    time_differences = timestamps.sub(timestamp).abs()
    
    closest_indices = time_differences.nsmallest(2).index
    closest_timestamps = timestamps.loc[closest_indices].values
    closest_rows = raw_coordinates_dataframe.loc[closest_indices]
    
    if len(closest_timestamps) == 2:
        lower_time, upper_time = sorted(closest_timestamps)
        lower_idx, upper_idx = closest_indices[closest_timestamps.argsort()]
        
        time_span = upper_time - lower_time
        ratio = (timestamp - lower_time) / time_span if time_span != 0 else 0.0
        
        interpolated_data = raw_coordinates_dataframe.iloc[lower_idx] + (raw_coordinates_dataframe.iloc[upper_idx] - raw_coordinates_dataframe.iloc[lower_idx]) * ratio
        interpolated_data['delta_time'] = time_span
    else:
        interpolated_data = raw_coordinates_dataframe.iloc[closest_indices[0]]
        interpolated_data['delta_time'] = 0.0
    
    return interpolated_data

def match_images_dataframes(image_list, raw_coordinates_dataframe: pd.DataFrame, offset: float = 0.0) -> pd.DataFrame:
    interpolated_data_list = [get_interpolated_data(image_name, raw_coordinates_dataframe, offset) for image_name in image_list]
    matched_data = pd.DataFrame(interpolated_data_list)
    matched_data = matched_data.assign(image_name=image_list).reset_index(drop=True)
    return matched_data

def filter_quality_recorded_trackers(coordinate_dataframe: pd.DataFrame, quality_threshold: float = 0.5, verbose: bool = True) -> pd.DataFrame:
    coordinate_dataframe = coordinate_dataframe[coordinate_dataframe["quality"] > quality_threshold]
    if verbose:
        print(f"Filtering trackers quality... Keeping {coordinate_dataframe.shape[0]} frames")
    return coordinate_dataframe

def filter_blurry(coordinate_dataframe: pd.DataFrame, movement_threshold_mm: float = 7, verbose: bool = True) -> pd.DataFrame:
    # Copy the dataframe to avoid modifying the original data
    coordinate_dataframe = coordinate_dataframe.copy()

    # Ensure that 'delta_time' is present in the dataframe
    if 'delta_time' not in coordinate_dataframe.columns:
        raise ValueError("delta_time column is missing in coordinate_dataframe")

    # Extract rotation speeds (angular velocities) components in rad/s
    rotation_speeds_columns = CoordinateDataKeys.TOOL_ROTATION_SPEED
    rotation_speeds = coordinate_dataframe[rotation_speeds_columns].to_numpy()

    # Extract translation speeds components
    translation_speeds_columns = CoordinateDataKeys.TOOL_TRANSLATION_SPEED
    translation_speeds = coordinate_dataframe[translation_speeds_columns].to_numpy()

    # Confirm units and convert if necessary
    translation_speeds *= 1000  # Convert from m/s to mm/s

    # Extract delta_time from the dataframe
    delta_times = coordinate_dataframe['delta_time'].to_numpy()  # seconds

    # Handle zero or very small delta_times to avoid division by zero or negligible movements
    min_delta_time = 1e-6  # Minimum allowable delta_time (1 microsecond)
    delta_times = np.where(delta_times < min_delta_time, min_delta_time, delta_times)

    # Use your specific position vector from rotation center to tool tip (in mm)
    r_tip = np.array([70, -1, 200])  # mm

    # Number of frames
    num_frames = rotation_speeds.shape[0]

    # Repeat r_tip for each frame
    r_tips = np.tile(r_tip, (num_frames, 1))  # Shape: (num_frames, 3)

    # Calculate the linear velocity at the tool tip due to rotation for each frame
    v_rotational = np.cross(rotation_speeds, r_tips)  # rad/s x mm -> mm/s

    # Calculate the total velocity at the tool tip
    v_total = translation_speeds + v_rotational  # mm/s

    # Calculate estimated movement as speed (mm/s)
    estimated_movement = np.linalg.norm(v_total, axis=1)  # This is now in mm/s

    coordinate_dataframe["estimated_movement"] = estimated_movement

    zero_movement_indices = np.where(estimated_movement == 0)[0]
    
    if len(zero_movement_indices) > 0:
        print("Zero Movement Indices:", zero_movement_indices)

    # Filter out frames where the estimated movement exceeds the threshold
    coordinate_dataframe = coordinate_dataframe[coordinate_dataframe["estimated_movement"] < movement_threshold_mm]

    if verbose:
        print(f"Filtering blurry images... Keeping {coordinate_dataframe.shape[0]} frames")

    return coordinate_dataframe



# def _synchronise_fiducials_timestamps(
#     selected_data: SelectedData, triangulated_points: pd.DataFrame
# ) -> pd.DataFrame:
#     timestamps = selected_data.coordinate_dataframe["timestamp"].to_numpy()
#
#     return triangulated_points[triangulated_points["time_stamp"].isin(timestamps)]
#

import pandas as pd


def interpolate_data_at_timestamp(timestamp: float, dataframe: pd.DataFrame, fiducial_indexes: list) -> pd.DataFrame:
    interpolated_frames = []

    for fiducial_index in fiducial_indexes:
        # Filter the dataframe for the specific fiducial_index
        df_filtered = dataframe[dataframe['fiducial_index'] == fiducial_index]

        # Find the two closest distinct timestamps for the specific fiducial_index
        closest_indices = df_filtered['time_stamp'].sub(timestamp).abs().nsmallest(2).index
        closest_timestamps = df_filtered.loc[closest_indices, 'time_stamp']

        if len(closest_timestamps) == 2:
            lower_idx, upper_idx = closest_indices
            lower_time, upper_time = closest_timestamps

            # Check if lower_time and upper_time are equal to avoid division by zero
            if lower_time == upper_time:
                interpolated_data = df_filtered.loc[lower_idx]
            else:
                ratio = (timestamp - lower_time) / (upper_time - lower_time)
                interpolated_data = df_filtered.loc[lower_idx] + (
                            df_filtered.loc[upper_idx] - df_filtered.loc[lower_idx]) * ratio
        else:
            interpolated_data = df_filtered.loc[closest_indices[0]]

        # Add the DataFrame to the list
        interpolated_frames.append(interpolated_data.to_frame().T)

    # Combine all interpolated frames
    combined_interpolated_data = pd.concat(interpolated_frames, axis=0)
    return combined_interpolated_data


def _synchronise_fiducials_timestamps(selected_data, triangulated_points: pd.DataFrame) -> pd.DataFrame:
    timestamps = selected_data.coordinate_dataframe["timestamp"].to_numpy()
    fiducial_indexes = set(triangulated_points["fiducial_index"])

    interpolated_data_frames = []

    for timestamp in timestamps:
        interpolated_data = interpolate_data_at_timestamp(timestamp, triangulated_points, fiducial_indexes)
        interpolated_data_frames.append(interpolated_data)

    # Combine all interpolated data frames
    all_interpolated_data = pd.concat(interpolated_data_frames, axis=0).reset_index(drop=True)

    return all_interpolated_data


def _get_fiducial_dataframe(
    synchronised_triangulated_points: pd.DataFrame, fiducial_index: int
) -> pd.DataFrame:
    fiducial_dataframe = synchronised_triangulated_points[
        synchronised_triangulated_points["fiducial_index"] == fiducial_index
    ]

    fiducial_dataframe = fiducial_dataframe[["time_stamp", "x", "y", "z"]]
    fiducial_dataframe = fiducial_dataframe.drop_duplicates(
        subset=["time_stamp"], keep="first"
    )

    return fiducial_dataframe.rename(
        columns={
            "x": CoordinateDataKeys.FIDUCIALS_3D.get(fiducial_index)[0],
            "y": CoordinateDataKeys.FIDUCIALS_3D.get(fiducial_index)[1],
            "z": CoordinateDataKeys.FIDUCIALS_3D.get(fiducial_index)[2],
        }
    )


def add_fiducial_coordinates(
    selected_data: SelectedData, verbose: bool = False
) -> None:
    if verbose:
        print("Joining fiducials...")

    triangulated_points_file = os.path.join(
        selected_data.data_folder, RawDataFolder.TRIANGULATED_POINTS
    )
    triangulated_points = pd.read_csv(triangulated_points_file, delimiter=r"\s+")

    synchronised_triangulated_points = _synchronise_fiducials_timestamps(
        selected_data, triangulated_points
    )



    for fiducial_index in range(4):
        fiducial_dataframe = _get_fiducial_dataframe(
            synchronised_triangulated_points, fiducial_index
        )

        selected_data.coordinate_dataframe = pd.merge(
            selected_data.coordinate_dataframe,
            fiducial_dataframe,
            left_on="timestamp",
            right_on="time_stamp",
            how="inner",
            validate="one_to_one",
        ).drop(columns=["time_stamp"])

For a better understanding here are selsctor_configs: 
from enum import Enum

import numpy as np
import pandas as pd
from pydantic import BaseModel

from src.camera_param import CameraParameters
from src.raw_dataset_configs import MetadataFile

class SelectorParameters(BaseModel):
    join_fiducials: bool = False
    join_tip_coordinates: bool = False
    join_tip_correction: bool = False
    join_2d_positions: bool = False

    # Filtering parameters
    timestamp_offset: float = 0.05
    quality_threshold: float | None = 0.5
    movement_threshold_mm: float | None = 7

    # Image reduction parameters for mask approach
    interest_region_radius: int = 500
    tip_inclusion_radius: int = 50
    final_tool_radius: int = 100
    small_component_threshold: int = 0
    cropping_margin: int = 0

    # Image reduction parameters for tool center approach
    pixel_margin: int = 5


class KeyPoints(BaseModel):
    tool_center: np.ndarray
    tool_bur_center: np.ndarray
    tool_bur_tip: np.ndarray | None = None
    fiducials: list[np.ndarray] | None = None

    class Config:
        arbitrary_types_allowed = True

    def get_key_points_array(self) -> np.ndarray:
        array = [self.tool_center, self.tool_bur_center]

        if self.tool_bur_tip is not None:
            array.append(self.tool_bur_tip)

        if self.fiducials is not None:
            array.extend(self.fiducials)

        return np.array(array)


class SelectedData(BaseModel):
    data_folder: str
    image_folder: str
    coordinate_dataframe: pd.DataFrame
    camera_parameters: CameraParameters
    metadata: MetadataFile
    tip_coordinates: np.ndarray

    class Config:
        arbitrary_types_allowed = True


class ReductionType(int, Enum):
    MASK_WITH_HAND = 0
    MASK_WITHOUT_HAND = 1
    TOOL_CENTER = 2
    APPROXIMATE_SHAPE = 3


class AnnotatedSample(BaseModel):
    row_number: int
    reduction_type: ReductionType
    image: np.ndarray
    reduced_image: np.ndarray
    reduced_masked_image: np.ndarray | None = None
    key_points: KeyPoints
    image_name: str
    mask: np.ndarray | None = None

    class Config:
        arbitrary_types_allowed = True

raw_data_congfigs
from enum import Enum
from pydantic import BaseModel


class RawDataFolder(str, Enum):
    CAMERA_CONFIGURATION_FILE = "logs/fp8000_camera.dat"
    COORDINATES_FILE = "logs/coord_log_led_based_0.txt"
    IMAGE_FOLDER = "images"
    METADATA_FILE = "metadata.json"
    SPINE_LOG_FILE = "logs/spine.log"
    TRIANGULATED_POINTS = "logs/fp8000_triangualted_points_data_log_0.txt"


class CoordinateDataKeys(list[str], Enum):
    TIP_CENTER_3D = ["tip_x_mm", "tip_y_mm", "tip_z_mm"]
    TIP_CENTER_2D = ["tip_2d_x", "tip_2d_y"]
    TIP_3D = ["tip_corrected_x_mm", "tip_corrected_y_mm", "tip_corrected_z_mm"]
    TIP_2D = ["tip_corrected_2d_x", "tip_corrected_2d_y"]
    FIDUCIALS_2D = ["fiducial_2d_{index}_x", "fiducial_2d_{index}_y"]
    FIDUCIALS_3D = ["fiducial_{index}_x", "fiducial_{index}_y", "fiducial_{index}_z"]
    TOOL_CENTER_2D = ["center_x", "center_y"]
    TOOL_CENTER_3D = ["trans_x_mm", "trans_y_mm", "trans_z_mm"]
    TOOL_ROTATION_MATRIX = [
        "transposed_rot_row0_col0",
        "rot_0_1",
        "rot_0_2",
        "rot_1_0",
        "rot_1_1",
        "rot_1_2",
        "rot_2_0",
        "rot_2_1",
        "rot_2_2",
    ]
    TOOL_ROTATION_SPEED = [
        "rot_body_vel_x_radsec",
        "rot_body_vel_y_radsec",
        "rot_body_vel_z_radsec",
    ]
    TOOL_TRANSLATION_SPEED = [
        "lin_body_vel_x_msec",
        "lin_body_vel_y_msec",
        "lin_body_vel_z_msec",
    ]

    def get(self, index: int) -> list[str]:
        return [key.format(index=index) for key in self.value]


class MetadataFile(BaseModel):
    date: str
    bur_id: str
    bur_name: str
    bur_level: int
    attachment_id: str
    tracker_id: int
    bur_offset_mm: list[float]

and camaera_param:
import numpy as np
from pydantic import BaseModel


class CameraParameters(BaseModel):
    rotation_vector: np.ndarray
    translation_vector: np.ndarray
    camera_matrix: np.ndarray
    distortion_coefficients: np.ndarray

    class Config:
        arbitrary_types_allowed = True

Please ask if somethig missing to suggest a good solution