lavavu 1.8.84__cp39-cp39-win_amd64.whl → 1.9.5__cp39-cp39-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lavavu/lavavu.py CHANGED
@@ -14,7 +14,8 @@ See the :any:`lavavu.Viewer` class documentation for more information.
14
14
  """
15
15
 
16
16
  __all__ = ['Viewer', 'Object', 'Properties', 'ColourMap', 'DrawData', 'Figure', 'Geometry', 'Image', 'Video',
17
- 'download', 'grid2d', 'grid3d', 'cubehelix', 'loadCPT', 'matplotlib_colourmap', 'printH5', 'lerp',
17
+ 'download', 'grid2d', 'grid3d', 'cubehelix', 'loadCPT', 'matplotlib_colourmap', 'printH5',
18
+ 'lerp', 'vector_magnitude', 'vector_normalise', 'vector_align',
18
19
  'player', 'inject', 'hidecode', 'style', 'cellstyle', 'cellwidth',
19
20
  'version', 'settings', 'is_ipython', 'is_notebook', 'getname']
20
21
 
@@ -50,6 +51,9 @@ from collections import deque
50
51
  import time
51
52
  import weakref
52
53
  import asyncio
54
+ import quaternion as quat
55
+ import platform
56
+ import matplotlib
53
57
 
54
58
  if sys.version_info[0] < 3:
55
59
  print("Python 3 required. LavaVu no longer supports Python 2.7.")
@@ -73,8 +77,38 @@ try:
73
77
  except (ImportError) as e:
74
78
  moderngl_window = None
75
79
 
80
+ try:
81
+ import av
82
+ except (ImportError) as e:
83
+ av = None
84
+
76
85
  #import swig module
77
- import LavaVuPython
86
+ context = os.environ.get("LV_CONTEXT", "").strip()
87
+ if platform.system() == 'Linux':
88
+ #Default context requires DISPLAY set for X11
89
+ display = os.environ.get("DISPLAY", "").strip()
90
+ if len(context) == 0: context = 'default'
91
+ if context != 'default' or len(display) == 0:
92
+ if context != 'osmesa':
93
+ try:
94
+ #Try EGL via moderngl, will use headless GPU if available
95
+ testctx = moderngl.create_context(standalone=True, require=330, backend='egl')
96
+ context = 'moderngl'
97
+ except Exception as e:
98
+ context = 'osmesa'
99
+
100
+ if context == 'osmesa':
101
+ #OSMesa fallback, CPU only, multicore
102
+ from osmesa import LavaVuPython
103
+
104
+ #Default module if none already loaded
105
+ try:
106
+ LavaVuPython
107
+ except:
108
+ import LavaVuPython
109
+
110
+ os.environ['LV_CONTEXT'] = context
111
+
78
112
  version = LavaVuPython.version
79
113
  server_ports = []
80
114
 
@@ -577,12 +611,12 @@ class Object(dict):
577
611
  return key in self.dict
578
612
 
579
613
  def __repr__(self):
580
- return str(self.ref)
614
+ return self.__str__()
581
615
 
582
616
  def __str__(self):
583
617
  #Default string representation
584
618
  self.parent._get() #Ensure in sync
585
- return '{\n' + str('\n'.join([' %s=%s' % (k,json.dumps(v)) for k,v in self.dict.items()])) + '\n}\n'
619
+ return '{\n' + str('\n'.join([' %s=%s' % (k,json.dumps(v)) for k,v in self.dict.items()])) + '\n}'
586
620
 
587
621
  #Interface for setting filters
588
622
  def include(self, *args, **kwargs):
@@ -1111,7 +1145,12 @@ class Object(dict):
1111
1145
  #Re-arrange to array of [r,g,b,a] values
1112
1146
  data = numpy.dstack((data[0],data[1],data[2]))
1113
1147
 
1114
- self._loadScalar(data, LavaVuPython.lucRGBAData)
1148
+ #Use the shape as dims for texture image data
1149
+ if len(data.shape) > 1:
1150
+ width, height = data.shape[1], data.shape[0]
1151
+ depth = 4 #4 channel RGBA
1152
+
1153
+ self._loadScalar(data, LavaVuPython.lucRGBAData, width, height, depth)
1115
1154
 
1116
1155
 
1117
1156
  def luminance(self, data):
@@ -1133,7 +1172,7 @@ class Object(dict):
1133
1172
 
1134
1173
  self._loadScalar(data, LavaVuPython.lucLuminanceData, width, height, depth)
1135
1174
 
1136
- def texture(self, data=None, flip=True, filter=2, bgr=False, label=""):
1175
+ def texture(self, data=None, flip=None, filter=2, bgr=False, label=""):
1137
1176
  """
1138
1177
  Load or clear texture data for object
1139
1178
 
@@ -1153,7 +1192,7 @@ class Object(dict):
1153
1192
  If not provided, will use the default/primary texture for the object
1154
1193
  flip : boolean
1155
1194
  flip the texture vertically after loading
