Files
2026-07-12 22:29:30 -07:00

622 lines
22 KiB
Python

"""
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)