From dcf7793609541b15ceb75de099972606db74b00f Mon Sep 17 00:00:00 2001 From: Nils Mechtel Date: Tue, 20 Aug 2024 10:39:45 +0200 Subject: [PATCH 01/18] add cache bust to dockerfile --- Dockerfile | 3 +++ build_and_push.sh | 5 ++++- docker-compose.yml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index a0f935e..19a7438 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,9 @@ COPY ./bioimageio_colab/register_sam_service.py /app/register_sam_service.py # Change ownership of the application directory to the non-root user RUN chown -R bioimageio_colab:bioimageio_colab /app/ +# Add a build argument for cache invalidation +ARG CACHEBUST=1 + # Fetch the Hypha server version and reinstall or upgrade hypha-rpc to the matching version RUN HYPHA_VERSION=$(curl -s https://hypha.aicell.io/config.json | jq -r '.hypha_version') && \ pip install --upgrade "hypha-rpc<=$HYPHA_VERSION" diff --git a/build_and_push.sh b/build_and_push.sh index 4ea220a..b207327 100644 --- a/build_and_push.sh +++ b/build_and_push.sh @@ -22,7 +22,10 @@ IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY}:latest # Log in to GHCR echo "$GHCR_PAT" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin -# Build the Docker image using Docker Compose +# Generate a dynamic CACHEBUST value (timestamp) +export CACHEBUST=$(date +%Y%m%d%H%M%S) + +# Build the Docker image using Docker Compose with the CACHEBUST argument docker-compose build # Push the Docker image to GHCR diff --git a/docker-compose.yml b/docker-compose.yml index 63e0668..d58d222 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: context: . dockerfile: Dockerfile args: - SOURCE_LABEL: "https://github.com/bioimage-io/bioimageio-colab" + CACHEBUST: ${CACHEBUST} image: ghcr.io/bioimage-io/bioimageio-colab:latest env_file: - .env From 65547783fe33c0e5a9f6793fc7348ec1d1fca60c Mon Sep 17 00:00:00 2001 From: Nils Mechtel Date: Tue, 20 Aug 2024 10:46:56 +0200 Subject: [PATCH 02/18] remove overwrite --- bioimageio_colab/register_sam_service.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bioimageio_colab/register_sam_service.py b/bioimageio_colab/register_sam_service.py index 6dc6f19..5cfc064 100644 --- a/bioimageio_colab/register_sam_service.py +++ b/bioimageio_colab/register_sam_service.py @@ -223,8 +223,7 @@ async def register_service(args: dict) -> None: # remove the user id from the storage # returns True if the user was removed successfully "remove_user_id": remove_user_id, # TODO: add a timeout to remove a user after a certain time - }, - overwrite=True, + } ) sid = service_info["id"] assert sid == f"{args.workspace_name}/{args.client_id}:{args.service_id}" From 4d3bd616364eaa98d7842d5ee4f10fefba547bfd Mon Sep 17 00:00:00 2001 From: Nils Mechtel Date: Tue, 20 Aug 2024 19:44:44 +0200 Subject: [PATCH 03/18] run script to register data providing service --- docs/index.html | 91 ++++++++++++++----------- docs/pyodide-worker.js | 2 +- docs/{provide_images.py => services.py} | 24 +++++-- 3 files changed, 74 insertions(+), 43 deletions(-) rename docs/{provide_images.py => services.py} (80%) diff --git a/docs/index.html b/docs/index.html index e8e0aa6..b8407c7 100644 --- a/docs/index.html +++ b/docs/index.html @@ -63,12 +63,12 @@ @@ -20,36 +20,77 @@ --color-green: #07332E; } - .bg-turquoise { background-color: var(--color-turquoise); } - .bg-blue { background-color: var(--color-blue); } - .bg-purple { background-color: var(--color-purple); } - .bg-green { background-color: var(--color-green); } - .bg-disabled-button { background-color: #a9a9a9; } + .bg-turquoise { + background-color: var(--color-turquoise); + } + + .bg-blue { + background-color: var(--color-blue); + } + + .bg-purple { + background-color: var(--color-purple); + } + + .bg-green { + background-color: var(--color-green); + } + + .bg-disabled-button { + background-color: #a9a9a9; + } /* Hover states with 50% transparency */ - .bg-turquoise-hover:hover { background-color: rgba(149, 176, 173, 0.3); } - .bg-blue-hover:hover { background-color: rgba(49, 73, 102, 0.3); } - .bg-purple-hover:hover { background-color: rgba(71, 28, 77, 0.3); } - .bg-green-hover:hover { background-color: rgba(7, 51, 46, 0.3); } - + .bg-turquoise-hover:hover { + background-color: rgba(149, 176, 173, 0.3); + } + + .bg-blue-hover:hover { + background-color: rgba(49, 73, 102, 0.3); + } + + .bg-purple-hover:hover { + background-color: rgba(71, 28, 77, 0.3); + } + + .bg-green-hover:hover { + background-color: rgba(7, 51, 46, 0.3); + } + .spinner-border { border: 4px solid rgba(0, 0, 0, 0.1); - border-left-color: var(--color-blue); + border-left-color: var(--color-purple); border-radius: 50%; width: 2rem; height: 2rem; animation: spin 1s infinite linear; } + .floating-spinner{ + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background-color: rgba(0, 0, 0, 0.1); /* Optional: for a semi-transparent background */ + z-index: 9999 /* Ensure it is on top of other elements */ + + } + @keyframes spin { to { transform: rotate(360deg); } } + .tab-active { background-color: var(--color-turquoise); border-radius: 0.5rem; color: white; } + .separator { border-top: 1px solid #555; margin: 1rem 0; @@ -57,374 +98,412 @@ + -
- - +
+ + ReactDOM.render(, document.getElementById('app')); + - + + \ No newline at end of file diff --git a/docs/services.py b/docs/services.py index bbf2172..2561019 100644 --- a/docs/services.py +++ b/docs/services.py @@ -63,9 +63,9 @@ def upload_image_to_s3(): raise NotImplementedError -async def register_service(): +async def register_service(server_url, token): # Connect to the server link - server = await connect_to_server({"server_url": SERVER_URL}) + server = await connect_to_server({"server_url": server_url, "token": token}) # Generate token for the current workspace token = await server.generate_token() @@ -114,8 +114,3 @@ async def register_service(): # Option 5: Send the annotator URL via hypha service # requires the registration of a service in the main thread - -# Start the service -loop = asyncio.get_event_loop() -loop.create_task(register_service()) -loop.run_forever() From cb598c18cf3500b1a46b66c605e50798927b55ea Mon Sep 17 00:00:00 2001 From: Nils Mechtel Date: Thu, 22 Aug 2024 11:21:30 +0200 Subject: [PATCH 05/18] create annotator URL --- docs/data-providing-service.py | 87 +++++++++++++++ docs/index.html | 192 +++++++++++++++++---------------- docs/services.py | 116 -------------------- 3 files changed, 187 insertions(+), 208 deletions(-) create mode 100644 docs/data-providing-service.py delete mode 100644 docs/services.py diff --git a/docs/data-providing-service.py b/docs/data-providing-service.py new file mode 100644 index 0000000..8be77b4 --- /dev/null +++ b/docs/data-providing-service.py @@ -0,0 +1,87 @@ +import os +from functools import partial +from typing import List, Tuple + +import numpy as np +from hypha_rpc import connect_to_server +from kaibu_utils import features_to_mask +from tifffile import imread, imwrite + + +def list_image_files(image_folder: str, supported_file_types: Tuple[str]): + return [f for f in os.listdir(image_folder) if f.endswith(supported_file_types)] + + +def read_image(file_path: str): + image = imread(file_path) + if len(image.shape) == 3 and image.shape[0] == 3: + image = np.transpose(image, [1, 2, 0]) + return image + + +def get_random_image(image_folder: str, supported_file_types: Tuple[str]): + filenames = list_image_files(image_folder, supported_file_types) + r = np.random.randint(len(filenames) - 1) + file_name = filenames[r] + image = read_image(os.path.join(image_folder, file_name)) + return (image, file_name.split(".")[0]) + + +def save_annotation(annotations_folder: str, image_name: str, features, image_shape): + mask = features_to_mask(features, image_shape) + n_image_masks = len( + [f for f in os.listdir(annotations_folder) if f.startswith(image_name)] + ) + mask_name = os.pth.join( + annotations_folder, f"{image_name}_mask_{n_image_masks + 1}.tif" + ) + imwrite(mask_name, mask) + + +def upload_image_to_s3(): + """ + Steps: + - Create a user prefix on S3 + - Create a data and annotation prefix + - For every image: + - Load the image from the data folder into a numpy array + - Upload the image to the data prefix + + Return: + - The user prefix + + # TODO: register a data providing service on K8S cluster that uses the user prefix (get_random_image_s3, save_annotation_s3) + """ + raise NotImplementedError + + +async def register_service( + server_url: str, + token: str, + image_folder: str, + annotations_folder: str, + supported_file_types: List[str], +): + # Connect to the server link + server = await connect_to_server({"server_url": server_url, "token": token}) + + # Register the service + svc = await server.register_service( + { + "name": "Collaborative Annotation", + "id": "data-provider", + "config": { + "visibility": "public", # TODO: make protected + "run_in_executor": True, + }, + # Exposed functions: + # get a random image from the dataset + # returns the image as a numpy image + "get_random_image": partial( + get_random_image, image_folder, tuple(supported_file_types) + ), + # save the annotation mask + # pass the filename of the image, the new filename, the features and the image shape + "save_annotation": partial(save_annotation, annotations_folder), + } + ) diff --git a/docs/index.html b/docs/index.html index 204b4a9..898505d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -59,13 +59,14 @@ .spinner-border { border: 4px solid rgba(0, 0, 0, 0.1); - border-left-color: var(--color-purple); + border-left-color: var(--color-turquoise); border-radius: 50%; width: 2rem; height: 2rem; animation: spin 1s infinite linear; } - .floating-spinner{ + + .floating-spinner { position: fixed; top: 0; left: 0; @@ -74,9 +75,10 @@ display: flex; justify-content: center; align-items: center; - background-color: rgba(0, 0, 0, 0.1); /* Optional: for a semi-transparent background */ - z-index: 9999 /* Ensure it is on top of other elements */ - + /* Optional: for a semi-transparent background */ + background-color: rgba(0, 0, 0, 0.1); + /* Ensure it is on top of other elements */ + z-index: 9999 } @keyframes spin { @@ -101,7 +103,7 @@ @@ -110,42 +112,47 @@ const { useState, useEffect } = React; - const REGISTER_SERVICE_FILE = 'services.py'; + const serverUrl = "wss://hypha.aicell.io/ws"; + const serviceFile = "data-providing-service.py"; + const pluginUrl = "https://raw.githubusercontent.com/bioimage-io/bioimageio-colab/main/plugins/bioimageio-colab-annotator.imjoy.html"; let workerPromise; + function App() { - const [serverURL, setServerURL] = useState("wss://hypha.aicell.io/ws"); - const [pyodideOutput, setPyodideOutput] = useState([]); const [user, setUser] = useState({}); + const [hyphaVersion, setHyphaVersion] = useState(""); + const [isRunning, setIsRunning] = useState(false); + const [pyodideOutput, setPyodideOutput] = useState([]); + const [supportedFileTypes, setSupportedFileTypes] = useState([".tiff", ".tif"]); const [imageFolderHandle, setImageFolderHandle] = useState(null); const [imageList, setImageList] = useState([]); + const [isLoadingImages, setIsLoadingImages] = useState(false); const [annotationsFolderHandle, setAnnotationsFolderHandle] = useState(null); const [annotationsList, setAnnotationsList] = useState([]); - const [isRunning, setIsRunning] = useState(false); - const [isLoadingImages, setIsLoadingImages] = useState(false); const [isLoadingAnnotations, setIsLoadingAnnotations] = useState(false); - const [annotationURL, setAnnotationURL] = useState("placeholder"); + const [annotationURL, setAnnotationURL] = useState(""); const [copyFeedback, setCopyFeedback] = useState(""); const [activeTab, setActiveTab] = useState("Local Deployment"); useEffect(() => { + // Create a new Pyodide worker workerPromise = new Promise((resolve, reject) => { const workerManager = new PyodideWorkerManager(); workerManager.createWorker().then(worker => { - setIsRunning(false); resolve(worker); }).catch(error => { console.error("Error creating Pyodide worker:", error); reject(error); }); }); - window.getFromDB('imageFolderDirHandle').then((dirHandle) => { + // Get image folder handle from IndexedDB + window.getFromDB("imageFolderDirHandle").then((dirHandle) => { if (dirHandle) setImageFolderHandle(dirHandle); // Set image folder handle }); }, []); const runCode = async (code) => { const pyodideWorker = await workerPromise; - console.log("Running code:\n-------------\n", code); // TODO: Remove this line + console.log("Running code:\n----------\n", code); try { const result = await pyodideWorker.runScript(code); handleResult(result); @@ -157,8 +164,8 @@ }; const handleResult = (result) => { - const stdout = result.filter(r => r.type === 'stdout').map(r => r.content).join(""); - const stderr = result.filter(r => r.type === 'stderr').map(r => r.content).join(""); + const stdout = result.filter(r => r.type === "stdout").map(r => r.content).join(""); + const stderr = result.filter(r => r.type === "stderr").map(r => r.content).join(""); setPyodideOutput([stdout, stderr].filter(Boolean)); }; @@ -167,29 +174,30 @@ } const login = async () => { - let token = localStorage.getItem('token'); + let token = localStorage.getItem("token"); if (token) { - const tokenExpiry = localStorage.getItem('tokenExpiry'); + const tokenExpiry = localStorage.getItem("tokenExpiry"); if (tokenExpiry && new Date(tokenExpiry) > new Date()) { - console.log("Using saved token:", token); + console.log("Using saved token:\n----------\n", token); return token; } } token = await hyphaWebsocketClient.login({ - "server_url": serverURL, + "server_url": serverUrl, "login_callback": loginCallback, }); - localStorage.setItem('token', token); - localStorage.setItem('tokenExpiry', new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString()); + localStorage.setItem("token", token); + localStorage.setItem("tokenExpiry", new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString()); return token; } const handleLogin = async () => { const token = await login(); const server = await hyphaWebsocketClient.connectToServer({ - "server_url": serverURL, - "token": token + "server_url": serverUrl, + "token": token, }); + setHyphaVersion(server.config.hyphaVersion); setUser(server.config.user); console.log("Logged in as:", server.config.user); }; @@ -199,13 +207,17 @@ try { const files = []; for await (const entry of dirHandle.values()) { - if (entry.kind === 'file') { - files.push(entry.name); + if (entry.kind === "file") { + const fileType = entry.name.slice(entry.name.lastIndexOf(".")).toLowerCase(); + if (supportedFileTypes.includes(fileType)) { + files.push(entry.name); + } } } setFileList(files.sort()); // Sort files alphabetically } catch (error) { console.error("Error updating file list:", error); + throw error; } finally { setLoading(false); } @@ -217,67 +229,68 @@ setImageFolderHandle(dirHandle); } catch (error) { console.error("Error accessing folder:", error); + throw error; } }; useEffect(() => { if (imageFolderHandle) { - window.saveToDB('imageFolderDirHandle', imageFolderHandle); + window.saveToDB("imageFolderDirHandle", imageFolderHandle); updateFileList(imageFolderHandle, setImageList, setIsLoadingImages); // Update image list console.log("Mounted folder:", imageFolderHandle.name); } }, [imageFolderHandle]); - // const checkAnnotationURL = async () => { - // const intervalId = setInterval(() => { - // const url = localStorage.getItem('annotationURL'); - // if (url) { - // setAnnotationURL(url); - // clearInterval(intervalId); // Stop checking once the URL is available - // } - // }, 1000); - // }; - const deployAnnotationSession = async () => { - if (imageFolderHandle) { - try { - const annotationsFolder = await imageFolderHandle.getDirectoryHandle('annotations', { create: true }); - setAnnotationsFolderHandle(annotationsFolder); - await updateFileList(annotationsFolder, setAnnotationsList, setIsLoadingAnnotations); - console.log("Created annotations folder:", annotationsFolder.name); - } catch (error) { - console.error("Error creating annotations folder:", error); - } - // Run code to start annotation session - const code = await fetch(`./${REGISTER_SERVICE_FILE}`).then(response => response.text()); - try { - setIsRunning(true); - await workerPromise; - await runCode(code); - const server_url = serverURL; - const token = localStorage.getItem('token'); - if (!token) { - alert("Please login first"); - return; - } - const outputs = await runCode(`await register_service("${server_url}", "${token}")`); - // find an output which contains type=service - const serviceOutput = outputs.find(output => output.type === 'service'); - if (serviceOutput) { - const annotationURL = serviceOutput.content; - setAnnotationURL(annotationURL); - } - await runCode(`import asyncio; loop = asyncio.get_event_loop(); loop.run_forever()`); - } - catch (error) { - console.error("Error running code:", error); - } - finally { - setIsRunning(false); + // Check if user is logged in + if (!user.email) { + alert("Please login first"); + return; + } + const token = localStorage.getItem("token"); + // Create annotations folder + try { + const annotationsFolder = await imageFolderHandle.getDirectoryHandle("annotations", { create: true }); + setAnnotationsFolderHandle(annotationsFolder); + await updateFileList(annotationsFolder, setAnnotationsList, setIsLoadingAnnotations); + console.log("Created annotations folder:", annotationsFolder.name); + } catch (error) { + console.error("Error creating annotations folder:", error); + throw error; + } + // Start annotation session + try { + setIsRunning(true); + // Load the service code + const code = await fetch(`./${serviceFile}`).then(response => response.text()); + await runCode(code); + // Register the service + const outputs = await runCode( + `await register_service("${serverUrl}", "${token}", "/mnt", "/mnt/annotations", "${supportedFileTypes}")` + ); + // Find an output which contains type=service + const serviceOutput = outputs.find(output => output.type === "service"); + // Assert serviceOutput + if (!serviceOutput) { + console.error("Service output not found:", outputs); + throw new Error("Something went wrong while registering the service."); } + // Create the annotator URL + const annotationSid = serviceOutput.attrs.id; + const configStr = `{"server_url": "${serverUrl}", "annotation_service_id": "${annotationSid}", "token": "${token}"}`; + const encodedConfig = encodeURIComponent(configStr); + const annotatorUrl = `https://imjoy.io/lite?plugin=${pluginUrl}&config=${encodedConfig}`; + console.log("Annotation URL:\n----------\n", annotatorUrl); + setAnnotationURL(annotatorUrl); + // Run the event loop + await runCode(`import asyncio; loop = asyncio.get_event_loop(); loop.run_forever()`); + } + catch (error) { + console.error("Error registering service:", error); + throw error; } - else { - alert("Please mount a folder first"); + finally { + setIsRunning(false); } }; @@ -313,10 +326,10 @@ user={user} activeTab={activeTab} onTabClick={setActiveTab} - annotationURL={annotationURL} // Just for testing, remove later on + hyphaVersion={hyphaVersion} />
-

