""" Control the Light Crafter 6500DLP evaluation module over USB. The code is based around the dlp6500 class, which builds the command packets to be sent to the DMD. However, the details of sending the USB packets is implemented in operating system specific subclasses which handle the details of sending these packets. Currently, only support for Windows has been written and tested in the dlp6500win() class. Extensions to Linux can be accomplished by implementing only two functions, _send_raw_packet() and _get_device(), in the dlp6500ix() class. This would likely also require importing an Linux compatible HID module. Although Texas Instruments has an SDK for this evaluation module (http://www.ti.com/tool/DLP-ALC-LIGHTCRAFTER-SDK), but it is not very well documented and we had difficulty building it. Further, it is intended to produce a static library which cannot be used with e.g. python and the ctypes library as a dll could be. This DMD control code was originally based on refactoring in https://github.com/mazurenko/Lightcrafter6500DMDControl. The combine_patterns() function was inspired by https://github.com/csi-dcsc/Pycrafter6500. """ import pywinusb.hid as pyhid import sys import time import struct import numpy as np import copy import datetime import json import argparse ############################################## # compress DMD pattern data ############################################## def combine_patterns(patterns, bit_depth=1): """ Given a series of binary patterns, combine these into 24 bit RGB images to send to DMD. For binary patterns, DMD supports sending a group of up to 24 patterns as an RGB image, with each bit of the 24 bit RGB values giving the pattern for one image. :param patterns: nimgs x ny x nx array of uint8 :param int bit_depth: 1 :return combined_patterns: """ # todo: don't know if there is a pattern combination for other bit depths? if bit_depth != 1: raise NotImplementedError('not implemented') if not np.all(np.logical_or(patterns == 0, patterns == 1)): raise ValueError('patterns must be binary') combined_patterns = [] # determine number of compressed images and create them n_combined_patterns = int(np.ceil(len(patterns) / 24)) for num_pat in range(n_combined_patterns): combined_pattern_current = np.zeros((3, patterns.shape[1], patterns.shape[2]), dtype=np.uint8) for ii in range(np.min([24, len(patterns) - 24*num_pat])): # first 8 patterns encoded in B byte of color image, next 8 in G, last 8 in R if ii < 8: combined_pattern_current[2, :, :] += patterns[ii + 24*num_pat, :, :] * 2**ii elif ii >= 8 and ii < 16: combined_pattern_current[1, :, :] += patterns[ii + 24*num_pat, :, :] * 2**(ii-8) elif ii >= 16 and ii < 24: combined_pattern_current[0, :, :] += patterns[ii + 24*num_pat, :, :] * 2**(ii-16) combined_patterns.append(combined_pattern_current) return combined_patterns def split_combined_patterns(combined_patterns): """ Split binary patterns which have been combined into a single uint8 RGB image back to separate images. :param combined_patterns: 3 x Ny x Nx uint8 array representing up to 24 combined patterns. Actually will accept input of arbitrary dimensions as long as first dimension has size 3. :return: 24 x Ny x Nx array. This will always have first dimension of 24 because the number of zero patterns at the end is ambiguous. """ patterns = np.zeros((24,) + combined_patterns.shape[1:], dtype=np.uint8) for ii in range(8): patterns[ii] = (combined_patterns[2] & 2**ii) >> ii for ii in range(8, 16): patterns[ii] = (combined_patterns[1] & 2 ** (ii-8)) >> (ii-8) for ii in range(16, 24): patterns[ii] = (combined_patterns[0] & 2 ** (ii - 16)) >> (ii+8) return patterns def encode_erle(pattern): """ Encode a 24bit pattern in enhanced run length encoding (ERLE). ERLE is similar to RLE, but now the number of repeats byte is given by either one or two bytes. specification: ctrl byte 1, ctrl byte 2, ctrl byte 3, description 0 , 0 , n/a , end of image 0 , 1 , n , copy n pixels from the same position on the previous line 0 , n>1 , n/a , n uncompressed RGB pixels follow n>1 , n/a , n/a , repeat following pixel n times :param pattern: uint8 3 x Ny x Nx array of RGB values, or Ny x Nx array :return pattern_compressed: """ # pattern must be uint8 if pattern.dtype != np.uint8: raise ValueError('pattern must be of type uint8') # if 2D pattern, expand this to RGB with pattern in B layer and RG=0 if pattern.ndim == 2: pattern = np.concatenate((np.zeros((1,) + pattern.shape, dtype=np.uint8), np.zeros((1,) + pattern.shape, dtype=np.uint8), np.array(pattern[None, :, :], copy=True)), axis=0) if pattern.ndim != 3 and pattern.shape[0] != 3: raise ValueError("Image data is wrong shape. Must be 3 x ny x nx, with RGB values in each layer.") pattern_compressed = [] _, ny, nx = pattern.shape # todo: not sure if this is allowed to cross row_rgb boundaries? If so, could pattern.ravel() instead of looping # todo: don't think above suggestion works, but if last n pixels of above row_rgb are same as first n of this one # todo: then with ERLE encoding I can use \x00\x01 Hex(n). But checking this may not be so easy. Right now # todo: only implemented if entire rows are the same! # todo: erle and rle are different enough probably should split apart more # loop over pattern rows for ii in range(pattern.shape[1]): row_rgb = pattern[:, ii, :] # if this row_rgb is the same as the last row_rgb, can communicate this by sending length of row_rgb # and then \x00\x01 (copy n pixels from previous line) # todo: can also do this for shorter sequences than the entire row_rgb if ii > 0 and np.array_equal(row_rgb, pattern[:, ii - 1, :]): msb, lsb = erle_len2bytes(nx) pattern_compressed += [0x00, 0x01, msb, lsb] else: # find points along row where pixel value changes # for RGB image, change happens when ANY pixel value changes value_changed = np.sum(np.abs(np.diff(row_rgb, axis=1)), axis=0) != 0 # also need to include zero, as this will need to be encoded. # add one to index to get position of first new value instead of last old value inds_change = np.concatenate((np.array([0]), np.where(value_changed)[0] + 1)) # get lengths for each repeat, including last one which extends until end of the line run_lens = np.concatenate((np.array(inds_change[1:] - inds_change[:-1]), np.array([nx - inds_change[-1]]))) # now build compressed list for ii, rlen in zip(inds_change, run_lens): v = row_rgb[:, ii] length_bytes = erle_len2bytes(rlen) pattern_compressed += length_bytes + [v[0], v[1], v[2]] # bytes indicating image end pattern_compressed += [0x00, 0x01, 0x00] return pattern_compressed def encode_rle(pattern): """ Compress pattern use run length encoding (RLE) 'rle': row_rgb length encoding (RLE). Information is encoded as number of repeats of a given value and values. In RLE the number of repeats is given by a single byte. e.g. AAABBCCCCD = 3A2B4C1D The DMD uses a `24bit RGB' encoding scheme, meaning four bits represent each piece of information. The first byte (i.e. the control byte) gives the length, and the next three give the values for RGB. The only exceptions occur when the control byte is 0x00, in this case there are several options. If the next byte is 0x00 this indicates 'end of line', if it is 0x01 this indicates 'end of image', and if it is any other number n, then this indicates the following 3*n bytes are uncompressed i.e. \x00 \x03 \xAB\xCD\xEF \x11\x22\x33 \x44\x55\x66 -> \xAB\xCD\xEF \x11\x22\x33 \x44\x55\x66 specification: ctrl byte 1, color byte, description 0 , 0 , end of line 0 , 1 , end of image (required) 0 , n>=2 , n uncompressed RGB pixels follow n>0 , n/a , repeat following RGB pixel n times :param pattern: :return: """ # pattern must be uint8 if pattern.dtype != np.uint8: raise ValueError('pattern must be of type uint8') # if 2D pattern, expand this to RGB with pattern in B layer and RG=0 if pattern.ndim == 2: pattern = np.concatenate((np.zeros((1,) + pattern.shape, dtype=np.uint8), np.zeros((1,) + pattern.shape, dtype=np.uint8), np.array(pattern[None, :, :], copy=True)), axis=0) if pattern.ndim != 3 and pattern.shape[0] != 3: raise ValueError("Image data is wrong shape. Must be 3 x ny x nx, with RGB values in each layer.") pattern_compressed = [] _, ny, nx = pattern.shape # loop over pattern rows for ii in range(pattern.shape[1]): row_rgb = pattern[:, ii, :] # if this row_rgb is the same as the last row_rgb, can communicate this by sending length of row_rgb # and then \x00\x01 (copy n pixels from previous line) # todo: can also do this for shorter sequences than the entire row_rgb if ii > 0 and np.array_equal(row_rgb, pattern[:, ii - 1, :]): msb, lsb = erle_len2bytes(nx) pattern_compressed += [0x00, 0x01, msb, lsb] else: # find points along row where pixel value changes # for RGB image, change happens when ANY pixel value changes value_changed = np.sum(np.abs(np.diff(row_rgb, axis=1)), axis=0) != 0 # also need to include zero, as this will need to be encoded. # add one to index to get position of first new value instead of last old value inds_change = np.concatenate((np.array([0]), np.where(value_changed)[0] + 1)) # get lengths for each repeat, including last one which extends until end of the line run_lens = np.concatenate((np.array(inds_change[1:] - inds_change[:-1]), np.array([nx - inds_change[-1]]))) # now build compressed list for ii, rlen in zip(inds_change, run_lens): v = row_rgb[:, ii] if rlen <= 255: pattern_compressed += [rlen, v[0], v[1], v[2]] else: # if run is longer than one byte, need to break it up counter = 0 while counter < rlen: end_pt = np.min([counter + 255, rlen]) - 1 current_len = end_pt - counter + 1 pattern_compressed += [current_len, v[0], v[1], v[2]] counter = end_pt + 1 # todo: do I need an end of line character? # todo: is this correct for RLE? # bytes indicating image end pattern_compressed += [0x00] return pattern_compressed def decode_erle(dmd_size, pattern_bytes): """ Decode pattern from ERLE or RLE. :param dmd_size: [ny, nx] :param pattern_bytes: list of bytes representing encoded pattern :return: """ ii = 0 # counter tracking position in compressed byte array line_no = 0 # counter tracking line number line_pos = 0 # counter tracking next position to write in line current_line = np.zeros((3, dmd_size[1]), dtype=np.uint8) rgb_pattern = np.zeros((3, 0, dmd_size[1]), dtype=np.uint8) # todo: maybe should rewrite popping everything to avoid dealing with at least one counter? while ii < len(pattern_bytes): # reset each new line if line_pos == dmd_size[1]: rgb_pattern = np.concatenate((rgb_pattern, current_line[:, None, :]), axis=1) current_line = np.zeros((3, dmd_size[1]), dtype=np.uint8) line_pos = 0 line_no += 1 elif line_pos >= dmd_size[1]: raise ValueError("While reading line %d, length of line exceeded expected value" % line_no) # end of image denoted by single 0x00 byte if ii == len(pattern_bytes) - 1: if pattern_bytes[ii] == 0: break else: raise ValueError('Image not terminated with 0x00') # control byte of zero indicates special response if pattern_bytes[ii] == 0: # end of line if pattern_bytes[ii + 1] == 0: ii += 1 continue # copy bytes from previous lines elif pattern_bytes[ii + 1] == 1: if pattern_bytes[ii + 2] < 128: n_to_copy = pattern_bytes[ii + 2] ii += 3 else: n_to_copy = erle_bytes2len(pattern_bytes[ii + 2:ii + 4]) ii += 4 # copy bytes from same position in previous line current_line[:, line_pos:line_pos + n_to_copy] = rgb_pattern[:, line_no-1, line_pos:line_pos + n_to_copy] line_pos += n_to_copy # next n bytes unencoded else: if pattern_bytes[ii + 1] < 128: n_unencoded = pattern_bytes[ii + 1] ii += 2 else: n_unencoded = erle_bytes2len(pattern_bytes[ii + 1:ii + 3]) ii += 3 for jj in range(n_unencoded): current_line[0, line_pos + jj] = int(pattern_bytes[ii + 3*jj]) current_line[1, line_pos + jj] = int(pattern_bytes[ii + 3*jj + 1]) current_line[2, line_pos + jj] = int(pattern_bytes[ii + 3*jj + 2]) ii += 3 * n_unencoded line_pos += n_unencoded continue # control byte != 0, regular decoding # get block len if pattern_bytes[ii] < 128: block_len = pattern_bytes[ii] ii += 1 else: block_len = erle_bytes2len(pattern_bytes[ii:ii + 2]) ii += 2 # write values to lists for rgb colors current_line[0, line_pos:line_pos + block_len] = np.asarray([pattern_bytes[ii]] * block_len, dtype=np.uint8) current_line[1, line_pos:line_pos + block_len] = np.asarray([pattern_bytes[ii + 1]] * block_len, dtype=np.uint8) current_line[2, line_pos:line_pos + block_len] = np.asarray([pattern_bytes[ii + 2]] * block_len, dtype=np.uint8) ii += 3 line_pos += block_len return rgb_pattern def erle_len2bytes(length): """ Encode a length between 0-2**15-1 as 1 or 2 bytes for use in erle encoding format. Do this in the following way: if length < 128, encode as one byte If length > 128, then encode as two bits. Create the least significant byte (LSB) as follows: set the most significant bit as 1 (this is a flag indicating two bytes are being used), then use the least signifcant 7 bits from length. Construct the most significant byte (MSB) by throwing away the 7 bits already encoded in the LSB. i.e. lsb = (length & 0x7F) | 0x80 msb = length >> 7 :param length: integer 0-(2**15-1) :return: """ # check input if isinstance(length, float): if length.is_integer(): length = int(length) else: raise TypeError('length must be convertible to integer.') # if not isinstance(length, int): # raise Exception('length must be an integer') if length < 0 or length > 2 ** 15 - 1: raise ValueError('length is negative or too large to be encoded.') # main function if length < 128: len_bytes = [length] else: # i.e. lsb is formed by taking the 7 least significant bits and extending to 8 bits by adding # a 1 in the msb position lsb = (length & 0x7F) | 0x80 # second byte obtained by throwing away first 7 bits and keeping what remains msb = length >> 7 len_bytes = [lsb, msb] return len_bytes def erle_bytes2len(byte_list): """ Convert a 1 or 2 byte list in little endian order to length :param list byte_list: [byte] or [lsb, msb] :return length: """ # if msb is None: # length = lsb # else: # length = (msb << 7) + (lsb - 0x80) if len(byte_list) == 1: length = byte_list[0] else: lsb, msb = byte_list length = (msb << 7) + (lsb - 0x80) return length ############################################## # firmware indexing helper functions ############################################## def firmware_index_2pic_bit(firmware_indices): """ convert from single firmware pattern index to picture and bit indices @param firmware_indices: @return: """ pic_inds = firmware_indices // 24 bit_inds = firmware_indices - 24 * pic_inds return pic_inds, bit_inds def pic_bit_ind_2firmware_ind(pic_inds, bit_inds): """ Convert from picture and bit indices to single firmware pattern index @param pic_inds: @param bit_inds: @return: """ firmware_inds = pic_inds * 24 + bit_inds return firmware_inds ############################################## # firmware configuration ############################################## def validate_channel_map(cm): """ check that channel_map is of the correct format @param cm: @return success, message: """ for ch in list(cm.keys()): modes = list(cm[ch].keys()) if "default" not in modes: return False, f"'default' not present in channel '{ch:s}'" for m in modes: keys = list(cm[ch][m].keys()) # check picture indices if "picture_indices" not in keys: return False, f"'picture_indices' not present in channel '{ch:s}', mode '{m:s}'" pi = cm[ch][m]["picture_indices"] if not isinstance(pi, (np.ndarray, list)): return False, f"'picture_indices' wrong type for channel '{ch:s}', mode '{m:s}'" if isinstance(pi, np.ndarray) and pi.ndim != 1: return False, f"'picture_indices' array with wrong dimension, '{ch:s}', mode '{m:s}'" # check bit indices if "bit_indices" not in keys: return False, f"'bit_indices' not present in channel '{ch:s}', mode '{m:s}'" bi = cm[ch][m]["bit_indices"] if not isinstance(bi, (np.ndarray, list)): return False, f"'bit_indices' wrong type for channel '{ch:s}', mode '{m:s}'" if isinstance(bi, np.ndarray) and bi.ndim != 1: return False, f"'bit_indices' array with wrong dimension, '{ch:s}', mode '{m:s}'" return True, "array validated" def save_config_file(fname, pattern_data, channel_map=None): """ Save DMD firmware configuration file @param fname: @param pattern_data: @param channel_map: @return: """ tstamp = datetime.datetime.now().strftime("%Y_%m_%d_%H;%M;%S") # ensure no numpy arrays in pattern_data pattern_data_list = copy.deepcopy(pattern_data) for p in pattern_data_list: for k, v in p.items(): if isinstance(v, np.ndarray): p[k] = v.tolist() # ensure no numpy arrays in channel map channel_map_list = None if channel_map is not None: valid, error = validate_channel_map(channel_map) if not valid: raise ValueError(f"channel_map validation failed with error '{error:s}'") # numpy arrays are not seriablizable ... so avoid these channel_map_list = copy.deepcopy(channel_map) for _, current_ch_dict in channel_map_list.items(): for mode in current_ch_dict.keys(): for k, v in current_ch_dict[mode].items(): if isinstance(v, np.ndarray): current_ch_dict[mode][k] = v.tolist() with open(fname, "w") as f: json.dump({"timestamp": tstamp, "firmware_pattern_data": pattern_data_list, "channel_map": channel_map_list}, f, indent="\t") def load_config_file(fname): """ Load DMD firmware configuration file @param fname: @return: """ with open(fname, "r") as f: data = json.load(f) tstamp = data["timestamp"] pattern_data = data["firmware_pattern_data"] channel_map = data["channel_map"] # convert entries to numpy arrays for p in pattern_data: for k, v in p.items(): if isinstance(v, list) and len(v) > 1: p[k] = np.atleast_1d(v) if channel_map is not None: # validate channel map valid, error = validate_channel_map(channel_map) if not valid: raise ValueError(f"channel_map validation failed with error '{error:s}'") # convert entries to numpy arrays for ch, presets in channel_map.items(): for mode_name, m in presets.items(): for k, v in m.items(): if isinstance(v, list): m[k] = np.atleast_1d(v) return pattern_data, channel_map, tstamp def get_preset_info(preset, pattern_data): """ Get useful data from preset @param preset: @param pattern_data: @return: """ # indices of patterns bi = preset["bit_indices"] pi = preset["picture_indices"] inds = pic_bit_ind_2firmware_ind(pi, bi) # list of pattern data pd = [pattern_data[ii] for ii in inds] # single dictionary with all pattern data pd_all = {} for k in pd[0].keys(): pd_all[k] = [p[k] for p in pd] return pd_all, pd, bi, pi, inds ############################################## # dlp6500 DMD ############################################## class dlp6500: """ Base class for communicating with DLP6500 """ width = 1920 # pixels height = 1080 # pixels pitch = 7.56 # um # todo: maybe add mirror geometry info too e.g. gamma_on, gamma_off, rotation information # todo: but also maybe this requires too many assumptions about geometry that should not be made here # tried to match with the DLP6500 GUI names where possible command_dict = {'Read_Error_Code': 0x0100, 'Read_Error_Description': 0x0101, 'Get_Hardware_Status': 0x1A0A, 'Get_System_Status': 0x1A0B, 'Get_Main_Status': 0x1A0C, 'Get_Firmware_Version': 0x0205, 'Get_Firmware_Type': 0x0206, 'Get_Firmware_Batch_File_Name': 0x1A14, 'Execute_Firmware_Batch_File': 0x1A15, 'Set_Firmware_Batch_Command_Delay_Time': 0x1A16, 'PAT_START_STOP': 0x1A24, 'DISP_MODE': 0x1A1B, 'MBOX_DATA': 0x1A34, 'PAT_CONFIG': 0x1A31, 'PATMEM_LOAD_INIT_MASTER': 0x1A2A, 'PATMEM_LOAD_DATA_MASTER': 0x1A2B, 'TRIG_OUT1_CTL': 0x1A1D, 'TRIG_OUT2_CTL': 0x1A1E, 'TRIG_IN1_CTL': 0x1A35, 'TRIG_IN2_CTL': 0x1A36} err_dictionary = {'no error': 0, 'batch file checksum error': 1, 'device failure': 2, 'invalid command number': 3, 'incompatible controller/dmd': 4, 'command not allowed in current mode': 5, 'invalid command parameter': 6, 'item referred by the parameter is not present': 7, 'out of resource (RAM/flash)': 8, 'invalid BMP compression type': 9, 'pattern bit number out of range': 10, 'pattern BMP not present in flash': 11, 'pattern dark time is out of range': 12, 'signal delay parameter is out of range': 13, 'pattern exposure time is out of range': 14, 'pattern number is out of range': 15, 'invalid pattern definition': 16, 'pattern image memory address is out of range': 17, 'internal error': 255} def __init__(self, vendor_id=0x0451, product_id=0xc900, debug: bool = True, firmware_pattern_info: list = None, presets: dict = None, config_file=None): """ Get instance of DLP LightCrafter evaluation module (DLP6500 or DLP9000). This is the base class which os dependent classes should inherit from. The derived classes only need to implement _get_device and _send_raw_packet. :param vendor_id: vendor id, used to find DMD USB device :param product_id: product id, used to find DMD USB device :param bool debug: If True, will print output of commands. :param firmware_pattern_info: :param presets: :param config_file: either provide config file or provide firmware_pattern_info and presets """ if config_file is not None and (firmware_pattern_info is not None or presets is not None): raise ValueError("both config_file and either firmware_pattern_info or presets were provided. But" "only one of these should be provided.") # load configuration file if config_file is not None: firmware_pattern_info, presets, _ = load_config_file(config_file) # set firmware pattern info self.firmware_pattern_info = firmware_pattern_info self.presets = presets # USB packet length not including report_id_byte self.packet_length_bytes = 64 self.debug = debug # find device self._get_device(vendor_id, product_id) def __del__(self): pass # sending and receiving commands, operating system dependence def _get_device(self, vendor_id, product_id): """ Return handle to DMD. This command can contain OS dependent implementation :param vendor_id: usb vendor id :param product_id: usb product id :return: """ pass def _send_raw_packet(self, buffer, listen_for_reply=False, timeout=5): """ Send a single USB packet. todo: this command can contain os dependent implementation one interesting issue is it seems on linux the report ID byte is stripped by the driver, so we would not need to worry about it here. For windows, we must handle manually. :param buffer: list of bytes to send to device :param listen_for_reply: whether or not to listen for a reply :param timeout: timeout in seconds :return: """ pass # sending and receiving commands, no operating system dependence def send_raw_command(self, buffer, listen_for_reply=False, timeout=5): """ Send a raw command over USB, possibly including multiple packets. In contrast to send_command, this function does not generate the required header data. It deals with splitting one command into multiple packets and appropriately padding the supplied buffer. :param buffer: buffer to send. List of bytes. :param listen_for_reply: Boolean. Whether to wait for a reply form USB device :param timeout: time to wait for reply, in seconds :return: reply: a list of lists of bytes. Each list represents the response for a separate packet. """ reply = [] # handle sending multiple packets if necessary data_counter = 0 while data_counter < len(buffer): # ensure data is correct length data_counter_next = data_counter + self.packet_length_bytes data_to_send = buffer[data_counter:data_counter_next] if len(data_to_send) < self.packet_length_bytes: # pad with zeros if necessary data_to_send += [0x00] * (self.packet_length_bytes - len(data_to_send)) packet_reply = self._send_raw_packet(data_to_send, listen_for_reply, timeout) # if packet_reply != []: # reply.append(packet_reply) reply += packet_reply # increment for next packet data_counter = data_counter_next return reply def send_command(self, rw_mode, reply, command, data=(), sequence_byte=0x00): """ Send USB command to DLP6500 DMD. Only works on Windows. For documentation of DMD commands, see dlpu018.pdf, available at http://www.ti.com/product/DLPC900/technicaldocuments DMD uses little endian byte order. They also use the convention that, when converting from binary to hex the MSB is the rightmost. i.e. \b11000000 = \x03. TODO: is this actually true??? Seems to not be true wrt to flag_byte, but true wrt to pattern defining bytes...maybe only care about this for data that is passed through the DMD? :param rw_mode: 'r' for read, or 'w' for write :param reply: boolean :param command: two byte integer :param data: data to be transmitted. List of integers, where each integer gives a byte :param sequence_byte: integer :return response_buffer: """ # construct header, 4 bytes long # first byte is flag byte flagstring = '' if rw_mode == 'r': flagstring += '1' elif rw_mode == 'w': flagstring += '0' else: raise ValueError("flagstring should be 'r' or 'w' but was '%s'" % flagstring) # second bit is reply if reply: flagstring += '1' else: flagstring += '0' # third bit is error bit flagstring += '0' # fourth and fifth reserved flagstring += '00' # 6-8 destination flagstring += '000' # first byte flag_byte = int(flagstring, 2) # second byte is sequence byte. This is used only to identify responses to given commands. # third and fourth are length of payload, respectively LSB and MSB bytes len_payload = len(data) + 2 len_lsb, len_msb = struct.unpack('BB', struct.pack('H', len_payload)) # get USB command bytes cmd_lsb, cmd_msb = struct.unpack('BB', struct.pack('H', command)) # this does not exactly correspond with what TI calls the header. It is a combination of # the report id_byte, the header, and the USB command bytes header = [flag_byte, sequence_byte, len_lsb, len_msb, cmd_lsb, cmd_msb] buffer = header + list(data) # print commands during debugging if self.debug: # get command name if possible # header print('header: ' + bin(header[0]), end=' ') for ii in range(1, len(header)): print("0x%0.2X" % header[ii], end=' ') print('') # get command name, if possible for k, v in self.command_dict.items(): if v == command: print(k + " (" + hex(command) + ") :", end=' ') break # print contents of command for ii in range(len(data)): print("0x%0.2X" % data[ii], end=' ') print('') return self.send_raw_command(buffer, reply) def decode_command(self, buffer, mode='first-packet'): """ Decode DMD command into constituent pieces """ if mode == 'first-packet': flag_byte = bin(buffer[1]) sequence_byte = hex(buffer[2]) len_bytes = struct.pack('B', buffer[4]) + struct.pack('B', buffer[3]) data_len = struct.unpack('H', len_bytes)[0] cmd = struct.pack('B', buffer[6]) + struct.pack('B', buffer[5]) data = buffer[7:] elif mode == 'nth-packet': flag_byte = None sequence_byte = None len_bytes = None data_len = None cmd = None data = buffer[1:] else: raise ValueError("mode must be 'first-packet' or 'nth-packet', but was '%s'" % mode) return flag_byte, sequence_byte, data_len, cmd, data def decode_flag_byte(self, flag_byte): """ Get parameters from flags set in the flag byte :param flag_byte: :return: """ errs = [2 ** ii & flag_byte != 0 for ii in range(5, 8)] err_names = ['error', 'host requests reply', 'read transaction'] result = {} for e, en in zip(errs, err_names): result[en] = e return result def decode_response(self, buffer): """ Parse USB response from DMD into useful info :param buffer: :return: """ if buffer == []: raise ValueError("buffer was empty") flag_byte = buffer[0] response = self.decode_flag_byte(flag_byte) sequence_byte = buffer[1] # len of data len_bytes = struct.pack('B', buffer[2]) + struct.pack('B', buffer[3]) data_len = struct.unpack(' 0: err_code = resp['data'][0] else: err_code = None error_type = 'not defined' for k, v in self.err_dictionary.items(): if v == err_code: error_type = k break return error_type, err_code def read_error_description(self): """ Retrieve error code description for the last error. When new error messages are written to the DMD buffer, they are written over previous messages. If the new error messages is shorter than the previous one, the remaining characters from earlier errors will still be in the buffer and may be returned. :return: """ buffer = self.send_command('r', True, self.command_dict["Read_Error_Description"]) resp = self.decode_response(buffer) # read until find C style string termination byte, \x00 err_description = '' for ii, d in enumerate(resp['data']): if d == 0: break err_description += chr(d) return err_description def get_hw_status(self): """ Get hardware status of DMD :return: """ buffer = self.send_command('r', True, self.command_dict["Get_Hardware_Status"]) resp = self.decode_response(buffer) errs = [(2**ii & resp['data'][0]) != 0 for ii in range(8)] err_names = ['internal initialization success', 'incompatible controller or DMD', 'DMD rest controller error', 'forced swap error', 'slave controller present', 'reserved', 'sequence abort status error', 'sequencer error'] result = {} for e, en in zip(errs, err_names): result[en] = e return result def get_system_status(self): """ Get status of internal memory test :return: """ buffer = self.send_command('r', True, self.command_dict["Get_System_Status"]) resp = self.decode_response(buffer) return {'internal memory test passed': bool(resp['data'][0])} def get_main_status(self): """ Get DMD main status :return: """ # todo: which byte gives info? first data byte? buffer = self.send_command('r', True, self.command_dict["Get_Main_Status"]) resp = self.decode_response(buffer) errs = [2 ** ii & resp['data'][0] != 0 for ii in range(8)] err_names = ['DMD micromirrors are parked', 'sequencer is running normally', 'video is frozen', 'external video source is locked', 'port 1 syncs valid', 'port 2 syncs valid', 'reserved', 'reserved'] result = {} for e, en in zip(errs, err_names): result[en] = e return result def get_firmware_version(self): """ Get firmware version information from DMD :return: """ buffer = self.send_command('r', True, self.command_dict["Get_Firmware_Version"]) resp = self.decode_response(buffer) app_version = resp['data'][0:4] app_patch = struct.unpack(' 20e3: raise ValueError('rising edge delay must be in range -20 -- 20000us') if falling_edge_delay_us < -20 or falling_edge_delay_us > 20e3: raise ValueError('falling edge delay must be in range -20 -- 20000us') if invert: assert rising_edge_delay_us >= falling_edge_delay_us # data trig_byte = [int(invert)] rising_edge_bytes = struct.unpack('BB', struct.pack(' 511: raise ValueError("num_patterns must be <= 511 but was %d" % num_patterns) num_patterns_bytes = list(struct.unpack('BB', struct.pack('= 0 pattern_index_bytes = list(struct.unpack('BB', struct.pack('=105us :param list[int] dark_times: dark times in us. Either a single uint8 number or a list the same length as the number of patterns :param bool triggered: Whether or not DMD should wait to be triggered to display the next pattern :param bool clear_pattern_after_trigger: Whether or not to keep displaying the pattern at the end of exposure time, i.e. during time while DMD is waiting for the next trigger. :param int bit_depth: Bit depth of patterns :param int num_repeats: Number of repeats. 0 means infinite. :param str compression_mode: 'erle', 'rle', or 'none' :param bool combine_images: :return stored_image_indices, stored_bit_indices: image and bit indices where each image was stored """ # ######################### # check arguments # ######################### if patterns.dtype != np.uint8: raise ValueError('patterns must be of dtype uint8') if patterns.ndim == 2: patterns = np.expand_dims(patterns, axis=0) npatterns = len(patterns) # if only one exp_times, apply to all patterns if not isinstance(exp_times, (list, np.ndarray)): exp_times = [exp_times] if not all(list(map(lambda t: isinstance(t, int), exp_times))): raise ValueError("exp_times must be a list of integers") if patterns.shape[0] > 1 and len(exp_times) == 1: exp_times = exp_times * patterns.shape[0] # if only one dark_times, apply to all patterns if isinstance(dark_times, int): dark_times = [dark_times] if not all(list(map(lambda t: isinstance(t, int), dark_times))): raise ValueError("dark_times must be a list of integers") if patterns.shape[0] > 1 and len(dark_times) == 1: dark_times = dark_times * patterns.shape[0] # ######################### # ######################### # need to issue stop before changing mode. Otherwise DMD will sometimes lock up and not be responsive. self.start_stop_sequence('stop') # set to on-the-fly mode buffer = self.set_pattern_mode('on-the-fly') resp = self.decode_response(buffer) if resp['error']: print(self.read_error_description()) # stop any currently running sequences # note: want to stop after changing pattern mode, because otherwise may throw error self.start_stop_sequence('stop') # set image parameters for look up table # When uploading 1 bit image, each set of 24 images are first combined to a single 24 bit RGB image. pattern_index # refers to which 24 bit RGB image a pattern is in, and pattern_bit_index refers to which bit of that image (i.e. # in the RGB bytes, it is stored in. # todo: decide if is smaller to send compressed as 24 bit RGB or individual image... stored_image_indices = np.zeros(npatterns, dtype=int) stored_bit_indices = np.zeros(npatterns, dtype=int) for ii, (p, et, dt) in enumerate(zip(patterns, exp_times, dark_times)): stored_image_indices[ii] = ii // 24 stored_bit_indices[ii] = ii % 24 buffer = self.pattern_display_lut_definition(ii, exposure_time_us=et, dark_time_us=dt, wait_for_trigger=triggered, clear_pattern_after_trigger=clear_pattern_after_trigger, bit_depth=bit_depth, stored_image_index=stored_image_indices[ii], stored_image_bit_index=stored_bit_indices[ii]) resp = self.decode_response(buffer) if resp['error']: print(self.read_error_description()) buffer = self.pattern_display_lut_configuration(npatterns, num_repeats) resp = self.decode_response(buffer) if resp['error']: print(self.read_error_description()) # can combine images if bit depth = 1 # todo: test if things work if I don't combine images in the 24bit format if combine_images: if bit_depth == 1: patterns = combine_patterns(patterns) else: raise NotImplementedError("Combining multiple images into a 24-bit RGB image is only" " implemented for bit depth 1.") # compress and load images # images must be loaded in backwards order according to programming manual for ii, dmd_pattern in reversed(list(enumerate(patterns))): if self.debug: print("sending pattern %d/%d" % (ii + 1, len(patterns))) if compression_mode == 'none': raise NotImplementedError("compression mode 'none' has not been tested.") compressed_pattern = np.packbits(dmd_pattern.ravel()) elif compression_mode == 'rle': raise NotImplementedError("compression mode 'rle' as not been tested.") compressed_pattern = encode_rle(dmd_pattern) elif compression_mode == 'erle': compressed_pattern = encode_erle(dmd_pattern) # pattern has an additional 48 bytes which must be sent also. # todo: Maybe I should nest the call to init_pattern_bmp_load in pattern_bmp_load? buffer = self.init_pattern_bmp_load(len(compressed_pattern) + 48, pattern_index=ii) resp = self.decode_response(buffer) if resp['error']: print(self.read_error_description()) self.pattern_bmp_load(compressed_pattern, compression_mode) # this PAT_CONFIG command is necessary, otherwise subsequent calls to set_pattern_sequence() will not behave # as expected. buffer = self.pattern_display_lut_configuration(npatterns, num_repeats) resp = self.decode_response(buffer) if resp['error']: print(self.read_error_description()) # start sequence self.start_stop_sequence('start') # some weird behavior where wants to be STOPPED before starting triggered sequence. This seems to happen # intermittently. Probably due to some other DMD setting that I'm not aware of? # todo: is this still true? One problem is the way the patterns respond depends on what the DMD's # trigger state is when loading if triggered: self.start_stop_sequence('stop') return stored_image_indices, stored_bit_indices def set_pattern_sequence(self, image_indices, bit_indices, exp_times, dark_times, triggered=False, clear_pattern_after_trigger=True, bit_depth=1, num_repeats=0, mode='pre-stored'): """ Setup pattern sequence from patterns previously stored in DMD memory, either in on-the-fly pattern mode, or in pre-stored pattern mode In most cases, use program_dmd_seq() instead of calling this function directly :param list[int] image_indices: :param list[int] bit_indices: :param int exp_times: :param int dark_times: :param bool triggered: :param bool clear_pattern_after_trigger: :param int bit_depth: :param int num_repeats: number of repeats. 0 repeats means repeat continuously. :param str mode: pre-stored or :return: """ # ######################### # check arguments # ######################### if isinstance(image_indices, int) or np.issubdtype(type(image_indices), np.integer): image_indices = [image_indices] elif isinstance(image_indices, np.ndarray): image_indices = list(image_indices) if isinstance(bit_indices, int) or np.issubdtype(type(bit_indices), np.integer): bit_indices = [bit_indices] elif isinstance(bit_indices, np.ndarray): bit_indices = list(bit_indices) if len(image_indices) != len(bit_indices): raise ValueError("image_indices and bit_indices must be the same length.") nimgs = len(image_indices) if mode == 'on-the-fly' and 0 not in bit_indices: raise ValueError("Known issue (not with this code, but with DMD) that if 0 is not included in the bit" "indices, then the patterns displayed will not correspond with the indices supplied.") # if only one exp_times, apply to all patterns if isinstance(exp_times, int): exp_times = [exp_times] if not all(list(map(lambda t: isinstance(t, int), exp_times))): raise ValueError("exp_times must be a list of integers") if nimgs > 1 and len(exp_times) == 1: exp_times = exp_times * nimgs # if only one dark_times, apply to all patterns if isinstance(dark_times, int): dark_times = [dark_times] if not all(list(map(lambda t: isinstance(t, int), dark_times))): raise ValueError("dark_times must be a list of integers") if nimgs > 1 and len(dark_times) == 1: dark_times = dark_times * nimgs # ######################### # ######################### # need to issue stop before changing mode, otherwise DMD will sometimes lock up and not be responsive. self.start_stop_sequence('stop') # set to pattern mode buffer = self.set_pattern_mode(mode) resp = self.decode_response(buffer) if resp['error']: print(self.read_error_description()) # stop any currently running sequences # note: want to stop after changing pattern mode, because otherwise may throw error self.start_stop_sequence('stop') # set image parameters for look up table_ for ii, (et, dt) in enumerate(zip(exp_times, dark_times)): buffer = self.pattern_display_lut_definition(ii, exposure_time_us=et, dark_time_us=dt, wait_for_trigger=triggered, clear_pattern_after_trigger=clear_pattern_after_trigger, bit_depth=bit_depth, stored_image_index=image_indices[ii], stored_image_bit_index=bit_indices[ii]) resp = self.decode_response(buffer) if resp['error']: print(self.read_error_description()) # PAT_CONFIG command buffer = self.pattern_display_lut_configuration(nimgs, num_repeat=num_repeats) if buffer == []: print(self.read_error_description()) else: resp = self.decode_response(buffer) if resp['error']: print(self.read_error_description()) # start sequence self.start_stop_sequence('start') # some weird behavior where wants to be STOPPED before starting triggered sequence if triggered: self.start_stop_sequence('stop') ####################################### # high-level commands for working with patterns and pattern sequences # the primary difference from the low level functions is that the high-level functions recognize # the concept of "channels" and "modes" describing families of DMD patterns. This information can be # supplied at instantiation using the "presets" argument ####################################### def get_dmd_sequence(self, modes: list[str], channels: list[str], nrepeats: list[int], ndarkframes: int, blank: list[bool], mode_pattern_indices=None): """ Generate DMD patterns from a list of modes and channels This function requires that self.presets exists. self.presets[channel][mode] are dictionaries with two keys, "picture_indices" and "bit_indices" @param modes: @param channels: @param nrepeats: @param ndarkframes: @param blank: @param mode_pattern_indices: @return picture_indices, bit_indices: """ if self.presets is None: raise ValueError("presets was None, but must be populated with channels and modes for this function to work") # check channel argument if isinstance(channels, str): channels = [channels] if not isinstance(channels, list): raise ValueError() nmodes = len(channels) # check mode argument if isinstance(modes, str): modes = [modes] if not isinstance(modes, list): raise ValueError() if len(modes) == 1 and nmodes > 1: modes = modes * nmodes if len(modes) != nmodes: raise ValueError() # check pattern indices argument if mode_pattern_indices is None: mode_pattern_indices = [] for c, m in zip(channels, modes): npatterns = len(self.presets[c][m]["picture_indices"]) mode_pattern_indices.append(np.arange(npatterns, dtype=int)) if isinstance(mode_pattern_indices, int): mode_pattern_indices = [mode_pattern_indices] if not isinstance(mode_pattern_indices, list): raise ValueError() if len(mode_pattern_indices) == 1 and nmodes > 1: mode_pattern_indices = mode_pattern_indices * nmodes if len(mode_pattern_indices) != nmodes: raise ValueError() # check nrepeats correct type if isinstance(nrepeats, int): nrepeats = [nrepeats] if not isinstance(nrepeats, list): raise ValueError() if nrepeats is None: nrepeats = [] for _ in zip(channels, modes): nrepeats.append(1) if len(nrepeats) == 1 and nmodes > 1: nrepeats = nrepeats * nmodes if len(nrepeats) != nmodes: raise ValueError() # check blank argument if isinstance(blank, bool): blank = [blank] if not isinstance(blank, list): raise ValueError() if len(blank) == 1 and nmodes > 1: blank = blank * nmodes if len(blank) != nmodes: raise ValueError() # processing pic_inds = [] bit_inds = [] for c, m, ind, nreps in zip(channels, modes, mode_pattern_indices, nrepeats): # need np.array(..., copy=True) to don't get references in arrays pi = np.array(np.atleast_1d(self.presets[c][m]["picture_indices"]), copy=True) bi = np.array(np.atleast_1d(self.presets[c][m]["bit_indices"]), copy=True) # select indices pi = pi[ind] bi = bi[ind] # repeats pi = np.hstack([pi] * nreps) bi = np.hstack([bi] * nreps) pic_inds.append(pi) bit_inds.append(bi) # insert dark frames if ndarkframes != 0: for ii in range(nmodes): ipic_off = self.presets[channels[ii]]["off"]["picture_indices"] ibit_off = self.presets[channels[ii]]["off"]["bit_indices"] pic_inds[ii] = np.concatenate((ipic_off * np.ones(ndarkframes, dtype=int), pic_inds[ii]), axis=0).astype(int) bit_inds[ii] = np.concatenate((ibit_off * np.ones(ndarkframes, dtype=int), bit_inds[ii]), axis=0).astype(int) # insert blanking frames for ii in range(nmodes): if blank[ii]: npatterns = len(pic_inds[ii]) ipic_off = self.presets[channels[ii]]["off"]["picture_indices"] ibit_off = self.presets[channels[ii]]["off"]["bit_indices"] ipic_new = np.zeros((2 * npatterns), dtype=int) ipic_new[::2] = pic_inds[ii] ipic_new[1::2] = ipic_off ibit_new = np.zeros((2 * npatterns), dtype=int) ibit_new[::2] = bit_inds[ii] ibit_new[1::2] = ibit_off pic_inds[ii] = ipic_new bit_inds[ii] = ibit_new pic_inds = np.hstack(pic_inds) bit_inds = np.hstack(bit_inds) return pic_inds, bit_inds def program_dmd_seq(self, modes: list[str], channels: list[str], nrepeats: list[int], ndarkframes: int, blank: list[bool], mode_pattern_indices: list[int], triggered: bool, verbose: bool = False, exp_time_us: int = 105): """ convenience function for generating DMD pattern and programming DMD @param dmd: @param modes: @param channels: @param nrepeats: @param ndarkframes: @param blank: @param mode_pattern_indices: @param triggered: @param verbose: @return: """ pic_inds, bit_inds = self.get_dmd_sequence(modes, channels, nrepeats, ndarkframes, blank, mode_pattern_indices) # ######################################### # DMD commands # ######################################### self.debug = verbose self.start_stop_sequence('stop') # check DMD trigger state delay1_us, mode_trig1 = self.get_trigger_in1() # print('trigger1 delay=%dus' % delay1_us) # print('trigger1 mode=%d' % mode_trig1) # dmd.set_trigger_in2('rising') mode_trig2 = self.get_trigger_in2() # print("trigger2 mode=%d" % mode_trig2) self.set_pattern_sequence(pic_inds, bit_inds, exp_time_us, 0, triggered=triggered, clear_pattern_after_trigger=False, bit_depth=1, num_repeats=0, mode='pre-stored') if verbose: # print pattern info print("%d picture indices: " % len(pic_inds), end="") print(pic_inds) print("%d bit indices: " % len(bit_inds), end="") print(bit_inds) print("finished programming DMD") return pic_inds, bit_inds class dlp6500win(dlp6500): """ Class for handling dlp6500 on windows os """ def __init__(self, **kwargs): super(dlp6500win, self).__init__(**kwargs) def __del__(self): self.dmd.close() def _get_device(self, vendor_id, product_id): """ Return handle to DMD. This command can contain OS dependent implenetation :param vendor_id: usb vendor id :param product_id: usb product id :return: """ filter = pyhid.HidDeviceFilter(vendor_id=vendor_id, product_id=product_id) devices = filter.get_devices() self.dmd = devices[0] self.dmd.open() # variable for holding response of dmd self._response = [] # strip off first return byte response_handler = lambda data: self._response.append(data[1:]) self.dmd.set_raw_data_handler(response_handler) def _send_raw_packet(self, buffer, listen_for_reply=False, timeout=5): """ Send a single USB packet. :param buffer: list of bytes to send to device :param listen_for_reply: whether or not to listen for a reply :param timeout: timeout in seconds :return: reply: a list of bytes """ # ensure packet is correct length assert len(buffer) == self.packet_length_bytes report_id_byte = [0x00] # clear reply buffer before sending self._response = [] # send reports = self.dmd.find_output_reports() reports[0].send(report_id_byte + buffer) # only wait for a reply if necessary if listen_for_reply: tstart = time.time() while self._response == []: time.sleep(0.1) tnow = time.time() if timeout is not None: if (tnow - tstart) > timeout: print('read command timed out') break if self._response != []: reply = copy.deepcopy(self._response[0]) else: reply = [] return reply class dlp6500ix(dlp6500): """ Class for handling dlp6500 on linux os """ def __init__(self, **kwargs): raise NotImplementedError("dlp6500ix has not been fully implemented. The functions _get_device() and" " _send_raw_packet() need to be implemented.") super(dlp6500ix, self).__init__(**kwargs) def __del__(self): pass def _get_device(self, vendor_id, product_id): pass def _send_raw_packet(self, buffer, listen_for_reply=False, timeout=5): pass class dlp6500dummy(dlp6500): """Dummy class, useful for testing command generation when no DMD is connected""" def __init__(self, **kwargs): super(dlp6500dummy, self).__init__(**kwargs) def _get_device(self, vendor_id, product_id): pass def _send_raw_packet(self, buffer, listen_for_reply=False, timeout=5): pass def read_error_description(self): return [['']] if __name__ == "__main__": # ####################### # example command line parser # ####################### fname = "dmd_config.json" try: pattern_data, presets, _ = load_config_file(fname) except FileNotFoundError: raise FileNotFoundError(f"file `{fname:s}` was not found. For the command line parser to work, place " f"a file with this name in the same directory as dlp6500.py") # ####################### # define arguments # ####################### parser = argparse.ArgumentParser(description="Set DLP6500 DMD pattern sequence from the command line.") # allowed channels all_channels = list(presets.keys()) parser.add_argument("channels", type=str, nargs="+", choices=all_channels, help="supply the channels to be used in this acquisition as strings separated by spaces") # allowed modes modes = list(set([m for c in all_channels for m in list(presets[c].keys())])) modes_help = "supply the modes to be used with each channel as strings separated by spaces." \ "each channel supports its own list of modes.\n" for c in all_channels: modes_with_parenthesis = ["'%s'" % m for m in list(presets[c].keys())] modes_help += ("channel '%s' supports: " % c) + ", ".join(modes_with_parenthesis) + ".\n" parser.add_argument("-m", "--modes", type=str, nargs=1, choices=modes, default="default", help=modes_help) # pattern indices pattern_indices_help = "Among the patterns specified in the subset specified by `channels` and `modes`," \ " only run these indices. For a given channel and mode, allowed indices range from 0 to npatterns - 1." \ "This options is most commonly used when only a single channel and mode are provided.\n" for c in list(presets.keys()): for m in list(presets[c].keys()): pattern_indices_help += "channel '%s` and mode '%s' npatterns = %d.\n" % (c, m, len(presets[c][m]["picture_indices"])) parser.add_argument("-i", "--pattern_indices", type=int, help=pattern_indices_help) parser.add_argument("-r", "--nrepeats", type=int, default=1, help="number of times to repeat the patterns specificed by `channels`, `modes`, and `pattern_indices`") # other parser.add_argument("-t", "--triggered", action="store_true", help="set DMD to wait for trigger before switching pattern") parser.add_argument("-d", "--ndarkframes", type=int, default=0, help="set number of dark frames to be added before each color of SIM/widefield pattern") parser.add_argument("-b", "--blank", action="store_true", help="set whether or not to insert OFF patterns between each SIM pattern to blank laser") parser.add_argument("-v", "--verbose", action="store_true", help="print more verbose DMD programming information") parser.add_argument("--illumination_time", type=int, default = 105, help="illumination time in microseconds. Ignored if triggered is true") args = parser.parse_args() if args.verbose: print(args) # ####################### # load DMD # ####################### use_dummy = False if use_dummy: dmd = dlp6500dummy(firmware_pattern_info=pattern_data, presets=presets) else: # detect system if sys.platform == "win32": dmd = dlp6500win(firmware_pattern_info=pattern_data, presets=presets) elif sys.platform == "linux": dmd = dlp6500ix(firmware_pattern_info=pattern_data, presets=presets) else: raise NotImplementedError(f"platform was '{sys.platform:s}' but must be 'win32' or 'linux'") pic_inds, bit_inds = dmd.program_dmd_seq(args.modes, args.channels, args.nrepeats, args.ndarkframes, args.blank, args.pattern_indices, args.triggered, args.verbose, args.illumination_time)