Skip to content

Commit

Permalink
Merge branch 'release/v0.11.0' into 'main'
Browse files Browse the repository at this point in the history
Release/v0.11.0

See merge request momentfactory/products/xagora/omniverse/kit-exts-ndi!50
  • Loading branch information
fredericl-mf committed Apr 26, 2023
2 parents 0d08b36 + 5faa5d1 commit ac84c01
Show file tree
Hide file tree
Showing 19 changed files with 688 additions and 688 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ You may want to use [example.usda](./example.usda) in Create for your first test
- [ndi-python](https://github.com/buresu/ndi-python)

## Known issues
- The list of dynamic textures is not automatically refreshed when the stage change (create/load new scene, delete material in the stage, etc.), you'll need to manually update the list by clicking the corresponding button in the extension window.
- Currently implemented with Python, performance could be greatly improved with C++ (but limited by DynamicTextureProvider implementation)
- You can ignore warnings in the form of `[Warning] [omni.hydra] Material parameter '...' was assigned to incompatible texture: '...'`
- You can ignore warnings in the form of `[Warning] [omni.ext._impl._internal] mf.ov.ndi-... -> <class 'mf.ov.ndi...'>: extension object is still alive, something holds a reference on it...`
- You can ignore the first istance of `[Warning] Could not get stage`, because the extension loads before the stage is initialized
6 changes: 1 addition & 5 deletions exts/mf.ov.ndi/config/extension.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[package]
version = "0.10.0"
version = "0.11.0"

title = "MF NDI® extension"
description = "An extension to enable NDI® live video input in Omniverse."
Expand Down Expand Up @@ -38,7 +38,3 @@ dependencies = [
"omni.usd"
]
timeout = 60
stdoutFailPatterns.exclude = [
"*Could not get stage*",
"*Model doesn't have a registered window*"
]
Binary file modified exts/mf.ov.ndi/data/preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified exts/mf.ov.ndi/data/ui.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions exts/mf.ov.ndi/docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
- NDI® source monitoring (dimensions, fps, etc.)

## [0.11.0] - 2023-04-20

### Changed
- NDI® status now displayed as a dot with colors
- red: NDI® source offline
- white: NDI® source online
- green: Stream playing
- orange: NDI® drops
- Code refactor

## [0.10.0] - 2023-04-12

### Added
Expand Down
203 changes: 131 additions & 72 deletions exts/mf.ov.ndi/mf/ov/ndi/NDItools.py
Original file line number Diff line number Diff line change
@@ -1,128 +1,172 @@
from .comboboxModel import ComboboxModel
import NDIlib as ndi
from .eventsystem import EventSystem

import carb.profiler
import logging
import time
from typing import List
import omni.ui
import NDIlib as ndi
import numpy as np
import omni.ui
import threading
import time
from typing import List


class NDIData():
def __init__(self, source: str, active: bool = False):
self._source = source
self._active = active
self._on_value_changed_fn = None
class NDItools():
def __init__(self):
self._ndi_ok = False
self._ndi_find = None

def get_source(self) -> str:
return self._source
self._ndi_init()
self._ndi_find_init()

def is_active(self) -> bool:
return self._active
self._finder = None
self._create_finder()

def set_active(self, active: bool = True):
self._active = active
if self._on_value_changed_fn is not None:
self._on_value_changed_fn()
self._streams = []

def set_active_value_changed_fn(self, fn):
self._on_value_changed_fn = fn
stream = omni.kit.app.get_app().get_update_event_stream()
self._sub = stream.create_subscription_to_pop(self._on_update, name="update")

def destroy(self):
self._sub.unsubscribe()
self._sub = None

class NDItools():
NONE_DATA = NDIData(ComboboxModel.NONE_VALUE)
PROXY_DATA = NDIData(ComboboxModel.PROXY_VALUE, True)
self._finder.destroy()

def __init__(self):
for stream in self._streams:
stream.destroy()
self._streams.clear()

if self._ndi_ok:
if self._ndi_find is not None:
ndi.find_destroy(self._ndi_find)
ndi.destroy()
self._ndi_ok = False
self._ndi_find = None

def ndi_init(self):
def is_ndi_ok(self) -> bool:
return self._ndi_ok

def _on_update(self, e):
to_remove = []
for stream in self._streams:
if not stream.is_running():
to_remove.append(stream)

for stream in to_remove:
self._streams.remove(stream)
EventSystem.send_event(EventSystem.STREAM_STOP_TIMEOUT_EVENT, payload={"dynamic_id": stream.get_id()})
stream.destroy()

def _ndi_init(self):
if not ndi.initialize():
logger = logging.getLogger(__name__)
logger.error("Could not initialize NDI®")
return
self._ndi_ok = True

def ndi_find_init(self):
def _ndi_find_init(self):
self._ndi_find = ndi.find_create_v2()
if self._ndi_find is None:
self._is_running = False
logger = logging.getLogger(__name__)
logger.error("Could not initialize NDI® find")
ndi.destroy()
self._ndi_ok = False
return

def _create_finder(self):
if self._ndi_find:
self._finder = NDIfinder(self)

def get_ndi_find(self):
return self._ndi_find

def is_ndi_ok(self):
return self._ndi_ok
def get_stream(self, dynamic_id):
return next((x for x in self._streams if x.get_id() == dynamic_id), None)

def destroy(self):
if self._ndi_ok:
if self._ndi_find is not None:
ndi.find_destroy(self._ndi_find)
ndi.destroy()
self._ndi_ok = False
def try_add_stream(self, dynamic_id: str, ndi_source: str, lowbandwidth: bool) -> bool:
stream: NDIVideoStream = NDIVideoStream(dynamic_id, ndi_source, lowbandwidth, self)
if not stream.is_ok:
logger = logging.getLogger(__name__)
logger.error(f"Error opening stream: {ndi_source}")
return False

self._streams.append(stream)
return True

def try_add_stream_proxy(self, dynamic_id: str, ndi_source: str, fps: float, lowbandwidth: bool) -> bool:
stream: NDIVideoStreamProxy = NDIVideoStreamProxy(dynamic_id, ndi_source, fps, lowbandwidth)
if not stream.is_ok:
logger = logging.getLogger(__name__)
logger.error(f"Error opening stream: {ndi_source}")
return False

self._streams.append(stream)
return True

def stop_stream(self, dynamic_id: str):
stream = self.get_stream(dynamic_id)
if stream is not None:
self._streams.remove(stream)
stream.destroy()

def stop_all_streams(self):
for stream in self._streams:
stream.destroy()
self._streams.clear()


class NDIfinder():
SLEEP_INTERVAL: float = 2 # seconds

def __init__(self, on_sources_changed, tools: NDItools):
self._on_sources_changed = on_sources_changed
def __init__(self, tools: NDItools):
self._tools = tools
self._previous_sources: List[str] = []

if tools.is_ndi_ok():
self._is_running = True
self._ndi_find = tools.get_ndi_find()
self._thread = threading.Thread(target=self._search)
self._thread.start()
self._is_running = True
self._thread = threading.Thread(target=self._search)
self._thread.start()

def destroy(self):
self._is_running = False
self._thread.join()
self._thread = None

def _search(self):
if self._ndi_find is not None:
find = self._tools.get_ndi_find()
if find:
while self._is_running:
sources = ndi.find_get_current_sources(self._ndi_find)
sources = ndi.find_get_current_sources(find)
result = [s.ndi_name for s in sources]
delta = set(result) ^ set(self._previous_sources)
if len(delta) > 0:
self._previous_sources = result
self._on_sources_changed(result)
EventSystem.send_event(EventSystem.NDIFINDER_NEW_SOURCES, payload={"sources": result})
time.sleep(NDIfinder.SLEEP_INTERVAL)

def destroy(self):
if self._is_running:
self._is_running = False
self._thread.join()
self._thread = None
self._is_running = False


class NDIVideoStream():
NO_FRAME_TIMEOUT = 5 # seconds

def __init__(self, name: str, stream_uri: str, lowbandwidth: bool, tools: NDItools):
self.name = name
self.uri = stream_uri
self.is_ok = False
self._thread: threading.Thread
def __init__(self, dynamic_id: str, ndi_source: str, lowbandwidth: bool, tools: NDItools):
self._dynamic_id = dynamic_id
self._ndi_source = ndi_source
self._lowbandwidth = lowbandwidth
self._thread: threading.Thread = None
self._ndi_recv = None

self.is_ok = False

if not tools.is_ndi_ok():
return

ndi_find = tools.get_ndi_find()
source = None
sources = ndi.find_get_current_sources(ndi_find)
source_candidates = [s for s in sources if s.ndi_name == stream_uri]
source_candidates = [s for s in sources if s.ndi_name == self._ndi_source]
if len(source_candidates) != 0:
source = source_candidates[0]

if source is None:
logger = logging.getLogger(__name__)
logger.error(f"TIMEOUT: Could not find source at \"{stream_uri}\".")
logger.error(f"TIMEOUT: Could not find source at \"{self._ndi_source}\".")
return

if lowbandwidth:
Expand All @@ -139,7 +183,7 @@ def __init__(self, name: str, stream_uri: str, lowbandwidth: bool, tools: NDItoo
ndi.recv_connect(self._ndi_recv, source)

self._is_running = True
self._thread = threading.Thread(target=self._update_texture, args=(name, ))
self._thread = threading.Thread(target=self._update_texture, args=(self._dynamic_id, ))
self._thread.start()

self.is_ok = True
Expand All @@ -150,6 +194,12 @@ def destroy(self):
self._thread = None
ndi.recv_destroy(self._ndi_recv)

def get_id(self) -> str:
return self._dynamic_id

def is_running(self) -> bool:
return self._is_running

def get_recv_high_bandwidth(self):
recv_create_desc = ndi.RecvCreateV3()
recv_create_desc.color_format = ndi.RECV_COLOR_FORMAT_BGRX_BGRA
Expand All @@ -163,9 +213,9 @@ def get_recv_low_bandwidth(self):
return recv_create_desc

@carb.profiler.profile
def _update_texture(self, name: str):
def _update_texture(self, dynamic_id: str):
carb.profiler.begin(0, 'Omniverse NDI®::Init')
dynamic_texture = omni.ui.DynamicTextureProvider(name)
dynamic_texture = omni.ui.DynamicTextureProvider(dynamic_id)

last_read = time.time() - 1 # Make sure we run on the first frame
fps = 120.0
Expand Down Expand Up @@ -209,20 +259,23 @@ def _update_texture(self, name: str):


class NDIVideoStreamProxy():
def __init__(self, name: str, stream_uri: str, fps: float, lowbandwidth: bool):
self.name = name
self.uri = stream_uri
self.is_ok = False
def __init__(self, dynamic_id: str, ndi_source: str, fps: float, lowbandwidth: bool):
self._dynamic_id = dynamic_id
self._ndi_source = ndi_source
self._fps = fps
self._lowbandwidth = lowbandwidth
self._thread: threading.Thread = None

self.is_ok = False

denominator = 1
if lowbandwidth:
denominator = 3
w = int(1920 / denominator)
w = int(1920 / denominator) # TODO: dimensions from name like for fps
h = int(1080 / denominator)

self._is_running = True
self._thread = threading.Thread(target=self._update_texture, args=(name, fps, w, h, ))
self._thread = threading.Thread(target=self._update_texture, args=(self._dynamic_id, self._fps, w, h, ))
self._thread.start()

self.is_ok = True
Expand All @@ -232,12 +285,18 @@ def destroy(self):
self._thread.join()
self._thread = None

def get_id(self) -> str:
return self._dynamic_id

def is_running(self) -> bool:
return self._is_running

@carb.profiler.profile
def _update_texture(self, name: str, fps: float, width: float, height: float):
def _update_texture(self, dynamic_id: str, fps: float, width: float, height: float):
carb.profiler.begin(0, 'Omniverse NDI®::Init')
color = np.array([255, 0, 0, 255], np.uint8)
channels = len(color)
dynamic_texture = omni.ui.DynamicTextureProvider(name)
dynamic_texture = omni.ui.DynamicTextureProvider(dynamic_id)
frame = np.full((height, width, channels), color, dtype=np.uint8)

last_read = time.time() - 1
Expand Down
Loading

0 comments on commit ac84c01

Please sign in to comment.