import json
import numpy as np
import os
import pkg_resources
import re
from typing import Any, AnyStr, Dict, List, Optional, Tuple
[docs]def load_peripheral(pdata, templates=None):
"""Load a peripheral from a dict
This loads a peripheral with support for templates, as used in the board
definition file format
Args:
pdata: A dict containing the peripheral definition
templates: A dict mapping types to template definitions
"""
if not 'type' in pdata:
raise ValueError("Peripheral definition requires a type field")
template = None
if templates is not None and pdata['type'] in templates:
template = templates[pdata['type']]
periph = pdata
# Override electrodes with fields from template
def map_electrode(e):
eid = e['id']
if template is None:
return e
e_template = next((x for x in template['electrodes'] if x['id'] == eid), None)
if e_template is None:
return e
# Merge dicts, with values in e taking priority in case of duplicate keys
return {**e_template, **e}
periph['electrodes'] = [map_electrode(e) for e in periph['electrodes']]
return periph
[docs]class Fiducial(object):
"""Represents a fiducial location
"""
def __init__(self, corners: List[List[int]], label: str=""):
self.corners = corners
self.label = label
@staticmethod
def from_dict(data):
return Fiducial(**data)
def to_dict(self):
return {
'corners': self.corners,
'label': self.label
}
[docs]class ControlPoint(object):
"""Represents a control point in an image
A control point is a pair of corresponding points -- one in image coordinates
and the other in grid coordinates -- used to calibrate the position of
the electrode grid relative to fiducials.
"""
def __init__(self, grid_coord: Tuple[float, float], image_coord: Tuple[float, float]):
self.grid = grid_coord
self.image = image_coord
def from_dict(data):
if not 'grid' in data:
raise ValueError(f'A control point must have a `grid` and `image` attribute: {data}')
if not 'image' in data:
raise ValueError(f'A control point must have a `grid` and `image` attribute: {data}')
return ControlPoint(data['grid'], data['image'])
[docs]class Registration(object):
"""A registration is a collection of fiducials and control points which
together define relationship between the electrode locations and fiducials
"""
def __init__(self, data: dict):
if not 'fiducials' in data:
raise ValueError(f'A Registration requires a fiducials attribute, not found in: {data}')
if not 'control_points' in data:
raise ValueError(f'A Registration requires a control points attribute, not found in: {data}')
if not isinstance(data['fiducials'], list):
raise ValueError(f'A Registration `fiducial` attribute must be a list: {data}')
if not isinstance(data['control_points'], list):
raise ValueError(f'a Registration `control_points` attribute must be a list: {data}')
self.fiducials = [Fiducial.from_dict(f) for f in data['fiducials']]
self.control_points = [ControlPoint.from_dict(cp) for cp in data['control_points']]
[docs]class Layout(object):
"""Represents the 'layout' property of a baord definition
A layout defines the placement and pin mapping for the electrodes on the
board.
"""
def __init__(self, layout_def: Dict[str, Any]):
self.peripherals = None
self.grids = []
def intify_pins(grid_pins):
result = []
for row in grid_pins:
new_row: List[Optional[int]] = []
for pin in row:
if pin == -1 or pin is None:
new_row.append(None)
else:
new_row.append(int(pin))
result.append(new_row)
return result
# Old format files use 'grid' to define a single grid
# New format uses an array of objects, under the key 'grids'
if 'grid' in layout_def:
self.grids.append({
'origin': [0.0, 0.0],
'pitch': 1.0,
'pins': intify_pins(layout_def['grid'])
})
elif 'grids' in layout_def:
for g in layout_def['grids']:
self.grids.append({
'origin': g['origin'],
'pitch': g['pitch'],
'pins': intify_pins(g['pins']),
})
if 'peripherals' in layout_def:
self.peripherals = [load_peripheral(p, layout_def.get('peripheral_templates', None)) for p in layout_def['peripherals']]
[docs] def grid_location_to_pin(self, x: int, y: int, grid_number:int =0):
"""Return the pin number at given grid location, or None if no pin is
defined there.
"""
if grid_number < len(self.grids):
grid = self.grids[grid_number]['pins']
else:
grid = [[]] # Empty grid
if y < 0 or y >= len(grid):
return None
row = grid[y]
if x < 0 or x >= len(row):
return None
return grid[y][x]
[docs] def pin_to_grid_location(self, pin: int) -> Optional[Tuple[Tuple[int, int], int]]:
"""Return the grid location of a given pin number
"""
for g, grid in enumerate(self.grids):
for y, row in enumerate(grid['pins']):
for x, p in enumerate(row):
if p == pin:
return ((x, y), g)
return None
[docs] def pin_polygon(self, pin: int) -> Optional[List[Tuple[int, int]]]:
"""Get the polygon defining a pin in board coordinates
"""
# Try to find the pin in a grid
grid_info = self.pin_to_grid_location(pin)
if grid_info is not None:
loc, grid_idx = grid_info
square = np.array([[0., 0.], [0., 1.], [1., 1.], [1., 0.]])
grid = self.grids[grid_idx]
polygon = (square + loc) * grid['pitch'] + grid['origin']
return polygon.tolist()
# Try to find the pin in a peripheral
if self.peripherals is None:
return None
for periph in self.peripherals:
for el in periph['electrodes']:
if el['pin'] == pin:
polygon = np.array(el['polygon'])
rotation = np.deg2rad(periph.get('rotation', 0.0))
R = np.array([[np.cos(rotation), -np.sin(rotation)], [np.sin(rotation), np.cos(rotation)]])
polygon = np.dot(R, polygon.T).T
return (polygon + periph['origin']).tolist()
return None
[docs] def as_dict(self) -> dict:
"""Return a serializable dict version of the board definition
"""
return {
"grids": self.grids,
"peripherals": self.peripherals
}
[docs]class Board(object):
"""Represents the top-level object in an electrode board definition file
"""
def __init__(self, board_def: Dict[str, Any]):
self.registration: Optional[Registration] = None
if not 'layout' in board_def:
raise RuntimeError("Board definition file must contain a 'layout' object")
self.layout = Layout(board_def['layout'])
self.oversized_electrodes = board_def.get('oversized_electrodes', [])
if 'registration' in board_def:
self.registration = Registration(board_def['registration'])
[docs] @staticmethod
def load_from_file(filepath):
"""Create a Board from a board definition file
"""
with open(filepath, 'r') as f:
data = json.loads(f.read())
return Board(data)
[docs] @staticmethod
def load_from_string(data: AnyStr) -> 'Board':
"""Create a board from a JSON string in memory
"""
return Board(json.loads(data))
[docs] def as_dict(self) -> dict:
"""Return a serializable dict representation of the board
"""
return {
'layout': self.layout.as_dict(),
'oversized_electrodes': self.oversized_electrodes,
}
[docs]def list_boards():
"""Find all available board definitions.
Uses same search rules as load_board; see :func:`load_board`.
Returns:
A list of board names, which can be passed to `load_board`
"""
config_dir = os.path.expanduser("~/.config/purpledrop/boards")
package_files = pkg_resources.resource_listdir('purpledrop', 'boards')
if os.path.isdir(config_dir):
config_files = os.listdir(config_dir)
else:
config_files = []
board_names = []
def add_files(files):
for f in files:
print(f"Checking {f}")
match = re.match(r'(.+).json', os.path.basename(f))
if match:
board_names.append(match.group(1))
# Config files take priority, if there are any duplicates
add_files(package_files)
add_files(config_files)
return board_names
[docs]def load_board(name) -> Optional[Board]:
"""Load a board definition by name or path
Attempt to load a board definition from the name, using the following
priorities (the first to succeed is returned):
1. Load as a full path
2. Load from ~/.config/purpledrop/boards/{name}.json
3. Load from package resources (`purpledrop/boards` in repo)
"""
if os.path.isfile(name):
return Board.load_from_file(name)
home_path = os.path.expanduser(f"~/.config/purpledrop/boards/{name}.json")
if os.path.isfile(home_path):
return Board.load_from_file(home_path)
try:
resource_data = pkg_resources.resource_string('purpledrop', f"boards/{name}.json")
return Board.load_from_string(resource_data)
except FileNotFoundError:
pass
return None