Repository: ManiacalLabs/BiblioPixel
Pull request #293 (2017-07-18 10:30:22)

Allow projects to use numpy arrays.
"Sooo, sooo difficult!  Only in version 4!" -someone.  :-D

Turns out it's just another flag to the data_maker.

Of course, this flag is turned off by default so no behavior changes, but you can turn on numpy as part of your maker flags in your project, or with the command line `--numpy`/`-n` flag.


Patch:
diff --git a/bibliopixel/data_maker.py b/bibliopixel/data_maker.py
index 43976e0..1872836 100644
--- a/bibliopixel/data_maker.py
+++ b/bibliopixel/data_maker.py
@@ -1,30 +1,45 @@
 import ctypes
 from multiprocessing.sharedctypes import RawArray
+from . import log
 
-# Note https://stackoverflow.com/questions/37705974/
+try:
+    import numpy
+except:
+    numpy = None
 
-BYTE, FLOAT = ctypes.c_uint8, ctypes.c_float
+USE_NUMPY = False
 
 
-def shared_list_maker(type):
-    return lambda size: RawArray(type, size)
+def Maker(floating=None, shared_memory=False, use_numpy=USE_NUMPY):
+    def list_maker(size):
+        return [(0, 0, 0)] * size
 
+    if use_numpy and not numpy:
+        log.error('numpy module is not available.')
+        use_numpy = False
 
-def list_maker(size):
-    return [(0, 0, 0)] * size
+    if use_numpy:
+        log.info('Using numpy')
 
+    if not (shared_memory or use_numpy):
+        return bytearray, list_maker
 
-class Maker(object):
-    def __init__(self, integer=False, shared_memory=False):
-        if shared_memory:
-            self.make_packet = shared_list_maker(BYTE)
-            number_type = BYTE if integer else FLOAT
-            self.color_list = shared_list_maker(3 * number_type)
+    byte_type, float_type = ctypes.c_uint8, ctypes.c_float
+    floating = (not shared_memory) if (floating is None) else floating
+    number_type = float_type if floating else byte_type
 
-        else:
-            self.make_packet = bytearray
-            self.color_list = list_maker
+    if shared_memory:
+        def shared_list_maker(type):
+            # Note https://stackoverflow.com/questions/37705974/
+            return lambda size: RawArray(type, size)
+
+        return shared_list_maker(byte_type), shared_list_maker(3 * number_type)
+
+    def numpy_list_maker(size):
+        return numpy.array(list_maker(size), dtype=number_type)
+
+    return bytearray, numpy_list_maker
 
 
 MAKER = Maker()
-ColorList = MAKER.color_list
+ColorList = MAKER[1]
diff --git a/bibliopixel/drivers/SPI/APA102.py b/bibliopixel/drivers/SPI/APA102.py
index f94f2a7..97c96aa 100644
--- a/bibliopixel/drivers/SPI/APA102.py
+++ b/bibliopixel/drivers/SPI/APA102.py
@@ -18,8 +18,8 @@ class APA102(SPIBase):
         self._pixel_stop = self._start_frame + self._pixel_bytes
         self._reset_frame = 4  # for SK9822 [0, 0, 0, 0]
         self._end_frame = (num // 2) + 1
-        self._packet = self.maker.make_packet(self._start_frame + self._pixel_bytes +
-                                              self._reset_frame + self._end_frame)
+        self._packet = self.make_packet(self._start_frame + self._pixel_bytes +
+                                        self._reset_frame + self._end_frame)
 
         self.set_device_brightness(0xFF)  # required to setup _packet
 
diff --git a/bibliopixel/drivers/driver_base.py b/bibliopixel/drivers/driver_base.py
index 9965029..2276119 100644
--- a/bibliopixel/drivers/driver_base.py
+++ b/bibliopixel/drivers/driver_base.py
@@ -21,8 +21,7 @@ class DriverBase(object):
             if num == 0:
                 raise ValueError("Either num, or width and height are needed!")
 
-        self.maker = maker
-
+        self.make_packet, self.color_list = maker
         self.numLEDs = num
         gamma = gamma or _gamma.DEFAULT
         self.gamma = gamma
@@ -37,7 +36,7 @@ class DriverBase(object):
 
         self.width = width
         self.height = height
-        self._buf = maker.make_packet(self.bufByteCount())
+        self._buf = self.make_packet(self.bufByteCount())
 
         self.lastUpdate = 0
         self.brightness_lock = threading.Lock()
diff --git a/bibliopixel/layout/layout.py b/bibliopixel/layout/layout.py
index 4df0afd..083dd19 100644
--- a/bibliopixel/layout/layout.py
+++ b/bibliopixel/layout/layout.py
@@ -15,7 +15,7 @@ class Layout(object):
 
         # This buffer will always be the same list - i.e. is guaranteed to only
         # be changed by list surgery, never assignment.
-        self._colors = maker.color_list(self.numLEDs)
+        self._colors = maker[1](self.numLEDs)
 
         pos = 0
         for d in self.drivers:
diff --git a/bibliopixel/layout/strip.py b/bibliopixel/layout/strip.py
index 543affa..c6a3968 100644
--- a/bibliopixel/layout/strip.py
+++ b/bibliopixel/layout/strip.py
@@ -6,7 +6,7 @@ from . geometry.strip import gen_strip
 class Strip(Layout):
 
     def __init__(self, drivers, threadedUpdate=False,
-                 brightness=255, pixelWidth=1, **kwargs):
+                 brightness=255, pixelWidth=1, coordMap=None, **kwargs):
         super().__init__(drivers, threadedUpdate, brightness,
                          maker=kwargs.get('maker', data_maker.MAKER))
 