1156
- (default is enabled as usually required for OpenGL but can be disabled)
1195
+ (default is to load value from "fliptexture" property)
1157
1196
  filter : int
1158
1197
  type of filtering, 0=None/nearest, 1=linear, 2=mipmap linear
1159
1198
  bgr : boolean
@@ -1163,6 +1202,11 @@ class Object(dict):
1163
1202
  #Clear texture
1164
1203
  self.parent.app.clearTexture(self.ref)
1165
1204
  return
1205
+ if flip is None:
1206
+ if "fliptexture" in self:
1207
+ flip = self["fliptexture"]
1208
+ else:
1209
+ flip = self.parent["fliptexture"]
1166
1210
  if isinstance(data, str):
1167
1211
  self.parent.app.setTexture(self.ref, data, flip, filter, bgr, label)
1168
1212
  return
@@ -1233,6 +1277,8 @@ class Object(dict):
1233
1277
  self.ref.colourMap = data.ref
1234
1278
  data = None
1235
1279
  else:
1280
+ if isinstance(data, matplotlib.colors.Colormap):
1281
+ data = matplotlib_cmap(data)
1236
1282
  if data is None:
1237
1283
  self.parent._get() #Ensure in sync
1238
1284
  cmid = self["colourmap"]
@@ -2052,13 +2098,15 @@ class _LavaVuWrapper(LavaVuPython.LavaVu):
2052
2098
  #Shared context
2053
2099
  _ctx = None
2054
2100
 
2055
- def __init__(self, threaded, runargs, resolution=None, binpath=None, context="default"):
2101
+ def __init__(self, threaded, runargs, resolution=None, binpath=None, context=None):
2056
2102
  self.args = runargs
2057
2103
  self._closing = False
2058
2104
  self.resolution = resolution
2059
2105
  self._loop = None
2060
2106
 
2061
2107
  #OpenGL context creation options
2108
+ if context is None:
2109
+ context = os.environ.get("LV_CONTEXT", "default")
2062
2110
  havecontext = False
2063
2111
  self.use_moderngl = False
2064
2112
  self.use_moderngl_window = False
@@ -2319,11 +2367,10 @@ class _LavaVuWrapper(LavaVuPython.LavaVu):
2319
2367
  if _LavaVuWrapper._ctx:
2320
2368
  self.ctx = _LavaVuWrapper._ctx
2321
2369
  else:
2322
- import platform
2323
2370
  if platform.system() == 'Linux':
2324
2371
  self.ctx = moderngl.create_context(standalone=True, require=330, backend='egl')
2325
2372
  else:
2326
- self.ctx = moderngl.create_standalone_context(require=330)
2373
+ self.ctx = moderngl.create_context(standalone=True, require=330)
2327
2374
  #print(self.ctx.info)
2328
2375
  _LavaVuWrapper._ctx = self.ctx
2329
2376
 
@@ -2700,7 +2747,7 @@ class Viewer(dict):
2700
2747
 
2701
2748
  """
2702
2749
 
2703
- def __init__(self, *args, resolution=None, binpath=None, context="default", port=8080, threads=False, **kwargs):
2750
+ def __init__(self, *args, resolution=None, binpath=None, context=None, port=8080, threads=False, **kwargs):
2704
2751
  """
2705
2752
  Create and init viewer instance
2706
2753
 
@@ -2716,6 +2763,7 @@ class Viewer(dict):
2716
2763
  context : str
2717
2764
  OpenGL context type, *"default"* will create a context and window based on available configurations.
2718
2765
  *"provided"* specifies a user provided context, set this if you have already created and activated the context.
2766
+ *"osmesa"* if built with the osmesa module, will use the self-contained software renderer to render without a window or GPU, needs to be set in LV_CONTEXT to work
2719
2767
  *"moderngl"* creates a context in python using the moderngl module (experimental).
2720
2768
  *"moderngl."* creates a context and a window in python using the moderngl module (experimental) specify class after separator, eg: moderngl.headless, moderngl.pyglet, moderngl.pyqt5.
2721
2769
  port : int
@@ -2735,6 +2783,10 @@ class Viewer(dict):
2735
2783
  self._thread = None
2736
2784
  self._collections = {}
2737
2785
  self.validate = True #Property validation flag
2786
+ self.recording = None
2787
+ self.context = context
2788
+ if self.context is None:
2789
+ self.context = os.environ.get('LV_CONTEXT', 'default')
2738
2790
 
2739
2791
  #Exit handler to clean up threads
2740
2792
  #(__del__ does not always seem to get called on termination)
@@ -2775,7 +2827,7 @@ class Viewer(dict):
2775
2827
  if not binpath:
2776
2828
  binpath = os.path.abspath(os.path.dirname(__file__))
2777
2829
  try:
2778
- self.app = _LavaVuWrapper(threads, self.args(*args, **kwargs), resolution, binpath, context)
2830
+ self.app = _LavaVuWrapper(threads, self.args(*args, **kwargs), resolution, binpath, self.context)
2779
2831
  except (RuntimeError) as e:
2780
2832
  print("LavaVu Init error: " + str(e))
