""" Filename: part_A.py Description: First part of processing Author: Tyler de Zeeuw License: GPL-3.0 """ # Built-in imports import os import re import sys import time import shutil import argparse import traceback import threading import subprocess # External library imports import gphoto2 as gp #type: ignore # NOTE: Assumes calibration files have been made PWD = os.path.dirname(os.path.abspath(__file__)) MSYS2_BIN = str(PWD) + r"\res_gphoto2\bin" MSYS2_CAMLIBS = str(PWD) + r"\res_gphoto2\lib\libgphoto2\2.5.34" MSYS2_IOLIBS = str(PWD) + r"\res_gphoto2\lib\libgphoto2_port\0.12.2" os.environ["IOLIBS"] = MSYS2_IOLIBS os.environ["PATH"] = MSYS2_BIN + os.pathsep + os.environ.get("PATH", "") os.environ["CAMLIBS"] = MSYS2_CAMLIBS # ---------- Lumix Configuration ---------- OUTPUT_PREFIX_CAM1 = "Left_Camera" OUTPUT_PREFIX_CAM2 = "Right_Camera" TARGET_FRAMES = 5 TRIGGER_RETRY_TIMEOUT = 2.0 TRIGGER_RETRY_INTERVAL = 0.02 # 20ms between retries # ---------- Calibrated S24+ Configuration ---------- SHUTTER_X = 720 SHUTTER_Y = 2600 DROP_DISTANCE = 250 HOLD_DURATION_S = 0.55 ADB_PATH = "C:/Android/platform-tools/adb" MASTER_START_TIME = time.perf_counter() # def get_precise_timestamp(img_path): # if not os.path.exists(img_path): # return "File not found" # try: # with open(img_path, 'rb') as image_file: # my_image = Image(image_file) # if my_image.has_exif: # base_time = my_image.get("datetime_original") # sub_seconds = my_image.get("subsec_time_original", "000") # if base_time: # return f"{base_time}.{sub_seconds}" # return "No EXIF data found" # except Exception as e: # return f"Error reading EXIF: {e}" def run_adb_shell(command_string): try: result = subprocess.run([ADB_PATH, "shell", command_string], capture_output=True, text=True, check=True) return result.stdout.strip() except subprocess.CalledProcessError as e: print(f"ADB Shell Error: {e.stderr.strip()}") return None def trigger_humanized_chained_burst(): mid_x = SHUTTER_Y + int(DROP_DISTANCE / 2) end_x = SHUTTER_Y + DROP_DISTANCE print(f"[Phone] Targeting landscape shutter center at: ({SHUTTER_Y}, {SHUTTER_X})") print("[Phone] Executing humanized landscape slide-and-hold sequence...") chained_cmd = ( f"input motionevent DOWN {SHUTTER_Y} {SHUTTER_X} ; " f"sleep 0.05 ; " f"input motionevent MOVE {mid_x} {SHUTTER_X} ; " f"input motionevent MOVE {end_x} {SHUTTER_X} ; " f"sleep {HOLD_DURATION_S} ; " f"input motionevent UP {end_x} {SHUTTER_X}" ) run_adb_shell(chained_cmd) print("[Phone] Landscape burst gesture completed.") def pull_recent_burst_photos(local_dest_dir, max_photos_to_check=10): print("\n[Transfer System] Scanning phone for new burst frames...") os.makedirs(local_dest_dir, exist_ok=True) camera_dir = "/storage/emulated/0/DCIM/Camera" list_cmd = f"ls -t {camera_dir}" file_list_raw = run_adb_shell(list_cmd) if not file_list_raw: print("No images found on the mobile device.") return [] lines = file_list_raw.split('\n') recent_files = [l.strip() for l in lines if l.strip().endswith(('.jpg', '.jpeg', '.png'))] if not recent_files: print("No image files located on phone.") return [] files_to_pull = [] current_time = time.time() print("Filtering phone files captured in the last burst window...") for filename in recent_files[:max_photos_to_check]: remote_path = f"{camera_dir}/{filename}" stat_out = run_adb_shell(f"stat -c %Y {remote_path}") if stat_out and stat_out.isdigit(): file_time = int(stat_out) if (current_time - file_time) < 30: files_to_pull.append((remote_path, filename)) if not files_to_pull: print("No fresh burst frames found on phone from this exact run.") return [] print(f"Found {len(files_to_pull)} new phone burst frames. Pulling files...") for remote_path, filename in files_to_pull: local_path = os.path.join(local_dest_dir, filename) try: subprocess.run([ADB_PATH, "pull", remote_path, local_path], capture_output=True, check=True) print(f" Successfully transferred from phone: {filename}") except subprocess.CalledProcessError as e: print(f" Failed to pull {filename}: {e.stderr.decode().strip()}") return files_to_pull def delete_phone_photos(files_list): if not files_list: return print(f"\n[Phone Cleanup] Clean Sweep: Deleting {len(files_list)} target frames from phone storage...") for remote_path, _ in files_list: run_adb_shell(f"rm {remote_path}") print("[Phone Cleanup] Phone storage cleanup complete.") # ---------- Lumix Subsystem ---------- def init_camera_by_path(port_path): camera = gp.Camera() context = gp.Context() port_info_list = gp.PortInfoList() port_info_list.load() try: idx = port_info_list.lookup_path(port_path) camera.set_port_info(port_info_list[idx]) camera.init(context) return camera, context except gp.GPhoto2Error as e: print(f"Failed to initialize camera at {port_path}: {e}") return None, None def trigger_capture_with_retry(camera, context, timeout, interval, prefix, frame_idx): deadline = time.time() + timeout attempt = 0 while time.time() < deadline: attempt += 1 try: camera.trigger_capture(context) return True except gp.GPhoto2Error: time.sleep(interval) continue print(f"[{prefix}] Frame {frame_idx}: trigger_capture failed after {attempt} attempts (timeout).") return False def derive_preceding_filenames(folder, last_name, count): match = re.match(r'^([A-Za-z]+)(\d+)(\.[A-Za-z0-9]+)$', last_name) if not match: return None prefix, digits, ext = match.groups() width = len(digits) last_num = int(digits) names = [] for i in range(count): num = last_num - i if num < 0: break names.append((folder, f"{prefix}{num:0{width}d}{ext}")) names.reverse() return names def capture_burst_sequence(camera, context, prefix, count): accepted = 0 for i in range(count): ok = trigger_capture_with_retry( camera, context, TRIGGER_RETRY_TIMEOUT, TRIGGER_RETRY_INTERVAL, prefix, i + 1 ) if ok: accepted += 1 return accepted def gather_file_events(camera, context, prefix, expected_count): file_events = [] start_time = time.time() timeout_seconds = 4.0 while time.time() - start_time < timeout_seconds: event_type, event_data = camera.wait_for_event(10, context) if event_type == gp.GP_EVENT_FILE_ADDED: file_events.append((event_data.folder, event_data.name)) if len(file_events) >= expected_count: break elif event_type == gp.GP_EVENT_TIMEOUT: continue return file_events def gather_and_wipe_buffer(camera, context, prefix): deleted_count = 0 timeout_seconds = 5.0 start_time = time.time() print(f"[{prefix}] Flushing file queue...") while time.time() - start_time < timeout_seconds: event_type, event_data = camera.wait_for_event(500, context) if event_type == gp.GP_EVENT_FILE_ADDED: folder = event_data.folder name = event_data.name try: camera.file_delete(folder, name) print(f"[{prefix}] Deleted junk frame: {folder}/{name}") deleted_count += 1 except gp.GPhoto2Error: print(f"[{prefix}] Failed to delete: {folder}/{name} (System busy)") start_time = time.time() elif event_type == gp.GP_EVENT_TIMEOUT: continue return deleted_count def threaded_fast_burst(camera, context, save_dir, prefix, timing_shared, barrier, abort_event, save_barrier, delete_barrier): print(f"[{prefix}] Starting initial burst of {TARGET_FRAMES} frames...") burst_start = time.time() accepted_first = capture_burst_sequence(camera, context, prefix, TARGET_FRAMES) burst_end_time = time.time() timing_shared[prefix] = burst_end_time burst_elapsed = burst_end_time - burst_start fps = accepted_first / burst_elapsed if burst_elapsed > 0 else 0 print(f"[{prefix}] {accepted_first}/{TARGET_FRAMES} triggers accepted in {burst_elapsed:.3f}s (~{fps:.2f} fps).") barrier.wait() sync_failed = abort_event.is_set() files_to_delete = [] if sync_failed: print(f"[{prefix}] !! Out of Sync !! Discarding batch 1 events...") save_barrier.wait() delete_barrier.wait() # Keeps timeline aligned even on drop branches time.sleep(1.5) deleted_count = gather_and_wipe_buffer(camera, context, prefix) print(f"[{prefix}] Aborted successfully. Cleaned up {deleted_count} files.") return else: file_events = gather_file_events(camera, context, prefix, accepted_first) if not file_events: print(f"Error: [{prefix}] did not report any new file creations.") save_barrier.wait() delete_barrier.wait() return last_folder, last_name = file_events[-1] targets_to_download = derive_preceding_filenames(last_folder, last_name, accepted_first) files_to_delete = targets_to_download if not targets_to_download: print(f"[{prefix}] Falling back to downloading only the anchor frame.") last_folder, last_name = file_events[-1] targets_to_download = [(last_folder, last_name)] print(f"[{prefix}] Downloading {len(targets_to_download)} frame(s)...") for idx, (folder, name) in enumerate(targets_to_download): target = os.path.join(save_dir, f"{prefix}_{idx + 1}.jpg") try: cf = camera.file_get(folder, name, gp.GP_FILE_TYPE_NORMAL) cf.save(target) print(f"-> Saved: {target} (source: {folder}/{name})") except gp.GPhoto2Error as e: print(f"Failed downloading {folder}/{name} for {prefix}: {e}") print(f"[{prefix}] Done saving. Signaling coordinator...") save_barrier.wait() delete_barrier.wait() if files_to_delete: print(f"[{prefix}] Clean Sweep: Deleting {len(files_to_delete)} files from card...") for folder, name in files_to_delete: try: camera.file_delete(folder, name) except gp.GPhoto2Error: continue print(f"[{prefix}] Storage cleanup complete.") print_elapsed() # ---------- Pipeline Worker ---------- # def standalone_pipeline_worker(directory): # print(f"\n[Separate Process] Worker process started with PID: {os.getpid()}") # print(f"{'Frame':<10} | {'Left Camera Time':<30} | {'Right Camera Time':<30}") # print("-" * 78) # for i in range(1, 6): # left_img = os.path.join(directory, f"Left_Camera_{i}.jpg") # right_img = os.path.join(directory, f"Right_Camera_{i}.jpg") # left_time = get_precise_timestamp(left_img) # right_time = get_precise_timestamp(right_img) # print(f"Shot {i:<5} | {left_time:<30} | {right_time:<30}") # print("\n[Separate Process] EXIF analysis complete. Process exiting gracefully.\n") def print_elapsed(label="Timestamp"): """Helper function to print time elapsed since the script started.""" elapsed = time.perf_counter() - MASTER_START_TIME print(f"[{label}] {elapsed:.3f}s total elapsed time") def gui_entry(initial_snirf=None, bypass_2d=False): # 1. Android Initialization if initial_snirf is not None: print(initial_snirf) if not bypass_2d: print("Waking up phone camera via ADB...") run_adb_shell("am start -a android.media.action.STILL_IMAGE_CAMERA") time.sleep(1.5) # 2. Lumix Hardware Initialization print("Searching for connected Lumix cameras...") all_detected = gp.Camera.autodetect() lumix_detected = [ (name, value) for name, value in all_detected.items() if "panasonic" in name.lower() ] print(f"Found {len(all_detected)} total USB devices.") print(f"Lumix devices found: {lumix_detected}") if len(lumix_detected) < 2: print("Error: Need at least 2 Lumix cameras connected.") sys.exit(1) print("\nConnecting to Lumix cameras...") camA, contextA = init_camera_by_path(lumix_detected[0][1]) camB, contextB = init_camera_by_path(lumix_detected[1][1]) if not camA or not camB: print("Error: Lumix initialization failed.") sys.exit(1) # 3. Directory Setup unique_folder_name = "session_1" base_dir = os.path.dirname(os.path.abspath(__file__)) save_dir = os.path.join(base_dir, unique_folder_name) if os.path.exists(save_dir): print(f"Forcibly purging existing directory: {save_dir}") shutil.rmtree(save_dir, ignore_errors=True) os.makedirs(save_dir, exist_ok=True) print(f"Created isolated workspace folder: {save_dir}") print("\n!!! Camera drive mode should be set to Burst 1 / H !!!") print("Waiting for GUI trigger click...", flush=True) # wait for button press from the gui input("") print("\nExecuting synchronized multi-system burst...") timing_shared = {} barrier = threading.Barrier(3) save_barrier = threading.Barrier(3) delete_barrier = threading.Barrier(3) abort_event = threading.Event() # Create Lumix threads tA = threading.Thread(target=threaded_fast_burst, args=(camA, contextA, save_dir, OUTPUT_PREFIX_CAM1, timing_shared, barrier, abort_event, save_barrier, delete_barrier)) tB = threading.Thread(target=threaded_fast_burst, args=(camB, contextB, save_dir, OUTPUT_PREFIX_CAM2, timing_shared, barrier, abort_event, save_barrier, delete_barrier)) t_phone = threading.Thread(target=trigger_humanized_chained_burst) MASTER_START_TIME = time.perf_counter() # Fire the ADB Burst first print("[Coordinator] Launching phone burst thread...") t_phone.start() time.sleep(0.1) # Fire the Lumix camera bursts print("[Coordinator] Launching Lumix camera burst threads...") tA.start() tB.start() print_elapsed() # Coordinator evaluates Lumix synchronization constraints while len(timing_shared) < 2: time.sleep(0.01) delta = abs(timing_shared[OUTPUT_PREFIX_CAM1] - timing_shared[OUTPUT_PREFIX_CAM2]) print(f"\n[Coordinator] Lumix burst sync time difference: {delta:.4f}s") if delta > 0.05: print("[Coordinator] Sync target missed (>0.05s)! Activating recovery plan across all nodes...") abort_event.set() else: print("[Coordinator] Perfect alignment achieved (<0.05s). Proceeding with regular downloads...") print_elapsed() # Release burst execution barrier barrier.wait() print_elapsed("b") # Wait for both Lumix threads to finish saving local copies completely # Wait until phone physical gesture thread finishes before running ADB pulls t_phone.join() phone_files_captured = pull_recent_burst_photos(local_dest_dir=save_dir) save_barrier.wait() print("\n[Coordinator] Both Lumix cameras finished downloading. Pulling phone media...") print_elapsed("S") if 'abort_event' in locals() and abort_event.is_set(): print("\n[Coordinator] Out of sync state detected. Skipping independent 3D Pipeline invocation.", flush=True) else: print("\n[Coordinator] All device frames pulled successfully! Spawning completely independent 3D Pipeline...", flush=True) MASTER_START_TIME = time.perf_counter() script_path = os.path.join(os.path.dirname(__file__), "part_B.py") current_dir = os.path.dirname(os.path.abspath(__file__)) venv_python = os.path.join(current_dir, ".venv", "Scripts", "python.exe") cmd_args = [venv_python, "-u", script_path, "--start-time", str(MASTER_START_TIME)] if initial_snirf: cmd_args.extend(["--snirf", str(initial_snirf)]) subprocess.Popen( cmd_args, stdout=sys.stdout, stderr=sys.stderr, cwd=os.path.join(current_dir) ) if not bypass_2d: print("\n[Coordinator] Entering synchronized deletion sweep across all hardware nodes...") # Release the delete barrier to let Lumix cameras clean card blocks alongside the ADB script delete_barrier.wait() delete_phone_photos(phone_files_captured) # Cleanup hardware bindings tA.join() tB.join() camA.exit(contextA) camB.exit(contextB) print("\nComplete multi-device burst capture sequence finished.") # ---------- Main Execution Pipeline ---------- def main(): # 1. Android Initialization print("Waking up phone camera via ADB...") run_adb_shell("am start -a android.media.action.STILL_IMAGE_CAMERA") time.sleep(1.5) # 2. Lumix Hardware Initialization print("Searching for connected Lumix cameras...") all_detected = gp.Camera.autodetect() lumix_detected = [ (name, value) for name, value in all_detected.items() if "panasonic" in name.lower() ] print(f"Found {len(all_detected)} total USB devices.") print(f"Lumix devices found: {lumix_detected}") if len(lumix_detected) < 2: print("Error: Need at least 2 Lumix cameras connected.") sys.exit(1) print("\nConnecting to Lumix cameras...") camA, contextA = init_camera_by_path(lumix_detected[0][1]) camB, contextB = init_camera_by_path(lumix_detected[1][1]) if not camA or not camB: print("Error: Lumix initialization failed.") sys.exit(1) # 3. Directory Setup unique_folder_name = "session_1" base_dir = os.path.dirname(os.path.abspath(__file__)) save_dir = os.path.join(base_dir, unique_folder_name) if os.path.exists(save_dir): print(f"Forcibly purging existing directory: {save_dir}") shutil.rmtree(save_dir, ignore_errors=True) os.makedirs(save_dir, exist_ok=True) print(f"Created isolated workspace folder: {save_dir}") print("\n!!! Camera drive mode should be set to Burst 1 / H !!!") input("Press Enter to execute all synced burst captures (Lumix + S24+)...") print("\nExecuting synchronized multi-system burst...") timing_shared = {} barrier = threading.Barrier(3) save_barrier = threading.Barrier(3) delete_barrier = threading.Barrier(3) # Controls deletion phase entry abort_event = threading.Event() # Create Lumix threads tA = threading.Thread(target=threaded_fast_burst, args=(camA, contextA, save_dir, OUTPUT_PREFIX_CAM1, timing_shared, barrier, abort_event, save_barrier, delete_barrier)) tB = threading.Thread(target=threaded_fast_burst, args=(camB, contextB, save_dir, OUTPUT_PREFIX_CAM2, timing_shared, barrier, abort_event, save_barrier, delete_barrier)) t_phone = threading.Thread(target=trigger_humanized_chained_burst) MASTER_START_TIME = time.perf_counter() # Fire the ADB Burst first print("[Coordinator] Launching phone burst thread...") t_phone.start() time.sleep(0.1) # Fire the Lumix camera bursts print("[Coordinator] Launching Lumix camera burst threads...") tA.start() tB.start() print_elapsed() # Coordinator evaluates Lumix synchronization constraints while len(timing_shared) < 2: time.sleep(0.01) delta = abs(timing_shared[OUTPUT_PREFIX_CAM1] - timing_shared[OUTPUT_PREFIX_CAM2]) print(f"\n[Coordinator] Lumix burst sync time difference: {delta:.4f}s") if delta > 0.05: print("[Coordinator] Sync target missed (>0.05s)! Activating recovery plan across all nodes...") abort_event.set() else: print("[Coordinator] Perfect alignment achieved (<0.05s). Proceeding with regular downloads...") print_elapsed() # Release burst execution barrier barrier.wait() print_elapsed("b") # Wait for both Lumix threads to finish saving local copies completely # Wait until phone physical gesture thread finishes before running ADB pulls t_phone.join() phone_files_captured = pull_recent_burst_photos(local_dest_dir=save_dir) save_barrier.wait() print("\n[Coordinator] Both Lumix cameras finished downloading. Pulling phone media...") print_elapsed("S") print("\n[Coordinator] All device frames pulled successfully! Spawning completely independent 3D Pipeline...") print_elapsed() script_path = os.path.join(os.path.dirname(__file__), "part_B.py") current_dir = os.path.dirname(os.path.abspath(__file__)) venv_python = os.path.join(current_dir, "..", ".venv", "Scripts", "python.exe") subprocess.Popen( [venv_python, "-u", script_path, str(MASTER_START_TIME)], stdout=sys.stdout, stderr=sys.stderr, cwd=os.path.join(current_dir) ) print("\n[Coordinator] Entering synchronized deletion sweep across all hardware nodes...") # Release the delete barrier to let Lumix cameras clean card blocks alongside the ADB script delete_barrier.wait() delete_phone_photos(phone_files_captured) # Cleanup hardware bindings tA.join() tB.join() camA.exit(contextA) camB.exit(contextB) print("\nComplete multi-device burst capture sequence finished.") if __name__ == "__main__": parser = argparse.ArgumentParser(description="gphoto2 isolated subprocess bridge") parser.add_argument("--cmd", type=str, required=False, help="Command execution choice (e.g., detect)") parser.add_argument("--snirf", type=str, required=False, help="Path to a SNIRF file to load on startup") parser.add_argument("--bypass-2d", action="store_true", help="Bypass 2D processing pipelines") args = parser.parse_args() try: if args.cmd == "gui": gui_entry(bypass_2d=True, initial_snirf=args.snirf) else: main() except Exception as e: print(f"BRIDGE_ERROR: {type(e).__name__}: {str(e)}") traceback.print_exc(file=sys.stderr) sys.exit(1)