lysine commited on
Commit
d0af1c9
·
0 Parent(s):

first commit

Browse files
.dockerignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ node_modules
2
+ npm-debug.log
.env.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ PORT=7860
2
+ VITE_SERVER_URL=http://localhost:7860/
.eslintignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ node_modules
2
+ .DS_Store
3
+ dist
4
+ dist-ssr
5
+ *.local
6
+ .eslintcache
.eslintrc.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "env": {
3
+ "browser": true,
4
+ "es2021": true,
5
+ "node": true
6
+ },
7
+ "extends": [
8
+ "eslint:recommended",
9
+ "plugin:@typescript-eslint/recommended",
10
+ "prettier"
11
+ ],
12
+ "parser": "@typescript-eslint/parser",
13
+ "parserOptions": {
14
+ "ecmaVersion": 12,
15
+ "sourceType": "module"
16
+ },
17
+ "plugins": ["@typescript-eslint"],
18
+ "rules": {
19
+ "@typescript-eslint/no-explicit-any": "off"
20
+ }
21
+ }
.github/workflows/hugging-face.yml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face hub
2
+ on:
3
+ push:
4
+ branches: [main]
5
+
6
+ # to run this workflow manually from the Actions tab
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ sync-to-hub:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v3
14
+ with:
15
+ fetch-depth: 0
16
+ - name: Push to hub
17
+ env:
18
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
19
+ run: git push https://lysine:[email protected]/spaces/lysine/marrow-cells main
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .DS_Store
3
+ dist
4
+ dist-ssr
5
+ *.local
6
+ .eslintcache
7
+ .env
.prettierignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ node_modules
2
+ .DS_Store
3
+ dist
4
+ dist-ssr
5
+ *.local
6
+ .eslintcache
.prettierrc.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "singleQuote": true,
3
+ "arrowParens": "avoid",
4
+ "tabWidth": 2,
5
+ "trailingComma": "es5"
6
+ }
Dockerfile ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ARG VITE_SERVER_URL
2
+
3
+ FROM node:18
4
+
5
+ # Set up a new user named "user" with user ID 1000
6
+ RUN useradd -o -u 1000 user
7
+
8
+ # Install pip
9
+ RUN apt-get update && apt-get install -y \
10
+ curl \
11
+ git \
12
+ python3.11 \
13
+ python3-pip
14
+
15
+ # Install kaggle silently
16
+ RUN yes | pip3 install kaggle --exists-action i --break-system-packages
17
+
18
+ # Switch to the "user" user
19
+ USER user
20
+
21
+ # Set home to the user's home directory
22
+ ENV HOME=/home/user \
23
+ PATH=/home/user/.local/bin:$PATH
24
+
25
+ # Set the working directory to the user's home directory
26
+ WORKDIR $HOME/app
27
+
28
+ # Copy the current directory contents into the container at $HOME/app setting the owner to the user
29
+ COPY --chown=user . $HOME/app
30
+
31
+ # Install npm dependencies
32
+ RUN npm install
33
+
34
+ # Build client and server
35
+ RUN export VITE_SERVER_URL=$MODEL_REPO_NAME && npm run build
36
+
37
+ # Download bone marrow cell dataset from Kaggle
38
+ RUN --mount=type=secret,id=KAGGLE_USERNAME,mode=0444,required=true \
39
+ --mount=type=secret,id=KAGGLE_KEY,mode=0444,required=true \
40
+ export KAGGLE_USERNAME=$(cat /run/secrets/KAGGLE_USERNAME) && \
41
+ export KAGGLE_KEY=$(cat /run/secrets/KAGGLE_KEY) && \
42
+ kaggle datasets download -d andrewmvd/bone-marrow-cell-classification --unzip -p $HOME/app/dist/app/marrow-cell-data
43
+
44
+ EXPOSE 7860
45
+ CMD [ "npm", "run", "start" ]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Henry Lin
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.
README.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: marrow-cells
3
+ emoji: 🦴
4
+ colorFrom: red
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # marrow-cells
11
+
12
+ A single-page web app presenting bone marrow cells from the Bone Marrow Cell Classification dataset.
13
+
14
+ [![Open marrow-cells on HF Spaces](https://huggingface.co/datasets/huggingface/badges/raw/main/open-in-hf-spaces-xl-dark.svg)](https://lysine-marrow-cells.hf.space/)
15
+
16
+ This app features over 170,000 individual cells from the dataset. You can compare 2 classes of cells side by side or try to identify an unlabelled set of cells.
17
+
18
+ ## License
19
+
20
+ This project is licensed under the MIT License - see the LICENSE file for details.
21
+
22
+ ## Acknowledgments
23
+
24
+ * Thanks [Bone Marrow Cell Classification](https://www.kaggle.com/datasets/andrewmvd/bone-marrow-cell-classification) for providing cell images and labels.
25
+ * Thanks [kaggle](https://www.kaggle.com/) for hosting the dataset and providing convenient tools to download it.
26
+ * Thanks [Hugging Face](https://huggingface.co/) for hosting the app online for free.
index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/src/app/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Bone Marrow Cell Database</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/app/main.tsx"></script>
12
+ </body>
13
+ </html>
nodemon.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "watch": ["src"],
3
+ "ext": "js,jsx,ts,tsx,json",
4
+ "exec": "ts-node --esm --project tsconfig.server.json --experimental-specifier-resolution=node ./src/server"
5
+ }
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "marrow-cells",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "concurrently \"npm run server:dev\" \"npm run client:dev\"",
7
+ "client:dev": "vite",
8
+ "server:dev": "nodemon",
9
+ "server:build": "tsc --project tsconfig.server.json",
10
+ "client:build": "vite build",
11
+ "build": "npm run server:build && npm run client:build",
12
+ "serve": "vite preview",
13
+ "test": "tsc && prettier --check . && eslint . && stylelint \"**/*.css\"",
14
+ "start": "node --experimental-specifier-resolution=node dist/server.js",
15
+ "docker": "npm run docker:build && npm run docker:run",
16
+ "docker:build": "docker build -t auscultate .",
17
+ "docker:run": "docker run -it -p 7860:7860 auscultate",
18
+ "lint": "eslint **/*.{js,jsx,ts,tsx} --fix --cache",
19
+ "format": "prettier **/*.{js,jsx,ts,tsx,css,html,json,md,mdx} --write"
20
+ },
21
+ "devDependencies": {
22
+ "@babel/core": "^7.22.10",
23
+ "@babel/node": "^7.22.10",
24
+ "@babel/preset-env": "^7.22.10",
25
+ "@types/cors": "^2.8.13",
26
+ "@types/express": "^4.17.17",
27
+ "@types/node": "^20.4.9",
28
+ "@types/react": "^18.2.20",
29
+ "@types/react-dom": "^18.2.7",
30
+ "@types/react-helmet": "^6.1.6",
31
+ "@types/react-router-dom": "^5.3.3",
32
+ "@typescript-eslint/eslint-plugin": "^6.3.0",
33
+ "@typescript-eslint/parser": "^6.3.0",
34
+ "@vitejs/plugin-react-refresh": "^1.3.6",
35
+ "autoprefixer": "^10.4.14",
36
+ "concurrently": "^8.2.0",
37
+ "daisyui": "^3.5.1",
38
+ "eslint": "^8.46.0",
39
+ "eslint-config-prettier": "^9.0.0",
40
+ "http-proxy-middleware": "^2.0.6",
41
+ "nodemon": "^3.0.1",
42
+ "postcss": "^8.4.27",
43
+ "prettier": "3.0.1",
44
+ "tailwindcss": "^3.3.3",
45
+ "ts-node": "^10.9.1",
46
+ "typescript": "^5.1.6",
47
+ "vite": "^4.4.9"
48
+ },
49
+ "dependencies": {
50
+ "@hapi/boom": "^10.0.1",
51
+ "axios": "^1.4.0",
52
+ "cors": "^2.8.5",
53
+ "dotenv": "^16.3.1",
54
+ "express": "^4.18.2",
55
+ "glob": "^10.3.3",
56
+ "react": "^18.2.0",
57
+ "react-dom": "^18.2.0",
58
+ "react-helmet": "^6.1.0",
59
+ "react-router-dom": "^6.15.0",
60
+ "readable-regexp": "^1.3.4",
61
+ "zod": "^3.21.4"
62
+ }
63
+ }
postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
src/app/App.tsx ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react';
2
+ import { redirect } from 'react-router-dom';
3
+ import { createBrowserRouter, RouterProvider } from 'react-router-dom';
4
+ import Compare from './marrow-cells/Compare';
5
+ // import Identify from './marrow-cells/Identify';
6
+ import { Helmet } from 'react-helmet';
7
+ import { CellTypes } from '../marrow-cell-types';
8
+ import { getTypes } from './marrow-cells/api';
9
+ import { DataProvider } from './DataContext';
10
+
11
+ function Redirect({ to }: { to: string }) {
12
+ useEffect(() => {
13
+ redirect(to);
14
+ }, []);
15
+ return null;
16
+ }
17
+
18
+ const router = createBrowserRouter([
19
+ {
20
+ path: '/',
21
+ element: <Redirect to="/compare" />,
22
+ },
23
+ {
24
+ path: '/compare',
25
+ element: <Compare />,
26
+ },
27
+ // {
28
+ // path: '/identify',
29
+ // element: <Identify />,
30
+ // },
31
+ ]);
32
+
33
+ export default function App() {
34
+ const [cellTypes, setCellTypes] = useState<CellTypes>({});
35
+
36
+ useEffect(() => {
37
+ getTypes().then(setCellTypes);
38
+ }, []);
39
+
40
+ return (
41
+ <>
42
+ <div className="p-8 flex flex-col gap-2">
43
+ <Helmet>
44
+ <title>Bone Marrow Cell Database</title>
45
+ </Helmet>
46
+ <div className="text-sm breadcrumbs flex justify-center w-full">
47
+ <ul>
48
+ <li>
49
+ <a href="https://lysine-med.hf.space/">Med</a>
50
+ </li>
51
+ <li>Marrow Cells</li>
52
+ </ul>
53
+ </div>
54
+ <p className="text-3xl text-center">Bone Marrow Cell Database</p>
55
+ <p className="text-center">
56
+ Filter and access bone marrow cell images from the Bone Marrow Cell
57
+ Classification dataset.
58
+ </p>
59
+ <p className="font-bold">Points to note</p>
60
+ <ul className="list-disc">
61
+ <li>Classification of the cells may not be 100% accurate.</li>
62
+ <li>
63
+ Not all cells are identifiable, and not all identified cells are
64
+ classified into a specific cell type.
65
+ </li>
66
+ </ul>
67
+ {cellTypes ? (
68
+ <DataProvider data={{ cellTypes }}>
69
+ <RouterProvider router={router} />
70
+ </DataProvider>
71
+ ) : (
72
+ <div className="alert">
73
+ <svg
74
+ xmlns="http://www.w3.org/2000/svg"
75
+ fill="none"
76
+ viewBox="0 0 24 24"
77
+ className="stroke-info shrink-0 w-6 h-6"
78
+ >
79
+ <path
80
+ stroke-linecap="round"
81
+ stroke-linejoin="round"
82
+ stroke-width="2"
83
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
84
+ ></path>
85
+ </svg>
86
+ <span>Loading cell types...</span>
87
+ </div>
88
+ )}
89
+ </div>
90
+ </>
91
+ );
92
+ }
src/app/DataContext.tsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { PropsWithChildren, createContext, useContext } from 'react';
2
+ import { CellTypes } from '../marrow-cell-types';
3
+
4
+ interface AppData {
5
+ cellTypes: CellTypes;
6
+ }
7
+
8
+ const dataContext = createContext<AppData>({
9
+ cellTypes: {},
10
+ });
11
+
12
+ export function useData() {
13
+ return useContext(dataContext);
14
+ }
15
+
16
+ interface DataProviderProps {
17
+ data: AppData;
18
+ }
19
+
20
+ export function DataProvider({
21
+ children,
22
+ data,
23
+ }: PropsWithChildren<DataProviderProps>) {
24
+ return <dataContext.Provider value={data}>{children}</dataContext.Provider>;
25
+ }
src/app/favicon.svg ADDED
src/app/globals.css ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ body {
6
+ margin: 0;
7
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
8
+ Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
9
+ -webkit-font-smoothing: antialiased;
10
+ -moz-osx-font-smoothing: grayscale;
11
+ }
12
+
13
+ code {
14
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
15
+ monospace;
16
+ }
src/app/main.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import './globals.css';
4
+ import App from './App';
5
+
6
+ const container = document.querySelector('#root');
7
+ const root = createRoot(container!);
8
+ root.render(
9
+ <React.StrictMode>
10
+ <App />
11
+ </React.StrictMode>
12
+ );
src/app/marrow-cells/Compare.tsx ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react';
2
+ import { useSearchParams } from 'react-router-dom';
3
+ import { RandomResult } from '../../marrow-cell-types';
4
+ import { getImages } from './api';
5
+ import { useData } from '../DataContext';
6
+
7
+ interface CompareParams {
8
+ type1?: string;
9
+ type2?: string;
10
+ }
11
+
12
+ function compareToParams(filter: CompareParams): URLSearchParams {
13
+ const params = new URLSearchParams();
14
+ if (filter.type1) {
15
+ params.append('type1', filter.type1);
16
+ }
17
+ if (filter.type2) {
18
+ params.append('type2', filter.type2);
19
+ }
20
+ return params;
21
+ }
22
+
23
+ function paramsToCompare(params: URLSearchParams): CompareParams {
24
+ const newFilter: CompareParams = {};
25
+ if (params.has('type1')) {
26
+ newFilter.type1 = params.get('type1')!;
27
+ }
28
+ if (params.has('type2')) {
29
+ newFilter.type2 = params.get('type2')!;
30
+ }
31
+ return newFilter;
32
+ }
33
+
34
+ export default function Compare(): JSX.Element {
35
+ const { cellTypes } = useData();
36
+ const [searchParams, setSearchParams] = useSearchParams();
37
+
38
+ const [cellType1, setCellType1] = useState<string>();
39
+ const [imageSet1, setImageSet1] = useState<RandomResult>();
40
+ const [loading1, setLoading1] = useState<boolean>(false);
41
+ const [cellType2, setCellType2] = useState<string>();
42
+ const [imageSet2, setImageSet2] = useState<RandomResult>();
43
+ const [loading2, setLoading2] = useState<boolean>(false);
44
+
45
+ const loadImages = (cell: 1 | 2) => {
46
+ if (cell === 1) {
47
+ if (!cellType1) return;
48
+ setLoading1(true);
49
+ } else {
50
+ if (!cellType2) return;
51
+ setLoading2(true);
52
+ }
53
+ getImages({ type: [cell === 1 ? cellType1! : cellType2!] })
54
+ .then(result => {
55
+ if (cell === 1) {
56
+ setImageSet1(result);
57
+ } else {
58
+ setImageSet2(result);
59
+ }
60
+ })
61
+ .finally(() => {
62
+ if (cell === 1) {
63
+ setLoading1(false);
64
+ } else {
65
+ setLoading2(false);
66
+ }
67
+ });
68
+ };
69
+
70
+ useEffect(() => {
71
+ loadImages(1);
72
+ }, [cellType1]);
73
+
74
+ useEffect(() => {
75
+ loadImages(2);
76
+ }, [cellType2]);
77
+
78
+ useEffect(() => {
79
+ const newFilter = paramsToCompare(searchParams);
80
+ setCellType1(newFilter.type1);
81
+ setCellType2(newFilter.type2);
82
+ }, [searchParams]);
83
+
84
+ const updateParams = (
85
+ type1: string | undefined,
86
+ type2: string | undefined
87
+ ) => {
88
+ setSearchParams(compareToParams({ type1, type2 }));
89
+ };
90
+
91
+ return (
92
+ <>
93
+ <div className="tabs">
94
+ <a
95
+ className={`tab tab-bordered ${
96
+ location.pathname.startsWith('/compare') ? 'tab-active' : ''
97
+ }`}
98
+ >
99
+ Compare
100
+ </a>
101
+ <a
102
+ className={`tab tab-bordered ${
103
+ location.pathname.startsWith('/compare') ? 'tab-active' : ''
104
+ }`}
105
+ >
106
+ Identify
107
+ </a>
108
+ </div>
109
+ <div className="flex w-full items-center gap-4">
110
+ <div className="flex flex-col gap-4">
111
+ <div className="form-control w-full max-w-xs">
112
+ <select
113
+ className={`select select-bordered ${
114
+ loading1 ? 'animate-pulse' : ''
115
+ }`}
116
+ disabled={loading1}
117
+ onChange={e => updateParams(e.target.value, cellType2)}
118
+ >
119
+ <option disabled selected>
120
+ Select a cell type
121
+ </option>
122
+ {Object.entries(cellTypes).map(([key, value]) => (
123
+ <option key={key} value={key}>
124
+ {value}
125
+ </option>
126
+ ))}
127
+ </select>
128
+ <label className="label">
129
+ <span
130
+ className={`label-text-alt ${!imageSet1 ? 'invisible' : ''}`}
131
+ >
132
+ {imageSet1?.count ?? 0}
133
+ </span>
134
+ </label>
135
+ </div>
136
+ <div className="flex flex-wrap gap-4">
137
+ {imageSet1?.images.map(image => (
138
+ <img key={image} src={image} className="shadow-lg" alt="Cell" />
139
+ ))}
140
+ </div>
141
+ </div>
142
+ <div className="divider divider-horizontal"></div>
143
+ <div className="flex flex-col gap-4">
144
+ <div className="form-control w-full max-w-xs">
145
+ <select
146
+ className={`select select-bordered ${
147
+ loading2 ? 'animate-pulse' : ''
148
+ }`}
149
+ disabled={loading2}
150
+ onChange={e => updateParams(e.target.value, cellType2)}
151
+ >
152
+ <option disabled selected>
153
+ Select a cell type
154
+ </option>
155
+ {Object.entries(cellTypes).map(([key, value]) => (
156
+ <option key={key} value={key}>
157
+ {value}
158
+ </option>
159
+ ))}
160
+ </select>
161
+ <label className="label">
162
+ <span
163
+ className={`label-text-alt ${!imageSet2 ? 'invisible' : ''}`}
164
+ >
165
+ {imageSet2?.count ?? 0}
166
+ </span>
167
+ </label>
168
+ </div>
169
+ <div className="flex flex-wrap gap-4">
170
+ {imageSet2?.images.map(image => (
171
+ <img key={image} src={image} className="shadow-lg" alt="Cell" />
172
+ ))}
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </>
177
+ );
178
+ }
src/app/marrow-cells/Identify.tsx ADDED
@@ -0,0 +1,518 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Link, useSearchParams } from 'react-router-dom';
3
+ import { Helmet } from 'react-helmet';
4
+ import { FilterParams } from '../../marrow-cell-types';
5
+ import { getDataUrl, getImages, getTypes } from './api';
6
+
7
+ function filterToParams(filter: FilterParams): URLSearchParams {
8
+ const params = new URLSearchParams();
9
+ if (filter.type) {
10
+ filter.type.forEach(type => params.append('type', type));
11
+ }
12
+ return params;
13
+ }
14
+
15
+ function paramsToFilter(params: URLSearchParams): FilterParams {
16
+ const newFilter: FilterParams = {};
17
+ if (params.has('type')) {
18
+ newFilter.type = params.getAll('type') as string[];
19
+ }
20
+ return newFilter;
21
+ }
22
+
23
+ export default function Identify(): JSX.Element {
24
+ const [searchParams, setSearchParams] = useSearchParams();
25
+
26
+ const [patientId, setPatientId] = useState<number | null>(null);
27
+ const [resultCount, setResultCount] = useState<number>(-1);
28
+
29
+ const [filterParams, setFilterParams] = useState<FilterParams>({});
30
+
31
+ const [patient, setPatient] = useState<FullPatient | null>(null);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const [loading, setLoading] = useState<boolean>(false);
34
+
35
+ const [audioZoom, setAudioZoom] = useState(100);
36
+ const [regionsLevel, setRegionsLevel] = useState<RegionsLevel>(
37
+ RegionsLevel.None
38
+ );
39
+ const [showAnswer, setShowAnswer] = useState(false);
40
+ const [spectrogram, setSpectrogram] = useState(false);
41
+
42
+ const getRandom = () => {
43
+ setLoading(true);
44
+ getRandomPatient(filterParams)
45
+ .then(result => {
46
+ setPatientId(result.patientId);
47
+ setResultCount(result.count);
48
+ setSearchParams(p => {
49
+ p.set('id', result.patientId.toString());
50
+ return p;
51
+ });
52
+ })
53
+ .catch(err => {
54
+ if (err.response.status === 404) {
55
+ setResultCount(0);
56
+ }
57
+ })
58
+ .finally(() => {
59
+ setLoading(false);
60
+ });
61
+ };
62
+
63
+ const loadPatient = () => {
64
+ setLoading(true);
65
+ getPatient(patientId!)
66
+ .then(patient => {
67
+ setPatient(patient);
68
+ setError(null);
69
+ })
70
+ .catch(err => {
71
+ setPatient(null);
72
+ setError(err.response.data.message);
73
+ })
74
+ .finally(() => {
75
+ setLoading(false);
76
+ });
77
+ };
78
+
79
+ useEffect(() => {
80
+ setShowAnswer(false);
81
+ setFilterParams(paramsToFilter(searchParams));
82
+ if (patientId === null) {
83
+ if (searchParams.has('id')) {
84
+ setPatientId(Number(searchParams.get('id')));
85
+ } else {
86
+ getRandom();
87
+ }
88
+ } else {
89
+ loadPatient();
90
+ }
91
+ }, [searchParams, patientId]);
92
+
93
+ const randomClicked = () => {
94
+ setSearchParams(filterToParams(filterParams));
95
+ setPatientId(null);
96
+ };
97
+
98
+ const arrayToggle = <T extends string>(
99
+ array: Exclude<keyof FilterParams, 'murmur'>,
100
+ value: T,
101
+ checked: boolean
102
+ ): void => {
103
+ setFilterParams(f => {
104
+ if (checked) {
105
+ return {
106
+ ...f,
107
+ [array]: [...(f[array] ?? []), value],
108
+ };
109
+ } else {
110
+ return {
111
+ ...f,
112
+ [array]: (f[array] as string[] | null)?.filter(v => v !== value),
113
+ };
114
+ }
115
+ });
116
+ };
117
+
118
+ return (
119
+ <div className="p-8 flex flex-col gap-2">
120
+ <Helmet>
121
+ <title>Heart Sounds Database - auscultate</title>
122
+ </Helmet>
123
+ <div className="text-sm breadcrumbs flex justify-center w-full">
124
+ <ul>
125
+ <li>
126
+ <a href="https://lysine-med.hf.space/">Med</a>
127
+ </li>
128
+ <li>
129
+ <Link to="/">Auscultation</Link>
130
+ </li>
131
+ <li>Heart Sounds</li>
132
+ </ul>
133
+ </div>
134
+ <p className="text-3xl text-center">Heart Sounds Database</p>
135
+ <p className="text-center">
136
+ Filter and access heart sounds from the CirCor DigiScope Phonocardiogram
137
+ Dataset.
138
+ </p>
139
+ <p className="font-bold">Points to note</p>
140
+ <ul className="list-disc">
141
+ <li>
142
+ The provided analysis may not be 100% accurate and may not be the only
143
+ abnormalities found.
144
+ </li>
145
+ <li>
146
+ This dataset only records murmur. Other heart sound abnormalities are
147
+ not considered.
148
+ </li>
149
+ <li>
150
+ A patient with no detected murmur may still have other undocumented
151
+ abnormalities.
152
+ </li>
153
+ </ul>
154
+ <div className="collapse collapse-arrow bg-base-200 my-4">
155
+ <input type="checkbox" />
156
+ <div className="collapse-title text-xl font-medium">Select Filters</div>
157
+ <div className="collapse-content">
158
+ <form className="bg-base-200 p-4 pt-0 flex flex-col items-center w-full">
159
+ <fieldset className="w-full" disabled={loading}>
160
+ <div className="divider">Auscultation location</div>
161
+ <div className="flex flex-wrap gap-4 justify-center">
162
+ {Object.values(Location).map(loc => (
163
+ <div key={loc} className="form-control">
164
+ <label className="label cursor-pointer gap-2">
165
+ <input
166
+ type="checkbox"
167
+ checked={filterParams.location?.includes(loc) ?? false}
168
+ onChange={e =>
169
+ arrayToggle('location', loc, e.target.checked)
170
+ }
171
+ className="checkbox"
172
+ />
173
+ <span className="label-text">{nameLocation(loc)}</span>
174
+ </label>
175
+ </div>
176
+ ))}
177
+ </div>
178
+
179
+ <div className="divider">Murmur Type</div>
180
+ <div className="flex flex-wrap gap-4 justify-center">
181
+ <div className="form-control">
182
+ <label className="label cursor-pointer gap-2">
183
+ <input
184
+ type="radio"
185
+ className="radio"
186
+ checked={!filterParams.murmur}
187
+ onChange={e => {
188
+ if (e.target.checked) {
189
+ setFilterParams(f => ({ ...f, murmur: undefined }));
190
+ }
191
+ }}
192
+ />
193
+ <span className="label-text">No filter</span>
194
+ </label>
195
+ </div>
196
+ {Object.values(MurmurFilter).map(filter => (
197
+ <div key={filter} className="form-control">
198
+ <label className="label cursor-pointer gap-2">
199
+ <input
200
+ type="radio"
201
+ className="radio"
202
+ checked={filterParams.murmur === filter}
203
+ onChange={e => {
204
+ if (e.target.checked) {
205
+ setFilterParams(f => ({ ...f, murmur: filter }));
206
+ }
207
+ }}
208
+ />
209
+ <span className="label-text">{nameMurmur(filter)}</span>
210
+ </label>
211
+ </div>
212
+ ))}
213
+ </div>
214
+
215
+ <div className="divider">Murmur location</div>
216
+ <div className="flex flex-wrap gap-4 justify-center">
217
+ {Object.values(Location).map(loc => (
218
+ <div key={loc} className="form-control">
219
+ <label className="label cursor-pointer gap-2">
220
+ <input
221
+ type="checkbox"
222
+ checked={
223
+ filterParams.murmurLocation?.includes(loc) ?? false
224
+ }
225
+ onChange={e =>
226
+ arrayToggle('murmurLocation', loc, e.target.checked)
227
+ }
228
+ className="checkbox"
229
+ />
230
+ <span className="label-text">{nameLocation(loc)}</span>
231
+ </label>
232
+ </div>
233
+ ))}
234
+ </div>
235
+
236
+ <div className="divider">Murmur Timing</div>
237
+ <div className="flex flex-wrap gap-4 justify-center">
238
+ {Object.values(MurmurTiming).map(loc => (
239
+ <div key={loc} className="form-control">
240
+ <label className="label cursor-pointer gap-2">
241
+ <input
242
+ type="checkbox"
243
+ checked={filterParams.timing?.includes(loc) ?? false}
244
+ onChange={e =>
245
+ arrayToggle('timing', loc, e.target.checked)
246
+ }
247
+ className="checkbox"
248
+ />
249
+ <span className="label-text">
250
+ {nameTiming(
251
+ loc,
252
+ ['systolic', 'diastolic'].includes(
253
+ filterParams.murmur ?? ''
254
+ )
255
+ ? (filterParams.murmur as 'systolic' | 'diastolic')
256
+ : 'general'
257
+ )}
258
+ </span>
259
+ </label>
260
+ </div>
261
+ ))}
262
+ </div>
263
+
264
+ <div className="divider">Murmur Shape</div>
265
+ <div className="flex flex-wrap gap-4 justify-center">
266
+ {Object.values(MurmurShape).map(loc => (
267
+ <div key={loc} className="form-control">
268
+ <label className="label cursor-pointer gap-2">
269
+ <input
270
+ type="checkbox"
271
+ checked={filterParams.shape?.includes(loc) ?? false}
272
+ onChange={e =>
273
+ arrayToggle('shape', loc, e.target.checked)
274
+ }
275
+ className="checkbox"
276
+ />
277
+ <span className="label-text">{loc}</span>
278
+ </label>
279
+ </div>
280
+ ))}
281
+ </div>
282
+
283
+ <div className="divider">Murmur Grading</div>
284
+ <div className="flex flex-wrap gap-4 justify-center">
285
+ {Object.values(MurmurGrading).map(loc => (
286
+ <div key={loc} className="form-control">
287
+ <label className="label cursor-pointer gap-2">
288
+ <input
289
+ type="checkbox"
290
+ checked={filterParams.grading?.includes(loc) ?? false}
291
+ onChange={e =>
292
+ arrayToggle('grading', loc, e.target.checked)
293
+ }
294
+ className="checkbox"
295
+ />
296
+ <span className="label-text">
297
+ {nameGrading(
298
+ loc,
299
+ ['systolic', 'diastolic'].includes(
300
+ filterParams.murmur ?? ''
301
+ )
302
+ ? (filterParams.murmur as 'systolic' | 'diastolic')
303
+ : 'general'
304
+ )}
305
+ </span>
306
+ </label>
307
+ </div>
308
+ ))}
309
+ </div>
310
+
311
+ <div className="divider">Murmur Pitch</div>
312
+ <div className="flex flex-wrap gap-4 justify-center">
313
+ {Object.values(MurmurPitch).map(loc => (
314
+ <div key={loc} className="form-control">
315
+ <label className="label cursor-pointer gap-2">
316
+ <input
317
+ type="checkbox"
318
+ checked={filterParams.pitch?.includes(loc) ?? false}
319
+ onChange={e =>
320
+ arrayToggle('pitch', loc, e.target.checked)
321
+ }
322
+ className="checkbox"
323
+ />
324
+ <span className="label-text">{loc}</span>
325
+ </label>
326
+ </div>
327
+ ))}
328
+ </div>
329
+
330
+ <div className="divider">Murmur Quality</div>
331
+ <div className="flex flex-wrap gap-4 justify-center">
332
+ {Object.values(MurmurQuality).map(loc => (
333
+ <div key={loc} className="form-control">
334
+ <label className="label cursor-pointer gap-2">
335
+ <input
336
+ type="checkbox"
337
+ checked={filterParams.quality?.includes(loc) ?? false}
338
+ onChange={e =>
339
+ arrayToggle('quality', loc, e.target.checked)
340
+ }
341
+ className="checkbox"
342
+ />
343
+ <span className="label-text">{loc}</span>
344
+ </label>
345
+ </div>
346
+ ))}
347
+ </div>
348
+
349
+ <div className="divider">Heart Outcome</div>
350
+ <div className="flex flex-wrap gap-4 justify-center">
351
+ <div className="form-control">
352
+ <label className="label cursor-pointer gap-2">
353
+ <input
354
+ type="radio"
355
+ className="radio"
356
+ checked={!filterParams.outcome}
357
+ onChange={e => {
358
+ if (e.target.checked) {
359
+ setFilterParams(f => ({ ...f, outcome: undefined }));
360
+ }
361
+ }}
362
+ />
363
+ <span className="label-text">No filter</span>
364
+ </label>
365
+ </div>
366
+ {Object.values(Outcome).map(filter => (
367
+ <div key={filter} className="form-control">
368
+ <label className="label cursor-pointer gap-2">
369
+ <input
370
+ type="radio"
371
+ className="radio"
372
+ checked={filterParams.outcome === filter}
373
+ onChange={e => {
374
+ if (e.target.checked) {
375
+ setFilterParams(f => ({ ...f, outcome: filter }));
376
+ }
377
+ }}
378
+ />
379
+ <span className="label-text">{filter}</span>
380
+ </label>
381
+ </div>
382
+ ))}
383
+ </div>
384
+ </fieldset>
385
+ </form>
386
+ </div>
387
+
388
+ <button
389
+ className="btn btn-primary"
390
+ onClick={randomClicked}
391
+ disabled={loading}
392
+ >
393
+ {loading ? (
394
+ <span className="loading loading-spinner loading-sm"></span>
395
+ ) : (
396
+ 'Random Patient'
397
+ )}
398
+ </button>
399
+ </div>
400
+ {resultCount < 0 ? null : (
401
+ <p>{resultCount} patients with the selected filters.</p>
402
+ )}
403
+ {error === null ? null : (
404
+ <div className="alert alert-error">
405
+ <svg
406
+ xmlns="http://www.w3.org/2000/svg"
407
+ className="stroke-current shrink-0 h-6 w-6"
408
+ fill="none"
409
+ viewBox="0 0 24 24"
410
+ >
411
+ <path
412
+ strokeLinecap="round"
413
+ strokeLinejoin="round"
414
+ strokeWidth="2"
415
+ d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
416
+ />
417
+ </svg>
418
+ <div>
419
+ <h3 className="font-bold">
420
+ An error occurred while loading the patient
421
+ </h3>
422
+ <div className="text-xs">{error}</div>
423
+ </div>
424
+ </div>
425
+ )}
426
+ <div className="divider"></div>
427
+ {patient === null ? null : (
428
+ <div>
429
+ <Demographics patient={patient} />
430
+ <div className="flex gap-4 my-4 justify-end flex-wrap">
431
+ <div className="flex items-center gap-2">
432
+ <span>Zoom: </span>
433
+ <input
434
+ type="range"
435
+ min="20"
436
+ max="1000"
437
+ value={audioZoom}
438
+ onChange={e => setAudioZoom(Number(e.target.value))}
439
+ className="range range-sm min-w-[250px] w-1/4"
440
+ />
441
+ </div>
442
+ </div>
443
+ {patient.tracks.map(track => (
444
+ <AuscultationTrack
445
+ key={track.audioFile}
446
+ patient={patient}
447
+ track={track}
448
+ zoom={audioZoom}
449
+ showAnswer={showAnswer}
450
+ spectrogram={showAnswer && spectrogram}
451
+ regionsLevel={showAnswer ? regionsLevel : RegionsLevel.None}
452
+ />
453
+ ))}
454
+ <div className="collapse collapse-arrow bg-base-200">
455
+ <input
456
+ type="checkbox"
457
+ checked={showAnswer}
458
+ onChange={e => setShowAnswer(e.target.checked)}
459
+ />
460
+ <div className="collapse-title text-xl font-medium">
461
+ Heart Sound Analysis
462
+ </div>
463
+ <div className="collapse-content">
464
+ <div className="p-4 pt-0 flex flex-col items-center w-full gap-4">
465
+ <p className="text-lg">{getMurmurDescription(patient)}</p>
466
+ {patient.murmur !== MurmurStatus.Present ? null : (
467
+ <p>
468
+ All audible locations:{' '}
469
+ {patient.murmurLocations.map(loc => (
470
+ <kbd className="kbd" key={loc}>
471
+ {nameLocation(loc)}
472
+ </kbd>
473
+ ))}
474
+ </p>
475
+ )}
476
+ <p className="text-lg">Heart outcome: {patient.outcome}</p>
477
+ <div className="flex gap-8 my-4 justify-end flex-wrap">
478
+ <div className="flex items-center gap-4">
479
+ <span className="label-text">Annotations:</span>
480
+ <div>
481
+ <input
482
+ type="range"
483
+ min="0"
484
+ max="3"
485
+ value={regionsLevel}
486
+ onChange={e => setRegionsLevel(Number(e.target.value))}
487
+ className="range"
488
+ step="1"
489
+ />
490
+ <div className="w-full flex justify-between text-[5px] px-2">
491
+ <span>|</span>
492
+ <span>|</span>
493
+ <span>|</span>
494
+ <span>|</span>
495
+ </div>
496
+ </div>
497
+ </div>
498
+ <div className="divider-vertical" />
499
+ <div className="form-control">
500
+ <label className="label cursor-pointer flex gap-4">
501
+ <span className="label-text">Show spectrogram:</span>
502
+ <input
503
+ type="checkbox"
504
+ className="toggle"
505
+ checked={spectrogram}
506
+ onChange={e => setSpectrogram(e.target.checked)}
507
+ />
508
+ </label>
509
+ </div>
510
+ </div>
511
+ </div>
512
+ </div>
513
+ </div>
514
+ </div>
515
+ )}
516
+ </div>
517
+ );
518
+ }
src/app/marrow-cells/api.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+ import { CellTypes, FilterParams, RandomResult } from '../../marrow-cell-types';
3
+
4
+ const SERVER_BASE_PATH = import.meta.env.VITE_SERVER_URL;
5
+
6
+ export async function getImages(params: FilterParams): Promise<RandomResult> {
7
+ const response = await axios.get(
8
+ `${SERVER_BASE_PATH}api/marrow-cells/images`,
9
+ {
10
+ params,
11
+ }
12
+ );
13
+ return response.data;
14
+ }
15
+
16
+ export async function getTypes(): Promise<CellTypes> {
17
+ const response = await axios.get(`${SERVER_BASE_PATH}api/marrow-cells/types`);
18
+ return response.data;
19
+ }
20
+
21
+ export function getDataUrl(filename: string): string {
22
+ return `${SERVER_BASE_PATH}marrow-cell-data/bone_marrow_cecll_dataset/${filename}`;
23
+ }
src/lib/helper.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Request, Response, NextFunction } from 'express';
2
+ import { AnyZodObject, z } from 'zod';
3
+ import { badRequest } from '@hapi/boom';
4
+
5
+ function indent(str: string, spaces: number) {
6
+ return str
7
+ .split('\n')
8
+ .map(line => ' '.repeat(spaces) + line)
9
+ .join('\n');
10
+ }
11
+
12
+ function extractZodMessage(error: any): string {
13
+ if (Array.isArray(error)) {
14
+ return error.map(extractZodMessage).join('\n');
15
+ } else {
16
+ let union: string[] = [];
17
+ if ('unionErrors' in error) {
18
+ union = error.unionErrors.map(extractZodMessage);
19
+ } else if ('issues' in error) {
20
+ union = error.issues.map(extractZodMessage);
21
+ }
22
+ if (
23
+ 'message' in error &&
24
+ typeof error.message === 'string' &&
25
+ !error.message.includes('\n')
26
+ ) {
27
+ if (union.length === 0) return error.message;
28
+ return error.message + '\n' + indent(union.join('\n'), 2);
29
+ } else if (union.length > 0) {
30
+ return union.join('\n');
31
+ } else {
32
+ return '';
33
+ }
34
+ }
35
+ }
36
+
37
+ export async function validate<T extends AnyZodObject>(
38
+ req: Request,
39
+ schema: T
40
+ ): Promise<z.infer<T>> {
41
+ try {
42
+ return await schema.parseAsync(req);
43
+ } catch (error: any) {
44
+ throw badRequest(extractZodMessage(error));
45
+ }
46
+ }
47
+
48
+ export function wrap(
49
+ fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
50
+ ) {
51
+ return async function (req: Request, res: Response, next: NextFunction) {
52
+ try {
53
+ return await fn(req, res, next);
54
+ } catch (err) {
55
+ next(err);
56
+ }
57
+ };
58
+ }
src/lib/marrow-cells/api.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import { z } from 'zod';
3
+ import { validate, wrap } from '../helper';
4
+ import { cellTypes, cellImages } from './data';
5
+ import { notFound } from '@hapi/boom';
6
+ import { RandomResult } from '../../marrow-cell-types';
7
+
8
+ const router = express.Router();
9
+
10
+ router.get(
11
+ '/types',
12
+ wrap(async (_req, res) => {
13
+ res.status(200).json(cellTypes);
14
+ })
15
+ );
16
+
17
+ router.get(
18
+ '/images',
19
+ wrap(async (req, res) => {
20
+ const { query } = await validate(
21
+ req,
22
+ z.object({
23
+ query: z
24
+ .object({
25
+ type: z.union([z.string().array(), z.string()]).optional(),
26
+ })
27
+ .strict(),
28
+ })
29
+ );
30
+ if (query.type) {
31
+ const validateType = (type: string) => {
32
+ if (!(type in cellImages)) {
33
+ throw notFound(`${query.type} is not a valid cell type`);
34
+ }
35
+ };
36
+ if (Array.isArray(query.type)) {
37
+ query.type.forEach(validateType);
38
+ } else {
39
+ validateType(query.type);
40
+ }
41
+ }
42
+ const typeOptions: string[] = [];
43
+ if (Array.isArray(query.type) && query.type.length === 0) {
44
+ throw notFound('No cell types given');
45
+ } else if (Array.isArray(query.type)) {
46
+ typeOptions.push(...query.type);
47
+ } else if (query.type) {
48
+ typeOptions.push(query.type);
49
+ }
50
+ if (typeOptions.length === 0) {
51
+ typeOptions.push(...Object.keys(cellImages));
52
+ }
53
+ const type = typeOptions[Math.floor(Math.random() * typeOptions.length)];
54
+ const images = cellImages[type].slice();
55
+ // randomly sample at least 10 images
56
+ const sampleSize = Math.min(10, images.length);
57
+ const sample: string[] = [];
58
+ for (let i = 0; i < sampleSize; i++) {
59
+ const index = Math.floor(Math.random() * images.length);
60
+ sample.push(images[index]);
61
+ images.splice(index, 1);
62
+ }
63
+ res.status(200).json({
64
+ type,
65
+ images: sample,
66
+ count: cellImages[type].length,
67
+ } satisfies RandomResult);
68
+ })
69
+ );
70
+
71
+ export default router;
src/lib/marrow-cells/data.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'fs/promises';
2
+ import { CellTypes } from '../../marrow-cell-types';
3
+ import { Glob } from 'glob';
4
+
5
+ const DATA_DIR = 'dist/app/marrow-cell-data/';
6
+
7
+ export let cellTypes: CellTypes;
8
+ export let cellImages: { [key: string]: string[] };
9
+
10
+ export async function readData(): Promise<void> {
11
+ console.log('Marrow cells: Reading labels');
12
+
13
+ cellTypes = {};
14
+ const data = await fs.readFile(DATA_DIR + 'abbreviations.csv', {
15
+ encoding: 'utf8',
16
+ });
17
+ data
18
+ .split(/\r?\n/)
19
+ .slice(1)
20
+ .forEach(line => {
21
+ const [key, description] = line.split(';');
22
+ cellTypes[key] = description;
23
+ });
24
+
25
+ console.log('Marrow cells: Indexing images');
26
+
27
+ cellImages = {};
28
+ const directories = (
29
+ await fs.readdir(DATA_DIR + 'bone_marrow_cell_dataset', {
30
+ withFileTypes: true,
31
+ })
32
+ )
33
+ .filter(dirent => dirent.isDirectory())
34
+ .map(dirent => dirent.name);
35
+
36
+ for (const directory of directories) {
37
+ cellImages[directory] = [];
38
+ const g = new Glob(directory + '/**/*', {
39
+ cwd: DATA_DIR + 'bone_marrow_cell_dataset/',
40
+ });
41
+ for await (const file of g) {
42
+ cellImages[directory].push(file);
43
+ }
44
+ }
45
+ }
src/marrow-cell-types.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface CellTypes {
2
+ [key: string]: string;
3
+ }
4
+
5
+ export interface RandomResult {
6
+ images: string[];
7
+ type: string;
8
+ count: number;
9
+ }
10
+
11
+ export interface FilterParams {
12
+ type?: string[];
13
+ }
src/server.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'dotenv/config';
2
+ import express, { Request, Response, NextFunction } from 'express';
3
+ import marrowCellApi from './lib/marrow-cells/api';
4
+ import { readData, cellImages } from './lib/marrow-cells/data';
5
+ import cors from 'cors';
6
+ import { isBoom } from '@hapi/boom';
7
+ import { fileURLToPath } from 'url';
8
+ import { dirname, join } from 'path';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+
13
+ const { PORT = 7860 } = process.env;
14
+
15
+ const app = express();
16
+
17
+ // Enable cross-origin resource sharing
18
+ app.use(cors());
19
+
20
+ // Middleware that parses json and looks at requests where the Content-Type header matches the type option.
21
+ app.use(express.json());
22
+
23
+ // Serve API requests from the router
24
+ app.use('/api/marrow-cells', marrowCellApi);
25
+
26
+ // Serve app production bundle
27
+ app.use(express.static('dist/app'));
28
+
29
+ app.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {
30
+ if (res.headersSent) {
31
+ return next(err);
32
+ }
33
+ if (isBoom(err)) {
34
+ return res.status(err.output.statusCode).json(err.output.payload);
35
+ }
36
+ next(err);
37
+ });
38
+
39
+ // Handle client routing, return all requests to the app
40
+ app.get('*', (_req, res) => {
41
+ res.sendFile(join(__dirname, 'app/index.html'));
42
+ });
43
+
44
+ Promise.all([
45
+ (async () => {
46
+ await readData();
47
+ console.log(
48
+ `Marrow cells: ${
49
+ Object.keys(cellImages).length
50
+ } cell types and ${Object.values(cellImages).reduce(
51
+ (prev, curr) => prev + curr.length,
52
+ 0
53
+ )} cell images loaded`
54
+ );
55
+ })(),
56
+ ]).then(() => {
57
+ app.listen(PORT, () => {
58
+ console.log(`Server listening at http://localhost:${PORT}`);
59
+ });
60
+ });
tailwind.config.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
4
+ theme: {
5
+ extend: {},
6
+ },
7
+ plugins: [require('daisyui')],
8
+ };
tsconfig.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
6
+ "moduleResolution": "Node",
7
+ "strict": true,
8
+ "sourceMap": true,
9
+ "resolveJsonModule": true,
10
+ "esModuleInterop": true,
11
+ "types": ["vite/client", "node"],
12
+ "noEmit": true,
13
+ "noUnusedLocals": true,
14
+ "noUnusedParameters": true,
15
+ "noImplicitReturns": true,
16
+ "jsx": "react",
17
+ "allowJs": false,
18
+ "isolatedModules": true,
19
+ "skipLibCheck": false,
20
+ "allowSyntheticDefaultImports": true,
21
+ "forceConsistentCasingInFileNames": true
22
+ },
23
+ "include": ["./src/app", "src/app/marrow-cells/Identify.tsx"]
24
+ }
tsconfig.server.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "module": "ES2022",
6
+ "target": "ES2022",
7
+ "noEmit": false
8
+ },
9
+ "include": ["src/*.ts"],
10
+ "exclude": ["src/app"]
11
+ }
vite.config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import dotenv from 'dotenv';
2
+ dotenv.config();
3
+
4
+ import { defineConfig } from 'vite';
5
+ import reactRefresh from '@vitejs/plugin-react-refresh';
6
+
7
+ const { PORT = 7860 } = process.env;
8
+
9
+ // https://vitejs.dev/config/
10
+ export default defineConfig({
11
+ plugins: [reactRefresh()],
12
+ server: {
13
+ proxy: {
14
+ '/api': {
15
+ target: `http://localhost:${PORT}`,
16
+ changeOrigin: true,
17
+ },
18
+ },
19
+ },
20
+ build: {
21
+ outDir: 'dist/app',
22
+ },
23
+ });