2781
2833
  pass
@@ -3212,10 +3264,12 @@ class Viewer(dict):
3212
3264
  self.validate = self.state["properties"]["validate"]
3213
3265
  self._objects._sync()
3214
3266
 
3215
- def _set(self):
3267
+ def _set(self, reload=None):
3216
3268
  #Export state to lavavu
3217
3269
  #(include current object list state)
3218
3270
  #self.state["objects"] = [obj.dict for obj in self._objects.list]
3271
+ if reload is not None:
3272
+ self.state["reload"] = reload
3219
3273
  self.app.setState(json.dumps(self.state))
3220
3274
 
3221
3275
  def commands(self, cmds, queue=False):
@@ -3695,7 +3749,7 @@ class Viewer(dict):
3695
3749
  else:
3696
3750
  return c.tolist()
3697
3751
 
3698
- def texture(self, label, data=None, flip=True, filter=2, bgr=False):
3752
+ def texture(self, label, data=None, flip=None, filter=2, bgr=False):
3699
3753
  """
3700
3754
  Load or clear global/shared texture data
3701
3755
 
@@ -3714,7 +3768,7 @@ class Viewer(dict):
3714
3768
  Pass a string to load a texture from given filename
3715
3769
  flip : boolean
3716
3770
  flip the texture vertically after loading
3717
- (default is enabled as usually required for OpenGL but can be disabled)
3771
+ (default is to load value from "fliptexture" property)
3718
3772
  filter : int
3719
3773
  type of filtering, 0=None/nearest, 1=linear, 2=mipmap linear
3720
3774
  bgr : boolean
@@ -3724,6 +3778,8 @@ class Viewer(dict):
3724
3778
  #Clear texture
3725
3779
  self.app.clearTexture(None, label)
3726
3780
  return
3781
+ if flip is None:
3782
+ flip = self["fliptexture"]
3727
3783
  if isinstance(data, str):
3728
3784
  self.app.setTexture(None, data, flip, filter, bgr, label)
3729
3785
  return
@@ -3821,6 +3877,9 @@ class Viewer(dict):
3821
3877
  Render a new frame, explicit display update
3822
3878
  """
3823
3879
  self.app.render()
3880
+ #Video recording?
3881
+ if self.recording:
3882
+ self.recording.frame()
3824
3883
 
3825
3884
  def init(self):
3826
3885
  """
@@ -4128,7 +4187,7 @@ class Viewer(dict):
4128
4187
  from IPython.display import display,HTML,Javascript
4129
4188
  display(Javascript(js + code))
4130
4189
 
4131
- def video(self, filename="", fps=30, quality=1, resolution=(0,0), **kwargs):
4190
+ def video(self, filename="", resolution=(0,0), fps=30, quality=0, encoder="h264", player=None, options={}, **kwargs):
4132
4191
  """
4133
4192
  Record and show the generated video inline within an ipython notebook.
4134
4193
 
@@ -4145,25 +4204,36 @@ class Viewer(dict):
4145
4204
  ----------
4146
4205
  filename : str
4147
4206
  Name of the file to save, if not provided a default will be used
4207
+ resolution : list or tuple
4208
+ Video resolution in pixels [x,y]
4148
4209
  fps : int
4149
4210
  Frames to output per second of video
4150
4211
  quality : int
4151
- Encoding quality, 1=low(default), 2=medium, 3=high, higher quality reduces
4212
+ Encoding quality, 1=low, 2=medium, 3=high, higher quality reduces
4152
4213
  encoding artifacts at cost of larger file size
4153
- resolution : list or tuple
4154
- Video resolution in pixels [x,y]
4214
+ If omitted will use default settings, can fine tune settings in kwargs
4215
+ encoder : str
4216
+ Name of encoder to use, eg: "h264" (default), "mpeg"
4217
+ player : dict
4218
+ Args to pass to the player when the video is finished, eg:
4219
+ {"width" : 800, "height", 400, "params": "controls autoplay"}
4220
+ options : dict
4221
+ Args to pass to the encoder, eg: see
4222
+ https://trac.ffmpeg.org/wiki/Encode/H.264
4155
4223
  **kwargs :
4156
- Any additional keyword args will be passed to lavavu.player()
4224
+ Any additional keyword args will also be passed as options to the encoder
4157
4225
 
4158
4226
  Returns
4159
4227
  -------
4160
4228
  recorder : Video(object)
4161
4229
  Context manager object that controls the video recording
4162
4230
  """
4163
- return Video(self, filename, resolution, fps, quality, **kwargs)
4231
+ return Video(self, filename, resolution, fps, quality, encoder, player, options, **kwargs)
4164
4232
 
4165
- def video_steps(self, filename="", start=0, end=0, fps=10, quality=1, resolution=(0,0), **kwargs):
4233
+ def video_steps(self, filename="", start=0, end=0, resolution=(0,0), fps=30, quality=2, encoder="h264", player=None, options={}, **kwargs):
4166
4234
  """
4235
+ TODO: Fix to use pyAV
4236
+
4167
4237
  Record a video of the model by looping through all time steps
