Files
lights/synced_photo_taker.py
T
2026-07-12 22:29:30 -07:00

258 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Filename: synced_photo_taker.py
Description: Takes photos from all cameras at the same time
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import os
import sys
import time
import threading
import subprocess
# External library imports
import gphoto2 as gp
import cv2
#TODO: Ship this with the application
ADB_PATH = "C:/Android/platform-tools/adb"
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 threaded_trigger(camera, context, results_dict, cam_key):
try:
file_path = camera.capture(gp.GP_CAPTURE_IMAGE, context)
results_dict[cam_key] = file_path
except Exception as e:
print(f"Capture error on {cam_key}: {e}")
results_dict[cam_key] = None
def is_screen_on():
"""Return True if the phone screen is currently on."""
result = subprocess.run(
[ADB_PATH, "shell", "dumpsys", "power"],
capture_output=True, text=True
)
for line in result.stdout.split('\n'):
if 'Display Power' in line and 'state=ON' in line:
return True
return False
def ensure_phone_camera_open():
"""Wake phone if needed, dismiss lock screen, and open camera app."""
if not is_screen_on():
subprocess.run([ADB_PATH, "shell", "input", "keyevent", "26"]) # Power
time.sleep(1.0)
subprocess.run([ADB_PATH, "shell", "wm", "dismiss-keyguard"], capture_output=True)
subprocess.run([ADB_PATH, "shell", "input", "swipe", "500", "1500", "500", "500"], capture_output=True)
time.sleep(0.5)
subprocess.run([
ADB_PATH, "shell", "am", "start", "-a", "android.media.action.STILL_IMAGE_CAMERA"
], capture_output=True)
time.sleep(3.0)
def delete_phone_file(remote_path):
"""Delete a file on the phone to avoid re-pulling it."""
subprocess.run([ADB_PATH, "shell", "rm", remote_path], capture_output=True)
def pull_latest_phone_photo(save_dir, suffix, trigger_time=None):
"""
Pull the most recently created .jpg (by modification time).
If trigger_time is given, only consider files newer than that epoch.
Returns True on success.
"""
cmd = str(ADB_PATH) + " shell stat -c '%Y %n' /sdcard/DCIM/Camera/*.jpg 2>/dev/null"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode != 0 or not result.stdout.strip():
print("No photos found on phone.")
return False
files_with_time = []
for line in result.stdout.strip().split('\n'):
parts = line.split(' ', 1)
if len(parts) == 2:
try:
epoch = int(parts[0])
path = parts[1]
files_with_time.append((epoch, path))
except:
pass
if not files_with_time:
return False
if trigger_time is not None:
files_with_time = [(ep, p) for ep, p in files_with_time if ep > trigger_time]
if not files_with_time:
print("No new photo found after trigger.")
return False
latest_epoch, latest_remote = max(files_with_time, key=lambda x: x[0])
local_path = os.path.join(save_dir, f"Phone_fullres_{suffix}.jpg")
subprocess.run([ADB_PATH, "pull", latest_remote, local_path], capture_output=True)
img = cv2.imread(local_path)
if img is not None:
print(f"Phone photo saved: {local_path} ({img.shape[1]}x{img.shape[0]})")
return True
else:
print("Failed to pull valid photo.")
return False
def trigger_phone_shutter():
"""Trigger shutter and return the epoch time just before firing."""
subprocess.run([ADB_PATH, "shell", "input", "keyevent", "27"])
time.sleep(3.0)
return int(time.time()) - 5
def capture_and_save_triplet(camA, camB, contextA, contextB, save_dir, suffix):
capture_results = {}
tA = threading.Thread(target=threaded_trigger, args=(camA, contextA, capture_results, 'camA'))
tB = threading.Thread(target=threaded_trigger, args=(camB, contextB, capture_results, 'camB'))
tA.start()
tB.start()
trigger_phone_shutter()
tA.join()
tB.join()
fileA = capture_results.get('camA')
fileB = capture_results.get('camB')
if not fileA or not fileB:
print("Lumix capture failed.")
return False
targetA = os.path.join(save_dir, f"Cam_1_fullres_{suffix}.jpg")
targetB = os.path.join(save_dir, f"Cam_2_fullres_{suffix}.jpg")
try:
cfA = gp.CameraFile()
camA.file_get(fileA.folder, fileA.name, gp.GP_FILE_TYPE_NORMAL, cfA, contextA)
cfA.save(targetA)
cfB = gp.CameraFile()
camB.file_get(fileB.folder, fileB.name, gp.GP_FILE_TYPE_NORMAL, cfB, contextB)
cfB.save(targetB)
print(f"Cam1 saved: {targetA}")
print(f"Cam2 saved: {targetB}")
except Exception as e:
print(f"Lumix download error: {e}")
return False
phone_ok = pull_latest_phone_photo(save_dir, suffix)
return phone_ok
def main():
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)
adb_check = subprocess.run([ADB_PATH, "devices"], capture_output=True, text=True)
if "device" not in adb_check.stdout.split("\n")[1]:
print("No ADB device found. Connect phone and enable USB debugging.")
sys.exit(1)
save_dir = os.path.dirname(os.path.abspath(__file__))
print("\nWaking phone and opening camera...")
ensure_phone_camera_open()
print("Taking warmup photo (discarded)...")
trigger_phone_shutter()
_ = pull_latest_phone_photo(save_dir, "warmup")
if _:
cmd = str(ADB_PATH) + " shell stat -c '%Y %n' /sdcard/DCIM/Camera/*.jpg 2>/dev/null"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.stdout.strip():
lines = result.stdout.strip().split('\n')
_, latest_path = max((int(l.split()[0]), l.split(' ',1)[1]) for l in lines if l)
delete_phone_file(latest_path)
print("Warm-up photo deleted from phone.")
print("Warm-up complete.\n")
try:
for i in range(1, 21):
print(f"\n--- Calibration triplet {i}/20 ---")
success = capture_and_save_triplet(camA, camB, contextA, contextB, save_dir, str(i))
if not success:
print("Capture failed for this triplet. You may delete the faulty files later.")
if i < 20:
print("Waiting a second...")
time.sleep(1)
input("\nPosition the optode cap, then press Enter to capture...")
success = capture_and_save_triplet(camA, camB, contextA, contextB, save_dir, "optode")
if success:
for cam, base in [("Cam_1", "Cam_1"), ("Cam_2", "Cam_2"), ("Phone", "Cam_3")]:
src = os.path.join(save_dir, f"{cam}_fullres_optode.jpg")
dst = os.path.join(save_dir, f"{base}.jpg")
if os.path.exists(src):
os.replace(src, dst)
print("Optode photos saved as Cam_1.jpg, Cam_2.jpg, Cam_3.jpg.")
else:
print("Optode capture failed.")
except KeyboardInterrupt:
print("\nInterrupted.")
finally:
camA.exit(contextA)
camB.exit(contextB)
print("Cameras released.")
if __name__ == "__main__":
main()