diff --git a/firmware/octopi_firmware_v2/main_controller_teensy41/main_controller_teensy41.ino b/firmware/octopi_firmware_v2/main_controller_teensy41/main_controller_teensy41.ino index 7433b72bd..8aa06abba 100644 --- a/firmware/octopi_firmware_v2/main_controller_teensy41/main_controller_teensy41.ino +++ b/firmware/octopi_firmware_v2/main_controller_teensy41/main_controller_teensy41.ino @@ -68,6 +68,8 @@ static const int SEND_HARDWARE_TRIGGER = 30; static const int SET_STROBE_DELAY = 31; static const int SET_AXIS_DISABLE_ENABLE = 32; static const int SET_PIN_LEVEL = 41; +static const int SET_CONTINUOUS_HARDWARE_TRIGGERING = 42; +static const int CANCEL_CONTINUOUS_HARDWARE_TRIGGERING = 43; static const int INITFILTERWHEEL = 253; static const int INITIALIZE = 254; static const int RESET = 255; @@ -90,6 +92,7 @@ static const int AXES_XY = 4; static const int AXIS_W = 5; static const int BIT_POS_JOYSTICK_BUTTON = 0; +static const int BIT_POS_CONTINUOUS_TRIGGER_ENABLED = 1; static const int LIM_CODE_X_POSITIVE = 0; static const int LIM_CODE_X_NEGATIVE = 1; @@ -572,38 +575,97 @@ void set_illumination_led_matrix(int source, uint8_t r, uint8_t g, uint8_t b) turn_on_illumination(); //update the illumination } +/*********************** Continuous hardware triggering (debug / test setup only!) *************************/ +struct continuous_hardware_triggering_state { + bool enabled; + uint32_t triggered_us; + uint32_t not_triggered_us; + uint8_t trigger_channel; + uint16_t requested_frames; + + elapsedMicros us_since_last_transition; + uint32_t frames_so_far; +}; + +static struct continuous_hardware_triggering_state DEFAULT_CONTINUOUS_TRIGGERING_STATE = { + .enabled = false, + .triggered_us = 50000, + .not_triggered_us = 50000, + .trigger_channel = 0, + .requested_frames = 0, + + .us_since_last_transition = 0, + .frames_so_far = 0 +}; + +static struct continuous_hardware_triggering_state cont_trig_state = DEFAULT_CONTINUOUS_TRIGGERING_STATE; + void ISR_strobeTimer() { for (int camera_channel = 0; camera_channel < 6; camera_channel++) { - // strobe pulse - if (control_strobe[camera_channel]) - { - if (illumination_on_time[camera_channel] <= 30000) - { - // if the illumination on time is smaller than 30 ms, use delayMicroseconds to control the pulse length to avoid pulse length jitter - if ( ((micros() - timestamp_trigger_rising_edge[camera_channel]) >= strobe_delay[camera_channel]) && strobe_output_level[camera_channel] == LOW ) - { - turn_on_illumination(); - delayMicroseconds(illumination_on_time[camera_channel]); - turn_off_illumination(); - control_strobe[camera_channel] = false; + // If we're in continuous triggering mode, ignore other strobing requests and control the illumination ourselves. + if (cont_trig_state.enabled) { + if (cont_trig_state.trigger_channel != camera_channel) { + continue; + } + + uint8_t current_level = digitalRead(camera_trigger_pins[camera_channel]); + uint8_t next_level = current_level == HIGH ? LOW : HIGH; + + unsigned long this_interval = current_level == HIGH ? cont_trig_state.not_triggered_us : cont_trig_state.triggered_us; + unsigned long elapsed_us = cont_trig_state.us_since_last_transition; + bool need_transition = elapsed_us >= this_interval; + + if (need_transition) { + // If this is a transition to low, we also want to turn on illumination. Otherwise turn it off. + // NOTE(imo): LOW corresponds to 5V, HIGH to 0V + if (next_level == LOW) { + //turn_on_illumination(); + cont_trig_state.frames_so_far++; + } else { + //turn_off_illumination(); } + + digitalWrite(camera_trigger_pins[camera_channel], next_level); + trigger_output_level[camera_channel] = next_level; + cont_trig_state.us_since_last_transition = 0; + + // We finished our last trigger if we just went down to 0V (HIGH), and we've seen as many frames as requested. + if (cont_trig_state.frames_so_far >= cont_trig_state.requested_frames && next_level == HIGH) { + cont_trig_state.enabled = false; + } } - else + } else { + // strobe pulse + if (control_strobe[camera_channel]) { - // start the strobe - if ( ((micros() - timestamp_trigger_rising_edge[camera_channel]) >= strobe_delay[camera_channel]) && strobe_output_level[camera_channel] == LOW ) + if (illumination_on_time[camera_channel] <= 30000) { - turn_on_illumination(); - strobe_output_level[camera_channel] = HIGH; + // if the illumination on time is smaller than 30 ms, use delayMicroseconds to control the pulse length to avoid pulse length jitter + if ( ((micros() - timestamp_trigger_rising_edge[camera_channel]) >= strobe_delay[camera_channel]) && strobe_output_level[camera_channel] == LOW ) + { + turn_on_illumination(); + delayMicroseconds(illumination_on_time[camera_channel]); + turn_off_illumination(); + control_strobe[camera_channel] = false; + } } - // end the strobe - if (((micros() - timestamp_trigger_rising_edge[camera_channel]) >= strobe_delay[camera_channel] + illumination_on_time[camera_channel]) && strobe_output_level[camera_channel] == HIGH) + else { - turn_off_illumination(); - strobe_output_level[camera_channel] = LOW; - control_strobe[camera_channel] = false; + // start the strobe + if ( ((micros() - timestamp_trigger_rising_edge[camera_channel]) >= strobe_delay[camera_channel]) && strobe_output_level[camera_channel] == LOW ) + { + turn_on_illumination(); + strobe_output_level[camera_channel] = HIGH; + } + // end the strobe + if (((micros() - timestamp_trigger_rising_edge[camera_channel]) >= strobe_delay[camera_channel] + illumination_on_time[camera_channel]) && strobe_output_level[camera_channel] == HIGH) + { + turn_off_illumination(); + strobe_output_level[camera_channel] = LOW; + control_strobe[camera_channel] = false; + } } } } @@ -1602,6 +1664,37 @@ void loop() { digitalWrite(pin, level); break; } + case SET_CONTINUOUS_HARDWARE_TRIGGERING: + { + uint8_t channel = buffer_rx[2] & 0x0f; + uint8_t triggered_ms = buffer_rx[3]; + uint8_t not_triggered_ms = buffer_rx[4]; + uint16_t frame_count = (uint32_t)(buffer_rx[5] << 8 | buffer_rx[6]); + + noInterrupts(); + cont_trig_state.enabled = true; + cont_trig_state.triggered_us = 1000 * (uint32_t)(triggered_ms); + cont_trig_state.not_triggered_us = 1000 * (uint32_t)(not_triggered_ms); + cont_trig_state.trigger_channel = channel; + cont_trig_state.requested_frames = frame_count; + + // NOTE(imo): HIGH pulls the line down to 0V, LOW lets it float to 5V. + digitalWrite(camera_trigger_pins[channel], HIGH); + cont_trig_state.frames_so_far = 0; + trigger_output_level[channel] = HIGH; + cont_trig_state.us_since_last_transition = 0; + long cont_illum_time = cont_trig_state.triggered_us + cont_trig_state.not_triggered_us; + illumination_on_time[channel] = cont_illum_time; + interrupts(); + break; + } + case CANCEL_CONTINUOUS_HARDWARE_TRIGGERING: + { + noInterrupts(); + cont_trig_state.enabled = false; + interrupts(); + break; + } case CONFIGURE_STAGE_PID: { int axis = buffer_rx[2]; @@ -1741,6 +1834,7 @@ void loop() { is_preparing_for_homing_Z = false; is_preparing_for_homing_W = false; cmd_id = 0; + cont_trig_state = DEFAULT_CONTINUOUS_TRIGGERING_STATE; break; } default: @@ -1754,45 +1848,16 @@ void loop() { for (int camera_channel = 0; camera_channel < 6; camera_channel++) { // end the trigger pulse - if (trigger_output_level[camera_channel] == LOW && (micros() - timestamp_trigger_rising_edge[camera_channel]) >= TRIGGER_PULSE_LENGTH_us ) - { - digitalWrite(camera_trigger_pins[camera_channel], HIGH); - trigger_output_level[camera_channel] = HIGH; - } - - /* - // strobe pulse - if(control_strobe[camera_channel]) - { - if(illumination_on_time[camera_channel] <= 30000) + noInterrupts(); + bool continuous_triggering = cont_trig_state.enabled && cont_trig_state.trigger_channel == camera_channel; + interrupts(); + if (!continuous_triggering) { + if (trigger_output_level[camera_channel] == LOW && (micros() - timestamp_trigger_rising_edge[camera_channel]) >= TRIGGER_PULSE_LENGTH_us ) { - // if the illumination on time is smaller than 30 ms, use delayMicroseconds to control the pulse length to avoid pulse length jitter (can be up to 20 us if using the code in the else branch) - if( ((micros()-timestamp_trigger_rising_edge[camera_channel])>=strobe_delay[camera_channel]) && strobe_output_level[camera_channel]==LOW ) - { - turn_on_illumination(); - delayMicroseconds(illumination_on_time[camera_channel]); - turn_off_illumination(); - control_strobe[camera_channel] = false; - } + digitalWrite(camera_trigger_pins[camera_channel], HIGH); + trigger_output_level[camera_channel] = HIGH; } - else - { - // start the strobe - if( ((micros()-timestamp_trigger_rising_edge[camera_channel])>=strobe_delay[camera_channel]) && strobe_output_level[camera_channel]==LOW ) - { - turn_on_illumination(); - strobe_output_level[camera_channel] = HIGH; - } - // end the strobe - if(((micros()-timestamp_trigger_rising_edge[camera_channel])>=strobe_delay[camera_channel]+illumination_on_time[camera_channel]) && strobe_output_level[camera_channel]==HIGH) - { - turn_off_illumination(); - strobe_output_level[camera_channel] = LOW; - control_strobe[camera_channel] = false; - } - } - } - */ + } } // homing - preparing for homing @@ -2178,8 +2243,11 @@ void loop() { if (joystick_button_pressed && millis() - joystick_button_pressed_timestamp > 1000) joystick_button_pressed = false; - buffer_tx[18] &= ~ (1 << BIT_POS_JOYSTICK_BUTTON); // clear the joystick button bit - buffer_tx[18] = buffer_tx[18] | joystick_button_pressed << BIT_POS_JOYSTICK_BUTTON; + // Entry 18 is a bunch of state bits. We reset them every time, so just clear to 0 here. + buffer_tx[18] = 0; + noInterrupts(); // Hold interrupts for the cont_trig_state check + buffer_tx[18] = (joystick_button_pressed << BIT_POS_JOYSTICK_BUTTON) | (cont_trig_state.enabled << BIT_POS_CONTINUOUS_TRIGGER_ENABLED); + interrupts(); // Calculate and fill out the checksum. NOTE: This must be after all other buffer_tx modifications are done! uint8_t checksum = crc8ccitt(buffer_tx, MSG_LENGTH - 1); diff --git a/software/control/_def.py b/software/control/_def.py index 5ccc7ddbb..ca6c7ddda 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -160,6 +160,8 @@ class CMD_SET: SET_STROBE_DELAY = 31 SET_AXIS_DISABLE_ENABLE = 32 SET_PIN_LEVEL = 41 + SET_CONTINUOUS_HARDWARE_TRIGGERING = 42 + CANCEL_CONTINUOUS_TRIGGERING = 43 INITFILTERWHEEL = 253 INITIALIZE = 254 RESET = 255 @@ -173,7 +175,7 @@ class CMD_SET2: BIT_POS_JOYSTICK_BUTTON = 0 -BIT_POS_SWITCH = 1 +BIT_POS_CONTINUOUS_TRIGGERING = 1 class HOME_OR_ZERO: diff --git a/software/control/microcontroller.py b/software/control/microcontroller.py index 02f47ad76..d77e6aa86 100644 --- a/software/control/microcontroller.py +++ b/software/control/microcontroller.py @@ -130,7 +130,9 @@ def reconnect(self, attempts: int) -> bool: class SimSerial(AbstractCephlaMicroSerial): @staticmethod - def response_bytes_for(command_id, execution_status, x, y, z, theta, joystick_button, switch) -> bytes: + def response_bytes_for( + command_id, execution_status, x, y, z, theta, joystick_button, continuous_triggering + ) -> bytes: """ - command ID (1 byte) - execution status (1 byte) @@ -144,7 +146,9 @@ def response_bytes_for(command_id, execution_status, x, y, z, theta, joystick_bu """ crc_calculator = CrcCalculator(Crc8.CCITT, table_based=True) - button_state = joystick_button << BIT_POS_JOYSTICK_BUTTON | switch << BIT_POS_SWITCH + button_state = ( + joystick_button << BIT_POS_JOYSTICK_BUTTON | continuous_triggering << BIT_POS_CONTINUOUS_TRIGGERING + ) reserved_state = 0 # This is just filler for the 4 reserved bytes. response = bytearray( struct.pack(">BBiiiiBi", command_id, execution_status, x, y, z, theta, button_state, reserved_state) @@ -165,7 +169,7 @@ def __init__(self): self.z = 0 self.theta = 0 self.joystick_button = False - self.switch = False + self.continuous_triggering = False self._closed = False @@ -221,7 +225,7 @@ def _respond_to(self, write_bytes): self.z, self.theta, self.joystick_button, - self.switch, + self.continuous_triggering, ) ) @@ -476,7 +480,7 @@ def __init__(self, serial_device: AbstractCephlaMicroSerial, reset_and_initializ self.z_pos = 0 # unit: microstep or encoder resolution self.w_pos = 0 # unit: microstep or encoder resolution self.theta_pos = 0 # unit: microstep or encoder resolution - self.button_and_switch_state = 0 + self.button_switch_and_other_bits_state = 0 self.joystick_button_pressed = 0 # This is used to keep track of whether or not we should emit joystick events to the joystick listeners, # and can be changed with enable_joystick(...) @@ -491,6 +495,8 @@ def __init__(self, serial_device: AbstractCephlaMicroSerial, reset_and_initializ self.joystick_event_listeners = [] self.switch_state = 0 + self.continuous_triggering_enabled: bool = False + self.last_command = None self.last_command_send_timestamp = time.time() self.last_command_aborted_error = None @@ -606,6 +612,38 @@ def set_illumination_led_matrix(self, illumination_source, r, g, b): cmd[5] = min(int(b * 255), 255) self.send_command(cmd) + def set_continuous_triggering( + self, frame_count: int, triggered_ms: int, not_triggered_ms: int, trigger_output_ch: int = 0 + ): + if frame_count <= 0: + raise ValueError(f"frame_count must be >0 but is: {frame_count}") + + if triggered_ms <= 0 or triggered_ms > 255: + raise ValueError(f"triggered_ms must be >0 and <255, but is: {triggered_ms}") + + if not_triggered_ms <= 0 or not_triggered_ms > 255: + raise ValueError(f"not_triggered_ms must be >0 and <255, but is: {not_triggered_ms}") + + cmd = bytearray(self.tx_buffer_length) + cmd[1] = CMD_SET.SET_CONTINUOUS_HARDWARE_TRIGGERING + cmd[2] = trigger_output_ch & 0xFF + cmd[3] = triggered_ms & 0xFF + cmd[4] = not_triggered_ms & 0xFF + payload = self._int_to_payload(frame_count, 2) + cmd[5] = (payload >> 8) & 0xFF + cmd[6] = payload & 0xFF + + self.log.debug( + f"Sending continuous triggering request to micro for: {frame_count=}, {triggered_ms=} [ms], {not_triggered_ms=} [ms], {trigger_output_ch=}" + ) + self.send_command(cmd) + + def cancel_continuous_triggering(self): + cmd = bytearray(self.tx_buffer_length) + cmd[1] = CMD_SET.CANCEL_CONTINUOUS_TRIGGERING + self.log.debug("Canceling continuous triggering...") + self.send_command(cmd) + def send_hardware_trigger(self, control_illumination=False, illumination_on_time_us=0, trigger_output_ch=0): illumination_on_time_us = int(illumination_on_time_us) cmd = bytearray(self.tx_buffer_length) @@ -1110,7 +1148,7 @@ def get_msg_with_good_checksum(): - Y pos (4 bytes) - Z pos (4 bytes) - Theta (4 bytes) - - buttons and switches (1 byte) + - buttons, switches, and other bit fields (1 byte) - reserved (4 bytes) - CRC (1 byte) """ @@ -1161,9 +1199,9 @@ def get_msg_with_good_checksum(): msg[14:18], MicrocontrollerDef.N_BYTES_POS ) # unit: microstep or encoder resolution - self.button_and_switch_state = msg[18] + self.button_switch_and_other_bits_state = msg[18] # joystick button - tmp = self.button_and_switch_state & (1 << BIT_POS_JOYSTICK_BUTTON) + tmp = self.button_switch_and_other_bits_state & (1 << BIT_POS_JOYSTICK_BUTTON) joystick_button_pressed = tmp > 0 if self.joystick_button_pressed != joystick_button_pressed: if self.joystick_listener_events_enabled: @@ -1176,9 +1214,8 @@ def get_msg_with_good_checksum(): self.ack_joystick_button_pressed() self.joystick_button_pressed = joystick_button_pressed - # switch - tmp = self.button_and_switch_state & (1 << BIT_POS_SWITCH) - self.switch_state = tmp > 0 + tmp = self.button_switch_and_other_bits_state & (1 << BIT_POS_CONTINUOUS_TRIGGERING) + self.continuous_triggering_enabled = tmp > 0 with self._received_packet_cv: self._received_packet_cv.notify_all() @@ -1192,7 +1229,7 @@ def get_pos(self): return self.x_pos, self.y_pos, self.z_pos, self.theta_pos def get_button_and_switch_state(self): - return self.button_and_switch_state + return self.button_switch_and_other_bits_state def is_busy(self): return self.mcu_cmd_execution_in_progress @@ -1224,7 +1261,7 @@ def _int_to_payload(signed_int, number_of_bytes): payload = signed_int else: payload = 2 ** (8 * number_of_bytes) + signed_int # find two's completement - return payload + return int(payload) @staticmethod def _payload_to_int(payload, number_of_bytes): diff --git a/software/tools/hardware_acquisition_capture_test.py b/software/tools/hardware_acquisition_capture_test.py new file mode 100644 index 000000000..654628323 --- /dev/null +++ b/software/tools/hardware_acquisition_capture_test.py @@ -0,0 +1,183 @@ +import logging +import math +import threading +import time + +import control.microcontroller +import squid.camera.utils +import squid.config +import squid.logging +from squid.abc import CameraFrame, CameraAcquisitionMode + +log = squid.logging.get_logger("camera stress test") + + +class Stats: + def __init__(self): + self.callback_frame_count = 0 + self.last_callback_frame_time = time.time() + self._last_report_frame_count = -1 + + self.start_time = time.time() + self._update_lock = threading.Lock() + + def start(self): + with self._update_lock: + self.callback_frame_count = 0 + self.last_callback_frame_time = time.time() + + self.read_frame_count = 0 + self.last_read_frame_time = time.time() + + self.start_time = time.time() + + def callback_frame(self): + with self._update_lock: + self.callback_frame_count += 1 + self.last_callback_frame_time = time.time() + + def _summary_line(self, label, count, last_frame): + elapsed = last_frame - self.start_time + return f"{label}: {count} in {elapsed:.3f} [s] ({count / elapsed:.3f} [Hz])\n" + + def report_if_on_interval(self, interval): + with self._update_lock: + if self.callback_frame_count % interval == 0 and self._last_report_frame_count != self.callback_frame_count: + self._last_report_frame_count = self.callback_frame_count + self.report() + + def report(self): + log.info(self) + + def __str__(self): + return ( + f"Stats (elapsed = {time.time() - self.start_time} [s]):\n" + f" {self._summary_line('callback', self.callback_frame_count, self.last_callback_frame_time)}" + ) + + +def main(args): + if args.verbose: + squid.logging.set_stdout_log_level(logging.DEBUG) + + microcontroller = control.microcontroller.Microcontroller( + serial_device=control.microcontroller.get_microcontroller_serial_device() + ) + + def hw_trigger(illum_time: float) -> bool: + microcontroller.send_hardware_trigger(False) + + return True + + def strobe_delay_fn(strobe_time_ms: float): + microcontroller.set_strobe_delay_us(int(strobe_time_ms * 1000)) + + return True + + # Special case for simulated camera + if args.camera.lower() == "simulated": + log.info("Using simulated camera!") + camera_type = squid.config.CameraVariant.GXIPY # Not actually used + simulated = True + else: + camera_type = squid.config.CameraVariant.from_string(args.camera) + simulated = False + + if not camera_type: + log.error(f"Invalid camera type '{args.camera}'") + return 1 + + default_config = squid.config.get_camera_config() + force_this_camera_config = default_config.model_copy(update={"camera_type": camera_type}) + + cam = squid.camera.utils.get_camera( + force_this_camera_config, simulated, hw_trigger_fn=hw_trigger, hw_set_strobe_delay_ms_fn=strobe_delay_fn + ) + + stats = Stats() + + def frame_callback(frame: CameraFrame): + stats.callback_frame() + + log.info("Registering frame callback...") + cam.add_frame_callback(frame_callback) + cam.set_exposure_time(args.exposure) + + # TODO(imo): When cameras officially support LEVEL_TRIGGER we need to add and implement that in the cameras. For + # now, always use HARDWARE_TRIGGER and figure it out behind the scenes. + cam.set_acquisition_mode(CameraAcquisitionMode.HARDWARE_TRIGGER) + + # We just want some illumination enabled so we can see if the firmware is doing its job + microcontroller.set_illumination_led_matrix(0, r=0, g=0, b=100) + + log.info("Starting streaming...") + cam.start_streaming() + stats.start() + + end_time = time.time() + args.max_runtime + + log.info( + ( + f"Camera Info:\n" + f" Type: {args.camera}\n" + f" Resolution: {cam.get_resolution()}\n" + f" Exposure Time: {cam.get_exposure_time()} [ms]\n" + f" Strobe Time: {cam.get_strobe_time()} [ms]\n" + ) + ) + + try: + + if args.batch_mode: + frame_time = cam.get_total_frame_time() + triggered_ms = args.trigger_ms + not_triggered_ms = int(math.ceil(frame_time) - math.ceil(triggered_ms) + round(args.extra_not_triggered_ms)) + + microcontroller.set_continuous_triggering(args.frame_count, triggered_ms, not_triggered_ms, 0) + while time.time() < end_time and stats.callback_frame_count < args.frame_count: + if not args.batch_mode and cam.get_ready_for_trigger(): + log.debug("Sending trigger...") + cam.send_trigger() + log.debug("Trigger sent....") + + stats.report_if_on_interval(args.report_interval) + time.sleep(0.0001) + + finally: + if args.batch_mode: + microcontroller.cancel_continuous_triggering() + log.info("Stopping streaming...") + cam.stop_streaming() + + stats.report() + return 0 + + +if __name__ == "__main__": + import argparse + import sys + + ap = argparse.ArgumentParser(description="hammer a camera to test it.") + + ap.add_argument("--frame_count", type=float, help="The number of frames to try to capture", default=100) + ap.add_argument( + "--batch_mode", action="store_true", help="Ask the microcontroller to trigger all the frames (in a row) for us." + ) + ap.add_argument("--exposure", type=float, help="The exposure time in ms", default=1) + ap.add_argument("--report_interval", type=int, help="Report every this many frames captured.", default=100) + ap.add_argument("--verbose", action="store_true", help="Turn on debug logging") + ap.add_argument( + "--camera", + type=str, + required=True, + choices=["hamamatsu", "toupcam", "gxipy", "simulated"], + help="The type of camera to create and use for this test.", + ) + ap.add_argument("--max_runtime", type=float, help="The maximum runtime before timing out.", default=60) + ap.add_argument( + "--extra_not_triggered_ms", type=int, help="Extra time, in ms, to add between triggers.", default=0 + ) + ap.add_argument("--trigger_ms", type=int, help="The time to spend in trigger state", default=1) + args = ap.parse_args() + + sys.exit(main(args))