4168
4238
 
4169
4239
  Shows the generated video inline within an ipython notebook.
@@ -4181,20 +4251,26 @@ class Viewer(dict):
4181
4251
  First timestep to record, if not specified will use first available
4182
4252
  end : int
4183
4253
  Last timestep to record, if not specified will use last available
4254
+ resolution : list or tuple
4255
+ Video resolution in pixels [x,y]
4184
4256
  fps : int
4185
4257
  Frames to output per second of video
4186
4258
  quality : int
4187
- Encoding quality, 1=low(default), 2=medium, 3=high, higher quality reduces
4259
+ Encoding quality, 1=low, 2=medium(default), 3=high, higher quality reduces
4188
4260
  encoding artifacts at cost of larger file size
4189
- resolution : list or tuple
4190
- Video resolution in pixels [x,y]
4191
4261
  **kwargs :
4192
- Any additional keyword args will be passed to lavavu.player()
4262
+ Any additional keyword args will also be passed as options to the encoder
4193
4263
  """
4194
4264
 
4195
4265
  try:
4196
- fn = self.app.video(filename, fps, resolution[0], resolution[1], start, end, quality, **kwargs)
4197
- player(fn, **kwargs)
4266
+ steps = self.steps
4267
+ if end == 0: end = len(steps)
4268
+ steps = steps[start:end]
4269
+ from tqdm.notebook import tqdm
4270
+ with Video(self, filename, resolution, fps, quality, encoder, player, options, **kwargs):
4271
+ for s in tqdm(steps, desc='Rendering loop'):
4272
+ self.timestep(s)
4273
+ self.render()
4198
4274
  except (Exception) as e:
4199
4275
  print("Video output error: " + str(e))
4200
4276
  pass
@@ -4378,7 +4454,7 @@ class Viewer(dict):
4378
4454
  #Issue redisplay to active viewer
4379
4455
  self.control.redisplay()
4380
4456
 
4381
- def camera(self, data=None):
4457
+ def camera(self, data=None, quiet=False):
4382
4458
  """
4383
4459
  Get/set the current camera viewpoint
4384
4460
 
@@ -4389,6 +4465,8 @@ class Viewer(dict):
4389
4465
  ----------
4390
4466
  data : dict
4391
4467
  Camera view to apply if any
4468
+ quiet : bool
4469
+ Skip print and calling "camera" script command which produces noisy terminal output
4392
4470
 
4393
4471
  Returns
4394
4472
  -------
@@ -4399,7 +4477,8 @@ class Viewer(dict):
4399
4477
  me = getname(self)
4400
4478
  if not me: me = "lv"
4401
4479
  #Also print in terminal for debugging
4402
- self.commands("camera")
4480
+ if not quiet and not data:
4481
+ self.commands("camera")
4403
4482
  #Get: export from first view
4404
4483
  vdat = {}
4405
4484
  if len(self.state["views"]) and self.state["views"][0]:
@@ -4407,27 +4486,110 @@ class Viewer(dict):
4407
4486
  for key in ["translate", "rotate", "xyzrotate", "fov", "focus"]:
4408
4487
  if key in src:
4409
4488
  dst[key] = copy.copy(src[key])
4410
- #Round down arrays to max 3 decimal places
4489
+ #Round down arrays to max 6 decimal places
4411
4490
  try:
4412
4491
  for r in range(len(dst[key])):
4413
- dst[key][r] = round(dst[key][r], 3)
4492
+ dst[key][r] = round(dst[key][r], 6)
4414
4493
  except:
4415
4494
  #Not a list/array
4416
4495
  pass
4417
4496
 
4418
4497
  copyview(vdat, self.state["views"][0])
4419
4498
 
4420
- print(me + ".translation(" + str(vdat["translate"])[1:-1] + ")")
4421
- print(me + ".rotation(" + str(vdat["xyzrotate"])[1:-1] + ")")
4499
+ if not quiet and not data:
4500
+ print(me + ".translation(" + str(vdat["translate"])[1:-1] + ")")
4501
+ print(me + ".rotation(" + str(vdat["xyzrotate"])[1:-1] + ")")
4422
4502
 
4423
4503
  #Set
4424
4504
  if data is not None:
4425
4505
  copyview(self.state["views"][0], data)
4426
- self._set()
4506
+ self._set(reload=False)
4427
4507
 
4428
4508
  #Return
4429
4509
  return vdat
4430
4510
 