+

Crowd-sourcing Annotation Tool

{isRunning && ( @@ -326,7 +339,7 @@

-
+

First, mount a local folder to store your images. Your data stays in the browser, ensuring you keep full control. Next, deploy the data service from your browser, which will create an "annotations" folder for saving annotation masks. Finally, share the annotator URL.

@@ -368,7 +381,7 @@

{imageFolderHandle && (
+ style={{ maxHeight: "calc(100vh - 226px)", width: "50%" }}>

Number of Images: ({imageList.length})

@@ -439,7 +451,7 @@

Number of Images: ({imageList.length})

Number of Annotations: ({annotationsList.length})

-
diff --git a/docs/pyodide-worker.js b/docs/pyodide-worker.js index 1265a6d..21a7834 100644 --- a/docs/pyodide-worker.js +++ b/docs/pyodide-worker.js @@ -259,43 +259,53 @@ async def run(source, io_context): const mountedFs = {} self.onmessage = async (event) => { - if(event.data.source){ - try{ - const { source, io_context } = event.data - self.pyodide.globals.set("source", source) - self.pyodide.globals.set("io_context", io_context && self.pyodide.toPy(io_context)) - outputs = [] + if (event.data.source) { + try { + const { source, io_context } = event.data; + self.pyodide.globals.set("source", source); + self.pyodide.globals.set("io_context", io_context && self.pyodide.toPy(io_context)); + outputs = []; // see https://github.com/pyodide/pyodide/blob/b177dba277350751f1890279f5d1a9096a87ed13/src/js/api.ts#L546 - // sync native ==> browser - await new Promise((resolve, _) => self.pyodide.FS.syncfs(true, resolve)); - await self.pyodide.runPythonAsync("await run(source, io_context)") - // sync browser ==> native - await new Promise((resolve, _) => self.pyodide.FS.syncfs(false, resolve)), - console.log("Execution done", outputs) - self.postMessage({ executionDone: true, outputs }) - outputs = [] - } - catch(e){ - console.error("Execution Error", e) - self.postMessage({ executionError: e.message }) + await new Promise((resolve, _) => self.pyodide.FS.syncfs(true, resolve)); // sync native ==> browser + await self.pyodide.runPythonAsync("await run(source, io_context)"); + await new Promise((resolve, _) => self.pyodide.FS.syncfs(false, resolve)); // sync browser ==> native + + console.log("Execution done", outputs); + self.postMessage({ executionDone: true, outputs }); + outputs = []; + } catch (e) { + console.error("Execution Error", e); + self.postMessage({ executionError: e.message }); } } - if(event.data.mount){ - try{ - const { mountPoint, dirHandle } = event.data.mount - if(mountedFs[mountPoint]){ - console.log("Unmounting native FS:", mountPoint) - await self.pyodide.FS.unmount(mountPoint) - delete mountedFs[mountPoint] + if (event.data.mount) { + try { + const { mountPoint, dirHandle } = event.data.mount; + if (mountedFs[mountPoint]) { + console.log("Unmounting native FS:", mountPoint); + await self.pyodide.FS.unmount(mountPoint); + delete mountedFs[mountPoint]; } - const nativefs = await self.pyodide.mountNativeFS(mountPoint, dirHandle) - mountedFs[mountPoint] = nativefs - console.log("Native FS mounted:", mountPoint, nativefs) - self.postMessage({ mounted: mountPoint }) + const nativefs = await self.pyodide.mountNativeFS(mountPoint, dirHandle); + mountedFs[mountPoint] = nativefs; + console.log("Native FS mounted:", mountPoint, nativefs); + self.postMessage({ mounted: mountPoint }); + } catch (e) { + self.postMessage({ mountError: e.message }); } - catch(e){ - self.postMessage({ mountError: e.message }) + } + if (event.data.sync) { + try { + const direction = event.data.sync; + if (direction === "native-to-browser") { + await new Promise((resolve, _) => self.pyodide.FS.syncfs(true, resolve)); // sync native ==> browser + } else if (direction === "browser-to-native") { + await new Promise((resolve, _) => self.pyodide.FS.syncfs(false, resolve)); // sync browser ==> native + } + self.postMessage({ synced: true }); + } catch (e) { + console.error("Error syncing file system:", e); + self.postMessage({ syncError: e.message }); } } - -} +}; diff --git a/docs/worker-manager.js b/docs/worker-manager.js index 126994e..547f094 100644 --- a/docs/worker-manager.js +++ b/docs/worker-manager.js @@ -62,6 +62,9 @@ class PyodideWorkerManager { async mount(mountPoint, dirHandle) { return await self.mountNativeFs(id, mountPoint, dirHandle) }, + async syncFs(direction) { + return await self.syncFs(id, direction); + }, async render(container) { self.render(id, container) }, @@ -135,6 +138,26 @@ class PyodideWorkerManager { }) } + async syncFs(workerId, direction) { + if (!workerId) { + throw new Error("No worker ID provided and no current worker available."); + } + const worker = await this.getWorker(workerId); + return new Promise((resolve, reject) => { + const handler = e => { + if (e.data.synced) { + worker.removeEventListener("message", handler); + resolve(true); + } else if (e.data.syncError) { + worker.removeEventListener("message", handler); + reject(new Error(e.data.syncError)); + } + }; + worker.addEventListener("message", handler); + worker.postMessage({ sync: direction }); + }); + } + addToRecord(workerId, record) { if (!this.workerRecords[workerId]) { this.workerRecords[workerId] = [] From b7fc87a0bea0b8167bbe4b1c55c8958cf047645e Mon Sep 17 00:00:00 2001 From: Nils Mechtel Date: Sat, 24 Aug 2024 15:21:37 +0200 Subject: [PATCH 15/18] handle case when sam service is unavailable --- plugins/bioimageio-colab-annotator.imjoy.html | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/plugins/bioimageio-colab-annotator.imjoy.html b/plugins/bioimageio-colab-annotator.imjoy.html index 309325d..401f477 100644 --- a/plugins/bioimageio-colab-annotator.imjoy.html +++ b/plugins/bioimageio-colab-annotator.imjoy.html @@ -71,13 +71,12 @@ // Get the SAM service from the server let sam; - // TODO: Uncomment this block after the SAM service is available - // try { - // sam = await server.getService(samServiceId); - // } catch (e) { - // await api.alert(`Failed to get the bioimageio-colab SAM service (id=${samServiceId}). (Error: ${e})`); - // return; - // } + try { + sam = await server.getService(samServiceId); + } catch (e) { + sam = null; + await api.showMessage(`Failed to get the bioimageio-colab SAM service (id=${samServiceId}). (Error: ${e})`); + } // Function to get a new image and set up the viewer const getImage = async () => { @@ -113,7 +112,7 @@ // Compute embeddings if not already computed for the image if (!this.embeddingIsCalculated) { - api.showMessage("Computing embeddings for the image..."); + await api.showMessage("Computing embeddings for the image..."); try { await sam.compute_embedding(userID, this.modelName, this.image); } catch (e) { @@ -124,7 +123,7 @@ } // Perform segmentation - api.showMessage("Segmenting..."); + await api.showMessage("Segmenting..."); const features = await sam.segment(userID, pointCoords, pointLabels); // Add the segmented features as polygons to the annotation layer From 142a1fbd84e3f8a80299711c58af6152a83ca536 Mon Sep 17 00:00:00 2001 From: Nils Mechtel Date: Sat, 24 Aug 2024 15:38:42 +0200 Subject: [PATCH 16/18] display workspace and user --- plugins/bioimageio-colab-annotator.imjoy.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/bioimageio-colab-annotator.imjoy.html b/plugins/bioimageio-colab-annotator.imjoy.html index 401f477..9dcfd09 100644 --- a/plugins/bioimageio-colab-annotator.imjoy.html +++ b/plugins/bioimageio-colab-annotator.imjoy.html @@ -42,6 +42,8 @@ const config = ctx.config || {}; const serverUrl = config.server_url || "https://hypha.aicell.io"; const annotationServiceId = config.annotation_service_id || "ws-user-google-oauth2|113850572436772761139/*:data-provider"; // default for testing plugin + const workspace = config.workspace + const token = config.token const samServiceId = "bioimageio-colab/model-server:interactive-segmentation"; // Create and display the viewer window @@ -53,12 +55,13 @@ // Connect to the Hypha server const server = await hyphaWebsocketClient.connectToServer({ server_url: serverUrl, - token: config.token, - workspace: config.workspace, + token: token, + workspace: workspace, }); // Get the user ID const userID = server.config.user.id; + await api.showMessage(`Connected to workspace ${server.config.user.scope.current_workspace} as user ${userID}.`); // Get the bioimageio-colab service from the server let dataProvider; From 73fc1d4af9169d2f6692949bb37da45693d2f75a Mon Sep 17 00:00:00 2001 From: Wei Ouyang Date: Sun, 25 Aug 2024 21:51:28 -0700 Subject: [PATCH 17/18] Improve UI and Support listing annotations --- docs/data-providing-service.py | 13 +- docs/index.html | 442 ++++++++++++++++++++++++--------- 2 files changed, 328 insertions(+), 127 deletions(-) diff --git a/docs/data-providing-service.py b/docs/data-providing-service.py index effbae2..0a00371 100644 --- a/docs/data-providing-service.py +++ b/docs/data-providing-service.py @@ -1,7 +1,8 @@ import os import json -from functools import partial from typing import Tuple +import time +from functools import partial import numpy as np from hypha_rpc import connect_to_server @@ -55,11 +56,12 @@ def upload_image_to_s3(): """ raise NotImplementedError - async def register_service( server_url: str, token: str, supported_file_types_json: str, + name: str, + description: str, ): # Define path to images and annotations images_path = "/mnt" @@ -78,8 +80,10 @@ async def register_service( # Register the service svc = await server.register_service( { - "name": "Collaborative Annotation", - "id": "data-provider", + "name": name, + "description": description, + "id": "data-provider-" + str(int(time.time()*100)), + "type": "annotation-data-provider", "config": { "visibility": "public", # TODO: make protected "run_in_executor": True, @@ -95,3 +99,4 @@ async def register_service( "save_annotation": partial(save_annotation, annotations_path), } ) + print(f"Service registered with ID: {svc['id']}") \ No newline at end of file diff --git a/docs/index.html b/docs/index.html index 74693c2..61b403b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,7 +4,7 @@ - BioimgeIO Colab + BioImage.IO Colab @@ -13,20 +13,33 @@ @@ -131,11 +193,15 @@ const [annotationsList, setAnnotationsList] = useState([]); const [isLoadingAnnotations, setIsLoadingAnnotations] = useState(false); const [annotationURL, setAnnotationURL] = useState(""); - const [copyFeedback, setCopyFeedback] = useState(""); - const [activeTab, setActiveTab] = useState("Local Deployment"); + const [copyFeedback, setCopyFeedback] = useState("Share Annotation URL"); + const [activeTab, setActiveTab] = useState("Local Sessions"); + const [showSessionModal, setShowSessionModal] = useState(false); + const [showShareModal, setShowShareModal] = useState(false); + const [sessionName, setSessionName] = useState(localStorage.getItem("sessionName") || ""); + const [sessionDescription, setSessionDescription] = useState(localStorage.getItem("sessionDescription") || ""); + const [isCreatingSession, setIsCreatingSession] = useState(false); useEffect(() => { - // Create a new Pyodide worker workerPromise = new Promise((resolve, reject) => { const workerManager = new PyodideWorkerManager(); workerManager.createWorker().then(worker => { @@ -145,14 +211,12 @@ reject(error); }); }); - // Get login token from localStorage const savedToken = useSavedToken(); if (savedToken) { handleLogin(); } - // Get image folder handle from IndexedDB window.getFromDB("imageFolderDirHandle").then((dirHandle) => { - if (dirHandle) setImageFolderHandle(dirHandle); // Set image folder handle + if (dirHandle) setImageFolderHandle(dirHandle); }); }, []); @@ -218,10 +282,8 @@ console.log("Logged in as:", server.config.user); } catch (error) { console.error("Error connecting to server:", error); - // Clear token and tokenExpiry localStorage.setItem("token", ""); localStorage.setItem("tokenExpiry", ""); - // Retry login handleLogin(); } }; @@ -247,7 +309,7 @@ } } } - setFileList(files.sort()); // Sort files alphabetically + setFileList(files.sort()); } catch (error) { console.error("Error updating file list:", error); throw error; @@ -269,19 +331,25 @@ useEffect(() => { if (imageFolderHandle) { window.saveToDB("imageFolderDirHandle", imageFolderHandle); - updateFileList(imageFolderHandle, setImageList, setIsLoadingImages); // Update image list + updateFileList(imageFolderHandle, setImageList, setIsLoadingImages); console.log("Mounted folder:", imageFolderHandle.name); } }, [imageFolderHandle]); - const deployAnnotationSession = async () => { - // Check if user is logged in + const createAnnotationSession = async () => { if (!user.email) { alert("Please login first"); return; } + setShowSessionModal(true); + }; + + const handleCreateSession = async () => { + setIsCreatingSession(true); const token = localStorage.getItem("token"); - // Create annotations folder + localStorage.setItem("sessionName", sessionName); + localStorage.setItem("sessionDescription", sessionDescription); + try { const annotationsFolder = await imageFolderHandle.getDirectoryHandle("annotations", { create: true }); setAnnotationsFolderHandle(annotationsFolder); @@ -291,28 +359,21 @@ console.error("Error creating annotations folder:", error); throw error; } - // Start annotation session try { setIsRunning(true); - // Load the service code const code = await fetch(`./${serviceFile}`).then(response => response.text()); await runCode(code); - // Mount the image folder to the pyodide worker const pyodideWorker = await workerPromise; await pyodideWorker.mount("/mnt", imageFolderHandle); - // Register the service const supportedFileTypesJson = JSON.stringify(supportedFileTypes).replace(/"/g, '\\"'); const outputs = await runCode( - `await register_service("${serverUrl}", "${token}", "${supportedFileTypesJson}")` + `await register_service("${serverUrl}", "${token}", "${supportedFileTypesJson}", "${sessionName}", "${sessionDescription}")` ); - // Find an output which contains type=service const serviceOutput = outputs.find(output => output.type === "service"); - // Assert serviceOutput if (!serviceOutput) { console.error("Service output not found:", outputs); throw new Error("Something went wrong while registering the service."); } - // Create the annotator URL const annotationSid = serviceOutput.attrs.id; console.log("Annotation Service ID:", annotationSid); const configStr = `{"server_url": "${serverUrl}", "annotation_service_id": "${annotationSid}", "token": "${token}"}`; @@ -320,9 +381,7 @@ const annotatorUrl = `https://imjoy.io/lite?plugin=${pluginUrl}&config=${encodedConfig}`; console.log("Annotation URL:\n----------\n", annotatorUrl); setAnnotationURL(annotatorUrl); - // Ensure that the same image files are displayed that were mounted to pyodide (sync native ==> browser is called in runCode) updateFileList(imageFolderHandle, setImageList, setIsLoadingImages) - // Run the event loop await runCode(`import asyncio; loop = asyncio.get_event_loop(); loop.run_forever()`); } catch (error) { @@ -331,34 +390,18 @@ } finally { setIsRunning(false); + setShowSessionModal(false); + setIsCreatingSession(false); } }; const shareAnnotationURL = async () => { if (!annotationURL) return; - - setCopyFeedback("Copying Annotation URL..."); - // Disable button while copying - const button = document.querySelector("#share-url-button"); - button.disabled = true; - - try { - await navigator.clipboard.writeText(annotationURL); - setTimeout(() => { - setCopyFeedback("Copied Annotation URL!"); - // Re-enable button after feedback time - setTimeout(() => { - setCopyFeedback(""); - button.disabled = false; - }, 5000); - }, 1000); - } catch (err) { - console.error("Failed to copy annotation URL:", err); - setCopyFeedback(""); - button.disabled = false; - } + setShowShareModal(true); }; + const progressPercentage = annotationsList.length > 0 ? Math.round((annotationsList.length / imageList.length) * 100) : 0; + return (

- Crowd-sourcing Annotation Tool + BioImage.IO Colab: Annotate & Collaborate

{isRunning && (
)} - {activeTab === "Local Deployment" && ( + {activeTab === "Local Sessions" && ( <> -
-

- First, mount a local folder to store your images. Your data stays in the browser, ensuring you keep full control. Next, deploy the data service from your browser, which will create an "annotations" folder for saving annotation masks. Finally, share the annotator URL. -

-

- Note: Closing this tab stops the annotation deployment. Use "Remote Deployments" to keep sessions running. -

-
1.
2.
3.
{imageFolderHandle && ( -
-

Number of Images: ({imageList.length})

-
@@ -436,7 +474,7 @@

Number of Images: ({imageList.length})

{imageList.length > 0 ? ( imageList.map((file, index) => ( -
  • {file}
  • +
  • {file}
  • )) ) : (
  • No files found.
  • @@ -447,11 +485,11 @@

    Number of Images: ({imageList.length})

    -

    Number of Annotations: ({annotationsList.length})

    -
    @@ -463,7 +501,7 @@

    Number of Annotations: ({annotationsList.l
      {annotationsList.length > 0 ? ( annotationsList.map((file, index) => ( -
    • {file}
    • +
    • {file}
    • )) ) : (
    • No files found.
    • @@ -473,46 +511,152 @@

      Number of Annotations: ({annotationsList.l

    )}
    +
    +
    + {`Annotated ${annotationsList.length} out of ${imageList.length} images`} +
    +
    +
    Getting Started
    +
    +

    + BioImage.IO Colab is an interactive, AI-powered annotation tool designed for collaborative annotation. Whether you're crowd-sourcing annotations or working within a team, this tool offers a streamlined way to manage and complete your image annotations efficiently. +

    +

    + Step 1: Mount a local folder containing images for annotation. The data stays in your browser, ensuring full control over your files. +

    +

    + Step 2: Create an annotation session to generate an annotation URL for the Kaibu tool. This URL can be shared with collaborators. +

    +

    + Step 3: Share the annotation URL, and annotations will be automatically saved in the "annotations" folder within your mounted directory. +

    +

    + Note: Closing this tab will stop the annotation session. Use the "Remote Deployments" feature (coming soon) to maintain active sessions. +

    + +
    +
    )} - {activeTab === "Remote Deployment" && ( -
    -
    - - Remote Deployment is coming soon. + {activeTab === "Community Annotations" && ( + + )} +
    + + {showSessionModal && ( +
    +
    +
    Create Annotation Session
    +
    + + setSessionName(e.target.value)} + /> +
    +
    + + +
    +
    + Public + +
    +
    + +
    - )} +
    + )} - {activeTab === "Browse Deployments" && ( -
    -
    - - Browse Deployments is coming soon. + {showShareModal && ( +
    +
    +
    Share Annotation URL
    +
    + + + Open URL + +
    - )} -
    +
    + )}
    ); } function Sidebar({ onLogin, user, activeTab, onTabClick, hyphaVersion }) { + const [isLoggingIn, setIsLoggingIn] = useState(false); + + const handleLoginClick = async () => { + setIsLoggingIn(true); + await onLogin(); + setIsLoggingIn(false); + }; + return ( -
    -

    Navigation

    +
    +

    BioImage.IO Colab

    {user.email ? ( + Welcome, {user.email} ) : ( )} @@ -522,27 +666,28 @@

    Navigation

    • onTabClick("Local Deployment")} + className={`mb-2 p-2 rounded-md cursor-pointer ${activeTab === "Local Sessions" ? "tab-active" : "bg-hover"}`} + onClick={() => onTabClick("Local Sessions")} > - Local Deployment + Local Sessions
    • onTabClick("Remote Deployment")} > - Remote Deployment + Remote Deployment
    • onTabClick("Browse Deployments")} + className={`mb-2 p-2 rounded-md cursor-pointer ${activeTab === "Community Annotations" ? "tab-active" : "bg-hover"}`} + onClick={() => onTabClick("Community Annotations")} > - Browse Deployments + Community Annotations
    {hyphaVersion && (
    + Hypha Version: {hyphaVersion}
    )} @@ -550,8 +695,59 @@

    Navigation

    ); } + function CommunityAnnotations({ serverUrl, token }) { + const [annotationServices, setAnnotationServices] = useState([]); + + useEffect(() => { + const fetchAnnotations = async () => { + try { + const server = await hyphaWebsocketClient.connectToServer({ + "server_url": serverUrl, + "token": token, + }); + const services = await server.listServices({ "type": "annotation-data-provider" }); + setAnnotationServices(services); + await server.disconnect(); + } catch (error) { + console.error("Error fetching annotation services:", error); + } + }; + + fetchAnnotations(); + }, [serverUrl, token]); + + return ( +
    + {annotationServices.length > 0 ? ( + annotationServices.map((service) => { + const configStr = `{"server_url": "${serverUrl}", "annotation_service_id": "${service.id}", "token": "${token}"}`; + const encodedConfig = encodeURIComponent(configStr); + const annotatorUrl = `https://imjoy.io/lite?plugin=${pluginUrl}&config=${encodedConfig}`; + + return ( +
    +

    {service.name}

    +

    {service.description}

    + + Launch + +
    + ); + }) + ) : ( +

    No annotation sessions available.

    + )} +
    + ); + } + ReactDOM.render(, document.getElementById("app")); - \ No newline at end of file + From f841df768ef784f31278ecb6a5c060e8ed5b5964 Mon Sep 17 00:00:00 2001 From: Wei Ouyang Date: Sun, 25 Aug 2024 22:11:22 -0700 Subject: [PATCH 18/18] disable run docker on pull requests --- .github/workflows/docker-publish.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 4f3472a..5bd35c9 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -4,9 +4,6 @@ on: push: branches: - main # Trigger the workflow on pushes to the main branch - pull_request: - branches: - - main # Trigger the workflow on pull requests to the main branch jobs: build-and-push: