diff --git a/README.md b/README.md new file mode 100644 index 0000000..368decf --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ + +# ome-zarr-catalog + +An app for hosting a catalog of OME-Zarr samples: Images and Plates supported. + +You provide a list of OME-Zarr URLs and the app loads metadata and displays a +zoomable thumbnail of the image in a table. diff --git a/package-lock.json b/package-lock.json index 0c492d4..2fd4d00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@hms-dbmi/viv": "^0.13.3", + "papaparse": "^5.3.2", "react": "^16.14.0", "react-dom": "^16.14.0" }, @@ -2205,6 +2206,11 @@ "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" }, + "node_modules/papaparse": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.3.2.tgz", + "integrity": "sha512-6dNZu0Ki+gyV0eBsFKJhYr+MdQYAzFUGlBMNj3GNrmHxmz1lfRa24CjFObPXtjcetlOv5Ad299MhIK0znp3afw==" + }, "node_modules/parse-headers": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.5.tgz", @@ -4277,6 +4283,11 @@ "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" }, + "papaparse": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.3.2.tgz", + "integrity": "sha512-6dNZu0Ki+gyV0eBsFKJhYr+MdQYAzFUGlBMNj3GNrmHxmz1lfRa24CjFObPXtjcetlOv5Ad299MhIK0znp3afw==" + }, "parse-headers": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.5.tgz", diff --git a/package.json b/package.json index 1d26282..5e1b649 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@hms-dbmi/viv": "^0.13.3", + "papaparse": "^5.3.2", "react": "^16.14.0", "react-dom": "^16.14.0" }, diff --git a/public/csv_examples/idr_study.csv b/public/csv_examples/idr_study.csv new file mode 100644 index 0000000..a12d3af --- /dev/null +++ b/public/csv_examples/idr_study.csv @@ -0,0 +1,14 @@ +Study,URL +idr0054,https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0054A/5025551.zarr +idr0054,https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0054A/5025552.zarr +idr0054,https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0054A/5025553.zarr +idr0076,https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0076A/10501752.zarr +idr0047,https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0047A/4496763.zarr +idr0062,https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0062A/6001240.zarr +idr0052,https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0052A/5514375.zarr +idr0001,https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0001A/2551.zarr +idr0101,https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0101A/13457227.zarr +idr0101,https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0101A/13457537.zarr +idr0101,https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0101A/13457539.zarr +idr0048,https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0048A/9846151.zarr/ +idr0048,https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0048A/9846152.zarr/ \ No newline at end of file diff --git a/public/csv_examples/url_only.csv b/public/csv_examples/url_only.csv new file mode 100644 index 0000000..3fcf71b --- /dev/null +++ b/public/csv_examples/url_only.csv @@ -0,0 +1,19 @@ +URL +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/6001254.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/6001255.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/6001256.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/6001257.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/6001258.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9822151.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9822152.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836831.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836832.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836833.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836834.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836835.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836836.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836837.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836838.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836839.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836840.zarr +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836841.zarr \ No newline at end of file diff --git a/public/zarr_samples.csv b/public/zarr_samples.csv new file mode 100644 index 0000000..6a4d98d --- /dev/null +++ b/public/zarr_samples.csv @@ -0,0 +1,12 @@ +URL,License,Study,DOI,Date added +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0054A/5025553.zarr,CC BY 4.0,idr0054,,2022-06-03 +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0076A/10501752.zarr,CC BY 4.0,idr0076,,2022-06-21 +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0047A/4496763.zarr,CC BY 4.0,idr0047,,2022-06-21 +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0062A/6001240.zarr,CC BY 4.0,idr0062,,2022-06-21 +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0052A/5514375.zarr,CC BY 4.0,idr0052,,2022-06-21 +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0001A/2551.zarr,CC BY 4.0,idr0001,,2022-07-06 +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0101A/13457227.zarr,CC BY 4.0,idr0101,,2022-10-13 +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0101A/13457537.zarr,CC BY 4.0,idr0101,,2022-10-13 +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0101A/13457539.zarr,CC BY 4.0,idr0101,,2022-10-13 +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0048A/9846151.zarr/,CC BY 4.0,idr0048,,2023-01-12 +https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0048A/9846152.zarr/,CC BY 4.0,idr0048,,2023-01-12 \ No newline at end of file diff --git a/public/zarr_samples.json b/public/zarr_samples.json deleted file mode 100644 index 9b8a466..0000000 --- a/public/zarr_samples.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "urls": [ - "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0001A/2551.zarr", - "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0048A/9846152.zarr", - "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.3/9836842.zarr", - "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.2/6001240.zarr", - "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/4495402.zarr", - "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0048A/9846151.zarr" - ] -} diff --git a/src/App.css b/src/App.css index b73bbed..4814ee3 100644 --- a/src/App.css +++ b/src/App.css @@ -1,8 +1,8 @@ #root { - max-width: 1280px; margin: 0 auto; padding: 2rem; text-align: center; + padding: 0; } table { @@ -20,6 +20,8 @@ th { background-color: #f0f0f0; border: 1px solid #e0e0e0; border-bottom: 1px solid #111; + position: sticky; + top: 0; } td { diff --git a/src/App.jsx b/src/App.jsx index 80971f2..64f300f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,35 +1,61 @@ -/// app.js + import React from "react"; -import ImageItem from "./ImageItem"; +import Papa from "papaparse"; + +import CatelogTable from "./CatelogTable"; + +const supportedColumns = [ + "Version", + "Axes", + "shape", + "chunks", + "Wells", + "Fields", + "Keywords", + "Thumbnail", +]; -import zarr_samples_json from "../public/zarr_samples.json"; +const defaultColumns = [ + "Thumbnail" +] -// DeckGL react component export default function App() { - let sources = zarr_samples_json.urls; - - let items = sources.map((source) => ); - - return ( - - - - - - - - - - - - - - - - - {items} - -
Versions3 URLsizeXsizeYsizeZsizeCsizeTAxesWellsFieldsKeywordsThumbnail
- ); + + const [tableData, setTableData] = React.useState([]); + const [tableColumns, setTableColumns] = React.useState([]); + + // check for ?csv=url + const params = new URLSearchParams(location.search); + let csvUrl = params.get("csv"); + // columns=Version,Thumbnail etc from supportedColumns + let cols = params.get("columns"); + let zarrColumns = []; + if (cols) { + zarrColumns = cols.split(",").filter(col => supportedColumns.includes(col)); + } else { + zarrColumns = defaultColumns; + } + try { + new URL(csvUrl); + } catch (error) { + // If no valid URL provided, use default + csvUrl = "/zarr_samples.csv"; + } + + + React.useEffect(() => { + + // load csv and use this for the left side of the table... + Papa.parse(csvUrl, { + header: true, + download: true, + complete: function (results) { + setTableData(results.data); + setTableColumns(results.meta.fields); + }, + }); + }, []); + + return } diff --git a/src/CatelogTable.jsx b/src/CatelogTable.jsx new file mode 100644 index 0000000..142cccb --- /dev/null +++ b/src/CatelogTable.jsx @@ -0,0 +1,52 @@ + +import React from "react"; + +import ImageItem from "./ImageItem"; +import ZarrUrl from "./ZarrUrl"; + + +export default function CatelogTable({tableColumns, tableData, zarrColumns}) { + + function renderRow(rowdata) { + return + {tableColumns.map((col_name) => { + if (col_name == "URL") { + return + } else { + return {rowdata[col_name]} + } + })} + + } + + // ignore any row without a "URL" field + const validRows = tableData.filter(rowdata => rowdata.URL?.length > 0); + + const table_rows = validRows.map((rowdata) => { + // Each row is a combination of custom csv data and NGFF Image data + return ( + {renderRow(rowdata)} + + + ); + }); + + return ( + + + + {tableColumns.map((name) => ( + + ))} + {zarrColumns.map((name) => ( + + ))} + + {table_rows} + +
{name}{name}
+ ); +} diff --git a/src/ImageItem.jsx b/src/ImageItem.jsx index e62c87c..adb6cfa 100644 --- a/src/ImageItem.jsx +++ b/src/ImageItem.jsx @@ -1,27 +1,25 @@ import React from "react"; -import Viewer from "./Viewer"; -import OpenWith from "./OpenWith"; -import CopyButton from "./CopyButton"; -import { loadOmeroMultiscales, open, getNgffAxes } from "./util"; +import Thumbnail from "./Thumbnail"; +import { open, getNgffAxes } from "./util"; +import { openArray } from "zarr"; // DeckGL react component -export default function ImageItem({ source }) { - let config = { source }; - - const [layers, setLayers] = React.useState([]); +export default function ImageItem({ source, zarr_columns }) { + if (source.endsWith("/")) { + source = source.slice(0, -1); + } const [imgInfo, setImageInfo] = React.useState({}); React.useEffect(() => { const fn = async function () { - let node = await open(config.source); + let node = await open(source); let attrs = await node.attrs.asObject(); - console.log("attrs", attrs); let keywords = []; - let wells; - let fields; + let wells = ""; + let fields = ""; // Check if we have a plate or bioformats2raw.layout... let redirectSource; @@ -29,51 +27,52 @@ export default function ImageItem({ source }) { fields = attrs.plate.field_count; wells = attrs.plate.wells.length; let wellPath = source + "/" + attrs.plate.wells[0].path; - let wellJson = await fetch(wellPath + "/.zattrs").then(rsp => rsp.json()); + let wellJson = await fetch(wellPath + "/.zattrs").then((rsp) => + rsp.json() + ); redirectSource = wellPath + "/" + wellJson.well.images[0].path; - } else if (attrs['bioformats2raw.layout']) { + keywords.push("plate"); + } else if (attrs["bioformats2raw.layout"]) { // Use the first image at /0 redirectSource = source + "/0"; } if (redirectSource) { // reload with new source - config = {source: redirectSource} - node = await open(config.source); + source = redirectSource; + node = await open(source); attrs = await node.attrs.asObject(); keywords.push("bioformats2raw.layout"); } - const axes = getNgffAxes(attrs.multiscales); + // If we are showing Keywords, check for labels under image... + if (zarr_columns.includes("Keywords")) { + try { + let labelsJson = await fetch(source + "/labels/.zattrs").then((rsp) => + rsp.json() + ); + keywords.push(`labels (${labelsJson.labels.join(", ")})`); + } catch (err) {} + } - let layerData = await loadOmeroMultiscales(config, node, attrs); + const axes = getNgffAxes(attrs.multiscales); - let shape = layerData.loader[0]._data.meta.shape; + // load first dataset (highest resolution image) to get shape, chunks + let path = attrs.multiscales[0].datasets[0].path; + const store = await openArray({ store: source + "/" + path, mode: "r" }); + let shape = store.meta.shape; + let chunks = store.meta.chunks; - let selections = []; - layerData.channelsVisible.forEach((visible, chIndex) => { - if (visible) { - selections.push( - axes.map((axis, dim) => { - if (axis.type == "time") return 0; - if (axis.name == "z") return parseInt(shape[dim] / 2); - if (axis.name == "c") return chIndex; - return 0; - }) - ); - } - }); - const dims = {}; - axes.forEach((axis, dim) => (dims[axis.name] = shape[dim])); - layerData.selections = selections; - - setLayers([layerData]); setImageInfo({ - dims: dims, - axes: axes.map((axis) => axis.name).join(""), - version: attrs.multiscales?.[0]?.version, - keywords, - wells, - fields + attrs, + Axes: axes.map((axis) => axis.name).join(""), + Version: attrs.multiscales?.[0]?.version, + Keywords: keywords.join(", "), + Wells: wells, + shape: "(" + shape.join(", ") + ")", + chunks: "(" + chunks.join(", ") + ")", + Fields: fields, + // use this source for to handle plate -> Image update + source, }); }; @@ -83,40 +82,35 @@ export default function ImageItem({ source }) { let wrapperStyle = { width: 150, height: 100, - position: "relative", }; - let sizes = ["x", "y", "z", "c", "t"].map((dim) => ( - {imgInfo?.dims?.[dim]} - )); - - let link_style = { - maxWidth: 150, - display: "block", - textOverflow: "ellipsis", - direction: "rtl", - whiteSpace: "nowrap", - overflow: "hidden" + function renderColumn(col_name) { + if (col_name == "Thumbnail") { + return ( +
+ {imgInfo.attrs && ( + + )} +
+ ); + } else { + if (imgInfo[col_name] != undefined) { + return imgInfo[col_name]; + } else { + return Loading... + } + } } return ( - - {imgInfo.version} - - {source} - - - - {sizes} - {imgInfo.axes} - {imgInfo.wells} - {imgInfo.fields} - {imgInfo?.keywords?.join(", ")} - -
- -
- - + + {zarr_columns.map((col_name) => ( + {renderColumn(col_name)} + ))} + ); } diff --git a/src/OpenWith.jsx b/src/OpenWith.jsx index 54a3829..da75034 100644 --- a/src/OpenWith.jsx +++ b/src/OpenWith.jsx @@ -1,6 +1,8 @@ import React from "react"; import openwithJson from "../public/openwith.json"; +// use static import of vizarr_logo.png to get base URL for other logos +import vizarr_logo from "/vizarr_logo.png"; export default function OpenWith({ source }) { let viewers = openwithJson.viewers; @@ -8,8 +10,8 @@ export default function OpenWith({ source }) { return ( {viewers.map((viewer) => ( - - + + ))} diff --git a/src/Thumbnail.jsx b/src/Thumbnail.jsx new file mode 100644 index 0000000..690aee0 --- /dev/null +++ b/src/Thumbnail.jsx @@ -0,0 +1,98 @@ +import React from "react"; + +import { openArray, slice } from "zarr"; +import { + getNgffAxes, + renderTo8bitArray, + getMinMaxValues, + getDefaultVisibilities, + hexToRGB, + getDefaultColors, +} from "./util"; + +export default function Thumbnail({ source, attrs }) { + // const [r, setChunk] = React.useState(); + + const [canvasSize, setCanvasSize] = React.useState({ + width: 100, + height: 100, + }); + + const canvas = React.useRef(); + + React.useEffect(() => { + const fn = async function () { + let paths = attrs.multiscales[0].datasets.map((d) => d.path); + let axes = getNgffAxes(attrs.multiscales).map((a) => a.name); + + let path = paths.at(-1); + const store = await openArray({ store: source + "/" + path, mode: "r" }); + + let chDim = axes.indexOf("c"); + + let shape = store.meta.shape; + let dims = shape.length; + let ch = store.meta.chunks; + + let channel_count = shape[chDim]; + let visibilities; + let colors; + if (attrs?.omero?.channels) { + visibilities = attrs.omero.channels.map((ch) => ch.active); + colors = attrs.omero.channels.map((ch) => hexToRGB(ch.color)); + } else { + visibilities = getDefaultVisibilities(channel_count); + colors = getDefaultColors(channel_count, visibilities); + } + // filter for active channels + colors = colors.filter((col, idx) => visibilities[idx]); + + let activeChannels = visibilities.reduce((prev, active, index) => { + if (active) prev.push(index); + return prev; + }, []); + + let promises = activeChannels.map((chIndex) => { + let indecies = shape.map((dimSize, index) => { + // channel + if (index == chDim) return chIndex; + // x and y + if (index >= dims - 2) { + return slice(0, dimSize); + } + // z + if (axes[index] == "z") { + return parseInt(dimSize / 2); + } + return 0; + }); + return store.get(indecies); + }); + + let ndChunks = await Promise.all(promises); + + let minMaxValues = ndChunks.map((ch) => getMinMaxValues(ch)); + + let rbgData = renderTo8bitArray(ndChunks, minMaxValues, colors); + + // setChunk(data); + let width = shape.at(-1); + let height = shape.at(-2); + setCanvasSize({ width, height }); + + const ctx = canvas.current.getContext("2d"); + ctx.putImageData(new ImageData(rbgData, width, height), 0, 0); + }; + + fn(); + }, []); + + return ( + + ); +} diff --git a/src/ZarrUrl.jsx b/src/ZarrUrl.jsx new file mode 100644 index 0000000..1f2f219 --- /dev/null +++ b/src/ZarrUrl.jsx @@ -0,0 +1,25 @@ +import React from "react"; + +import CopyButton from "./CopyButton"; +import OpenWith from "./OpenWith"; + +export default function ZarrUrl({ source }) { + let link_style = { + maxWidth: 150, + display: "block", + textOverflow: "ellipsis", + direction: "rtl", + whiteSpace: "nowrap", + overflow: "hidden", + }; + + return ( + + + {source} + + + + + ); +} diff --git a/src/util.js b/src/util.js index 8ac39f9..1e39901 100644 --- a/src/util.js +++ b/src/util.js @@ -297,3 +297,62 @@ export async function loadOmeroMultiscales(config, zarrGroup, attrs) { name: meta.name ?? name, }; } + + +export function renderTo8bitArray(ndChunks, minMaxValues, colors) { + // Render chunks (array) into 2D 8-bit data for new ImageData(arr) + // ndChunks is list of zarr arrays + + // assume all chunks are same shape + const shape = ndChunks[0].shape; + const height = shape[0]; + const width = shape[1]; + + if (!minMaxValues) { + minMaxValues = ndChunks.map(getMinMaxValues); + } + + // let rgb = [255, 255, 255]; + + const rgba = new Uint8ClampedArray(4 * height * width).fill(0); + let offset = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + for (let p = 0; p < ndChunks.length; p++) { + let rgb = colors[p]; + let data = ndChunks[p].data; + let range = minMaxValues[p]; + let rawValue = data[y][x]; + let fraction = (rawValue - range[0]) / (range[1] - range[0]); + // for red, green, blue, + for (let i = 0; i < 3; i++) { + // rgb[i] is 0-255... + let v = (fraction * rgb[i]) << 0; + // increase pixel intensity if value is higher + rgba[offset * 4 + i] = Math.max(rgba[offset * 4 + i], v); + } + } + rgba[offset * 4 + 3] = 255; // alpha + offset += 1; + } + } + return rgba; +} + +export function getMinMaxValues(chunk2d) { + const shape = chunk2d.shape; + const height = shape[0]; + const width = shape[1]; + const data = chunk2d.data; + let maxV = 0; + let minV = Infinity; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let rawValue = data[y][x]; + maxV = Math.max(maxV, rawValue); + minV = Math.min(minV, rawValue); + } + } + return [minV, maxV]; +} +