4511
+ def lookat(self, pos, at=None, up=None):
4512
+ """
4513
+ Set the camera with a position coord and lookat coord
4514
+
4515
+ Parameters
4516
+ ----------
4517
+ pos : list/numpy.ndarray
4518
+ Camera position in world coords
4519
+ lookat : list/numpy.ndarray
4520
+ Look at position in world coords, defaults to model origin
4521
+ up : list/numpy.ndarray
4522
+ Up vector, defaults to Y axis [0,1,0]
4523
+ """
4524
+
4525
+ # Use the origin from viewer if no target provided
4526
+ if at is None:
4527
+ at = self["focus"]
4528
+ else:
4529
+ self.focus(*at)
4530
+
4531
+ # Default to Y-axis up vector
4532
+ if up is None:
4533
+ up = numpy.array([0, 1, 0])
4534
+
4535
+ # Calculate the rotation matrix
4536
+ heading = numpy.array(pos) - numpy.array(at)
4537
+ zd = vector_normalise(heading)
4538
+ xd = vector_normalise(numpy.cross(up, zd))
4539
+ yd = vector_normalise(numpy.cross(zd, xd))
4540
+ q = quat.from_rotation_matrix(numpy.array([xd, yd, zd]))
4541
+ q = q.normalized()
4542
+
4543
+ # Apply the rotation
4544
+ self.rotation(q.x, q.y, q.z, q.w)
4545
+
4546
+ # Translate back by heading vector length in Z
4547
+ # (model origin in lavavu takes care of lookat offset)
4548
+ tr = [0, 0, -vector_magnitude(numpy.array(pos) - numpy.array(at))]
4549
+
4550
+ # Apply translation
4551
+ self.translation(tr)
4552
+
4553
+ def camlerpto(self, pos, L):
4554
+ # Lerp using current camera orientation as start point
4555
+ pos0 = self.camera(quiet=True)
4556
+ return self.camlerp(pos0, pos)
4557
+
4558
+ def camlerp(self, pos0, pos1, L):
4559
+ """
4560
+ Linearly Interpolate between two camera positions/orientations and
4561
+ set the camera to the resulting position/orientation
4562
+ """
4563
+ final = {}
4564
+ for key in ["translate", "rotate", "focus"]:
4565
+ val0 = numpy.array(pos0[key])
4566
+ val1 = numpy.array(pos1[key])
4567
+ res = val0 + (val1 - val0) * L
4568
+ if len(res) > 3:
4569
+ # Normalise quaternion
4570
+ res = res / numpy.linalg.norm(res)
4571
+ final[key] = res.tolist()
4572
+
4573
+ self.camera(final, quiet=True)
4574
+
4575
+ def flyto(self, pos, steps, stop=False, callback=None):
4576
+ # Fly using current camera orientation as start point
4577
+ pos0 = self.camera(quiet=True)
4578
+ return self.fly(pos0, pos, steps, stop, callback)
4579
+
4580
+ def fly(self, pos0, pos1, steps, stop=False, callback=None):
4581
+ self.camera(pos0, quiet=True)
4582
+ self.render()
4583
+
4584
+ for i in range(steps):
4585
+ if stop and i > stop:
4586
+ break
4587
+ L = i / (steps - 1)
4588
+ self.camlerp(pos0, pos1, L)
4589
+ if callback is not None:
4590
+ callback(self, i, steps)
4591
+ self.render()
4592
+
4431
4593
  def getview(self):
4432
4594
  """
4433
4595
  Get current view settings
@@ -5256,54 +5418,42 @@ def player(filename, params="controls autoplay loop", **kwargs):
5256
5418
  import uuid
5257
5419
  vid = 'video_' + str(uuid.uuid4())[:8]
5258
5420
 
5259
- '''
5260
- def get_fn(filename):
5261
- import os
5262
- #This is unreliable, path incorrect on NCI
5263
- nbpath = os.getenv('JPY_SESSION_NAME')
5264
- if nbpath is not None:
5265
- import os.path
5266
- relpath = os.path.relpath(os.getcwd(), start=os.path.dirname(nbpath))
5267
- return relpath + '/' + filename
5268
- return filename
5269
- ''';
5270
-
5271
- import uuid
5272
- uid = uuid.uuid1()
5273
-
5274
- #Embed player
5275
- display(Video(url=os.path.relpath(filename), html_attributes=f"id='{vid}' " + params, **kwargs))
5276
-
5277
- #Add download link
5278
- display(HTML(f'<a id="link_{vid}" href="{filename}" download>Download Video</a>'))
5279
-
5280
5421
  # Fallback - replace url on gadi and similar jupyterhub installs with
5281
5422
  # fixed working directory that doesn't match notebook dir
5282
5423
  # check the video tag url and remove subpath on 404 error
5283
5424
  display(Javascript(f"""
5284
- let el = document.getElementById('{vid}');
5285
- let url = el.src;
5286
- fetch(url, {{method: 'HEAD'}}).then(response=>{{
5287
- if(response.status == 404) {{
5425
+ function video_error(el) {{
5426
+ let url = el.src;
5288
5427
  console.log("Bad video url: " + url);
5289
5428
  let toppath = "/files/home/"
5290
5429
  let baseurl = url.substring(0, url.indexOf(toppath)+toppath.length);
5291
5430
  let endurl = url.substring(url.indexOf("{filename}"));
5292
5431
  let fixed = baseurl + endurl;
5293
- console.log("Replaced video url: " + fixed);
5294
- el.src = fixed;
5295
- //Also fix download link
5296
- document.getElementById('link_{vid}').href = fixed;
5297
- }}
5298
- }});
5432
+ if (url != fixed) {{
5433
+ console.log("Replaced video url: " + fixed);
5434
+ el.src = fixed;
5435
+ //Also fix download link
5436
+ document.getElementById('link_{vid}').href = fixed;
5437
+ }} else {{
5438
+ console.log("Not replacing video url, no change: " + fixed);
5439
+ }}
5440
+ }}
5299
5441
  """))
5300
-
5442
+
5443
+ #Embed player
5444
+ filename = os.path.relpath(filename)
5445
+ display(Video(url=filename, html_attributes=f'id="{vid}" onerror="video_error(this)"' + params, **kwargs))
5446
+
5447
+ #Add download link
5448
+ display(HTML(f'<a id="link_{vid}" href="{filename}" download>Download Video</a>'))
5301
5449
 
5302
5450
  #Class for managing video animation recording
5303
5451
  class Video(object):
5304
5452
  """
5305
5453
  The Video class provides an interface to record animations
5306
5454
 
5455
+ This now uses pyAV avoiding the need to build LavaVu with ffmpeg support
5456
+
5307
5457
  Example
5308
5458
  -------
5309
5459
 
@@ -5315,7 +5465,7 @@ class Video(object):
5315
5465
  ... lv.rotate('y', 10) # doctest: +SKIP
5316
5466
  ... lv.render() # doctest: +SKIP
5317
5467
  """
5318
- def __init__(self, viewer=None, filename="", resolution=(0,0), framerate=30, quality=1, **kwargs):
5468
+ def __init__(self, viewer, filename="output.mp4", resolution=(0,0), framerate=30, quality=2, encoder="h264", player=None, options={}, **kwargs):
5319
5469
  """
5320
5470
  Record and show the generated video inline within an ipython notebook.
5321
5471
 
@@ -5335,79 +5485,149 @@ class Video(object):
5335
5485
  fps : int
5336
5486
  Frames to output per second of video
5337
5487
  quality : int
5338
- Encoding quality, 1=low(default), 2=medium, 3=high, higher quality reduces
5488
+ Encoding quality, 1=low, 2=medium(default), 3=high, higher quality reduces
5339
5489
  encoding artifacts at cost of larger file size
5490
+ If omitted will use default settings, can fine tune settings in kwargs
5340
5491
  resolution : list or tuple
5341
5492
  Video resolution in pixels [x,y]
5493
+ encoder : str
5494
+ Name of encoder to use, eg: "h264" (default), "mpeg"
5495
+ player : dict
5496
+ Args to pass to the player when the video is finished, eg:
5497
+ {"width" : 800, "height", 400, "params": "controls autoplay"}
5498
+ options : dict
5499
+ Args to pass to the encoder, eg: see
5500
+ https://trac.ffmpeg.org/wiki/Encode/H.264
5342
5501
  **kwargs :
5343
- Any additional keyword args will be passed to lavavu.player()
5502
+ Any additional keyword args will also be passed as options to the encoder
5344
5503
  """
5345
- self.resolution = resolution
5504
+ if av is None:
5505
+ raise(ImportError("Video output not supported without pyAV - pip install av"))
5506
+ return
5507
+ self.resolution = list(resolution)
5508
+ if self.resolution[0] == 0 or self.resolution[1] == 0:
5509
+ self.resolution = (viewer.app.viewer.width, viewer.app.viewer.height)
5510
+ #Ensure resolution values are even or encoder fails
5511
+ self.resolution = (2 * int(self.resolution[0]//2), 2 * int(self.resolution[1] // 2))
5346
5512
  self.framerate = framerate
5347
5513
  self.quality = quality
5348
5514
  self.viewer = viewer
5349
5515
  self.filename = filename
5350
- self.kwargs = kwargs
5351
- if not viewer:
5352
- self.encoder = LavaVuPython.VideoEncoder(filename, framerate, quality);
5353
- else:
5354
- self.encoder = None
5516
+ self.player = player
5517
+ if self.player is None:
5518
+ #Default player is half output resolution, unless < 900 then full
5519
+ if "width" in kwargs or "height" in kwargs or "params" in kwargs:
5520
+ #Back compatibility with old args
5521
+ self.player = {}
5522
+ self.player.update(kwargs)
5523
+ kwargs = {}
5524
+ elif self.resolution[0] > 900:
5525
+ #Larger output res - default to half size player
5526
+ self.player = {"width": self.resolution[0]//2, "height": self.resolution[1]//2}
5527
+ else:
5528
+ self.player = {}
5529
+ self.encoder = encoder
5530
+ self.options = options
5531
+ #Also include extra args
5532
+ options.update(kwargs)
5533
+ self.container = None
5355
5534
 
5356
5535
  def start(self):
5357
5536
  """
5358
5537
  Start recording, all rendered frames will be added to the video
