nwb-video-widgets 0.1.0__py3-none-any.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.
- nwb_video_widgets/__init__.py +16 -0
- nwb_video_widgets/_utils.py +328 -0
- nwb_video_widgets/dandi_pose_widget.py +356 -0
- nwb_video_widgets/dandi_video_widget.py +155 -0
- nwb_video_widgets/local_pose_widget.py +334 -0
- nwb_video_widgets/local_video_widget.py +130 -0
- nwb_video_widgets/pose_widget.css +624 -0
- nwb_video_widgets/pose_widget.js +798 -0
- nwb_video_widgets/video_widget.css +484 -0
- nwb_video_widgets/video_widget.js +566 -0
- nwb_video_widgets/video_widget.py +170 -0
- nwb_video_widgets-0.1.0.dist-info/METADATA +174 -0
- nwb_video_widgets-0.1.0.dist-info/RECORD +15 -0
- nwb_video_widgets-0.1.0.dist-info/WHEEL +4 -0
- nwb_video_widgets-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""NWB video player widget with configurable grid layout."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import anywidget
|
|
7
|
+
import traitlets
|
|
8
|
+
|
|
9
|
+
# Default grid layout: single row with Left, Body, Right cameras
|
|
10
|
+
DEFAULT_GRID_LAYOUT = [["VideoLeftCamera", "VideoBodyCamera", "VideoRightCamera"]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NWBFileVideoPlayer(anywidget.AnyWidget):
|
|
14
|
+
"""Display videos in a configurable grid layout with synchronized playback.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
nwbfile_raw : pynwb.NWBFile
|
|
19
|
+
Raw NWB file containing video ImageSeries in acquisition
|
|
20
|
+
dandi_asset : dandi.dandiapi.RemoteBlobAsset
|
|
21
|
+
Asset object for the raw NWB file. The dandiset is derived from
|
|
22
|
+
the asset's client and dandiset_id attributes.
|
|
23
|
+
grid_layout : list of list of str, optional
|
|
24
|
+
Grid layout specifying which videos to display and how to arrange them.
|
|
25
|
+
Each inner list represents a row, and each string is a video series name.
|
|
26
|
+
Videos not found in the NWB file are silently skipped.
|
|
27
|
+
Default: [["VideoLeftCamera", "VideoBodyCamera", "VideoRightCamera"]]
|
|
28
|
+
|
|
29
|
+
Example
|
|
30
|
+
-------
|
|
31
|
+
Default layout (single row):
|
|
32
|
+
|
|
33
|
+
>>> widget = NWBFileVideoPlayer(nwbfile_raw, dandi_asset)
|
|
34
|
+
|
|
35
|
+
Custom 2x2 grid:
|
|
36
|
+
|
|
37
|
+
>>> widget = NWBFileVideoPlayer(
|
|
38
|
+
... nwbfile_raw, dandi_asset,
|
|
39
|
+
... grid_layout=[
|
|
40
|
+
... ["VideoLeftCamera", "VideoRightCamera"],
|
|
41
|
+
... ["VideoBodyCamera"],
|
|
42
|
+
... ]
|
|
43
|
+
... )
|
|
44
|
+
|
|
45
|
+
Single video:
|
|
46
|
+
|
|
47
|
+
>>> widget = NWBFileVideoPlayer(
|
|
48
|
+
... nwbfile_raw, dandi_asset,
|
|
49
|
+
... grid_layout=[["VideoLeftCamera"]]
|
|
50
|
+
... )
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
video_urls = traitlets.Dict({}).tag(sync=True)
|
|
54
|
+
grid_layout = traitlets.List([]).tag(sync=True)
|
|
55
|
+
# Timestamps for each video: {video_name: [t0, t1, ...]}
|
|
56
|
+
# Used to display NWB session time instead of video-relative time
|
|
57
|
+
video_timestamps = traitlets.Dict({}).tag(sync=True)
|
|
58
|
+
|
|
59
|
+
_esm = pathlib.Path(__file__).parent / "video_widget.js"
|
|
60
|
+
_css = pathlib.Path(__file__).parent / "video_widget.css"
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
nwbfile_raw,
|
|
65
|
+
dandi_asset,
|
|
66
|
+
grid_layout: Optional[list[list[str]]] = None,
|
|
67
|
+
**kwargs,
|
|
68
|
+
):
|
|
69
|
+
video_urls = self.get_video_urls_from_dandi(nwbfile_raw, dandi_asset)
|
|
70
|
+
video_timestamps = self.get_video_timestamps(nwbfile_raw)
|
|
71
|
+
layout = grid_layout if grid_layout is not None else DEFAULT_GRID_LAYOUT
|
|
72
|
+
super().__init__(
|
|
73
|
+
video_urls=video_urls,
|
|
74
|
+
grid_layout=layout,
|
|
75
|
+
video_timestamps=video_timestamps,
|
|
76
|
+
**kwargs,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def get_video_urls_from_dandi(nwbfile_raw, dandi_asset) -> dict[str, str]:
|
|
81
|
+
"""Extract video S3 URLs from raw NWB file using DANDI API.
|
|
82
|
+
|
|
83
|
+
Videos in NWB files are stored as ImageSeries with external_file paths.
|
|
84
|
+
This function finds all ImageSeries with external files and resolves
|
|
85
|
+
their relative paths to full S3 URLs using the DANDI API.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
nwbfile_raw : pynwb.NWBFile
|
|
90
|
+
Raw NWB file containing video ImageSeries in acquisition
|
|
91
|
+
dandi_asset : dandi.dandiapi.RemoteBlobAsset
|
|
92
|
+
Asset object for the raw NWB file. The dandiset is derived from
|
|
93
|
+
the asset's client and dandiset_id attributes.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
dict
|
|
98
|
+
Mapping of video names to S3 URLs.
|
|
99
|
+
Keys: 'VideoLeftCamera', 'VideoBodyCamera', 'VideoRightCamera'
|
|
100
|
+
|
|
101
|
+
Example
|
|
102
|
+
-------
|
|
103
|
+
>>> from dandi.dandiapi import DandiAPIClient
|
|
104
|
+
>>> client = DandiAPIClient()
|
|
105
|
+
>>> dandiset = client.get_dandiset("000409")
|
|
106
|
+
>>> dandi_asset = dandiset.get_asset_by_path("sub-.../sub-..._raw.nwb")
|
|
107
|
+
>>> video_urls = NWBFileVideoPlayer.get_video_urls_from_dandi(
|
|
108
|
+
... nwbfile_raw, dandi_asset
|
|
109
|
+
... )
|
|
110
|
+
"""
|
|
111
|
+
from pathlib import Path
|
|
112
|
+
|
|
113
|
+
from pynwb.image import ImageSeries
|
|
114
|
+
|
|
115
|
+
# Derive dandiset from dandi_asset
|
|
116
|
+
dandiset = dandi_asset.client.get_dandiset(dandi_asset.dandiset_id)
|
|
117
|
+
|
|
118
|
+
nwb_parent = Path(dandi_asset.path).parent
|
|
119
|
+
video_urls = {}
|
|
120
|
+
|
|
121
|
+
for name, obj in nwbfile_raw.acquisition.items():
|
|
122
|
+
# Videos are stored as ImageSeries with external_file attribute
|
|
123
|
+
if isinstance(obj, ImageSeries) and obj.external_file is not None:
|
|
124
|
+
relative_path = obj.external_file[0].lstrip("./")
|
|
125
|
+
full_path = str(nwb_parent / relative_path)
|
|
126
|
+
|
|
127
|
+
video_asset = dandiset.get_asset_by_path(full_path)
|
|
128
|
+
if video_asset is not None:
|
|
129
|
+
video_urls[name] = video_asset.get_content_url(
|
|
130
|
+
follow_redirects=1, strip_query=True
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return video_urls
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def get_video_timestamps(nwbfile_raw) -> dict[str, list[float]]:
|
|
137
|
+
"""Extract video timestamps from NWB file ImageSeries.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
nwbfile_raw : pynwb.NWBFile
|
|
142
|
+
Raw NWB file containing video ImageSeries in acquisition
|
|
143
|
+
|
|
144
|
+
Returns
|
|
145
|
+
-------
|
|
146
|
+
dict
|
|
147
|
+
Mapping of video names to timestamp arrays.
|
|
148
|
+
Each array contains the NWB session timestamps for each frame.
|
|
149
|
+
"""
|
|
150
|
+
from pynwb.image import ImageSeries
|
|
151
|
+
|
|
152
|
+
video_timestamps = {}
|
|
153
|
+
|
|
154
|
+
for name, obj in nwbfile_raw.acquisition.items():
|
|
155
|
+
if isinstance(obj, ImageSeries) and obj.external_file is not None:
|
|
156
|
+
# Get timestamps - could be explicit or computed from starting_time + rate
|
|
157
|
+
if obj.timestamps is not None:
|
|
158
|
+
timestamps = obj.timestamps[:]
|
|
159
|
+
elif obj.starting_time is not None and obj.rate is not None:
|
|
160
|
+
# Compute timestamps from starting_time and rate
|
|
161
|
+
n_frames = len(obj.external_file) if hasattr(obj, 'dimension') else 1
|
|
162
|
+
# For external files, we may not know frame count upfront
|
|
163
|
+
# Use a reasonable estimate or just store start/rate info
|
|
164
|
+
timestamps = [obj.starting_time]
|
|
165
|
+
else:
|
|
166
|
+
timestamps = [0.0]
|
|
167
|
+
|
|
168
|
+
video_timestamps[name] = [float(t) for t in timestamps]
|
|
169
|
+
|
|
170
|
+
return video_timestamps
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nwb-video-widgets
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interactive Jupyter widgets for NWB video and pose visualization
|
|
5
|
+
Project-URL: Homepage, https://github.com/catalystneuro/nwb-video-widgets
|
|
6
|
+
Project-URL: Repository, https://github.com/catalystneuro/nwb-video-widgets
|
|
7
|
+
Author: Heberto Mayorquin
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: anywidget,jupyter,neuroscience,nwb,pose,video,widget
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: Jupyter
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: anywidget>=0.9.0
|
|
21
|
+
Requires-Dist: matplotlib
|
|
22
|
+
Requires-Dist: numpy
|
|
23
|
+
Requires-Dist: pynwb
|
|
24
|
+
Provides-Extra: dandi
|
|
25
|
+
Requires-Dist: dandi>=0.60.0; extra == 'dandi'
|
|
26
|
+
Requires-Dist: h5py; extra == 'dandi'
|
|
27
|
+
Requires-Dist: remfile>=0.1.13; extra == 'dandi'
|
|
28
|
+
Provides-Extra: test
|
|
29
|
+
Requires-Dist: opencv-python-headless; extra == 'test'
|
|
30
|
+
Requires-Dist: pytest-cov; extra == 'test'
|
|
31
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# nwb-video-widgets
|
|
35
|
+
|
|
36
|
+
[](https://badge.fury.io/py/nwb-video-widgets)
|
|
37
|
+
[](https://www.python.org/downloads/)
|
|
38
|
+
[](https://opensource.org/licenses/MIT)
|
|
39
|
+
|
|
40
|
+
Interactive Jupyter widgets for NWB video and pose estimation visualization. Built with [anywidget](https://anywidget.dev/) for compatibility across JupyterLab, Jupyter Notebook, VS Code, and Google Colab.
|
|
41
|
+
|
|
42
|
+
## Table of Contents
|
|
43
|
+
|
|
44
|
+
- [Installation](#installation)
|
|
45
|
+
- [Video Player Widgets](#video-player-widgets)
|
|
46
|
+
- [Pose Estimation Widgets](#pose-estimation-widgets)
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
For local only NWB file usage:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install nwb-video-widgets
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
For DANDI integration and streaming support:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install nwb-video-widgets[dandi]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Video Player Widgets
|
|
62
|
+
|
|
63
|
+
Multi-camera synchronized video player with configurable layout (Row, Column, or Grid).
|
|
64
|
+
|
|
65
|
+

|
|
66
|
+
|
|
67
|
+
**Features:**
|
|
68
|
+
|
|
69
|
+
- Interactive settings panel for video selection
|
|
70
|
+
- Multiple layout modes (Row, Column, Grid)
|
|
71
|
+
- Synchronized playback across all videos
|
|
72
|
+
- Session time display with NWB timestamps
|
|
73
|
+
|
|
74
|
+
### DANDI Streaming
|
|
75
|
+
|
|
76
|
+
Use `NWBDANDIVideoPlayer` for videos hosted on DANDI:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from dandi.dandiapi import DandiAPIClient
|
|
80
|
+
from nwb_video_widgets import NWBDANDIVideoPlayer
|
|
81
|
+
|
|
82
|
+
client = DandiAPIClient()
|
|
83
|
+
dandiset = client.get_dandiset("000409", "draft")
|
|
84
|
+
asset = dandiset.get_asset_by_path("sub-NYU-39/sub-NYU-39_ses-..._behavior.nwb")
|
|
85
|
+
|
|
86
|
+
widget = NWBDANDIVideoPlayer(asset=asset)
|
|
87
|
+
widget
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Local Files
|
|
91
|
+
|
|
92
|
+
Use `NWBLocalVideoPlayer` for local NWB files:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from pynwb import read_nwb
|
|
96
|
+
from nwb_video_widgets import NWBLocalVideoPlayer
|
|
97
|
+
|
|
98
|
+
nwbfile = read_nwb("experiment.nwb")
|
|
99
|
+
widget = NWBLocalVideoPlayer(nwbfile)
|
|
100
|
+
widget
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Pose Estimation Widgets
|
|
106
|
+
|
|
107
|
+
Overlays DeepLabCut keypoints on streaming video with support for camera selection.
|
|
108
|
+
|
|
109
|
+

|
|
110
|
+
|
|
111
|
+
**Features:**
|
|
112
|
+
|
|
113
|
+
- Camera selection via settings panel
|
|
114
|
+
- Keypoint visibility toggles (All/None/individual)
|
|
115
|
+
- Label display toggle
|
|
116
|
+
- Session time display (NWB timestamps)
|
|
117
|
+
- Custom keypoint colors via colormap or explicit hex values
|
|
118
|
+
- Supports split files (videos in raw file, pose in processed file)
|
|
119
|
+
|
|
120
|
+
### DANDI Streaming
|
|
121
|
+
|
|
122
|
+
Use `NWBDANDIPoseEstimationWidget` for DANDI-hosted files:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from dandi.dandiapi import DandiAPIClient
|
|
126
|
+
from nwb_video_widgets import NWBDANDIPoseEstimationWidget
|
|
127
|
+
|
|
128
|
+
client = DandiAPIClient()
|
|
129
|
+
dandiset = client.get_dandiset("000409", "draft")
|
|
130
|
+
|
|
131
|
+
# Single file (videos + pose in same file)
|
|
132
|
+
asset = dandiset.get_asset_by_path("sub-.../sub-..._combined.nwb")
|
|
133
|
+
widget = NWBDANDIPoseEstimationWidget(asset=asset)
|
|
134
|
+
|
|
135
|
+
# Or split files (videos in raw, pose in processed)
|
|
136
|
+
raw_asset = dandiset.get_asset_by_path("sub-.../sub-..._desc-raw.nwb")
|
|
137
|
+
processed_asset = dandiset.get_asset_by_path("sub-.../sub-..._desc-processed.nwb")
|
|
138
|
+
widget = NWBDANDIPoseEstimationWidget(
|
|
139
|
+
asset=processed_asset,
|
|
140
|
+
video_asset=raw_asset,
|
|
141
|
+
)
|
|
142
|
+
widget
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Local Files
|
|
146
|
+
|
|
147
|
+
Use `NWBLocalPoseEstimationWidget` for local NWB files:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from pynwb import read_nwb
|
|
151
|
+
from nwb_video_widgets import NWBLocalPoseEstimationWidget
|
|
152
|
+
|
|
153
|
+
# Single file
|
|
154
|
+
nwbfile = read_nwb("experiment.nwb")
|
|
155
|
+
widget = NWBLocalPoseEstimationWidget(nwbfile)
|
|
156
|
+
widget
|
|
157
|
+
|
|
158
|
+
# Or split files
|
|
159
|
+
nwbfile_raw = read_nwb("raw.nwb")
|
|
160
|
+
nwbfile_processed = read_nwb("processed.nwb")
|
|
161
|
+
widget = NWBLocalPoseEstimationWidget(
|
|
162
|
+
nwbfile=nwbfile_processed,
|
|
163
|
+
video_nwbfile=nwbfile_raw,
|
|
164
|
+
)
|
|
165
|
+
widget
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**Parameters:**
|
|
169
|
+
|
|
170
|
+
| Parameter | Type | Description |
|
|
171
|
+
|-----------|------|-------------|
|
|
172
|
+
| `keypoint_colors` | `str` or `dict` | Matplotlib colormap name (e.g., `'tab10'`) or dict mapping keypoint names to hex colors |
|
|
173
|
+
| `default_camera` | `str` | Camera to display initially |
|
|
174
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
nwb_video_widgets/__init__.py,sha256=4m-AlratOMO31jFrnWQSRqU2duXHMs7ZI3Iogs-hbAs,616
|
|
2
|
+
nwb_video_widgets/_utils.py,sha256=kXcgjbq8mdgR0P4drPRy_Wrag3F5Od_7Ysikrr_VQMk,10566
|
|
3
|
+
nwb_video_widgets/dandi_pose_widget.py,sha256=Tk_ZnqZIRIJzIAghuc1OEh0tmP9tMDP8UVDzZ4CoySg,13511
|
|
4
|
+
nwb_video_widgets/dandi_video_widget.py,sha256=OaaJrNe0IoQ8X-fkDddIa6GfpCjsYZoso3-OAJEruTA,5020
|
|
5
|
+
nwb_video_widgets/local_pose_widget.py,sha256=6yrm3qoRf0C1s2CFQXTv9xCUG9NrDm4_mEv2Z8_QYww,12774
|
|
6
|
+
nwb_video_widgets/local_video_widget.py,sha256=c1RRTGkOdbtEBFHUw8FzHbpezWCdRPGauhXW72sTozM,4325
|
|
7
|
+
nwb_video_widgets/pose_widget.css,sha256=nTaPfxr-6TC2IlcjNf0jVHDEqG3dx0i5jF1mtf1tzPo,15172
|
|
8
|
+
nwb_video_widgets/pose_widget.js,sha256=zoXoLI1j63BOvngQ-Riufy7ae297VUidXW1KA7Z2TjI,26888
|
|
9
|
+
nwb_video_widgets/video_widget.css,sha256=h6G67yoRNaVR9obRZWJ8xgo6KBKbzuRfUgDPKCYlBFo,11893
|
|
10
|
+
nwb_video_widgets/video_widget.js,sha256=chqvXBD4p_9GEOvakVZHQqEz7WZwlzfNo0-toI_ix2Y,20235
|
|
11
|
+
nwb_video_widgets/video_widget.py,sha256=glRQOBquOHDFeKC_aW_fMvJGqqh90AsMRWzMGSokonA,6252
|
|
12
|
+
nwb_video_widgets-0.1.0.dist-info/METADATA,sha256=wh2Hie2ItnAEnHc31M0Uf8KRL3g7KsjMGUGecd9Xll4,5177
|
|
13
|
+
nwb_video_widgets-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
+
nwb_video_widgets-0.1.0.dist-info/licenses/LICENSE,sha256=9Xuwwu2OWDAyD-b0_T2W-E13CysuHPPHYu7aNSOtIMI,1070
|
|
15
|
+
nwb_video_widgets-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CatalystNeuro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|