diff --git a/itkwidgets/standalone/index.html b/itkwidgets/standalone/index.html index bd84a241..a9f437b9 100644 --- a/itkwidgets/standalone/index.html +++ b/itkwidgets/standalone/index.html @@ -44,6 +44,7 @@ } async function setupViewerForImJoy(server) { const version = itkVtkViewer.version + await server.registerService({ name: 'itkwidgets-service', id: 'itkwidgets_service', @@ -59,13 +60,16 @@ cover: ['https://kitware.github.io/itk-vtk-viewer/docs/howToUse.jpg', 'https://kitware.github.io/itk-vtk-viewer/docs/imjoy.png'], }); + itkVtkViewer.imJoyCodecs.forEach((codec) => { server.registerCodec(codec) }) + const remoteID = `${workspace}/itkwidgets-server`; let remoteService = await server.getService(`${remoteID}:itkwidgets-input-obj`); const data = await remoteService.inputObject(); const viewer = await itkVtkViewer.createViewer(container, data.data); + await server.registerService({ name: "itk_vtk_viewer", id: "itk-vtk-viewer", @@ -75,8 +79,26 @@ require_context: false, run_in_executor: true, }, - viewer: () => viewer + viewer: () => viewer, + capture_screenshot: async () => { + const viewProxy = viewer.getViewProxy(); + const representationProxy = viewProxy.getRepresentations()[0]; + let mapper = null + if (representationProxy && representationProxy.getClassName() === 'vtkVolumeRepresentationProxy') { + mapper = representationProxy.getMapper(); + mapper.setAutoAdjustSampleDistances(false) + } + + const capturedImage = await viewer.captureImage() + + if (representationProxy && representationProxy.getClassName() === 'vtkVolumeRepresentationProxy') { + mapper.setAutoAdjustSampleDistances(true) + } + + return capturedImage + }, }); + await server.registerService({ name: "setLabelImage", id: "set-label-image", @@ -89,9 +111,10 @@ set_label_image: async (uuid) => { remoteService = await server.getService(`${remoteID}:fetch-zarr-store`); let labelImage = await remoteService.fetchZarrStore(uuid); - await viewer.setLabelImage(labelImage); + viewer.setLabelImage(labelImage); } }); + remoteService = await server.getService(`${remoteID}:itkwidgets-viewer-ready`); await remoteService.viewerReady(viewer); } diff --git a/itkwidgets/standalone_server.py b/itkwidgets/standalone_server.py index f2c3ba37..f85fa540 100644 --- a/itkwidgets/standalone_server.py +++ b/itkwidgets/standalone_server.py @@ -10,10 +10,14 @@ import sys import time from pathlib import Path - +from base64 import b64decode import webbrowser +from playwright.sync_api import sync_playwright import imjoy_rpc +import numpy as np +import PIL +from imgcat import imgcat from imjoy_rpc.hypha import connect_to_server_sync from itkwidgets.standalone.config import SERVER_HOST, SERVER_PORT, VIEWER_HTML @@ -31,6 +35,7 @@ from urllib3 import PoolManager, exceptions logging.getLogger("urllib3").setLevel(logging.ERROR) +logging.getLogger("websocket-client").setLevel(logging.ERROR) def find_port(port=SERVER_PORT): @@ -47,6 +52,7 @@ def find_port(port=SERVER_PORT): OPTS = None EVENT = threading.Event() ZARR_STORE = {} +BROWSER = None def standalone_viewer(url): @@ -71,6 +77,18 @@ def input_dict(): data = build_init_data(user_input) ui = user_input.get("ui", "reference") data["config"] = build_config(ui) + + if data["view_mode"] is not None: + vm = data["view_mode"] + if vm == "x": + data["view_mode"] = "XPlane" + elif vm == "y": + data["view_mode"] = "YPlane" + elif vm == "z": + data["view_mode"] = "ZPlane" + elif vm == "v": + data["view_mode"] = "Volume" + return {"data": data} @@ -83,7 +101,11 @@ def read_files(): if reader: reader = ConversionBackend(reader) else: - reader = detect_cli_io_backend(input) + reader = detect_cli_io_backend([input]) + if not input.find('://') == -1 and not Path(input).exists(): + sys.stderr.write(f"File not found: {input}\n") + # hack + raise KeyboardInterrupt ngff_image = cli_input_to_ngff_image(reader, [input]) user_input[param] = ngff_image return user_input @@ -179,11 +201,7 @@ def start_viewer(server_url): } ) - workspace = server.config.workspace - token = server.generate_token() - params = urlencode({"workspace": workspace, "token": token}) - webbrowser.open_new_tab(f"{server_url}/itkwidgets/index.html?{params}") - return server + return server, input_obj def main(): @@ -222,19 +240,82 @@ def main(): timeout -= 0.1 time.sleep(0.1) - server = start_viewer(server_url) + server, input_obj = start_viewer(server_url) + workspace = server.config.workspace + token = server.generate_token() + params = urlencode({"workspace": workspace, "token": token}) + url = f"{server_url}/itkwidgets/index.html?{params}" + + # Updates for resolution progression + rate = 1.0 + fast_rate = 0.05 + if OPTS.rotate: + rate = fast_rate + + if OPTS.browser: + sys.stdout.write(f"Viewer url:\n\n {url}\n\n") + webbrowser.open_new_tab(f"{server_url}/itkwidgets/index.html?{params}") + else: + playwright = sync_playwright().start() + args = [ + "--enable-unsafe-webgpu", + ] + browser = playwright.chromium.launch(args=args) + BROWSER = browser + page = browser.new_page() + + terminal_size = os.get_terminal_size() + width = terminal_size.columns * 10 + is_tmux = 'TMUX' in os.environ and 'tmux' in os.environ['TMUX'] + # https://github.com/tmux/tmux/issues/1502 + if is_tmux: + width = min(width, 400) + else: + width = min(width, 1024) + height = width + page.set_viewport_size({"width": width, "height": height}) + + response = page.goto(url, timeout=0, wait_until="load") + assert response.status == 200, ( + "Failed to start browser app instance, " + f"status: {response.status}, url: {url}" + ) + + input_data = input_obj["data"] + if not input_data["use2D"]: + if input_data["x_slice"] is None and input_data["view_mode"] == "XPlane": + page.locator('label[itk-vtk-tooltip-content="X plane play scroll"]').click() + rate = fast_rate + elif input_data["y_slice"] is None and input_data["view_mode"] == "YPlane": + page.locator('label[itk-vtk-tooltip-content="Y plane play scroll"]').click() + rate = fast_rate + elif input_data["y_slice"] is None and input_data["view_mode"] == "ZPlane": + page.locator('label[itk-vtk-tooltip-content="Z plane play scroll"]').click() + rate = fast_rate + + EVENT.wait() # Wait until viewer is created before launching REPL + workspace = server.config.workspace + svc = server.get_service(f"{workspace}/itkwidgets-client:itk-vtk-viewer") + viewer = view(itk_viewer=svc.viewer(), server=server) + if not OPTS.browser: + terminal_height = min(terminal_size.lines - 1, terminal_size.columns // 3) + + + while True: + png_bin = b64decode(svc.capture_screenshot()[22:]) + imgcat(png_bin, height=terminal_height) + time.sleep(rate) + CSI = b'\033[' + sys.stdout.buffer.write(CSI + str(terminal_height).encode() + b"F") + if OPTS.repl: - EVENT.wait() # Wait until viewer is created before launching REPL - workspace = server.config.workspace - svc = server.get_service(f"{workspace}/itkwidgets-client:itk-vtk-viewer") - viewer = view(itk_viewer=svc.viewer(), server=server) banner = f""" Welcome to the itkwidgets command line tool! Press CTRL+D or run `exit()` to terminate the REPL session. Use the `viewer` object to manipulate the viewer. """ - exitmsg = "Exiting REPL. Press CTRL+C to teminate CLI tool." - code.interact(banner=banner, local={"viewer": viewer}, exitmsg=exitmsg) + exitmsg = "Exiting REPL. Press CTRL+C to exit the viewer." + code.interact(banner=banner, local={"viewer": viewer, "svc": svc, "server": server}, exitmsg=exitmsg) def cli_entrypoint(): @@ -243,11 +324,11 @@ def cli_entrypoint(): parser = argparse.ArgumentParser() parser.add_argument("data", nargs="?", type=str, help="Path to a data file.") - parser.add_argument("--image", type=str, help="Path to an image data file.") + parser.add_argument("-i", "--image", dest="image", type=str, help="Path to an image data file.") parser.add_argument( - "--label-image", type=str, help="Path to a label image data file." + "-l", "--label-image", dest="label_image", type=str, help="Path to a label image data file." ) - parser.add_argument("--point-set", type=str, help="Path to a point set data file.") + parser.add_argument("-p", "--point-set", dest="point_set", type=str, help="Path to a point set data file.") parser.add_argument( "--use2D", dest="use2D", action="store_true", default=False, help="Image is 2D." ) @@ -264,6 +345,13 @@ def cli_entrypoint(): default=False, help="Print all log messages to stdout.", ) + parser.add_argument( + "-b", "--browser", + dest="browser", + action="store_true", + default=False, + help="Render to a browser tab instead of the terminal.", + ) parser.add_argument( "--repl", dest="repl", @@ -273,7 +361,7 @@ def cli_entrypoint(): ) # General Interface parser.add_argument( - "--rotate", + "-r", "--rotate", dest="rotate", action="store_true", default=False, @@ -307,6 +395,7 @@ def cli_entrypoint(): "--bg-color", type=tuple, nargs="+", + default=(0.0, 0.0, 0.0), help="Background color: (red, green, blue) tuple, components from 0.0 to 1.0.", ) # Images @@ -400,9 +489,9 @@ def cli_entrypoint(): help="Whether to used gradient-based shadows in the volume rendering.", ) parser.add_argument( - "--view-mode", + "-m", "--view-mode", type=str, - choices=["XPlane", "YPlane", "ZPlane", "Volume"], + choices=["x", "y", "z", "v"], help="Only relevant for 3D scenes.", ) parser.add_argument( @@ -427,8 +516,18 @@ def cli_entrypoint(): OPTS = parser.parse_args() - main() + try: + main() + except KeyboardInterrupt: + if BROWSER: + BROWSER.close() + if not OPTS.browser: + # Clear `^C%` + CSI = b'\033[' + sys.stdout.buffer.write(CSI + b"1K") + sys.stdout.buffer.write(b"\n") + sys.exit(0) if __name__ == "__main__": - cli_entrypoint() + cli_entrypoint() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index b61edf8d..a24f3eca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,8 @@ cli = [ "hypha >= 0.15.28", "IPython >= 8.4.0", "itk-io >= 5.3.0", + "ngff-zarr[cli]", + "playwright", ] notebook = [