5359
5538
  """
5360
- if self.encoder:
5361
- if self.resolution[0] <= 0: self.resolution[0] = 1280
5362
- if self.resolution[1] <= 0: self.resolution[1] = 720
5363
- self.encoder.open(self.resolution[0], self.resolution[1])
5364
- else:
5365
- self.filename = self.viewer.app.encodeVideo(self.filename, self.framerate, self.quality, self.resolution[0], self.resolution[1])
5366
- #Clear existing image frames
5367
- if os.path.isdir(self.filename):
5368
- for f in glob.glob(self.filename + "/frame_*.jpg"):
5369
- os.remove(f)
5539
+ #https://trac.ffmpeg.org/wiki/Encode/H.264
5540
+ # The range of the CRF scale is 0–51, where 0 is lossless (for 8 bit only, for 10 bit use -qp 0),
5541
+ # 23 is the default, and 51 is worst quality possible
5542
+ #Compression level, lower = high quality
5543
+ #See also: https://github.com/PyAV-Org/PyAV/blob/main/tests/test_encode.py
5544
+ options = {}
5545
+ if self.encoder == 'h264' or self.encoder == 'libx265':
5546
+ #Only have default options for h264/265 for now
5547
+ if self.quality == 1:
5548
+ options['qmin'] = '30' #'20' #'8'
5549
+ options['qmax'] = '35' #'41'
5550
+ options['crf'] = '30' #'40'
5551
+ elif self.quality == 2:
5552
+ options['qmin'] = '25' #'2'
5553
+ options['qmax'] = '30' #'31'
5554
+ options['crf'] = '20' #'23'
5555
+ elif self.quality == 3:
5556
+ options['qmin'] = '20' #'1'
5557
+ options['qmax'] = '25' #'4'
5558
+ options['crf'] = '10' #'10'
5559
+
5560
+ #Default preset, tune for h264
5561
+ options["preset"] = 'veryfast' #'veryfast' 'medium' 'slow'
5562
+ options["tune"] = 'animation' #'film'
5563
+
5564
+ #Settings from our original c++ encoder
5565
+ #self.options['i_quant_factor'] = '0.71'
5566
+ #self.options['qcompress'] = '0.6'
5567
+ #self.options['max_qdiff'] = '4'
5568
+ #self.options['refs'] = '3'
5569
+
5570
+ #Merge user options, allowing override of above settings
5571
+ options.update(self.options)
5572
+
5573
+ #print(options)
5574
+ self.container = av.open(self.filename, mode="w")
5575
+ self.stream = self.container.add_stream(self.encoder, rate=self.framerate, options=options)
5576
+ self.stream.width = self.resolution[0]
5577
+ self.stream.height = self.resolution[1]
5578
+ self.stream.pix_fmt = "yuv420p"
5579
+ self.viewer.recording = self
5580
+
5581
+ stream = self.stream
5582
+ #print(stream)
5583
+ #print(stream.codec)
5584
+ #print(stream.codec_context)
5585
+ #print(stream.profiles)
5586
+ #print(stream.profile)
5587
+ #print(stream.options)
5588
+ #Need to set profile here or it isn't applied
5589
+ #(Default to main)
5590
+ stream.profile = 'Main' #Baseline / High
5591
+ cc = stream.codec_context
5592
+ #print(cc.options)
5593
+ #print(cc.profile)
5594
+
5595
+ def frame(self):
5596
+ """
5597
+ Write a frame, called when viewer.render() is called
5598
+ while a recording is in progress
5599
+ """
5600
+ img = self.viewer.rawimage(resolution=self.resolution, channels=3)
5601
+ frame = av.VideoFrame.from_ndarray(img.data, format="rgb24")
5602
+ for packet in self.stream.encode(frame):
5603
+ self.container.mux(packet)
5370
5604
 
5371
5605
  def pause(self):
5372
5606
  """
5373
5607
  Pause/resume recording, no rendered frames will be added to the video while paused
5374
5608
  """
5375
- if self.encoder:
5376
- self.encoder.render = not self.encoder.render
5609
+ if self.viewer.recording:
5610
+ self.viewer.recording = None
5377
5611
  else:
5378
- self.viewer.app.pauseVideo()
5612
+ self.viewer.recording = self
5379
5613
 
5380
5614
  def stop(self):
5381
5615
  """
5382
5616
  Stop recording, final frames will be written and file closed, ready to play.
5383
5617
  No further frames will be added to the video
5384
5618
  """
5385
- if self.encoder:
5386
- self.encoder.close()
5387
- self.filename = self.encoder.filename
5388
- else:
5389
- self.viewer.app.encodeVideo()
5390
- #Check if encoded video is a directory (not built with video encoding support)
5391
- #if so attempt to encode with ffmpeg
5392
- if os.path.isdir(self.filename):
5393
- log = ""
5394
- ffmpeg_exe = 'ffmpeg'
5395
- try:
5396
- outfn = '{0}.mp4'.format(self.filename)
5397
- cmd = ffmpeg_exe + ' -r {1} -i "{0}/frame_%05d.jpg" -c:v libx264 -vf fps={1} -movflags +faststart -pix_fmt yuv420p -y "{2}"'.format(self.filename, self.framerate, outfn)
5398
- log += "No built in video encoding, attempting to build movie from frames with ffmpeg:\n"
5399
- log += cmd
5400
-
5401
- import subprocess
5402
- #os.system(cmd)
5403
- subprocess.check_call(cmd, shell=True)
5404
- self.filename = outfn
5405
- except (Exception) as e:
5406
- print("Video encoding failed: ", str(e), "\nlog:\n", log)
5619
+ # Flush stream
5620
+ for packet in self.stream.encode():
5621
+ self.container.mux(packet)
5622
+ # Close the file
5623
+ self.container.close()
5624
+ self.container = None
5625
+ self.stream = None
5626
+ self.viewer.recording = None
5407
5627
 
5408
5628
  def write(self, image):
5409
5629
  """