@@ -23,17 +23,29 @@ class Strip(Layout):
             self.set = self._setScaled
             self.numLEDs = self.numLEDs / self.pixelWidth
 
+        self.coordMap = coordMap
+        if self.coordMap:
+            if len(self.coordMap) != self.numLEDs:
+                raise ValueError('coordMap length must equal total number of pixels!')
+            self.set_base = self._set_strip_mapped
+        else:
+            self.set_base = self._set_base
+
         self.set_pixel_positions(gen_strip(self.numLEDs))
 
+    def _set_strip_mapped(self, pixel, color):
+        pixel = self.coordMap[pixel]
+        self._set_base(pixel, color)
+
     # Set single pixel to Color value
     def _set(self, pixel, color):
         """Set pixel to RGB color tuple"""
-        self._set_base(pixel, color)
+        self.set_base(pixel, color)
 
     def _setScaled(self, pixel, color):
         start = pixel * self.pixelWidth
         for p in range(start, start + self.pixelWidth):
-            self._set_base(p, color)
+            self.set_base(p, color)
 
     def get(self, pixel):
         """Get RGB color tuple of color at index pixel"""
diff --git a/bibliopixel/main/common_flags.py b/bibliopixel/main/common_flags.py
index 2761df1..c1cf17b 100644
--- a/bibliopixel/main/common_flags.py
+++ b/bibliopixel/main/common_flags.py
@@ -56,6 +56,10 @@ def add_common_flags(parser):
 
 
 def add_project_flags(parser):
+    parser.add_argument(
+        '-a', '--animation', default=None,
+        help='Default animation type if no animation is specified')
+
     parser.add_argument(
         '-d', '--driver', default='simpixel',
         help='Default driver type if no driver is specified')
@@ -65,12 +69,8 @@ def add_project_flags(parser):
         help='Default layout class if no layout is specified')
 
     parser.add_argument(
-        '-t', '--ledtype', default=None,
-        help='Default LED type if no LED type is specified')
-
-    parser.add_argument(
-        '-a', '--animation', default=None,
-        help='Default animation type if no animation is specified')
+        '-n', '--numpy', action='store_true',
+        help='Use numpy if it available.')
 
     parser.add_argument(
         '-p', '--path', default=None, help=PATH_HELP)
@@ -78,7 +78,12 @@ def add_project_flags(parser):
     parser.add_argument(
         '-s', action='store_true', help='Run SimPixel at the default URL')
 
-    parser.add_argument('--simpixel', help='Run SimPixel at a specific URL')
+    parser.add_argument(
+        '--simpixel', help='Run SimPixel at a specific URL')
+
+    parser.add_argument(
+        '-t', '--ledtype', default=None,
+        help='Default LED type if no LED type is specified')
 
 
 def make_animation(args, desc):
@@ -96,4 +101,7 @@ def make_animation(args, desc):
     if args and args.ledtype:
         project_flags['driver']['ledtype'] = args.ledtype
 
+    if args and args.numpy:
+        project_flags['maker'] = {'use_numpy': True}
+
     return project.project_to_animation(desc, project_flags)
diff --git a/bibliopixel/project/project.py b/bibliopixel/project/project.py
index ad75b6f..b2e86c0 100644
--- a/bibliopixel/project/project.py
+++ b/bibliopixel/project/project.py
@@ -7,6 +7,7 @@ from .. layout.geometry import gen_matrix
 from .. layout.multimap import MultiMapBuilder
 from .. util import files
 from .. remote import control
+import copy
 
 
 def _make_object(*args, field_types=FIELD_TYPES, **kwds):
@@ -17,10 +18,12 @@ def _make_layout(layout, driver=None, drivers=None, maker=None):
     if driver is None and drivers is None:
         raise ValueError('Projects has no driver or drivers section')
 
+    _layout = copy.deepcopy(layout)
+    coord_map = _layout.get('coordMap', None)
+    _layout.pop('coordMap', None)
+
     if drivers is None:
         drivers = [_make_object(**driver)]
-        coord_map = None
-
     else:
         build = MultiMapBuilder()
 
