diff --git a/.github/workflows/Make_api_image.yml b/.github/workflows/Make_api_image.yml new file mode 100644 index 00000000..6d37e8de --- /dev/null +++ b/.github/workflows/Make_api_image.yml @@ -0,0 +1,33 @@ +name: Make api image + +on: + push: + tags: + - 'v_[0-9]+.[0-9]+.[0-9]+' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Branch name + id: branch_name + run: | + echo ::set-output name=SOURCE_NAME::${GITHUB_REF#refs/*/} + echo ::set-output name=SOURCE_BRANCH::${GITHUB_REF#refs/heads/} + echo ::set-output name=SOURCE_TAG::${GITHUB_REF#refs/tags/} + - name: Build base image + run: | + echo $SOURCE_NAME + echo $SOURCE_BRANCH + echo $SOURCE_TAG + env: + SOURCE_TAG: ${{ steps.branch_name.outputs.SOURCE_TAG }} + + - uses: actions/checkout@v1 + - name: 'Build api image' + run: export VERSION=${{ steps.branch_name.outputs.SOURCE_TAG }} && docker-compose -f ./builds/docker-compose.yml build api + - name: Docker login + run: docker login docker.pkg.github.com -u $GITHUB_ACTOR -p ${{ secrets.GITHUB_TOKEN }} + - name: 'Pushing images to distant repository' + run: docker push docker.pkg.github.com/ruellepaul/datatensor/datatensor-api:${{ steps.branch_name.outputs.SOURCE_TAG }} diff --git a/.github/workflows/Make_celery_image.yml b/.github/workflows/Make_celery_image.yml new file mode 100644 index 00000000..3e476754 --- /dev/null +++ b/.github/workflows/Make_celery_image.yml @@ -0,0 +1,33 @@ +name: Make celery image + +on: + push: + tags: + - 'v_[0-9]+.[0-9]+.[0-9]+' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Branch name + id: branch_name + run: | + echo ::set-output name=SOURCE_NAME::${GITHUB_REF#refs/*/} + echo ::set-output name=SOURCE_BRANCH::${GITHUB_REF#refs/heads/} + echo ::set-output name=SOURCE_TAG::${GITHUB_REF#refs/tags/} + - name: Build base image + run: | + echo $SOURCE_NAME + echo $SOURCE_BRANCH + echo $SOURCE_TAG + env: + SOURCE_TAG: ${{ steps.branch_name.outputs.SOURCE_TAG }} + + - uses: actions/checkout@v1 + - name: 'Build celery image' + run: export VERSION=${{ steps.branch_name.outputs.SOURCE_TAG }} && docker-compose -f ./builds/docker-compose.yml build celery + - name: Docker login + run: docker login docker.pkg.github.com -u $GITHUB_ACTOR -p ${{ secrets.GITHUB_TOKEN }} + - name: 'Pushing images to distant repository' + run: docker push docker.pkg.github.com/ruellepaul/datatensor/datatensor-celery:${{ steps.branch_name.outputs.SOURCE_TAG }} diff --git a/.github/workflows/Make_ux_image_production.yml b/.github/workflows/Make_ux_image_production.yml new file mode 100644 index 00000000..5e5fb808 --- /dev/null +++ b/.github/workflows/Make_ux_image_production.yml @@ -0,0 +1,33 @@ +name: Make ux production image + +on: + push: + tags: + - 'v_[0-9]+.[0-9]+.[0-9]+' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Branch name + id: branch_name + run: | + echo ::set-output name=SOURCE_NAME::${GITHUB_REF#refs/*/} + echo ::set-output name=SOURCE_BRANCH::${GITHUB_REF#refs/heads/} + echo ::set-output name=SOURCE_TAG::${GITHUB_REF#refs/tags/} + - name: Build base image + run: | + echo $SOURCE_NAME + echo $SOURCE_BRANCH + echo $SOURCE_TAG + env: + SOURCE_TAG: ${{ steps.branch_name.outputs.SOURCE_TAG }} + + - uses: actions/checkout@v1 + - name: 'Build ui image' + run: export VERSION=${{ steps.branch_name.outputs.SOURCE_TAG }} && export ENVIRONMENT=production && docker-compose -f ./builds/docker-compose.yml build ux + - name: Docker login + run: docker login docker.pkg.github.com -u $GITHUB_ACTOR -p ${{ secrets.GITHUB_TOKEN }} + - name: 'Pushing images to distant repository' + run: export ENVIRONMENT=production && docker push docker.pkg.github.com/ruellepaul/datatensor/datatensor-ux:${{ steps.branch_name.outputs.SOURCE_TAG }}_$ENVIRONMENT diff --git a/.gitignore b/.gitignore new file mode 100755 index 00000000..66e3e104 --- /dev/null +++ b/.gitignore @@ -0,0 +1,199 @@ +## General +*.pyc +*~ +.~* +.*.swp +jenkins-test-reports/ +.idea/ +/.idea/ +/.idea/** + + +## Security +*.pem +*.csv +deploy.sh +run_ui.sh +builds/development/init_env.sh +builds/development/development.env +builds/production/init_env.sh +builds/production/login.sh +builds/production/elk/.env + + +## React dependencies +ui/.eslintcache +ui/node_modules/ +/node_modules +/.pnp +.pnp.js + +## Boilerplate +boilerplate/ + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + + + +## Sass +.sass-cache/ +/.sass-cache/ +*.css.map +*.sass.map +*.scss.map + + +## Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.venv +env/ +venv/ +.venv_3.8 +env/_3.8 +venv_3.8/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project user +.spyderproject +.spyproject + +# Rope project user +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Pycharm related +../.idea + +# generator data +api/workflows/generator/datasources +api/workflows/generator/datasources/* diff --git a/Dockerfile-api b/Dockerfile-api new file mode 100755 index 00000000..7017657b --- /dev/null +++ b/Dockerfile-api @@ -0,0 +1,13 @@ +# === Api image ==== +FROM python:3.9-slim AS release + +COPY api /api +WORKDIR /api +RUN apt-get update +RUN apt-get install ffmpeg libsm6 libxext6 -y +RUN pip install --user -r requirements.txt --no-warn-script-location + +ENV PATH /root/.local/bin:$PATH + +CMD ["python", "-m", "app"] + diff --git a/Dockerfile-celery b/Dockerfile-celery new file mode 100755 index 00000000..8a60b787 --- /dev/null +++ b/Dockerfile-celery @@ -0,0 +1,12 @@ +# === Api image ==== +FROM python:3.9-slim AS release + +COPY api /api +WORKDIR /api +RUN apt-get update +RUN apt-get install ffmpeg libsm6 libxext6 -y +RUN pip install --user -r requirements.txt --no-warn-script-location + +ENV PATH /root/.local/bin:$PATH + +CMD ["celery", "-A", "worker", "worker", "--loglevel=INFO"] diff --git a/Dockerfile-ux b/Dockerfile-ux new file mode 100755 index 00000000..44e974a1 --- /dev/null +++ b/Dockerfile-ux @@ -0,0 +1,23 @@ +# === UI image ==== +FROM node:14.16.0-alpine3.13 + +# ==== Arg section ==== +# System args +ARG ENVIRONMENT + +# ==== Env section ==== +# System env +ENV ENVIRONMENT=$ENVIRONMENT + +WORKDIR /ux + +ENV PATH /ux/node_modules/.bin:$PATH + +COPY ux /ux + +COPY builds/${ENVIRONMENT}/.env /ux/.env + +RUN yarn add package.json --silent +RUN yarn run build + +CMD yarn run serve:production \ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 00000000..25cb1efd --- /dev/null +++ b/README.md @@ -0,0 +1,226 @@ +# Datatensor + +## Run locally :computer: + +### Stack +- Backend: python 3.8 +- Database: MongoDB +- Frontend: Node ^14.14.0 https://nodejs.org/en/download/ + +Assuming you have everything installed, you must be able to use node and npm commands : + +```bash +$ node -v +$ npm -v +``` + +### Installation + +Paste these commands in your project folder. This will clone the project & install python and node dependencies + +```bash +(venv) $ git clone https://github.com/RuellePaul/datatensor.git +(venv) $ cd datatensor +(venv) $ pip install --upgrade pip +(venv) $ pip install -r api/requirements.txt +(venv) $ brew install rabbitmq +(venv) $ brew services rabbitmq start +(venv) $ cd ux +(venv) $ yarn +``` + +### Run + +#### Locally + +Set environment variable in `/development/init_env.sh` + +**Backend** + +Run FastAPI backend using : + +```bash +(venv) $ python api/app.py +``` + +**Front end** + +Run React front-end using : + +```bash +(venv) $ cd ux +(venv) $ yarn run development +``` + +**Worker** + +Run Rabbit MQ server using : + +```bash +(venv) $ cd api +(venv) $ celery -A worker worker --loglevel=INFO +``` + +```bash +(venv) $ cd api +(venv) $ celery -A worker worker --loglevel=INFO +``` + +
+ +#### With docker + +_MacOS Procedure_ + +Install docker using : + +```bash +brew install --cask docker +``` + +Launch Docker Desktop, and run docker deamon. + + +## Deployment :bow_and_arrow: + +_This section show deployment for `test` env, but the same apply for other envs._ + +On PyCharm terminal, push a new tag : + +```bash +(venv) $ git tag v_0.0.1 +(venv) $ git push origin v_0.0.1 +``` + + +On AWS, search for `DTProduction` instance, or rebuild it using DTProduction instance model. + +Then, login using SSH to this instance using `DTProductionLogin.sh` script : + +```bash +(venv) $ cd builds/production +(venv) $ source DTProductionLogin.sh +``` + +⚠️  You must have `DTProductionKeys.pem` in `builds/production`. + +Next, on the machine, install `git`, `docker` and `docker-compose` : + +```bash +sudo -i +apt install docker.io +apt install docker-compose +``` + +Then, use login to Github Packages : + +```bash +cat ./github_token.txt | docker login https://docker.pkg.github.com -u --password-stdin +``` + +⚠️  `` must be authorized to collaborate on Datatensor github project, and you must have a `github_token.txt` *with repo, workflow and packages* enabled. +Retrieve it from github here : https://github.com/settings/tokens/new + +You can now clone the project : + +```bash +git clone https://github.com/RuellePaul/datatensor.git +cd datatensor +``` + +If prompted, login with `` and your github token as password. + +Fill the env with secret keys (copy from local) : + +```bash +cd ~/datatensor/builds/production +nano init_env.sh +``` + +Add the `deploy.sh` script : + +```bash +cd ~/datatensor +nano deploy.sh +``` + +### Renew certificates for all domain/subdomains + + +- In all `conf.d/.conf` files, comment the line : + +```bash +# return 301 https://$server_name$request_uri; +``` + +Then deploy `proxy` + +- Run certbot : + +```bash +apt install certbot +certbot certonly --dry-run +``` + +Select option : +``` +2: Place files in webroot directory (webroot) +``` + +Please enter in your domain name(s): +```bash +datatensor.io www.datatensor.io api.datatensor.io kibana.datatensor.io +``` + +Input the `webroot`: +```bash +/var/www/letsencrypt +``` + +If the dry run is successful, run in `/etc/letsencrypt` + +```bash +rm -rfd archive +mkdir archive +rm -rfd live +mkdir live +``` +then run the same as above without `--dry-run` argument. + +- Rename generated certs directory in `/etc/letsencrypt/live` : +```bash +mv datatensor.io-0004/ datatensor.io +``` +- Replace the line in all `conf.d/.conf`: +``` +return 301 https://$server_name$request_uri; +``` + +and re-deploy `proxy` service + +### Deploy ELK + +Hydrate `~/datatensor/builds/production/elk/.env` with production values. + +Deploy filebeat : + +``` +cd ~/datatensor/builds +docker-compose up -d filebeat +``` + +Deploy elasticsearch and kibana : + +``` +cd ~/datatensor/builds/production/elk +docker-compose up -d elasticsearch kibana +``` + +In `certs` docker volume, copy/paste `ca.crt` certificate in path `~/datatensor/builds/production/elk`, then deploy logstash : + +``` +cd ~/datatensor/builds/production/elk +docker-compose up -d logstash +``` + +Kibana is visible at URL `https://kibana.datatensor.io` \ No newline at end of file diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/app.py b/api/app.py new file mode 100755 index 00000000..911bb86b --- /dev/null +++ b/api/app.py @@ -0,0 +1,128 @@ +import os + +import uvicorn +from fastapi import FastAPI, Depends, Request, HTTPException +from fastapi.responses import JSONResponse +from pydantic import ValidationError +from starlette.middleware.cors import CORSMiddleware + +from authentication.auth import auth +from authentication.oauth import oauth +from config import Config +from database import encrypt_init +from dependencies import logged_user, logged_admin +from errors import APIError +from logger import logger +from public.public import public +from routers.categories.categories import categories +from routers.datasets.datasets import datasets +from routers.datasources.datasources import datasources +from routers.exports.exports import exports +from routers.images.images import images +from routers.labels.labels import labels +from routers.notifications.notifications import notifications +from routers.pipelines.pipelines import pipelines +from routers.tasks.tasks import tasks +from routers.users.users import users +from search.search import search +from websocket.socket import sockets + +PREFIX = '/v2' + +app = FastAPI( + title='Datatensor API', + version='1.0.0', + docs_url=f'{PREFIX}/docs' +) + +config_name = os.getenv('FLASK_UI_CONFIGURATION', 'development') + +app.add_middleware( + CORSMiddleware, + allow_origins=[Config.UI_URL], + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], +) + +# Socket +app.include_router(sockets) + +# Authentication +app.include_router(auth, prefix=f'{PREFIX}/auth', tags=['auth']) +app.include_router(oauth, prefix=f'{PREFIX}/oauth', tags=['oauth']) + +# Public dataset +app.include_router(public, prefix=f'{PREFIX}/public', tags=['public']) + +# Users | 🔒 Admin partially +app.include_router(users, prefix=f'{PREFIX}/users', + dependencies=[Depends(logged_user)], tags=['users']) + +# Datasources | 🔒 Admin only +app.include_router(datasources, prefix=f'{PREFIX}/datasources', + dependencies=[Depends(logged_admin)], tags=['datasources']) + +# Notifications +app.include_router(notifications, prefix=f'{PREFIX}/notifications', + dependencies=[Depends(logged_user)], tags=['notifications']) + +# Search +app.include_router(search, prefix=f'{PREFIX}/search', + dependencies=[Depends(logged_user)], tags=['search']) + +# Datasets +app.include_router(datasets, prefix=f'{PREFIX}/datasets', + dependencies=[Depends(logged_user)], tags=['datasets']) + +# Datasets ➤ Categories +datasets.include_router(categories, prefix='/{dataset_id}/categories', tags=['categories']) + +# Datasets ➤ Pipelines +datasets.include_router(pipelines, prefix='/{dataset_id}/pipelines', tags=['pipelines']) + +# Images ➤ Labels +images.include_router(labels, prefix='/{image_id}/labels', tags=['labels']) + +# Datasets ➤ Images +datasets.include_router(images, prefix='/{dataset_id}/images', tags=['images']) + +# Dataset ➤ Tasks +datasets.include_router(tasks, prefix='/{dataset_id}/tasks', tags=['tasks']) + +# Dataset ➤ Exports +datasets.include_router(exports, prefix='/{dataset_id}/exports', tags=['exports']) + +# Users ➤ Tasks 🔒 Admin partially (for generator) +app.include_router(tasks, prefix=f'{PREFIX}/tasks', tags=['tasks'], dependencies=[Depends(logged_user)]) + + +@app.get('/traceback') +def traceback_generator(): + """ + Used for log parsing in ELK stack + """ + return 1 / 0 + + +@app.exception_handler(HTTPException) +def handle_api_error(request: Request, error: APIError): + logger.notify(error.router, error.detail, level='error') + return JSONResponse(status_code=error.status_code, content={'message': error.detail, 'data': error.data}) + + +@app.exception_handler(ValidationError) +def handle_api_error(request: Request, error: ValidationError): + logger.notify('ValidationError', f'{error.json()}', level='error') + return JSONResponse(status_code=500, content={'message': error.json()}) + + +if __name__ == '__main__': + encrypt_init(Config.DB_HOST, key=Config.DB_ENCRYPTION_KEY, setup=True) + uvicorn.run('app:app', + host='127.0.0.1', + port=4069, + debug=Config.ENVIRONMENT == 'development', + reload=Config.ENVIRONMENT == 'development', + proxy_headers=True, + forwarded_allow_ips='*') diff --git a/api/authentication/auth.py b/api/authentication/auth.py new file mode 100755 index 00000000..301c149b --- /dev/null +++ b/api/authentication/auth.py @@ -0,0 +1,155 @@ +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse + +import errors +from authentication import core +from authentication.models import * +from dependencies import logged_user +from logger import logger +from routers.notifications.core import insert_notification +from routers.notifications.models import NotificationPostBody, NotificationType +from routers.users.core import find_user_by_email, find_user_by_recovery_code, reset_user_password +from routers.users.models import User +from utils import parse, password_context + +auth = APIRouter() + + +@auth.post('/login', response_model=AuthResponse) +def do_login(payload: AuthLoginBody): + """ + Login workflow (email + password) + """ + user_id = core.user_id_hash(payload.email) + user = core.user_with_password_from_user_id(user_id) + if not user: + raise errors.InvalidAuthentication('Auth', errors.INVALID_CREDENTIALS) + + user_password = bytes(user.password, 'utf-8') + if not password_context.verify(payload.password, user_password): + raise errors.InvalidAuthentication('Auth', errors.INVALID_CREDENTIALS) + + access_token = core.encode_access_token(user_id) + + response = JSONResponse(content={ + 'user': parse(user), + 'accessToken': access_token + }) + + logger.notify('Auth', f'Logged in as `{payload.email}`') + + return response + + +@auth.post('/register', response_model=AuthResponse) +def do_register(payload: AuthRegisterBody): + """ + Register workflow (email + password) + """ + core.check_captcha(payload.recaptcha) + + email = payload.email + + user_id = core.user_id_hash(email) + user = core.user_from_user_id(user_id) + + if user: + raise errors.Forbidden('Auth', errors.USER_ALREADY_EXISTS) + + activation_code = core.generate_code() + + core.send_email_with_activation_code(email, activation_code) + user = core.register_user(user_id, payload.name, email, payload.password, activation_code) + + if user.is_verified: + notification = NotificationPostBody(type=NotificationType('REGISTRATION')) + else: + notification = NotificationPostBody(type=NotificationType('EMAIL_CONFIRM_REQUIRED')) + insert_notification(user_id=user_id, notification=notification) + + access_token = core.encode_access_token(user_id) + + response = JSONResponse(content={ + 'user': parse(user), + 'accessToken': access_token + }) + + logger.notify('Auth', f'Registered user `{email}`') + + return response + + +@auth.post('/send-password-recovery-link') +def send_password_recovery_link(payload: AuthSendPasswordRecoveryBody): + """ + Forgot password workflow (send recovery link) + """ + core.check_captcha(payload.recaptcha) + + email = payload.email + if not find_user_by_email(email): + return + recovery_code = core.generate_code() + core.store_recovery_code(email, recovery_code) + core.send_email_with_recovery_link(email, recovery_code) + + logger.notify('Auth', f'Send password recovery link to `{email}`') + + +@auth.post('/reset-password') +def do_reset_password(payload: AuthResetPasswordBody): + """ + Forgot password workflow (reset password using recovery code) + """ + new_password = payload.new_password + recovery_code = payload.recovery_code + + user = find_user_by_recovery_code(recovery_code) + reset_user_password(user, new_password) + + logger.notify('Auth', f'Send password recovery to user `{user.id}`') + + +@auth.get('/me', response_model=User) +def me(user: User = Depends(logged_user)): + """ + Return user from access token + """ + + logger.notify('Auth', f'Fetch whoami for user `{user.id}`') + + return parse(user) + + +@auth.post('/email-confirmation', response_model=AuthResponse) +def do_email_confirmation(payload: AuthEmailConfirmBody): + """ + Validates the code in the link provided in email body + """ + + user = core.verify_user_email(payload.activation_code) + access_token = core.encode_access_token(user.id) + + notification = NotificationPostBody(type=NotificationType('EMAIL_CONFIRM_DONE')) + insert_notification(user_id=user.id, notification=notification) + + response = JSONResponse(content={ + 'user': parse({ + **user.mongo(), + 'is_verified': True + }), + 'accessToken': access_token + }) + + logger.notify('Auth', f'Verified email `{user.email}`') + + return response + + +@auth.post('/unregister') +def do_unregister(user: User = Depends(logged_user)): + """ + Unregister logged user + """ + core.unregister_user(user.id) + logger.notify('Auth', f'Unregister user `{user.id}`') diff --git a/api/authentication/core.py b/api/authentication/core.py new file mode 100755 index 00000000..c42ebbd8 --- /dev/null +++ b/api/authentication/core.py @@ -0,0 +1,276 @@ +import hashlib +import random +import ssl +import string +from datetime import datetime, timedelta + +import jwt +import oauthlib.oauth2.rfc6749.errors +import requests +from oauthlib.oauth2 import WebApplicationClient +from sendgrid import SendGridAPIClient +from sendgrid.helpers.mail import Mail + +import errors +from config import Config +from routers.datasets.core import remove_datasets +from routers.users.models import User, UserWithPassword +from utils import encrypt_field, password_context + +# allow sendgrid email +ssl._create_default_https_context = ssl._create_unverified_context + +db = Config.db + + +# Token +def encode_access_token(user_id): + payload = {'user_id': user_id, 'exp': datetime.utcnow() + timedelta(minutes=Config.SESSION_DURATION_IN_MINUTES)} + access_token = jwt.encode(payload, Config.ACCESS_TOKEN_KEY, algorithm='HS256') + return access_token + + +def verify_access_token(access_token) -> User: + if not access_token: + raise errors.InvalidAuthentication() + + try: + user_id = jwt.decode(access_token, Config.ACCESS_TOKEN_KEY, algorithms='HS256').get('user_id') + if not user_id: + raise errors.InvalidAuthentication() + except jwt.exceptions.ExpiredSignatureError: + raise errors.ExpiredAuthentication(data='ERR_EXPIRED') + + user = db.users.find_one({'_id': user_id}, {'password': 0}) + if not user: + raise errors.InvalidAuthentication('Auth', errors.USER_NOT_FOUND) + + return User.from_mongo(user) + + +# User related +def authorization_url_from_scope(scope): + client = WebApplicationClient(Config.OAUTH[scope]['CLIENT_ID']) + authorization_url = client.prepare_request_uri( + Config.OAUTH[scope]['AUTHORIZATION_URL'], + redirect_uri=f'{Config.UI_URL}/auth/callback/{scope}', + scope=Config.OAUTH[scope]['SCOPES'] + ) + return authorization_url + + +def profile_from_code(code, scope): + client = WebApplicationClient(Config.OAUTH[scope]['CLIENT_ID']) + token_url, headers, body = client.prepare_token_request( + Config.OAUTH[scope]['TOKEN_URL'], + redirect_url=f"{Config.UI_URL}/auth/callback/{scope}", + code=code + ) + response = requests.post( + token_url, + headers=headers, + data=f'''{body}{f"&client_secret={Config.OAUTH[scope]['CLIENT_SECRET']}" + if scope == 'stackoverflow' else ''}''', + auth=((Config.OAUTH[scope]['CLIENT_ID'], Config.OAUTH[scope]['CLIENT_SECRET']) + if scope != 'stackoverflow' else None), + ) + try: + client.parse_request_body_response(response.text) + except oauthlib.oauth2.rfc6749.errors.OAuth2Error as e: + raise errors.InternalError('OAuth', f'Cannot fetch OAuth2 profile : {str(e)}') + uri, headers, body = client.add_token(Config.OAUTH[scope]['USER_URL']) + profile = requests.get(uri, headers=headers, data=body, params={ + 'access_token': response.json()['access_token'], + 'key': Config.OAUTH[scope]['KEY'] + } if scope == 'stackoverflow' else {}).json() + return profile + + +def user_id_from_profile(profile, scope): + if scope == 'github': + oauth_id = profile['node_id'] + elif scope == 'google': + oauth_id = profile['sub'] + elif scope == 'stackoverflow': + oauth_id = profile['items'][0]['user_id'] + else: + raise ValueError('Invalid scope') + + user_id = user_id_hash(oauth_id) + return user_id + + +def google_profile_from_google_access_token(google_access_token): + google_profile = jwt.decode(google_access_token, options={"verify_signature": False}) + return google_profile + + +def user_id_hash(identifier): + user_id = hashlib.sha256(str(identifier).encode('utf-8')).hexdigest() + return user_id + + +def user_from_user_id(user_id) -> User: + return User.from_mongo(db.users.find_one({'_id': user_id})) + + +def user_with_password_from_user_id(user_id) -> UserWithPassword: + return UserWithPassword.from_mongo(db.users.find_one({'_id': user_id})) + + +def user_from_activation_code(activation_code) -> User: + return User.from_mongo(db.users.find_one({'activation_code': activation_code})) + + +def generate_code(): + return ''.join(random.choices(string.ascii_uppercase + string.digits, k=32)) + + +def register_user(user_id, name, email, password, activation_code) -> User: + user = User(id=user_id, + created_at=datetime.now(), + email=email, + name=name, + is_admin=user_id in Config.ADMIN_USER_IDS, + avatar=None, + is_verified=False) + encrypted_password = password_context.hash(password) + db.users.insert_one({ + **user.mongo(), + 'email': user.email, + 'password': encrypt_field(encrypted_password), + 'activation_code': activation_code + }) + return user + + +def register_user_from_profile(profile, scope) -> User: + user_id = user_id_from_profile(profile, scope) + + if scope == 'github': + user = User(id=user_id, + created_at=datetime.now(), + name=profile.get('name'), + is_admin=user_id in Config.ADMIN_USER_IDS, + avatar=profile.get('avatar_url'), + scope=scope, + is_verified=True) + + elif scope == 'google': + user = User(id=user_id, + created_at=datetime.now(), + email=profile.get('email'), + name=profile.get('name'), + is_admin=user_id in Config.ADMIN_USER_IDS, + avatar=profile.get('picture'), + scope=scope, + is_verified=True) + + elif scope == 'stackoverflow': + user = User(id=user_id, + created_at=datetime.now(), + name=profile['items'][0]['display_name'], + is_admin=user_id in Config.ADMIN_USER_IDS, + avatar=profile['items'][0]['profile_image'], + scope=scope, + is_verified=True) + else: + raise ValueError('Invalid scope') + + db.users.insert_one({ + **user.mongo(), + 'email': user.email if user.email else None, + }) + return user + + +def check_captcha(captcha): + url = 'https://www.google.com/recaptcha/api/siteverify' + data = { + 'secret': Config.GOOGLE_CAPTCHA_SECRET_KEY, + 'response': captcha + } + r = requests.post(url, data=data) + if r.status_code != 200: + raise errors.InternalError('Auth', errors.CAPTCHA_INVALID) + if not r.json().get('success'): + raise errors.BadRequest('Auth', errors.CAPTCHA_INVALID) + + +def send_email_with_activation_code(email, activation_code): + subject = "Welcome to Datatensor ! Confirm your email" + activation_url = f'{Config.UI_URL}/auth/email-confirmation?activation_code={activation_code}' + html_content = f""" +
Welcome to Datatensor !
+ By clicking on the following link, you are confirming your email address. +
+ Confirm your email +
+
+ Happy hacking ! + """ + + message = Mail( + from_email='noreply@datatensor.io', + to_emails=email, + subject=subject, + html_content=html_content + ) + try: + sg = SendGridAPIClient(Config.SENDGRID_API_KEY) + sg.send(message) + except Exception as e: + raise errors.InternalError('Auth', f'Unable to send email | {str(e)}') + + +def store_recovery_code(email, recovery_code): + db.users.find_one_and_update({'email': email}, + {'$set': {'recovery_code': recovery_code}}) + + +def send_email_with_recovery_link(email, recovery_code): + subject = "Datatensor | Forgot password" + recovery_url = f'{Config.UI_URL}/auth/forgot-password?recovery_code={recovery_code}' + html_content = f""" +
Forgot password
+ Reset password of your Datatensor account by clinking the following link : +
+ Reset password +
+
+ Happy hacking ! + """ + + message = Mail( + from_email='noreply@datatensor.io', + to_emails=email, + subject=subject, + html_content=html_content + ) + try: + sg = SendGridAPIClient(Config.SENDGRID_API_KEY) + sg.send(message) + except Exception as e: + raise errors.InternalError('Auth', f'Unable to send email | {str(e)}') + + +def verify_user_email(activation_code) -> User: + user = user_from_activation_code(activation_code) + + if not user: + raise errors.Forbidden('Auth', errors.INVALID_CODE) + + if user.is_verified: + raise errors.BadRequest('Auth', errors.ALREADY_VERIFIED) + + db.users.find_one_and_update({'_id': user.id}, + {'$set': {'is_verified': True, 'activation_code': None}}) + + return user + + +def unregister_user(user_id): + remove_datasets(user_id) + db.notifications.delete_many({'user_id': user_id}) + db.tasks.delete_many({'user_id': user_id}) + db.users.delete_one({'_id': user_id}) diff --git a/api/authentication/models.py b/api/authentication/models.py new file mode 100644 index 00000000..01f7f452 --- /dev/null +++ b/api/authentication/models.py @@ -0,0 +1,72 @@ +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field, HttpUrl + +from utils import MongoModel + + +class Scope(str, Enum): + github = 'github' + google = 'google' + stackoverflow = 'stackoverflow' + + +class User(MongoModel): + id: str = Field() + email: Optional[str] = None # github oauth users doesn't have an email + name: Optional[str] = None # github users can have a non specified name + created_at: datetime + is_verified: bool + is_admin: bool + scope: Optional[Scope] = None + avatar: Optional[str] = None + phone: Optional[str] = None + country: Optional[str] = None + city: Optional[str] = None + is_public: Optional[bool] = True + + +class AuthLoginBody(BaseModel): + email: str + password: str + + +class AuthRegisterBody(BaseModel): + email: str + password: str + name: str + recaptcha: str + + +class AuthSendPasswordRecoveryBody(BaseModel): + email: str + recaptcha: str + + +class AuthResetPasswordBody(BaseModel): + new_password: str + recovery_code: str + + +class AuthEmailConfirmBody(BaseModel): + activation_code: str + + +class AuthResponse(BaseModel): + user: dict + accessToken: str + + +class OAuthAuthorizationResponse(BaseModel): + authorization_url: HttpUrl + + +class OAuthCallbackBody(BaseModel): + code: str + scope: Scope + + +class OAuthGoogleOneTap(BaseModel): + google_access_token: str diff --git a/api/authentication/oauth.py b/api/authentication/oauth.py new file mode 100755 index 00000000..c8f7f416 --- /dev/null +++ b/api/authentication/oauth.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from authentication import core +from authentication.models import * +from logger import logger +from routers.notifications.core import insert_notification +from routers.notifications.models import NotificationPostBody, NotificationType +from utils import parse + +oauth = APIRouter() + + +@oauth.get('/authorization/{scope}', response_model=OAuthAuthorizationResponse) +def oauth_authorization(scope: str): + """ + Fetch and return OAuth authorization url, depending on requested scope. + """ + + authorization_url = core.authorization_url_from_scope(scope) + response = {'authorization_url': authorization_url} + + logger.notify('OAuth', f'Fetch OAuth authorization url for `{scope}`') + + return response + + +@oauth.post('/callback', response_model=AuthResponse) +def oauth_callback(payload: OAuthCallbackBody): + """ + Using code provided by OAuth workflow, fetch profile depending on requested scope; register user if doesn't exists + """ + + scope = payload.scope + + profile = core.profile_from_code(payload.code, scope) + user_id = core.user_id_from_profile(profile, scope) + + user = core.user_from_user_id(user_id) + + if not user: + user = core.register_user_from_profile(profile, scope) + notification = NotificationPostBody(type=NotificationType('REGISTRATION')) + insert_notification(user_id, notification) + logger.notify('OAuth', f'Registered `{user.name}` from `{scope}`') + + access_token = core.encode_access_token(user_id) + response = JSONResponse(content={ + 'user': parse(user), + 'accessToken': access_token + }) + + logger.notify('OAuth', f'Logged in user `{user.id}` from `{scope}`') + + return response + + +@oauth.post('/google-one-tap', response_model=AuthResponse) +def oauth_callback(payload: OAuthGoogleOneTap): + """ + Decoding google access token provided by Google One Tap workflow, retrieve use and access_token + """ + + google_access_token = payload.google_access_token + + google_profile = core.google_profile_from_google_access_token(google_access_token) + + user_id = core.user_id_from_profile(google_profile, 'google') + user = core.user_from_user_id(user_id) + + if not user: + user = core.register_user_from_profile(google_profile, 'google') + notification = NotificationPostBody(type=NotificationType('REGISTRATION')) + insert_notification(user_id, notification) + logger.notify('OAuth', f'Registered `{user.name}` from `google` using `Google One Tap`') + + access_token = core.encode_access_token(user_id) + response = JSONResponse(content={ + 'user': parse(user), + 'accessToken': access_token + }) + + logger.notify('OAuth', f'Logged in user `{user.id}` from `google` using `Google One Tap`') + + return response diff --git a/api/config.py b/api/config.py new file mode 100755 index 00000000..1c125a49 --- /dev/null +++ b/api/config.py @@ -0,0 +1,104 @@ +import os +from typing import Any, List + +from fastapi import FastAPI +from pydantic import AnyHttpUrl, BaseSettings + +import errors +from database import encrypt_init + +os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' # to use OAuth2 without https + +if 'ACCESS_TOKEN_KEY' not in os.environ: + raise errors.InternalError( + detail='Environment variable are not set. Use init_env.sh script' + ) + + +class Settings(BaseSettings): + ENVIRONMENT = os.environ['ENVIRONMENT'] + + ROOT_PATH: str = os.path.abspath(os.path.join(FastAPI().root_path, os.pardir)) + DATASOURCES_PATH: str = os.path.join(ROOT_PATH, 'api', 'workflows', 'generator', 'datasources') + + UI_URL: str = 'https://localhost:5069' + API_URI: str = 'http://127.0.0.1:4069' + + MAX_CONTENT_LENGTH: int = 1 * 1000 * 1024 * 1024 # 1 Go + + ADMIN_USER_IDS: List[str] = [ + '58a802c1b350056c737ca447db48c7c645581b265e61d2ceeae5e0320adc7e6a', # RuellePaul (github) + 'b813fd7e62edcdd7b630837e2f7314e0aa28684eca85a15787be242386ee4e0f', # RuellePaul (google) + '83d2218ec37d73a99944dbcd90e5753908a418b99fa79678402ba6bc97a81f83' # ThomasRoudil (github) + ] + + DATASOURCES: List[dict] = [ + { + 'key': 'coco2014', + 'name': 'COCO 2014', + 'download_url': 'http://images.cocodataset.org/annotations/annotations_trainval2014.zip', + 'filenames': ['instances_val2014.json', 'instances_train2014.json'] + }, + { + 'key': 'coco2017', + 'name': 'COCO 2017', + 'download_url': 'http://images.cocodataset.org/annotations/annotations_trainval2017.zip', + 'filenames': ['instances_val2017.json', 'instances_train2017.json'] + }, + ] + + ACCESS_TOKEN_KEY: str = os.environ['ACCESS_TOKEN_KEY'] + SESSION_DURATION_IN_MINUTES: int = 120 + + GOOGLE_CAPTCHA_SECRET_KEY: str = os.environ['GOOGLE_CAPTCHA_SECRET_KEY'] + + SENDGRID_API_KEY: str = os.environ['SENDGRID_API_KEY'] + + OAUTH: dict = { + 'github': { + 'AUTHORIZATION_URL': 'https://github.com/login/oauth/authorize', + 'TOKEN_URL': 'https://github.com/login/oauth/access_token', + 'USER_URL': 'https://api.github.com/user', + 'CLIENT_ID': 'a1c2fca55dd2294221cc', + 'CLIENT_SECRET': os.environ['OAUTH_GITHUB_CLIENT_SECRET'], + 'SCOPES': ['openid', 'email', 'profile'] + }, + 'google': { + 'AUTHORIZATION_URL': 'https://accounts.google.com/o/oauth2/v2/auth', + 'TOKEN_URL': 'https://oauth2.googleapis.com/token', + 'USER_URL': 'https://openidconnect.googleapis.com/v1/userinfo', + 'CLIENT_ID': '1020592902157-8elmelc4n4l2fh3jk4jltf5ulb3mqp5v.apps.googleusercontent.com', + 'CLIENT_SECRET': os.environ['OAUTH_GOOGLE_CLIENT_SECRET'], + 'SCOPES': ['openid', 'email', 'profile'] + }, + 'stackoverflow': { + 'AUTHORIZATION_URL': 'https://stackoverflow.com/oauth', + 'TOKEN_URL': 'https://stackoverflow.com/oauth/access_token/json', + 'USER_URL': 'https://api.stackexchange.com/2.2/me?site=stackoverflow', + 'CLIENT_ID': '19511', + 'CLIENT_SECRET': os.environ['OAUTH_STACKOVERFLOW_CLIENT_SECRET'], + 'SCOPES': [], + 'KEY': os.environ['OAUTH_STACKOVERFLOW_KEY'] + } + } + + S3_BUCKET: str = 'dtserverdevbucket' + S3_KEY: str = os.environ['S3_KEY'] + S3_SECRET: str = os.environ['S3_SECRET'] + S3_LOCATION: AnyHttpUrl = f'http://{S3_BUCKET}.s3.amazonaws.com/' + + DB_ENCRYPTION_KEY: str = os.environ['DB_ENCRYPTION_KEY'] + DB_HOST: str = 'localhost:27017' + DB_NAME: str = '' + DB_ENCRYPT_CLIENT: Any = None + db: Any = None + + def __init__(self): + super().__init__() + self.DB_NAME: str = f'datatensor_{self.ENVIRONMENT}' + self.DB_ENCRYPT_CLIENT, self.db = encrypt_init(self.DB_HOST, + db_name=self.DB_NAME, + key=self.DB_ENCRYPTION_KEY) + + +Config = Settings() diff --git a/api/database.py b/api/database.py new file mode 100755 index 00000000..ab486423 --- /dev/null +++ b/api/database.py @@ -0,0 +1,44 @@ +from base64 import b64decode + +from pymongo import errors, MongoClient +from pymongo.encryption import ClientEncryption +from pymongo.encryption_options import AutoEncryptionOpts + + +def encrypt_init(host, db_name=None, key=None, setup=False): + if not key: + raise KeyError('Invalid key for DB encryption') + master_key = b64decode(key) + kms_providers = {'local': {'key': master_key}} + + key_vault_namespace = 'encryption_datatensor.__pymongoVault' + key_vault_db_name, key_vault_coll_name = key_vault_namespace.split('.', 1) + + auto_encryption_opts = AutoEncryptionOpts( + kms_providers, key_vault_namespace, bypass_auto_encryption=True) + + client = MongoClient(host=host, auto_encryption_opts=auto_encryption_opts) + + key_vault = client[key_vault_db_name][key_vault_coll_name] + key_vault.create_index( + 'keyAltNames', + unique=True, + partialFilterExpression={'keyAltNames': {'$exists': True}}) + + client_encryption = ClientEncryption( + kms_providers, + key_vault_namespace, + client, + client.codec_options) + + if setup: + try: + client_encryption.create_data_key( + 'local', key_alt_names=['datatensor_key']) + except errors.EncryptionError as e: + if e.cause.code == 11000: # DuplicateKeyError + pass + return + + client = client[db_name] + return client_encryption, client diff --git a/api/dependencies.py b/api/dependencies.py new file mode 100644 index 00000000..fe0a06ea --- /dev/null +++ b/api/dependencies.py @@ -0,0 +1,39 @@ +from typing import Optional, Union + +from fastapi import Depends, Header, Request + +import errors +from authentication.core import verify_access_token +from config import Config +from routers.datasets.core import find_dataset +from routers.datasets.models import Dataset +from routers.users.models import User + + +def get_access_token(authorization: Optional[str] = Header(None)) -> Union[str, None]: + return authorization + + +def logged_user(access_token: str = Depends(get_access_token)) -> User: + user = verify_access_token(access_token=access_token) + return user + + +def logged_admin(user: User = Depends(logged_user)) -> User: + if user.id not in Config.ADMIN_USER_IDS: + raise errors.Forbidden('Auth', errors.USER_NOT_ADMIN) + return user + + +def dataset_belongs_to_user(dataset_id, user: User = Depends(logged_user)) -> Dataset: + dataset = find_dataset(dataset_id) + if dataset.user_id != user.id: + raise errors.Forbidden('Auth', errors.NOT_YOUR_DATASET) + return Dataset.parse_obj(dataset) + + +def get_ip_address(request: Request, x_forwarded_for: Union[str, None] = Header(None)) -> Union[str, None]: + print(dict(request.headers)) + if Config.ENVIRONMENT == 'development': + return '127.0.0.1' + return x_forwarded_for diff --git a/api/errors.py b/api/errors.py new file mode 100755 index 00000000..1d851a85 --- /dev/null +++ b/api/errors.py @@ -0,0 +1,89 @@ +from fastapi import HTTPException + + +class APIError(HTTPException): + def __init__(self, status_code, router=None, detail=None, data=None): + super().__init__(status_code, + detail=detail or 'Something went wrong') + self.router = router or 'Unknown' + self.data = data + + +class BadRequest(APIError): + def __init__(self, router=None, detail=None, data=None): + super().__init__(400, + router=router, + detail=detail or 'Bad request', + data=data) + + +class InvalidAuthentication(APIError): + def __init__(self, router=None, detail=None, data=None): + super().__init__(401, + router=router or 'Auth', + detail=detail or 'Invalid Authentication', + data=data) + + +class ExpiredAuthentication(APIError): + def __init__(self, router=None, detail=None, data=None): + super().__init__(401, + router=router or 'Auth', + detail=detail or 'Expired authentication token', + data=data) + + +class Forbidden(APIError): + def __init__(self, router=None, detail=None, data=None): + super().__init__(403, + router=router, + detail=detail or 'Forbidden', + data=data) + + +class NotFound(APIError): + def __init__(self, router=None, detail=None, data=None): + super().__init__(404, + router=router, + detail=detail or 'Not found', + data=data) + + +class InternalError(APIError): + def __init__(self, router=None, detail=None, data=None): + super().__init__(500, + router=router, + detail=detail or 'Internal error', + data=data) + + +CAPTCHA_MISSING = 'Missing google recatpcha.' +CAPTCHA_INVALID = 'Invalid captcha. Try again.' +ALREADY_VERIFIED = 'User is already verified.' +USER_NOT_VERIFIED = 'User email is not verified.' +USER_NOT_ADMIN = 'This action requires admin privileges.' +USER_IS_ADMIN = 'This user has admin privileges.' +INVALID_CREDENTIALS = 'Invalid email or password. Please try again.' +INVALID_CODE = 'Invalid code provided.' +INVALID_PASSWORD = "Passwords don't match. Please try again." + +USER_NOT_FOUND = 'User not found.' +USER_ALREADY_EXISTS = 'This user already exists' +DATASET_NOT_FOUND = 'This dataset does not exist.' +DATASET_ALREADY_EXISTS = 'This dataset already exists' +IMAGE_NOT_FOUND = 'This image does not exists.' +IMAGE_ALREADY_EXISTS = 'This image already exists.' +CATEGORY_NOT_FOUND = 'This category does not exist.' +CATEGORY_ALREADY_EXISTS = 'This category already exists.' +PIPELINE_NOT_FOUND = 'This pipeline does not exist.' +PIPELINE_ALREADY_EXISTS = 'This pipeline already exists.' +LABEL_NOT_FOUND = 'This label does not exist.' +LABEL_ALREADY_EXISTS = 'This label already exists.' +TASK_NOT_FOUND = 'This task does not exist.' +TASK_ALREADY_EXISTS = 'This task already exists.' +NOTIFICATION_NOT_FOUND = 'This notification does not exist.' +NOTIFICATION_ALREADY_EXISTS = 'This notification already exists.' + +NOT_YOUR_DATASET = 'Action not permitted : not your dataset' +USER_HAS_A_SCOPE = 'Action not permitted : your account is linked to external authentication.' +TOO_MANY_PIPELINES = 'Action not permitted : this dataset already has augmented images.' diff --git a/api/logger.py b/api/logger.py new file mode 100755 index 00000000..dca212ed --- /dev/null +++ b/api/logger.py @@ -0,0 +1,35 @@ +import logging +from datetime import datetime + +from pytz import timezone + + +def timetz(*args): + return datetime.now(tz).timetuple() + + +tz = timezone('Europe/Paris') + + +def create_logger(): + logging.setLoggerClass(DatatensorLogger) + _logger = logging.getLogger('api_logger') + console_handler = logging.StreamHandler() + + formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s', '%Y-%m-%dT%H:%M:%S%z') + console_handler.setFormatter(formatter) + + _logger.addHandler(console_handler) + + _logger.setLevel(logging.INFO) + + return _logger + + +class DatatensorLogger(logging.Logger): + + def notify(self, router, message, level='info'): + getattr(logger, level)(f'{router} | {message}') + + +logger = create_logger() diff --git a/api/public/core.py b/api/public/core.py new file mode 100644 index 00000000..6d294ffd --- /dev/null +++ b/api/public/core.py @@ -0,0 +1,2 @@ +from typing import List + diff --git a/api/public/models.py b/api/public/models.py new file mode 100644 index 00000000..94233093 --- /dev/null +++ b/api/public/models.py @@ -0,0 +1,23 @@ +from typing import List + +from pydantic import BaseModel + +from routers.datasets.models import DatasetExtended +from routers.images.models import ImageExtended +from routers.labels.models import Label +from routers.pipelines.models import Operation + + +class PublicDatasetResponse(BaseModel): + datasets: List[DatasetExtended] + images: List[ImageExtended] + + +class PublicSampleBody(BaseModel): + image_id: str + operations: List[Operation] + labels: List[Label] + + +class NewsletterBody(BaseModel): + email: str diff --git a/api/public/public-dataset/1.jpeg b/api/public/public-dataset/1.jpeg new file mode 100644 index 00000000..a23b412e Binary files /dev/null and b/api/public/public-dataset/1.jpeg differ diff --git a/api/public/public-dataset/2.jpeg b/api/public/public-dataset/2.jpeg new file mode 100644 index 00000000..cdf8c77b Binary files /dev/null and b/api/public/public-dataset/2.jpeg differ diff --git a/api/public/public-dataset/3.jpeg b/api/public/public-dataset/3.jpeg new file mode 100644 index 00000000..097f13d1 Binary files /dev/null and b/api/public/public-dataset/3.jpeg differ diff --git a/api/public/public-dataset/4.jpeg b/api/public/public-dataset/4.jpeg new file mode 100644 index 00000000..d8f63a27 Binary files /dev/null and b/api/public/public-dataset/4.jpeg differ diff --git a/api/public/public-dataset/5.jpeg b/api/public/public-dataset/5.jpeg new file mode 100644 index 00000000..2f9c4bdd Binary files /dev/null and b/api/public/public-dataset/5.jpeg differ diff --git a/api/public/public-dataset/6.jpeg b/api/public/public-dataset/6.jpeg new file mode 100644 index 00000000..0ffaac77 Binary files /dev/null and b/api/public/public-dataset/6.jpeg differ diff --git a/api/public/public-dataset/data.json b/api/public/public-dataset/data.json new file mode 100644 index 00000000..070584c6 --- /dev/null +++ b/api/public/public-dataset/data.json @@ -0,0 +1,891 @@ +{ + "datasets": [ + { + "id": "00000000-0000-0000-0000-000000000000", + "user_id": "46eaee40-0663-4132-a0c4-bde9633ec208", + "name": "Humans, cats & dogs", + "description": "Case study of object recognition, human and pet detection", + "is_public": true, + "created_at": "2021-12-06T08:47:45.144546", + "image_count": 6846, + "augmented_count": 0, + "categories": [ + { + "id": "5026ac72-05f1-4de5-a6b6-60ec7c5d5428", + "dataset_id": "00000000-0000-0000-0000-000000000000", + "name": "Dog", + "supercategory": "animal", + "labels_count": 4504 + }, + { + "id": "fa8316b2-b493-4475-8e3a-82a78294ff54", + "dataset_id": "00000000-0000-0000-0000-000000000000", + "name": "Cat", + "supercategory": "animal", + "labels_count": 5437 + }, + { + "id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec", + "dataset_id": "00000000-0000-0000-0000-000000000000", + "name": "Person", + "supercategory": "person", + "labels_count": 12274 + } + ], + "user": { + "id": "46eaee40-0663-4132-a0c4-bde9633ec208", + "email": "anika.visser@datatensor.io", + "name": "Anika Visser", + "created_at": "2021-09-10T12:13:26.724000", + "is_verified": true, + "is_admin": false, + "scope": "github", + "avatar": "/public-data/personas/1.png", + "phone": "06", + "country": "", + "city": "", + "is_public": true + } + }, + { + "id": "00000000-0000-0000-0000-000000000001", + "user_id": "e831fa25-75b6-4310-bbf5-a6c60e462725", + "name": "Cerebral injuries", + "description": "Extraction and assistance in the detection of brain lesions.", + "is_public": false, + "created_at": "2021-12-06T08:47:45.144546", + "image_count": 3487, + "augmented_count": 0, + "categories": [ + { + "id": "673eef6c-2b33-4702-8ece-55c86228f78c", + "dataset_id": "00000000-0000-0000-0000-000000000000", + "name": "Cerebral lesion", + "supercategory": "person", + "labels_count": 6484 + } + ], + "user": { + "id": "00000000-0000-0000-0000-000000000001", + "email": "demo@datatensor.io", + "name": "Stan Goodman", + "created_at": "2021-08-09T12:13:26.724000", + "is_verified": true, + "is_admin": false, + "scope": "google", + "avatar": "/public-data/personas/2.jpeg", + "phone": "06", + "country": "", + "city": "", + "is_public": true + } + }, + { + "id": "00000000-0000-0000-0000-000000000002", + "user_id": "ded244a4-3208-4c07-95b2-7590cedff7fe", + "name": "Bullets impact", + "description": "In order to create an algorithm capable of counting the impacts of a rifle bullet", + "is_public": false, + "created_at": "2021-12-06T08:47:45.144546", + "image_count": 3487, + "augmented_count": 0, + "categories": [ + { + "id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808", + "dataset_id": "00000000-0000-0000-0000-000000000002", + "name": "Bullet impact", + "supercategory": "miscellaneous", + "labels_count": 1436 + } + ], + "user": { + "id": "ded244a4-3208-4c07-95b2-7590cedff7fe", + "email": "demo@datatensor.io", + "name": "Siegbert Gottfried", + "created_at": "2021-08-09T12:13:26.724000", + "is_verified": true, + "is_admin": false, + "scope": null, + "avatar": "/public-data/personas/3.png", + "phone": "06", + "country": "", + "city": "", + "is_public": true + } + } + ], + "images": [ + { + "id": "24d31047-46ce-4b82-a2b1-e49b885feea4", + "dataset_id": "00000000-0000-0000-0000-000000000000", + "path": "/public-data/datasets/1.jpeg", + "name": "1.jpeg", + "size": 345602, + "width": 1280, + "height": 816, + "labels": [ + { + "id": "78b0767d-111e-48ad-8bc6-221cb461ac16", + "x": 0.281452, + "y": 0.436478, + "w": 0.222581, + "h": 0.464151, + "image_id": "24d31047-46ce-4b82-a2b1-e49b885feea4", + "category_id": "fa8316b2-b493-4475-8e3a-82a78294ff54" + }, + { + "id": "d299000f-d674-4871-9a35-3c560f42c3e4", + "x": 0.025806, + "y": 0.193711, + "w": 0.400806, + "h": 0.558491, + "image_id": "24d31047-46ce-4b82-a2b1-e49b885feea4", + "category_id": "5026ac72-05f1-4de5-a6b6-60ec7c5d5428" + }, + { + "id": "3b2a6dd9-e1bd-42e2-9114-7048cfb785f6", + "x": 0.402419, + "y": 0.226415, + "w": 0.235484, + "h": 0.642767, + "image_id": "24d31047-46ce-4b82-a2b1-e49b885feea4", + "category_id": "5026ac72-05f1-4de5-a6b6-60ec7c5d5428" + } + ] + }, + { + "id": "b47aff63-4ccf-45d9-9066-54d1efaa58e7", + "dataset_id": "00000000-0000-0000-0000-000000000000", + "path": "/public-data/datasets/2.jpeg", + "name": "2.jpeg", + "size": 286293, + "width": 1280, + "height": 850, + "labels": [ + { + "id": "91ea5359-686b-4e73-8ae1-fc8f3c4f61d9", + "x": 0.392742, + "y": 0.451691, + "w": 0.259677, + "h": 0.541063, + "image_id": "b47aff63-4ccf-45d9-9066-54d1efaa58e7", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + } + ] + }, + { + "id": "888eafd5-a277-4bbe-82e2-2764a442d333", + "dataset_id": "00000000-0000-0000-0000-000000000000", + "path": "/public-data/datasets/3.jpeg", + "name": "3.jpeg", + "size": 78016, + "width": 650, + "height": 426, + "labels": [ + { + "id": "d99cc5e1-6d0f-4b62-a7d2-3777c3dde14f", + "x": 0.216935, + "y": 0.156671, + "w": 0.26129, + "h": 0.50918, + "image_id": "888eafd5-a277-4bbe-82e2-2764a442d333", + "category_id": "fa8316b2-b493-4475-8e3a-82a78294ff54" + }, + { + "id": "93749a82-11f4-416a-acb4-90271dc665f7", + "x": 0.443548, + "y": 0.152999, + "w": 0.555645, + "h": 0.840881, + "image_id": "888eafd5-a277-4bbe-82e2-2764a442d333", + "category_id": "5026ac72-05f1-4de5-a6b6-60ec7c5d5428" + }, + { + "id": "6bb4c258-caa0-4632-acef-e208c99efe86", + "x": 0.001613, + "y": 0.363525, + "w": 0.918548, + "h": 0.629131, + "image_id": "888eafd5-a277-4bbe-82e2-2764a442d333", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + } + ] + }, + { + "id": "2c95e066-6dcb-41f8-a2f6-16d5c410a2d1", + "dataset_id": "00000000-0000-0000-0000-000000000000", + "path": "/public-data/datasets/4.jpeg", + "name": "4.jpeg", + "size": 256948, + "width": 1280, + "height": 853, + "labels": [ + { + "id": "44c357cc-1b35-4e9c-aacc-96d9d581c22f", + "x": 0.255645, + "y": 0.169675, + "w": 0.534677, + "h": 0.826715, + "image_id": "2c95e066-6dcb-41f8-a2f6-16d5c410a2d1", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "135b7be4-61d4-4306-b576-92fdadabe98b", + "x": 0.349194, + "y": 0.34657, + "w": 0.326613, + "h": 0.45728, + "image_id": "2c95e066-6dcb-41f8-a2f6-16d5c410a2d1", + "category_id": "fa8316b2-b493-4475-8e3a-82a78294ff54" + } + ] + }, + { + "id": "9a561cfe-b709-417a-be2d-925f4ab9cf54", + "dataset_id": "00000000-0000-0000-0000-000000000000", + "path": "/public-data/datasets/5.jpeg", + "name": "5.jpeg", + "size": 85599, + "width": 728, + "height": 486, + "labels": [ + { + "id": "ec8c1c88-1645-494b-9404-9a4b9edac0d7", + "x": 0.275, + "y": 0.201923, + "w": 0.223387, + "h": 0.558894, + "image_id": "9a561cfe-b709-417a-be2d-925f4ab9cf54", + "category_id": "5026ac72-05f1-4de5-a6b6-60ec7c5d5428" + }, + { + "id": "ec9bfc88-67e2-489c-beeb-ce25471e3081", + "x": 0.472581, + "y": 0.204327, + "w": 0.2, + "h": 0.637019, + "image_id": "9a561cfe-b709-417a-be2d-925f4ab9cf54", + "category_id": "5026ac72-05f1-4de5-a6b6-60ec7c5d5428" + } + ] + }, + { + "id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "dataset_id": "00000000-0000-0000-0000-000000000000", + "path": "/public-data/datasets/6.jpeg", + "name": "6.jpeg", + "size": 343671, + "width": 1000, + "height": 666, + "labels": [ + { + "id": "9ad4a9ff-0ef4-4b02-a7c8-ed477439648d", + "x": 0.116935, + "y": 0.16747, + "w": 0.122581, + "h": 0.501205, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "7fad310d-de53-4a70-a32b-7bd217020b55", + "x": 0.239516, + "y": 0.153012, + "w": 0.116935, + "h": 0.420482, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "c9fe9775-c666-40ab-90a5-3342d8e13fe0", + "x": 0.4266125483870968, + "y": 0.20602418072289155, + "w": 0.097581, + "h": 0.301205, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "d029339c-34f1-4519-ada9-5801b5b5d424", + "x": 0.903226, + "y": 0.227711, + "w": 0.047581, + "h": 0.168675, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "be548a8e-0728-4665-ac17-11f81eff2b03", + "x": 0.840323, + "y": 0.23012, + "w": 0.045968, + "h": 0.163855, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "69c6800a-5601-4571-be15-043afb8c2837", + "x": 0.816129, + "y": 0.228916, + "w": 0.049194, + "h": 0.184337, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "5ab21f04-04e4-4463-ad4a-a4b4466af6b8", + "x": 0.778226, + "y": 0.23734918072289155, + "w": 0.035484, + "h": 0.162651, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "57bdf66d-526c-42db-8600-61ca4949d270", + "x": 0.783871, + "y": 0.242169, + "w": 0.033871, + "h": 0.166265, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "42fd2dd4-88c6-463c-8f64-d5ecdbb70e53", + "x": 0.700806, + "y": 0.195181, + "w": 0.08871, + "h": 0.245783, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "7db33415-2ea4-4c08-be88-a1e99649217d", + "x": 0.6830644516129033, + "y": 0.21445818072289155, + "w": 0.066129, + "h": 0.219277, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "c452d821-10b2-49d9-89c2-ff6e532f18f4", + "x": 0.635484, + "y": 0.218072, + "w": 0.048387, + "h": 0.244578, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "93be9e3e-8a86-44e4-b6e6-8bf60617b339", + "x": 0.5983875483870967, + "y": 0.22048236144578315, + "w": 0.047580645161290326, + "h": 0.15903572289156626, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "f96e82f6-d101-4e1c-a1c2-11f2a7a7bb6d", + "x": 0.537903, + "y": 0.226506, + "w": 0.054839, + "h": 0.171084, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "2ca4d5e3-e3ea-4a7d-badd-2998dce66bb3", + "x": 0.498387, + "y": 0.210843, + "w": 0.041935, + "h": 0.192771, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "97c370d9-0d3d-49e3-ace9-cfa68ae0c71d", + "x": 0.429839, + "y": 0.216867, + "w": 0.029839, + "h": 0.185542, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "f8a1c355-2929-4b24-871c-e774913f5aec", + "x": 0.470161, + "y": 0.214458, + "w": 0.05, + "h": 0.20241, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "a580d36d-772d-4268-9c90-b505c44f3a99", + "x": 0.327419, + "y": 0.20000036144578315, + "w": 0.075, + "h": 0.240964, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "8c416e1d-cddf-4ea6-a59f-76cc8c7d1702", + "x": 0.228226, + "y": 0.185542, + "w": 0.03629, + "h": 0.218072, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "cd47dfb1-a11c-4ee8-81c5-29cc663800e4", + "x": 0.203226, + "y": 0.198795, + "w": 0.037097, + "h": 0.274699, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "75bee038-4e8e-4ff7-8139-22354ec56497", + "x": 0.083871, + "y": 0.219277, + "w": 0.037903, + "h": 0.175904, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "d1ddad0d-1e21-4a3c-96be-344ac613a384", + "x": 0.320161, + "y": 0.189157, + "w": 0.050806, + "h": 0.26506, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + }, + { + "id": "2c68c4e7-4b4b-4dbd-9f7d-49e0ab785c9f", + "x": 0.187097, + "y": 0.19759, + "w": 0.030645, + "h": 0.144578, + "image_id": "6d2a63c0-a695-4e09-8a04-dac16e70fe14", + "category_id": "be90a15e-19d0-48dc-8a3d-5a6ad93f25ec" + } + ] + }, + { + "id": "6cac9b6b-73ac-4253-b065-61996ab7022c", + "dataset_id": "00000000-0000-0000-0000-000000000001", + "path": "/public-data/datasets/brain-1.jpeg", + "name": "brain-1.jpeg", + "size": 36356, + "width": 600, + "height": 364, + "pipeline_id": null, + "original_image_id": null, + "labels": [ + { + "id": "75f9ed99-eb27-412e-9e72-33bf1bc75efb", + "x": 0.536585, + "y": 0.451183, + "w": 0.022584, + "h": 0.025148, + "image_id": "6cac9b6b-73ac-4253-b065-61996ab7022c", + "category_id": "673eef6c-2b33-4702-8ece-55c86228f78c" + }, + { + "id": "6abc024c-e54b-433c-82d7-caaf754b23fa", + "x": 0.470641, + "y": 0.377219, + "w": 0.026197, + "h": 0.032544, + "image_id": "6cac9b6b-73ac-4253-b065-61996ab7022c", + "category_id": "673eef6c-2b33-4702-8ece-55c86228f78c" + } + ] + }, + { + "id": "e410ddc6-d61d-457d-a933-388e71d0dc2a", + "dataset_id": "00000000-0000-0000-0000-000000000001", + "path": "/public-data/datasets/brain-2.jpeg", + "name": "brain-2.jpeg", + "size": 31546, + "width": 350, + "height": 350, + "pipeline_id": null, + "original_image_id": null, + "labels": [ + { + "id": "1a457ab7-7d41-4e5f-8dc0-24b08ddd7d61", + "x": 0.565492, + "y": 0.128713, + "w": 0.030714, + "h": 0.027903, + "image_id": "e410ddc6-d61d-457d-a933-388e71d0dc2a", + "category_id": "673eef6c-2b33-4702-8ece-55c86228f78c" + }, + { + "id": "d53584f1-282b-48c8-92b8-94c3687ccd33", + "x": 0.599819, + "y": 0.429343, + "w": 0.049684, + "h": 0.048605, + "image_id": "e410ddc6-d61d-457d-a933-388e71d0dc2a", + "category_id": "673eef6c-2b33-4702-8ece-55c86228f78c" + }, + { + "id": "ab2386d9-1001-471d-81af-4f4ab4820dfe", + "x": 0.754291, + "y": 0.587759, + "w": 0.051491, + "h": 0.056706, + "image_id": "e410ddc6-d61d-457d-a933-388e71d0dc2a", + "category_id": "673eef6c-2b33-4702-8ece-55c86228f78c" + } + ] + }, + { + "id": "4a812c52-2d4e-4b55-9d3f-bcb8b585cc91", + "dataset_id": "00000000-0000-0000-0000-000000000001", + "path": "/public-data/datasets/brain-3.jpeg", + "name": "brain-3.jpeg", + "size": 20343, + "width": 256, + "height": 192, + "pipeline_id": null, + "original_image_id": null, + "labels": [ + { + "id": "3b6032d8-9cff-4db6-97f7-95f68bcc98f1", + "x": 0.553749, + "y": 0.155875, + "w": 0.04607, + "h": 0.058753, + "image_id": "4a812c52-2d4e-4b55-9d3f-bcb8b585cc91", + "category_id": "673eef6c-2b33-4702-8ece-55c86228f78c" + }, + { + "id": "1558629f-3aee-4957-ae07-9e5037b4ed0b", + "x": 0.535682, + "y": 0.450839, + "w": 0.053297, + "h": 0.073141, + "image_id": "4a812c52-2d4e-4b55-9d3f-bcb8b585cc91", + "category_id": "673eef6c-2b33-4702-8ece-55c86228f78c" + }, + { + "id": "980561e2-a3a3-46a9-8503-3f58df91d32d", + "x": 0.355917, + "y": 0.420863, + "w": 0.084914, + "h": 0.052758, + "image_id": "4a812c52-2d4e-4b55-9d3f-bcb8b585cc91", + "category_id": "673eef6c-2b33-4702-8ece-55c86228f78c" + } + ] + }, + { + "id": "0bfe5684-5785-4166-9c06-3d1580077f94", + "dataset_id": "00000000-0000-0000-0000-000000000001", + "path": "/public-data/datasets/brain-4.jpeg", + "name": "brain-4.jpeg", + "size": 85777, + "width": 704, + "height": 704, + "pipeline_id": null, + "original_image_id": null, + "labels": [ + { + "id": "5e20cc88-6698-42f5-ad10-7314935bc173", + "x": 0.750678, + "y": 0.180018, + "w": 0.077687, + "h": 0.036904, + "image_id": "0bfe5684-5785-4166-9c06-3d1580077f94", + "category_id": "673eef6c-2b33-4702-8ece-55c86228f78c" + }, + { + "id": "01078b3b-709e-4879-a9a3-e28a354f757b", + "x": 0.816621, + "y": 0.349235, + "w": 0.036134, + "h": 0.082808, + "image_id": "0bfe5684-5785-4166-9c06-3d1580077f94", + "category_id": "673eef6c-2b33-4702-8ece-55c86228f78c" + }, + { + "id": "e6526470-88f8-48b7-8963-ba763363792e", + "x": 0.560072, + "y": 0.346535, + "w": 0.112014, + "h": 0.055806, + "image_id": "0bfe5684-5785-4166-9c06-3d1580077f94", + "category_id": "673eef6c-2b33-4702-8ece-55c86228f78c" + } + ] + }, + { + "id": "becfcbb8-a1ca-466b-93e6-2fc4b02b91e3", + "dataset_id": "00000000-0000-0000-0000-000000000002", + "path": "/public-data/datasets/bullets-1.jpeg", + "name": "bullets-1.jpeg", + "size": 84027, + "width": 612, + "height": 408, + "original_image_id": null, + "labels": [ + { + "id": "bb38ea8a-b438-44d2-8f8c-aba583c5fd91", + "x": 0.14838754838709675, + "y": 0.274368, + "w": 0.13225809677419353, + "h": 0.19013226113116727, + "image_id": "becfcbb8-a1ca-466b-93e6-2fc4b02b91e3", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "e889febd-fdf1-4879-ae07-2bfa842c71e0", + "x": 0.5879027419354839, + "y": 0.086643, + "w": 0.13871, + "h": 0.229844, + "image_id": "becfcbb8-a1ca-466b-93e6-2fc4b02b91e3", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "64a991f6-2f3d-475e-bb75-82e81ce29be3", + "x": 0.649194, + "y": 0.694344, + "w": 0.18871, + "h": 0.184116, + "image_id": "becfcbb8-a1ca-466b-93e6-2fc4b02b91e3", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + } + ] + }, + { + "id": "c82e9722-f478-4108-9466-ec27a687f872", + "dataset_id": "00000000-0000-0000-0000-000000000002", + "path": "/public-data/datasets/bullets-2.jpeg", + "name": "bullets-2.jpeg", + "size": 77258, + "width": 600, + "height": 338, + "pipeline_id": null, + "original_image_id": null, + "labels": [ + { + "id": "1516b3b5-904d-44d7-a7f7-65cad0215d3a", + "x": 0.149051, + "y": 0.522293, + "w": 0.052394, + "h": 0.08758, + "image_id": "c82e9722-f478-4108-9466-ec27a687f872", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "06868321-dc98-43a0-84d8-20f92e2a045a", + "x": 0.676603315266486, + "y": 0.14331192993630573, + "w": 0.065041, + "h": 0.106688, + "image_id": "c82e9722-f478-4108-9466-ec27a687f872", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "6414d222-bd94-46e2-a63b-d01dfa1aaa6a", + "x": 0.611563, + "y": 0.234076, + "w": 0.077687, + "h": 0.128981, + "image_id": "c82e9722-f478-4108-9466-ec27a687f872", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "b539167c-e2e8-490a-b493-5c5bc8d09297", + "x": 0.172538342366757, + "y": 0.5828025732484076, + "w": 0.080397630532972, + "h": 0.13535035668789808, + "image_id": "c82e9722-f478-4108-9466-ec27a687f872", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "eb9c5782-8706-4929-b2c5-8fa76729f150", + "x": 0.781391315266486, + "y": 0.14012764331210192, + "w": 0.081301, + "h": 0.13535, + "image_id": "c82e9722-f478-4108-9466-ec27a687f872", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "405da1db-6453-40bb-a0d7-4598efab026a", + "x": 0.866305, + "y": 0.04777028662420382, + "w": 0.085818, + "h": 0.183121, + "image_id": "c82e9722-f478-4108-9466-ec27a687f872", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "529d62ac-dc53-4900-97fb-cc58a05bd554", + "x": 0.448058, + "y": 0.4156052866242038, + "w": 0.098464, + "h": 0.178344, + "image_id": "c82e9722-f478-4108-9466-ec27a687f872", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + } + ] + }, + { + "id": "2e9487a2-3b4f-4cb8-963b-303e1db67177", + "dataset_id": "00000000-0000-0000-0000-000000000002", + "path": "/public-data/datasets/bullets-3.jpeg", + "name": "bullets-3.jpeg", + "size": 172800, + "width": 600, + "height": 400, + "pipeline_id": null, + "original_image_id": null, + "labels": [ + { + "id": "0c1c690a-1ef3-471b-a752-a8d1335a9be7", + "x": 0.831978711833785, + "y": 0.32345054447439353, + "w": 0.04878026106594399, + "h": 0.07142832884097036, + "image_id": "2e9487a2-3b4f-4cb8-963b-303e1db67177", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "155cf005-5edb-4d35-85ef-097756e1fe4d", + "x": 0.915989054200542, + "y": 0.10242612668463612, + "w": 0.04878060343270099, + "h": 0.07277645552560646, + "image_id": "2e9487a2-3b4f-4cb8-963b-303e1db67177", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "3bb43e7a-ac7d-4bac-a2e4-1b81b434333c", + "x": 0.737127, + "y": 0.429919, + "w": 0.046070684733514, + "h": 0.07816716442048517, + "image_id": "2e9487a2-3b4f-4cb8-963b-303e1db67177", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "114d1bf4-f273-4bd6-9f33-abd142bfd4b6", + "x": 0.41011702710027104, + "y": 0.6361184177897573, + "w": 0.05239394579945799, + "h": 0.07816745552560646, + "image_id": "2e9487a2-3b4f-4cb8-963b-303e1db67177", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "03bf8bd6-8aa0-4e38-97d3-4599a0a0e689", + "x": 0.700090342366757, + "y": 0.385445, + "w": 0.052393972899729, + "h": 0.08220987331536388, + "image_id": "2e9487a2-3b4f-4cb8-963b-303e1db67177", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "282b1792-e68a-4c71-b5eb-0d81a4c48097", + "x": 0.145438, + "y": 0.622642, + "w": 0.056911, + "h": 0.091644, + "image_id": "2e9487a2-3b4f-4cb8-963b-303e1db67177", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "c86c3e80-7087-4b30-9cae-b011d231fe53", + "x": 0.5582653423667571, + "y": 0.38274883557951483, + "w": 0.060523603432700995, + "h": 0.08894916442048517, + "image_id": "2e9487a2-3b4f-4cb8-963b-303e1db67177", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "1a0f9ff6-e2ee-4a19-ba06-b93088a8cdf1", + "x": 0.43541068473351396, + "y": 0.443396, + "w": 0.059621, + "h": 0.092992, + "image_id": "2e9487a2-3b4f-4cb8-963b-303e1db67177", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "ad649d91-aa51-483a-9db2-8eced8888a19", + "x": 0.339657, + "y": 0.673854, + "w": 0.064137, + "h": 0.102426, + "image_id": "2e9487a2-3b4f-4cb8-963b-303e1db67177", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "98a39321-a4c6-4d2d-a6f3-18f889be8fbc", + "x": 0.08672065763324299, + "y": 0.770889, + "w": 0.065041, + "h": 0.101078, + "image_id": "2e9487a2-3b4f-4cb8-963b-303e1db67177", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + } + ] + }, + { + "id": "f8acaba0-9e65-49f0-9f07-40af8b4fe1b7", + "dataset_id": "00000000-0000-0000-0000-000000000002", + "path": "/public-data/datasets/bullets-4.jpeg", + "name": "bullets-4.jpeg", + "size": 50238, + "width": 600, + "height": 400, + "pipeline_id": null, + "original_image_id": null, + "labels": [ + { + "id": "9bbfe9e5-e7e0-4ceb-8a8b-098bcb5ce70a", + "x": 0.861788684733514, + "y": 0.15633383557951483, + "w": 0.08401128816621499, + "h": 0.10646916442048518, + "image_id": "f8acaba0-9e65-49f0-9f07-40af8b4fe1b7", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "e3d5a2b2-3e8d-44b0-a1c9-8122d8659b24", + "x": 0.186991945799458, + "y": 0.0795147088948787, + "w": 0.222222, + "h": 0.297844, + "image_id": "f8acaba0-9e65-49f0-9f07-40af8b4fe1b7", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + }, + { + "id": "e813c0f3-16ca-472a-b5b8-74aa1cdf42e4", + "x": 0.563686, + "y": 0.590296, + "w": 0.238482, + "h": 0.30593, + "image_id": "f8acaba0-9e65-49f0-9f07-40af8b4fe1b7", + "category_id": "dcec7454-ef99-47d0-ad8a-b43ff6b0f808" + } + ] + } + ] +} diff --git a/api/public/public.py b/api/public/public.py new file mode 100644 index 00000000..ed3fc10e --- /dev/null +++ b/api/public/public.py @@ -0,0 +1,84 @@ +import base64 +import json +import os +from typing import List + +import cv2 +from fastapi import APIRouter, Depends + +import errors +from config import Config +from dependencies import get_ip_address +from logger import logger +from routers.images.models import Image +from routers.labels.models import Label +from routers.pipelines.core import perform_sample +from routers.pipelines.models import SampleResponse +from utils import parse +from .models import PublicDatasetResponse, PublicSampleBody, NewsletterBody + +db = Config.db + +public = APIRouter() + +public_data_path = os.path.join(Config.ROOT_PATH, 'api', 'public', 'public-dataset') +json_file = open(os.path.join(public_data_path, 'data.json'), 'r') +public_data = json.load(json_file) + + +def _find_public_image(image_id) -> Image: + return Image.parse_obj(next(image for image in public_data['images'] if image['id'] == image_id)) + + +def _find_public_labels(image_id) -> List[Label]: + return [Label.parse_obj(label) for label in public_data['labels'] if label['image_id'] == image_id] + + +@public.get('/', response_model=PublicDatasetResponse) +def get_public_data(ip_address: str = Depends(get_ip_address)): + logger.notify('Public', f'Fetch public data from {ip_address or "unknown"}') + return public_data + + +@public.post('/sample', response_model=SampleResponse) +def get_public_sample(payload: PublicSampleBody): + image_id = payload.image_id + operations = payload.operations + + image = _find_public_image(image_id) + labels = payload.labels + + image_path = os.path.join(Config.ROOT_PATH, 'api', 'public', 'public-dataset', image.path.split('/')[0], image.name) + cv2image = cv2.imread(image_path) + + augmented_images, augmented_labels = perform_sample( + image, + labels, + operations, + cv2image=cv2image + ) + + encoded_images = [cv2.imencode('.jpg', augmented_image)[1].tostring() + for augmented_image in augmented_images] + base64_encoded_images = [base64.b64encode(image) for image in encoded_images] + + logger.notify('Public', f'Fetch public sample on image `{image.name}`') + + return { + 'images': base64_encoded_images, + 'images_labels': parse(augmented_labels) + } + + +@public.post('/newsletter') +def register_newsletter(payload: NewsletterBody): + email = payload.email + + if not email: + raise errors.BadRequest('Newsletter', "Missing email") + + if email in [user['email'] for user in list(db.newsletter_users.find())]: + raise errors.Forbidden('Newsletter', f"Email {email} already registered to newsletter") + + db.newsletter_users.insert_one({'email': payload.email}) + logger.notify('Newsletter', f'Add {payload.email} in newsletter email collection') \ No newline at end of file diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100755 index 00000000..e2c47cda --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,20 @@ +aiofiles==0.7.0 +augmentor==0.2.8 +boto3==1.17.103 +celery==5.1.2 +fastapi==0.70.1 +PyJWT==2.1.0 +oauthlib==3.1.1 +opencv-python==4.5.1.48 +orjson==3.6.4 +passlib[bcrypt]==1.7.4 +pymongo==3.11.4 +pymongocrypt==1.1.0 +pytest==6.2.4 +pytz==2021.3 +python-multipart==0.0.5 +requests==2.25.1 +requests_oauthlib==1.3.0 +sendgrid==6.7.1 +uuid==1.30 +uvicorn[standard]==0.14.0 diff --git a/api/routers/categories/categories.py b/api/routers/categories/categories.py new file mode 100644 index 00000000..42faa0ad --- /dev/null +++ b/api/routers/categories/categories.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Depends + +from dependencies import dataset_belongs_to_user +from logger import logger +from routers.categories.core import find_categories, find_category, find_images_of_category, remove_category, \ + insert_category +from routers.categories.models import * +from utils import parse + +categories = APIRouter() + + +@categories.get('/', response_model=CategoriesResponse) +def get_categories(dataset_id, offset: int = 0, limit: int = 0): + """ + Fetch paginated categories list of given dataset. + """ + response = {'categories': find_categories(dataset_id, offset, limit)} + logger.notify('Categories', f'Fetch categories for dataset `{dataset_id}`') + return parse(response) + + +@categories.get('/{category_id}', response_model=CategoryResponse) +def get_category(dataset_id, category_id): + """ + Fetch given category of given dataset. + """ + response = {'category': find_category(dataset_id, category_id)} + logger.notify('Categories', f'Fetch category `{category_id}` for dataset `{dataset_id}`') + return parse(response) + + +@categories.get('/{category_id}/images', response_model=ImagesCategoryResponse) +def get_category(dataset_id, category_id, include_labels=False, offset: int = 0, limit: int = 0): + """ + Fetch images of a given category. + """ + images, total_count = find_images_of_category(dataset_id, category_id, include_labels, offset, limit) + response = {'images': images, 'total_count': total_count} + logger.notify('Images', f'Fetch images for category `{category_id}` of dataset `{dataset_id}`') + return parse(response) + + +@categories.post('/') +def post_category(category: CategoryPostBody, dataset_id, dataset=Depends(dataset_belongs_to_user)): + """ + Create a new category on given dataset, and returns it. + """ + response = {'category': insert_category(dataset_id, category)} + logger.notify('Categories', f'Add category `{category.name}` for dataset `{dataset_id}`') + return parse(response) + + +@categories.delete('/{category_id}') +def delete_category(dataset_id, category_id, dataset=Depends(dataset_belongs_to_user)): + """ + Delete given category of given dataset. + """ + remove_category(dataset_id, category_id) + logger.notify('Categories', f'Delete category `{category_id}` for dataset `{dataset_id}`') diff --git a/api/routers/categories/core.py b/api/routers/categories/core.py new file mode 100644 index 00000000..2b308856 --- /dev/null +++ b/api/routers/categories/core.py @@ -0,0 +1,61 @@ +from typing import List +from uuid import uuid4 + +import errors +from config import Config +from routers.categories.models import Category, SuperCategory +from routers.images.models import ImageExtended +from routers.labels.core import find_labels_from_image_ids, find_labels_of_category + +db = Config.db + + +def find_categories(dataset_id, offset=0, limit=0) -> List[Category]: + categories = list(db.categories.find({'dataset_id': dataset_id}).skip(offset).limit(limit)) + if categories is None: + raise errors.NotFound('Categories', errors.CATEGORY_NOT_FOUND) + return [Category.from_mongo(category) for category in categories] + + +def find_category(dataset_id, category_id) -> Category: + category = db.categories.find_one({'_id': category_id, 'dataset_id': dataset_id}) + if category is None: + raise errors.NotFound('Categories', errors.CATEGORY_NOT_FOUND) + return Category.from_mongo(category) + + +def find_images_of_category(dataset_id, category_id, include_labels=False, offset=0, limit=0) -> tuple[List[ImageExtended], int]: + labels = find_labels_of_category(category_id) + images = db.images.find({'dataset_id': dataset_id, + 'pipeline_id': None, + '_id': {'$in': [label.image_id for label in labels]}} + ).skip(offset).limit(limit) + total_count = db.images.count({'dataset_id': dataset_id, + 'pipeline_id': None, + '_id': {'$in': [label.image_id for label in labels]}}) + images = [ImageExtended.from_mongo(image) for image in images] + if include_labels: + labels = find_labels_from_image_ids([image.id for image in images]) + if labels is not None: + for image in images: + image.labels = [label for label in labels if label.image_id == image.id] + return images, total_count + + +def insert_category(dataset_id, category) -> Category: + if db.categories.find_one({'dataset_id': dataset_id, 'name': category.name}): + raise errors.Forbidden('Categories', errors.CATEGORY_ALREADY_EXISTS) + category = Category( + id=str(uuid4()), + dataset_id=dataset_id, + name=category.name, + supercategory=category.supercategory or SuperCategory('miscellaneous') + ) + db.categories.insert_one(category.mongo()) + return category + + +def remove_category(dataset_id, category_id): + db.labels.delete_many({'category_id': category_id}) + db.categories.delete_one({'_id': category_id, + 'dataset_id': dataset_id}) diff --git a/api/routers/categories/models.py b/api/routers/categories/models.py new file mode 100644 index 00000000..6252b1d6 --- /dev/null +++ b/api/routers/categories/models.py @@ -0,0 +1,49 @@ +from enum import Enum +from typing import List, Optional + +from pydantic import BaseModel, Field + +from routers.images.models import ImageExtended +from utils import MongoModel + + +class SuperCategory(str, Enum): + person = 'person' + vehicle = 'vehicle' + electronic = 'electronic' + indoor = 'indoor' + outdoor = 'outdoor' + sports = 'sports' + furniture = 'furniture' + accessory = 'accessory' + kitchen = 'kitchen' + animal = 'animal' + appliance = 'appliance' + food = 'food' + miscellaneous = 'miscellaneous' + + +class Category(MongoModel): + id: str = Field() + dataset_id: str + name: str + supercategory: Optional[SuperCategory] = None + labels_count: Optional[int] = None + + +class CategoriesResponse(BaseModel): + categories: List[Category] = [] + + +class CategoryResponse(BaseModel): + category: Category + + +class ImagesCategoryResponse(BaseModel): + images: List[ImageExtended] + total_count: int + + +class CategoryPostBody(BaseModel): + name: str + supercategory: Optional[SuperCategory] = None diff --git a/api/routers/datasets/core.py b/api/routers/datasets/core.py new file mode 100644 index 00000000..5166481d --- /dev/null +++ b/api/routers/datasets/core.py @@ -0,0 +1,103 @@ +from datetime import datetime +from typing import List +from uuid import uuid4 + +import errors +from config import Config +from routers.categories.core import find_categories +from routers.datasets.models import Dataset, DatasetExtended, DatasetPostBody, DatasetPatchBody +from routers.users.core import find_user +from routers.images.core import remove_all_images + +db = Config.db + + +def find_datasets(user_id, offset=0, limit=0, include_user=False, include_categories=False) -> List[DatasetExtended]: + datasets = list(db.datasets + .find({'$or': [{'user_id': user_id}, {'is_public': True}]}) + .skip(offset) + .limit(limit)) + if datasets is None: + raise errors.NotFound('Datasets', errors.DATASET_NOT_FOUND) + datasets = [DatasetExtended.from_mongo(dataset) for dataset in datasets] + if include_user: + for dataset in datasets: + dataset.user = find_user(dataset.user_id) + if include_categories: + for dataset in datasets: + dataset.categories = find_categories(dataset.id) + return datasets + + +def find_own_datasets(user_id) -> List[Dataset]: + datasets = list(db.datasets + .find({'user_id': user_id})) + if datasets is None: + raise errors.NotFound('Datasets', errors.DATASET_NOT_FOUND) + return [Dataset.from_mongo(dataset) for dataset in datasets] + + +def find_dataset(dataset_id, include_user=False, include_categories=False) -> DatasetExtended: + dataset = db.datasets.find_one({'_id': dataset_id}) + if dataset is None: + raise errors.NotFound('Datasets', errors.DATASET_NOT_FOUND) + dataset = DatasetExtended.from_mongo(dataset) + if include_user: + dataset.user = find_user(dataset.user_id) + if include_categories: + dataset.categories = find_categories(dataset_id) + return dataset + + +def update_dataset(user_id, dataset_id, payload: DatasetPatchBody): + dataset_to_update = db.datasets.find_one({'_id': dataset_id}) + + if not dataset_to_update: + raise errors.NotFound('Datasets', errors.DATASET_NOT_FOUND) + + if dataset_to_update['user_id'] != user_id: + raise errors.Forbidden('Datasets', errors.NOT_YOUR_DATASET) + + db.datasets.find_one_and_update({'_id': dataset_id}, + {'$set': {k: v for k, v in payload.dict().items() if v is not None}}) + + +def insert_dataset(user_id, payload: DatasetPostBody) -> Dataset: + dataset = Dataset( + id=str(uuid4()), + user_id=user_id, + name=payload.name, + description=payload.description, + is_public=False, + created_at=datetime.now(), + image_count=0, + augmented_count=0 + ) + db.datasets.insert_one(dataset.mongo()) + return dataset + + +def remove_dataset(user_id, dataset_id): + dataset_to_remove = db.datasets.find_one({'_id': dataset_id}) + + if not dataset_to_remove: + raise errors.NotFound('Datasets', errors.DATASET_NOT_FOUND) + + if dataset_to_remove['user_id'] != user_id: + raise errors.Forbidden('Datasets', errors.NOT_YOUR_DATASET) + + remove_all_images(dataset_id) + db.categories.delete_many({'dataset_id': dataset_id}) + db.tasks.delete_many({'dataset_id': dataset_id}) + db.datasets.delete_one({'_id': dataset_id, 'user_id': user_id}) + # TODO : delete notifications (with their task_id) + + +def remove_datasets(user_id): + datasets = find_own_datasets(user_id) + + for dataset in datasets: + try: + remove_dataset(user_id, dataset.id) + except errors.Forbidden: + pass diff --git a/api/routers/datasets/datasets.py b/api/routers/datasets/datasets.py new file mode 100644 index 00000000..da9746ad --- /dev/null +++ b/api/routers/datasets/datasets.py @@ -0,0 +1,83 @@ +from fastapi import APIRouter, Depends + +import errors +from dependencies import logged_admin, logged_user +from logger import logger +from routers.datasets.core import find_datasets, find_dataset, remove_dataset, remove_datasets, insert_dataset, \ + update_dataset +from routers.datasets.models import * +from routers.users.models import User +from utils import parse + +datasets = APIRouter() + + +@datasets.get('/', response_model=DatasetsResponse) +def get_datasets(user: User = Depends(logged_user), + include_user: bool = False, + include_categories: bool = False, + offset: int = 0, + limit: int = 0): + """ + Fetch paginated datasets list (either user datasets, or public ones). + """ + response = {'datasets': find_datasets(user.id, offset, limit, + include_user=include_user, + include_categories=include_categories)} + logger.notify('Datasets', f'Fetch datasets for user `{user.id}`') + return parse(response) + + +@datasets.get('/{dataset_id}', response_model=DatasetResponse) +def get_dataset(dataset_id, + include_user: bool = False, + include_categories: bool = False): + """ + Fetch dataset. + """ + response = {'dataset': find_dataset(dataset_id, + include_user=include_user, + include_categories=include_categories)} + logger.notify('Datasets', f'Fetch dataset `{dataset_id}`') + return parse(response) + + +@datasets.post('/', response_model=DatasetResponse) +def post_dataset(payload: DatasetPostBody, user: User = Depends(logged_user)): + """ + Create a new dataset. + 🔒️ Verified users + """ + if not user.is_verified: + raise errors.Forbidden('Auth', errors.USER_NOT_VERIFIED, data='ERR_VERIFY') + response = {'dataset': insert_dataset(user.id, payload)} + logger.notify('Datasets', f'Add dataset `{payload.name}` for user `{user.id}`') + return parse(response) + + +@datasets.patch('/{dataset_id}') +def patch_dataset(dataset_id, payload: DatasetPatchBody, user: User = Depends(logged_user)): + """ + Update dataset (name, description & privacy).. + """ + update_dataset(user.id, dataset_id, payload) + logger.notify('Datasets', f'Update dataset `{dataset_id}` for user `{user.id}`') + + +@datasets.delete('/') +def delete_datasets(admin: User = Depends(logged_admin)): + """ + Delete all datasets of admin user, and other linked collections (`images`, `labels`, `tasks`...). + 🔒️ Admin only + """ + remove_datasets(admin.id) + logger.notify('Datasets', f'Delete datasets for admin `{admin.id}`') + + +@datasets.delete('/{dataset_id}') +def delete_dataset(dataset_id, user: User = Depends(logged_user)): + """ + Delete a dataset, and other linked collections (`images`, `labels`, `tasks`...) + """ + remove_dataset(user.id, dataset_id) + logger.notify('Datasets', f'Delete dataset for user `{user.id}`') diff --git a/api/routers/datasets/models.py b/api/routers/datasets/models.py new file mode 100644 index 00000000..37d33afb --- /dev/null +++ b/api/routers/datasets/models.py @@ -0,0 +1,44 @@ +from datetime import datetime +from typing import List, Optional + +from pydantic import Field, BaseModel + +from routers.categories.models import Category +from routers.users.models import User +from utils import MongoModel + + +class Dataset(MongoModel): + id: str = Field() + user_id: str + name: str + description: str + is_public: bool = False + created_at: datetime + image_count: int + augmented_count: int = 0 + exported_at: Optional[datetime] = None + + +class DatasetExtended(Dataset): + categories: Optional[List[Category]] + user: Optional[User] + + +class DatasetsResponse(BaseModel): + datasets: List[DatasetExtended] = [] + + +class DatasetResponse(BaseModel): + dataset: DatasetExtended + + +class DatasetPostBody(BaseModel): + name: str + description: str + + +class DatasetPatchBody(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + is_public: Optional[bool] = None diff --git a/api/routers/datasources/core.py b/api/routers/datasources/core.py new file mode 100644 index 00000000..bb997b13 --- /dev/null +++ b/api/routers/datasources/core.py @@ -0,0 +1,106 @@ +import json +import os +import zipfile +from typing import List +from uuid import uuid4 + +import requests + +import errors +from config import Config +from logger import logger +from routers.datasources.models import DatasourceKey + +db = Config.db + + +def download_annotations(datasource_key: DatasourceKey): + if not os.path.exists(Config.DATASOURCES_PATH): + os.mkdir(Config.DATASOURCES_PATH) + + datasource_path = os.path.join(Config.DATASOURCES_PATH, datasource_key) + if not os.path.exists(datasource_path): + os.mkdir(datasource_path) + + annotations_path = os.path.join(datasource_path, 'annotations') + datasource = [datasource for datasource in Config.DATASOURCES if datasource['key'] == datasource_key][0] + + if os.path.exists(annotations_path): + return annotations_path, datasource + + logger.notify('Datasources', f'Downloading {datasource["name"]}...') + + response = requests.get(datasource['download_url'], stream=True) + if response.status_code != 200: + raise errors.APIError(503, 'Datasources', f"Datasource {datasource['name']} unreachable") + + datasource_path = os.path.join(Config.DATASOURCES_PATH, datasource_key) + zip_path = os.path.join(datasource_path, f'{datasource_key}.zip') + with open(zip_path, 'wb') as fd: + for chunk in response.iter_content(chunk_size=128): + fd.write(chunk) + + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + zip_ref.extractall(datasource_path) + + os.remove(zip_path) + return annotations_path, datasource + + +def find_datasources() -> List[dict]: + return Config.DATASOURCES + + +def find_categories(datasource_key: DatasourceKey): + try: + annotations_path, datasource = download_annotations(datasource_key) + except Exception as e: + raise errors.InternalError('Datasoures', f'Download of {datasource_key} failed, {str(e)}') + + filename = datasource['filenames'][0] + try: + json_file = open(os.path.join(annotations_path, filename), 'r') + datasource_content = json.load(json_file) + categories = datasource_content['categories'] + datasource_annotations = datasource_content['annotations'] + json_file.close() + except FileNotFoundError: + raise errors.InternalError('Datasoures', f'Filename {filename} not found for datasource {datasource_key}') + + for category in categories: + category['labels_count'] = 0 + + for datasource_label in datasource_annotations: + category_id = datasource_label['category_id'] + category_to_update = next(category for category in categories if category['id'] == category_id) + category_to_update['labels_count'] += 1 + + for category in categories: + category['_id'] = str(uuid4()) + category.pop('id', None) + + return categories + + +def find_max_image_count(datasource_key, selected_categories): + datasource_path = os.path.join(Config.DATASOURCES_PATH, datasource_key) + annotations_path = os.path.join(datasource_path, 'annotations') + datasource = [datasource for datasource in Config.DATASOURCES if datasource['key'] == datasource_key][0] + filename = datasource['filenames'][0] + + try: + json_file = open(os.path.join(annotations_path, filename), 'r') + json_remote_dataset = json.load(json_file) + json_file.close() + categories_remote = [category for category in json_remote_dataset['categories'] + if category['name'] in selected_categories] + category_ids = [category['id'] for category in categories_remote] + + labels_remote = [label for label in json_remote_dataset['annotations'] if label['category_id'] in category_ids] + del json_remote_dataset + + image_remote_ids = [label['image_id'] for label in labels_remote] + except FileNotFoundError: + raise errors.InternalError('Datasoures', f'Filename {filename} not found for datasource {datasource_key}') + + return len(set(image_remote_ids)) diff --git a/api/routers/datasources/datasources.py b/api/routers/datasources/datasources.py new file mode 100644 index 00000000..5af9dbc7 --- /dev/null +++ b/api/routers/datasources/datasources.py @@ -0,0 +1,42 @@ +from fastapi import APIRouter + +from logger import logger +from routers.datasources.core import find_datasources, find_categories, find_max_image_count +from routers.datasources.models import * +from utils import parse + +datasources = APIRouter() + + +@datasources.get('/', response_model=DatasourcesResponse) +def get_datasources(): + """ + Fetch list of datasources. + 🔒️ Admin only + """ + response = {'datasources': find_datasources()} + logger.notify('Datasources', f'Fetch datasources') + return parse(response) + + +@datasources.get('/categories', response_model=DatasourceCategoriesResponse) +def get_categories(datasource_key: DatasourceKey): + """ + Fetch available categories for given datasource. + 🔒️ Admin only + """ + response = {'categories': find_categories(datasource_key)} + logger.notify('Datasources', f'Fetch categories for datasource `{datasource_key}`') + return parse(response) + + +@datasources.post('/max-image-count', response_model=DatasourceMaxImageCountResponse) +def post_image_count(payload: DatasourceMaxImageCountBody): + """ + Fetch max image count for given datasource and selected categories. + 🔒️ Admin only + """ + result = find_max_image_count(payload.datasource_key, payload.selected_categories) + response = {'max_image_count': result} + logger.notify('Datasources', f'Fetch image_count ({result}) for datasource `{payload.datasource_key}`') + return parse(response) diff --git a/api/routers/datasources/models.py b/api/routers/datasources/models.py new file mode 100644 index 00000000..1ac00f06 --- /dev/null +++ b/api/routers/datasources/models.py @@ -0,0 +1,42 @@ +from enum import Enum +from typing import List, Optional + +from pydantic import AnyHttpUrl + +from routers.categories.models import SuperCategory +from utils import BaseModel + + +class DatasourceKey(str, Enum): + coco2014 = 'coco2014' + coco2017 = 'coco2017' + + +class Datasource(BaseModel): + key: DatasourceKey + name: str + download_url: AnyHttpUrl + filenames: List[str] + + +class DatasourcesResponse(BaseModel): + datasources: List[Datasource] + + +class DatasourceCategory(BaseModel): + name: str + labels_count: int + supercategory: Optional[SuperCategory] = None + + +class DatasourceCategoriesResponse(BaseModel): + categories: List[DatasourceCategory] = [] + + +class DatasourceMaxImageCountBody(BaseModel): + datasource_key: DatasourceKey + selected_categories: List[str] + + +class DatasourceMaxImageCountResponse(BaseModel): + max_image_count: int diff --git a/api/routers/exports/core.py b/api/routers/exports/core.py new file mode 100644 index 00000000..e1c1037e --- /dev/null +++ b/api/routers/exports/core.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from config import Config +from routers.datasets.models import DatasetExtended +from routers.exports.models import Export +from routers.images.core import find_all_images, find_labels_from_image_ids +from utils import parse + +db = Config.db + + +def process_export(dataset: DatasetExtended) -> Export: + images = find_all_images(dataset.id) + export = Export.parse_obj({ + **parse(dataset), + 'images': images, + 'labels': find_labels_from_image_ids([image.id for image in images]) + }) + db.datasets.find_one_and_update({'_id': dataset.id}, + {'$set': {'exported_at': datetime.now()}}) + return export diff --git a/api/routers/exports/exports.py b/api/routers/exports/exports.py new file mode 100644 index 00000000..254b46e5 --- /dev/null +++ b/api/routers/exports/exports.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Depends + +import errors +from config import Config +from dependencies import logged_user +from logger import logger +from routers.datasets.core import find_dataset +from routers.exports.core import process_export +from routers.exports.models import * +from routers.users.models import User +from utils import parse + +db = Config.db + +exports = APIRouter() + + +@exports.get('/', response_model=Export) +def get_export(dataset_id, user: User = Depends(logged_user)): + """ + Process dataset export. + """ + dataset = find_dataset(dataset_id, include_categories=True) + + if not dataset.is_public and dataset.user_id != user.id: + raise errors.Forbidden('Auth', errors.NOT_YOUR_DATASET) + + response = process_export(dataset) + logger.notify('Exports', f'Process export of dataset `{dataset.id}`') + return parse(response) diff --git a/api/routers/exports/models.py b/api/routers/exports/models.py new file mode 100644 index 00000000..5e635c65 --- /dev/null +++ b/api/routers/exports/models.py @@ -0,0 +1,10 @@ +from typing import List + +from routers.datasets.models import DatasetExtended +from routers.images.models import Image +from routers.labels.models import Label + + +class Export(DatasetExtended): + images: List[Image] + labels: List[Label] diff --git a/api/routers/images/core.py b/api/routers/images/core.py new file mode 100644 index 00000000..cd5af7a0 --- /dev/null +++ b/api/routers/images/core.py @@ -0,0 +1,222 @@ +import concurrent.futures +from typing import List +from uuid import uuid4 + +import boto3 +import cv2 +import numpy + +import errors +from config import Config +from routers.images.models import Image, ImageExtended +from routers.labels.core import find_labels_from_image_ids, regroup_labels_by_category +from routers.labels.models import Label + +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg'} + +db = Config.db +s3 = boto3.client( + "s3", + aws_access_key_id=Config.S3_KEY, + aws_secret_access_key=Config.S3_SECRET +) + + +def allowed_file(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + + +def compress_image(image): + width = image.shape[1] + height = image.shape[0] + if width > 1280: + compression_ratio = width / 1280 + dist_shape = (int(width / compression_ratio), int(height / compression_ratio)) + image = cv2.resize(image, dist_shape) + elif height > 720: + compression_ratio = height / 720 + dist_shape = (int(width / compression_ratio), int(height / compression_ratio)) + image = cv2.resize(image, dist_shape) + return image + + +def upload_image(image_bytes, image_id): + try: + s3.put_object(Bucket=Config.S3_BUCKET, Key=image_id, Body=image_bytes, ACL='public-read') + path = f"{Config.S3_LOCATION}{image_id}" + return path + except Exception as e: + print(e) + raise errors.InternalError('Images', f'Cannot upload file to S3, {str(e)}') + + +def upload_file(payload) -> Image: + file = payload['file'] + filename = payload['filename'] + dataset_id = payload['dataset_id'] + if file and allowed_file(filename): + image_id = str(uuid4()) + name = filename # FIXME : secure filename + image = cv2.imdecode(numpy.fromstring(file.read(), numpy.uint8), cv2.IMREAD_UNCHANGED) + image = compress_image(image) + image_bytes = cv2.imencode('.jpg', image)[1].tostring() + path = upload_image(image_bytes, image_id) + return Image( + id=image_id, + dataset_id=dataset_id, + path=path, + name=name, + size=len(image_bytes), + width=image.shape[1], + height=image.shape[0], + ) + + +def delete_images_from_s3(image_ids): + def getrows_byslice(array): + for start in range(0, len(array), 1000): + yield array[start:start + 1000] + + try: + for image_ids_to_delete in getrows_byslice(image_ids): + s3.delete_objects( + Bucket=Config.S3_BUCKET, + Delete={'Objects': [{'Key': image_id} for image_id in image_ids_to_delete]} + ) + except Exception as e: + raise errors.InternalError('Images', f'Cannot delete file from S3, {str(e)}') + + +def delete_image_from_s3(image_id): + try: + s3.delete_object( + Bucket=Config.S3_BUCKET, + Key=image_id + ) + except Exception as e: + raise errors.InternalError('Images', f'Cannot delete file from S3, {str(e)}') + + +def find_all_images(dataset_id, offset=0, limit=0) -> List[Image]: + images = list(db.images + .find({'dataset_id': dataset_id}) + .skip(offset) + .limit(limit)) + if images is None: + raise errors.NotFound('Images', errors.IMAGE_NOT_FOUND) + images = [ImageExtended.from_mongo(image) for image in images] + return images + + +def find_images(dataset_id, + original_image_id=None, + pipeline_id=None, + include_labels=False, + offset=0, + limit=0) -> List[ImageExtended]: + if original_image_id and pipeline_id: + query = {'dataset_id': dataset_id, 'original_image_id': original_image_id, 'pipeline_id': pipeline_id} + elif pipeline_id: + query = {'dataset_id': dataset_id, 'pipeline_id': pipeline_id} + elif original_image_id: + query = {'dataset_id': dataset_id, 'original_image_id': original_image_id} + else: + query = {'dataset_id': dataset_id, 'original_image_id': None, 'pipeline_id': None} + + images = list(db.images + .find(query) + .skip(offset) + .limit(limit)) + if images is None: + raise errors.NotFound('Images', errors.IMAGE_NOT_FOUND) + images = [ImageExtended.from_mongo(image) for image in images] + if include_labels: + labels = find_labels_from_image_ids([image.id for image in images]) + if labels is not None: + for image in images: + image.labels = [label for label in labels if label.image_id == image.id] + return images + + +def find_image(dataset_id, image_id) -> Image: + image = db.images.find_one({'_id': image_id, 'dataset_id': dataset_id}) + if image is None: + raise errors.NotFound('Images', errors.IMAGE_NOT_FOUND) + return Image.from_mongo(image) + + +def insert_images(dataset_id, request_files) -> List[Image]: + with concurrent.futures.ThreadPoolExecutor() as executor: + results = executor.map(upload_file, [{'filename': file.filename, 'file': file.file, 'dataset_id': dataset_id} + for file in request_files]) + images = list(filter(None.__ne__, results)) + db.images.insert_many([image.mongo() for image in images]) + db.datasets.update_one({'_id': dataset_id}, + {'$inc': { + 'image_count': len(images) + }}, + upsert=False) + return images + + +def remove_all_images(dataset_id): + images = find_all_images(dataset_id) + image_ids = [image.id for image in images if image.original_image_id is None] + augmented_image_ids = [image.id for image in images if image.original_image_id] + remove_original_images(dataset_id, image_ids) + remove_augmented_images(dataset_id, augmented_image_ids) + db.pipelines.delete_many({'dataset_id': dataset_id}) + + +def remove_original_images(dataset_id, image_ids): + # Find labels of images to delete + labels = list(db.labels.find({'image_id': {'$in': image_ids}})) + labels = [Label.from_mongo(label) for label in labels] + + # Delete images and associated labels + delete_images_from_s3(image_ids) + db.images.delete_many({'dataset_id': dataset_id, '_id': {'$in': image_ids}}) + db.labels.delete_many({'image_id': {'$in': image_ids}}) + + # Decrease labels_count on associated categories + for category_id, labels_count in regroup_labels_by_category(labels).items(): + db.categories.find_one_and_update( + {'dataset_id': dataset_id, '_id': category_id}, + {'$inc': {'labels_count': -labels_count}} + ) + + # Decrease image_count on associated dataset + db.datasets.update_one({'_id': dataset_id}, + {'$inc': {'image_count': -len(image_ids)}}, + upsert=False) + + +def remove_augmented_images(dataset_id, augmented_image_ids): + # Find labels of augmented images to delete + labels = list(db.labels.find({'image_id': {'$in': augmented_image_ids}})) + labels = [Label.from_mongo(label) for label in labels] + + # Delete augmented images and associated labels + delete_images_from_s3(augmented_image_ids) + db.images.delete_many({'dataset_id': dataset_id, '_id': {'$in': augmented_image_ids}}) + db.labels.delete_many({'image_id': {'$in': augmented_image_ids}}) + + # Decrease labels_count on associated categories + for category_id, labels_count in regroup_labels_by_category(labels).items(): + db.categories.find_one_and_update( + {'dataset_id': dataset_id, '_id': category_id}, + {'$inc': {'labels_count': -labels_count}} + ) + + # Decrease augmented_count on associated dataset + db.datasets.update_one({'_id': dataset_id}, + {'$inc': {'augmented_count': -len(augmented_image_ids)}}, + upsert=False) + + +def remove_image(dataset_id, image_id) -> int: + remove_original_images(dataset_id, [image_id]) + augmented_image_ids_to_delete = [image.id for image in find_images(dataset_id, original_image_id=image_id)] + if augmented_image_ids_to_delete: + remove_augmented_images(dataset_id, augmented_image_ids_to_delete) + return 1 + len(augmented_image_ids_to_delete) diff --git a/api/routers/images/images.py b/api/routers/images/images.py new file mode 100644 index 00000000..9071eed3 --- /dev/null +++ b/api/routers/images/images.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, Depends, File, UploadFile + +from dependencies import dataset_belongs_to_user +from logger import logger +from routers.images.core import find_images, find_image, remove_all_images, remove_image, insert_images +from routers.images.models import * +from utils import parse + +images = APIRouter() + + +@images.get('/', response_model=ImagesResponse) +def get_images(dataset_id: str, + original_image_id: Optional[str] = None, + include_labels=False, + offset: int = 0, + limit: int = 0): + """ + Fetch paginated images list of given dataset. + """ + response = {'images': find_images(dataset_id, + original_image_id, + include_labels=include_labels, + offset=offset, + limit=limit)} + logger.notify('Images', f'Fetch images of dataset `{dataset_id}`') + return parse(response) + + +@images.get('/ids', response_model=ImageIdsResponse) +def get_image_ids(dataset_id: str): + """ + Fetch all image_ids of given dataset (original image_ids or image ids). + """ + response = {'image_ids': [image.id for image in find_images(dataset_id)]} + logger.notify('Images', f'Fetch image_ids of dataset `{dataset_id}`') + return parse(response) + + +@images.get('/{image_id}', response_model=ImageResponse) +def get_image(dataset_id, image_id): + """ + Fetch given image of given dataset. + """ + response = {'image': find_image(dataset_id, image_id)} + logger.notify('Images', f'Fetch image `{image_id}` of dataset `{dataset_id}`') + return parse(response) + + +@images.post('/') +def post_images(dataset_id, files: List[UploadFile] = File(...), dataset=Depends(dataset_belongs_to_user)): + """ + Upload a list of images. + """ + response = {'images': insert_images(dataset_id, files)} + logger.notify('Images', f'Upload {len(files)} for dataset `{dataset_id}`') + return parse(response) + + +@images.delete('/') +def delete_images(dataset_id, dataset=Depends(dataset_belongs_to_user)): + """ + Delete all images (original & augmented) of given dataset. + """ + logger.notify('Images', f'Delete images of dataset `{dataset_id}`') + remove_all_images(dataset_id) + + +@images.delete('/{image_id}', response_model=ImageDeleteResponse) +def delete_image(dataset_id, image_id, dataset=Depends(dataset_belongs_to_user)): + """ + Delete given image of given dataset. + """ + response = {'deleted_count': remove_image(dataset_id, image_id)} + logger.notify('Images', f'Delete image `{image_id}` of dataset `{dataset_id}`') + return parse(response) diff --git a/api/routers/images/models.py b/api/routers/images/models.py new file mode 100644 index 00000000..c5f19af8 --- /dev/null +++ b/api/routers/images/models.py @@ -0,0 +1,38 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + +from routers.labels.models import Label +from utils import MongoModel + + +class Image(MongoModel): + id: str = Field() + dataset_id: str + path: str + name: str + size: int + width: int + height: int + pipeline_id: Optional[str] = None + original_image_id: Optional[str] = None + + +class ImageExtended(Image): + labels: Optional[List[Label]] + + +class ImagesResponse(BaseModel): + images: List[ImageExtended] = [] + + +class ImageIdsResponse(BaseModel): + image_ids: List[str] = [] + + +class ImageResponse(BaseModel): + image: Image + + +class ImageDeleteResponse(BaseModel): + deleted_count: int diff --git a/api/routers/labels/core.py b/api/routers/labels/core.py new file mode 100644 index 00000000..8e31d632 --- /dev/null +++ b/api/routers/labels/core.py @@ -0,0 +1,83 @@ +import uuid +from typing import Dict, List + +import errors +from config import Config +from routers.labels.models import Label + +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg'} + +db = Config.db + + +def regroup_labels_by_category(labels: List[Label]) -> Dict[str, int]: + result = {} + for label in labels: + if label.category_id in result.keys(): + result[label.category_id] += 1 + else: + result[label.category_id] = 1 + return result + + +def merge_regrouped_labels(old, new) -> Dict[str, int]: + result = new + for category_id, labels_count in old.items(): + if category_id in result.keys(): + result[category_id] -= labels_count + else: + result[category_id] = -labels_count + return result + + +def find_labels(image_id, offset=0, limit=0) -> List[Label]: + labels = list(db.labels.find({'image_id': image_id}).skip(offset).limit(limit)) + if labels is None: + raise errors.NotFound('Labels', errors.LABEL_NOT_FOUND) + return [Label.from_mongo(label) for label in labels] + + +def find_labels_from_image_ids(image_ids, offset=0, limit=0) -> List[Label]: + labels = list(db.labels.find({'image_id': {'$in': image_ids}}).skip(offset).limit(limit)) + if labels is None: + raise errors.NotFound('Labels', errors.LABEL_NOT_FOUND) + return [Label.from_mongo(label) for label in labels] + + +def find_label(image_id, label_id) -> Label: + label = db.labels.find_one({'_id': label_id, + 'image_id': image_id}) + if label is None: + raise errors.NotFound('Labels', errors.LABEL_NOT_FOUND) + return Label.from_mongo(label) + + +def find_labels_of_category(category_id) -> List[Label]: + labels = db.labels.find({'category_id': category_id}) + return [Label.from_mongo(label) for label in labels] + + +def replace_labels(image_id, labels) -> Dict[str, int]: + # Find current image labels + old_labels = find_labels(image_id) + + # Delete these labels + db.labels.delete_many({'image_id': image_id}) + + # Update labels_count on associated categories + old = regroup_labels_by_category(old_labels) + new = regroup_labels_by_category(labels) + merged = merge_regrouped_labels(old, new) + for category_id, labels_count in merged.items(): + db.categories.find_one_and_update( + {'_id': category_id}, + {'$inc': {'labels_count': labels_count}} + ) + + # Insert new labels + if labels: + db.labels.insert_many([{**label.dict(), + 'image_id': image_id, + '_id': str(uuid.uuid4())} for label in labels]) + + return merged diff --git a/api/routers/labels/labels.py b/api/routers/labels/labels.py new file mode 100644 index 00000000..2fdcbd83 --- /dev/null +++ b/api/routers/labels/labels.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, Depends +from typing import Dict + +from dependencies import dataset_belongs_to_user +from logger import logger +from routers.labels.core import find_labels, find_label, replace_labels +from routers.labels.models import * +from utils import parse + +labels = APIRouter() + + +@labels.get('/', response_model=LabelsResponse) +def get_labels(image_id, offset: int = 0, limit: int = 0): + """ + Fetch paginated labels list of given image. + """ + response = {'labels': find_labels(image_id, offset, limit)} + logger.notify('Labels', f'Fetch labels of image `{image_id}`') + return parse(response) + + +@labels.get('/{label_id}', response_model=LabelResponse) +def get_label(image_id, label_id): + """ + Fetch given label of given image. + """ + response = {'label': find_label(image_id, label_id)} + logger.notify('Labels', f'Fetch label `{label_id}` of image `{image_id}`') + return parse(response) + + +@labels.post('/', response_model=Dict[str, int]) +def post_labels(image_id, payload: LabelPostBody, dataset=Depends(dataset_belongs_to_user)): + """ + Replace labels to a given image. + """ + response = replace_labels(image_id, payload.labels) + logger.notify('Labels', f'Update labels of image `{image_id}`') + return parse(response) diff --git a/api/routers/labels/models.py b/api/routers/labels/models.py new file mode 100644 index 00000000..31a52336 --- /dev/null +++ b/api/routers/labels/models.py @@ -0,0 +1,27 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + +from utils import MongoModel + + +class Label(MongoModel): + id: str = Field() + x: float + y: float + w: float + h: float + image_id: Optional[str] = None + category_id: Optional[str] = None + + +class LabelsResponse(BaseModel): + labels: List[Label] = [] + + +class LabelResponse(BaseModel): + label: Label + + +class LabelPostBody(BaseModel): + labels: List[Label] diff --git a/api/routers/notifications/core.py b/api/routers/notifications/core.py new file mode 100644 index 00000000..42a17e10 --- /dev/null +++ b/api/routers/notifications/core.py @@ -0,0 +1,32 @@ +from datetime import datetime +from typing import List +from uuid import uuid4 + +import errors +from config import Config +from routers.notifications.models import Notification + +db = Config.db + + +def find_notifications(user_id) -> List[Notification]: + notifications = list(db.notifications.find({'user_id': user_id})) + if notifications is None: + raise errors.NotFound('Notifications', errors.NOTIFICATION_NOT_FOUND) + return [Notification.from_mongo(notification) for notification in notifications] + + +def insert_notification(user_id, notification: Notification): + db.notifications.insert_one({'_id': str(uuid4()), + 'user_id': user_id, + 'created_at': datetime.now(), + 'opened': False, + **notification.dict()}) + + +def read_notifications(user_id): + db.notifications.update_many({'user_id': user_id}, {'$set': {'opened': True}}) + + +def remove_notifications(user_id): + db.notifications.delete_many({'user_id': user_id}) diff --git a/api/routers/notifications/models.py b/api/routers/notifications/models.py new file mode 100644 index 00000000..66473dbf --- /dev/null +++ b/api/routers/notifications/models.py @@ -0,0 +1,30 @@ +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + +from utils import MongoModel + + +class NotificationType(str, Enum): + EMAIL_CONFIRM_REQUIRED = 'EMAIL_CONFIRM_REQUIRED' + EMAIL_CONFIRM_DONE = 'EMAIL_CONFIRM_DONE' + REGISTRATION = 'REGISTRATION' + TASK_SUCCEED = 'TASK_SUCCEED' + TASK_FAILED = 'TASK_FAILED' + + +class Notification(MongoModel): + id: str = Field() + user_id: str + created_at: datetime + opened: bool + type: NotificationType + task_id: Optional[str] = None + + +class NotificationPostBody(BaseModel): + type: NotificationType + task_id: Optional[str] = None + description: Optional[str] = None diff --git a/api/routers/notifications/notifications.py b/api/routers/notifications/notifications.py new file mode 100644 index 00000000..ee385862 --- /dev/null +++ b/api/routers/notifications/notifications.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, Depends + +from dependencies import logged_user +from logger import logger +from routers.notifications.core import read_notifications, remove_notifications +from routers.users.models import User + +notifications = APIRouter() + + +@notifications.patch('/read') +def patch_notifications(user: User = Depends(logged_user)): + """ + Fetch notifications of logged user + """ + read_notifications(user.id) + logger.notify('Notifications', f'Read notifications for user `{user.id}`') + + +@notifications.delete('/') +def delete_notifications(user: User = Depends(logged_user)): + """ + Delete notifications of logged user + """ + remove_notifications(user.id) + logger.notify('Notifications', f'Remove notifications for user `{user.id}`') diff --git a/api/routers/pipelines/core.py b/api/routers/pipelines/core.py new file mode 100644 index 00000000..ba3f2f8c --- /dev/null +++ b/api/routers/pipelines/core.py @@ -0,0 +1,132 @@ +import random +from typing import List, Union +from uuid import uuid4 + +import cv2 +import numpy +import requests +from Augmentor import DataPipeline +from PIL import Image as PILImage + +import errors +from config import Config +from routers.images.core import find_images, remove_augmented_images +from routers.images.models import Image +from routers.labels.models import Label +from routers.pipelines.models import Operation +from routers.pipelines.models import Pipeline + +db = Config.db + + +def find_pipelines(dataset_id, offset=0, limit=0) -> List[Label]: + pipelines = list(db.pipelines.find({'dataset_id': dataset_id}).skip(offset).limit(limit)) + if pipelines is None: + raise errors.NotFound('Pipelines', errors.PIPELINE_NOT_FOUND) + return [Pipeline.from_mongo(pipeline) for pipeline in pipelines] + + +def delete_pipeline(dataset_id, pipeline_id): + images = find_images(dataset_id, pipeline_id=pipeline_id) + augmented_image_ids = [image.id for image in images] + if images: + remove_augmented_images(dataset_id, augmented_image_ids) + db.pipelines.delete_one({'_id': pipeline_id}) + + +def from_image_bytes(image_bytes): + return cv2.imdecode(numpy.fromstring(image_bytes, numpy.uint8), cv2.IMREAD_UNCHANGED) + + +def from_image_path(path): + image_bytes = requests.get(path).content + return from_image_bytes(image_bytes) + + +def draw_ellipsis(width, height, label: Label): + blank_image = numpy.zeros((height, width, 3), numpy.uint8) + x = int(label.x * width) + y = int(label.y * height) + w = int(label.w * width) + h = int(label.h * height) + center = tuple([int(x + w / 2), int(y + h / 2)]) + axes = tuple([int(w / 2) + 1, int(h / 2) + 1]) + cv2.ellipse(blank_image, center, axes, 0, 0, 360, (255, 255, 255), -1) + return blank_image + + +def retrieve_label_from_ellipsis(image, image_id) -> Union[Label, None]: + mask = cv2.inRange(image, (120, 120, 120), (255, 255, 255)) + rect = cv2.boundingRect(mask) + if not rect: + return None + label = Label( + id=str(uuid4()), + image_id=image_id, + x=round(rect[0] / image.shape[1], 6), + y=round(rect[1] / image.shape[0], 6), + w=round(rect[2] / image.shape[1], 6), + h=round(rect[3] / image.shape[0], 6) + ) + return label + + +class AugmentorPipeline(DataPipeline): + + def __init__(self, image: Image, labels: List[Label]): + self.image = image + self.labels = labels + super().__init__(image, labels) + + def sample(self, n, cv2image=None): + if cv2image is None: + images = [from_image_path(self.image.path)] + else: + images = [cv2image] + + for label in self.labels: + images.append(draw_ellipsis(self.image.width, self.image.height, label)) + + input_images = [PILImage.fromarray(x) for x in images] + + output_images = [] + output_images_labels: List[List[Label]] = [] + for i in range(0, n): + augmented_images = input_images + + for operation in self.operations: + roll = round(random.uniform(0, 1), 1) + if roll <= operation.probability: + augmented_images = operation.perform_operation(augmented_images) + + output_images.append(numpy.asarray(augmented_images[0])) + + labels = [retrieve_label_from_ellipsis(numpy.asarray(image), self.image.id) + for image in augmented_images[1:]] + + for index, label in enumerate(labels): + if label: + label.category_id = self.labels[index].category_id + + labels = list(filter(None.__ne__, labels)) + + output_images_labels.append(labels) + + return output_images, output_images_labels + + +def perform_sample(image: Image, labels: List[Label], operations: List[Operation], cv2image=None, n=None): + pipeline = AugmentorPipeline(image, labels) + for operation in operations: + getattr(pipeline, operation.type)(probability=operation.probability, **operation.properties) + if cv2image is not None: + if cv2image.shape[1] > cv2image.shape[0]: + return pipeline.sample(4, cv2image=cv2image) + else: + return pipeline.sample(3, cv2image=cv2image) + elif n: + return pipeline.sample(n) + elif image.width > image.height: + return pipeline.sample(4) + else: + return pipeline.sample(3) diff --git a/api/routers/pipelines/models.py b/api/routers/pipelines/models.py new file mode 100644 index 00000000..8ca072b5 --- /dev/null +++ b/api/routers/pipelines/models.py @@ -0,0 +1,49 @@ +from enum import Enum +from typing import List + +from pydantic import BaseModel, Field +from routers.labels.models import Label + +from utils import MongoModel + + +class OperationType(str, Enum): + ROTATE = 'rotate' + FLIP_RANDOM = 'flip_random' + SKEW = 'skew' + CROP_RANDOM = 'crop_random' + SHEAR = 'shear' + RANDOM_DISTORTION = 'random_distortion' + GAUSSIAN_DISTORTION = 'gaussian_distortion' + RANDOM_BRIGHTNESS = 'random_brightness' + RANDOM_COLOR = 'random_color' + RANDOM_CONTRAST = 'random_contrast' + HISTOGRAM_EQUALISATION = 'histogram_equalisation' + INVERT = 'invert' + GREYSCALE = 'greyscale' + + +class Operation(BaseModel): + type: OperationType + probability: float + properties: dict + + +class Pipeline(MongoModel): + id: str = Field() + dataset_id: str + image_count: int + operations: List[Operation] + + +class PipelinesResponse(BaseModel): + pipelines: List[Pipeline] + + +class SampleBody(BaseModel): + operations: List[Operation] + + +class SampleResponse(BaseModel): + images: List[str] # base64 encoded + images_labels: List[List[Label]] diff --git a/api/routers/pipelines/pipelines.py b/api/routers/pipelines/pipelines.py new file mode 100644 index 00000000..eb52759d --- /dev/null +++ b/api/routers/pipelines/pipelines.py @@ -0,0 +1,61 @@ +import base64 + +import cv2 +from fastapi import APIRouter, Depends + +from dependencies import dataset_belongs_to_user +from logger import logger +from routers.images.core import find_images +from routers.pipelines.core import find_pipelines, perform_sample, delete_pipeline +from routers.pipelines.models import * +from utils import parse + +pipelines = APIRouter() + + +@pipelines.get('/', response_model=PipelinesResponse) +def get_dataset_pipelines(dataset_id, offset: int = 0, limit: int = 0): + """ + Fetch paginated pipelines list of given dataset. + """ + response = {'pipelines': find_pipelines(dataset_id, offset, limit)} + logger.notify('Pipelines', f'Fetch pipelines of dataset `{dataset_id}`') + return parse(response) + + +@pipelines.post('/sample', response_model=SampleResponse) +def do_sample(dataset_id, payload: SampleBody, dataset=Depends(dataset_belongs_to_user)): + """ + Execute a sample of augmentor operations pipeline + """ + operations = payload.operations + + images = find_images(dataset_id, limit=1, include_labels=True) + + augmented_images = [] + augmented_labels = [] + + for image in images: + current_images, current_labels = perform_sample(image, image.labels, operations) + augmented_images.extend(current_images) + augmented_labels.extend(current_labels) + + encoded_images = [cv2.imencode('.jpg', augmented_image)[1].tostring() + for augmented_image in augmented_images] + base64_encoded_images = [base64.b64encode(image) for image in encoded_images] + + logger.notify('Pipelines', f'Do sample with {len(operations)} operations for dataset `{dataset_id}`') + + return { + 'images': base64_encoded_images, + 'images_labels': parse(augmented_labels) + } + + +@pipelines.delete('/{pipeline_id}') +def delete_dataset_pipeline(dataset_id, pipeline_id, dataset=Depends(dataset_belongs_to_user)): + """ + Delete a pipeline & associated images, labels, and task + """ + delete_pipeline(dataset_id, pipeline_id) + logger.notify('Pipelines', f'Delete pipeline `{pipeline_id}` of dataset `{dataset_id}`') diff --git a/api/routers/tasks/core.py b/api/routers/tasks/core.py new file mode 100644 index 00000000..cca94118 --- /dev/null +++ b/api/routers/tasks/core.py @@ -0,0 +1,61 @@ +from datetime import datetime +from typing import List +from uuid import uuid4 + +import errors +from config import Config +from routers.datasets.core import find_own_datasets +from routers.tasks.models import Task, TaskStatus +from worker import run_generator, run_augmentor + +db = Config.db + + +def user_is_allowed_to_create_task(user, dataset_id, task_type) -> bool: + if task_type == 'generator': + if not user.is_admin: + raise errors.Forbidden('Tasks', errors.USER_NOT_ADMIN) + + if task_type == 'augmentor': + if dataset_id is None: + raise errors.Forbidden('Tasks', errors.DATASET_NOT_FOUND) + user_dataset_ids = [dataset.id for dataset in find_own_datasets(user.id)] + if dataset_id not in user_dataset_ids: + raise errors.Forbidden('Tasks', errors.NOT_YOUR_DATASET) + if task_type == 'augmentor': + pipelines_count = db.pipelines.count({'dataset_id': dataset_id}) + if pipelines_count >= 1: + raise errors.Forbidden('Tasks', errors.TOO_MANY_PIPELINES) + return True + + +def find_dataset_tasks(user_id, dataset_id) -> List[Task]: + tasks = list(db.tasks.find({'user_id': user_id, 'dataset_id': dataset_id})) + if tasks is None: + raise errors.NotFound('Tasks', errors.TASK_NOT_FOUND) + return [Task.from_mongo(task) for task in tasks] + + +def insert_task(user, dataset_id, task_type, properties) -> Task: + assert user_is_allowed_to_create_task(user, dataset_id, task_type) + + task = Task( + id=str(uuid4()), + user_id=user.id, + dataset_id=dataset_id, + type=task_type, + created_at=datetime.now(), + status=TaskStatus('pending'), + progress=0, + properties=properties + ) + + db.tasks.insert_one(task.mongo()) + + if task.type == 'generator': + run_generator.delay(user.id, task.id, properties=task.properties) + + if task.type == 'augmentor': + run_augmentor.delay(user.id, task.id, dataset_id, properties=task.properties) + + return task diff --git a/api/routers/tasks/models.py b/api/routers/tasks/models.py new file mode 100644 index 00000000..f96da464 --- /dev/null +++ b/api/routers/tasks/models.py @@ -0,0 +1,58 @@ +from datetime import datetime +from enum import Enum +from typing import List, Optional, Union + +from pydantic import BaseModel, Field + +from routers.datasources.models import DatasourceKey +from routers.pipelines.models import Operation +from utils import MongoModel + + +class TaskType(str, Enum): + generator = 'generator' + augmentor = 'augmentor' + + +class TaskStatus(str, Enum): + pending = 'pending' + active = 'active' + success = 'success' + failed = 'failed' + + +class TaskGeneratorProperties(BaseModel): + datasource_key: DatasourceKey + selected_categories: List[str] + image_count: int + + +class TaskAugmentorProperties(BaseModel): + image_count: int + operations: List[Operation] + + +TaskProperties = Union[TaskGeneratorProperties, TaskAugmentorProperties] + + +class Task(MongoModel): + id: str = Field() + user_id: str + dataset_id: Optional[str] = None + type: TaskType + properties: Optional[TaskProperties] = None + status: TaskStatus + progress: float + created_at: datetime + ended_at: Optional[datetime] = None + error: Optional[str] = None + + +class TaskPostBody(BaseModel): + type: TaskType + properties: TaskProperties + + +class TaskResponse(BaseModel): + task: Task + diff --git a/api/routers/tasks/tasks.py b/api/routers/tasks/tasks.py new file mode 100644 index 00000000..7f2f4b79 --- /dev/null +++ b/api/routers/tasks/tasks.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Depends + +from dependencies import logged_user +from logger import logger +from routers.tasks.core import insert_task +from routers.tasks.models import * +from routers.users.models import User +from utils import parse + +tasks = APIRouter() + + +@tasks.post('/', response_model=TaskResponse) +def post_task(payload: TaskPostBody, dataset_id=None, user: User = Depends(logged_user)): + """ + Create a new pending task + """ + response = {'task': insert_task(user, dataset_id, payload.type, payload.properties)} + logger.notify('Tasks', f'Add task `{payload.type}` for user `{user.id}`') + return parse(response) diff --git a/api/routers/users/core.py b/api/routers/users/core.py new file mode 100644 index 00000000..9d233d94 --- /dev/null +++ b/api/routers/users/core.py @@ -0,0 +1,82 @@ +from typing import List, Union + +import errors +from config import Config +from routers.users.models import User +from utils import encrypt_field, password_context + +db = Config.db + + +def find_users(offset=0, limit=0) -> List[User]: + users = list(db.users.find().skip(offset).limit(limit)) + return [User.from_mongo(user) for user in users] + + +def find_user(user_id) -> User: + user = db.users.find_one({'_id': user_id}) + if not user: + raise errors.NotFound('Users', errors.USER_NOT_FOUND) + return User.from_mongo(user) + + +def find_user_by_email(email) -> Union[User, None]: + user = db.users.find_one({'email': email}) + if not user: + return None + return User.from_mongo(user) + + +def find_user_by_recovery_code(recovery_code) -> User: + user = db.users.find_one_and_update({'recovery_code': recovery_code}, {'$set': {'recovery_code': None}}) + if not user: + raise errors.Forbidden('Users', 'Invalid recovery code. Please try again.') + return User.from_mongo(user) + + +def update_user(user, update): + db.users.find_one_and_update({'_id': user.id}, + {'$set': dict(update)}, + projection={'_id': 0}) + + +def update_user_password(user, password, new_password): + user_full = db.users.find_one({'_id': user.id}) + user_password_encrypted = user_full.get('password') + + if user.scope or not user_password_encrypted: + raise errors.BadRequest('Users', errors.USER_HAS_A_SCOPE) + + user_password = bytes(user_password_encrypted, 'utf-8') + + if not password_context.verify(password, user_password): + raise errors.InvalidAuthentication('Users', errors.INVALID_PASSWORD) + + encrypted_password = password_context.hash(new_password) + db.users.find_one_and_update({'_id': user.id}, + {'$set': {'password': encrypt_field(encrypted_password)}}) + + +def reset_user_password(user, new_password): + if user.scope: + raise errors.BadRequest('Users', errors.USER_HAS_A_SCOPE) + + encrypted_password = password_context.hash(new_password) + db.users.find_one_and_update({'_id': user.id}, + {'$set': {'password': encrypt_field(encrypted_password)}}) + + +def remove_users(user_ids): + db.notifications.delete_many({'user_id': {'$in': user_ids}}) + db.users.delete_many({'_id': {'$in': user_ids}, 'is_admin': False}) + + +def remove_user(user_id): + user_to_delete = find_user(user_id) + if not user_to_delete: + raise errors.NotFound('Users', errors.USER_NOT_FOUND) + if user_to_delete.is_admin: + raise errors.Forbidden('Users', errors.USER_IS_ADMIN) + + db.notifications.delete_many({'user_id': user_id}) + db.users.delete_one({'_id': user_id, 'is_admin': False}) diff --git a/api/routers/users/models.py b/api/routers/users/models.py new file mode 100644 index 00000000..6c013767 --- /dev/null +++ b/api/routers/users/models.py @@ -0,0 +1,34 @@ +from typing import Any, List + +from pydantic import BaseModel + +from authentication.models import User + + +class UserWithPassword(User): + password: Any + + +class UsersResponse(BaseModel): + users: List[User] = [] + + +class UserResponse(BaseModel): + user: User + + +class UserUpdateProfileBody(BaseModel): + city: str + country: str + is_public: bool + name: str + phone: str + + +class UserUpdatePasswordBody(BaseModel): + password: str + new_password: str + + +class UserDeleteBody(BaseModel): + user_ids: List[str] diff --git a/api/routers/users/users.py b/api/routers/users/users.py new file mode 100644 index 00000000..7a1b7335 --- /dev/null +++ b/api/routers/users/users.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter, Depends + +from dependencies import logged_admin, logged_user +from logger import logger +from routers.users.core import find_users, find_user, remove_users, remove_user, update_user, update_user_password +from routers.users.models import * +from utils import parse + +users = APIRouter() + + +@users.get('/', response_model=UsersResponse, dependencies=[Depends(logged_admin)]) +def get_users(offset: int = 0, limit: int = 0): + """ + Fetch paginated datatensor users list. + 🔒️ Admin only + """ + response = {'users': find_users(offset, limit)} + logger.notify('Users', f'Fetch users') + return parse(response) + + +@users.get('/{user_id}', response_model=UserResponse) +def get_user(user_id): + """ + Fetch user, given `user_id` + """ + response = {'user': find_user(user_id)} + logger.notify('Users', f'Fetch user `{user_id}`') + return parse(response) + + +@users.patch('/me') +def patch_user(update: UserUpdateProfileBody, user: User = Depends(logged_user)): + """ + Update logged user profile + """ + update_user(user, update) + logger.notify('Users', f'Update user profile of user `{user.id}`') + + +@users.patch('/me/password') +def patch_user_password(payload: UserUpdatePasswordBody, user: User = Depends(logged_user)): + """ + Update logged user password + """ + update_user_password(user, payload.password, payload.new_password) + logger.notify('Users', f'Update user password of user `{user.id}`') + + +@users.delete('/', dependencies=[Depends(logged_admin)]) +def delete_users(payload: UserDeleteBody): + """ + Delete selected users, if they aren't admin. + 🔒️ Admin only + """ + remove_users(payload.user_ids) + logger.notify('Users', f'Removed {len(payload.user_ids)} users') + + +@users.delete('/{user_id}', dependencies=[Depends(logged_admin)]) +def delete_user(user_id): + """ + Delete given user, if he is not an admin. + 🔒️ Admin only + """ + remove_user(user_id) + logger.notify('Users', f'Removed user `{user_id}`') diff --git a/api/run_worker.sh b/api/run_worker.sh new file mode 100644 index 00000000..f0aa753e --- /dev/null +++ b/api/run_worker.sh @@ -0,0 +1,2 @@ +export PYTHONPATH=$PYTHONPATH:/Users/paulruelle/PycharmProjects/datatensor +celery -A worker worker --loglevel=INFO \ No newline at end of file diff --git a/api/search/core.py b/api/search/core.py new file mode 100644 index 00000000..8b1709aa --- /dev/null +++ b/api/search/core.py @@ -0,0 +1,47 @@ +from typing import List, Union + +from config import Config +from routers.categories.models import Category +from routers.datasets.core import find_datasets +from routers.images.core import find_images +from routers.labels.models import Label +from utils import get_unique + +db = Config.db + + +def search_categories(user_id) -> List[Category]: + datasets = find_datasets(user_id) + categories = list(db.categories.find({'dataset_id': {'$in': [dataset.id for dataset in datasets]}})) + categories = get_unique(categories, 'name') + return [Category.from_mongo(category) for category in categories] + + +def search_dataset_ids_from_category_names(category_names: List[str]): + query = '|'.join(category_names) + categories = list(db.categories.find({'$or': [{'name': {'$regex': query, '$options': 'i'}}, + {'supercategory': {'$regex': query, '$options': 'i'}}]})) + matched_datasets_ids = [category['dataset_id'] for category in categories] + return list(set(matched_datasets_ids)) + + +def search_unlabeled_image_id(dataset_id, offset) -> Union[str, None]: + image_ids = [image.id for image in find_images(dataset_id)] + labels = list(db.labels.find({'image_id': {'$in': image_ids}})) + labels = [Label.from_mongo(label) for label in labels] + + unlabeled_image_id = None + + labeled_image_ids = set(label.image_id for label in labels) + try: + unlabeled_image_id = next(image_id for image_id in image_ids[offset:] + if image_id not in labeled_image_ids + and image_ids.index(image_id) > offset) + except StopIteration: + try: + if not unlabeled_image_id: + unlabeled_image_id = next(image_id for image_id in image_ids + if image_id not in labeled_image_ids) + except StopIteration: + pass + return unlabeled_image_id diff --git a/api/search/models.py b/api/search/models.py new file mode 100644 index 00000000..715b8004 --- /dev/null +++ b/api/search/models.py @@ -0,0 +1,35 @@ +from typing import List, Union + +from pydantic import BaseModel + +from routers.categories.models import Category +from routers.datasets.models import Dataset +from routers.images.models import Image +from routers.users.models import User + + +class SearchDatatensorResult(BaseModel): + datasets: List[Dataset] + categories: List[Category] + images: List[Image] + users: List[User] + + +class SearchDatatensorResponse(BaseModel): + result: SearchDatatensorResult + + +class SearchDatasetsPayload(BaseModel): + category_names: List[str] + + +class SearchCategoriesResponse(BaseModel): + categories: List[Category] + + +class SearchDatasetsResponse(BaseModel): + dataset_ids: List[str] + + +class SearchUnlabeledImageIdResponse(BaseModel): + image_id: Union[str, None] diff --git a/api/search/search.py b/api/search/search.py new file mode 100644 index 00000000..de0bd1e5 --- /dev/null +++ b/api/search/search.py @@ -0,0 +1,38 @@ +from fastapi import APIRouter, Depends + +from dependencies import dataset_belongs_to_user, logged_user +from logger import logger +from search.core import search_categories, search_dataset_ids_from_category_names, search_unlabeled_image_id +from search.models import * +from utils import parse + +search = APIRouter() + + +@search.get('/categories', response_model=SearchCategoriesResponse) +def search_unique_public_categories(user: User = Depends(logged_user)): + response = { + 'categories': search_categories(user.id) + } + logger.notify('Search', f'Fetch categories') + return parse(response) + + +@search.post('/datasets', response_model=SearchDatasetsResponse) +def search_datasets_from_category_names(payload: SearchDatasetsPayload): + response = { + 'dataset_ids': search_dataset_ids_from_category_names(payload.category_names) + } + logger.notify('Search', f'Fetch dataset ids for category names {payload.category_names}') + return parse(response) + + +@search.post('/datasets/{dataset_id}/unlabeled-image-id', response_model=SearchUnlabeledImageIdResponse) +def search_next_unlabeled_image(dataset_id, + offset: int = 0, + dataset=Depends(dataset_belongs_to_user)): + response = { + 'image_id': search_unlabeled_image_id(dataset_id, offset) + } + logger.notify('Search', f'Fetch unlabeled image id for dataset `{dataset_id}`') + return parse(response) diff --git a/api/utils.py b/api/utils.py new file mode 100755 index 00000000..b25227af --- /dev/null +++ b/api/utils.py @@ -0,0 +1,109 @@ +import json +from uuid import UUID + +from bson import json_util +from bson.objectid import ObjectId +from datetime import date, datetime +from passlib.context import CryptContext +from pydantic import BaseModel, BaseConfig +from pymongo.encryption import Algorithm + +from config import Config + +db = Config.db + +password_context = CryptContext(schemes=['bcrypt'], deprecated='auto') + + +def encrypt_field(data): + return Config.DB_ENCRYPT_CLIENT.encrypt( + data, + Algorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Random, + key_alt_name='datatensor_key' + ) + + +def default(value): + if isinstance(value, (datetime, date)): + return value.isoformat() + elif isinstance(value, ObjectId): + return str(value) + elif isinstance(value, UUID): + return str(value) + elif isinstance(value, BaseModel): + return value.dict() + else: + return json_util.default(value) + + +def parse(data): + return json.loads(json.dumps(data, default=default)) + + +def update_task(task_id, **args): + db.tasks.find_one_and_update({'_id': task_id}, {'$set': args}) + + +def increment_task_progress(task_id, delta): + db.tasks.update_one( + {'_id': task_id}, + { + '$inc': { + 'progress': delta + } + }, upsert=False) + + +class OID(str): + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, v): + return ObjectId(str(v)) + + +class MongoModel(BaseModel): + class Config(BaseConfig): + allow_population_by_field_name = True + json_encoders = { + datetime: lambda dt: dt.isoformat(), + ObjectId: lambda oid: str(oid), + UUID: lambda: str(UUID) + } + + def mongo(self, **kwargs): + exclude_unset = kwargs.pop('exclude_unset', True) + by_alias = kwargs.pop('by_alias', True) + + parsed = self.dict( + exclude_unset=exclude_unset, + by_alias=by_alias, + **kwargs, + ) + + # Mongo uses `_id` as default key. We should stick to that as well. + if '_id' not in parsed and 'id' in parsed: + parsed['_id'] = parsed.pop('id') + + return parsed + + @classmethod + def from_mongo(cls, data: dict): + if not data: + return data + id = data.pop('_id', None) + return cls(**dict(data, id=id)) + + +def get_unique(iterable, key): + unique_keys = list(set([el[key].lower() for el in iterable])) + result = [] + for unique_key in unique_keys: + try: + unique_element = next(el for el in iterable if el[key] == unique_key) + result.append(unique_element) + except StopIteration: + pass + return result diff --git a/api/websocket/socket.py b/api/websocket/socket.py new file mode 100644 index 00000000..1c214556 --- /dev/null +++ b/api/websocket/socket.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter, WebSocket +from starlette.websockets import WebSocketDisconnect +from websockets.exceptions import ConnectionClosedOK + +from authentication.core import verify_access_token +from logger import logger +from routers.notifications.core import find_notifications +from routers.tasks.core import find_dataset_tasks +from utils import parse, update_task + +sockets = APIRouter() + + +@sockets.websocket('/ws/datasets/{dataset_id}/tasks') +async def get_tasks(dataset_id: str, websocket: WebSocket): + """ + Websocket | paginated list of tasks. + """ + await websocket.accept() + while True: + try: + access_token = await websocket.receive_text() + user = verify_access_token(access_token) + tasks = find_dataset_tasks(user.id, dataset_id) + await websocket.send_json(parse(tasks)) + except ConnectionClosedOK: + logger.notify('Websocket', f'Tasks websocket closed') + break + except WebSocketDisconnect: + logger.notify('Websocket', f'Tasks websocket disconnected') + break + + +@sockets.websocket('/ws/notifications') +async def get_tasks(websocket: WebSocket): + """ + Websocket | paginated list of tasks. + """ + await websocket.accept() + while True: + try: + access_token = await websocket.receive_text() + user = verify_access_token(access_token) + result = find_notifications(user.id) + response = {'notifications': result} + await websocket.send_json(parse(response)) + except ConnectionClosedOK: + logger.notify('Websocket', f'Notifications closed') + break + except WebSocketDisconnect: + logger.notify('Websocket', f'Notifications websocket disconnected') + break diff --git a/api/worker.py b/api/worker.py new file mode 100644 index 00000000..16e3466f --- /dev/null +++ b/api/worker.py @@ -0,0 +1,66 @@ +import functools +from datetime import datetime + +from celery import Celery + +import errors +from routers.notifications.core import insert_notification +from routers.notifications.models import NotificationPostBody, NotificationType +from utils import update_task +from workflows.augmentor import augmentor +from workflows.generator import generator + +app = Celery('worker', broker='pyamqp://', backend='rpc://') + + +class CeleryConfig: + task_serializer = 'pickle' + result_serializer = 'pickle' + event_serializer = 'json' + accept_content = ['application/json', 'application/x-python-serialize'] + result_accept_content = ['application/json', 'application/x-python-serialize'] + + +app.config_from_object(CeleryConfig) + + +def handle_task_error(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + user_id = args[0] + task_id = args[1] + try: + result = func(*args, **kwargs) + except errors.APIError as error: + update_task(task_id, status='failed', error=error.detail, ended_at=datetime.now()) + notification = NotificationPostBody(type=NotificationType('TASK_FAILED'), + task_id=task_id, + description=error.detail) + insert_notification(user_id=user_id, notification=notification) + except Exception as e: + message = f"An error occured {str(e)}" + update_task(task_id, status='failed', error=message, ended_at=datetime.now()) + notification = NotificationPostBody(type=NotificationType('TASK_FAILED'), + task_id=task_id, + description=message) + insert_notification(user_id=user_id, notification=notification) + else: + update_task(task_id, status='success', progress=1, ended_at=datetime.now()) + notification = NotificationPostBody(type=NotificationType('TASK_SUCCEED'), + task_id=task_id) + insert_notification(user_id=user_id, notification=notification) + return result + + return wrapper + + +@app.task +@handle_task_error +def run_generator(user_id, task_id, properties): + generator.main(user_id, task_id, properties) + + +@app.task +@handle_task_error +def run_augmentor(user_id, task_id, dataset_id, properties): + augmentor.main(user_id, task_id, dataset_id, properties) diff --git a/api/workflows/augmentor/augmentor.py b/api/workflows/augmentor/augmentor.py new file mode 100644 index 00000000..fcde7c6b --- /dev/null +++ b/api/workflows/augmentor/augmentor.py @@ -0,0 +1,121 @@ +import concurrent.futures +import random +from uuid import uuid4 + +import cv2 +import numpy +from Augmentor import DataPipeline +from PIL import Image as PILImage + +from config import Config +from routers.images.core import find_images, upload_image +from routers.images.models import Image +from routers.labels.core import find_labels, regroup_labels_by_category +from routers.pipelines.core import from_image_path, draw_ellipsis, retrieve_label_from_ellipsis +from routers.pipelines.models import Pipeline +from routers.tasks.models import TaskAugmentorProperties +from utils import update_task, increment_task_progress + +db = Config.db + + +def process_augmentation(payload): + index = payload['index'] + image = payload['image'] + labels = payload['labels'] + operations = payload['operations'] + dataset_id = payload['dataset_id'] + pipeline_id = payload['pipeline_id'] + task_id = payload['task_id'] + image_count = payload['image_count'] + + images = [from_image_path(image.path)] + + for label in labels[index]: + images.append(draw_ellipsis(image.width, image.height, label)) + + input_images = [PILImage.fromarray(x) for x in images] + + augmented_images = input_images + + for operation in operations: + roll = round(random.uniform(0, 1), 1) + if roll <= operation.probability: + augmented_images = operation.perform_operation(augmented_images) + + new_image_id = str(uuid4()) + + new_labels = [retrieve_label_from_ellipsis(numpy.asarray(augmented_image), new_image_id) + for augmented_image in augmented_images[1:]] + for j, label in enumerate(new_labels): + if label: + label.category_id = labels[index][j].category_id + new_labels = list(filter(None.__ne__, new_labels)) + + image_bytes = cv2.imencode('.jpg', numpy.asarray(augmented_images[0]))[1].tostring() + path = upload_image(image_bytes, new_image_id) + new_image = Image( + id=new_image_id, + dataset_id=dataset_id, + path=path, + name=f'augmented-{image.name}', + size=len(image_bytes), + width=augmented_images[0].width, + height=augmented_images[0].height, + pipeline_id=pipeline_id, + original_image_id=image.id + ) + db.images.insert_one(new_image.mongo()) + if new_labels: + db.labels.insert_many([label.mongo() for label in new_labels]) + + for category_id, labels_count in regroup_labels_by_category(new_labels).items(): + db.categories.find_one_and_update( + {'_id': category_id}, + {'$inc': {'labels_count': labels_count}} + ) + + increment_task_progress(task_id, 1 / image_count) + + +class AugmentorPipeline(DataPipeline): + + def __init__(self, task_id, dataset_id, properties): + self.task_id = task_id + self.dataset_id = dataset_id + self.images = find_images(dataset_id) + self.labels = [find_labels(image.id) for image in self.images] + self.properties = properties + super().__init__(self.images, self.labels) + + def sample(self): + pipeline_id = str(uuid4()) + pipeline = Pipeline( + id=pipeline_id, + dataset_id=self.dataset_id, + operations=self.properties.operations, + image_count=self.properties.image_count + ) + db.pipelines.insert_one(pipeline.mongo()) + db.datasets.update_one({'_id': self.dataset_id}, + {'$inc': {'augmented_count': self.properties.image_count}}, + upsert=False) + with concurrent.futures.ThreadPoolExecutor() as executor: + result = executor.map(process_augmentation, + [{'index': index % len(self.images), + 'image': self.images[index % len(self.images)], + 'labels': self.labels, + 'operations': self.operations, + 'dataset_id': self.dataset_id, + 'pipeline_id': pipeline_id, + 'task_id': self.task_id, + 'image_count': self.properties.image_count + } for index in range(self.properties.image_count)]) + + +def main(user_id, task_id, dataset_id, properties: TaskAugmentorProperties): + update_task(task_id, status='active') + pipeline = AugmentorPipeline(task_id, dataset_id, properties) + for operation in properties.operations: + getattr(pipeline, operation.type)(probability=operation.probability, **operation.properties) + pipeline.sample() diff --git a/api/workflows/generator/generator.py b/api/workflows/generator/generator.py new file mode 100644 index 00000000..0e1b48b5 --- /dev/null +++ b/api/workflows/generator/generator.py @@ -0,0 +1,178 @@ +import concurrent.futures +import json +import os +from datetime import datetime +from time import sleep +from uuid import uuid4 + +import requests + +import errors +from config import Config +from routers.datasets.models import Dataset +from routers.datasources.core import download_annotations +from routers.images.core import allowed_file, upload_image +from routers.labels.core import regroup_labels_by_category +from routers.labels.models import Label +from routers.tasks.models import TaskGeneratorProperties +from utils import update_task, increment_task_progress + +db = Config.db + + +# TODO : refactor this (use models) + + +def _download_image(image_url): + try: + response = requests.get(image_url) + if response.status_code != 200: + return + return response + except requests.exceptions.ConnectionError: + return + + +def _process_image(args): + task_id = args['task_id'] + dataset_id = args['dataset_id'] + image_remote_dataset = args['image_remote_dataset'] + image_count = args['image_count'] + category_labels = args['category_labels'] + categories = args['categories'] + filename = image_remote_dataset['file_name'] + if filename and allowed_file(filename): + image_id = str(uuid4()) + response = _download_image(image_remote_dataset['flickr_url']) + if not response: + response = _download_image(image_remote_dataset['coco_url']) + if not response: + return + image_bytes = response.content + path = upload_image(image_bytes, image_id) + saved_image = { + '_id': image_id, + 'dataset_id': dataset_id, + 'path': path, + 'name': str(filename), + 'size': len(image_bytes), + 'width': image_remote_dataset['width'], + 'height': image_remote_dataset['height'] + } + labels = [{ + '_id': str(uuid4()), + 'image_id': image_id, + 'x': category_label['bbox'][0] / image_remote_dataset['width'], + 'y': category_label['bbox'][1] / image_remote_dataset['height'], + 'w': category_label['bbox'][2] / image_remote_dataset['width'], + 'h': category_label['bbox'][3] / image_remote_dataset['height'], + 'category_id': category_label['category_id'] + } for category_label in category_labels] + saved_categories = [] + for label in labels: + category = [category for category in categories + if category['_internal_id'] == label['category_id']][0] + label['category_id'] = category['_id'] + + if category['name'] not in [saved_category['name'] for saved_category in saved_categories]: + saved_categories.append(category) + + db.images.insert_one(saved_image) + db.labels.insert_many(labels) + labels = [Label.from_mongo(label) for label in labels] + for category_id, labels_count in regroup_labels_by_category(labels).items(): + db.categories.find_one_and_update( + {'_id': category_id}, + {'$inc': {'labels_count': labels_count}} + ) + increment_task_progress(task_id, 1 / image_count) + + +def _filter_annotations(json_remote_dataset, selected_categories, image_count=None): + categories_remote = [category for category in json_remote_dataset['categories'] + if category['name'] in selected_categories] + category_ids = [category['id'] for category in categories_remote] + + labels_remote = [label for label in json_remote_dataset['annotations'] + if label['category_id'] in category_ids] + label_ids = [label['image_id'] for label in labels_remote] + + images_remote = [image for image in json_remote_dataset['images'] if image['id'] in label_ids] + if image_count: + images_remote = images_remote[:image_count] + return images_remote, categories_remote, labels_remote + + +def _generate_dataset_name(categories): + supercategories = list(set(category['supercategory'] for category in categories))[:4] + dataset_name = f"{', '.join(supercategories)}" + return dataset_name.title() + + +def main(user_id, task_id, properties: TaskGeneratorProperties): + datasource_key = properties.datasource_key + selected_categories = properties.selected_categories + image_count = properties.image_count + + dataset_id = str(uuid4()) + update_task(task_id, dataset_id=dataset_id, status='active') + + try: + download_annotations(datasource_key) + except Exception as e: + raise errors.InternalError('Datasources', f'Download of {datasource_key} failed, {str(e)}') + + datasource = [datasource for datasource in Config.DATASOURCES if datasource['key'] == datasource_key][0] + + # TODO : use multiple filenames + filename = datasource['filenames'][0] + + annotations_path = os.path.join(Config.DATASOURCES_PATH, datasource_key, 'annotations') + json_file = open(os.path.join(annotations_path, filename), 'r') + json_remote_dataset = json.load(json_file) + json_file.close() + + images_remote, categories_remote, labels_remote = _filter_annotations(json_remote_dataset, + selected_categories, + image_count) + del json_remote_dataset + + categories = [{ + '_id': str(uuid4()), + '_internal_id': category['id'], + 'dataset_id': dataset_id, + 'name': category['name'], + 'supercategory': category['supercategory'] + } for category in categories_remote] + + dataset = Dataset(id=dataset_id, + user_id=user_id, + created_at=datetime.now(), + name=_generate_dataset_name(categories), + description=f"Generated with {len(categories)} categorie{'s' if len(categories) > 1 else ''}, from {datasource['name']}", + image_count=image_count, + augmented_count=0, + is_public=True) + db.datasets.insert_one(dataset.mongo()) + db.categories.insert_many([{ + '_id': category['_id'], + 'dataset_id': category['dataset_id'], + 'name': category['name'], + 'supercategory': category['supercategory'], + 'labels_count': 0 + } for category in categories]) + + with concurrent.futures.ThreadPoolExecutor() as executor: + executor.map(_process_image, + ({'task_id': task_id, + 'dataset_id': dataset_id, + 'image_remote_dataset': image, + 'image_count': image_count, + 'category_labels': [el for el in labels_remote if el['image_id'] == image['id']], + 'categories': categories} + for image in images_remote)) + + sleep(1) + + image_count = db.images.count({'dataset_id': dataset_id}) + db.datasets.find_one_and_update({'_id': dataset_id}, {'$set': {'image_count': image_count}}) diff --git a/builds/docker-compose.yml b/builds/docker-compose.yml new file mode 100755 index 00000000..eb03bbec --- /dev/null +++ b/builds/docker-compose.yml @@ -0,0 +1,89 @@ +version: '3.7' + +services: + + api: + image: ghcr.io/ruellepaul/datatensor/datatensor-api:v_1.0.3 + environment: + - 'ENVIRONMENT=${ENVIRONMENT}' + - 'DB_ENCRYPTION_KEY=${DB_ENCRYPTION_KEY}' + - 'ACCESS_TOKEN_KEY=${ACCESS_TOKEN_KEY}' + - 'GOOGLE_CAPTCHA_SECRET_KEY=${GOOGLE_CAPTCHA_SECRET_KEY}' + - 'SENDGRID_API_KEY=${SENDGRID_API_KEY}' + - 'OAUTH_GITHUB_CLIENT_SECRET=${OAUTH_GITHUB_CLIENT_SECRET}' + - 'OAUTH_GOOGLE_CLIENT_SECRET=${OAUTH_GOOGLE_CLIENT_SECRET}' + - 'OAUTH_STACKOVERFLOW_CLIENT_SECRET=${OAUTH_STACKOVERFLOW_CLIENT_SECRET}' + - 'OAUTH_STACKOVERFLOW_KEY=${OAUTH_STACKOVERFLOW_KEY}' + - 'S3_KEY=${S3_KEY}' + - 'S3_SECRET=${S3_SECRET}' + build: + context: ../ + dockerfile: Dockerfile-api + volumes: + - './${ENVIRONMENT}/config.py:/api/config.py' + network_mode: host + + + celery: + image: docker.pkg.github.com/ruellepaul/datatensor/datatensor-celery:v_${VERSION} + build: + context: ../ + dockerfile: Dockerfile-celery + environment: + - 'ENVIRONMENT=${ENVIRONMENT}' + - 'DB_ENCRYPTION_KEY=${DB_ENCRYPTION_KEY}' + - 'ACCESS_TOKEN_KEY=${ACCESS_TOKEN_KEY}' + - 'GOOGLE_CAPTCHA_SECRET_KEY=${GOOGLE_CAPTCHA_SECRET_KEY}' + - 'SENDGRID_API_KEY=${SENDGRID_API_KEY}' + - 'OAUTH_GITHUB_CLIENT_SECRET=${OAUTH_GITHUB_CLIENT_SECRET}' + - 'OAUTH_GOOGLE_CLIENT_SECRET=${OAUTH_GOOGLE_CLIENT_SECRET}' + - 'OAUTH_STACKOVERFLOW_CLIENT_SECRET=${OAUTH_STACKOVERFLOW_CLIENT_SECRET}' + - 'OAUTH_STACKOVERFLOW_KEY=${OAUTH_STACKOVERFLOW_KEY}' + - 'S3_KEY=${S3_KEY}' + - 'S3_SECRET=${S3_SECRET}' + network_mode: host + depends_on: + - api + volumes: + - './${ENVIRONMENT}/config.py:/api/config.py' + restart: on-failure + + + db: + image: mongo + network_mode: host + + + proxy: + build: + context: proxy + network_mode: host + volumes: + - '/var/www/letsencrypt:/var/www/letsencrypt' + - '/etc/letsencrypt:/etc/letsencrypt' + + + rabbitmq: + image: rabbitmq:latest + container_name: 'rabbitmq' + network_mode: host + + + ux: + image: docker.pkg.github.com/ruellepaul/datatensor/datatensor-ux:v_${VERSION}_${ENVIRONMENT} + build: + args: + - 'ENVIRONMENT=${ENVIRONMENT}' + context: ../ + dockerfile: Dockerfile-ux + volumes: + - '/ux/node_modules' + network_mode: host + + filebeat: + build: + context: ./production/elk/filebeat + volumes: + - '/var/lib/docker:/var/lib/docker:ro' + - '/var/run/docker.sock:/var/run/docker.sock' + network_mode: host \ No newline at end of file diff --git a/builds/production/.env b/builds/production/.env new file mode 100644 index 00000000..3b26d664 --- /dev/null +++ b/builds/production/.env @@ -0,0 +1,5 @@ +NOVE_ENV="production" +REACT_APP_ENVIRONMENT="production" + +REACT_APP_GOOGLE_CAPTCHA_SITE_KEY="6LdTS80cAAAAAMLwEK7LkU2JdSGg4qGoOUrvAiBJ" +REACT_APP_OAUTH_GOOGLE_CLIENT_ID="1015468889518-qfog501sgfjv8jusml7pvjpps8gdoeru.apps.googleusercontent.com" \ No newline at end of file diff --git a/builds/production/config.py b/builds/production/config.py new file mode 100755 index 00000000..11b3cc19 --- /dev/null +++ b/builds/production/config.py @@ -0,0 +1,103 @@ +import os +from typing import Any, List + +from fastapi import FastAPI +from pydantic import AnyHttpUrl, BaseSettings + +import errors +from database import encrypt_init + +os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' # to use OAuth2 without https + +if 'ACCESS_TOKEN_KEY' not in os.environ: + raise errors.InternalError( + detail='Environment variable are not set. Use init_env.sh script.' + ) + + +class Settings(BaseSettings): + ENVIRONMENT = os.environ['ENVIRONMENT'] + + ROOT_PATH: str = os.path.abspath(os.path.join(FastAPI().root_path, os.pardir)) + DATASOURCES_PATH: str = os.path.join(ROOT_PATH, 'api', 'workflows', 'generator', 'datasources') + + UI_URL: str = 'https://datatensor.io' + API_URI: str = 'https://api.datatensor.io' + + MAX_CONTENT_LENGTH: int = 1 * 1000 * 1024 * 1024 # 1 Go + + ADMIN_USER_IDS: List[str] = [ + '58a802c1b350056c737ca447db48c7c645581b265e61d2ceeae5e0320adc7e6a', # RuellePaul (github) + 'b813fd7e62edcdd7b630837e2f7314e0aa28684eca85a15787be242386ee4e0f' # RuellePaul (google) + ] + + DATASOURCES: List[dict] = [ + { + 'key': 'coco2014', + 'name': 'COCO 2014', + 'download_url': 'http://images.cocodataset.org/annotations/annotations_trainval2014.zip', + 'filenames': ['instances_val2014.json', 'instances_train2014.json'] + }, + { + 'key': 'coco2017', + 'name': 'COCO 2017', + 'download_url': 'http://images.cocodataset.org/annotations/annotations_trainval2017.zip', + 'filenames': ['instances_val2017.json', 'instances_train2017.json'] + }, + ] + + ACCESS_TOKEN_KEY: str = os.environ['ACCESS_TOKEN_KEY'] + SESSION_DURATION_IN_MINUTES: int = 120 + + GOOGLE_CAPTCHA_SECRET_KEY: str = os.environ['GOOGLE_CAPTCHA_SECRET_KEY'] + + SENDGRID_API_KEY: str = os.environ['SENDGRID_API_KEY'] + + OAUTH: dict = { + 'github': { + 'AUTHORIZATION_URL': 'https://github.com/login/oauth/authorize', + 'TOKEN_URL': 'https://github.com/login/oauth/access_token', + 'USER_URL': 'https://api.github.com/user', + 'CLIENT_ID': '0eff110490cfa6f0efc0', + 'CLIENT_SECRET': os.environ['OAUTH_GITHUB_CLIENT_SECRET'], + 'SCOPES': ['openid', 'email', 'profile'] + }, + 'google': { + 'AUTHORIZATION_URL': 'https://accounts.google.com/o/oauth2/v2/auth', + 'TOKEN_URL': 'https://oauth2.googleapis.com/token', + 'USER_URL': 'https://openidconnect.googleapis.com/v1/userinfo', + 'CLIENT_ID': '1015468889518-qfog501sgfjv8jusml7pvjpps8gdoeru.apps.googleusercontent.com', + 'CLIENT_SECRET': os.environ['OAUTH_GOOGLE_CLIENT_SECRET'], + 'SCOPES': ['openid', 'email', 'profile'] + }, + 'stackoverflow': { + 'AUTHORIZATION_URL': 'https://stackoverflow.com/oauth', + 'TOKEN_URL': 'https://stackoverflow.com/oauth/access_token/json', + 'USER_URL': 'https://api.stackexchange.com/2.2/me?site=stackoverflow', + 'CLIENT_ID': '21110', + 'CLIENT_SECRET': os.environ['OAUTH_STACKOVERFLOW_CLIENT_SECRET'], + 'SCOPES': [], + 'KEY': os.environ['OAUTH_STACKOVERFLOW_KEY'] + } + } + + S3_BUCKET: str = 'dtproductionbucket' + S3_KEY: str = os.environ['S3_KEY'] + S3_SECRET: str = os.environ['S3_SECRET'] + S3_LOCATION: AnyHttpUrl = f'http://{S3_BUCKET}.s3.amazonaws.com/' + + DB_ENCRYPTION_KEY: str = os.environ['DB_ENCRYPTION_KEY'] + DB_HOST: str = 'mongodb://127.0.0.1:27017/' + DB_NAME: str = '' + DB_ENCRYPT_CLIENT: Any = None + db: Any = None + + def __init__(self): + super().__init__() + self.DB_NAME: str = f'datatensor_{self.ENVIRONMENT}' + self.DB_ENCRYPT_CLIENT, self.db = encrypt_init(self.DB_HOST, + db_name=self.DB_NAME, + key=self.DB_ENCRYPTION_KEY) + + +Config = Settings() diff --git a/builds/production/elk/docker-compose.yml b/builds/production/elk/docker-compose.yml new file mode 100644 index 00000000..432adb7b --- /dev/null +++ b/builds/production/elk/docker-compose.yml @@ -0,0 +1,138 @@ +version: "2.2" + +services: + setup: + image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION} + volumes: + - certs:/usr/share/elasticsearch/config/certs + user: "0" + command: > + bash -c ' + if [ x${ELASTIC_PASSWORD} == x ]; then + echo "Set the ELASTIC_PASSWORD environment variable in the .env file"; + exit 1; + elif [ x${KIBANA_PASSWORD} == x ]; then + echo "Set the KIBANA_PASSWORD environment variable in the .env file"; + exit 1; + fi; + if [ ! -f certs/ca.zip ]; then + echo "Creating CA"; + bin/elasticsearch-certutil ca --silent --pem -out config/certs/ca.zip; + unzip config/certs/ca.zip -d config/certs; + fi; + if [ ! -f certs/certs.zip ]; then + echo "Creating certs"; + echo -ne \ + "instances:\n"\ + " - name: es01\n"\ + " dns:\n"\ + " - es01\n"\ + " - localhost\n"\ + " ip:\n"\ + " - 127.0.0.1\n"\ + > config/certs/instances.yml; + bin/elasticsearch-certutil cert --silent --pem -out config/certs/certs.zip --in config/certs/instances.yml --ca-cert config/certs/ca/ca.crt --ca-key config/certs/ca/ca.key; + unzip config/certs/certs.zip -d config/certs; + fi; + echo "Setting file permissions" + chown -R root:root config/certs; + find . -type d -exec chmod 750 \{\} \;; + find . -type f -exec chmod 640 \{\} \;; + echo "Waiting for Elasticsearch availability"; + until curl -s --cacert config/certs/ca/ca.crt https://es01:9200 | grep -q "missing authentication credentials"; do sleep 30; done; + echo "Setting kibana_system password"; + until curl -s -X POST --cacert config/certs/ca/ca.crt -u elastic:${ELASTIC_PASSWORD} -H "Content-Type: application/json" https://es01:9200/_security/user/kibana_system/_password -d "{\"password\":\"${KIBANA_PASSWORD}\"}" | grep -q "^{}"; do sleep 10; done; + echo "All done!"; + ' + healthcheck: + test: ["CMD-SHELL", "[ -f config/certs/es01/es01.crt ]"] + interval: 1s + timeout: 5s + retries: 120 + + es01: + depends_on: + setup: + condition: service_healthy + image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION} + volumes: + - certs:/usr/share/elasticsearch/config/certs + - esdata01:/usr/share/elasticsearch/data + ports: + - ${ES_PORT}:9200 + environment: + - node.name=es01 + - cluster.name=${CLUSTER_NAME} + - cluster.initial_master_nodes=es01 + - ELASTIC_PASSWORD=${ELASTIC_PASSWORD} + - bootstrap.memory_lock=true + - xpack.security.enabled=true + - xpack.security.http.ssl.enabled=true + - xpack.security.http.ssl.key=certs/es01/es01.key + - xpack.security.http.ssl.certificate=certs/es01/es01.crt + - xpack.security.http.ssl.certificate_authorities=certs/ca/ca.crt + - xpack.security.http.ssl.verification_mode=certificate + - xpack.security.transport.ssl.enabled=true + - xpack.security.transport.ssl.key=certs/es01/es01.key + - xpack.security.transport.ssl.certificate=certs/es01/es01.crt + - xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt + - xpack.security.transport.ssl.verification_mode=certificate + - xpack.license.self_generated.type=${LICENSE} + mem_limit: ${MEM_LIMIT} + ulimits: + memlock: + soft: -1 + hard: -1 + healthcheck: + test: + [ + "CMD-SHELL", + "curl -s --cacert config/certs/ca/ca.crt https://localhost:9200 | grep -q 'missing authentication credentials'", + ] + interval: 10s + timeout: 10s + retries: 120 + + logstash: + image: docker.elastic.co/logstash/logstash:${STACK_VERSION} + environment: + - ELASTIC_PASSWORD=${ELASTIC_PASSWORD} + volumes: + - ./logstash/pipeline:/usr/share/logstash/pipeline + - ./ca.crt:/usr/share/logstash/config/certs/ca/ca.crt + network_mode: host + + kibana: + depends_on: + es01: + condition: service_healthy + image: docker.elastic.co/kibana/kibana:${STACK_VERSION} + volumes: + - certs:/usr/share/kibana/config/certs + - kibanadata:/usr/share/kibana/data + ports: + - ${KIBANA_PORT}:5601 + environment: + - SERVERNAME=kibana + - ELASTICSEARCH_HOSTS=https://es01:9200 + - ELASTICSEARCH_USERNAME=kibana_system + - ELASTICSEARCH_PASSWORD=${KIBANA_PASSWORD} + - ELASTICSEARCH_SSL_CERTIFICATEAUTHORITIES=config/certs/ca/ca.crt + mem_limit: ${MEM_LIMIT} + healthcheck: + test: + [ + "CMD-SHELL", + "curl -s -I http://localhost:5601 | grep -q 'HTTP/1.1 302 Found'", + ] + interval: 10s + timeout: 10s + retries: 120 + +volumes: + certs: + driver: local + esdata01: + driver: local + kibanadata: + driver: local diff --git a/builds/production/elk/filebeat/Dockerfile b/builds/production/elk/filebeat/Dockerfile new file mode 100644 index 00000000..4248b243 --- /dev/null +++ b/builds/production/elk/filebeat/Dockerfile @@ -0,0 +1,9 @@ +FROM docker.elastic.co/beats/filebeat:8.1.0 + +# Copy our custom configuration file +COPY filebeat.yml /usr/share/filebeat/filebeat.yml + +USER root +# Create a directory to map volume with all docker log files +RUN chown -R root:filebeat /usr/share/filebeat/ +RUN chmod -R go-w /usr/share/filebeat/ diff --git a/builds/production/elk/filebeat/filebeat.yml b/builds/production/elk/filebeat/filebeat.yml new file mode 100644 index 00000000..956237f0 --- /dev/null +++ b/builds/production/elk/filebeat/filebeat.yml @@ -0,0 +1,19 @@ +filebeat.inputs: +- type: container + paths: + - '/var/lib/docker/containers/*/*.log' + +- type: container + paths: + - '/var/lib/docker/containers/*/*.log' + multiline: + pattern : '^Traceback' + negate: true + match: after + flush_pattern: 'Error: ' + tags: ['multiline'] + +output: + logstash: + hosts: ['127.0.0.1:5044'] + enabled: true diff --git a/builds/production/elk/kibana/export.ndjson b/builds/production/elk/kibana/export.ndjson new file mode 100644 index 00000000..0a87daee --- /dev/null +++ b/builds/production/elk/kibana/export.ndjson @@ -0,0 +1,6 @@ +{"attributes":{"buildNum":50485,"defaultIndex":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","discover:rowHeightOption":0,"theme:darkMode":true,"truncate:maxHeight":0},"coreMigrationVersion":"8.1.0","id":"8.1.0","migrationVersion":{"config":"8.1.0"},"references":[],"type":"config","updated_at":"2022-03-15T12:26:10.327Z","version":"WzMwNzQsMV0="} +{"attributes":{"fieldAttrs":"{\"router\":{\"count\":10},\"tags\":{\"count\":4},\"message\":{\"count\":6},\"timestamp_datatensor\":{\"count\":4},\"agent.name\":{\"count\":2},\"log_level\":{\"count\":4},\"parsed_message\":{\"count\":1}}","fields":"[]","runtimeFieldMap":"{}","timeFieldName":"@timestamp","title":"datatensor-*","typeMeta":"{}"},"coreMigrationVersion":"8.1.0","id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","migrationVersion":{"index-pattern":"8.0.0"},"references":[],"type":"index-pattern","updated_at":"2022-03-15T12:16:32.787Z","version":"WzI4MzAsMV0="} +{"attributes":{"columns":["log_level","router","parsed_message"],"description":"","grid":{},"hideChart":false,"kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"log_level.keyword : *\",\"language\":\"kuery\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"sort":[["@timestamp","desc"]],"title":"Datatensor Search"},"coreMigrationVersion":"8.1.0","id":"905676c0-a44f-11ec-9534-d9942ffaea85","migrationVersion":{"search":"8.0.0"},"references":[{"id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"search","updated_at":"2022-03-15T12:16:11.988Z","version":"WzI4MjEsMV0="} +{"attributes":{"columns":["message"],"description":"","grid":{},"hideChart":false,"kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"tags.keyword : \\\"traceback\\\" and \\\"Traceback (most recent call last)\\\"\",\"language\":\"kuery\"},\"filter\":[],\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}"},"sort":[["@timestamp","desc"]],"title":"Datatensor | Tracebacks"},"coreMigrationVersion":"8.1.0","id":"c885a890-a459-11ec-9534-d9942ffaea85","migrationVersion":{"search":"8.0.0"},"references":[{"id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern"}],"type":"search","updated_at":"2022-03-15T12:18:59.946Z","version":"WzI5MjIsMV0="} +{"attributes":{"description":"Datatensor logs 🔥","hits":0,"kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"},"optionsJSON":"{\"useMargins\":true,\"syncColors\":false,\"hidePanelTitles\":false}","panelsJSON":"[{\"version\":\"8.1.0\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":0,\"w\":9,\"h\":7,\"i\":\"89e39f47-a88b-4cc1-90bc-5c628e1d11d7\"},\"panelIndex\":\"89e39f47-a88b-4cc1-90bc-5c628e1d11d7\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsMetric\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"dfa6c260-a3ec-11ec-9534-d9942ffaea85\",\"name\":\"indexpattern-datasource-current-indexpattern\"},{\"type\":\"index-pattern\",\"id\":\"dfa6c260-a3ec-11ec-9534-d9942ffaea85\",\"name\":\"indexpattern-datasource-layer-51ede650-4620-4251-98a8-850757679787\"}],\"state\":{\"visualization\":{\"layerId\":\"51ede650-4620-4251-98a8-850757679787\",\"accessor\":\"eec390ac-a149-4684-ac4e-4e5b7d147456\",\"layerType\":\"data\"},\"query\":{\"query\":\"\\\"Fetch public data\\\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"indexpattern\":{\"layers\":{\"51ede650-4620-4251-98a8-850757679787\":{\"columns\":{\"eec390ac-a149-4684-ac4e-4e5b7d147456\":{\"label\":\"Public views\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true}},\"columnOrder\":[\"eec390ac-a149-4684-ac4e-4e5b7d147456\"],\"incompleteColumns\":{}}}}}}},\"enhancements\":{}}},{\"version\":\"8.1.0\",\"type\":\"lens\",\"gridData\":{\"x\":9,\"y\":0,\"w\":9,\"h\":7,\"i\":\"50f1868c-a372-4038-aa93-3b17d6096bd9\"},\"panelIndex\":\"50f1868c-a372-4038-aa93-3b17d6096bd9\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsMetric\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"dfa6c260-a3ec-11ec-9534-d9942ffaea85\",\"name\":\"indexpattern-datasource-current-indexpattern\"},{\"type\":\"index-pattern\",\"id\":\"dfa6c260-a3ec-11ec-9534-d9942ffaea85\",\"name\":\"indexpattern-datasource-layer-f62a64dc-498e-4d8c-874b-bf7b70cb95dd\"}],\"state\":{\"visualization\":{\"layerId\":\"f62a64dc-498e-4d8c-874b-bf7b70cb95dd\",\"accessor\":\"6155f271-c275-4cc0-9996-3ada13084a3e\",\"layerType\":\"data\"},\"query\":{\"query\":\"router.keyword : \\\"Public\\\" and \\\"Fetch public sample\\\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"indexpattern\":{\"layers\":{\"f62a64dc-498e-4d8c-874b-bf7b70cb95dd\":{\"columns\":{\"6155f271-c275-4cc0-9996-3ada13084a3e\":{\"label\":\"Home sample\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true}},\"columnOrder\":[\"6155f271-c275-4cc0-9996-3ada13084a3e\"],\"incompleteColumns\":{}}}}}}},\"enhancements\":{}}},{\"version\":\"8.1.0\",\"type\":\"lens\",\"gridData\":{\"x\":18,\"y\":0,\"w\":9,\"h\":7,\"i\":\"d5187a15-f674-4b3a-95a2-4b205512c365\"},\"panelIndex\":\"d5187a15-f674-4b3a-95a2-4b205512c365\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsMetric\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"dfa6c260-a3ec-11ec-9534-d9942ffaea85\",\"name\":\"indexpattern-datasource-current-indexpattern\"},{\"type\":\"index-pattern\",\"id\":\"dfa6c260-a3ec-11ec-9534-d9942ffaea85\",\"name\":\"indexpattern-datasource-layer-35de0f22-f2c5-449b-8ecb-3e8ffb1290e0\"}],\"state\":{\"visualization\":{\"layerId\":\"35de0f22-f2c5-449b-8ecb-3e8ffb1290e0\",\"accessor\":\"1d548d23-133a-4971-9c88-cca1276b4d70\",\"layerType\":\"data\"},\"query\":{\"query\":\"\\\"Registered\\\" \",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"indexpattern\":{\"layers\":{\"35de0f22-f2c5-449b-8ecb-3e8ffb1290e0\":{\"columns\":{\"1d548d23-133a-4971-9c88-cca1276b4d70\":{\"label\":\"Users registered\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true}},\"columnOrder\":[\"1d548d23-133a-4971-9c88-cca1276b4d70\"],\"incompleteColumns\":{}}}}}}},\"enhancements\":{}}},{\"version\":\"8.1.0\",\"type\":\"lens\",\"gridData\":{\"x\":27,\"y\":0,\"w\":8,\"h\":7,\"i\":\"d49fb45a-0357-459e-bec1-262ef7adbdce\"},\"panelIndex\":\"d49fb45a-0357-459e-bec1-262ef7adbdce\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsMetric\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"dfa6c260-a3ec-11ec-9534-d9942ffaea85\",\"name\":\"indexpattern-datasource-current-indexpattern\"},{\"type\":\"index-pattern\",\"id\":\"dfa6c260-a3ec-11ec-9534-d9942ffaea85\",\"name\":\"indexpattern-datasource-layer-35de0f22-f2c5-449b-8ecb-3e8ffb1290e0\"}],\"state\":{\"visualization\":{\"layerId\":\"35de0f22-f2c5-449b-8ecb-3e8ffb1290e0\",\"accessor\":\"1d548d23-133a-4971-9c88-cca1276b4d70\",\"layerType\":\"data\"},\"query\":{\"query\":\"\\\"Logged\\\" \",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"indexpattern\":{\"layers\":{\"35de0f22-f2c5-449b-8ecb-3e8ffb1290e0\":{\"columns\":{\"1d548d23-133a-4971-9c88-cca1276b4d70\":{\"label\":\"Users connexion\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true}},\"columnOrder\":[\"1d548d23-133a-4971-9c88-cca1276b4d70\"],\"incompleteColumns\":{}}}}}}},\"enhancements\":{}},\"title\":\"\"},{\"version\":\"8.1.0\",\"type\":\"lens\",\"gridData\":{\"x\":35,\"y\":0,\"w\":13,\"h\":22,\"i\":\"f0e6ebcc-198a-449d-8f1a-d337818b353d\"},\"panelIndex\":\"f0e6ebcc-198a-449d-8f1a-d337818b353d\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsDatatable\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"dfa6c260-a3ec-11ec-9534-d9942ffaea85\",\"name\":\"indexpattern-datasource-current-indexpattern\"},{\"type\":\"index-pattern\",\"id\":\"dfa6c260-a3ec-11ec-9534-d9942ffaea85\",\"name\":\"indexpattern-datasource-layer-5ee8b5cc-b8b4-4213-8466-ff0cd487e6d1\"}],\"state\":{\"visualization\":{\"layerId\":\"5ee8b5cc-b8b4-4213-8466-ff0cd487e6d1\",\"layerType\":\"data\",\"columns\":[{\"isTransposed\":false,\"columnId\":\"6dd22fcb-4f79-4fc9-a2f7-5435d1337750\"},{\"isTransposed\":false,\"columnId\":\"1917933a-9cb2-4e60-bf2d-e58e7eb3ab02\"}]},\"query\":{\"query\":\"\\\"Logged\\\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"indexpattern\":{\"layers\":{\"5ee8b5cc-b8b4-4213-8466-ff0cd487e6d1\":{\"columns\":{\"6dd22fcb-4f79-4fc9-a2f7-5435d1337750\":{\"label\":\"Connexions\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"parsed_message.keyword\",\"isBucketed\":true,\"params\":{\"size\":1000,\"orderBy\":{\"type\":\"column\",\"columnId\":\"1917933a-9cb2-4e60-bf2d-e58e7eb3ab02\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"}},\"customLabel\":true},\"1917933a-9cb2-4e60-bf2d-e58e7eb3ab02\":{\"label\":\"Count\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"customLabel\":true}},\"columnOrder\":[\"6dd22fcb-4f79-4fc9-a2f7-5435d1337750\",\"1917933a-9cb2-4e60-bf2d-e58e7eb3ab02\"],\"incompleteColumns\":{}}}}}}},\"enhancements\":{}}},{\"version\":\"8.1.0\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":7,\"w\":35,\"h\":15,\"i\":\"59722a68-3d88-404f-885a-0a245a49366d\"},\"panelIndex\":\"59722a68-3d88-404f-885a-0a245a49366d\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"dfa6c260-a3ec-11ec-9534-d9942ffaea85\",\"name\":\"indexpattern-datasource-current-indexpattern\"},{\"type\":\"index-pattern\",\"id\":\"dfa6c260-a3ec-11ec-9534-d9942ffaea85\",\"name\":\"indexpattern-datasource-layer-815e3507-fa6c-4339-958e-dd2bd28c943e\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"right\"},\"valueLabels\":\"hide\",\"fittingFunction\":\"None\",\"yLeftExtent\":{\"mode\":\"full\"},\"yRightExtent\":{\"mode\":\"full\"},\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"815e3507-fa6c-4339-958e-dd2bd28c943e\",\"accessors\":[\"36d459da-3926-439f-b75f-63f6a9806852\"],\"position\":\"top\",\"seriesType\":\"bar_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"palette\":{\"type\":\"palette\",\"name\":\"status\"},\"xAccessor\":\"cf314d0d-00d9-46e1-9c8b-3f90ae3e4063\",\"splitAccessor\":\"76f59371-589d-4661-9f00-9cfdf0908b98\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"indexpattern\":{\"layers\":{\"815e3507-fa6c-4339-958e-dd2bd28c943e\":{\"columns\":{\"76f59371-589d-4661-9f00-9cfdf0908b98\":{\"label\":\"Top values of log_level.keyword\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"log_level.keyword\",\"isBucketed\":true,\"params\":{\"size\":5,\"orderBy\":{\"type\":\"column\",\"columnId\":\"36d459da-3926-439f-b75f-63f6a9806852\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"}}},\"cf314d0d-00d9-46e1-9c8b-3f90ae3e4063\":{\"label\":\"@timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"@timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\"}},\"36d459da-3926-439f-b75f-63f6a9806852\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\"}},\"columnOrder\":[\"76f59371-589d-4661-9f00-9cfdf0908b98\",\"cf314d0d-00d9-46e1-9c8b-3f90ae3e4063\",\"36d459da-3926-439f-b75f-63f6a9806852\"],\"incompleteColumns\":{}}}}}}},\"enhancements\":{}}},{\"version\":\"8.1.0\",\"type\":\"search\",\"gridData\":{\"x\":0,\"y\":22,\"w\":27,\"h\":23,\"i\":\"99dc14d8-85fe-45db-a8df-49e988315d88\"},\"panelIndex\":\"99dc14d8-85fe-45db-a8df-49e988315d88\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_99dc14d8-85fe-45db-a8df-49e988315d88\"},{\"version\":\"8.1.0\",\"type\":\"search\",\"gridData\":{\"x\":27,\"y\":22,\"w\":21,\"h\":23,\"i\":\"4eed46e8-4b59-471c-b47f-41aef986ce6f\"},\"panelIndex\":\"4eed46e8-4b59-471c-b47f-41aef986ce6f\",\"embeddableConfig\":{\"enhancements\":{}},\"panelRefName\":\"panel_4eed46e8-4b59-471c-b47f-41aef986ce6f\"}]","timeRestore":false,"title":"Datatensor","version":1},"coreMigrationVersion":"8.1.0","id":"0e49d8a0-a3ed-11ec-9534-d9942ffaea85","migrationVersion":{"dashboard":"8.1.0"},"references":[{"id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","name":"89e39f47-a88b-4cc1-90bc-5c628e1d11d7:indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","name":"89e39f47-a88b-4cc1-90bc-5c628e1d11d7:indexpattern-datasource-layer-51ede650-4620-4251-98a8-850757679787","type":"index-pattern"},{"id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","name":"50f1868c-a372-4038-aa93-3b17d6096bd9:indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","name":"50f1868c-a372-4038-aa93-3b17d6096bd9:indexpattern-datasource-layer-f62a64dc-498e-4d8c-874b-bf7b70cb95dd","type":"index-pattern"},{"id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","name":"d5187a15-f674-4b3a-95a2-4b205512c365:indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","name":"d5187a15-f674-4b3a-95a2-4b205512c365:indexpattern-datasource-layer-35de0f22-f2c5-449b-8ecb-3e8ffb1290e0","type":"index-pattern"},{"id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","name":"d49fb45a-0357-459e-bec1-262ef7adbdce:indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","name":"d49fb45a-0357-459e-bec1-262ef7adbdce:indexpattern-datasource-layer-35de0f22-f2c5-449b-8ecb-3e8ffb1290e0","type":"index-pattern"},{"id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","name":"f0e6ebcc-198a-449d-8f1a-d337818b353d:indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","name":"f0e6ebcc-198a-449d-8f1a-d337818b353d:indexpattern-datasource-layer-5ee8b5cc-b8b4-4213-8466-ff0cd487e6d1","type":"index-pattern"},{"id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","name":"59722a68-3d88-404f-885a-0a245a49366d:indexpattern-datasource-current-indexpattern","type":"index-pattern"},{"id":"dfa6c260-a3ec-11ec-9534-d9942ffaea85","name":"59722a68-3d88-404f-885a-0a245a49366d:indexpattern-datasource-layer-815e3507-fa6c-4339-958e-dd2bd28c943e","type":"index-pattern"},{"id":"905676c0-a44f-11ec-9534-d9942ffaea85","name":"99dc14d8-85fe-45db-a8df-49e988315d88:panel_99dc14d8-85fe-45db-a8df-49e988315d88","type":"search"},{"id":"c885a890-a459-11ec-9534-d9942ffaea85","name":"4eed46e8-4b59-471c-b47f-41aef986ce6f:panel_4eed46e8-4b59-471c-b47f-41aef986ce6f","type":"search"}],"type":"dashboard","updated_at":"2022-03-15T22:13:56.898Z","version":"WzM0NTQsMV0="} +{"excludedObjects":[],"excludedObjectsCount":0,"exportedCount":5,"missingRefCount":0,"missingReferences":[]} \ No newline at end of file diff --git a/builds/production/elk/logstash/pipeline/logstash.conf b/builds/production/elk/logstash/pipeline/logstash.conf new file mode 100644 index 00000000..37823fe5 --- /dev/null +++ b/builds/production/elk/logstash/pipeline/logstash.conf @@ -0,0 +1,41 @@ +input { + beats { + port => 5044 + } +} + +filter { + grok { + match => [ "message", "%{TIMESTAMP_ISO8601:timestamp_datatensor} %{WORD:log_level} %{DATA:router} \| %{GREEDYDATA:parsed_message}" ] + add_tag => [ "app_related" ] + } + + if "multiline" in [tags] { + grok { + match => [ "message", "Traceback"] + add_tag => [ "traceback" ] + remove_tag => [ "multiline", "_grokparsefailure" ] + } + } + + if "multiline" in [tags] { + drop { } + } + + if "_grokparsefailure" in [tags] { + if "metricbeat" not in [tags] { + drop { } + } + } +} + +output { + elasticsearch { + hosts => "localhost:9200" + user => "elastic" + password => "${ELASTIC_PASSWORD}" + ssl => true + cacert => "/usr/share/logstash/config/certs/ca/ca.crt" + index => 'datatensor-%{+YYYY.MM.dd}' + } +} \ No newline at end of file diff --git a/builds/proxy/Dockerfile b/builds/proxy/Dockerfile new file mode 100755 index 00000000..fb4ea3a5 --- /dev/null +++ b/builds/proxy/Dockerfile @@ -0,0 +1,8 @@ +FROM nginx:1.19.6 +RUN apt-get update && apt-get install -y \ + build-essential \ + curl \ + && rm -rf /var/lib/apt/lists/* +COPY nginx.conf /etc/nginx/nginx.conf +COPY conf.d /etc/nginx/conf.d +CMD nginx -g "daemon off;" diff --git a/builds/proxy/conf.d/api.datatensor.io.conf b/builds/proxy/conf.d/api.datatensor.io.conf new file mode 100644 index 00000000..6f728918 --- /dev/null +++ b/builds/proxy/conf.d/api.datatensor.io.conf @@ -0,0 +1,41 @@ +server { + listen 80; + server_name api.datatensor.io; + + location ~ /.well-known/acme-challenge { + alias /var/www/letsencrypt/; + try_files $uri =404; + allow all; + } + + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl; + server_name api.datatensor.io; + + client_max_body_size 1024M; + + proxy_connect_timeout 1800; + proxy_send_timeout 1800; + proxy_read_timeout 1800; + send_timeout 1800; + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_certificate /etc/letsencrypt/live/datatensor.io/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/datatensor.io/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:4069$request_uri; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} \ No newline at end of file diff --git a/builds/proxy/conf.d/datatensor.io.conf b/builds/proxy/conf.d/datatensor.io.conf new file mode 100755 index 00000000..84048ed2 --- /dev/null +++ b/builds/proxy/conf.d/datatensor.io.conf @@ -0,0 +1,28 @@ +server { + listen 80; + server_name datatensor.io www.datatensor.io; + + location ~ /.well-known/acme-challenge { + alias /var/www/letsencrypt/; + try_files $uri =404; + allow all; + } + + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl; + server_name datatensor.io; + + client_max_body_size 1024M; + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_certificate /etc/letsencrypt/live/datatensor.io/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/datatensor.io/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:5069$request_uri; + } +} \ No newline at end of file diff --git a/builds/proxy/conf.d/kibana.datatensor.io.conf b/builds/proxy/conf.d/kibana.datatensor.io.conf new file mode 100644 index 00000000..4eeae89d --- /dev/null +++ b/builds/proxy/conf.d/kibana.datatensor.io.conf @@ -0,0 +1,28 @@ +server { + listen 80; + server_name kibana.datatensor.io; + + location ~ /.well-known/acme-challenge { + alias /var/www/letsencrypt/; + try_files $uri =404; + allow all; + } + + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl; + server_name kibana.datatensor.io; + + client_max_body_size 1024M; + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_certificate /etc/letsencrypt/live/datatensor.io/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/datatensor.io/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:5601$request_uri; + } +} \ No newline at end of file diff --git a/builds/proxy/mime.types b/builds/proxy/mime.types new file mode 100644 index 00000000..4aa1f268 --- /dev/null +++ b/builds/proxy/mime.types @@ -0,0 +1,96 @@ +types { + text/html html htm shtml; + text/css css; + text/xml xml; + image/gif gif; + image/jpeg jpeg jpg; + application/javascript js; + application/atom+xml atom; + application/rss+xml rss; + + text/mathml mml; + text/plain txt; + text/vnd.sun.j2me.app-descriptor jad; + text/vnd.wap.wml wml; + text/x-component htc; + + image/png png; + image/svg+xml svg; + image/tiff tif tiff; + image/vnd.wap.wbmp wbmp; + image/webp webp; + image/x-icon ico; + image/x-jng jng; + image/x-ms-bmp bmp; + + font/woff woff; + font/woff2 woff2; + + application/java-archive jar war ear; + application/json json; + application/mac-binhex40 hqx; + application/msword doc; + application/pdf pdf; + application/postscript ps eps ai; + application/rtf rtf; + application/vnd.apple.mpegurl m3u8; + application/vnd.google-earth.kml+xml kml; + application/vnd.google-earth.kmz kmz; + application/vnd.ms-excel xls; + application/vnd.ms-fontobject eot; + application/vnd.ms-powerpoint ppt; + application/vnd.oasis.opendocument.graphics odg; + application/vnd.oasis.opendocument.presentation odp; + application/vnd.oasis.opendocument.spreadsheet ods; + application/vnd.oasis.opendocument.text odt; + application/vnd.openxmlformats-officedocument.presentationml.presentation + pptx; + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + xlsx; + application/vnd.openxmlformats-officedocument.wordprocessingml.document + docx; + application/vnd.wap.wmlc wmlc; + application/x-7z-compressed 7z; + application/x-cocoa cco; + application/x-java-archive-diff jardiff; + application/x-java-jnlp-file jnlp; + application/x-makeself run; + application/x-perl pl pm; + application/x-pilot prc pdb; + application/x-rar-compressed rar; + application/x-redhat-package-manager rpm; + application/x-sea sea; + application/x-shockwave-flash swf; + application/x-stuffit sit; + application/x-tcl tcl tk; + application/x-x509-ca-cert der pem crt; + application/x-xpinstall xpi; + application/xhtml+xml xhtml; + application/xspf+xml xspf; + application/zip zip; + + application/octet-stream bin exe dll; + application/octet-stream deb; + application/octet-stream dmg; + application/octet-stream iso img; + application/octet-stream msi msp msm; + + audio/midi mid midi kar; + audio/mpeg mp3; + audio/ogg ogg; + audio/x-m4a m4a; + audio/x-realaudio ra; + + video/3gpp 3gpp 3gp; + video/mp2t ts; + video/mp4 mp4; + video/mpeg mpeg mpg; + video/quicktime mov; + video/webm webm; + video/x-flv flv; + video/x-m4v m4v; + video/x-mng mng; + video/x-ms-asf asx asf; + video/x-ms-wmv wmv; + video/x-msvideo avi; +} \ No newline at end of file diff --git a/builds/proxy/nginx.conf b/builds/proxy/nginx.conf new file mode 100755 index 00000000..026abf03 --- /dev/null +++ b/builds/proxy/nginx.conf @@ -0,0 +1,12 @@ +events { + worker_connections 1024; +} + +http { + proxy_headers_hash_max_size 1024; + proxy_headers_hash_bucket_size 64; + server_tokens off; + + include /etc/nginx/mime.types; + include /etc/nginx/conf.d/*.conf; +} diff --git a/ui b/ui deleted file mode 160000 index 093a69fe..00000000 --- a/ui +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 093a69fe5aa4178c3117f5cfb52232a4fd0bc0e4 diff --git a/ux/.babelrc b/ux/.babelrc new file mode 100755 index 00000000..43d709ce --- /dev/null +++ b/ux/.babelrc @@ -0,0 +1,5 @@ +{ + "presets": [ + "babel-preset-react-app" + ] +} \ No newline at end of file diff --git a/ux/.editorconfig b/ux/.editorconfig new file mode 100755 index 00000000..08517435 --- /dev/null +++ b/ux/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*.js] +end_of_line = lf +insert_final_newline = true +charset = utf-8 + +[*.js] +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab \ No newline at end of file diff --git a/ux/.env b/ux/.env new file mode 100755 index 00000000..d4223c45 --- /dev/null +++ b/ux/.env @@ -0,0 +1,4 @@ +REACT_APP_ENVIRONMENT="development" + +REACT_APP_GOOGLE_CAPTCHA_SITE_KEY="6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI" +REACT_APP_OAUTH_GOOGLE_CLIENT_ID="1020592902157-8elmelc4n4l2fh3jk4jltf5ulb3mqp5v.apps.googleusercontent.com" \ No newline at end of file diff --git a/ux/.eslintrc b/ux/.eslintrc new file mode 100755 index 00000000..76b583fa --- /dev/null +++ b/ux/.eslintrc @@ -0,0 +1,37 @@ +{ + "parser": "babel-eslint", + "env": { + "es6": true, + "browser": true + }, + "extends": [ + "airbnb-typescript", + "airbnb/hooks", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "plugin:mdx/recommended" + ], + "settings": { + "import/resolver": { + "node": { + "paths": ["src"], + "extensions": [".js", ".jsx", ".ts", ".tsx", ".mdx"] + } + } + }, + "rules": { + "comma-dangle": "off", + "import/no-unresolved": "off", + "jsx-a11y/anchor-is-valid": "off", + "jsx-a11y/click-events-have-key-events": "off", + "jsx-a11y/control-has-associated-label": "off", + "jsx-a11y/no-static-element-interactions": "off", + "no-console": "off", + "no-plusplus": "off", + "react-hooks/exhaustive-deps": "off", + "react/forbid-prop-types": "off", + "react/jsx-filename-extension": "off", + "react/jsx-props-no-spreading": "off", + "react/require-default-props": "off" + } +} diff --git a/ux/.gitignore b/ux/.gitignore new file mode 100755 index 00000000..bc5413e4 --- /dev/null +++ b/ux/.gitignore @@ -0,0 +1,24 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +/.env +/.env.local +/.env.development.local +/.env.test.local +/.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/ux/.prettierignore b/ux/.prettierignore new file mode 100755 index 00000000..e69de29b diff --git a/ux/.prettierrc b/ux/.prettierrc new file mode 100755 index 00000000..b215b387 --- /dev/null +++ b/ux/.prettierrc @@ -0,0 +1,15 @@ +{ + "bracketSpacing": false, + "printWidth": 120, + "singleQuote": true, + "semi": true, + "trailingComma": "none", + "tabWidth": 4, + "useTabs": false, + "react/jsx-max-props-per-line": [ + 1, + { + "when": "always" + } + ] +} diff --git a/ux/package.json b/ux/package.json new file mode 100755 index 00000000..372fdca2 --- /dev/null +++ b/ux/package.json @@ -0,0 +1,134 @@ +{ + "name": "datatensor", + "author": "Paul Ruelle", + "licence": "UNLICENSED", + "version": "3.1.0", + "private": true, + "scripts": { + "lint": "eslint ./src", + "start": "export HTTPS=true && PORT=5069 react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "serve:production": "serve -s build -l 5069" + }, + "browserslist": { + "production": [ + ">0.2%", + "ie 11", + "not dead", + "not op_mini all" + ], + "development": [ + "ie 11", + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "dependencies": { + "@date-io/core": "^2.8.0", + "@date-io/moment": "^1.3.13", + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "@mdx-js/react": "^1.6.14", + "@mui/icons-material": "^5.0.1", + "@mui/lab": "^5.0.0-alpha.49", + "@mui/material": "^5.0.2", + "@mui/styles": "^5.0.1", + "@mui/x-data-grid": "5.0.0-beta.2", + "@react-pdf/renderer": "^1.6.10", + "@reduxjs/toolkit": "^1.4.0", + "@types/history": "^4.7.7", + "@types/jest": "^25.2.3", + "@types/js-cookie": "^2.2.6", + "@types/jsonwebtoken": "^8.5.0", + "@types/jwt-decode": "^2.2.1", + "@types/lodash": "^4.14.158", + "@types/node": "^13.13.15", + "@types/nprogress": "^0.2.0", + "@types/react": "^16.9.43", + "@types/react-beautiful-dnd": "^13.0.0", + "@types/react-dom": "^16.9.8", + "@types/react-draft-wysiwyg": "^1.13.0", + "@types/react-google-recaptcha": "^2.1.0", + "@types/react-helmet": "^5.0.16", + "@types/react-redux": "^7.1.9", + "@types/react-router-dom": "^5.1.5", + "@types/redux-form": "^8.2.7", + "@types/redux-logger": "^3.0.8", + "@types/uuid": "^7.0.4", + "@types/yup": "^0.28.3", + "apexcharts": "^3.19.3", + "axios": "^0.21.1", + "axios-mock-adapter": "^1.18.2", + "babel-loader": "8.1.0", + "change-case": "^4.1.1", + "clsx": "^1.1.1", + "draft-js": "^0.11.6", + "firebase": "^7.17.1", + "formik": "^2.1.5", + "history": "^4.10.1", + "immer": "^6.0.9", + "immutable": "^4.0.0-rc.12", + "js-cookie": "^2.2.1", + "jsonwebtoken": "^8.5.1", + "jss": "^10.3.0", + "jss-rtl": "^0.3.0", + "jwt-decode": "^2.2.0", + "lodash": "^4.17.19", + "moment": "^2.27.0", + "notistack": "^2.0.2", + "npm-run-all": "^4.1.5", + "nprogress": "^0.2.0", + "package.json": "^2.0.1", + "prismjs": "^1.20.0", + "prop-types": "^15.7.2", + "rc-scrollbars": "^1.1.3", + "react": "^16.13.1", + "react-apexcharts": "^1.3.7", + "react-app-polyfill": "^1.0.6", + "react-beautiful-dnd": "^13.1.0", + "react-dom": "^16.13.1", + "react-draft-wysiwyg": "^1.14.5", + "react-dropzone": "^10.2.2", + "react-feather": "^2.0.8", + "react-google-one-tap-login": "^0.1.1", + "react-google-recaptcha": "^2.1.0", + "react-helmet": "^5.2.1", + "react-markdown": "^4.3.1", + "react-modal-image": "^2.5.0", + "react-quill": "^1.3.5", + "react-redux": "^7.2.0", + "react-router": "^5.2.0", + "react-router-dom": "^5.2.0", + "react-scripts": "^3.4.1", + "react-swipeable-views": "^0.14.0", + "redux": "^4.0.5", + "redux-devtools-extension": "^2.13.8", + "redux-form": "^8.3.6", + "redux-thunk": "^2.3.0", + "serve": "^11.3.0", + "use-typed-event-listener": "^3.0.0", + "uuid": "^8.3.2", + "yup": "^0.28.5" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^2.34.0", + "@typescript-eslint/parser": "^2.34.0", + "eslint": "^6.8.0", + "eslint-config-airbnb": "^18.2.0", + "eslint-config-airbnb-typescript": "^7.2.1", + "eslint-config-prettier": "^6.11.0", + "eslint-plugin-import": "^2.22.0", + "eslint-plugin-jsx-a11y": "^6.3.1", + "eslint-plugin-mdx": "^1.7.1", + "eslint-plugin-prettier": "^3.1.4", + "eslint-plugin-react": "^7.20.3", + "eslint-plugin-react-hooks": "^2.5.1", + "mdx-loader": "^3.0.2", + "numeral": "^2.0.6", + "prettier": "^1.19.1", + "typescript": "^3.9.7" + } +} diff --git a/ux/public/_redirects b/ux/public/_redirects new file mode 100755 index 00000000..50a46335 --- /dev/null +++ b/ux/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/ux/public/favicon.ico b/ux/public/favicon.ico new file mode 100755 index 00000000..4042afe5 Binary files /dev/null and b/ux/public/favicon.ico differ diff --git a/ux/public/index.html b/ux/public/index.html new file mode 100755 index 00000000..d6aba2f4 --- /dev/null +++ b/ux/public/index.html @@ -0,0 +1,40 @@ + + + + + + + + + + + Datatensor + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/ux/public/manifest.json b/ux/public/manifest.json new file mode 100755 index 00000000..db64aa66 --- /dev/null +++ b/ux/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "Datatensor", + "name": "Datatensor App", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/ux/public/public-data/datasets/1.jpeg b/ux/public/public-data/datasets/1.jpeg new file mode 100644 index 00000000..a23b412e Binary files /dev/null and b/ux/public/public-data/datasets/1.jpeg differ diff --git a/ux/public/public-data/datasets/2.jpeg b/ux/public/public-data/datasets/2.jpeg new file mode 100644 index 00000000..cdf8c77b Binary files /dev/null and b/ux/public/public-data/datasets/2.jpeg differ diff --git a/ux/public/public-data/datasets/3.jpeg b/ux/public/public-data/datasets/3.jpeg new file mode 100644 index 00000000..097f13d1 Binary files /dev/null and b/ux/public/public-data/datasets/3.jpeg differ diff --git a/ux/public/public-data/datasets/4.jpeg b/ux/public/public-data/datasets/4.jpeg new file mode 100644 index 00000000..d8f63a27 Binary files /dev/null and b/ux/public/public-data/datasets/4.jpeg differ diff --git a/ux/public/public-data/datasets/5.jpeg b/ux/public/public-data/datasets/5.jpeg new file mode 100644 index 00000000..2f9c4bdd Binary files /dev/null and b/ux/public/public-data/datasets/5.jpeg differ diff --git a/ux/public/public-data/datasets/6.jpeg b/ux/public/public-data/datasets/6.jpeg new file mode 100644 index 00000000..0ffaac77 Binary files /dev/null and b/ux/public/public-data/datasets/6.jpeg differ diff --git a/ux/public/public-data/datasets/brain-1.jpeg b/ux/public/public-data/datasets/brain-1.jpeg new file mode 100644 index 00000000..bfcd68af Binary files /dev/null and b/ux/public/public-data/datasets/brain-1.jpeg differ diff --git a/ux/public/public-data/datasets/brain-2.jpeg b/ux/public/public-data/datasets/brain-2.jpeg new file mode 100644 index 00000000..87cd1b17 Binary files /dev/null and b/ux/public/public-data/datasets/brain-2.jpeg differ diff --git a/ux/public/public-data/datasets/brain-3.jpeg b/ux/public/public-data/datasets/brain-3.jpeg new file mode 100644 index 00000000..ea4af27a Binary files /dev/null and b/ux/public/public-data/datasets/brain-3.jpeg differ diff --git a/ux/public/public-data/datasets/brain-4.jpeg b/ux/public/public-data/datasets/brain-4.jpeg new file mode 100644 index 00000000..49de022d Binary files /dev/null and b/ux/public/public-data/datasets/brain-4.jpeg differ diff --git a/ux/public/public-data/datasets/bullets-1.jpeg b/ux/public/public-data/datasets/bullets-1.jpeg new file mode 100644 index 00000000..d9b4a3be Binary files /dev/null and b/ux/public/public-data/datasets/bullets-1.jpeg differ diff --git a/ux/public/public-data/datasets/bullets-2.jpeg b/ux/public/public-data/datasets/bullets-2.jpeg new file mode 100644 index 00000000..f8c1c958 Binary files /dev/null and b/ux/public/public-data/datasets/bullets-2.jpeg differ diff --git a/ux/public/public-data/datasets/bullets-3.jpeg b/ux/public/public-data/datasets/bullets-3.jpeg new file mode 100644 index 00000000..a75f8ad8 Binary files /dev/null and b/ux/public/public-data/datasets/bullets-3.jpeg differ diff --git a/ux/public/public-data/datasets/bullets-4.jpeg b/ux/public/public-data/datasets/bullets-4.jpeg new file mode 100644 index 00000000..2e74655f Binary files /dev/null and b/ux/public/public-data/datasets/bullets-4.jpeg differ diff --git a/ux/public/public-data/personas/1.png b/ux/public/public-data/personas/1.png new file mode 100644 index 00000000..d21639fa Binary files /dev/null and b/ux/public/public-data/personas/1.png differ diff --git a/ux/public/public-data/personas/2.jpeg b/ux/public/public-data/personas/2.jpeg new file mode 100644 index 00000000..f91c38fa Binary files /dev/null and b/ux/public/public-data/personas/2.jpeg differ diff --git a/ux/public/public-data/personas/3.png b/ux/public/public-data/personas/3.png new file mode 100644 index 00000000..1353bd2c Binary files /dev/null and b/ux/public/public-data/personas/3.png differ diff --git a/ux/public/static/home/paul.jpg b/ux/public/static/home/paul.jpg new file mode 100644 index 00000000..6c6ca0fb Binary files /dev/null and b/ux/public/static/home/paul.jpg differ diff --git a/ux/public/static/images/app/create-dataset.svg b/ux/public/static/images/app/create-dataset.svg new file mode 100644 index 00000000..294f517c --- /dev/null +++ b/ux/public/static/images/app/create-dataset.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ux/public/static/images/app/not-found.svg b/ux/public/static/images/app/not-found.svg new file mode 100755 index 00000000..f5a1d4c4 --- /dev/null +++ b/ux/public/static/images/app/not-found.svg @@ -0,0 +1,100 @@ + + page not found + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ux/public/static/images/app/share-original.svg b/ux/public/static/images/app/share-original.svg new file mode 100644 index 00000000..453dbb8a --- /dev/null +++ b/ux/public/static/images/app/share-original.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ux/public/static/images/app/share.svg b/ux/public/static/images/app/share.svg new file mode 100644 index 00000000..c76f9855 --- /dev/null +++ b/ux/public/static/images/app/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ux/public/static/images/app/space-girl.svg b/ux/public/static/images/app/space-girl.svg new file mode 100644 index 00000000..6cc098b8 --- /dev/null +++ b/ux/public/static/images/app/space-girl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ux/public/static/images/app/upload.svg b/ux/public/static/images/app/upload.svg new file mode 100644 index 00000000..bfd47f3d --- /dev/null +++ b/ux/public/static/images/app/upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ux/public/static/images/augmentation/_seeds/control.png b/ux/public/static/images/augmentation/_seeds/control.png new file mode 100644 index 00000000..5f254a5d Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/control.png differ diff --git a/ux/public/static/images/augmentation/_seeds/control_label_free.png b/ux/public/static/images/augmentation/_seeds/control_label_free.png new file mode 100644 index 00000000..d0652040 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/control_label_free.png differ diff --git a/ux/public/static/images/augmentation/_seeds/crop-1.png b/ux/public/static/images/augmentation/_seeds/crop-1.png new file mode 100644 index 00000000..fe814517 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/crop-1.png differ diff --git a/ux/public/static/images/augmentation/_seeds/crop-2.png b/ux/public/static/images/augmentation/_seeds/crop-2.png new file mode 100644 index 00000000..693cb286 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/crop-2.png differ diff --git a/ux/public/static/images/augmentation/_seeds/crop-3.png b/ux/public/static/images/augmentation/_seeds/crop-3.png new file mode 100644 index 00000000..e483541c Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/crop-3.png differ diff --git a/ux/public/static/images/augmentation/_seeds/distortion-1.png b/ux/public/static/images/augmentation/_seeds/distortion-1.png new file mode 100644 index 00000000..2a637290 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/distortion-1.png differ diff --git a/ux/public/static/images/augmentation/_seeds/distortion-2.png b/ux/public/static/images/augmentation/_seeds/distortion-2.png new file mode 100644 index 00000000..1877071e Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/distortion-2.png differ diff --git a/ux/public/static/images/augmentation/_seeds/distortion-3.png b/ux/public/static/images/augmentation/_seeds/distortion-3.png new file mode 100644 index 00000000..bd5d7b03 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/distortion-3.png differ diff --git a/ux/public/static/images/augmentation/_seeds/distortion-4.png b/ux/public/static/images/augmentation/_seeds/distortion-4.png new file mode 100644 index 00000000..aa8a2b4a Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/distortion-4.png differ diff --git a/ux/public/static/images/augmentation/_seeds/flip-1.png b/ux/public/static/images/augmentation/_seeds/flip-1.png new file mode 100644 index 00000000..211af7d8 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/flip-1.png differ diff --git a/ux/public/static/images/augmentation/_seeds/flip-2.png b/ux/public/static/images/augmentation/_seeds/flip-2.png new file mode 100644 index 00000000..cd621710 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/flip-2.png differ diff --git a/ux/public/static/images/augmentation/_seeds/gaussian-1.png b/ux/public/static/images/augmentation/_seeds/gaussian-1.png new file mode 100644 index 00000000..7b74ef6e Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/gaussian-1.png differ diff --git a/ux/public/static/images/augmentation/_seeds/gaussian-2.png b/ux/public/static/images/augmentation/_seeds/gaussian-2.png new file mode 100644 index 00000000..a2bc2dba Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/gaussian-2.png differ diff --git a/ux/public/static/images/augmentation/_seeds/gaussian-3.png b/ux/public/static/images/augmentation/_seeds/gaussian-3.png new file mode 100644 index 00000000..07089413 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/gaussian-3.png differ diff --git a/ux/public/static/images/augmentation/_seeds/gaussian-4.png b/ux/public/static/images/augmentation/_seeds/gaussian-4.png new file mode 100644 index 00000000..e4a12920 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/gaussian-4.png differ diff --git a/ux/public/static/images/augmentation/_seeds/rotate-1.png b/ux/public/static/images/augmentation/_seeds/rotate-1.png new file mode 100644 index 00000000..60c796f4 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/rotate-1.png differ diff --git a/ux/public/static/images/augmentation/_seeds/rotate-2.png b/ux/public/static/images/augmentation/_seeds/rotate-2.png new file mode 100644 index 00000000..01d168c2 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/rotate-2.png differ diff --git a/ux/public/static/images/augmentation/_seeds/rotate-3.png b/ux/public/static/images/augmentation/_seeds/rotate-3.png new file mode 100644 index 00000000..a188ba95 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/rotate-3.png differ diff --git a/ux/public/static/images/augmentation/_seeds/rotate-4.png b/ux/public/static/images/augmentation/_seeds/rotate-4.png new file mode 100644 index 00000000..38714c9d Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/rotate-4.png differ diff --git a/ux/public/static/images/augmentation/_seeds/shear-1.png b/ux/public/static/images/augmentation/_seeds/shear-1.png new file mode 100644 index 00000000..929d2737 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/shear-1.png differ diff --git a/ux/public/static/images/augmentation/_seeds/shear-2.png b/ux/public/static/images/augmentation/_seeds/shear-2.png new file mode 100644 index 00000000..492fb135 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/shear-2.png differ diff --git a/ux/public/static/images/augmentation/_seeds/shear-3.png b/ux/public/static/images/augmentation/_seeds/shear-3.png new file mode 100644 index 00000000..eb093da8 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/shear-3.png differ diff --git a/ux/public/static/images/augmentation/_seeds/skew-1.png b/ux/public/static/images/augmentation/_seeds/skew-1.png new file mode 100644 index 00000000..9bc18659 Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/skew-1.png differ diff --git a/ux/public/static/images/augmentation/_seeds/skew-2.png b/ux/public/static/images/augmentation/_seeds/skew-2.png new file mode 100644 index 00000000..a70074ea Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/skew-2.png differ diff --git a/ux/public/static/images/augmentation/_seeds/skew-3.png b/ux/public/static/images/augmentation/_seeds/skew-3.png new file mode 100644 index 00000000..f04932ff Binary files /dev/null and b/ux/public/static/images/augmentation/_seeds/skew-3.png differ diff --git a/ux/public/static/images/augmentation/operations/crop_random.gif b/ux/public/static/images/augmentation/operations/crop_random.gif new file mode 100644 index 00000000..70d0db89 Binary files /dev/null and b/ux/public/static/images/augmentation/operations/crop_random.gif differ diff --git a/ux/public/static/images/augmentation/operations/flip_random.gif b/ux/public/static/images/augmentation/operations/flip_random.gif new file mode 100644 index 00000000..33d08b9e Binary files /dev/null and b/ux/public/static/images/augmentation/operations/flip_random.gif differ diff --git a/ux/public/static/images/augmentation/operations/gaussian_distortion.gif b/ux/public/static/images/augmentation/operations/gaussian_distortion.gif new file mode 100644 index 00000000..a0f8c6f9 Binary files /dev/null and b/ux/public/static/images/augmentation/operations/gaussian_distortion.gif differ diff --git a/ux/public/static/images/augmentation/operations/random_distortion.gif b/ux/public/static/images/augmentation/operations/random_distortion.gif new file mode 100644 index 00000000..951d1a74 Binary files /dev/null and b/ux/public/static/images/augmentation/operations/random_distortion.gif differ diff --git a/ux/public/static/images/augmentation/operations/rotate.gif b/ux/public/static/images/augmentation/operations/rotate.gif new file mode 100644 index 00000000..97d8f83d Binary files /dev/null and b/ux/public/static/images/augmentation/operations/rotate.gif differ diff --git a/ux/public/static/images/augmentation/operations/shear.gif b/ux/public/static/images/augmentation/operations/shear.gif new file mode 100644 index 00000000..93d81d0d Binary files /dev/null and b/ux/public/static/images/augmentation/operations/shear.gif differ diff --git a/ux/public/static/images/augmentation/operations/skew.gif b/ux/public/static/images/augmentation/operations/skew.gif new file mode 100644 index 00000000..a6ded64b Binary files /dev/null and b/ux/public/static/images/augmentation/operations/skew.gif differ diff --git a/ux/public/static/images/docs/create_categories.mp4 b/ux/public/static/images/docs/create_categories.mp4 new file mode 100644 index 00000000..1c69600a Binary files /dev/null and b/ux/public/static/images/docs/create_categories.mp4 differ diff --git a/ux/public/static/images/docs/create_dataset.mp4 b/ux/public/static/images/docs/create_dataset.mp4 new file mode 100644 index 00000000..14191314 Binary files /dev/null and b/ux/public/static/images/docs/create_dataset.mp4 differ diff --git a/ux/public/static/images/docs/labeling_images.mp4 b/ux/public/static/images/docs/labeling_images.mp4 new file mode 100644 index 00000000..7ee7b25c Binary files /dev/null and b/ux/public/static/images/docs/labeling_images.mp4 differ diff --git a/ux/public/static/images/docs/object_detection.jpeg b/ux/public/static/images/docs/object_detection.jpeg new file mode 100644 index 00000000..07d3cef4 Binary files /dev/null and b/ux/public/static/images/docs/object_detection.jpeg differ diff --git a/ux/public/static/images/docs/upload_images.mp4 b/ux/public/static/images/docs/upload_images.mp4 new file mode 100644 index 00000000..598d11c8 Binary files /dev/null and b/ux/public/static/images/docs/upload_images.mp4 differ diff --git a/ux/public/static/images/home/yolov4.mp4 b/ux/public/static/images/home/yolov4.mp4 new file mode 100644 index 00000000..a812a0ce Binary files /dev/null and b/ux/public/static/images/home/yolov4.mp4 differ diff --git a/ux/public/static/logo.svg b/ux/public/static/logo.svg new file mode 100755 index 00000000..c34dbf66 --- /dev/null +++ b/ux/public/static/logo.svg @@ -0,0 +1,26 @@ + + + Logo + Datatensor + + + + + + + + + + \ No newline at end of file diff --git a/ux/public/static/logos/android-icon-144x144.png b/ux/public/static/logos/android-icon-144x144.png new file mode 100644 index 00000000..34565bfb Binary files /dev/null and b/ux/public/static/logos/android-icon-144x144.png differ diff --git a/ux/public/static/logos/android-icon-192x192.png b/ux/public/static/logos/android-icon-192x192.png new file mode 100644 index 00000000..25eeaffb Binary files /dev/null and b/ux/public/static/logos/android-icon-192x192.png differ diff --git a/ux/public/static/logos/android-icon-36x36.png b/ux/public/static/logos/android-icon-36x36.png new file mode 100644 index 00000000..1cf6fa07 Binary files /dev/null and b/ux/public/static/logos/android-icon-36x36.png differ diff --git a/ux/public/static/logos/android-icon-48x48.png b/ux/public/static/logos/android-icon-48x48.png new file mode 100644 index 00000000..d6176cdf Binary files /dev/null and b/ux/public/static/logos/android-icon-48x48.png differ diff --git a/ux/public/static/logos/android-icon-72x72.png b/ux/public/static/logos/android-icon-72x72.png new file mode 100644 index 00000000..40f9e621 Binary files /dev/null and b/ux/public/static/logos/android-icon-72x72.png differ diff --git a/ux/public/static/logos/android-icon-96x96.png b/ux/public/static/logos/android-icon-96x96.png new file mode 100644 index 00000000..788dd0fe Binary files /dev/null and b/ux/public/static/logos/android-icon-96x96.png differ diff --git a/ux/public/static/logos/apple-icon-114x114.png b/ux/public/static/logos/apple-icon-114x114.png new file mode 100644 index 00000000..ad14ce17 Binary files /dev/null and b/ux/public/static/logos/apple-icon-114x114.png differ diff --git a/ux/public/static/logos/apple-icon-120x120.png b/ux/public/static/logos/apple-icon-120x120.png new file mode 100644 index 00000000..51655851 Binary files /dev/null and b/ux/public/static/logos/apple-icon-120x120.png differ diff --git a/ux/public/static/logos/apple-icon-144x144.png b/ux/public/static/logos/apple-icon-144x144.png new file mode 100644 index 00000000..34565bfb Binary files /dev/null and b/ux/public/static/logos/apple-icon-144x144.png differ diff --git a/ux/public/static/logos/apple-icon-152x152.png b/ux/public/static/logos/apple-icon-152x152.png new file mode 100644 index 00000000..b0823dee Binary files /dev/null and b/ux/public/static/logos/apple-icon-152x152.png differ diff --git a/ux/public/static/logos/apple-icon-180x180.png b/ux/public/static/logos/apple-icon-180x180.png new file mode 100644 index 00000000..cef6c92e Binary files /dev/null and b/ux/public/static/logos/apple-icon-180x180.png differ diff --git a/ux/public/static/logos/apple-icon-57x57.png b/ux/public/static/logos/apple-icon-57x57.png new file mode 100644 index 00000000..d81f6543 Binary files /dev/null and b/ux/public/static/logos/apple-icon-57x57.png differ diff --git a/ux/public/static/logos/apple-icon-60x60.png b/ux/public/static/logos/apple-icon-60x60.png new file mode 100644 index 00000000..7b39b282 Binary files /dev/null and b/ux/public/static/logos/apple-icon-60x60.png differ diff --git a/ux/public/static/logos/apple-icon-72x72.png b/ux/public/static/logos/apple-icon-72x72.png new file mode 100644 index 00000000..40f9e621 Binary files /dev/null and b/ux/public/static/logos/apple-icon-72x72.png differ diff --git a/ux/public/static/logos/apple-icon-76x76.png b/ux/public/static/logos/apple-icon-76x76.png new file mode 100644 index 00000000..f943891e Binary files /dev/null and b/ux/public/static/logos/apple-icon-76x76.png differ diff --git a/ux/public/static/logos/apple-icon-precomposed.png b/ux/public/static/logos/apple-icon-precomposed.png new file mode 100644 index 00000000..dbf73ecc Binary files /dev/null and b/ux/public/static/logos/apple-icon-precomposed.png differ diff --git a/ux/public/static/logos/apple-icon.png b/ux/public/static/logos/apple-icon.png new file mode 100644 index 00000000..dbf73ecc Binary files /dev/null and b/ux/public/static/logos/apple-icon.png differ diff --git a/ux/public/static/logos/browserconfig.xml b/ux/public/static/logos/browserconfig.xml new file mode 100644 index 00000000..c5541482 --- /dev/null +++ b/ux/public/static/logos/browserconfig.xml @@ -0,0 +1,2 @@ + +#ffffff \ No newline at end of file diff --git a/ux/public/static/logos/favicon-16x16.png b/ux/public/static/logos/favicon-16x16.png new file mode 100644 index 00000000..53985bce Binary files /dev/null and b/ux/public/static/logos/favicon-16x16.png differ diff --git a/ux/public/static/logos/favicon-32x32.png b/ux/public/static/logos/favicon-32x32.png new file mode 100644 index 00000000..39fe8336 Binary files /dev/null and b/ux/public/static/logos/favicon-32x32.png differ diff --git a/ux/public/static/logos/favicon-96x96.png b/ux/public/static/logos/favicon-96x96.png new file mode 100644 index 00000000..788dd0fe Binary files /dev/null and b/ux/public/static/logos/favicon-96x96.png differ diff --git a/ux/public/static/logos/favicon.ico b/ux/public/static/logos/favicon.ico new file mode 100644 index 00000000..5ddb2a59 Binary files /dev/null and b/ux/public/static/logos/favicon.ico differ diff --git a/ux/public/static/logos/manifest.json b/ux/public/static/logos/manifest.json new file mode 100644 index 00000000..013d4a6a --- /dev/null +++ b/ux/public/static/logos/manifest.json @@ -0,0 +1,41 @@ +{ + "name": "App", + "icons": [ + { + "src": "\/android-icon-36x36.png", + "sizes": "36x36", + "type": "image\/png", + "density": "0.75" + }, + { + "src": "\/android-icon-48x48.png", + "sizes": "48x48", + "type": "image\/png", + "density": "1.0" + }, + { + "src": "\/android-icon-72x72.png", + "sizes": "72x72", + "type": "image\/png", + "density": "1.5" + }, + { + "src": "\/android-icon-96x96.png", + "sizes": "96x96", + "type": "image\/png", + "density": "2.0" + }, + { + "src": "\/android-icon-144x144.png", + "sizes": "144x144", + "type": "image\/png", + "density": "3.0" + }, + { + "src": "\/android-icon-192x192.png", + "sizes": "192x192", + "type": "image\/png", + "density": "4.0" + } + ] +} \ No newline at end of file diff --git a/ux/public/static/logos/ms-icon-144x144.png b/ux/public/static/logos/ms-icon-144x144.png new file mode 100644 index 00000000..34565bfb Binary files /dev/null and b/ux/public/static/logos/ms-icon-144x144.png differ diff --git a/ux/public/static/logos/ms-icon-150x150.png b/ux/public/static/logos/ms-icon-150x150.png new file mode 100644 index 00000000..0ce87ad6 Binary files /dev/null and b/ux/public/static/logos/ms-icon-150x150.png differ diff --git a/ux/public/static/logos/ms-icon-310x310.png b/ux/public/static/logos/ms-icon-310x310.png new file mode 100644 index 00000000..6f08341e Binary files /dev/null and b/ux/public/static/logos/ms-icon-310x310.png differ diff --git a/ux/public/static/logos/ms-icon-70x70.png b/ux/public/static/logos/ms-icon-70x70.png new file mode 100644 index 00000000..10f14172 Binary files /dev/null and b/ux/public/static/logos/ms-icon-70x70.png differ diff --git a/ux/src/App.tsx b/ux/src/App.tsx new file mode 100755 index 00000000..781e0dc9 --- /dev/null +++ b/ux/src/App.tsx @@ -0,0 +1,27 @@ +import React, {FC} from 'react'; +import {Router} from 'react-router-dom'; +import {createBrowserHistory} from 'history'; +import GlobalStyles from 'src/components/utils/GlobalStyles'; +import ScrollReset from 'src/components/utils/ScrollReset'; +import {AuthProvider} from 'src/store/AuthContext'; +import routes, {renderRoutes} from 'src/routes'; +import Providers from './providers'; + +const history = createBrowserHistory(); + +const App: FC = () => { + + return ( + + + + + + {renderRoutes(routes)} + + + + ); +}; + +export default App; diff --git a/ux/src/assets/css/prism.css b/ux/src/assets/css/prism.css new file mode 100755 index 00000000..dfb091c3 --- /dev/null +++ b/ux/src/assets/css/prism.css @@ -0,0 +1,310 @@ +.hide { + display: none !important; +} + +.hidden { + position: absolute !important; + overflow: hidden !important; + width: 0.1px !important; + height: 0.1px !important; + opacity: 0 !important; + z-index: -1 !important; +} + +.visible { + opacity: 1 !important; +} + +.invisible { + opacity: 0 !important; + z-index: -10 !important; +} + +.relative { + position: relative !important; +} + +.scrollbar-container { + width: 100%; +} + +.flex { + display: flex; +} + +.flexGrow { + flex-grow: 700; +} + +.smaller { + font-size: 0.7em !important; + vertical-align: middle !important; +} + +.apex-tooltip { + padding: 8px 16px; + font-weight: bold; +} + +.blinking { + animation: blinker 1.5s ease infinite; +} + +.labelHighlight { + animation: labelHighlight 3s ease infinite; +} + +@keyframes blinker { + 30% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + 70% { + opacity: 1; + } +} + +@keyframes labelHighlight { + 50% { + opacity: 1; + } + 53% { + opacity: 0.8; + } + 56% { + opacity: 1; + } + 59% { + opacity: 0.8; + } + 65% { + opacity: 1; + } +} + +/* Google One Tap */ +#credential_picker_container { + top: unset !important; + left: unset !important; + bottom: 16px; + right: 16px; +} + +kbd { + background-color: #eee; + border-radius: 3px; + border: 1px solid #b4b4b4; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 2px 0 0 rgba(255, 255, 255, 0.7) inset; + color: #333; + display: inline-block; + font-size: 0.85em; + font-weight: 700; + line-height: 1; + padding: 2px 4px; + white-space: nowrap; + margin: 0px 5px; +} + +pre { + color: white; + border-radius: 4px; + background-color: #191c27; + padding: 16px; + font-size: 14px; + margin-bottom: 24px; +} + +code[class*='language-'], +pre[class*='language-'] { + color: rgb(191, 199, 213); + font-family: Inconsolata, Monaco, Consolas, 'Courier New', Courier, monospace; + direction: ltr; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + line-height: 1.5; + tab-size: 4; + hyphens: none; +} + +/* Code blocks */ +pre[class*='language-'] { + padding: 1em; + margin: 0.5em 0; + overflow: auto; + border-radius: 0.3em; +} + +:not(pre) > code[class*='language-'], +pre[class*='language-'] { + background: #1d1f21; +} + +/* Inline code */ +:not(pre) > code[class*='language-'] { + padding: 0.1em; + border-radius: 0.3em; +} + +.token.prolog { + color: rgb(0, 0, 128); +} + +.token.parameter { + color: rgb(255, 255, 255); +} + +.token.comment { + color: rgb(106, 153, 85); +} + +.token.doctype { + color: rgb(191, 199, 213); +} + +.token.cdata { + color: rgb(191, 199, 213); +} + +.token.punctuation { + color: rgb(136, 198, 190); +} + +.token.property { + color: rgb(252, 146, 158); +} + +.token.tag { + color: rgb(252, 146, 158); +} + +.token.class-name { + color: rgb(250, 200, 99); +} + +.token.boolean { +} + +.token.constant { + color: rgb(100, 102, 149); +} + +.token.symbol { + color: rgb(141, 200, 145); +} + +.token.deleted { + color: rgb(141, 200, 145); +} + +.token.number { + color: rgb(181, 206, 168); +} + +.token.inserted { + color: rgb(181, 206, 168); +} + +.token.selector { + color: rgb(215, 186, 125); +} + +.token.char { + color: rgb(209, 105, 105); +} + +.token.builtin { + color: rgb(197, 165, 197); +} + +.token.changed { + color: rgb(197, 165, 197); +} + +.token.keyword { + color: rgb(197, 165, 197); +} + +.token.string { + color: rgb(195, 232, 141); +} + +.token.attr-name { + color: rgb(156, 220, 254); +} + +.token.variable { + color: rgb(156, 220, 254); +} + +.token.operator { + color: #ededed; +} + +.token.entity { + color: #ffffb6; + cursor: help; +} + +.token.url { + color: #96cbfe; +} + +.language-css .token.string, +.style .token.string { + color: #87c38a; +} + +.token.atrule, +.token.attr-value { + color: #f9ee98; +} + +.token.function { + color: rgb(121, 182, 242); +} + +.token.regex { + color: #e9c062; +} + +.token.important { + color: #fd971f; +} + +.token.important, +.token.bold { + font-weight: bold; +} + +.token.italic { + font-style: italic; +} + +.highlight { + color: white; + animation: highlight 3s ease infinite; +} + +@keyframes highlight { + 0% { + border-radius: 50%; + transform: scale(0.975); + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); + } + + 70% { + border-radius: 50%; + transform: scale(1); + box-shadow: 0 0 0 5px rgba(255, 255, 255, 0); + } + + 100% { + border-radius: 50%; + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); + } +} diff --git a/ux/src/components/FancyLabel.tsx b/ux/src/components/FancyLabel.tsx new file mode 100755 index 00000000..c9391cce --- /dev/null +++ b/ux/src/components/FancyLabel.tsx @@ -0,0 +1,83 @@ +import React, {FC, ReactNode} from 'react'; +import clsx from 'clsx'; +import {alpha} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import {Theme} from 'src/theme'; + + +interface FancyLabelProps { + className?: string; + color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success' | 'info' | 'default'; + children?: ReactNode; + style?: {}; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + fontFamily: theme.typography.fontFamily, + alignItems: 'center', + borderRadius: 2, + display: 'inline-flex', + flexGrow: 0, + whiteSpace: 'nowrap', + cursor: 'default', + flexShrink: 0, + fontSize: theme.typography.pxToRem(12), + fontWeight: theme.typography.fontWeightMedium, + height: 20, + justifyContent: 'center', + letterSpacing: 0.5, + minWidth: 20, + padding: theme.spacing(0.5, 1), + textTransform: 'uppercase' + }, + primary: { + color: theme.palette.primary.main, + backgroundColor: alpha(theme.palette.primary.main, 0.08) + }, + secondary: { + color: theme.palette.secondary.main, + backgroundColor: alpha(theme.palette.secondary.main, 0.08) + }, + error: { + color: theme.palette.error.main, + backgroundColor: alpha(theme.palette.error.main, 0.08) + }, + success: { + color: theme.palette.success.main, + backgroundColor: alpha(theme.palette.success.main, 0.08) + }, + warning: { + color: theme.palette.warning.main, + backgroundColor: alpha(theme.palette.warning.main, 0.08) + }, + info: { + color: theme.palette.info.main, + backgroundColor: alpha(theme.palette.info.main, 0.08) + }, + default: { + color: theme.palette.text.primary, + backgroundColor: alpha(theme.palette.text.primary, 0.08) + } +})); + +const FancyLabel: FC = ({className = '', color = 'primary', children, style, ...rest}) => { + const classes = useStyles(); + + return ( + + {children} + + ); +}; + +export default FancyLabel; diff --git a/ux/src/components/ImagesDropzone.tsx b/ux/src/components/ImagesDropzone.tsx new file mode 100755 index 00000000..a661d025 --- /dev/null +++ b/ux/src/components/ImagesDropzone.tsx @@ -0,0 +1,202 @@ +import React, {FC, useCallback, useState} from 'react'; +import clsx from 'clsx'; +import {useDropzone} from 'react-dropzone'; +import Scrollbar from 'src/components/utils/Scrollbar'; +import {useSnackbar} from 'notistack'; +import { + Box, + Button, + CircularProgress, + IconButton, + Link, + List, + ListItem, + ListItemIcon, + ListItemText, + Tooltip, + Typography +} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import {ImageOutlined as ImageIcon, MoreVert as MoreIcon} from '@mui/icons-material'; +import {Theme} from 'src/theme'; +import useDataset from 'src/hooks/useDataset'; +import useImages from 'src/hooks/useImages'; +import api from 'src/utils/api'; +import bytesToSize from 'src/utils/bytesToSize'; +import {Image} from 'src/types/image'; + +interface ImagesDropzoneProps { + callback?: () => void; + className?: string; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + margin: theme.spacing(0, 0, 1) + }, + dropZone: { + border: `2px dashed #6e7f90`, + padding: theme.spacing(6), + display: 'flex', + justifyContent: 'center', + flexWrap: 'wrap', + alignItems: 'center', + '&:hover': { + backgroundColor: theme.palette.action.hover, + opacity: 0.5, + cursor: 'pointer' + } + }, + dragActive: { + backgroundColor: theme.palette.action.hover, + opacity: 0.5 + }, + image: { + width: 200 + }, + info: { + marginTop: theme.spacing(1) + }, + list: { + maxHeight: 320 + }, + actions: { + marginTop: theme.spacing(2), + display: 'flex', + justifyContent: 'flex-end', + '& > * + *': { + marginLeft: theme.spacing(2) + }, + color: theme.palette.background.paper + }, + loader: { + width: '20px !important', + height: '20px !important' + } +})); + +const ImagesDropzone: FC = ({callback, className, ...rest}) => { + const classes = useStyles(); + const {enqueueSnackbar} = useSnackbar(); + + const {dataset, saveDataset} = useDataset(); + const {saveImages} = useImages(); + + const [isUploading, setIsUploading] = useState(false); + const [files, setFiles] = useState([]); + + const handleDrop = useCallback(acceptedImages => { + setFiles(prevImages => + [...prevImages.filter(prev => !acceptedImages.map(image => image.path).includes(prev.path))].concat( + acceptedImages + ) + ); + }, []); + + const handleRemoveAll = () => { + setFiles([]); + }; + + const {getRootProps, getInputProps, isDragActive} = useDropzone({ + accept: 'image/jpeg, image/png', + onDrop: handleDrop, + minSize: 0, + maxSize: 1000 * 1024 * 1024 + }); + + const handleUpload = async () => { + if (!isUploading) { + setIsUploading(true); + let formData = new FormData(); + files.map(image => formData.append('files', image)); + try { + const response = await api.post<{images: Image[]}>(`/datasets/${dataset.id}/images/`, formData, { + headers: {'Content-Type': 'multipart/form-data'} + }); + saveImages((images: Image[] | null) => (images instanceof Array + ? [...images, ...response.data.images] + : response.data.images + )); + saveDataset({ + ...dataset, + image_count: dataset.image_count + response.data.images.length + }); + enqueueSnackbar(`${files.length} images uploaded`, { + variant: 'success' + }); + } catch (error) { + enqueueSnackbar(error.message || 'Something went wrong', { + variant: 'error' + }); + } finally { + setIsUploading(false); + setFiles([]); + callback(); + } + } + }; + + return ( +
+
+ +
+ Select Images +
+
+ + + Drop images here or click browse through your machine + + +
+
+ + + {files.map((file, i) => ( + + + + + + + + + + + + ))} + + +
+ {files.length > 0 && ( + + )} + +
+
+ ); +}; + +export default ImagesDropzone; diff --git a/ux/src/components/Page.tsx b/ux/src/components/Page.tsx new file mode 100755 index 00000000..49b4358f --- /dev/null +++ b/ux/src/components/Page.tsx @@ -0,0 +1,46 @@ +import React, {forwardRef, HTMLProps, ReactNode, useCallback, useEffect} from 'react'; +import {Helmet} from 'react-helmet'; +import {useLocation} from 'react-router-dom'; +import track from 'src/utils/analytics'; +import {useSelector} from 'src/store'; + + +interface PageProps extends HTMLProps { + children?: ReactNode; + title?: string; +} + +const Page = forwardRef(({children, title = '', ...rest}, ref) => { + const location = useLocation(); + + const {notifications} = useSelector(state => state.notifications); + + const unreadNotificationsCount = notifications.filter(notification => !notification.opened).length; + + const sendPageViewEvent = useCallback(() => { + track.pageview({ + page_path: location.pathname + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + sendPageViewEvent(); + }, [sendPageViewEvent]); + + return ( +
+ + + {unreadNotificationsCount > 0 ? `(${unreadNotificationsCount}) ` : ''} + {title} + {title ? ' | ' : ''} + Datatensor {process.env.REACT_APP_ENVIRONMENT === 'development' ? '🔥' : ''} + + + {children} +
+ ); +}); + +export default Page; diff --git a/ux/src/components/QuillEditor.tsx b/ux/src/components/QuillEditor.tsx new file mode 100755 index 00000000..f9863b4d --- /dev/null +++ b/ux/src/components/QuillEditor.tsx @@ -0,0 +1,110 @@ +import React, {FC} from 'react'; +import clsx from 'clsx'; +import ReactQuill from 'react-quill'; +import makeStyles from '@mui/styles/makeStyles'; +import {Theme} from 'src/theme'; + +// NOTE: At this moment, this ReactQuill does not export +// the types for props and we cannot extend them +interface QuillEditorProps { + className?: string; + + [key: string]: any; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + '& .ql-editor': { + maxHeight: 150, + '&::-webkit-scrollbar': { + width: '0.4em' + }, + '&::-webkit-scrollbar-track': { + boxShadow: `inset 0 0 6px ${theme.palette.primary.main}`, + webkitBoxShadow: `inset 0 0 6px ${theme.palette.primary.main}` + }, + '&::-webkit-scrollbar-thumb': { + backgroundColor: `${theme.palette.primary.main}`, + outline: '1px solid slategrey' + } + }, + '& .ql-toolbar': { + borderLeft: 'none', + borderTop: 'none', + borderRight: 'none', + borderBottom: `1px solid ${theme.palette.divider}`, + '& .ql-picker-label:hover': { + color: theme.palette.primary.main + }, + '& .ql-picker-label.ql-active': { + color: theme.palette.primary.main + }, + '& .ql-picker-item:hover': { + color: theme.palette.primary.main + }, + '& .ql-picker-item.ql-selected': { + color: theme.palette.primary.main + }, + '& button:hover': { + color: theme.palette.primary.main, + '& .ql-stroke': { + stroke: theme.palette.primary.main + } + }, + '& button:focus': { + color: theme.palette.primary.main, + '& .ql-stroke': { + stroke: theme.palette.primary.main + } + }, + '& button.ql-active': { + '& .ql-stroke': { + stroke: theme.palette.primary.main + } + }, + '& .ql-stroke': { + stroke: theme.palette.text.primary + }, + '& .ql-picker': { + color: theme.palette.text.primary + }, + '& .ql-picker-options': { + padding: theme.spacing(2), + backgroundColor: theme.palette.background.default, + border: 'none', + boxShadow: theme.shadows[10], + borderRadius: theme.shape.borderRadius + } + }, + '& .ql-container': { + border: 'none', + '& .ql-editor': { + fontFamily: theme.typography.fontFamily, + fontSize: 16, + color: theme.palette.text.primary, + '&.ql-blank::before': { + color: theme.palette.text.primary + } + } + } + } +})); + +const QuillEditor: FC = ({className, ...rest}) => { + const classes = useStyles(); + + return ( + // @ts-ignore + + ); +}; + +export default QuillEditor; diff --git a/ux/src/components/UserAvatar.tsx b/ux/src/components/UserAvatar.tsx new file mode 100755 index 00000000..a04e383c --- /dev/null +++ b/ux/src/components/UserAvatar.tsx @@ -0,0 +1,86 @@ +import React, {FC} from 'react'; +import clsx from 'clsx'; +import {Avatar, Badge} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import {User} from 'src/types/user'; +import getInitials from 'src/utils/getInitials'; +import {Theme} from 'src/theme'; +import {GithubIcon, GoogleIcon, StackoverflowIcon} from 'src/views/auth/LoginView/OAuthLoginButton'; +import useAuth from 'src/hooks/useAuth'; + + +interface UserAvatarProps { + className?: string; + style?: object; + user?: User; + disableBadge?: boolean; +} + +const useStyles = makeStyles((theme: Theme) => ({ + badge: { + '& .MuiBadge-badge': { + width: '100%', + height: '100%', + padding: 0 + } + }, + smallAvatar: { + width: '45%', + height: '45%', + minWidth: 22, + minHeight: 22, + maxWidth: 35, + maxHeight: 35, + background: theme.palette.background.default, + opacity: 0.8, + '& svg': { + padding: 2, + color: theme.palette.background.default, + fill: theme.palette.text.primary, + width: 'auto !important', + height: 'auto !important' + } + }, + border: { + border: `2px solid ${theme.palette.background.default}` + } +})); + +const UserAvatar: FC = ({user = null, className, disableBadge, ...rest}) => { + const classes = useStyles(); + const {user: loggedUser} = useAuth(); + + const displayedUser = user !== null ? user : loggedUser; + + return displayedUser.scope && !disableBadge ? ( + + {displayedUser.scope === 'github' && } + {displayedUser.scope === 'google' && } + {displayedUser.scope === 'stackoverflow' && } + + } + > + + {getInitials(displayedUser.name)} + + + ) : ( + + {getInitials(displayedUser.name)} + + ); +}; + +export default UserAvatar; diff --git a/ux/src/components/UserLabel.tsx b/ux/src/components/UserLabel.tsx new file mode 100755 index 00000000..f80e01c8 --- /dev/null +++ b/ux/src/components/UserLabel.tsx @@ -0,0 +1,54 @@ +import React, {FC} from 'react'; +import {Link as RouterLink} from 'react-router-dom'; +import clsx from 'clsx'; +import {Box, Link} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import {User} from 'src/types/user'; +import {Theme} from 'src/theme'; +import UserAvatar from 'src/components/UserAvatar'; + +interface UserLabelProps { + className?: string; + user: User; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + display: 'flex', + alignItems: 'center' + }, + link: { + display: 'block' + } +})); + +const UserLabel: FC = ({children, user, className}) => { + const classes = useStyles(); + + if (!user) return null; + + return ( +
+ + + + +
+ event.stopPropagation()} + to={`/app/users/${user.id}`} + variant="h6" + > + {user.name} + + + {children} +
+
+ ); +}; + +export default UserLabel; diff --git a/ux/src/components/charts/UserScopes.tsx b/ux/src/components/charts/UserScopes.tsx new file mode 100644 index 00000000..b8106856 --- /dev/null +++ b/ux/src/components/charts/UserScopes.tsx @@ -0,0 +1,104 @@ +import React, {FC} from 'react'; +import {useHistory} from 'react-router'; +import Chart from 'react-apexcharts'; +import clsx from 'clsx'; +import { + Card, + CardContent, + CardHeader, + Divider, + ListItemIcon, + ListItemText, + MenuItem, + Theme, + useTheme +} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import {Users as UsersIcon} from 'react-feather'; +import GenericMoreButton from 'src/components/utils/GenericMoreButton'; +import {User} from 'src/types/user'; + + +interface UserScopesProps { + className?: string; + users: User[]; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + background: 'transparent', + boxShadow: 'none', + border: `solid 1px ${theme.palette.divider}` + }, + shrink: { + paddingBottom: 0, + [theme.breakpoints.down('sm')]: { + paddingLeft: 0, + paddingRight: 0 + } + } +})); + +const UserScopes: FC = ({className, users, ...rest}) => { + const classes = useStyles(); + const theme = useTheme(); + + const history = useHistory(); + + return ( + + + history.push('/app/users')}> + + + + + + + } + title="Scopes Over Time" + /> + + + user.scope === null).length, + users.filter(user => user.scope === 'github').length, + users.filter(user => user.scope === 'google').length, + users.filter(user => user.scope === 'stackoverflow').length + ]} + type="donut" + height={350} + /> + + + ); +}; + +export default UserScopes; diff --git a/ux/src/components/charts/UsersOverTime.tsx b/ux/src/components/charts/UsersOverTime.tsx new file mode 100644 index 00000000..9c9cd525 --- /dev/null +++ b/ux/src/components/charts/UsersOverTime.tsx @@ -0,0 +1,159 @@ +import React, {FC} from 'react'; +import {useHistory} from 'react-router'; +import Chart from 'react-apexcharts'; +import clsx from 'clsx'; +import { + Card, + CardContent, + CardHeader, + Divider, + ListItemIcon, + ListItemText, + MenuItem, + Theme, + useTheme +} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import {Users as UsersIcon} from 'react-feather'; +import GenericMoreButton from 'src/components/utils/GenericMoreButton'; +import {User} from 'src/types/user'; +import moment from 'moment'; +import {TimeRange} from 'src/types/timeRange'; + + +interface UsersOverTimeProps { + className?: string; + users: User[]; + timeRange: TimeRange; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + background: 'transparent', + boxShadow: 'none', + border: `solid 1px ${theme.palette.divider}` + }, + shrink: { + paddingBottom: 0, + [theme.breakpoints.down('sm')]: { + paddingLeft: 0, + paddingRight: 0 + } + } +})); + +const buildArray = (size: number) => + Array.apply(null, Array(size)) + .map((_, i) => i) + .reverse(); + +const UsersOverTime: FC = ({className, users, timeRange, ...rest}) => { + const classes = useStyles(); + const theme = useTheme(); + + const history = useHistory(); + + const generateChartData = (size: number, interval: string, format: string) => ({ + data: buildArray(size + 1).map( + index => + users.filter(user => + moment(user.created_at).isBetween( + moment(new Date()).subtract(index + 1, interval), + moment(new Date()).subtract(index, interval) + ) + ).length + ), + labels: buildArray(size + 1).map(index => + moment(new Date()) + .subtract(index, interval) + .format(format) + ) + }); + + const usersOverTime = { + last_hour: generateChartData(60, 'minutes', 'HH:mm'), + last_day: generateChartData(24, 'hours', 'HH:00'), + last_week: generateChartData(7, 'days', 'DD MMM'), + last_month: generateChartData(31, 'days', 'DD MMM'), + last_year: generateChartData(12, 'months', 'MMM') + }; + + return ( + + + history.push('/app/users')}> + + + + + + + } + title="Users Over Time" + /> + + + value.toFixed(0) + } + } + ] + }} + series={[ + { + name: 'Users', + data: usersOverTime[timeRange.value].data.reduce( + (a, x, i) => [...a, x + (a[i - 1] || 0)], + [] + ) + } + ]} + type="area" + height={350} + /> + + + ); +}; + +export default UsersOverTime; diff --git a/ux/src/components/core/Augmentor/index.tsx b/ux/src/components/core/Augmentor/index.tsx new file mode 100755 index 00000000..cc920b32 --- /dev/null +++ b/ux/src/components/core/Augmentor/index.tsx @@ -0,0 +1,312 @@ +import React, {FC, useState} from 'react'; +import {Formik} from 'formik'; +import * as Yup from 'yup'; +import {useSnackbar} from 'notistack'; +import { + Alert, + Box, + Button, + capitalize, + Chip, + Dialog, + DialogContent, + FormHelperText, + Grid, + Link, + TextField, + Typography +} from '@mui/material'; +import {KeyboardArrowLeft as BackIcon, KeyboardArrowRight as NextIcon} from '@mui/icons-material'; +import {makeStyles} from '@mui/styles'; +import {Theme} from 'src/theme'; +import Pipeline from 'src/components/core/Pipeline'; +import PipelineSample from 'src/components/core/PipelineSample'; +import useImages from 'src/hooks/useImages'; +import api from 'src/utils/api'; +import {Task} from 'src/types/task'; +import useTasks from 'src/hooks/useTasks'; +import useDataset from 'src/hooks/useDataset'; +import {useDispatch, useSelector} from 'src/store'; +import {Operation, OperationType} from 'src/types/pipeline'; +import {Label} from 'src/types/label'; +import {OPERATIONS_ICONS, OPERATIONS_TYPES} from 'src/config'; +import {addTask} from 'src/slices/tasks'; + +const useStyles = makeStyles((theme: Theme) => ({ + root: {}, + wrapper: { + border: `solid 1px ${theme.palette.divider}`, + borderRadius: theme.spacing(0.5) + }, + refresh: { + width: '100%', + margin: theme.spacing(1, 'auto') + }, + dialog: { + padding: theme.spacing(1, 2, 2) + }, + close: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500] + }, + actions: { + marginTop: theme.spacing(2), + paddingTop: theme.spacing(2), + borderTop: `dashed 1px #7f8e9d`, + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + width: '100%' + } +})); + +interface AugmentorProps { + open: boolean; + handleClose: () => void; +} + +const Augmentor: FC = ({open, handleClose}) => { + const classes = useStyles(); + const {enqueueSnackbar} = useSnackbar(); + const dispatch = useDispatch(); + + const {dataset} = useDataset(); + const {images} = useImages(); + const {saveTasks} = useTasks(); + + const pipeline = useSelector(state => state.pipeline); + const operations: Operation[] = pipeline.operations.allTypes.map(type => pipeline.operations.byType[type]); + + const [activeStep, setActiveStep] = useState(0); + + const handleNext = () => { + setActiveStep(prevActiveStep => prevActiveStep + 1); + }; + + const handleBack = () => { + setActiveStep(prevActiveStep => prevActiveStep - 1); + }; + + const [operationType, setOperationType] = useState(OPERATIONS_TYPES[0]); + + if (images === null || images.length === 0) return null; + + return ( + + + { + try { + const response = await api.post<{ + task: Task; + }>(`/datasets/${dataset.id}/tasks/`, { + type: 'augmentor', + properties: { + operations, + image_count: values.image_count + } + }); + saveTasks(tasks => [...(tasks || []), response.data.task]); + dispatch(addTask(response.data.task)); + + setStatus({success: true}); + setSubmitting(false); + handleClose(); + } catch (err) { + console.error(err); + setStatus({success: false}); + setErrors({submit: err.message}); + setSubmitting(false); + enqueueSnackbar(err.message, {variant: 'error'}); + } + }} + > + {({errors, handleBlur, handleChange, handleSubmit, isSubmitting, touched, values}) => ( +
+ + {activeStep === 0 && ( + + + Introduction + + + + The image augmentation process allows you to{' '} + generate new images in order to get a richer dataset, + without having to re-label anything. + + + + To get augmented images of each original labeled image in your dataset, you + need to set up a pipeline of operations to apply.{' '} + window.open('/docs/datasets/augmentation', '_blank')} + > + Learn more + + + + + {OPERATIONS_TYPES.slice(0, 7).map(operation_type => ( + setOperationType(operation_type)} + sx={{mb: 0.5, mr: 0.5}} + variant={operation_type === operationType ? 'filled' : 'outlined'} + /> + ))} + + + + {`${operationType}.gif`} + + + + Every operation has at minimum a probability parameter, which controls how + likely the operation will be applied to each image that is seen as the image + passes through the pipeline. + + + )} + + {activeStep === 1 && ( + + + Sample + + + + A sample is the result of your operations pipeline applied to one image of + your dataset. + + + + api.post<{images: string[]; images_labels: Label[][]}>( + `/datasets/${dataset.id}/pipelines/sample`, + {operations} + ) + } + /> + + )} + + {activeStep === 2 && ( + + + Augmented image count + + + + Indicate the number of augmented images you want. + + + + + + + + + There are {dataset.image_count} original images in your + dataset, so you can generate up to{' '} + {dataset.image_count * 4} augmented images. + + + + {errors.submit && ( + + {errors.submit} + + )} + + )} + + + + Operations pipeline + + + + + + + + {activeStep > 0 && ( + + )} + + {activeStep === 0 && ( + + )} + {activeStep === 1 && ( + + )} + {activeStep === 2 && ( + + )} + +
+ )} +
+
+
+ ); +}; + +export default Augmentor; diff --git a/ux/src/components/core/Dataset/Categories.tsx b/ux/src/components/core/Dataset/Categories.tsx new file mode 100755 index 00000000..93c5db33 --- /dev/null +++ b/ux/src/components/core/Dataset/Categories.tsx @@ -0,0 +1,183 @@ +import React, {cloneElement, FC, useState} from 'react'; +import clsx from 'clsx'; +import {useSnackbar} from 'notistack'; + +import {Box, Button, capitalize, Chip, IconButton, Link, Tooltip, Typography, useTheme} from '@mui/material'; +import {CreateOutlined as EditIcon, Delete as DeleteIcon} from '@mui/icons-material'; +import makeStyles from '@mui/styles/makeStyles'; + +import {Theme} from 'src/theme'; +import AddCategoryAction from 'src/components/core/Labelisator/AddCategoryAction'; +import {Category} from 'src/types/category'; +import useDataset from 'src/hooks/useDataset'; +import useImage from 'src/hooks/useImage'; +import useCategory from 'src/hooks/useCategory'; +import api from 'src/utils/api'; +import {COLORS} from 'src/utils/colors'; +import {MAX_CATEGORIES_DISPLAYED, SUPERCATEGORIES_ICONS} from 'src/config'; +import useAuth from 'src/hooks/useAuth'; + +interface CategoriesProps { + className?: string; +} + +interface CategoryProps { + category: Category; + index: number; + edit: boolean; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + padding: theme.spacing(2, 0), + [theme.breakpoints.down('sm')]: { + padding: theme.spacing(0) + } + }, + categories: { + display: 'flex', + flexWrap: 'wrap', + alignContent: 'flex-start' + }, + link: { + display: 'flex', + alignItems: 'center', + marginLeft: theme.spacing(1) + } +})); + +const DTCategory: FC = ({category, index, edit}) => { + const theme = useTheme(); + + const {enqueueSnackbar} = useSnackbar(); + + const {dataset, saveCategories} = useDataset(); + const {currentCategory, saveCurrentCategory} = useCategory(); + const {saveLabels} = useImage(); + + const handleDeleteCategory = async (category_id: string) => { + try { + await api.delete(`/datasets/${dataset.id}/categories/${category_id}`); + + saveCategories(categories => categories.filter(category => category.id !== category_id)); + saveLabels(labels => labels.filter(label => label.category_id !== category_id)); + + if (currentCategory && currentCategory.id === category_id) saveCurrentCategory(null); + } catch (error) { + enqueueSnackbar(error.message || 'Something went wrong', { + variant: 'error' + }); + } + }; + + return ( + + + + {capitalize(category.name)} + {category.labels_count > 0 && ` • ${category.labels_count}`} + + + } + title={`${capitalize(category.name)} | ${capitalize(category.supercategory)}`} + size="medium" + style={{ + color: theme.palette.getContrastText(COLORS[index]), + background: COLORS[index], + borderColor: COLORS[index], + boxShadow: theme.shadows[1] + }} + variant="outlined" + clickable + onClick={() => saveCurrentCategory(category)} + onDelete={edit ? () => handleDeleteCategory(category.id) : null} + deleteIcon={ + edit ? ( + + + + + + ) : null + } + /> + + ); +}; + +const DTCategories: FC = ({className}) => { + const classes = useStyles(); + + const {user} = useAuth(); + const {dataset, categories} = useDataset(); + + const [expand, setExpand] = useState(false); + const [edit, setEdit] = useState(false); + + const toggleEdit = () => { + setEdit(true); + }; + + return ( +
+ + + Labels per category + + + {dataset.user_id === user.id && categories.length > 0 && ( + + )} + + +
+ {expand ? ( + <> + {categories + .sort((a, b) => -b.name.localeCompare(a.name)) + .map(category => ( + + ))} + + + + ) : ( + <> + {categories + .sort((a, b) => -b.name.localeCompare(a.name)) + .slice(0, MAX_CATEGORIES_DISPLAYED) + .map(category => ( + + ))} + {categories.length > MAX_CATEGORIES_DISPLAYED ? ( + setExpand(true)}> + and {categories.length - MAX_CATEGORIES_DISPLAYED} more... + + ) : ( + + )} + + )} +
+
+ ); +}; + +export default DTCategories; diff --git a/ux/src/components/core/Dataset/index.tsx b/ux/src/components/core/Dataset/index.tsx new file mode 100755 index 00000000..70772dda --- /dev/null +++ b/ux/src/components/core/Dataset/index.tsx @@ -0,0 +1,292 @@ +import React, {FC, useCallback, useEffect, useRef, useState} from 'react'; +import {useHistory} from 'react-router'; +import {Link as RouterLink} from 'react-router-dom'; +import clsx from 'clsx'; +import { + Box, + capitalize, + Card, + CardActionArea, + CardContent, + CardHeader, + Chip, + Link, + Skeleton, + Stack, + Typography +} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; + +import { + LocalOfferOutlined as LabelsIcon, + Lock as PrivateIcon, + PhotoLibraryOutlined as ImagesIcon, + Public as PublicIcon +} from '@mui/icons-material'; +import {Theme} from 'src/theme'; +import api from 'src/utils/api'; +import {Image} from 'src/types/image'; +import UserAvatar from 'src/components/UserAvatar'; +import {UserConsumer, UserProvider} from 'src/store/UserContext'; +import {EMPTY_DESCRIPTIONS} from 'src/constants'; +import DTImage from 'src/components/core/Images/Image'; +import {ImageProvider} from 'src/store/ImageContext'; +import useDataset from 'src/hooks/useDataset'; +import {Category} from 'src/types/category'; +import {SUPERCATEGORIES_ICONS} from 'src/config'; + +interface DatasetProps { + images?: Image[]; + className?: string; + onClick?: () => void; + disabled?: boolean; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + position: 'relative', + maxWidth: 400, + width: '100%' + }, + media: { + height: 200 + }, + categories: { + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(1), + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end' + }, + category: { + color: 'white', + background: 'rgba(0, 0, 0, 0.7)', + backdropFilter: 'blur(3px)', + marginBottom: theme.spacing(0.5) + }, + chip: { + marginLeft: 6 + }, + link: { + fontWeight: 400 + }, + noWrap: { + whiteSpace: 'nowrap' + }, + wrapper: { + display: 'flex', + alignItems: 'center', + '& > p': { + whiteSpace: 'nowrap' + } + }, + icon: { + color: theme.palette.text.primary, + marginRight: 8, + verticalAlign: 'middle' + }, + image: { + zIndex: 0, + maxHeight: 300, + minWidth: '100%', + overflow: 'hidden' + } +})); + +interface CategoryProps { + category: Category; + index: number; +} + +const DTCategory: FC = ({category, index}) => { + const classes = useStyles(); + + return ( + + + {capitalize(category.name)} + {` • ${category.labels_count || 0}`} + + + } + title={`${capitalize(category.name)} | ${capitalize(category.supercategory)}`} + size="small" + variant="outlined" + style={{filter: `opacity(${1 - 0.15 * index})`}} + /> + ); +}; + +const DTDataset: FC = ({className, images = null, onClick, disabled = false, ...rest}) => { + const classes = useStyles(); + const history = useHistory(); + + const datasetRef = useRef(null); + + const {dataset, categories} = useDataset(); + + const [imagesPreview, setImagesPreview] = useState(images); + + const fetchImages = useCallback(async () => { + try { + const response = await api.get<{images: Image[]}>(`/datasets/${dataset.id}/images/`, { + params: { + include_labels: true, + limit: 1 + } + }); + setImagesPreview(response.data.images); + } catch (err) { + console.error(err); + } + }, [dataset.id]); + + useEffect(() => { + if (imagesPreview === null) fetchImages(); + }, [fetchImages, imagesPreview]); + + const totalLabelsCount = categories.map(category => category.labels_count || 0).reduce((acc, val) => acc + val, 0); + + return ( + + + + {value => ( + } + title={{dataset.name && capitalize(dataset.name)}} + subheader={ + + event.stopPropagation()} + to={`/app/users/${value.user.id}`} + variant="subtitle2" + > + {value.user.name} + +   • + : } + size="small" + variant={dataset.is_public ? 'outlined' : 'filled'} + /> + + } + /> + )} + + + + history.push(`/app/datasets/${dataset.id}#`)} + disabled={disabled} + > + {imagesPreview instanceof Array ? ( + + {imagesPreview.map((imagePreview, index) => ( + + + +
+ {categories + .sort((a, b) => -b.name.localeCompare(a.name)) + .slice(0, 4) + .map((category, index) => ( + + ))} +
+
+ ))} +
+ ) : ( + + )} + + + + + {dataset.name && capitalize(dataset.name)} + + No description provided' + : dataset.description.length > 70 + ? `${dataset.description.slice(0, 70)}...` + : dataset.description + }} + /> + + + + + + + + {dataset.image_count}{' '} + + {dataset.image_count > 1 ? 'images' : 'image'} + + {dataset.augmented_count > 0 && ( + + + +{dataset.augmented_count} + {' '} + augmented + + )} + + + + + + + {totalLabelsCount}{' '} + + labels + + + + + + + +
+
+ ); +}; + +export default DTDataset; diff --git a/ux/src/components/core/Images/Image.tsx b/ux/src/components/core/Images/Image.tsx new file mode 100755 index 00000000..045d6db9 --- /dev/null +++ b/ux/src/components/core/Images/Image.tsx @@ -0,0 +1,133 @@ +import React, {FC, ReactNode, useEffect, useRef, useState} from 'react'; +import clsx from 'clsx'; +import {Box, ButtonBase, Skeleton} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import {Theme} from 'src/theme'; +import useCategory from 'src/hooks/useCategory'; +import useDataset from 'src/hooks/useDataset'; +import useImage from 'src/hooks/useImage'; +import {Category} from 'src/types/category'; +import {drawLabels, reset} from 'src/utils/labeling'; + +interface DTImageProps { + className?: string; + clickable?: boolean; + skeleton?: boolean; + overlay?: ReactNode; + onClick?: () => void; + highlightCategory?: Category; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + position: 'relative', + width: '100%', + '& .overlay': { + zIndex: 1050, + '& > *': { + zIndex: 1050 + } + } + }, + wrapper: { + position: 'relative', + '& img': { + height: '100%', + border: theme.palette.mode === 'dark' ? 'solid 1px black' : 'none', + boxShadow: '0px 0px 2px black' + } + }, + clickable: { + zIndex: 1000, + '& .MuiTouchRipple-root': { + zIndex: 1000 + }, + '&:hover img': { + filter: 'brightness(0.85)' + } + }, + canvas: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + zIndex: 1, + opacity: 0.95 + } +})); + +const DTImage: FC = ({ + className, + clickable = false, + skeleton = false, + overlay = null, + onClick = () => {}, + highlightCategory = null +}) => { + const classes = useStyles(); + + const {categories} = useDataset(); + const {currentCategory} = useCategory(); + const {image, labels} = useImage(); + + const imageRef = useRef(null); + const canvasRef = useRef(null); + + const [loaded, setLoaded] = useState(false); + + const handleLoad = () => { + if (imageRef.current.complete) setLoaded(true); + + if (canvasRef.current && imageRef.current.complete) { + reset(canvasRef.current); + drawLabels(canvasRef.current, labels, categories, 0, 0, false, false, currentCategory, highlightCategory); + } + }; + + useEffect(() => { + if (canvasRef.current && imageRef.current.complete) { + reset(canvasRef.current); + drawLabels(canvasRef.current, labels, categories, 0, 0, false, false, currentCategory, highlightCategory); + } + }, [labels, categories, loaded, currentCategory, highlightCategory]); + + return ( + + {skeleton && ( + + )} + {clickable ? ( + + {image?.name} + + + ) : ( +
+ {image?.name} + +
+ )} + {overlay && loaded &&
{overlay}
} +
+ ); +}; + +export default DTImage; diff --git a/ux/src/components/core/Images/ImagesList.tsx b/ux/src/components/core/Images/ImagesList.tsx new file mode 100755 index 00000000..70e7bc43 --- /dev/null +++ b/ux/src/components/core/Images/ImagesList.tsx @@ -0,0 +1,186 @@ +import React, {FC} from 'react'; +import clsx from 'clsx'; +import {useSnackbar} from 'notistack'; +import {IconButton, ListItemIcon, Menu, MenuItem, Skeleton, Tooltip, Typography} from '@mui/material'; +import {CreateOutlined as EditIcon, Delete as DeleteIcon, MoreVert as MoreIcon} from '@mui/icons-material'; +import Masonry from '@mui/lab/Masonry'; +import MasonryItem from '@mui/lab/MasonryItem'; +import makeStyles from '@mui/styles/makeStyles'; +import DTImage from 'src/components/core/Images/Image'; +import useDataset from 'src/hooks/useDataset'; +import useCategory from 'src/hooks/useCategory'; +import useImages from 'src/hooks/useImages'; +import {Theme} from 'src/theme'; +import {ImageProvider} from 'src/store/ImageContext'; +import {LAZY_LOAD_BATCH} from 'src/constants'; +import {Image} from 'src/types/image'; +import api from 'src/utils/api'; +import goToHash from 'src/utils/goToHash'; + +interface ImagesListProps { + className?: string; + onClick?: (image: Image) => void; +} + +interface ImageOverlayProps { + image: Image; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + overflow: 'hidden' + }, + filled: { + minHeight: 600 + }, + grid: { + display: 'flex', + marginLeft: -10, + width: 'auto', + marginRight: 10 + }, + column: { + paddingLeft: 10, + backgroundClip: 'padding-box', + '& > button': { + margin: theme.spacing(0, 0, 1) + } + }, + image: { + marginBottom: theme.spacing(1) + }, + icon: { + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(1), + width: 32, + height: 32, + background: 'rgba(0, 0, 0, 0.25)', + color: 'white', + '&:hover': { + background: 'rgba(0, 0, 0, 0.5)' + } + } +})); + +const ImageOverlay: FC = ({image}) => { + const classes = useStyles(); + const {enqueueSnackbar} = useSnackbar(); + + const [anchorEl, setAnchorEl] = React.useState(null); + + const open = Boolean(anchorEl); + const handleOpenMenu = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleCloseMenu = () => { + setAnchorEl(null); + }; + + const {dataset, saveDataset} = useDataset(); + const {images, saveImages} = useImages(); + + const handleOpenLabelisator = () => { + goToHash(image.id); + handleCloseMenu(); + }; + + const handleDelete = async () => { + try { + const response = await api.delete<{deleted_count: number}>(`/datasets/${dataset.id}/images/${image.id}`); + saveImages(images.filter(current => current.id !== image.id)); + saveDataset({ + ...dataset, + image_count: dataset.image_count - 1, + augmented_count: dataset.augmented_count - (response.data.deleted_count - 1) + }); + handleCloseMenu(); + } catch (error) { + enqueueSnackbar(error.message || 'Something went wrong', { + variant: 'error' + }); + } + }; + + return ( + <> + More} disableInteractive> + { + event.stopPropagation(); + handleOpenMenu(event); + }} + > + + + + + + + + + + Edit + + + + + + Delete + + + + ); +}; + +const DTImagesList: FC = ({className, onClick = () => {}, ...rest}) => { + const classes = useStyles(); + + const {images} = useImages(); + const {currentCategory} = useCategory(); + + if (images === null) + return ( +
+ + {Array.from(Array(LAZY_LOAD_BATCH), () => null).map((_, index) => ( + + + + ))} + +
+ ); + + if (images.length === 0) return null; + + return ( +
= LAZY_LOAD_BATCH && classes.filled)} {...rest}> + + {images.map((image: Image) => ( + + + onClick(image)} + skeleton + overlay={} + highlightCategory={currentCategory} + /> + + + ))} + +
+ ); +}; + +export default DTImagesList; diff --git a/ux/src/components/core/Images/UploadButton.tsx b/ux/src/components/core/Images/UploadButton.tsx new file mode 100644 index 00000000..4e01d1a3 --- /dev/null +++ b/ux/src/components/core/Images/UploadButton.tsx @@ -0,0 +1,56 @@ +import React, {FC, useState} from 'react'; +import {Button, Dialog, DialogContent, DialogTitle, IconButton} from '@mui/material'; +import {makeStyles} from '@mui/styles'; +import {Close as CloseIcon, PublishOutlined as UploadIcon} from '@mui/icons-material'; +import ImagesDropzone from 'src/components/ImagesDropzone'; +import {Theme} from 'src/theme'; + + +const useStyles = makeStyles((theme: Theme) => ({ + close: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500] + } +})); + +interface UploadButtonProps { + size?: 'small' | 'medium' | 'large' +} + +const UploadButton: FC = ({ size = 'medium' }) => { + const classes = useStyles(); + + const [openUpload, setOpenUpload] = useState(false); + + const handleUploadOpen = () => { + setOpenUpload(true); + }; + + const handleCloseUpload = () => { + setOpenUpload(false); + }; + + return ( + <> + + + + + Upload images + + + + + + + + + + ); +}; + +export default UploadButton; diff --git a/ux/src/components/core/Labelisator/AddCategoryAction.tsx b/ux/src/components/core/Labelisator/AddCategoryAction.tsx new file mode 100755 index 00000000..faa8409d --- /dev/null +++ b/ux/src/components/core/Labelisator/AddCategoryAction.tsx @@ -0,0 +1,185 @@ +import React, {FC, useState} from 'react'; +import {useSnackbar} from 'notistack'; + +import {Formik} from 'formik'; +import * as Yup from 'yup'; +import { + Box, + Button, + capitalize, + Chip, + Dialog, + DialogContent, + DialogTitle, + Grid, + IconButton, + InputLabel, + MenuItem, + Select, + TextField, + Typography +} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import {Add as AddIcon, CategoryOutlined as CategoriesIcon, Close as CloseIcon} from '@mui/icons-material'; +import useIsMountedRef from 'src/hooks/useIsMountedRef'; +import api from 'src/utils/api'; +import {Theme} from 'src/theme'; +import {Category} from 'src/types/category'; +import useDataset from 'src/hooks/useDataset'; +import {SUPERCATEGORIES, SUPERCATEGORIES_ICONS} from 'src/config'; + +interface AddCategoryActionProps { + className?: string; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: {}, + select: { + display: 'flex', + alignItems: 'center' + }, + close: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500] + } +})); + +const AddCategoryAction: FC = ({className}) => { + const classes = useStyles(); + const isMountedRef = useIsMountedRef(); + + const {enqueueSnackbar} = useSnackbar(); + + const {dataset, categories, saveCategories} = useDataset(); + + const [openCategoryCreation, setOpenCategoryCreation] = useState(false); + + const handleCloseCategoryCreation = () => { + setOpenCategoryCreation(false); + }; + + return ( + <> + + } + onClick={() => setOpenCategoryCreation(true)} + /> + + + + + + + + + Add a new category + + + + + + + + { + try { + const response = await api.post<{ + category: Category; + }>(`/datasets/${dataset.id}/categories/`, values); + saveCategories([...categories, response.data.category]); + setOpenCategoryCreation(false); + + if (isMountedRef.current) { + setStatus({success: true}); + setSubmitting(false); + } + } catch (error) { + enqueueSnackbar(error.message || 'Something went wrong', {variant: 'error'}); + + if (isMountedRef.current) { + setStatus({success: false}); + setSubmitting(false); + } + } + }} + > + {({errors, handleBlur, handleChange, handleSubmit, isSubmitting, touched, values}) => ( +
+ + + event.stopPropagation()} + value={values.name} + variant="outlined" + /> + + + Supercategory + + + + + + + +
+ )} +
+
+
+
+ + ); +}; + +export default AddCategoryAction; diff --git a/ux/src/components/core/Labelisator/Categories.tsx b/ux/src/components/core/Labelisator/Categories.tsx new file mode 100755 index 00000000..d195a66a --- /dev/null +++ b/ux/src/components/core/Labelisator/Categories.tsx @@ -0,0 +1,171 @@ +import React, {cloneElement, FC, useMemo, useState} from 'react'; +import clsx from 'clsx'; +import {Box, ButtonBase, capitalize, Chip, Link, Typography, useTheme} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import {Theme} from 'src/theme'; +import {Category} from 'src/types/category'; +import AddCategoryAction from 'src/components/core/Labelisator/AddCategoryAction'; +import useDataset from 'src/hooks/useDataset'; +import useImage from 'src/hooks/useImage'; +import useCategory from 'src/hooks/useCategory'; +import {currentCategoryCount} from 'src/utils/labeling'; +import {MAX_CATEGORIES_DISPLAYED, SUPERCATEGORIES_ICONS} from 'src/config'; +import {COLORS} from 'src/utils/colors'; + +interface CategoriesProps { + className?: string; +} + +interface CategoryProps { + category: Category; + index: number; +} + +interface ChipsProps { + categories: Category[]; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + width: '100%' + }, + button: { + borderRadius: 16 + }, + wrapper: { + marginTop: theme.spacing(1), + display: 'flex', + flexWrap: 'wrap' + }, + link: { + display: 'flex', + alignItems: 'center', + marginLeft: theme.spacing(1) + } +})); + +const DTCategory: FC = ({category, index}) => { + + const classes = useStyles(); + const theme = useTheme(); + + const {saveCurrentCategory} = useCategory(); + const {labels} = useImage(); + + const count = labels ? currentCategoryCount(labels, category) : 0; + + return ( + saveCurrentCategory(category)} + > + + + {capitalize(category.name)} + {count > 0 ? (category.labels_count && ` • ${count}`) : ''} + + + } + style={{ + color: theme.palette.getContrastText(COLORS[index]), + background: COLORS[index] + }} + title={`${capitalize(category.name)} | ${capitalize(category.supercategory)}`} + size={count > 0 ? 'medium' : 'small'} + /> + + ); +}; + +const Chips: FC = ({categories, children}) => { + const classes = useStyles(); + const {labels} = useImage(); + + const [expand, setExpand] = useState(false); + + const labeledCategories = useMemo( + () => (labels ? categories.filter(category => currentCategoryCount(labels, category) > 0) : []), + [categories, labels] + ); + + const unlabeledCategories = useMemo( + () => (labels ? categories.filter(category => currentCategoryCount(labels, category) === 0) : categories), + [categories, labels] + ); + + return ( +
+ + + On this image + +
+ {labeledCategories.map(category => ( + + + + ))} + + {children} +
+
+ + + Other categories + + +
+ {expand ? ( + unlabeledCategories.map(category => ( + + + + )) + ) : ( + <> + {unlabeledCategories.slice(0, MAX_CATEGORIES_DISPLAYED).map(category => ( + + + + ))} + {unlabeledCategories.length > MAX_CATEGORIES_DISPLAYED && ( + { + setExpand(true); + }} + > + and {unlabeledCategories.length - MAX_CATEGORIES_DISPLAYED} more... + + )} + + )} +
+
+ ); +}; + +const DTCategories: FC = ({className}) => { + const classes = useStyles(); + + const {categories} = useDataset(); + + return ( +
+ + + +
+ ); +}; + +export default DTCategories; diff --git a/ux/src/components/core/Labelisator/ContextMenu.tsx b/ux/src/components/core/Labelisator/ContextMenu.tsx new file mode 100644 index 00000000..5881bd81 --- /dev/null +++ b/ux/src/components/core/Labelisator/ContextMenu.tsx @@ -0,0 +1,109 @@ +import React, {FC} from 'react'; +import {Box, capitalize, Divider, ListItemIcon, Menu, MenuItem, Typography} from '@mui/material'; +import NestedMenuItem from 'src/components/utils/NestedMenuItem'; +import makeStyles from '@mui/styles/makeStyles'; +import {Tag as CategoryIcon, Trash as DeleteIcon} from 'react-feather'; +import {Label} from 'src/types/label'; +import {Point} from 'src/types/point'; +import {Theme} from 'src/theme'; +import {reset} from 'src/utils/labeling'; +import useDataset from 'src/hooks/useDataset'; +import useImage from 'src/hooks/useImage'; + +interface ContextMenuProps { + canvas: HTMLCanvasElement; // ToolMove's canvas + selectedLabels: Label[]; + point: Point; + handleClose: () => void; +} + +const useStyles = makeStyles((theme: Theme) => ({ + item: { + minWidth: 150 + } +})); + +const ContextMenu: FC = ({canvas, selectedLabels, point, handleClose}) => { + const classes = useStyles(); + + const {categories} = useDataset(); + const {labels, saveLabels, storePosition} = useImage(); + + const handleUpdateLabelCategory = category => { + handleClose(); + reset(canvas); + let newLabels = labels.map(label => + selectedLabels.map(selectedLabel => selectedLabel.id).includes(label.id) + ? {...label, category_id: category.id} + : label + ); + saveLabels(newLabels); + storePosition(newLabels); + }; + + const handleDeleteLabel = () => { + handleClose(); + reset(canvas); + const newLabels = labels.filter(label => !selectedLabels.map(label => label.id).includes(label.id)); + saveLabels(newLabels); + storePosition(newLabels); + }; + + return ( + + + + + + + Category + + + } + > + {categories + .sort((a, b) => -b.name.localeCompare(a.name)) + .map(category => ( + handleUpdateLabelCategory(category)} + > + + {selectedLabels + .map(selectedLabel => selectedLabel.category_id) + .includes(category.id) ? ( + {capitalize(category.name)} + ) : ( + capitalize(category.name) + )} + + + ))} + + + + + + + + + + Delete + {labels instanceof Array ? ` (${selectedLabels.length})` : ''} + + + + ); +}; + +export default ContextMenu; diff --git a/ux/src/components/core/Labelisator/KeyboardListener.tsx b/ux/src/components/core/Labelisator/KeyboardListener.tsx new file mode 100644 index 00000000..d781e03f --- /dev/null +++ b/ux/src/components/core/Labelisator/KeyboardListener.tsx @@ -0,0 +1,38 @@ +import {FC} from 'react'; +import useImage from 'src/hooks/useImage'; +import useEventListener from 'use-typed-event-listener'; + + +interface KeyboardListenerProps { + index: number; + imageIds: string[]; + setTool: any; +} + +const KeyboardListener: FC = ({index, imageIds, setTool}) => { + const {validateLabels, previousPosition} = useImage(); + + const handleKeyDown = async (event: KeyboardEvent) => { + if (event.key === 'ArrowLeft') { + if (index === 0) return; + window.location.replace(`#${imageIds[index - 1]}`) + } else if (event.key === 'ArrowRight') { + if (index === imageIds.length - 1) return; + window.location.replace(`#${imageIds[index + 1]}`) + } else if (event.key === 'a') { + setTool('label'); + } else if (!event.ctrlKey && event.key === 'z') { + setTool('move'); + } else if (event.key === 's') { + validateLabels(); + } else if (event.ctrlKey && event.key === 'z') { + previousPosition(); + } + }; + + useEventListener(window, 'keydown', handleKeyDown); + + return null; +}; + +export default KeyboardListener; diff --git a/ux/src/components/core/Labelisator/KeyboardShortcuts.tsx b/ux/src/components/core/Labelisator/KeyboardShortcuts.tsx new file mode 100755 index 00000000..13c63cda --- /dev/null +++ b/ux/src/components/core/Labelisator/KeyboardShortcuts.tsx @@ -0,0 +1,121 @@ +import React, {FC, useState} from 'react'; +import {Box, Button, Dialog, DialogContent, DialogTitle, Divider, Grid, IconButton, Typography} from '@mui/material'; + +import makeStyles from '@mui/styles/makeStyles'; + +import {Close as CloseIcon, Keyboard as KeyboardIcon} from '@mui/icons-material'; +import {Theme} from 'src/theme'; + + +interface ShortcutProps { + keyDesc: string; +} + +const useStyles = makeStyles((theme: Theme) => ({ + button: { + color: theme.palette.getContrastText(theme.palette.primary.main) + }, + dialog: { + padding: theme.spacing(1, 2, 2) + }, + close: { + position: 'absolute', + right: theme.spacing(2), + top: theme.spacing(2), + color: theme.palette.grey[500] + } +})); + +const Section: FC = ({children}) => ( + + {children} + +); + +const Shortcut: FC = ({keyDesc, children}) => ( + + + + + {children} + + + + + {keyDesc.toUpperCase()} + + + + +); + +const KeyboardShortcuts: FC = () => { + const classes = useStyles(); + + const [open, setOpen] = useState(false); + + const handleOpen = (): void => { + setOpen(true); + }; + + const handleClose = (): void => { + setOpen(false); + }; + + return ( + <> + + + + + + + + + + + + + + +
Labeling
+ + Change tool (draw) + + Change tool (move) + + Multi labels selection + + Restore previous state + +
+ +
Navigation
+ + Previous image + + Next image + + Save labels + +
+
+
+
+
+ + ); +}; + +export default KeyboardShortcuts; diff --git a/ux/src/components/core/Labelisator/NextUnlabeledImageAction.tsx b/ux/src/components/core/Labelisator/NextUnlabeledImageAction.tsx new file mode 100755 index 00000000..b851981e --- /dev/null +++ b/ux/src/components/core/Labelisator/NextUnlabeledImageAction.tsx @@ -0,0 +1,100 @@ +import React, {FC} from 'react'; +import {useSnackbar} from 'notistack'; +import clsx from 'clsx'; + +import {Formik} from 'formik'; +import * as Yup from 'yup'; +import {Button, CircularProgress} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import {ArrowRight} from 'react-feather'; +import {Theme} from 'src/theme'; +import useDataset from 'src/hooks/useDataset'; +import api from 'src/utils/api'; +import goToHash from 'src/utils/goToHash'; + + +interface NextUnlabeledImageActionProps { + index: number; + className?: string; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: {}, + button: { + whiteSpace: 'nowrap', + marginLeft: theme.spacing(2) + }, + loader: { + width: '20px !important', + height: '20px !important' + } +})); + +const NextUnlabeledImageAction: FC = ({index, className}) => { + const classes = useStyles(); + + const {enqueueSnackbar} = useSnackbar(); + + const {dataset} = useDataset(); + + return ( + { + try { + const response = await api.post<{image_id: string}>( + `/search/datasets/${dataset.id}/unlabeled-image-id`, + {}, + { + params: { + offset: index + } + } + ); + if (response.data.image_id) + goToHash(response.data.image_id); + else + enqueueSnackbar('All images have labels', { + variant: 'success' + }); + } catch (err) { + console.error(err); + setStatus({success: false}); + setErrors({submit: err.message}); + setSubmitting(false); + } + }} + > + {({handleSubmit, isSubmitting}) => ( +
+ +
+ )} +
+ ); +}; + +export default NextUnlabeledImageAction; diff --git a/ux/src/components/core/Labelisator/ToolLabel.tsx b/ux/src/components/core/Labelisator/ToolLabel.tsx new file mode 100644 index 00000000..7765ee45 --- /dev/null +++ b/ux/src/components/core/Labelisator/ToolLabel.tsx @@ -0,0 +1,134 @@ +import React, {FC, useRef, useState} from 'react'; +import clsx from 'clsx'; +import {v4 as uuid} from 'uuid'; +import {Point} from 'src/types/point'; +import makeStyles from '@mui/styles/makeStyles'; +import {Theme} from 'src/theme'; +import { + CANVAS_OFFSET, + currentPoint, + drawCursorLines, + drawRect, + formatRatio, + isHoveringLabels, + LABEL_MIN_HEIGHT, + LABEL_MIN_WIDTH, + pointIsOutside, + reset +} from 'src/utils/labeling'; +import useCategory from 'src/hooks/useCategory'; +import useImage from 'src/hooks/useImage'; + + +interface ToolLabelProps { + setTool: any; + autoSwitch: boolean; +} + +const useStyles = makeStyles((theme: Theme) => ({ + canvas: { + position: 'absolute', + top: -CANVAS_OFFSET, + left: -CANVAS_OFFSET, + width: `calc(100% + ${2 * CANVAS_OFFSET}px)`, + height: `calc(100% + ${2 * CANVAS_OFFSET}px)`, + zIndex: 1000 + } +})); + +const ToolLabel: FC = ({setTool, autoSwitch}) => { + const classes = useStyles(); + + const canvasRef = useRef(null); + + const {labels, saveLabels, storePosition} = useImage(); + const {currentCategory} = useCategory(); + + const [storedPoint, setStoredPoint] = useState(null); + + const handleMouseLeave = (event: React.MouseEvent) => { + reset(canvasRef.current); + }; + + const handleMouseDown = (event: React.MouseEvent) => { + let canvas = canvasRef.current; + if (!canvas) return; + + let point = currentPoint(event.nativeEvent); + if (pointIsOutside(canvasRef.current, point)) return; + if (event.nativeEvent.which === 1) { + setStoredPoint(point); + drawCursorLines(canvas, point); + } + }; + + const handleMouseMove = (event: React.MouseEvent) => { + let canvas = canvasRef.current; + if (!canvas) return; + + reset(canvas); + + let point = currentPoint(event.nativeEvent); + + if (event.nativeEvent.which === 0) { + // IDLE + drawCursorLines(canvas, point); + + if (autoSwitch && isHoveringLabels(canvas, point, labels)) { + setTool('move'); + return; + } + } + + if (event.nativeEvent.which === 1) + // START DRAW LABEL + drawRect(canvas, point, storedPoint); + }; + + const handleMouseUp = (event: React.MouseEvent) => { + if (event.nativeEvent.which === 1) { + if (!storedPoint) return; + + let point = currentPoint(event.nativeEvent); + if (pointIsOutside(canvasRef.current, point)) return; + let canvas = canvasRef.current; + if (canvas === null) return; + + if (Math.abs(storedPoint[0] - point[0]) < LABEL_MIN_WIDTH) return; + if (Math.abs(storedPoint[1] - point[1]) < LABEL_MIN_HEIGHT) return; + + reset(canvas); + drawCursorLines(canvas, point); + let newLabel = { + id: uuid(), + x: formatRatio( + Math.min(point[0] - CANVAS_OFFSET, storedPoint[0] - CANVAS_OFFSET) / + (canvas.width - 2 * CANVAS_OFFSET) + ), + y: formatRatio( + Math.min(point[1] - CANVAS_OFFSET, storedPoint[1] - CANVAS_OFFSET) / + (canvas.height - 2 * CANVAS_OFFSET) + ), + w: formatRatio((point[0] - storedPoint[0]) / (canvas.width - 2 * CANVAS_OFFSET)), + h: formatRatio((point[1] - storedPoint[1]) / (canvas.height - 2 * CANVAS_OFFSET)), + category_id: currentCategory?.id || null + }; + let newLabels = [...labels, newLabel]; + saveLabels(newLabels); + storePosition(newLabels); + } + }; + + return ( + + ); +}; + +export default ToolLabel; diff --git a/ux/src/components/core/Labelisator/ToolMove.tsx b/ux/src/components/core/Labelisator/ToolMove.tsx new file mode 100644 index 00000000..0f1750e6 --- /dev/null +++ b/ux/src/components/core/Labelisator/ToolMove.tsx @@ -0,0 +1,371 @@ +import React, {FC, useEffect, useRef, useState} from 'react'; +import {Box, Button, Hidden, Typography} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import {TouchApp as TouchIcon} from '@mui/icons-material'; +import ContextMenu from './ContextMenu'; +import useDataset from 'src/hooks/useDataset'; +import {Theme} from 'src/theme'; +import {Direction} from 'src/types/direction'; +import {Label} from 'src/types/label'; +import {Point} from 'src/types/point'; +import { + CANVAS_OFFSET, + currentLabelsHoverIds, + currentLabelsResized, + currentLabelsTranslated, + currentPoint, + currentSmallestLabelHoverIds, + distance, + drawLabels, + drawRect, + formatRatio, + LABEL_MIN_HEIGHT, + LABEL_MIN_WIDTH, + pointIsOutside, + renderCursor, + reset +} from 'src/utils/labeling'; +import useImage from 'src/hooks/useImage'; +import useCategory from 'src/hooks/useCategory'; +import {v4 as uuid} from 'uuid'; +import Cookies from 'js-cookie'; + +interface ToolMoveProps { + setTool: any; + autoSwitch: boolean; +} + +const useStyles = makeStyles((theme: Theme) => ({ + canvas: { + position: 'absolute', + top: -CANVAS_OFFSET, + left: -CANVAS_OFFSET, + width: `calc(100% + ${2 * CANVAS_OFFSET}px)`, + height: `calc(100% + ${2 * CANVAS_OFFSET}px)`, + zIndex: 1000 + }, + overlay: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + background: 'rgba(0, 0, 0, 0.65)', + zIndex: 1025, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column', + padding: theme.spacing(2, 3), + backdropFilter: 'blur(3px)', + color: 'white' + } +})); + +const ToolMove: FC = ({setTool, autoSwitch}) => { + const classes = useStyles(); + + const {categories} = useDataset(); + const {currentCategory} = useCategory(); + + const {image, labels, saveLabels, storePosition} = useImage(); + + const canvasRef = useRef(); + + const [direction, setDirection] = useState(null); + + const [storedPoint, setStoredPoint] = useState(null); + const [storedLabels, setStoredLabels] = useState([]); + + useEffect(() => { + if (direction === 'top-left' || direction === 'bottom-right') canvasRef.current.style.cursor = 'nwse-resize'; + else if (direction === 'top-right' || direction === 'bottom-left') + canvasRef.current.style.cursor = 'nesw-resize'; + }, [direction]); + + useEffect(() => { + reset(canvasRef.current); + }, [image.id]); + + const handleMouseDown = (event: React.MouseEvent) => { + let point = currentPoint(event.nativeEvent); + + let canvas = canvasRef.current; + + if (event.nativeEvent.which === 1) { + reset(canvas); + + let labelsHoverIds = event.shiftKey + ? currentLabelsHoverIds(canvas, point, labels) + : currentSmallestLabelHoverIds(canvas, point, labels); + if (labelsHoverIds.length > 0) { + setStoredPoint(point); + renderCursor(canvas, point, labels, (resizeLabel, direction) => { + setDirection(direction); + if (resizeLabel === null && direction === null) { + saveLabels(labels.filter(label => !labelsHoverIds.includes(label.id))); + setStoredLabels(labels.filter(label => labelsHoverIds.includes(label.id))); + drawLabels( + canvas, + labels.filter(label => labelsHoverIds.includes(label.id)), + categories, + CANVAS_OFFSET, + 5, + true, + true + ); + } else { + saveLabels(labels.filter(label => label.id !== resizeLabel.id)); + setStoredLabels(labels.filter(label => label.id === resizeLabel.id)); + drawLabels( + canvas, + labels.filter(label => label.id === resizeLabel.id), + categories, + CANVAS_OFFSET, + 5, + true, + true + ); + } + }); + } + } + }; + + const handleMouseMove = (event: React.MouseEvent) => { + let canvas = canvasRef.current; + let point = currentPoint(event.nativeEvent); + + if (event.nativeEvent.which === 0) { + reset(canvas); + // IDLE + let labelsHoverIds = event.shiftKey + ? currentLabelsHoverIds(canvas, point, labels) + : currentSmallestLabelHoverIds(canvas, point, labels); + + if (autoSwitch && labelsHoverIds.length === 0) { + setTool('label'); + return; + } + if (labels === null) return; + drawLabels( + canvas, + labels.filter(label => labelsHoverIds.includes(label.id)), + categories, + CANVAS_OFFSET, + 0, + true, + true + ); + renderCursor(canvas, point, labels, (label, direction) => setDirection(direction)); + if (direction === null) + if (labelsHoverIds.length === 0) canvas.style.cursor = 'initial'; + else canvas.style.cursor = 'move'; + } + + if (event.nativeEvent.which === 1) { + reset(canvas); + + // START MOVE + if (storedLabels.length === 0) return; + if (direction === null) { + let labelsTranslated = currentLabelsTranslated(canvas, storedLabels, point, storedPoint); + drawLabels(canvas, labelsTranslated, categories, CANVAS_OFFSET, 5, true, true); + } else { + let labelsResized = currentLabelsResized(canvas, storedLabels, point, storedPoint, direction); + drawLabels(canvas, labelsResized, categories, CANVAS_OFFSET, 5, true, true); + } + } + }; + + const handleMouseUp = (event: React.MouseEvent) => { + let canvas = canvasRef.current; + let point = currentPoint(event.nativeEvent); + + if (event.nativeEvent.which === 1) { + // LEFT CLICK + if (!storedPoint || !storedLabels) return; + if (storedLabels.length === 0) return; + + if (direction === null) { + let labelsTranslated = currentLabelsTranslated(canvas, storedLabels, point, storedPoint); + saveLabels([...labels, ...labelsTranslated]); + storePosition([...labels, ...labelsTranslated]); + } else { + let labelsResized = currentLabelsResized(canvas, storedLabels, point, storedPoint, direction, true); + saveLabels([...labels, ...labelsResized]); + storePosition([...labels, ...labelsResized]); + } + + setStoredPoint(null); + setStoredLabels([]); + } + }; + + const [contextMenuPoint, setContextMenuPoint] = useState([null, null]); + const handleContextMenu = (event: React.MouseEvent) => { + event.preventDefault(); + setContextMenuPoint([event.clientX, event.clientY]); + let point = currentPoint(event.nativeEvent); + setStoredPoint(point); + let canvas = canvasRef.current; + let labelsHoverIds = event.shiftKey + ? currentLabelsHoverIds(canvas, point, labels) + : currentSmallestLabelHoverIds(canvas, point, labels); + setStoredLabels(labels.filter(label => labelsHoverIds.includes(label.id))); + }; + + const handleClose = () => { + setContextMenuPoint([null, null]); + setStoredLabels([]); + reset(canvasRef.current); + }; + + const [storedPointA, setStoredPointA] = useState(null); + const [storedPointB, setStoredPointB] = useState(null); + + const handleTouch = (event: React.UIEvent) => { + const touchEvent = event.nativeEvent as TouchEvent; + const touches = touchEvent.changedTouches; + + const canvas = canvasRef.current; + if (canvas === null) return; + + if (touches.length === 1) { + if (storedLabels.length === 0) return; + + const offsetX = canvas.getBoundingClientRect().left; + const offsetY = canvas.getBoundingClientRect().top; + + const point = [touches[0].pageX - offsetX, touches[0].pageY - offsetY]; + setLastPoint(point); + + if (distance(lastPoint, storedPoint) > 100) setContextMenuPoint([null, null]); + + let labelsTranslated = currentLabelsTranslated(canvas, storedLabels, point, storedPoint); + reset(canvas); + drawLabels(canvas, labelsTranslated, categories, CANVAS_OFFSET, 5, true, true); + } else if (touches.length === 2) { + const offsetX = canvas.getBoundingClientRect().left; + const offsetY = canvas.getBoundingClientRect().top; + + const pointA = [touches[0].pageX - offsetX, touches[0].pageY - offsetY]; + const pointB = [touches[1].pageX - offsetX, touches[1].pageY - offsetY]; + setStoredPointA(pointA); + setStoredPointB(pointB); + + reset(canvas); + drawRect(canvas, pointB, pointA); + } + }; + + const handleTouchEnd = (event: React.UIEvent) => { + const canvas = canvasRef.current; + if (canvas === null) return; + reset(canvas); + + if (storedLabels.length > 0) { + if (!storedPoint || !storedLabels) return; + if (storedLabels.length === 0) return; + + if (distance(lastPoint, storedPoint) > 100) handleClose(); + + setLastPoint(null); + + let labelsTranslated = currentLabelsTranslated(canvas, storedLabels, lastPoint, storedPoint); + saveLabels([ + ...labels.filter(label => !labelsTranslated.map(label => label.id).includes(label.id)), + ...labelsTranslated + ]); + storePosition([ + ...labels.filter(label => !labelsTranslated.map(label => label.id).includes(label.id)), + ...labelsTranslated + ]); + } else { + if (!storedPointA) return; + if (!storedPointB) return; + + if (pointIsOutside(canvas, storedPointA)) return; + if (pointIsOutside(canvas, storedPointB)) return; + if (Math.abs(storedPointB[0] - storedPointA[0]) < LABEL_MIN_WIDTH) return; + if (Math.abs(storedPointB[1] - storedPointA[1]) < LABEL_MIN_HEIGHT) return; + + let newLabel = { + id: uuid(), + x: formatRatio( + Math.min(storedPointA[0] - CANVAS_OFFSET, storedPointB[0] - CANVAS_OFFSET) / + (canvas.width - 2 * CANVAS_OFFSET) + ), + y: formatRatio( + Math.min(storedPointA[1] - CANVAS_OFFSET, storedPointB[1] - CANVAS_OFFSET) / + (canvas.height - 2 * CANVAS_OFFSET) + ), + w: formatRatio((storedPointA[0] - storedPointB[0]) / (canvas.width - 2 * CANVAS_OFFSET)), + h: formatRatio((storedPointA[1] - storedPointB[1]) / (canvas.height - 2 * CANVAS_OFFSET)), + category_id: currentCategory?.id || null + }; + let newLabels = [...labels, newLabel]; + saveLabels(newLabels); + storePosition(newLabels); + setStoredPointA(null); + setStoredPointB(null); + } + }; + + const [lastPoint, setLastPoint] = useState(null); + + const [openOverlay, setOpenOverlay] = useState(!Boolean(Cookies.get('labelisatorTouchOverlay'))); + + const handleCloseOverlay = () => { + Cookies.set('labelisatorTouchOverlay', 'true'); + setOpenOverlay(false); + }; + + return ( + <> + + + {openOverlay && ( + +
+ + + To start drawing labels, touch with two fingers. + + + To change a label, long-tap and drag it. + + + + +
+
+ )} + + ); +}; + +export default ToolMove; diff --git a/ux/src/components/core/Labelisator/index.tsx b/ux/src/components/core/Labelisator/index.tsx new file mode 100644 index 00000000..0852afcb --- /dev/null +++ b/ux/src/components/core/Labelisator/index.tsx @@ -0,0 +1,537 @@ +import React, {FC, forwardRef, useCallback, useEffect, useState} from 'react'; +import clsx from 'clsx'; +import {Maximize as LabelIcon, Move as MoveIcon} from 'react-feather'; +import { + AppBar, + Badge, + Box, + Button, + capitalize, + Chip, + CircularProgress, + Container, + Dialog, + Divider, + FormControlLabel, + Grid, + Hidden, + IconButton, + Slide, + Slider, + Switch, + ToggleButton, + ToggleButtonGroup, + Toolbar, + Tooltip, + Typography, + useMediaQuery +} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import {TransitionProps} from '@mui/material/transitions'; +import { + BrandingWatermarkOutlined as LabelisatorIcon, + Close as CloseIcon, + ImageOutlined as ImageIcon, + Restore as RestoreIcon +} from '@mui/icons-material'; +import DTCategories from 'src/components/core/Labelisator/Categories'; +import DTImage from 'src/components/core/Images/Image'; +import Scrollbar from 'src/components/utils/Scrollbar'; +import KeyboardListener from './KeyboardListener'; +import KeyboardShortcuts from './KeyboardShortcuts'; +import NextUnlabeledImageAction from './NextUnlabeledImageAction'; +import ToolLabel from './ToolLabel'; +import ToolMove from './ToolMove'; +import {Theme} from 'src/theme'; +import useDataset from 'src/hooks/useDataset'; +import useCategory from 'src/hooks/useCategory'; +import {Image} from 'src/types/image'; +import api from 'src/utils/api'; +import {ImagesConsumer, ImagesProvider} from 'src/store/ImagesContext'; +import {ImageConsumer, ImageProvider} from 'src/store/ImageContext'; +import goToHash from 'src/utils/goToHash'; +import {CANVAS_OFFSET} from 'src/utils/labeling'; + +interface DTLabelisatorProps {} + +const useStyles = makeStyles((theme: Theme) => ({ + root: {}, + paper: { + overflow: 'hidden', + background: theme.palette.background.default + }, + container: { + touchAction: 'pan-y' + }, + labelisator: { + position: 'relative', + margin: `${CANVAS_OFFSET}px 0px`, + [theme.breakpoints.down('sm')]: { + marginLeft: -10, + marginRight: -10, + marginBottom: -10 + } + }, + image: { + opacity: 0.75, + transition: 'all 0.15s ease-in-out' + }, + selected: { + opacity: 1, + outline: `solid 3px ${theme.palette.primary.main}` + }, + content: { + padding: theme.spacing(2, 0) + }, + header: { + position: 'relative', + background: theme.palette.primary.main + }, + toolbar: { + alignItems: 'center', + color: theme.palette.getContrastText(theme.palette.text.primary) + }, + loader: { + display: 'block', + width: '20px !important', + height: '20px !important' + } +})); + +const Transition = forwardRef(function Transition( + props: TransitionProps & {children?: React.ReactElement}, + ref: React.Ref +) { + return ; +}); + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const hasUUIDHashes = () => { + if (window.location.hash.length === 0) return false; + + for (const hash of window.location.hash.split('#')) { + if (UUID_REGEX.test(hash)) return true; + } + return false; +}; + +const DTLabelisator: FC = () => { + const classes = useStyles(); + + const isDesktop = useMediaQuery((theme: Theme) => theme.breakpoints.up('sm')); + + const {dataset} = useDataset(); + const {currentCategory, saveCurrentCategory} = useCategory(); + + const [tool, setTool] = useState<'label' | 'move'>('label'); + const handleToolChange = (event: React.MouseEvent, newTool: 'label' | 'move' | null) => { + if (newTool !== null) setTool(newTool); + }; + + const [autoSwitch, setAutoSwitch] = useState(isDesktop); + + useEffect(() => { + setAutoSwitch(isDesktop); + setTool('move'); + }, [isDesktop, setAutoSwitch]); + + const [imageIds, setImageIds] = useState([]); + + const [open, setOpen] = useState(hasUUIDHashes()); + + const handleClose = () => { + setOpen(false); + saveCurrentCategory(null); + window.location.replace(`#`); + }; + + const fetchImageIds = useCallback(async () => { + if (imageIds.length > 0) return; + + try { + const response = await api.get<{image_ids: string[]}>(`/datasets/${dataset.id}/images/ids`); + setImageIds(response.data.image_ids); + } catch (err) { + console.error(err); + } + }, [dataset.id, imageIds]); + + useEffect(() => { + if (open) fetchImageIds(); + }, [fetchImageIds, open]); + + const image_id = window.location.hash.split('#')[1]; + + const [image, setImage] = useState(null); + const [imageAugmented, setImageAugmented] = useState(null); + + const fetchImage = useCallback(async () => { + if (!image_id) return; + + setImage(null); + setImageAugmented(null); + + try { + const response = await api.get<{image: Image}>(`/datasets/${dataset.id}/images/${image_id}`); + setImage(response.data.image); + } catch (err) { + console.error(err); + } + }, [dataset.id, image_id]); + + const handleSliderChange = (event, value) => { + try { + setImage(null); + setImageAugmented(null); + goToHash(imageIds[value]); + } catch (err) { + console.error(err); + } + }; + + useEffect(() => { + fetchImage(); + }, [fetchImage]); + + useEffect(() => { + const onHashChange = () => setOpen(hasUUIDHashes()); + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + + // eslint-disable-next-line + }, []); + + const [index, setIndex] = useState(0); + useEffect(() => { + setIndex(imageIds.indexOf(image_id)); + }, [imageIds, image_id]); + + return ( + + + + + + + + + + Labelisator + + + + + + + + + + + + + + + + + + + + + + Image {imageIds.indexOf(image_id) + 1} / {imageIds.length} + + + + + event.stopPropagation()} + onChangeCommitted={handleSliderChange} + valueLabelFormat={x => x + 1} + value={index} + onChange={(event, value) => setIndex(value as number)} + disabled={imageIds.length === 0} + /> + + + + + + + + + + + + Draw + A + + } + > + + + + + + Move + Z + + } + > + + + + + + + + + setAutoSwitch(!autoSwitch)} + /> + } + label={Auto switch} + /> + + +
+ + + {currentCategory && ( + + {capitalize(currentCategory.name)} + + } + title={`${capitalize(currentCategory.name)} | ${capitalize( + currentCategory.supercategory + )}`} + size="medium" + variant="outlined" + /> + )} + +
+ + + {value => ( + + + + Undo + CTRL+Z + + } + > + 1 + ? value.positions.length - 1 + : 0 + } + max={99} + > + + + + + + )} + + + + Save + S + + } + > + + { + + {value => ( + + )} + + } + + + + + {image || imageAugmented ? ( +
+
+ +
+
+ +
+ + + + +
+ ) : ( + + )} + + + + + + Original + + + {image && ( + + {imageAugmented === null ? ( + setImageAugmented(null)} + skeleton + /> + ) : ( + + setImageAugmented(null)} + skeleton + /> + + )} + + )} + + + + + Augmented + + + {image && ( + + + {value => ( + <> + {value.images instanceof Array && value.images.length > 0 && ( + + {value.images.map(current => ( + + {imageAugmented !== null && + imageAugmented.id === current.id ? ( + + setImageAugmented(current) + } + skeleton + /> + ) : ( + + + setImageAugmented(current) + } + skeleton + /> + + )} + + ))} + + )} + {value.images instanceof Array && value.images.length === 0 && ( + + No augmented images found. + + )} + {value.images === null && ( + + )} + + )} + + + )} + + + + + + + + + + +
+ ); +}; + +export default DTLabelisator; diff --git a/ux/src/components/core/Pipeline/Operation.tsx b/ux/src/components/core/Pipeline/Operation.tsx new file mode 100755 index 00000000..ed00cd34 --- /dev/null +++ b/ux/src/components/core/Pipeline/Operation.tsx @@ -0,0 +1,160 @@ +import React, {FC, forwardRef, useState} from 'react'; +import clsx from 'clsx'; +import {useSnackbar} from 'notistack'; +import { + Box, + capitalize, + Card, + CardContent, + Dialog, + Fade, + IconButton, + Tooltip, + Typography, + useMediaQuery +} from '@mui/material'; +import {DeleteOutline as DeleteIcon, Settings as SettingsIcon} from '@mui/icons-material'; +import makeStyles from '@mui/styles/makeStyles'; +import {Theme} from 'src/theme'; +import {RootState, useDispatch, useSelector} from 'src/store'; +import {Operation as OperationType} from 'src/types/pipeline'; +import OperationEdit from './OperationEdit'; +import {OPERATIONS_ICONS} from 'src/config'; +import ProbabilitySlider from './OperationEdit/ProbabilitySlider'; +import {deleteOperation} from 'src/slices/pipeline'; + +interface OperationProps { + className?: string; + operationType: string; + dragging: boolean; + readOnly?: boolean; + setDragDisabled?: (update: boolean | ((dataset: boolean) => boolean)) => void; + index?: number; + style?: {}; +} + +interface PopulatedOperation extends OperationType {} + +const operationSelector = (state: RootState, operationType: string): PopulatedOperation => { + const {operations} = state.pipeline; + return operations.byType[operationType]; +}; + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + outline: 'none', + padding: theme.spacing(1, 0) + }, + operation: { + '&:hover': { + backgroundColor: theme.palette.background.default + } + }, + content: { + padding: `${theme.spacing(1.5)} !important` + }, + dragging: { + backgroundColor: theme.palette.background.default + }, + tooltip: { + width: '100%', + maxWidth: 530, + padding: 0 + } +})); + +const Operation: FC = forwardRef( + ({operationType, className, readOnly, dragging, setDragDisabled, index, style, ...rest}, ref) => { + const classes = useStyles(); + const isLargeScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('xl')); + const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('md')); + + const operation = useSelector(state => operationSelector(state, operationType)); + const dispatch = useDispatch(); + const {enqueueSnackbar} = useSnackbar(); + + const [isOpened, setOpened] = useState(false); + + const handleToggle = () => { + setOpened(!isOpened); + }; + + const handleClose = () => { + setOpened(false); + }; + + const handleDelete = async (): Promise => { + try { + await dispatch(deleteOperation(operation.type)); + handleClose(); + } catch (err) { + console.error(err); + enqueueSnackbar('Something went wrong', { + variant: 'error' + }); + } + }; + + return ( +
+ } + placement={isLargeScreen ? 'right' : 'left'} + > + + + + {OPERATIONS_ICONS[operation.type]} + + {capitalize(operation.type).replaceAll('_', ' ')} + + + {readOnly === false && ( + <> + + + + + + + + + + + + )} + + + + + + + + + + +
+ ); + } +); + +export default Operation; diff --git a/ux/src/components/core/Pipeline/OperationAdd.tsx b/ux/src/components/core/Pipeline/OperationAdd.tsx new file mode 100755 index 00000000..6b518439 --- /dev/null +++ b/ux/src/components/core/Pipeline/OperationAdd.tsx @@ -0,0 +1,108 @@ +import React, {ChangeEvent, FC, useEffect, useState} from 'react'; +import clsx from 'clsx'; +import {Box, Button, capitalize, FormControl, MenuItem, Select} from '@mui/material'; +import {Add as AddIcon} from '@mui/icons-material'; +import makeStyles from '@mui/styles/makeStyles'; +import {Theme} from 'src/theme'; +import {useDispatch, useSelector} from 'src/store'; +import {createOperation} from 'src/slices/pipeline'; +import {OperationType} from 'src/types/pipeline'; +import {MAX_OPERATIONS_PER_PIPELINE, OPERATIONS_ICONS, OPERATIONS_TYPES} from 'src/config'; + +interface OperationAddProps { + className?: string; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: {}, + select: { + paddingLeft: theme.spacing(1) + } +})); + +const OperationAdd: FC = ({className, ...rest}) => { + const classes = useStyles(); + const dispatch = useDispatch(); + const [isExpanded, setExpanded] = useState(false); + const [operationType, setOperationType] = useState(OPERATIONS_TYPES[0]); + + const handleChange = (event: ChangeEvent): void => { + setOperationType(event.target.value as OperationType); + }; + + const handleAddInit = (): void => { + setExpanded(true); + }; + + const handleAddCancel = (): void => { + setExpanded(false); + }; + + const handleAddConfirm = async (): Promise => { + await dispatch(createOperation(operationType)); + setExpanded(false); + }; + + const pipeline = useSelector(state => state.pipeline); + + useEffect(() => { + if (pipeline.operations.allTypes.includes(operationType)) + setOperationType(OPERATIONS_TYPES.find(type => !pipeline.operations.allTypes.includes(type))) + + // eslint-disable-next-line + }, [pipeline.operations]); + + return ( +
+ {isExpanded ? ( + <> + + + + + + + + + + ) : ( + + + + )} +
+ ); +}; + +export default OperationAdd; diff --git a/ux/src/components/core/Pipeline/OperationEdit/OperationProperties.tsx b/ux/src/components/core/Pipeline/OperationEdit/OperationProperties.tsx new file mode 100644 index 00000000..437382e1 --- /dev/null +++ b/ux/src/components/core/Pipeline/OperationEdit/OperationProperties.tsx @@ -0,0 +1,367 @@ +import React, {FC, useCallback, useEffect} from 'react'; +import {Formik} from 'formik'; +import * as Yup from 'yup'; +import {Alert, Box, capitalize, Grid, InputLabel, MenuItem, Select, Slider, TextField, Typography} from '@mui/material'; +import {useDispatch} from 'src/store'; +import {Operation} from 'src/types/pipeline'; +import {updateOperation} from 'src/slices/pipeline'; +import {OPERATIONS_INITIAL_PROPERTIES, OPERATIONS_SHAPES} from 'src/config'; + +interface OperationPropertiesProps { + operation: Operation; + readOnly: boolean; +} + +const UpdateOnChange = ({operation, values}) => { + const dispatch = useDispatch(); + + const handlePropertiesChange = useCallback(async (properties): Promise => { + try { + await dispatch(updateOperation(operation.type, {properties})); + } catch (err) { + console.error(err); + } + }, [dispatch, operation.type]); + + useEffect(() => { + handlePropertiesChange(values); + }, [values, handlePropertiesChange]); + + return null; +}; + +const OperationProperties: FC = ({operation, readOnly = false}) => { + if (Object.keys(operation.properties).length === 0) return null; + + return ( + {}} + > + {({errors, handleBlur, handleChange, handleSubmit, setFieldValue, touched, values}) => ( +
+ + + Properties + + + + + {operation.type === 'rotate' && ( + <> + + + + + + + + )} + {operation.type === 'skew' && ( + <> + + + Magnitude + + setFieldValue('magnitude', value)} + value={values.magnitude} + min={0.05} + max={1} + step={0.05} + marks + valueLabelDisplay="auto" + disabled={readOnly} + /> + + + )} + {operation.type === 'crop_random' && ( + <> + + + Percentage_area + + setFieldValue('percentage_area', value)} + value={values.percentage_area} + min={0.05} + max={1} + step={0.05} + marks + valueLabelDisplay="auto" + disabled={readOnly} + /> + + + )} + {operation.type === 'shear' && ( + <> + + + + + + + + )} + {operation.type === 'random_distortion' && ( + <> + + + + + + + + + Magnitude + + setFieldValue('magnitude', value)} + value={values.magnitude} + min={1} + max={20} + step={1} + marks + valueLabelDisplay="auto" + disabled={readOnly} + /> + + + )} + {operation.type === 'gaussian_distortion' && ( + <> + + + + + + + + + Magnitude + + setFieldValue('magnitude', value)} + value={values.magnitude} + min={1} + max={20} + step={1} + marks + valueLabelDisplay="auto" + disabled={readOnly} + /> + + + Corner + + + + Method + + + + )} + + {['random_brightness', 'random_color', 'random_contrast'].includes(operation.type) && ( + <> + + + Min factor + + setFieldValue('min_factor', value)} + value={values.min_factor} + min={1} + max={4} + step={0.05} + marks + valueLabelDisplay="auto" + disabled={readOnly} + /> + + + + Max factor + + setFieldValue('max_factor', value)} + value={values.max_factor} + min={1} + max={4} + step={0.05} + marks + valueLabelDisplay="auto" + disabled={readOnly} + /> + + + )} + + + {Object.keys(errors).length > 0 && ( + + {Object.values(errors).map(error => ( + <> + {error} +
+ + ))} +
+ )} + + + + )} +
+ ); +}; + +export default OperationProperties; diff --git a/ux/src/components/core/Pipeline/OperationEdit/ProbabilitySlider.tsx b/ux/src/components/core/Pipeline/OperationEdit/ProbabilitySlider.tsx new file mode 100644 index 00000000..8b05baab --- /dev/null +++ b/ux/src/components/core/Pipeline/OperationEdit/ProbabilitySlider.tsx @@ -0,0 +1,48 @@ +import React, {FC} from 'react'; +import {Slider, Typography} from '@mui/material'; +import {useDispatch} from 'src/store'; +import {Operation} from 'src/types/pipeline'; +import {updateOperation} from 'src/slices/pipeline'; + +interface ProbabilitySliderProps { + operation: Operation; + setDragDisabled?: (update: boolean) => void; + disabled?: boolean; +} + +const ProbabilitySlider: FC = ({operation, setDragDisabled, ...rest}) => { + const dispatch = useDispatch(); + + const handleProbabilityChange = async (event, value): Promise => { + try { + await dispatch(updateOperation(operation.type, {probability: value})); + } catch (err) { + console.error(err); + } + }; + + return ( + <> + + Probability + + + event.stopPropagation()} + onChangeCommitted={handleProbabilityChange} + onMouseEnter={() => setDragDisabled && setDragDisabled(true)} + onMouseLeave={() => setDragDisabled && setDragDisabled(false)} + sx={{py: 1}} + {...rest} + /> + + ); +}; + +export default ProbabilitySlider; diff --git a/ux/src/components/core/Pipeline/OperationEdit/index.tsx b/ux/src/components/core/Pipeline/OperationEdit/index.tsx new file mode 100755 index 00000000..72cb71cb --- /dev/null +++ b/ux/src/components/core/Pipeline/OperationEdit/index.tsx @@ -0,0 +1,81 @@ +import React, {FC, forwardRef} from 'react'; +import {Alert, AlertTitle, Box, capitalize, Card, CardContent, IconButton, SvgIcon, Typography} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import {Close} from '@mui/icons-material'; +import {Theme} from 'src/theme'; +import {Operation} from 'src/types/pipeline'; +import OperationProperties from './OperationProperties'; +import {OPERATIONS_DESCRIPTION, OPERATIONS_ICONS} from 'src/config'; + +interface OperationEditProps { + className?: string; + operation: Operation; + handleClose: () => void; + readOnly: boolean; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + width: '100%', + background: 'rgb(0 0 0 / 85%)', + boxShadow: '0 0px 5px rgba(255, 255, 255, 0.2)', + backdropFilter: 'blur(7px)', + border: '0 0px 5px rgb(255 255 255 / 20%)' + }, + deleteAction: { + color: theme.palette.common.white, + backgroundColor: theme.palette.error.main, + '&:hover': { + backgroundColor: theme.palette.error.dark + } + } +})); + +const OperationEdit: FC = forwardRef( + ({operation, className, handleClose, readOnly = false, ...rest}, ref) => { + const classes = useStyles(); + + if (!operation) return null; + + return ( + + + + {OPERATIONS_ICONS[operation.type]} + {capitalize(operation.type).replaceAll('_', ' ')} + + + + + + + + + + + + + + + Details + + {OPERATIONS_DESCRIPTION[operation.type]} + + + + {`${operation.type}.gif`} + + + + + + + ); + } +); + +export default OperationEdit; diff --git a/ux/src/components/core/Pipeline/OperationsPipeline.tsx b/ux/src/components/core/Pipeline/OperationsPipeline.tsx new file mode 100755 index 00000000..d491cb14 --- /dev/null +++ b/ux/src/components/core/Pipeline/OperationsPipeline.tsx @@ -0,0 +1,94 @@ +import React, {FC, useState} from 'react'; +import clsx from 'clsx'; +import {Draggable, Droppable} from 'react-beautiful-dnd'; +import {Box, Divider} from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import {useSelector} from 'src/store'; +import {Theme} from 'src/theme'; +import Operation from './Operation'; +import OperationAdd from './OperationAdd'; + +interface ListProps { + className?: string; + readOnly?: boolean; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + width: '100%' + }, + inner: { + display: 'flex', + flexDirection: 'column', + maxHeight: '100%', + overflowY: 'hidden', + overflowX: 'hidden' + }, + droppableArea: { + minHeight: 80, + flexGrow: 1 + } +})); + +const OperationsPipeline: FC = ({className, readOnly, ...rest}) => { + const classes = useStyles(); + + const [dragDisabled, setDragDisabled] = useState(false); + + const pipeline = useSelector(state => state.pipeline); + + return ( +
+
+ + {provided => ( +
+ {pipeline.operations.allTypes.map((operationType, index) => ( + + {(provided, snapshot) => { + if (snapshot.isDragging) { + // @ts-ignore + provided.draggableProps.style.left = undefined; // @ts-ignore + provided.draggableProps.style.top = undefined; + } + return ( + + ); + }} + + ))} + {provided.placeholder} +
+ )} +
+ {!readOnly && ( + <> + + + + + + )} +
+
+ ); +}; + +export default OperationsPipeline; diff --git a/ux/src/components/core/Pipeline/index.tsx b/ux/src/components/core/Pipeline/index.tsx new file mode 100755 index 00000000..8a9b7578 --- /dev/null +++ b/ux/src/components/core/Pipeline/index.tsx @@ -0,0 +1,69 @@ +import React, {useEffect, FC} from 'react'; +import {DropResult} from 'react-beautiful-dnd'; +import {DragDropContext} from 'react-beautiful-dnd'; +import clsx from 'clsx'; +import makeStyles from '@mui/styles/makeStyles'; +import {Theme} from 'src/theme'; +import {useDispatch} from 'src/store'; +import {moveOperation, setDefaultPipeline} from 'src/slices/pipeline'; +import OperationsPipeline from './OperationsPipeline'; + +interface PipelineProps { + className?: string; + readOnly?: boolean; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + width: '100%', + maxWidth: 380, + flexGrow: 1, + flexShrink: 1, + display: 'flex', + overflow: 'hidden' + } +})); + +const Pipeline: FC = ({className, readOnly = false}) => { + const classes = useStyles(); + const dispatch = useDispatch(); + + const handleDragEnd = async ({source, destination, draggableId}: DropResult): Promise => { + try { + // Dropped outside the list + if (!destination) return; + + // Operation has not been moved + if (source.droppableId === destination.droppableId && source.index === destination.index) return; + + await dispatch(moveOperation(draggableId, destination.index)); + } catch (err) { + console.error(err); + try { + // Dropped outside the list + if (!destination) return; + + // Operation has not been moved + if (source.droppableId === destination.droppableId && source.index === destination.index) return; + + await dispatch(moveOperation(draggableId, destination.index)); + } catch (err) { + console.error(err); + } + } + }; + + useEffect(() => { + if (!readOnly) dispatch(setDefaultPipeline()); + }, [dispatch, readOnly]); + + return ( + +
+ +
+
+ ); +}; + +export default Pipeline; diff --git a/ux/src/components/core/PipelineSample.tsx b/ux/src/components/core/PipelineSample.tsx new file mode 100755 index 00000000..2f5ca076 --- /dev/null +++ b/ux/src/components/core/PipelineSample.tsx @@ -0,0 +1,155 @@ +import React, {FC, useCallback, useState} from 'react'; +import {AxiosResponse} from 'axios'; +import {useSnackbar} from 'notistack'; +import {Formik} from 'formik'; +import clsx from 'clsx'; +import {Box, Button, CircularProgress, FormHelperText, Skeleton} from '@mui/material'; +import {Refresh as RefreshIcon} from '@mui/icons-material'; +import Masonry from '@mui/lab/Masonry'; +import MasonryItem from '@mui/lab/MasonryItem'; +import makeStyles from '@mui/styles/makeStyles'; +import {Theme} from 'src/theme'; +import {useSelector} from 'src/store'; +import {Operation} from 'src/types/pipeline'; +import ImageBase64 from 'src/components/utils/ImageBase64'; +import {Label} from 'src/types/label'; +import wait from 'src/utils/wait'; +import useDataset from 'src/hooks/useDataset'; +import useImages from 'src/hooks/useImages'; +import SubmitFormikOnRender from 'src/components/utils/SubmitFormikOnRender'; + +interface PipelineSampleProps { + handler: (operations: Operation[]) => Promise; + className?: string; +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + padding: theme.spacing(1, 0, 0) + }, + loader: { + width: '20px !important', + height: '20px !important', + } +})); + +const PipelineSample: FC = ({handler, className}) => { + const classes = useStyles(); + const {enqueueSnackbar} = useSnackbar(); + + const {dataset} = useDataset(); + const {images} = useImages(); + + const pipeline = useSelector(state => state.pipeline); + + const [imagesBase64, setImagesBase64] = useState([]); + const [imagesLabels, setImagesLabels] = useState([]); + + const doSample = useCallback(async () => { + setImagesBase64([]); + setImagesLabels([]); + + if (dataset.id && pipeline.isLoaded) { + const operations: Operation[] = pipeline.operations.allTypes.map(type => pipeline.operations.byType[type]); + + try { + await wait(10); + + const response = await handler(operations); + + setImagesBase64(response.data.images); + setImagesLabels(response.data.images_labels); + } catch (error) { + console.error(error); + enqueueSnackbar(error.message || 'Something went wrong', {variant: 'error'}); + } + } + + // eslint-disable-next-line + }, [pipeline.isLoaded, dataset.id, pipeline.operations]); + + const image = images[0]; + + return ( +
+ { + try { + await doSample(); + + setStatus({success: true}); + setSubmitting(false); + } catch (err) { + console.error(err); + setStatus({success: false}); + setErrors({submit: err.message}); + setSubmitting(false); + } + }} + > + {({errors, handleSubmit, isSubmitting}) => ( +
+ {isSubmitting && ( + image.height ? 2 : 3}} spacing={1}> + {Array.from(Array(image.width > image.height ? 4 : 3), () => null).map((_, index) => ( + + + + + + ))} + + )} + + image.height ? 2 : 3}} spacing={1}> + {imagesBase64.map((imageBase64, index) => ( + + + + ))} + + + + + + + {errors.submit && ( + + {errors.submit} + + )} + + + + )} +
+
+ ); +}; + +export default PipelineSample; diff --git a/ux/src/components/core/Task/TaskProgress.tsx b/ux/src/components/core/Task/TaskProgress.tsx new file mode 100755 index 00000000..68bc8cfb --- /dev/null +++ b/ux/src/components/core/Task/TaskProgress.tsx @@ -0,0 +1,32 @@ +import React, {FC} from 'react'; +import {Task} from 'src/types/task'; +import {Box, LinearProgress, Typography} from '@mui/material'; + +interface TaskProgressProps { + task: Task; +} + +const TaskProgress: FC = ({task}) => { + + if (!task) + return null; + + if (task.status === 'pending' || task.status === 'active') + return ( + + + = 1 ? 'indeterminate' : 'determinate'} + value={100 * task.progress} + /> + + + {`${(100 * task.progress).toFixed(1)}%`} + + + ); + + return null; +}; + +export default TaskProgress; diff --git a/ux/src/components/core/Task/TaskSnackbar.tsx b/ux/src/components/core/Task/TaskSnackbar.tsx new file mode 100755 index 00000000..cf1ea7ab --- /dev/null +++ b/ux/src/components/core/Task/TaskSnackbar.tsx @@ -0,0 +1,74 @@ +import React, {FC, forwardRef} from 'react'; +import clsx from 'clsx'; +import {SnackbarContent} from 'notistack'; + +import {Box, Card, Typography} from '@mui/material'; + +import FancyLabel from 'src/components/FancyLabel'; +import {Task, TaskStatus} from 'src/types/task'; +import {useSelector} from 'src/store'; +import TaskProgress from './TaskProgress'; + +function colorReducer(status) { + switch (status) { + case 'pending': + return 'default'; + case 'success': + return 'success'; + case 'active': + return 'info'; + case 'failed': + return 'error'; + default: + throw new Error('Invalid status'); + } +} + +interface TaskStatusProps { + status: TaskStatus; +} + +const TaskStatusLabel: FC = ({status}) => { + return ( + + {status} + + ); +}; + +const TaskSnackbar = forwardRef( + ({id, dataset_id, task}, ref) => { + const {tasks} = useSelector(state => state.tasks); + const activeTask = (tasks || []).find( + task => task.dataset_id === dataset_id && ['pending', 'active'].includes(task.status) + ); + + if (!activeTask) return null; + if (activeTask.id !== task.id) return null; + + return ( + + + + + {activeTask.type} task + + + + + + + + + ); + } +); + +export default TaskSnackbar; diff --git a/ux/src/components/docs/SwaggerAPI.tsx b/ux/src/components/docs/SwaggerAPI.tsx new file mode 100755 index 00000000..8b022465 --- /dev/null +++ b/ux/src/components/docs/SwaggerAPI.tsx @@ -0,0 +1,23 @@ +import React, {FC} from 'react'; +import makeStyles from '@mui/styles/makeStyles'; +import {Theme} from 'src/theme'; +import {API_URI} from 'src/utils/api'; + + +interface SwaggerAPIlProps {} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + width: '100%', + minHeight: 'calc(100vh - 340px)', + background: 'white' + } +})); + +const SwaggerAPI: FC = () => { + const classes = useStyles(); + + return