5410
- Add a frame to the video (when writing custom video frames rather than rendering them within lavavu)
5630
+ Add a frame to the video
5411
5631
 
5412
5632
  Parameters
5413
5633
  ----------
@@ -5415,7 +5635,7 @@ class Video(object):
5415
5635
  Pass a list or numpy uint8 array of rgba values or an Image object
5416
5636
  values are loaded as 8 bit unsigned integer values
5417
5637
  """
5418
- if self.encoder:
5638
+ if self.container:
5419
5639
  if isinstance(image, Image):
5420
5640
  image = image.data
5421
5641
  else:
@@ -5423,13 +5643,17 @@ class Video(object):
5423
5643
  if image.size == self.resolution[0] * self.resolution[1] * 4:
5424
5644
  image = image.reshape(self.resolution[0], self.resolution[1], 4)
5425
5645
  image = image[::,::,:3] #Remove alpha channel
5426
- self.encoder.copyframe(image.ravel())
5646
+ #self.encoder.copyframe(image.ravel())
5647
+
5648
+ frame = av.VideoFrame.from_ndarray(image, format="rgb24")
5649
+ for packet in self.stream.encode(frame):
5650
+ self.container.mux(packet)
5427
5651
 
5428
5652
  def play(self):
5429
5653
  """
5430
- Show the video in an inline player if in an interative notebook
5654
+ Show the video in an inline player if in an interactive notebook
5431
5655
  """
5432
- player(self.filename, **self.kwargs)
5656
+ player(self.filename, **self.player)
5433
5657
 
5434
5658
  def __enter__(self):
5435
5659
  self.start()
@@ -5848,6 +6072,56 @@ def lerp(first, second, mu):
5848
6072
  final[i] += diff * mu
5849
6073
  return final
5850
6074
 
6075
+ def vector_magnitude(vec):
6076
+ return numpy.linalg.norm(vec)
6077
+
6078
+ def vector_normalise(vec):
6079
+ norm = numpy.linalg.norm(vec)
6080
+ if norm == 0:
6081
+ vn = vec
6082
+ else:
6083
+ vn = vec / norm
6084
+ return vn
6085
+
6086
+ def vector_align(v1, v2, lvformat=True):
6087
+ """
6088
+ Get a rotation quaterion to align vectors v1 with v2
6089
+
6090
+ Parameters
6091
+ ----------
6092
+ v1 : list/numpy.ndarray
6093
+ First 3 component vector
6094
+ v2 : list/numpy.ndarray
6095
+ Second 3 component vector to align the first to
6096
+
6097
+ Returns
6098
+ -------
6099
+ list: quaternion to rotate v1 to v2 (in lavavu format)
6100
+ """
6101
+
6102
+ # Check for parallel or opposite
6103
+ v1 = vector_normalise(numpy.array(v1))
6104
+ v2 = vector_normalise(numpy.array(v2))
6105
+ epsilon = numpy.finfo(numpy.float32).eps
6106
+ one_minus_eps = 1.0 - epsilon
6107
+ if numpy.dot(v1, v2) > one_minus_eps: # 1.0
6108
+ # No rotation
6109
+ return [0, 0, 0, 1]
6110
+ elif numpy.dot(v1, v2) < -one_minus_eps: # -1.0
6111
+ # 180 rotation about Y
6112
+ return [0, 1, 0, 1]
6113
+ xyz = numpy.cross(v1, v2)
6114
+ l1 = numpy.linalg.norm(v1)
6115
+ l2 = numpy.linalg.norm(v2)
6116
+ w = math.sqrt((l1 * l1) * (l2 * l2)) + numpy.dot(v1, v2)
6117
+ qr = quat.quaternion(w, xyz[0], xyz[1], xyz[2])
6118
+ qr = qr.normalized()
6119
+ # Return in LavaVu quaternion format
6120
+ if lvformat:
6121
+ return [qr.x, qr.y, qr.z, qr.w]
6122
+ else:
6123
+ return qr
6124
+
5851
6125
  if __name__ == '__main__':
5852
6126
  #Run doctests - only works on numpy 1.14+ due to changes in array printing
5853
6127
  npyv = numpy.__version__.split('.')
@@ -5857,3 +6131,4 @@ if __name__ == '__main__':
5857
6131
  import doctest
5858
6132
  doctest.testmod()
5859
6133
 
6134
+