@@ -33,10 +36,10 @@ def _make_layout(layout, driver=None, drivers=None, maker=None):
             drivers = [dict(driver, **d) for d in drivers]
 
         drivers = [make_driver(**d) for d in drivers]
-        coord_map = build.map
+        coord_map = coord_map or build.map
 
     maker = data_maker.Maker(**(maker or {}))
-    return _make_object(drivers, coordMap=coord_map, maker=maker, **layout)
+    return _make_object(drivers, coordMap=coord_map, maker=maker, **_layout)
 
 
 def make_animation(layout, animation, run=None):
diff --git a/bibliopixel/threads/sub.py b/bibliopixel/threads/sub.py
new file mode 100644
index 0000000..8210971
--- /dev/null
+++ b/bibliopixel/threads/sub.py
@@ -0,0 +1,40 @@
+"""
+More or less uniformly run something as a new daemon thread or process.
+"""
+
+import multiprocessing, threading, queue
+
+
+def _run_locally(input, output, function, args, **kwds):
+    function(input, output, *args, **kwds)
+
+
+def run(function, *args, use_subprocess=False, daemon=True, **kwds):
+    """
+    Create input, output queues, call `function` in a subprocess or a thread.
+
+    `function` is called like this:
+        function(input, output, *args, **kwds)
+
+    Args:
+        use_subprocess: if true, create a new multiprocess;  if false, create
+            a new thread
+        function: the function to call
+        *args, **kwds: arguments to the function.
+        daemon: is the thread or subprocess run as a daemon or not?
+
+    Returns:
+        a tuple with three elements: the subprocess or thread, an input
+        queue, and an output queue.
+    """
+    if use_subprocess:
+        Creator, Queue = multiprocessing.Process, multiprocessing.Queue
+    else:
+        Creator, Queue = threading.Thread, queue.Queue
+
+    input, output = Queue(), Queue()
+    args = input, output, function, args
+    sub = Creator(target=_run_locally, args=args, kwargs=kwds, daemon=daemon)
+    sub.start()
+
+    return sub, input, output


Test patch:
diff --git a/test/matrix_test.py b/test/matrix_test.py
index 390be81..5011375 100644
--- a/test/matrix_test.py
+++ b/test/matrix_test.py
@@ -177,11 +177,11 @@ class MatrixTest(BaseMatrixTest):
 
 
 class SharedMatrixTest(BaseMatrixTest):
-    maker = data_maker.Maker(shared_memory=True)
+    maker = data_maker.Maker(shared_memory=True, floating=True)
 
 
 class SharedMatrixIntegerTest(BaseMatrixTest):
-    maker = data_maker.Maker(shared_memory=True, integer=True)
+    maker = data_maker.Maker(shared_memory=True, floating=False)
 
 
 del BaseMatrixTest  # http://stackoverflow.com/a/22836015/43839
diff --git a/test/project_test.py b/test/project_test.py
index 444fa28..ffaa661 100644
--- a/test/project_test.py
+++ b/test/project_test.py
@@ -92,6 +92,26 @@ PROJECT_SHARED = """
 """
 
 
+PROJECT_NUMPY = """
+{
+    "driver": {
+        "typename": "dummy",
+        "num": 12
+    },
+
+    "layout": "strip",
+    "animation": "strip_test",
+    "run": {
+        "max_steps": 2
+    },
+
+    "maker": {
+        "use_numpy": true
+    }
+}
+"""
+
+
 PROJECT_SIM = """
 {
     "driver": {
@@ -208,6 +228,9 @@ class ProjectTest(unittest.TestCase):
     def test_shared(self):
         start(PROJECT_SHARED)
 
+    def test_numpy(self):
+        start(PROJECT_NUMPY)
+
     def test_sim(self):
         start(PROJECT_SIM)
 
diff --git a/test/sub_test.py b/test/sub_test.py
new file mode 100644
index 0000000..5a1c6d3
--- /dev/null
+++ b/test/sub_test.py
@@ -0,0 +1,34 @@
+import functools, time, unittest
+
+from bibliopixel.threads import sub
+
+pause = functools.partial(time.sleep, 0.005)
+
+
+def run(input, output, *arg, **kwds):
+    pause()
+
+    if arg != (1, 2, 3):
+        raise ValueError('1 2 3')
+
+    if kwds != dict(a=1):
+        raise ValueError('a=1')
+
+    pause()
+
+
+class SubTest(unittest.TestCase):
+    def do_test(self, use_subprocess):
+        s, i, o = sub.run(run, 1, 2, 3, a=1, use_subprocess=use_subprocess)
+        self.assertTrue(s.is_alive())
+        pause()
+        self.assertTrue(s.is_alive())
+        pause()
+        pause()
+        self.assertFalse(s.is_alive())
+
+    def test_subprocess(self):
+        self.do_test(True)
+
+    def test_threading(self):
+        self.do_test(False)