Merge branch 'v3-ci-and-tests' into 'v3'
Add more unit tests and fix CI See merge request openflexure/openflexure-microscope-server!194
This commit is contained in:
commit
d9c8a083fd
26 changed files with 954 additions and 3026 deletions
237
.gitlab-ci.yml
237
.gitlab-ci.yml
|
|
@ -1,21 +1,37 @@
|
||||||
stages:
|
stages:
|
||||||
|
- prepare
|
||||||
- analysis
|
- analysis
|
||||||
- testing
|
- testing
|
||||||
- build
|
- build
|
||||||
- package
|
- package
|
||||||
- deploy
|
- deploy
|
||||||
|
|
||||||
|
meta-build-image:
|
||||||
|
image: docker:stable
|
||||||
|
services:
|
||||||
|
- docker:dind
|
||||||
|
stage: prepare
|
||||||
|
script:
|
||||||
|
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
||||||
|
- cd .meta-testimage-python
|
||||||
|
- docker build -t $CI_REGISTRY/openflexure/openflexure-microscope-server/testimage-python:latest .
|
||||||
|
- docker push $CI_REGISTRY/openflexure/openflexure-microscope-server/testimage-python:latest
|
||||||
|
only:
|
||||||
|
refs: # commented out for initial run
|
||||||
|
- v3
|
||||||
|
changes:
|
||||||
|
- .meta/Dockerfile
|
||||||
|
|
||||||
|
|
||||||
# Re-usable block to install (and cache) Poetry and openflexure-microscope-server
|
# Re-usable block to install (and cache) Poetry and openflexure-microscope-server
|
||||||
.python:
|
.python:
|
||||||
image: balenalib/raspberry-pi:buster
|
image: registry.gitlab.com/openflexure/openflexure-microscope-server/testimage-python
|
||||||
retry: 1
|
retry: 1
|
||||||
before_script:
|
before_script:
|
||||||
- apt-get update
|
- python --version
|
||||||
- apt-get install apt-utils curl wget python3 python3-pip python3-venv raspi-gpio libgfortran3 libatlas3-base libffi-dev vim git-lfs libtiff5 libpango-1.0-0 libavcodec58 libgdk-pixbuf2.0-0 libjasper1 libswscale5 libpangocairo-1.0-0 libilmbase23 libatk1.0-0 libgtk-3-0 libwebp6 libcairo2 libavutil56 libcairo-gobject2 libopenexr23 libavformat58 libxslt1.1
|
- python -m venv .venv # NB this does not overwrite an existing cached venv
|
||||||
- python3 -m venv .venv # NB this does not overwrite an existing cached venv
|
|
||||||
- source .venv/bin/activate
|
- source .venv/bin/activate
|
||||||
- pip install pipenv # Should be cached after first run
|
- pip install -e .[dev]
|
||||||
- pipenv install --dev --deploy # Should be cached after first run - will just check packages are present
|
|
||||||
cache:
|
cache:
|
||||||
key: "${CI_COMMIT_REF_SLUG}"
|
key: "${CI_COMMIT_REF_SLUG}"
|
||||||
paths:
|
paths:
|
||||||
|
|
@ -42,57 +58,66 @@ stages:
|
||||||
- tags
|
- tags
|
||||||
- web
|
- web
|
||||||
|
|
||||||
# Python static analysis with PyLint
|
# Python formatting and static analysis with ruff
|
||||||
pylint:
|
ruff:
|
||||||
stage: analysis
|
stage: analysis
|
||||||
extends: .python
|
extends: .python
|
||||||
script:
|
script:
|
||||||
- poe pylint
|
- ruff check
|
||||||
|
- ruff format --check
|
||||||
|
|
||||||
# Python type checking with Mypy
|
# Python type checking with Mypy
|
||||||
mypy:
|
mypy:
|
||||||
stage: analysis
|
stage: analysis
|
||||||
extends: .python
|
extends: .python
|
||||||
script:
|
script:
|
||||||
- poe mypy
|
- mypy --cobertura-xml-report mypy --no-error-summary src | tee mypy/stdout.txt
|
||||||
|
after_script:
|
||||||
|
- cat mypy/stdout.txt | mypy-gitlab-code-quality > codequality.json
|
||||||
|
allow_failure: true
|
||||||
|
|
||||||
artifacts:
|
artifacts:
|
||||||
reports:
|
reports:
|
||||||
|
codequality: codequality.json
|
||||||
coverage_report:
|
coverage_report:
|
||||||
coverage_format: cobertura
|
coverage_format: cobertura
|
||||||
path: openflexure_microscope/cobertura.xml
|
path: mypy/cobertura.xml
|
||||||
|
|
||||||
# Python style analysis with Black
|
|
||||||
black:
|
|
||||||
stage: analysis
|
|
||||||
allow_failure: true
|
|
||||||
extends: .python
|
|
||||||
script:
|
|
||||||
- poe black_check
|
|
||||||
|
|
||||||
# Python unit tests with PyTest
|
# Python unit tests with PyTest
|
||||||
pytest:
|
pytest:
|
||||||
stage: testing
|
stage: testing
|
||||||
extends: .python
|
extends: .python
|
||||||
script:
|
script:
|
||||||
- poe test
|
- >
|
||||||
|
pytest --cov --cov-report term
|
||||||
|
--cov-report xml:coverage.xml
|
||||||
|
--junitxml=report.xml
|
||||||
|
# --gitlab-code-quality-report=pytest-warnings.json # can restore when it installs ok
|
||||||
|
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
|
||||||
artifacts:
|
artifacts:
|
||||||
when: always
|
when: always
|
||||||
reports:
|
|
||||||
junit: pytest_report.xml
|
|
||||||
|
|
||||||
# Generate and validate OpenAPI description
|
|
||||||
openapi:
|
|
||||||
stage: testing
|
|
||||||
extends: .python
|
|
||||||
script:
|
|
||||||
- mkdir -p docs/build/
|
|
||||||
- ofm-generate-openapi -o docs/build/swagger.yaml --validate
|
|
||||||
artifacts:
|
|
||||||
name: "openapi"
|
|
||||||
expose_as: "OpenAPI Description"
|
|
||||||
expire_in: 1 week
|
|
||||||
paths:
|
paths:
|
||||||
- "./docs/build/swagger.yaml"
|
- report.xml
|
||||||
|
reports:
|
||||||
|
junit: report.xml
|
||||||
|
# codequality: pytest-warnings.json
|
||||||
|
coverage_report:
|
||||||
|
coverage_format: cobertura
|
||||||
|
path: coverage.xml
|
||||||
|
|
||||||
|
# Generate and validate OpenAPI description - needs updating to work with v3
|
||||||
|
# openapi:
|
||||||
|
# stage: testing
|
||||||
|
# extends: .python
|
||||||
|
# script:
|
||||||
|
# - mkdir -p docs/build/
|
||||||
|
# - ofm-generate-openapi -o docs/build/swagger.yaml --validate
|
||||||
|
# artifacts:
|
||||||
|
# name: "openapi"
|
||||||
|
# expose_as: "OpenAPI Description"
|
||||||
|
# expire_in: 1 week
|
||||||
|
# paths:
|
||||||
|
# - "./docs/build/swagger.yaml"
|
||||||
|
|
||||||
# JavaScript linting with ESLint (via Vue CLI)
|
# JavaScript linting with ESLint (via Vue CLI)
|
||||||
eslint:
|
eslint:
|
||||||
|
|
@ -115,86 +140,86 @@ build:
|
||||||
paths:
|
paths:
|
||||||
- "src/openflexure_microscope/static/"
|
- "src/openflexure_microscope/static/"
|
||||||
|
|
||||||
# Package application into distribution tarball
|
# # Package application into distribution tarball
|
||||||
package:
|
# package:
|
||||||
stage: package
|
# stage: package
|
||||||
dependencies:
|
# dependencies:
|
||||||
- build
|
# - build
|
||||||
- openapi
|
# - openapi
|
||||||
|
|
||||||
image: ubuntu:latest
|
# image: ubuntu:latest
|
||||||
|
|
||||||
script:
|
# script:
|
||||||
# Build distribution archive
|
# # Build distribution archive
|
||||||
- mkdir -p dist
|
# - mkdir -p dist
|
||||||
- tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz .
|
# - tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz .
|
||||||
- tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz ./openflexure_microscope_server/static/
|
# - tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz ./openflexure_microscope_server/static/
|
||||||
- cp docs/build/swagger.yaml dist/openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml
|
# - cp docs/build/swagger.yaml dist/openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml
|
||||||
- cd dist/
|
# - cd dist/
|
||||||
- sha256sum openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz.sha256
|
# - sha256sum openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz.sha256
|
||||||
- sha256sum openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz.sha256
|
# - sha256sum openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz.sha256
|
||||||
- sha256sum openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml > openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml.sha256
|
# - sha256sum openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml > openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml.sha256
|
||||||
|
|
||||||
artifacts:
|
# artifacts:
|
||||||
name: "dist"
|
# name: "dist"
|
||||||
expire_in: 1 week
|
# expire_in: 1 week
|
||||||
paths:
|
# paths:
|
||||||
- "./dist/"
|
# - "./dist/"
|
||||||
|
|
||||||
only:
|
# only:
|
||||||
- master
|
# - master
|
||||||
- merge_requests
|
# - merge_requests
|
||||||
- tags
|
# - tags
|
||||||
- web
|
# - web
|
||||||
|
|
||||||
# Deploy to builds.openflexure.org
|
# # Deploy to builds.openflexure.org
|
||||||
deploy:
|
# deploy:
|
||||||
stage: deploy
|
# stage: deploy
|
||||||
dependencies:
|
# dependencies:
|
||||||
- package
|
# - package
|
||||||
|
|
||||||
image: ubuntu:latest
|
# image: ubuntu:latest
|
||||||
|
|
||||||
before_script:
|
# before_script:
|
||||||
- "which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )"
|
# - "which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )"
|
||||||
- eval $(ssh-agent -s)
|
# - eval $(ssh-agent -s)
|
||||||
- ssh-add <(echo "$SSH_PRIVATE_KEY_BATH_OPENFLEXURE_BASE64" | base64 --decode)
|
# - ssh-add <(echo "$SSH_PRIVATE_KEY_BATH_OPENFLEXURE_BASE64" | base64 --decode)
|
||||||
- mkdir -p ~/.ssh
|
# - mkdir -p ~/.ssh
|
||||||
- echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config
|
# - echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config
|
||||||
|
|
||||||
script:
|
# script:
|
||||||
# Install rsync if not already installed
|
# # Install rsync if not already installed
|
||||||
- "which rsync || ( apt-get update -y && apt-get install rsync -y )"
|
# - "which rsync || ( apt-get update -y && apt-get install rsync -y )"
|
||||||
|
|
||||||
# Upload the builds folder to openflexure-microscope builds
|
# # Upload the builds folder to openflexure-microscope builds
|
||||||
- rsync -hrvz -e ssh dist/ ci-user@openflexure.bath.ac.uk:/var/www/build/openflexure-microscope-server
|
# - rsync -hrvz -e ssh dist/ ci-user@openflexure.bath.ac.uk:/var/www/build/openflexure-microscope-server
|
||||||
|
|
||||||
# Run update-latest.py on the build server
|
# # Run update-latest.py on the build server
|
||||||
- ssh -t ci-user@openflexure.bath.ac.uk "/var/www/build/update-latest.py"
|
# - ssh -t ci-user@openflexure.bath.ac.uk "/var/www/build/update-latest.py"
|
||||||
|
|
||||||
only:
|
# only:
|
||||||
- tags
|
# - tags
|
||||||
- web
|
# - web
|
||||||
|
|
||||||
zenodo:
|
# zenodo:
|
||||||
stage: deploy
|
# stage: deploy
|
||||||
image: ubuntu:20.04
|
# image: ubuntu:20.04
|
||||||
before_script:
|
# before_script:
|
||||||
- apt-get update -qq
|
# - apt-get update -qq
|
||||||
- apt-get -y -qq install git python3-pip
|
# - apt-get -y -qq install git python3-pip
|
||||||
- pip3 install -r scripts/zenodo/requirements.txt
|
# - pip3 install -r scripts/zenodo/requirements.txt
|
||||||
variables:
|
# variables:
|
||||||
ZENODO_USE_SANDBOX: "false"
|
# ZENODO_USE_SANDBOX: "false"
|
||||||
script:
|
# script:
|
||||||
- git archive "${CI_COMMIT_REF_NAME}" --format=zip --output="${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-source.zip"
|
# - git archive "${CI_COMMIT_REF_NAME}" --format=zip --output="${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-source.zip"
|
||||||
- python3 scripts/zenodo/upload_to_zenodo.py "${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-source.zip" dist/*
|
# - python3 scripts/zenodo/upload_to_zenodo.py "${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-source.zip" dist/*
|
||||||
artifacts:
|
# artifacts:
|
||||||
# this is only a small html link, let's just keep it forever
|
# # this is only a small html link, let's just keep it forever
|
||||||
# gitlab doesn't understand "expire_in: never" yet though
|
# # gitlab doesn't understand "expire_in: never" yet though
|
||||||
expire_in: 100000 years
|
# expire_in: 100000 years
|
||||||
name: zenodo-${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-link.html
|
# name: zenodo-${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-link.html
|
||||||
paths:
|
# paths:
|
||||||
- zenodo-link.html
|
# - zenodo-link.html
|
||||||
only:
|
# only:
|
||||||
- tags
|
# - tags
|
||||||
- web
|
# - web
|
||||||
|
|
|
||||||
4
.meta-testimage-python/Dockerfile
Normal file
4
.meta-testimage-python/Dockerfile
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# A docker image with system-level dependencies installed already
|
||||||
|
FROM python:3.11-bookworm
|
||||||
|
RUN apt-get -yq update
|
||||||
|
RUN apt-get -yq install python3-opencv python3-numpy python3-scipy
|
||||||
13
Pipfile
13
Pipfile
|
|
@ -1,13 +0,0 @@
|
||||||
[[source]]
|
|
||||||
url = "https://www.piwheels.org/simple"
|
|
||||||
verify_ssl = true
|
|
||||||
name = "piwheels"
|
|
||||||
|
|
||||||
[requires]
|
|
||||||
python_version = '3.7'
|
|
||||||
|
|
||||||
[packages]
|
|
||||||
openflexure-microscope-server = {editable = true, path = "."}
|
|
||||||
|
|
||||||
[dev-packages]
|
|
||||||
openflexure-microscope-server = {editable = true, path = ".", extras = ["dev"]}
|
|
||||||
2387
Pipfile.lock
generated
2387
Pipfile.lock
generated
File diff suppressed because it is too large
Load diff
70
README.md
70
README.md
|
|
@ -85,7 +85,7 @@ To set up a development version of the software (most likely using emulated came
|
||||||
### Run the server manually on a Raspberry Pi
|
### Run the server manually on a Raspberry Pi
|
||||||
```
|
```
|
||||||
cd /var/openflexure
|
cd /var/openflexure
|
||||||
sudo -u openflexure-ws PATH="/var/openflexure/application/openflexure-microscope-server/.venv/bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" /var/openflexure/application/openflexure-microscope-server/.venv/bin/uvicorn --reload --reload-dir=./application --host 0.0.0.0 --port 5000 openflexure_microscope_server.server:app
|
sudo -u openflexure-ws PATH="/var/openflexure/application/openflexure-microscope-server/.venv/bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" /var/openflexure/application/openflexure-microscope-server/.venv/bin/openflexure-microscope-serve -c /var/openflexure/settings/ofm_config.json --host 0.0.0.0 --port 5000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -107,70 +107,36 @@ sudo -u openflexure-ws PATH="/var/openflexure/application/openflexure-microscope
|
||||||
## Formatting, linting, and tests
|
## Formatting, linting, and tests
|
||||||
All of the commands below assume that you are running in the OFM virtual environment, i.e. you have run `ofm activate` on an OpenFlexure SD card, or `source .venv/bin/activate` on Linux, or `.venv/Scripts/activate`on Windows.
|
All of the commands below assume that you are running in the OFM virtual environment, i.e. you have run `ofm activate` on an OpenFlexure SD card, or `source .venv/bin/activate` on Linux, or `.venv/Scripts/activate`on Windows.
|
||||||
|
|
||||||
**Before committing** you should auto-format your code:
|
**Before committing** you should lint and auto-format your code:
|
||||||
|
|
||||||
* To auto-format the Python code run `poe format`
|
* To lint run `ruff check`
|
||||||
|
* To auto-format the Python code run `ruff format`
|
||||||
* To auto-format the Javascript code, run
|
* To auto-format the Javascript code, run
|
||||||
* `cd webapp`
|
* `cd webapp`
|
||||||
* `npm run lint`
|
* `npm run lint`
|
||||||
|
|
||||||
**Before submitting a merge request/merging** please auto-format your code and also run the quality checks (linting, static analysis, and unit tests)
|
**Before merging** please auto-format your code and also run the quality checks (linting, static analysis, and unit tests)
|
||||||
|
|
||||||
* To auto-format and type-check the Python code run `poe check`
|
* All commands above
|
||||||
* To auto-format the Javascript code, run
|
* `mypy src` (currently fails)
|
||||||
* `cd webapp`
|
* `pytest`
|
||||||
* `npm run lint`
|
|
||||||
|
|
||||||
|
|
||||||
### Details
|
### Details
|
||||||
|
|
||||||
We use several code analysis and formatting libraries in this project. **Please run all of these before submitting a merge request.**
|
We use several code analysis and formatting libraries in this project. Our CI will check each of these automatically, so ensuring they pass locally will save you time. Currently `ruff` is used for linting/formatting and `pytest` for unit tests. `mypy` will be enabled once the codebase is ready.
|
||||||
|
|
||||||
Our CI will check each of these automatically, so ensuring they pass locally will save you time.
|
|
||||||
|
|
||||||
* **Black** - Code formatting with minimal configuration.
|
|
||||||
* While sometimes it's not perfect, its fine 90% of the time and prevents arguments about formatting.
|
|
||||||
* Automatically formats your code
|
|
||||||
* This will rewrite your files in-place, so if you want to be able to revert, make a backup first!
|
|
||||||
* `poe black`
|
|
||||||
* **Pylint** - Static code analysis
|
|
||||||
* Analyses your code, failing if issues are detected.
|
|
||||||
* We've disabled some less severe warnings, so _if anything fails your merge request will be blocked_
|
|
||||||
* `poe pylint`
|
|
||||||
* **Mypy** - Type checking
|
|
||||||
* Analyses your type hints and annotations to flag up potential bugs
|
|
||||||
* Where possible, use type hints in your code. Even if dependencies don't support it, it'll help identify issues.
|
|
||||||
* `poe mypy`
|
|
||||||
* **Pytest** - Unit testing
|
|
||||||
* While unit testing is of limited use due to our dependence on real hardware, some simple isolated functions can (and should) be unit tested.
|
|
||||||
* `poe test`
|
|
||||||
|
|
||||||
Though not in the CI, our `format` script also runs isort:
|
|
||||||
|
|
||||||
* **Isort** - Import sorting
|
|
||||||
* Automatically organises your imports to stop things getting out of hand
|
|
||||||
* `poe isort`
|
|
||||||
|
|
||||||
## Python environment, build, and dependencies
|
## Python environment, build, and dependencies
|
||||||
|
|
||||||
As of `2.10.0b0` we have switched to using `pipenv` for managing dependencies, and a standard `setuptools` based build system. See "local installation" above for instructions on how to install the project. Earlier versions of the project used `poetry` but we moved away because of [difficulties](https://gitlab.com/openflexure/openflexure-microscope-server/-/merge_requests/124) getting it to work both on the Raspberry Pi and in our CI pipeline. The new arrangement for configuration files is:
|
As of `v3` we specify dependencies in `pyproject.toml`. These are not currently frozen due to difficulties matching versions on different platforms. On Raspberry Pi, it's best to specify `--only-binary=:all:` to ensure libraries like `numpy` and `scipy` are not compiled from source (which takes many hours, and/or fails). Pinning dependencies with `requirements.txt` and/or `requirements.in` may happen in the future.
|
||||||
|
|
||||||
* Dependencies, and dev-dependencies, are specified in `setup.py` in the usual way (using `install_requires` and `extra_requires[dev]`).
|
|
||||||
* Package metadata is specified in `setup.py` in the usual way.
|
|
||||||
* `pyproject.toml` is retained, but *no longer includes package metadata or dependencies* and does not have a `[tool.poetry]` table. It does define the build system as per PEP517, which is `setuptools`, and it also contains settings for `black`, `poe` and `isort`.
|
|
||||||
* `Pipfile` is very minimal, and only declares dependencies on the current module, i.e. the dependencies declared in `setup.py`. It also specifies the Python version. This allows us to single-source dependency information from `setup.py` but use the dependency resolution/locking functionality of `pipenv`
|
|
||||||
* `Pipfile.lock` locks the dependency versions in the same way as `poetry.lock` used to, i.e. it specifies exact versions of everything, derived from the looser specifications in `setup.py`.
|
|
||||||
* Dependency resolution is a pain, in part because compiling packages like scipy and numpy from source on a Pi is slow and unreliable. To avoid this, we usually lock the dependencies on a Raspberry Pi, using:
|
|
||||||
`PIP_ONLY_BINARY="numpy, scipy, matplotlib" pipenv lock --dev`
|
|
||||||
This forces the use of wheels only, and thus restricts us to things that built OK on Piwheels. The resulting Pipfile.lock should then work nicely on other Raspberry Pis.
|
|
||||||
* **The Pipfile we use is now Raspberry Pi-specific** so if you attempt to use it on other platforms it will probably fail. This is because we've removed `pypi` to avoid hash conflicts. The CI now runs on a Raspberry Pi image, so that the tests use a similar environment to our deployment. Currently, changing the `[source]` entry back to PyPi will allow you to re-lock the dependencies on other platforms. In the future we may need to maintain two Pipfiles and two Pipfile.lock files.
|
|
||||||
|
|
||||||
## Creating releases
|
## Creating releases
|
||||||
|
|
||||||
* Update the application's internal version number
|
* Update the application's internal version number
|
||||||
* Edit `setup.py` to update the version number
|
* Edit `pyproject.toml` to update the version number
|
||||||
* Git commit and git push
|
* Update the changelog
|
||||||
* Create a new version tag on GitLab (e.g. `v2.6.11`)
|
* Git commit and git push
|
||||||
|
* Create a new version tag on GitLab (e.g. `v2.6.11`) that matches the `pyproject.toml` version number.
|
||||||
* Make sure you prefix a lower case 'v', otherwise it won't be recognised as a release!
|
* Make sure you prefix a lower case 'v', otherwise it won't be recognised as a release!
|
||||||
* This tagging will trigger a CI pipeline that builds the JS client, tarballs up the server, and deploys it
|
* This tagging will trigger a CI pipeline that builds the JS client, tarballs up the server, and deploys it
|
||||||
* Note: This also updates the build server's nginx redirect map file
|
* Note: This also updates the build server's nginx redirect map file
|
||||||
|
|
@ -180,10 +146,8 @@ As of `2.10.0b0` we have switched to using `pipenv` for managing dependencies, a
|
||||||
* `npm install -g conventional-changelog-cli`
|
* `npm install -g conventional-changelog-cli`
|
||||||
* `npx conventional-changelog -r 1 --config ./changelog.config.js -i CHANGELOG.md -s`
|
* `npx conventional-changelog -r 1 --config ./changelog.config.js -i CHANGELOG.md -s`
|
||||||
|
|
||||||
## Microscope extensions
|
## Adding functionality
|
||||||
|
|
||||||
The Microscope module, and Flask app, both support plugins for extending lower-level functionality not well suited to web API calls. The current documentation can be found [here](https://openflexure-microscope-software.readthedocs.io/en/latest/plugins.html).
|
The microscope comprises a number of `Thing` instances, which provide actions and properties to implement hardware control and software features. These may be imported from any Python module, using `ofm_config.json`. See the LabThings-FastAPI documentation for how to create a `Thing`.
|
||||||
If you want to add functions to the microscope software, this is probably the best mechanism to use if it works for you.
|
|
||||||
|
|
||||||
|
Currently, the camera and stage classes are provided by external libraries, `labthings-picamera2` and `labthings-sangaboard`. Replacing these is the easiest way to use alternative hardware: interfaces are defined in `openflexure_microscope_server.things.camera` and `openflexure_microscope_server.things.stage` respectively.
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ dependencies = [
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"labthings-fastapi[dev]",
|
"labthings-fastapi[dev]",
|
||||||
|
"ruff",
|
||||||
|
"mypy[reports]",
|
||||||
|
"mypy-gitlab-code-quality",
|
||||||
|
# "pytest-gitlab-code-quality", # pytest version constraint clashes with labthings
|
||||||
|
"pytest",
|
||||||
]
|
]
|
||||||
pi = [
|
pi = [
|
||||||
"labthings-picamera2 == 0.0.1-dev2",
|
"labthings-picamera2 == 0.0.1-dev2",
|
||||||
|
|
@ -58,49 +63,13 @@ build-backend = "setuptools.build_meta"
|
||||||
# don't believe it's critical at present, because we don't use
|
# don't believe it's critical at present, because we don't use
|
||||||
# the Python package - we distribute a tarball directly.
|
# the Python package - we distribute a tarball directly.
|
||||||
|
|
||||||
[tool.black]
|
|
||||||
exclude = '(\.eggs|\.git|\.venv|\venv|node_modules/)'
|
|
||||||
|
|
||||||
[tool.isort]
|
|
||||||
multi_line_output = 3
|
|
||||||
include_trailing_comma = true
|
|
||||||
force_grid_wrap = 0
|
|
||||||
use_parentheses = true
|
|
||||||
ensure_newline_before_comments = true
|
|
||||||
line_length = 88
|
|
||||||
|
|
||||||
[tool.pylint.'MESSAGES CONTROL']
|
|
||||||
# W1203 warns about using f strings in logging statements.
|
|
||||||
# I'm really not concerned about the performance implications of this,
|
|
||||||
# particularly as we often have the log level set quite high. I think
|
|
||||||
# for our current purposes, using f strings in logging statements is
|
|
||||||
# more readable and thus a good idea in many places - I've disabled
|
|
||||||
# this warning code globally for that reason.
|
|
||||||
disable = "fixme,C,R,W1203"
|
|
||||||
max-line-length = 88
|
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
plugins = ["pydantic.mypy"]
|
plugins = ["pydantic.mypy"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py39"
|
target-version = "py39"
|
||||||
|
|
||||||
[tool.poe.executor]
|
[tool.pytest.ini_options]
|
||||||
type = "virtualenv"
|
addopts = [
|
||||||
location = ".venv"
|
"--import-mode=importlib",
|
||||||
|
]
|
||||||
[tool.poe.tasks]
|
|
||||||
black = "black ."
|
|
||||||
black_check = "black --check ."
|
|
||||||
ruff = "ruff . --fix"
|
|
||||||
ruff_check = "ruff ."
|
|
||||||
isort = "isort openflexure_microscope"
|
|
||||||
pylint = "pylint openflexure_microscope"
|
|
||||||
mypy = "mypy --cobertura-xml-report openflexure_microscope openflexure_microscope"
|
|
||||||
test = "pytest . --junitxml=pytest_report.xml"
|
|
||||||
serve = "python -m openflexure_microscope.api.app"
|
|
||||||
|
|
||||||
format = ["black", "isort", "ruff"]
|
|
||||||
lint = ["ruff_check", "pylint", "mypy"]
|
|
||||||
|
|
||||||
check = ["format", "lint", "test"]
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Copyright (c) 2018 German Aerospace Center (DLR). All rights reserved.
|
||||||
SPDX-License-Identifier: MIT-DLR
|
SPDX-License-Identifier: MIT-DLR
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
@ -21,7 +22,7 @@ class Zenodo:
|
||||||
self.zenodo_url = "https://zenodo.org/api/deposit/depositions"
|
self.zenodo_url = "https://zenodo.org/api/deposit/depositions"
|
||||||
|
|
||||||
def create_new_deposit(self):
|
def create_new_deposit(self):
|
||||||
""" Creates a new (unpublished) Zenodo deposit and return its deposition ID. """
|
"""Creates a new (unpublished) Zenodo deposit and return its deposition ID."""
|
||||||
|
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
|
|
@ -35,7 +36,7 @@ class Zenodo:
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def set_metadata(self, deposition_id, metadata):
|
def set_metadata(self, deposition_id, metadata):
|
||||||
""" Sets the given metadata for the specified deposit. """
|
"""Sets the given metadata for the specified deposit."""
|
||||||
|
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
r = requests.put(
|
r = requests.put(
|
||||||
|
|
@ -48,7 +49,7 @@ class Zenodo:
|
||||||
print(r.json())
|
print(r.json())
|
||||||
|
|
||||||
def upload_file(self, deposition_id, file_path):
|
def upload_file(self, deposition_id, file_path):
|
||||||
""" Uploads a new file for the given deposit. """
|
"""Uploads a new file for the given deposit."""
|
||||||
|
|
||||||
file_name = os.path.basename(file_path)
|
file_name = os.path.basename(file_path)
|
||||||
data = {"filename": file_name}
|
data = {"filename": file_name}
|
||||||
|
|
@ -63,7 +64,7 @@ class Zenodo:
|
||||||
print(r.json())
|
print(r.json())
|
||||||
|
|
||||||
def publish_deposit(self, deposition_id):
|
def publish_deposit(self, deposition_id):
|
||||||
""" Publishes the given deposit. BEWARE: It is now visible to all!!! """
|
"""Publishes the given deposit. BEWARE: It is now visible to all!!!"""
|
||||||
|
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
self.zenodo_url + "/{}/actions/publish".format(deposition_id),
|
self.zenodo_url + "/{}/actions/publish".format(deposition_id),
|
||||||
|
|
@ -73,7 +74,7 @@ class Zenodo:
|
||||||
print(r.json())
|
print(r.json())
|
||||||
|
|
||||||
def create_new_version(self, deposition_id):
|
def create_new_version(self, deposition_id):
|
||||||
""" Creates a new version of an already published deposit. """
|
"""Creates a new version of an already published deposit."""
|
||||||
|
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
self.zenodo_url + "/{}/actions/newversion".format(deposition_id),
|
self.zenodo_url + "/{}/actions/newversion".format(deposition_id),
|
||||||
|
|
@ -84,7 +85,7 @@ class Zenodo:
|
||||||
return os.path.basename(r.json()["links"]["latest_draft"])
|
return os.path.basename(r.json()["links"]["latest_draft"])
|
||||||
|
|
||||||
def remove_all_files(self, deposition_id):
|
def remove_all_files(self, deposition_id):
|
||||||
""" Removes all uploaded files of a unpublished deposit. """
|
"""Removes all uploaded files of a unpublished deposit."""
|
||||||
|
|
||||||
r = requests.get(
|
r = requests.get(
|
||||||
self.zenodo_url + "/{}/files".format(deposition_id),
|
self.zenodo_url + "/{}/files".format(deposition_id),
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@ import os
|
||||||
|
|
||||||
from fastapi.responses import FileResponse, PlainTextResponse
|
from fastapi.responses import FileResponse, PlainTextResponse
|
||||||
|
|
||||||
OFM_LOG_FOLDER = "/var/openflexure/logs/"
|
OFM_LOG_FOLDER = "/var/openflexure/logs/"
|
||||||
OFM_LOG_FILE = os.path.join(OFM_LOG_FOLDER, "openflexure_microscope.log")
|
OFM_LOG_FILE = os.path.join(OFM_LOG_FOLDER, "openflexure_microscope.log")
|
||||||
|
|
||||||
|
|
||||||
def configure_logging():
|
def configure_logging():
|
||||||
root_logger = logging.getLogger()
|
root_logger = logging.getLogger()
|
||||||
root_logger.setLevel(logging.INFO)
|
root_logger.setLevel(logging.INFO)
|
||||||
|
|
@ -14,15 +15,13 @@ def configure_logging():
|
||||||
if not os.path.exists(OFM_LOG_FOLDER):
|
if not os.path.exists(OFM_LOG_FOLDER):
|
||||||
os.makedirs(OFM_LOG_FOLDER)
|
os.makedirs(OFM_LOG_FOLDER)
|
||||||
handler = RotatingFileHandler(
|
handler = RotatingFileHandler(
|
||||||
filename = OFM_LOG_FILE,
|
filename=OFM_LOG_FILE,
|
||||||
mode = "a",
|
mode="a",
|
||||||
maxBytes = 1000000,
|
maxBytes=1000000,
|
||||||
backupCount = 10,
|
backupCount=10,
|
||||||
)
|
)
|
||||||
handler.setFormatter(
|
handler.setFormatter(
|
||||||
logging.Formatter(
|
logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s")
|
||||||
"[%(asctime)s] [%(levelname)s] %(message)s"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
root_logger.addHandler(handler)
|
root_logger.addHandler(handler)
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
|
|
|
||||||
|
|
@ -43,5 +43,3 @@ def serve_from_cli(argv: Optional[list[str]] = None):
|
||||||
uvicorn.run(app, host=args.host, port=args.port)
|
uvicorn.run(app, host=args.host, port=args.port)
|
||||||
else:
|
else:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from socket import gethostname
|
||||||
|
|
||||||
def add_v2_endpoints(thing_server: ThingServer):
|
def add_v2_endpoints(thing_server: ThingServer):
|
||||||
app = thing_server.app
|
app = thing_server.app
|
||||||
|
|
||||||
# TODO: update openflexure connect to make this unnecessary!!
|
# TODO: update openflexure connect to make this unnecessary!!
|
||||||
# The endpoints below fool OpenFlexure Connect into thinking we are a
|
# The endpoints below fool OpenFlexure Connect into thinking we are a
|
||||||
# v2 microscope, so we show up correctly.
|
# v2 microscope, so we show up correctly.
|
||||||
|
|
@ -15,7 +16,7 @@ def add_v2_endpoints(thing_server: ThingServer):
|
||||||
fake_routes = [
|
fake_routes = [
|
||||||
"/api/v2/",
|
"/api/v2/",
|
||||||
"/api/v2/streams/snapshot",
|
"/api/v2/streams/snapshot",
|
||||||
"/api/v2/instrument/settings/name"
|
"/api/v2/instrument/settings/name",
|
||||||
]
|
]
|
||||||
return {url: {"url": url, "methods": ["GET"]} for url in fake_routes}
|
return {url: {"url": url, "methods": ["GET"]} for url in fake_routes}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,23 @@ from fastapi import FastAPI
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
|
|
||||||
def add_static_file(app: FastAPI, fname: str, folder: str):
|
def add_static_file(app: FastAPI, fname: str, folder: str):
|
||||||
print(f"Adding route for /{fname}")
|
print(f"Adding route for /{fname}")
|
||||||
p=os.path.join(folder, fname)
|
p = os.path.join(folder, fname)
|
||||||
app.get(
|
app.get(f"/{fname}", response_class=FileResponse, include_in_schema=False)(
|
||||||
f"/{fname}",
|
lambda: FileResponse(p)
|
||||||
response_class=FileResponse,
|
)
|
||||||
include_in_schema=False
|
|
||||||
)(lambda: FileResponse(p))
|
|
||||||
|
|
||||||
def add_static_files(app: FastAPI):
|
def add_static_files(app: FastAPI):
|
||||||
#with importlib.resources.as_file(openflexure_microscope_server) as p:
|
# with importlib.resources.as_file(openflexure_microscope_server) as p:
|
||||||
# static_path = p.join("/static/")
|
# static_path = p.join("/static/")
|
||||||
#TODO: don't hard code this!
|
# TODO: don't hard code this!
|
||||||
search_paths = [
|
search_paths = [
|
||||||
"/var/openflexure/application/openflexure-microscope-server/src/openflexure_microscope_server/static",
|
"/var/openflexure/application/openflexure-microscope-server/src/openflexure_microscope_server/static",
|
||||||
pathlib.Path().absolute() / "application/openflexure-microscope-server/src/openflexure_microscope_server/static",
|
pathlib.Path().absolute()
|
||||||
|
/ "application/openflexure-microscope-server/src/openflexure_microscope_server/static",
|
||||||
]
|
]
|
||||||
if __file__:
|
if __file__:
|
||||||
search_paths.append(pathlib.Path(__file__).parent.parent / "static")
|
search_paths.append(pathlib.Path(__file__).parent.parent / "static")
|
||||||
|
|
@ -34,6 +35,7 @@ def add_static_files(app: FastAPI):
|
||||||
@app.get("/", response_class=RedirectResponse)
|
@app.get("/", response_class=RedirectResponse)
|
||||||
async def redirect_fastapi():
|
async def redirect_fastapi():
|
||||||
return "/index.html"
|
return "/index.html"
|
||||||
|
|
||||||
for fname in os.listdir(static_path):
|
for fname in os.listdir(static_path):
|
||||||
fpath = os.path.join(static_path, fname)
|
fpath = os.path.join(static_path, fname)
|
||||||
if os.path.isfile(fpath):
|
if os.path.isfile(fpath):
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from openflexure_microscope_server.things.camera_stage_mapping import CameraStag
|
||||||
CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/")
|
CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/")
|
||||||
AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/")
|
AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/")
|
||||||
|
|
||||||
|
|
||||||
class RecentringThing(Thing):
|
class RecentringThing(Thing):
|
||||||
@thing_action
|
@thing_action
|
||||||
def recentre(
|
def recentre(
|
||||||
|
|
@ -27,7 +28,7 @@ class RecentringThing(Thing):
|
||||||
corresponds to the centre of the stage. This exploits the
|
corresponds to the centre of the stage. This exploits the
|
||||||
fact that the OpenFlexure stage moves in an arc, i.e. its
|
fact that the OpenFlexure stage moves in an arc, i.e. its
|
||||||
height will vary with X and Y. The point where the variation
|
height will vary with X and Y. The point where the variation
|
||||||
of Z with X and Y motion is smallest is the centre of its
|
of Z with X and Y motion is smallest is the centre of its
|
||||||
XY travel. This routine moves in X and Y, monitoring the
|
XY travel. This routine moves in X and Y, monitoring the
|
||||||
Z value of the focal plane, and attempts to find the point
|
Z value of the focal plane, and attempts to find the point
|
||||||
where Z does not vary with X and Y, which is where it stops.
|
where Z does not vary with X and Y, which is where it stops.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ camera together to perform an autofocus routine.
|
||||||
|
|
||||||
See repository root for licensing information.
|
See repository root for licensing information.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -14,8 +15,6 @@ from typing import Annotated, Mapping, Optional, Sequence
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
from labthings_fastapi.thing import Thing
|
from labthings_fastapi.thing import Thing
|
||||||
from labthings_fastapi.dependencies.raw_thing import raw_thing_dependency
|
|
||||||
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
|
|
||||||
from labthings_fastapi.dependencies.blocking_portal import BlockingPortal
|
from labthings_fastapi.dependencies.blocking_portal import BlockingPortal
|
||||||
from labthings_fastapi.decorators import thing_action
|
from labthings_fastapi.decorators import thing_action
|
||||||
from labthings_fastapi.types.numpy import NDArray
|
from labthings_fastapi.types.numpy import NDArray
|
||||||
|
|
@ -28,8 +27,10 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
### Autofocus utilities
|
### Autofocus utilities
|
||||||
|
|
||||||
|
|
||||||
class JPEGSharpnessMonitor:
|
class JPEGSharpnessMonitor:
|
||||||
__globals__ = globals() # Required for FastAPI dependency
|
__globals__ = globals() # Required for FastAPI dependency
|
||||||
|
|
||||||
def __init__(self, stage: Stage, camera: Camera, portal: BlockingPortal):
|
def __init__(self, stage: Stage, camera: Camera, portal: BlockingPortal):
|
||||||
self.camera = camera
|
self.camera = camera
|
||||||
self.stage = stage
|
self.stage = stage
|
||||||
|
|
@ -50,7 +51,7 @@ class JPEGSharpnessMonitor:
|
||||||
self.jpeg_sizes.append(len(frame))
|
self.jpeg_sizes.append(len(frame))
|
||||||
if not self.running:
|
if not self.running:
|
||||||
break
|
break
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Context manager, during which we will monitor sharpness from the camera"""
|
"""Context manager, during which we will monitor sharpness from the camera"""
|
||||||
|
|
@ -75,7 +76,7 @@ class JPEGSharpnessMonitor:
|
||||||
# Index of the data for this movement
|
# Index of the data for this movement
|
||||||
data_index: int = len(self.stage_positions) - 2
|
data_index: int = len(self.stage_positions) - 2
|
||||||
# Final z position after move
|
# Final z position after move
|
||||||
final_z_position: int = self.stage_positions[-1]['z']
|
final_z_position: int = self.stage_positions[-1]["z"]
|
||||||
return data_index, final_z_position
|
return data_index, final_z_position
|
||||||
|
|
||||||
def move_data(
|
def move_data(
|
||||||
|
|
@ -86,12 +87,10 @@ class JPEGSharpnessMonitor:
|
||||||
istop = istart + 2
|
istop = istart + 2
|
||||||
jpeg_times: np.ndarray = np.array(self.jpeg_times)
|
jpeg_times: np.ndarray = np.array(self.jpeg_times)
|
||||||
jpeg_sizes: np.ndarray = np.array(self.jpeg_sizes)
|
jpeg_sizes: np.ndarray = np.array(self.jpeg_sizes)
|
||||||
stage_times: np.ndarray = np.array(self.stage_times)[
|
stage_times: np.ndarray = np.array(self.stage_times)[istart:istop]
|
||||||
istart:istop
|
|
||||||
]
|
|
||||||
stage_zs: np.ndarray = np.array(
|
stage_zs: np.ndarray = np.array(
|
||||||
[p['z'] for p in self.stage_positions[istart:istop]]
|
[p["z"] for p in self.stage_positions[istart:istop]]
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
start: int = int(np.argmax(jpeg_times > stage_times[0]))
|
start: int = int(np.argmax(jpeg_times > stage_times[0]))
|
||||||
stop: int = int(np.argmax(jpeg_times > stage_times[1]))
|
stop: int = int(np.argmax(jpeg_times > stage_times[1]))
|
||||||
|
|
@ -130,6 +129,7 @@ class JPEGSharpnessMonitor:
|
||||||
|
|
||||||
SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()]
|
SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()]
|
||||||
|
|
||||||
|
|
||||||
class SharpnessDataArrays(BaseModel):
|
class SharpnessDataArrays(BaseModel):
|
||||||
jpeg_times: NDArray
|
jpeg_times: NDArray
|
||||||
jpeg_sizes: NDArray
|
jpeg_sizes: NDArray
|
||||||
|
|
@ -142,18 +142,18 @@ class AutofocusThing(Thing):
|
||||||
def fast_autofocus(
|
def fast_autofocus(
|
||||||
self,
|
self,
|
||||||
m: SharpnessMonitorDep,
|
m: SharpnessMonitorDep,
|
||||||
dz: int=2000,
|
dz: int = 2000,
|
||||||
start: str='centre',
|
start: str = "centre",
|
||||||
) -> SharpnessDataArrays:
|
) -> SharpnessDataArrays:
|
||||||
"""Sweep the stage up and down, then move to the sharpest point
|
"""Sweep the stage up and down, then move to the sharpest point
|
||||||
|
|
||||||
This method will will move down by dz/2, sweep up by dz, and then evaluate
|
This method will will move down by dz/2, sweep up by dz, and then evaluate
|
||||||
the position where the image was sharpest. We'll then move back down, and
|
the position where the image was sharpest. We'll then move back down, and
|
||||||
finally up to the sharpest point.
|
finally up to the sharpest point.
|
||||||
"""
|
"""
|
||||||
with m.run():
|
with m.run():
|
||||||
# Move to (-dz / 2)
|
# Move to (-dz / 2)
|
||||||
if start == 'centre':
|
if start == "centre":
|
||||||
m.focus_rel(-dz / 2)
|
m.focus_rel(-dz / 2)
|
||||||
# Move to dz while monitoring sharpness
|
# Move to dz while monitoring sharpness
|
||||||
# i: Sharpness monitor index for this move
|
# i: Sharpness monitor index for this move
|
||||||
|
|
@ -167,16 +167,16 @@ class AutofocusThing(Thing):
|
||||||
m.focus_rel(fz - z)
|
m.focus_rel(fz - z)
|
||||||
# Return all focus data
|
# Return all focus data
|
||||||
return m.data_dict()
|
return m.data_dict()
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def move_and_measure(
|
def move_and_measure(
|
||||||
self,
|
self,
|
||||||
m: SharpnessMonitorDep,
|
m: SharpnessMonitorDep,
|
||||||
dz: Sequence[int],
|
dz: Sequence[int],
|
||||||
wait: float=0,
|
wait: float = 0,
|
||||||
) -> SharpnessDataArrays:
|
) -> SharpnessDataArrays:
|
||||||
"""Make a move (or a series of moves) and monitor sharpness
|
"""Make a move (or a series of moves) and monitor sharpness
|
||||||
|
|
||||||
This method will will make a series of relative moves in z, and
|
This method will will make a series of relative moves in z, and
|
||||||
return the sharpness (JPEG size) vs time, along with timestamps
|
return the sharpness (JPEG size) vs time, along with timestamps
|
||||||
for the moves. This can be used to calibrate autofocus.
|
for the moves. This can be used to calibrate autofocus.
|
||||||
|
|
@ -195,9 +195,11 @@ class AutofocusThing(Thing):
|
||||||
return m.data_dict()
|
return m.data_dict()
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def looping_autofocus(self, stage: Stage, m: SharpnessMonitorDep, dz=2000, start='centre'):
|
def looping_autofocus(
|
||||||
|
self, stage: Stage, m: SharpnessMonitorDep, dz=2000, start="centre"
|
||||||
|
):
|
||||||
"""Repeatedly autofocus the stage until it looks focused.
|
"""Repeatedly autofocus the stage until it looks focused.
|
||||||
|
|
||||||
This action will run the `fast_autofocus` action until it settles on a point
|
This action will run the `fast_autofocus` action until it settles on a point
|
||||||
in the middle 3/5 of its range. Such logic can be helpful if the microscope
|
in the middle 3/5 of its range. Such logic can be helpful if the microscope
|
||||||
is close to focus, but not quite within `dz/2`. It will attempt to autofocus
|
is close to focus, but not quite within `dz/2`. It will attempt to autofocus
|
||||||
|
|
@ -209,14 +211,13 @@ class AutofocusThing(Thing):
|
||||||
|
|
||||||
with m.run():
|
with m.run():
|
||||||
while repeat and attempts < 10:
|
while repeat and attempts < 10:
|
||||||
|
if start == "centre":
|
||||||
if start == 'centre':
|
stage.move_relative(x=0, y=0, z=-(backlash + dz / 2))
|
||||||
stage.move_relative(x = 0, y = 0, z = -(backlash + dz / 2))
|
stage.move_relative(x=0, y=0, z=backlash)
|
||||||
stage.move_relative(x = 0, y = 0, z = backlash)
|
|
||||||
|
|
||||||
i, z = m.focus_rel(dz, block_cancellation=True)
|
i, z = m.focus_rel(dz, block_cancellation=True)
|
||||||
_, heights, sizes = m.move_data(i)
|
_, heights, sizes = m.move_data(i)
|
||||||
|
|
||||||
peak_height = heights[np.argmax(sizes)]
|
peak_height = heights[np.argmax(sizes)]
|
||||||
height_min = np.min(heights)
|
height_min = np.min(heights)
|
||||||
height_max = np.max(heights)
|
height_max = np.max(heights)
|
||||||
|
|
@ -226,25 +227,27 @@ class AutofocusThing(Thing):
|
||||||
or height_max - peak_height < dz / 5
|
or height_max - peak_height < dz / 5
|
||||||
):
|
):
|
||||||
attempts += 1
|
attempts += 1
|
||||||
start = 'centre'
|
start = "centre"
|
||||||
stage.move_absolute(z = peak_height-backlash)
|
stage.move_absolute(z=peak_height - backlash)
|
||||||
stage.move_absolute(z = peak_height)
|
stage.move_absolute(z=peak_height)
|
||||||
else:
|
else:
|
||||||
repeat = False
|
repeat = False
|
||||||
stage.move_relative(x = 0, y = 0, z = -(dz+backlash))
|
stage.move_relative(x=0, y=0, z=-(dz + backlash))
|
||||||
stage.move_absolute(z = peak_height)
|
stage.move_absolute(z=peak_height)
|
||||||
return heights.tolist(), sizes.tolist()
|
return heights.tolist(), sizes.tolist()
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def verify_focus_sharpness(self, sweep_sizes: list, camera: WrappedCamera, threshold: float = 0.95):
|
def verify_focus_sharpness(
|
||||||
'''Take the sharpness curve of the autofocus, and the size of the current frame
|
self, sweep_sizes: list, camera: WrappedCamera, threshold: float = 0.95
|
||||||
|
):
|
||||||
|
"""Take the sharpness curve of the autofocus, and the size of the current frame
|
||||||
to see if the autofocus completed successfully. Returns True if current sharpness
|
to see if the autofocus completed successfully. Returns True if current sharpness
|
||||||
is within "leniency" number of frames from the peak of the autofocus'''
|
is within "leniency" number of frames from the peak of the autofocus"""
|
||||||
|
|
||||||
current_sharpness = camera.grab_jpeg_size(stream_name='lores')
|
current_sharpness = camera.grab_jpeg_size(stream_name="lores")
|
||||||
|
|
||||||
peak = np.max(sweep_sizes)
|
peak = np.max(sweep_sizes)
|
||||||
base = np.min(sweep_sizes)
|
base = np.min(sweep_sizes)
|
||||||
cutoff = threshold * (peak - base)
|
cutoff = threshold * (peak - base)
|
||||||
|
|
||||||
return current_sharpness >= base + cutoff
|
return current_sharpness >= base + cutoff
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ should enabe the server to work.
|
||||||
|
|
||||||
See repository root for licensing information.
|
See repository root for licensing information.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from typing import Literal, Protocol, runtime_checkable
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
@ -23,15 +24,14 @@ from labthings_fastapi.types.numpy import NDArray
|
||||||
class JPEGBlob(Blob):
|
class JPEGBlob(Blob):
|
||||||
media_type: str = "image/jpeg"
|
media_type: str = "image/jpeg"
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class CameraProtocol(Protocol):
|
class CameraProtocol(Protocol):
|
||||||
"""A Thing representing a camera"""
|
"""A Thing representing a camera"""
|
||||||
|
|
||||||
def __enter__(self) -> None:
|
def __enter__(self) -> None: ...
|
||||||
...
|
|
||||||
|
def __exit__(self, _exc_type, _exc_value, _traceback) -> None: ...
|
||||||
def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
|
|
||||||
...
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def stream_active(self) -> bool:
|
def stream_active(self) -> bool:
|
||||||
|
|
@ -45,9 +45,8 @@ class CameraProtocol(Protocol):
|
||||||
def capture_array(
|
def capture_array(
|
||||||
self,
|
self,
|
||||||
resolution: Literal["lores", "main", "full"] = "main",
|
resolution: Literal["lores", "main", "full"] = "main",
|
||||||
) -> NDArray:
|
) -> NDArray: ...
|
||||||
...
|
|
||||||
|
|
||||||
def capture_jpeg(
|
def capture_jpeg(
|
||||||
self,
|
self,
|
||||||
metadata_getter: GetThingStates,
|
metadata_getter: GetThingStates,
|
||||||
|
|
@ -78,9 +77,10 @@ class CameraProtocol(Protocol):
|
||||||
"""Acquire one image from the preview stream and return its size"""
|
"""Acquire one image from the preview stream and return its size"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class BaseCamera(Thing):
|
class BaseCamera(Thing):
|
||||||
"""A Thing representing a camera
|
"""A Thing representing a camera
|
||||||
|
|
||||||
This is a concrete base class for `Thing`s implementing the `CameraProtocol`.
|
This is a concrete base class for `Thing`s implementing the `CameraProtocol`.
|
||||||
It provides the stream descriptors and actions to grab from the stream.
|
It provides the stream descriptors and actions to grab from the stream.
|
||||||
"""
|
"""
|
||||||
|
|
@ -110,12 +110,16 @@ class BaseCamera(Thing):
|
||||||
stream (either "main" for the preview stream, or "lores" for the low
|
stream (either "main" for the preview stream, or "lores" for the low
|
||||||
resolution preview). No metadata is returned.
|
resolution preview). No metadata is returned.
|
||||||
"""
|
"""
|
||||||
logging.info(f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) starting")
|
logging.info(
|
||||||
|
f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) starting"
|
||||||
|
)
|
||||||
stream = (
|
stream = (
|
||||||
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
|
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
|
||||||
)
|
)
|
||||||
frame = portal.call(stream.grab_frame)
|
frame = portal.call(stream.grab_frame)
|
||||||
logging.info(f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) got frame")
|
logging.info(
|
||||||
|
f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) got frame"
|
||||||
|
)
|
||||||
return JPEGBlob.from_bytes(frame)
|
return JPEGBlob.from_bytes(frame)
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
|
|
@ -129,12 +133,12 @@ class BaseCamera(Thing):
|
||||||
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
|
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
|
||||||
)
|
)
|
||||||
return portal.call(stream.next_frame_size)
|
return portal.call(stream.next_frame_size)
|
||||||
|
|
||||||
|
|
||||||
class CameraStub(BaseCamera):
|
class CameraStub(BaseCamera):
|
||||||
"""A stub for a camera, to allow dependencies
|
"""A stub for a camera, to allow dependencies
|
||||||
|
|
||||||
|
|
||||||
As of LabThings-FastAPI 0.0.7, we can't make a thing client dependency
|
As of LabThings-FastAPI 0.0.7, we can't make a thing client dependency
|
||||||
based on a protocol, because the protocol is not a Thing, and its
|
based on a protocol, because the protocol is not a Thing, and its
|
||||||
methods/properties are not decorated as Affordances. This stub class
|
methods/properties are not decorated as Affordances. This stub class
|
||||||
|
|
@ -142,9 +146,10 @@ class CameraStub(BaseCamera):
|
||||||
|
|
||||||
This stub class should be used for dependencies on the CameraProtocol.
|
This stub class should be used for dependencies on the CameraProtocol.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __enter__(self) -> None:
|
def __enter__(self) -> None:
|
||||||
raise NotImplementedError("Cameras must not inherit from CameraStub")
|
raise NotImplementedError("Cameras must not inherit from CameraStub")
|
||||||
|
|
||||||
def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
|
def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
|
||||||
raise NotImplementedError("Cameras must not inherit from CameraStub")
|
raise NotImplementedError("Cameras must not inherit from CameraStub")
|
||||||
|
|
||||||
|
|
@ -164,7 +169,7 @@ class CameraStub(BaseCamera):
|
||||||
resolution: Literal["lores", "main", "full"] = "main",
|
resolution: Literal["lores", "main", "full"] = "main",
|
||||||
) -> NDArray:
|
) -> NDArray:
|
||||||
raise NotImplementedError("Cameras must not inherit from CameraStub")
|
raise NotImplementedError("Cameras must not inherit from CameraStub")
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def capture_jpeg(
|
def capture_jpeg(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"""OpenFlexure Microscope OpenCV Camera
|
"""OpenFlexure Microscope OpenCV Camera
|
||||||
|
|
||||||
This module defines a camera Thing that uses OpenCV's
|
This module defines a camera Thing that uses OpenCV's
|
||||||
`VideoCapture`.
|
`VideoCapture`.
|
||||||
|
|
||||||
See repository root for licensing information.
|
See repository root for licensing information.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
|
@ -26,7 +27,8 @@ from . import BaseCamera, JPEGBlob
|
||||||
|
|
||||||
class OpenCVCamera(BaseCamera):
|
class OpenCVCamera(BaseCamera):
|
||||||
"""A Thing representing an OpenCV camera"""
|
"""A Thing representing an OpenCV camera"""
|
||||||
def __init__(self, camera_index: int=0):
|
|
||||||
|
def __init__(self, camera_index: int = 0):
|
||||||
self.camera_index = camera_index
|
self.camera_index = camera_index
|
||||||
self._capture_thread: Optional[Thread] = None
|
self._capture_thread: Optional[Thread] = None
|
||||||
self._capture_enabled = False
|
self._capture_enabled = False
|
||||||
|
|
@ -37,7 +39,7 @@ class OpenCVCamera(BaseCamera):
|
||||||
self._capture_thread = Thread(target=self._capture_frames)
|
self._capture_thread = Thread(target=self._capture_frames)
|
||||||
self._capture_thread.start()
|
self._capture_thread.start()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, _exc_type, _exc_value, _traceback):
|
def __exit__(self, _exc_type, _exc_value, _traceback):
|
||||||
if self.stream_active:
|
if self.stream_active:
|
||||||
self._capture_enabled = False
|
self._capture_enabled = False
|
||||||
|
|
@ -50,6 +52,7 @@ class OpenCVCamera(BaseCamera):
|
||||||
if self._capture_enabled and self._capture_thread:
|
if self._capture_enabled and self._capture_thread:
|
||||||
return self._capture_thread.is_alive()
|
return self._capture_thread.is_alive()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
mjpeg_stream = MJPEGStreamDescriptor()
|
mjpeg_stream = MJPEGStreamDescriptor()
|
||||||
lores_mjpeg_stream = MJPEGStreamDescriptor()
|
lores_mjpeg_stream = MJPEGStreamDescriptor()
|
||||||
|
|
||||||
|
|
@ -58,11 +61,15 @@ class OpenCVCamera(BaseCamera):
|
||||||
while self._capture_enabled:
|
while self._capture_enabled:
|
||||||
ret, frame = self.cap.read()
|
ret, frame = self.cap.read()
|
||||||
if not ret:
|
if not ret:
|
||||||
logging.error(f"Failed to capture frame from camera {self.camera_index}")
|
logging.error(
|
||||||
|
f"Failed to capture frame from camera {self.camera_index}"
|
||||||
|
)
|
||||||
break
|
break
|
||||||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||||
self.mjpeg_stream.add_frame(jpeg, portal)
|
self.mjpeg_stream.add_frame(jpeg, portal)
|
||||||
jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[1].tobytes()
|
jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[
|
||||||
|
1
|
||||||
|
].tobytes()
|
||||||
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
|
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
|
|
@ -78,9 +85,11 @@ class OpenCVCamera(BaseCamera):
|
||||||
"""
|
"""
|
||||||
ret, frame = self.cap.read()
|
ret, frame = self.cap.read()
|
||||||
if not ret:
|
if not ret:
|
||||||
raise RuntimeError(f"Failed to capture frame from camera {self.camera_index}")
|
raise RuntimeError(
|
||||||
|
f"Failed to capture frame from camera {self.camera_index}"
|
||||||
|
)
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def capture_jpeg(
|
def capture_jpeg(
|
||||||
self,
|
self,
|
||||||
|
|
@ -95,7 +104,9 @@ class OpenCVCamera(BaseCamera):
|
||||||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||||
exif_dict = {
|
exif_dict = {
|
||||||
"Exif": {
|
"Exif": {
|
||||||
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode("utf-8")
|
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
},
|
},
|
||||||
"GPS": {},
|
"GPS": {},
|
||||||
"Interop": {},
|
"Interop": {},
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ camera together to perform an autofocus routine.
|
||||||
|
|
||||||
See repository root for licensing information.
|
See repository root for licensing information.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
|
@ -23,34 +24,48 @@ from labthings_fastapi.dependencies.metadata import GetThingStates
|
||||||
from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor
|
from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor
|
||||||
from labthings_fastapi.types.numpy import NDArray
|
from labthings_fastapi.types.numpy import NDArray
|
||||||
from labthings_fastapi.server import ThingServer
|
from labthings_fastapi.server import ThingServer
|
||||||
|
from pydantic import RootModel
|
||||||
|
|
||||||
from . import BaseCamera, JPEGBlob
|
from . import BaseCamera, JPEGBlob
|
||||||
from ..stage import StageProtocol as Stage
|
from ..stage import StageProtocol as Stage
|
||||||
|
|
||||||
|
|
||||||
|
class ArrayModel(RootModel):
|
||||||
|
"""A model for an array"""
|
||||||
|
|
||||||
|
root: NDArray
|
||||||
|
|
||||||
|
|
||||||
class SimulatedCamera(BaseCamera):
|
class SimulatedCamera(BaseCamera):
|
||||||
"""A Thing representing an OpenCV camera"""
|
"""A Thing representing an OpenCV camera"""
|
||||||
shape = (600, 800, 3)
|
|
||||||
glyph_shape = (51, 51, 3)
|
|
||||||
canvas_shape = (3000, 4000, 3)
|
|
||||||
frame_interval = 0.1
|
|
||||||
_stage: Optional[Stage] = None
|
_stage: Optional[Stage] = None
|
||||||
_server: Optional[ThingServer] = None
|
_server: Optional[ThingServer] = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(
|
||||||
|
self,
|
||||||
|
shape: tuple[int, int, int] = (600, 800, 3),
|
||||||
|
glyph_shape: tuple[int, int, int] = (51, 51, 3),
|
||||||
|
canvas_shape: tuple[int, int, int] = (3000, 4000, 3),
|
||||||
|
frame_interval: float = 0.1,
|
||||||
|
):
|
||||||
|
self.shape = shape
|
||||||
|
self.glyph_shape = glyph_shape
|
||||||
|
self.canvas_shape = canvas_shape
|
||||||
|
self.frame_interval = frame_interval
|
||||||
self._capture_thread: Optional[Thread] = None
|
self._capture_thread: Optional[Thread] = None
|
||||||
self._capture_enabled = False
|
self._capture_enabled = False
|
||||||
self.generate_sprites()
|
self.generate_sprites()
|
||||||
self.generate_blobs()
|
self.generate_blobs()
|
||||||
self.generate_canvas()
|
self.generate_canvas()
|
||||||
|
|
||||||
def generate_sprites(self):
|
def generate_sprites(self):
|
||||||
"""Generate sprites to populate the image"""
|
"""Generate sprites to populate the image"""
|
||||||
self.sprites = []
|
self.sprites = []
|
||||||
black = np.zeros(self.glyph_shape, dtype=np.uint8)
|
black = np.zeros(self.glyph_shape, dtype=np.uint8)
|
||||||
x = np.arange(black.shape[0])
|
x = np.arange(black.shape[0])
|
||||||
y = np.arange(black.shape[1])
|
y = np.arange(black.shape[1])
|
||||||
rr = np.sqrt((x[:, None] - np.mean(x))**2 + (y[None, :] - np.mean(y))**2)
|
rr = np.sqrt((x[:, None] - np.mean(x)) ** 2 + (y[None, :] - np.mean(y)) ** 2)
|
||||||
for i in [5, 7, 9, 11, 13, 15]:
|
for i in [5, 7, 9, 11, 13, 15]:
|
||||||
sprite = black.copy()
|
sprite = black.copy()
|
||||||
sprite[rr < i] = 255
|
sprite[rr < i] = 255
|
||||||
|
|
@ -58,15 +73,15 @@ class SimulatedCamera(BaseCamera):
|
||||||
|
|
||||||
def generate_blobs(self, N: int = 1000):
|
def generate_blobs(self, N: int = 1000):
|
||||||
"""Generate coordinates of blobs
|
"""Generate coordinates of blobs
|
||||||
|
|
||||||
Blobs are characterised by X, Y, sprite
|
Blobs are characterised by X, Y, sprite
|
||||||
We also generate a KD tree to rapidly find blobs in an image
|
We also generate a KD tree to rapidly find blobs in an image
|
||||||
"""
|
"""
|
||||||
self.blobs = np.zeros((N, 3))
|
self.blobs = np.zeros((N, 3))
|
||||||
rng = np.random.default_rng()
|
rng = np.random.default_rng()
|
||||||
w = np.max(self.glyph_shape)
|
w = np.max(self.glyph_shape)
|
||||||
self.blobs[:, 0] = rng.uniform(w/2, self.canvas_shape[0]-w/2, N)
|
self.blobs[:, 0] = rng.uniform(w / 2, self.canvas_shape[0] - w / 2, N)
|
||||||
self.blobs[:, 1] = rng.uniform(w/2, self.canvas_shape[1]-w/2, N)
|
self.blobs[:, 1] = rng.uniform(w / 2, self.canvas_shape[1] - w / 2, N)
|
||||||
self.blobs[:, 2] = rng.choice(len(self.sprites), N)
|
self.blobs[:, 2] = rng.choice(len(self.sprites), N)
|
||||||
|
|
||||||
def generate_canvas(self):
|
def generate_canvas(self):
|
||||||
|
|
@ -76,28 +91,34 @@ class SimulatedCamera(BaseCamera):
|
||||||
w, h, _ = self.glyph_shape
|
w, h, _ = self.glyph_shape
|
||||||
for x, y, sprite in self.blobs:
|
for x, y, sprite in self.blobs:
|
||||||
self.canvas[
|
self.canvas[
|
||||||
int(x) - w//2:int(x) - w//2 + w,
|
int(x) - w // 2 : int(x) - w // 2 + w,
|
||||||
int(y) - h//2:int(y) - h//2 + h,
|
int(y) - h // 2 : int(y) - h // 2 + h,
|
||||||
] -= self.sprites[int(sprite)]
|
] -= self.sprites[int(sprite)]
|
||||||
|
|
||||||
def generate_image(self, pos: tuple[int, int]):
|
def generate_image(self, pos: tuple[int, int]):
|
||||||
"""Generate an image with blobs based on supplied coordinates"""
|
"""Generate an image with blobs based on supplied coordinates"""
|
||||||
cw, ch, _ = self.canvas_shape
|
cw, ch, _ = self.canvas_shape
|
||||||
w, h, _ = self.shape
|
w, h, _ = self.shape
|
||||||
tl = (int(pos[0]) - w//2 - cw//2, int(pos[1]) - h//2 - ch//2)
|
tl = (int(pos[0]) - w // 2 - cw // 2, int(pos[1]) - h // 2 - ch // 2)
|
||||||
return self.canvas[
|
image = self.canvas[
|
||||||
tuple(slice(tl[i],tl[i] + self.shape[i]) for i in range(2)) + (slice(None),)
|
tuple(slice(tl[i], tl[i] + self.shape[i]) for i in range(2))
|
||||||
|
+ (slice(None),)
|
||||||
]
|
]
|
||||||
|
if image.shape != self.shape:
|
||||||
|
raise ValueError(
|
||||||
|
f"Image shape {image.shape} does not match intended shape {self.shape}"
|
||||||
|
)
|
||||||
|
return image
|
||||||
|
|
||||||
def attach_to_server(self, server: ThingServer, path: str):
|
def attach_to_server(self, server: ThingServer, path: str):
|
||||||
self._server = server
|
self._server = server
|
||||||
return super().attach_to_server(server, path)
|
return super().attach_to_server(server, path)
|
||||||
|
|
||||||
def get_stage_position(self):
|
def get_stage_position(self):
|
||||||
if not self._stage and self._server:
|
if not self._stage and self._server:
|
||||||
self._stage = self._server.things["/stage/"]
|
self._stage = self._server.things["/stage/"]
|
||||||
return self._stage.instantaneous_position
|
return self._stage.instantaneous_position
|
||||||
|
|
||||||
def generate_frame(self):
|
def generate_frame(self):
|
||||||
"""Generate a frame with blobs based on the stage coordinates"""
|
"""Generate a frame with blobs based on the stage coordinates"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -105,14 +126,14 @@ class SimulatedCamera(BaseCamera):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to get stage position: {e}")
|
print(f"Failed to get stage position: {e}")
|
||||||
pos = {"x": 0, "y": 0}
|
pos = {"x": 0, "y": 0}
|
||||||
return self.generate_image((pos["x"]/10, pos["y"]/10))
|
return self.generate_image((pos["x"] / 10, pos["y"] / 10))
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
self._capture_enabled = True
|
self._capture_enabled = True
|
||||||
self._capture_thread = Thread(target=self._capture_frames)
|
self._capture_thread = Thread(target=self._capture_frames)
|
||||||
self._capture_thread.start()
|
self._capture_thread.start()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, _exc_type, _exc_value, _traceback):
|
def __exit__(self, _exc_type, _exc_value, _traceback):
|
||||||
if self.stream_active:
|
if self.stream_active:
|
||||||
self._capture_enabled = False
|
self._capture_enabled = False
|
||||||
|
|
@ -124,6 +145,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
if self._capture_enabled and self._capture_thread:
|
if self._capture_enabled and self._capture_thread:
|
||||||
return self._capture_thread.is_alive()
|
return self._capture_thread.is_alive()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
mjpeg_stream = MJPEGStreamDescriptor()
|
mjpeg_stream = MJPEGStreamDescriptor()
|
||||||
lores_mjpeg_stream = MJPEGStreamDescriptor()
|
lores_mjpeg_stream = MJPEGStreamDescriptor()
|
||||||
|
|
||||||
|
|
@ -131,17 +153,22 @@ class SimulatedCamera(BaseCamera):
|
||||||
portal = get_blocking_portal(self)
|
portal = get_blocking_portal(self)
|
||||||
while self._capture_enabled:
|
while self._capture_enabled:
|
||||||
time.sleep(self.frame_interval)
|
time.sleep(self.frame_interval)
|
||||||
frame = self.generate_frame()
|
try:
|
||||||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
frame = self.generate_frame()
|
||||||
self.mjpeg_stream.add_frame(jpeg, portal)
|
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||||
jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[1].tobytes()
|
self.mjpeg_stream.add_frame(jpeg, portal)
|
||||||
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
|
jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[
|
||||||
|
1
|
||||||
|
].tobytes()
|
||||||
|
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to capture frame: {e}, retrying...")
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def capture_array(
|
def capture_array(
|
||||||
self,
|
self,
|
||||||
resolution: Literal["main", "full"] = "full",
|
resolution: Literal["main", "full"] = "full",
|
||||||
) -> NDArray:
|
) -> ArrayModel:
|
||||||
"""Acquire one image from the camera and return as an array
|
"""Acquire one image from the camera and return as an array
|
||||||
|
|
||||||
This function will produce a nested list containing an uncompressed RGB image.
|
This function will produce a nested list containing an uncompressed RGB image.
|
||||||
|
|
@ -149,7 +176,7 @@ class SimulatedCamera(BaseCamera):
|
||||||
binary image formats will be added in due course.
|
binary image formats will be added in due course.
|
||||||
"""
|
"""
|
||||||
return self.generate_frame()
|
return self.generate_frame()
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def capture_jpeg(
|
def capture_jpeg(
|
||||||
self,
|
self,
|
||||||
|
|
@ -164,7 +191,9 @@ class SimulatedCamera(BaseCamera):
|
||||||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||||
exif_dict = {
|
exif_dict = {
|
||||||
"Exif": {
|
"Exif": {
|
||||||
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode("utf-8")
|
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
},
|
},
|
||||||
"GPS": {},
|
"GPS": {},
|
||||||
"Interop": {},
|
"Interop": {},
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,20 @@ and return the calibration data.
|
||||||
This module is only intended to be called from the OpenFlexure Microscope
|
This module is only intended to be called from the OpenFlexure Microscope
|
||||||
server, and depends on that server and its underlying LabThings library.
|
server, and depends on that server and its underlying LabThings library.
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
import time
|
import time
|
||||||
from typing import Annotated, Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Sequence, Tuple
|
from typing import (
|
||||||
|
Annotated,
|
||||||
|
Any,
|
||||||
|
Callable,
|
||||||
|
Dict,
|
||||||
|
List,
|
||||||
|
Mapping,
|
||||||
|
NamedTuple,
|
||||||
|
Optional,
|
||||||
|
Sequence,
|
||||||
|
Tuple,
|
||||||
|
)
|
||||||
from fastapi import Depends, HTTPException
|
from fastapi import Depends, HTTPException
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -21,7 +32,10 @@ from camera_stage_mapping.camera_stage_calibration_1d import (
|
||||||
calibrate_backlash_1d,
|
calibrate_backlash_1d,
|
||||||
image_to_stage_displacement_from_1d,
|
image_to_stage_displacement_from_1d,
|
||||||
)
|
)
|
||||||
from labthings_fastapi.dependencies.invocation import InvocationCancelledError, InvocationLogger
|
from labthings_fastapi.dependencies.invocation import (
|
||||||
|
InvocationCancelledError,
|
||||||
|
InvocationLogger,
|
||||||
|
)
|
||||||
from labthings_fastapi.types.numpy import NDArray, denumpify, DenumpifyingDict
|
from labthings_fastapi.types.numpy import NDArray, denumpify, DenumpifyingDict
|
||||||
from labthings_fastapi.decorators import thing_action, thing_property
|
from labthings_fastapi.decorators import thing_action, thing_property
|
||||||
from labthings_fastapi.thing import Thing
|
from labthings_fastapi.thing import Thing
|
||||||
|
|
@ -32,6 +46,7 @@ from .stage import StageDependency as Stage
|
||||||
CoordinateType = Tuple[float, float, float]
|
CoordinateType = Tuple[float, float, float]
|
||||||
XYCoordinateType = Tuple[float, float]
|
XYCoordinateType = Tuple[float, float]
|
||||||
|
|
||||||
|
|
||||||
class HardwareInterfaceModel(BaseModel):
|
class HardwareInterfaceModel(BaseModel):
|
||||||
move: Callable[[NDArray], None]
|
move: Callable[[NDArray], None]
|
||||||
get_position: Callable[[], NDArray]
|
get_position: Callable[[], NDArray]
|
||||||
|
|
@ -42,7 +57,7 @@ class HardwareInterfaceModel(BaseModel):
|
||||||
|
|
||||||
def downsample(factor: int, image: np.ndarray) -> np.ndarray:
|
def downsample(factor: int, image: np.ndarray) -> np.ndarray:
|
||||||
"""Downsample an image by taking the mean of each nxn region
|
"""Downsample an image by taking the mean of each nxn region
|
||||||
|
|
||||||
This should be very efficient: we calculate the mean of each
|
This should be very efficient: we calculate the mean of each
|
||||||
`factor * factor` square, no interpolation. If the image is
|
`factor * factor` square, no interpolation. If the image is
|
||||||
not an integer multiple of the resampling factor, we discard
|
not an integer multiple of the resampling factor, we discard
|
||||||
|
|
@ -54,43 +69,60 @@ def downsample(factor: int, image: np.ndarray) -> np.ndarray:
|
||||||
new_size = [d // factor for d in image.shape[:2]]
|
new_size = [d // factor for d in image.shape[:2]]
|
||||||
# First, we ensure we have something that's an integer multiple
|
# First, we ensure we have something that's an integer multiple
|
||||||
# of `factor`
|
# of `factor`
|
||||||
cropped = image[:new_size[0] * factor, :new_size[1] * factor, ...]
|
cropped = image[: new_size[0] * factor, : new_size[1] * factor, ...]
|
||||||
reshaped = cropped.reshape(
|
reshaped = cropped.reshape(
|
||||||
(new_size[0], factor, new_size[1], factor) + image.shape[2:]
|
(new_size[0], factor, new_size[1], factor) + image.shape[2:]
|
||||||
)
|
)
|
||||||
return reshaped.mean(axis=(1,3))
|
return reshaped.mean(axis=(1, 3))
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SETTLING_TIME = 0.2
|
||||||
|
|
||||||
|
|
||||||
def make_hardware_interface(
|
def make_hardware_interface(
|
||||||
stage: Stage, camera: Camera, downsample_factor: int = 2
|
stage: Stage, camera: Camera, downsample_factor: int = 2
|
||||||
) -> HardwareInterfaceModel:
|
) -> HardwareInterfaceModel:
|
||||||
"""Construct the functions we need to interface with the hardware"""
|
"""Construct the functions we need to interface with the hardware"""
|
||||||
axes = stage.axis_names
|
axes = stage.axis_names
|
||||||
|
|
||||||
def pos2dict(pos: Sequence[float]) -> Mapping[str, float]:
|
def pos2dict(pos: Sequence[float]) -> Mapping[str, float]:
|
||||||
return {k: p for k, p in zip(axes, pos)}
|
return {k: p for k, p in zip(axes, pos)}
|
||||||
|
|
||||||
def dict2pos(posd: Mapping[str, float]) -> Sequence[float]:
|
def dict2pos(posd: Mapping[str, float]) -> Sequence[float]:
|
||||||
return tuple(posd[k] for k in axes if k in posd)
|
return tuple(posd[k] for k in axes if k in posd)
|
||||||
|
|
||||||
def move(pos: CoordinateType) -> None:
|
def move(pos: CoordinateType) -> None:
|
||||||
current_pos = stage.position
|
current_pos = stage.position
|
||||||
new_pos = pos2dict(pos)
|
new_pos = pos2dict(pos)
|
||||||
displacement = {k: new_pos[k] - current_pos[k] for k in new_pos.keys()}
|
displacement = {k: new_pos[k] - current_pos[k] for k in new_pos.keys()}
|
||||||
stage.move_relative(**displacement)
|
stage.move_relative(**displacement)
|
||||||
|
|
||||||
def get_position() -> CoordinateType:
|
def get_position() -> CoordinateType:
|
||||||
return dict2pos(stage.position)
|
return dict2pos(stage.position)
|
||||||
|
|
||||||
def grab_image() -> np.ndarray:
|
def grab_image() -> np.ndarray:
|
||||||
img = camera.capture_array()
|
img = camera.capture_array()
|
||||||
return downsample(downsample_factor, img)
|
return downsample(downsample_factor, img)
|
||||||
|
|
||||||
def settle() -> None:
|
def settle() -> None:
|
||||||
time.sleep(0.2)
|
time.sleep(DEFAULT_SETTLING_TIME)
|
||||||
try:
|
try:
|
||||||
camera.capture_metadata # This discards frames on a picamera
|
camera.capture_metadata # This discards frames on a picamera
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass # Don't raise an error for other cameras (may consider grabbing a frame)
|
pass # Don't raise an error for other cameras (may consider grabbing a frame)
|
||||||
|
|
||||||
return HardwareInterfaceModel(
|
return HardwareInterfaceModel(
|
||||||
move=move, get_position=get_position, grab_image=grab_image, settle=settle, grab_image_downsampling=downsample_factor
|
move=move,
|
||||||
|
get_position=get_position,
|
||||||
|
grab_image=grab_image,
|
||||||
|
settle=settle,
|
||||||
|
grab_image_downsampling=downsample_factor,
|
||||||
)
|
)
|
||||||
|
|
||||||
HardwareInterfaceDep = Annotated[HardwareInterfaceModel, Depends(make_hardware_interface)]
|
|
||||||
|
HardwareInterfaceDep = Annotated[
|
||||||
|
HardwareInterfaceModel, Depends(make_hardware_interface)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class MoveHistory(NamedTuple):
|
class MoveHistory(NamedTuple):
|
||||||
|
|
@ -100,11 +132,11 @@ class MoveHistory(NamedTuple):
|
||||||
|
|
||||||
class LoggingMoveWrapper:
|
class LoggingMoveWrapper:
|
||||||
"""Wrap a move function, and maintain a log position/time.
|
"""Wrap a move function, and maintain a log position/time.
|
||||||
|
|
||||||
This class is callable, so it doesn't change the signature
|
This class is callable, so it doesn't change the signature
|
||||||
of the function it wraps - it just makes it possible to get
|
of the function it wraps - it just makes it possible to get
|
||||||
a list of all the moves we've made, and how long they took.
|
a list of all the moves we've made, and how long they took.
|
||||||
|
|
||||||
Said list is intended to be useful for calibrating the stage
|
Said list is intended to be useful for calibrating the stage
|
||||||
so we can estimate how long moves will take.
|
so we can estimate how long moves will take.
|
||||||
"""
|
"""
|
||||||
|
|
@ -141,33 +173,39 @@ class CSMUncalibratedError(HTTPException):
|
||||||
(
|
(
|
||||||
"The camera_stage_mapping calibration is not yet available. "
|
"The camera_stage_mapping calibration is not yet available. "
|
||||||
"This probably means you need to run the calibration routine."
|
"This probably means you need to run the calibration routine."
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CameraStageMapper(Thing):
|
class CameraStageMapper(Thing):
|
||||||
"""A Thing to manage mapping between image and stage coordinates"""
|
"""A Thing to manage mapping between image and stage coordinates"""
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_value, traceback):
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
self.thing_settings.write_to_file()
|
self.thing_settings.write_to_file()
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def calibrate_1d(
|
def calibrate_1d(
|
||||||
self,
|
self,
|
||||||
hw: HardwareInterfaceDep,
|
hw: HardwareInterfaceDep,
|
||||||
stage: Stage,
|
stage: Stage,
|
||||||
logger: InvocationLogger,
|
logger: InvocationLogger,
|
||||||
direction: Tuple[float, float, float],
|
direction: Tuple[float, float, float],
|
||||||
) -> DenumpifyingDict:
|
) -> DenumpifyingDict:
|
||||||
"""Move a microscope's stage in 1D, and figure out the relationship with the camera"""
|
"""Move a microscope's stage in 1D, and figure out the relationship with the camera"""
|
||||||
move = LoggingMoveWrapper(hw.move) # log positions and times for stage calibration
|
move = LoggingMoveWrapper(
|
||||||
|
hw.move
|
||||||
|
) # log positions and times for stage calibration
|
||||||
tracker = Tracker(hw.grab_image, hw.get_position, settle=hw.settle)
|
tracker = Tracker(hw.grab_image, hw.get_position, settle=hw.settle)
|
||||||
direction_array: np.ndarray = np.array(direction)
|
direction_array: np.ndarray = np.array(direction)
|
||||||
|
|
||||||
starting_position = stage.position
|
starting_position = stage.position
|
||||||
try:
|
try:
|
||||||
result: dict = calibrate_backlash_1d(tracker, move, direction_array, logger=logger)
|
result: dict = calibrate_backlash_1d(
|
||||||
|
tracker, move, direction_array, logger=logger
|
||||||
|
)
|
||||||
except InvocationCancelledError as e:
|
except InvocationCancelledError as e:
|
||||||
logger.info("Returning to starting position")
|
logger.info("Returning to starting position")
|
||||||
stage.move_absolute(**starting_position, block_cancellation=True)
|
stage.move_absolute(**starting_position, block_cancellation=True)
|
||||||
|
|
@ -181,7 +219,7 @@ class CameraStageMapper(Thing):
|
||||||
self, hw: HardwareInterfaceDep, stage: Stage, logger: InvocationLogger
|
self, hw: HardwareInterfaceDep, stage: Stage, logger: InvocationLogger
|
||||||
) -> DenumpifyingDict:
|
) -> DenumpifyingDict:
|
||||||
"""Move the microscope's stage in X and Y, to calibrate its relationship to the camera
|
"""Move the microscope's stage in X and Y, to calibrate its relationship to the camera
|
||||||
|
|
||||||
This performs two 1d calibrations in x and y, then combines their results.
|
This performs two 1d calibrations in x and y, then combines their results.
|
||||||
"""
|
"""
|
||||||
logger.info("Calibrating X axis:")
|
logger.info("Calibrating X axis:")
|
||||||
|
|
@ -202,7 +240,7 @@ class CameraStageMapper(Thing):
|
||||||
self.thing_settings["image_resolution"] = corrected_resolution
|
self.thing_settings["image_resolution"] = corrected_resolution
|
||||||
|
|
||||||
csm_matrix = cal_xy["image_to_stage_displacement"]
|
csm_matrix = cal_xy["image_to_stage_displacement"]
|
||||||
csm_as_string = f'[{round(csm_matrix[0][0], 2)}, {round(csm_matrix[0][1], 2)},],[{round(csm_matrix[1][0], 2)}, {round(csm_matrix[1][1], 2)}]'
|
csm_as_string = f"[{round(csm_matrix[0][0], 2)}, {round(csm_matrix[0][1], 2)},],[{round(csm_matrix[1][0], 2)}, {round(csm_matrix[1][1], 2)}]"
|
||||||
logger.info(f"CSM matrix is {csm_as_string}.")
|
logger.info(f"CSM matrix is {csm_as_string}.")
|
||||||
|
|
||||||
data: Dict[str, dict] = {
|
data: Dict[str, dict] = {
|
||||||
|
|
@ -219,14 +257,16 @@ class CameraStageMapper(Thing):
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@thing_property
|
@thing_property
|
||||||
def image_to_stage_displacement_matrix(self) -> Optional[List[List[float]]]: # 2x2 integer array
|
def image_to_stage_displacement_matrix(
|
||||||
|
self,
|
||||||
|
) -> Optional[List[List[float]]]: # 2x2 integer array
|
||||||
"""A 2x2 matrix that converts displacement in image coordinates to stage coordinates.
|
"""A 2x2 matrix that converts displacement in image coordinates to stage coordinates.
|
||||||
|
|
||||||
Note that this matrix is defined using "matrix coordinates", i.e. image coordinates
|
Note that this matrix is defined using "matrix coordinates", i.e. image coordinates
|
||||||
may be (y,x). This is an artifact of the way numpy, opencv, etc. define images. If
|
may be (y,x). This is an artifact of the way numpy, opencv, etc. define images. If
|
||||||
you are making use of this matrix in your own code, you will need to take care of
|
you are making use of this matrix in your own code, you will need to take care of
|
||||||
that conversion.
|
that conversion.
|
||||||
|
|
||||||
It is often helpful to give a concrete example: to make a move in image coordinates
|
It is often helpful to give a concrete example: to make a move in image coordinates
|
||||||
(`dy`, `dx`), where `dx` is horizontal, i.e. the longer dimension of the image, you
|
(`dy`, `dx`), where `dx` is horizontal, i.e. the longer dimension of the image, you
|
||||||
should move the stage by:
|
should move the stage by:
|
||||||
|
|
@ -241,12 +281,12 @@ class CameraStageMapper(Thing):
|
||||||
if not displacement_matrix:
|
if not displacement_matrix:
|
||||||
return None
|
return None
|
||||||
return np.array(displacement_matrix).tolist()
|
return np.array(displacement_matrix).tolist()
|
||||||
|
|
||||||
@thing_property
|
@thing_property
|
||||||
def image_resolution(self) -> Optional[Tuple[float, float]]:
|
def image_resolution(self) -> Optional[Tuple[float, float]]:
|
||||||
"""The image size used to calibrate the image_to_stage_displacement_matrix"""
|
"""The image size used to calibrate the image_to_stage_displacement_matrix"""
|
||||||
return self.thing_settings.get("image_resolution", None)
|
return self.thing_settings.get("image_resolution", None)
|
||||||
|
|
||||||
def assert_calibrated(self):
|
def assert_calibrated(self):
|
||||||
"""Raise an exception if the image_to_stage_displacement matrix is not set"""
|
"""Raise an exception if the image_to_stage_displacement matrix is not set"""
|
||||||
if self.image_to_stage_displacement_matrix is None:
|
if self.image_to_stage_displacement_matrix is None:
|
||||||
|
|
@ -254,10 +294,9 @@ class CameraStageMapper(Thing):
|
||||||
|
|
||||||
@thing_property
|
@thing_property
|
||||||
def last_calibration(self) -> Optional[Dict]:
|
def last_calibration(self) -> Optional[Dict]:
|
||||||
"""The results of the last calibration that was run
|
"""The results of the last calibration that was run"""
|
||||||
"""
|
|
||||||
return self.thing_settings.get("last_calibration", None)
|
return self.thing_settings.get("last_calibration", None)
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def move_in_image_coordinates(
|
def move_in_image_coordinates(
|
||||||
self,
|
self,
|
||||||
|
|
@ -266,7 +305,7 @@ class CameraStageMapper(Thing):
|
||||||
y: float,
|
y: float,
|
||||||
):
|
):
|
||||||
"""Move by a given number of pixels on the camera
|
"""Move by a given number of pixels on the camera
|
||||||
|
|
||||||
NB x and y here refer to what is usually understood to be the horizontal and
|
NB x and y here refer to what is usually understood to be the horizontal and
|
||||||
vertical axes of the image. In many toolkits, "matrix indices" are used, which
|
vertical axes of the image. In many toolkits, "matrix indices" are used, which
|
||||||
swap the order of these coordinates. This includes opencv and PIL. So, don't be
|
swap the order of these coordinates. This includes opencv and PIL. So, don't be
|
||||||
|
|
@ -278,8 +317,7 @@ class CameraStageMapper(Thing):
|
||||||
"""
|
"""
|
||||||
self.assert_calibrated()
|
self.assert_calibrated()
|
||||||
relative_move: np.ndarray = np.dot(
|
relative_move: np.ndarray = np.dot(
|
||||||
np.array([y, x]),
|
np.array([y, x]), np.array(self.image_to_stage_displacement_matrix)
|
||||||
np.array(self.image_to_stage_displacement_matrix)
|
|
||||||
)
|
)
|
||||||
stage.move_relative(x=relative_move[0], y=relative_move[1])
|
stage.move_relative(x=relative_move[0], y=relative_move[1])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ This module provides some settings management across the other Things, and
|
||||||
for code that currently lives in clients but needs to persist settings on
|
for code that currently lives in clients but needs to persist settings on
|
||||||
the server.
|
the server.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from socket import gethostname
|
from socket import gethostname
|
||||||
from typing import Annotated, Any, MutableMapping, Optional, Sequence
|
from typing import Annotated, Any, MutableMapping, Optional, Sequence
|
||||||
|
|
@ -29,10 +30,7 @@ def recursive_update(old_dict: MutableMapping, update: Mapping):
|
||||||
"""Update a dictionary recursively"""
|
"""Update a dictionary recursively"""
|
||||||
for k, v in update.items():
|
for k, v in update.items():
|
||||||
if isinstance(v, Mapping):
|
if isinstance(v, Mapping):
|
||||||
if (
|
if k in old_dict and isinstance(old_dict[k], MutableMapping):
|
||||||
k in old_dict
|
|
||||||
and isinstance(old_dict[k], MutableMapping)
|
|
||||||
):
|
|
||||||
recursive_update(old_dict[k], v)
|
recursive_update(old_dict[k], v)
|
||||||
else:
|
else:
|
||||||
old_dict[k] = dict(**v)
|
old_dict[k] = dict(**v)
|
||||||
|
|
@ -55,11 +53,13 @@ class SettingsManager(Thing):
|
||||||
def external_metadata(self) -> Mapping:
|
def external_metadata(self) -> Mapping:
|
||||||
"""External metadata stored in the server's settings"""
|
"""External metadata stored in the server's settings"""
|
||||||
return self.thing_settings.get("external_metadata", {})
|
return self.thing_settings.get("external_metadata", {})
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def update_external_metadata(self, data: Mapping, key: Optional[str] = None) -> None:
|
def update_external_metadata(
|
||||||
|
self, data: Mapping, key: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
"""Add or replace keys in the external metadata.
|
"""Add or replace keys in the external metadata.
|
||||||
|
|
||||||
The data supplied will be merged into the existing dictionary
|
The data supplied will be merged into the existing dictionary
|
||||||
recursively, i.e. if a key exists, it will be added to rather than
|
recursively, i.e. if a key exists, it will be added to rather than
|
||||||
replaced.
|
replaced.
|
||||||
|
|
@ -80,7 +80,7 @@ class SettingsManager(Thing):
|
||||||
@thing_action
|
@thing_action
|
||||||
def delete_external_metadata(self, key: str) -> None:
|
def delete_external_metadata(self, key: str) -> None:
|
||||||
"""Delete a key from the stored metadata.
|
"""Delete a key from the stored metadata.
|
||||||
|
|
||||||
The key may contain forward slashes, which are understood to separate
|
The key may contain forward slashes, which are understood to separate
|
||||||
levels of the dictionary - i.e. `'a/c'` will remove the `c` key from a
|
levels of the dictionary - i.e. `'a/c'` will remove the `c` key from a
|
||||||
dictionary that looks like: `{'a': {'c': 1}, 'b': {'d': 2}}`
|
dictionary that looks like: `{'a': {'c': 1}, 'b': {'d': 2}}`
|
||||||
|
|
@ -91,11 +91,10 @@ class SettingsManager(Thing):
|
||||||
del subdict[key.split("/")[-1]]
|
del subdict[key.split("/")[-1]]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404, detail="The specified key '{key}' was not found"
|
||||||
detail="The specified key '{key}' was not found"
|
|
||||||
)
|
)
|
||||||
self.thing_settings["external_metadata"] = metadata
|
self.thing_settings["external_metadata"] = metadata
|
||||||
|
|
||||||
@thing_property
|
@thing_property
|
||||||
def microscope_id(self) -> UUID:
|
def microscope_id(self) -> UUID:
|
||||||
"""A unique identifier for this microscope"""
|
"""A unique identifier for this microscope"""
|
||||||
|
|
@ -117,6 +116,7 @@ class SettingsManager(Thing):
|
||||||
def external_metadata_in_state(self) -> Sequence[str]:
|
def external_metadata_in_state(self) -> Sequence[str]:
|
||||||
"""A list of strings that are included in the "state" metadata"""
|
"""A list of strings that are included in the "state" metadata"""
|
||||||
return self.thing_settings.get("external_metadata_in_state", [])
|
return self.thing_settings.get("external_metadata_in_state", [])
|
||||||
|
|
||||||
@external_metadata_in_state.setter
|
@external_metadata_in_state.setter
|
||||||
def external_metadata_in_state(self, keys: Sequence[str]):
|
def external_metadata_in_state(self, keys: Sequence[str]):
|
||||||
"""Set the keys from external metadata that are returned in state"""
|
"""Set the keys from external metadata that are returned in state"""
|
||||||
|
|
@ -140,7 +140,9 @@ class SettingsManager(Thing):
|
||||||
return state
|
return state
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def save_all_thing_settings(self, thing_server: ThingServerDep, logger: InvocationLogger) -> None:
|
def save_all_thing_settings(
|
||||||
|
self, thing_server: ThingServerDep, logger: InvocationLogger
|
||||||
|
) -> None:
|
||||||
"""Ensure all the Things sync their settings to disk.
|
"""Ensure all the Things sync their settings to disk.
|
||||||
|
|
||||||
Normally, each Thing has a `thing_settings` attribute that can be
|
Normally, each Thing has a `thing_settings` attribute that can be
|
||||||
|
|
@ -161,7 +163,4 @@ class SettingsManager(Thing):
|
||||||
f"Could not write {name} settings to disk: permission error."
|
f"Could not write {name} settings to disk: permission error."
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.warning(
|
logger.warning(f"Could not write {name} settings, folder not found")
|
||||||
f"Could not write {name} settings, folder not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -7,10 +7,11 @@ from labthings_fastapi.dependencies.invocation import CancelHook
|
||||||
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
|
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
|
||||||
from collections.abc import Sequence, Mapping
|
from collections.abc import Sequence, Mapping
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class StageProtocol(Protocol):
|
class StageProtocol(Protocol):
|
||||||
"""A protocol for the OpenFlexure translation stage
|
"""A protocol for the OpenFlexure translation stage"""
|
||||||
"""
|
|
||||||
_axis_names: Sequence[str]
|
_axis_names: Sequence[str]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -32,18 +33,28 @@ class StageProtocol(Protocol):
|
||||||
def thing_state(self) -> Mapping[str, Any]:
|
def thing_state(self) -> Mapping[str, Any]:
|
||||||
"""Summary metadata describing the current state of the stage"""
|
"""Summary metadata describing the current state of the stage"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]):
|
def move_relative(
|
||||||
|
self,
|
||||||
|
cancel: CancelHook,
|
||||||
|
block_cancellation: bool = False,
|
||||||
|
**kwargs: Mapping[str, int],
|
||||||
|
):
|
||||||
"""Make a relative move. Keyword arguments should be axis names."""
|
"""Make a relative move. Keyword arguments should be axis names."""
|
||||||
...
|
...
|
||||||
|
|
||||||
def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]):
|
def move_absolute(
|
||||||
|
self,
|
||||||
|
cancel: CancelHook,
|
||||||
|
block_cancellation: bool = False,
|
||||||
|
**kwargs: Mapping[str, int],
|
||||||
|
):
|
||||||
"""Make an absolute move. Keyword arguments should be axis names."""
|
"""Make an absolute move. Keyword arguments should be axis names."""
|
||||||
...
|
...
|
||||||
|
|
||||||
def set_zero_position(self):
|
def set_zero_position(self):
|
||||||
"""Make the current position zero in all axes
|
"""Make the current position zero in all axes
|
||||||
|
|
||||||
This action does not move the stage, but resets the position to zero.
|
This action does not move the stage, but resets the position to zero.
|
||||||
It is intended for use after manually or automatically recentring the
|
It is intended for use after manually or automatically recentring the
|
||||||
stage.
|
stage.
|
||||||
|
|
@ -53,12 +64,13 @@ class StageProtocol(Protocol):
|
||||||
|
|
||||||
class BaseStage(Thing):
|
class BaseStage(Thing):
|
||||||
"""A base stage class for OpenFlexure translation stages
|
"""A base stage class for OpenFlexure translation stages
|
||||||
|
|
||||||
This can't be used directly but should reduce boilerplate code when
|
This can't be used directly but should reduce boilerplate code when
|
||||||
implementing new stages. A minimal working stage must implement
|
implementing new stages. A minimal working stage must implement
|
||||||
`move_relative` and `move_absolute` actions, which update the
|
`move_relative` and `move_absolute` actions, which update the
|
||||||
`position` property on completion, and provide `set_zero_position`.
|
`position` property on completion, and provide `set_zero_position`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_axis_names = ("x", "y", "z")
|
_axis_names = ("x", "y", "z")
|
||||||
|
|
||||||
@thing_property
|
@thing_property
|
||||||
|
|
@ -85,9 +97,7 @@ class BaseStage(Thing):
|
||||||
@property
|
@property
|
||||||
def thing_state(self):
|
def thing_state(self):
|
||||||
"""Summary metadata describing the current state of the stage"""
|
"""Summary metadata describing the current state of the stage"""
|
||||||
return {
|
return {"position": self.position}
|
||||||
"position": self.position
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class StageStub(BaseStage):
|
class StageStub(BaseStage):
|
||||||
|
|
@ -98,20 +108,31 @@ class StageStub(BaseStage):
|
||||||
methods/properties are not decorated as Affordances. This stub class
|
methods/properties are not decorated as Affordances. This stub class
|
||||||
is a workaround for that limitation, and should not be used directly.
|
is a workaround for that limitation, and should not be used directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]):
|
def move_relative(
|
||||||
|
self,
|
||||||
|
cancel: CancelHook,
|
||||||
|
block_cancellation: bool = False,
|
||||||
|
**kwargs: Mapping[str, int],
|
||||||
|
):
|
||||||
"""Make a relative move. Keyword arguments should be axis names."""
|
"""Make a relative move. Keyword arguments should be axis names."""
|
||||||
raise NotImplementedError("StageStub should not be used directly")
|
raise NotImplementedError("StageStub should not be used directly")
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]):
|
def move_absolute(
|
||||||
|
self,
|
||||||
|
cancel: CancelHook,
|
||||||
|
block_cancellation: bool = False,
|
||||||
|
**kwargs: Mapping[str, int],
|
||||||
|
):
|
||||||
"""Make an absolute move. Keyword arguments should be axis names."""
|
"""Make an absolute move. Keyword arguments should be axis names."""
|
||||||
raise NotImplementedError("StageStub should not be used directly")
|
raise NotImplementedError("StageStub should not be used directly")
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def set_zero_position(self):
|
def set_zero_position(self):
|
||||||
"""Make the current position zero in all axes
|
"""Make the current position zero in all axes
|
||||||
|
|
||||||
This action does not move the stage, but resets the position to zero.
|
This action does not move the stage, but resets the position to zero.
|
||||||
It is intended for use after manually or automatically recentring the
|
It is intended for use after manually or automatically recentring the
|
||||||
stage.
|
stage.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from labthings_fastapi.decorators import thing_action
|
from labthings_fastapi.decorators import thing_action
|
||||||
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationCancelledError
|
from labthings_fastapi.dependencies.invocation import (
|
||||||
|
CancelHook,
|
||||||
|
InvocationCancelledError,
|
||||||
|
)
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
@ -9,11 +12,15 @@ from . import BaseStage
|
||||||
|
|
||||||
class DummyStage(BaseStage):
|
class DummyStage(BaseStage):
|
||||||
"""A dummy stage for testing purposes
|
"""A dummy stage for testing purposes
|
||||||
|
|
||||||
This stage should work similarly to a Sangaboard stage, but without any
|
This stage should work similarly to a Sangaboard stage, but without any
|
||||||
hardware attached.
|
hardware attached.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __init__(self, step_time: float = 0.001, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.step_time = step_time
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
self.instantaneous_position = self.position
|
self.instantaneous_position = self.position
|
||||||
|
|
||||||
|
|
@ -21,13 +28,18 @@ class DummyStage(BaseStage):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]):
|
def move_relative(
|
||||||
|
self,
|
||||||
|
cancel: CancelHook,
|
||||||
|
block_cancellation: bool = False,
|
||||||
|
**kwargs: Mapping[str, int],
|
||||||
|
):
|
||||||
"""Make a relative move. Keyword arguments should be axis names."""
|
"""Make a relative move. Keyword arguments should be axis names."""
|
||||||
displacement = [kwargs.get(k, 0) for k in self.axis_names]
|
displacement = [kwargs.get(k, 0) for k in self.axis_names]
|
||||||
self.moving = True
|
self.moving = True
|
||||||
try:
|
try:
|
||||||
fraction_complete = 0.0
|
fraction_complete = 0.0
|
||||||
dt = 0.001
|
dt = self.step_time
|
||||||
max_displacement = max(abs(v) for v in displacement)
|
max_displacement = max(abs(v) for v in displacement)
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
while time.time() - start_time < dt * max_displacement:
|
while time.time() - start_time < dt * max_displacement:
|
||||||
|
|
@ -47,7 +59,7 @@ class DummyStage(BaseStage):
|
||||||
# and to mark the invocation as "cancelled" rather than stopped.
|
# and to mark the invocation as "cancelled" rather than stopped.
|
||||||
raise e
|
raise e
|
||||||
finally:
|
finally:
|
||||||
self.moving=False
|
self.moving = False
|
||||||
self.position = {
|
self.position = {
|
||||||
k: self.position[k] + int(fraction_complete * v)
|
k: self.position[k] + int(fraction_complete * v)
|
||||||
for k, v in zip(self.axis_names, displacement)
|
for k, v in zip(self.axis_names, displacement)
|
||||||
|
|
@ -55,19 +67,26 @@ class DummyStage(BaseStage):
|
||||||
self.instantaneous_position = self.position
|
self.instantaneous_position = self.position
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]):
|
def move_absolute(
|
||||||
|
self,
|
||||||
|
cancel: CancelHook,
|
||||||
|
block_cancellation: bool = False,
|
||||||
|
**kwargs: Mapping[str, int],
|
||||||
|
):
|
||||||
"""Make an absolute move. Keyword arguments should be axis names."""
|
"""Make an absolute move. Keyword arguments should be axis names."""
|
||||||
displacement = {
|
displacement = {
|
||||||
k: int(v) - self.position[k]
|
k: int(v) - self.position[k]
|
||||||
for k, v in kwargs.items()
|
for k, v in kwargs.items()
|
||||||
if k in self.axis_names
|
if k in self.axis_names
|
||||||
}
|
}
|
||||||
self.move_relative(cancel, block_cancellation=block_cancellation, **displacement)
|
self.move_relative(
|
||||||
|
cancel, block_cancellation=block_cancellation, **displacement
|
||||||
|
)
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def set_zero_position(self):
|
def set_zero_position(self):
|
||||||
"""Make the current position zero in all axes
|
"""Make the current position zero in all axes
|
||||||
|
|
||||||
This action does not move the stage, but resets the position to zero.
|
This action does not move the stage, but resets the position to zero.
|
||||||
It is intended for use after manually or automatically recentring the
|
It is intended for use after manually or automatically recentring the
|
||||||
stage.
|
stage.
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,10 @@ class Stitcher(Thing):
|
||||||
self._script = path_to_openflexure_stitch
|
self._script = path_to_openflexure_stitch
|
||||||
|
|
||||||
def run_subprocess(
|
def run_subprocess(
|
||||||
self, logger: InvocationLogger, cmd: list[str],
|
self,
|
||||||
) -> CompletedProcess:
|
logger: InvocationLogger,
|
||||||
|
cmd: list[str],
|
||||||
|
) -> CompletedProcess:
|
||||||
"""Run a subprocess and log any output"""
|
"""Run a subprocess and log any output"""
|
||||||
logger.info(f"Running command in subprocess: `{' '.join(cmd)}")
|
logger.info(f"Running command in subprocess: `{' '.join(cmd)}")
|
||||||
output = run(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True)
|
output = run(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True)
|
||||||
|
|
@ -31,19 +33,26 @@ class Stitcher(Thing):
|
||||||
logger.info(pipe)
|
logger.info(pipe)
|
||||||
output.check_returncode()
|
output.check_returncode()
|
||||||
return output
|
return output
|
||||||
|
|
||||||
def images_folder(self, smart_scan: SmartScanDep, scan_name: Optional[str]=None) -> str:
|
def images_folder(
|
||||||
|
self, smart_scan: SmartScanDep, scan_name: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
scan_folder = smart_scan.scan_folder_path(scan_name=scan_name)
|
scan_folder = smart_scan.scan_folder_path(scan_name=scan_name)
|
||||||
return os.path.join(scan_folder, "images")
|
return os.path.join(scan_folder, "images")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def output_up_to_date(folder: str, output_filename: str, image_prefix: str="image", image_suffix: str=".jpg") -> bool:
|
def output_up_to_date(
|
||||||
|
folder: str,
|
||||||
|
output_filename: str,
|
||||||
|
image_prefix: str = "image",
|
||||||
|
image_suffix: str = ".jpg",
|
||||||
|
) -> bool:
|
||||||
"""Check if any of the images in a folder are newer than a file
|
"""Check if any of the images in a folder are newer than a file
|
||||||
|
|
||||||
If there are no images (files with the prefix and suffix) newer than the
|
If there are no images (files with the prefix and suffix) newer than the
|
||||||
`output_filename`, we return `True`, i.e. the output is up to date. If
|
`output_filename`, we return `True`, i.e. the output is up to date. If
|
||||||
any image in the folder is newer, we return `False`.
|
any image in the folder is newer, we return `False`.
|
||||||
|
|
||||||
This is not flawless logic - if an update process is slow, images might be
|
This is not flawless logic - if an update process is slow, images might be
|
||||||
saved between starting that process and saving the output. Consequently,
|
saved between starting that process and saving the output. Consequently,
|
||||||
a `True` from this function does not guarantee the output is up to date.
|
a `True` from this function does not guarantee the output is up to date.
|
||||||
|
|
@ -61,24 +70,48 @@ class Stitcher(Thing):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def stitch_scan_from_stage(self, logger: InvocationLogger, smart_scan: SmartScanDep, scan_name: Optional[str]=None, downsample: float=1.0) -> JPEGBlob:
|
def stitch_scan_from_stage(
|
||||||
|
self,
|
||||||
|
logger: InvocationLogger,
|
||||||
|
smart_scan: SmartScanDep,
|
||||||
|
scan_name: Optional[str] = None,
|
||||||
|
downsample: float = 1.0,
|
||||||
|
) -> JPEGBlob:
|
||||||
"""Generate a stitched image based on stage position metadata"""
|
"""Generate a stitched image based on stage position metadata"""
|
||||||
output_fname = "stitched_from_stage.jpg"
|
output_fname = "stitched_from_stage.jpg"
|
||||||
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
|
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
|
||||||
if self.output_up_to_date(images_folder, output_fname):
|
if self.output_up_to_date(images_folder, output_fname):
|
||||||
logger.info(f"No images are newer than {output_fname}, skipping.")
|
logger.info(f"No images are newer than {output_fname}, skipping.")
|
||||||
else:
|
else:
|
||||||
self.run_subprocess(logger, [self._script, "--stitching_mode", "only_stage_stitch", images_folder])
|
self.run_subprocess(
|
||||||
|
logger,
|
||||||
|
[self._script, "--stitching_mode", "only_stage_stitch", images_folder],
|
||||||
|
)
|
||||||
return JPEGBlob.from_file(os.path.join(images_folder, output_fname))
|
return JPEGBlob.from_file(os.path.join(images_folder, output_fname))
|
||||||
|
|
||||||
@thing_action
|
|
||||||
def update_scan_correlations(self, logger: InvocationLogger, smart_scan: SmartScanDep, scan_name: Optional[str]=None) -> None:
|
|
||||||
"""Generate a stitched image based on stage position metadata"""
|
|
||||||
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
|
|
||||||
self.run_subprocess(logger, [self._script, "--stitching_mode", "only_correlate", images_folder])
|
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def stitch_scan(self, logger: InvocationLogger, smart_scan: SmartScanDep, scan_name: Optional[str]=None, downsample: float=1.0) -> None:
|
def update_scan_correlations(
|
||||||
|
self,
|
||||||
|
logger: InvocationLogger,
|
||||||
|
smart_scan: SmartScanDep,
|
||||||
|
scan_name: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
"""Generate a stitched image based on stage position metadata"""
|
"""Generate a stitched image based on stage position metadata"""
|
||||||
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
|
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
|
||||||
self.run_subprocess(logger, [self._script, "--stitching_mode", "all", images_folder])
|
self.run_subprocess(
|
||||||
|
logger, [self._script, "--stitching_mode", "only_correlate", images_folder]
|
||||||
|
)
|
||||||
|
|
||||||
|
@thing_action
|
||||||
|
def stitch_scan(
|
||||||
|
self,
|
||||||
|
logger: InvocationLogger,
|
||||||
|
smart_scan: SmartScanDep,
|
||||||
|
scan_name: Optional[str] = None,
|
||||||
|
downsample: float = 1.0,
|
||||||
|
) -> None:
|
||||||
|
"""Generate a stitched image based on stage position metadata"""
|
||||||
|
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
|
||||||
|
self.run_subprocess(
|
||||||
|
logger, [self._script, "--stitching_mode", "all", images_folder]
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ OpenFlexure Microscope system control Thing
|
||||||
|
|
||||||
This module defines a Thing that can shut down or restart the host computer.
|
This module defines a Thing that can shut down or restart the host computer.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
from labthings_fastapi.thing import Thing
|
from labthings_fastapi.thing import Thing
|
||||||
from labthings_fastapi.decorators import thing_action, thing_property
|
from labthings_fastapi.decorators import thing_action, thing_property
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class CommandOutput(BaseModel):
|
class CommandOutput(BaseModel):
|
||||||
output: str
|
output: str
|
||||||
error: str
|
error: str
|
||||||
|
|
@ -16,7 +18,7 @@ class CommandOutput(BaseModel):
|
||||||
|
|
||||||
class SystemControlThing(Thing):
|
class SystemControlThing(Thing):
|
||||||
"""
|
"""
|
||||||
Attempt to shutdown the device
|
Attempt to shutdown the device
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
|
|
@ -39,7 +41,7 @@ class SystemControlThing(Thing):
|
||||||
Checks if we are running on a Raspberry Pi.
|
Checks if we are running on a Raspberry Pi.
|
||||||
"""
|
"""
|
||||||
return os.path.exists("/usr/bin/raspi-config")
|
return os.path.exists("/usr/bin/raspi-config")
|
||||||
|
|
||||||
@thing_action
|
@thing_action
|
||||||
def reboot(self) -> CommandOutput:
|
def reboot(self) -> CommandOutput:
|
||||||
"""Attempt to reboot the device"""
|
"""Attempt to reboot the device"""
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ OpenFlexure Microscope API test Thing
|
||||||
|
|
||||||
This Thing is intended only for use testing out the API and client(s).
|
This Thing is intended only for use testing out the API and client(s).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from labthings_fastapi.thing import Thing
|
from labthings_fastapi.thing import Thing
|
||||||
from labthings_fastapi.decorators import thing_action, thing_property
|
from labthings_fastapi.decorators import thing_action, thing_property
|
||||||
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger
|
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,98 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from labthings_fastapi.client import ThingClient
|
from labthings_fastapi.client import ThingClient
|
||||||
|
from PIL import Image
|
||||||
|
import numpy as np
|
||||||
|
import piexif
|
||||||
|
import pytest
|
||||||
|
|
||||||
from openflexure_microscope_server.server import ThingServer
|
from openflexure_microscope_server.server import ThingServer
|
||||||
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
|
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
|
||||||
from openflexure_microscope_server.things.stage.dummy import DummyStage
|
from openflexure_microscope_server.things.stage.dummy import DummyStage
|
||||||
from openflexure_microscope_server.things.autofocus import AutofocusThing
|
from openflexure_microscope_server.things.autofocus import AutofocusThing
|
||||||
|
from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper
|
||||||
|
from openflexure_microscope_server.things import camera_stage_mapping
|
||||||
|
|
||||||
temp_folder = tempfile.TemporaryDirectory()
|
camera_stage_mapping.DEFAULT_SETTLING_TIME = 0 # skip the settling time for tests
|
||||||
server = ThingServer(temp_folder.name)
|
|
||||||
server.add_thing(SimulatedCamera(), "/camera/")
|
|
||||||
server.add_thing(DummyStage(), "/stage/")
|
|
||||||
server.add_thing(AutofocusThing(), "/autofocus/")
|
|
||||||
|
|
||||||
def test_autofocus():
|
|
||||||
with TestClient(server.app) as client:
|
@pytest.fixture
|
||||||
autofocus = ThingClient.from_url("/autofocus/", client)
|
def thing_server():
|
||||||
_ = autofocus.fast_autofocus()
|
temp_folder = tempfile.TemporaryDirectory()
|
||||||
|
server = ThingServer(settings_folder=temp_folder.name)
|
||||||
|
server.add_thing(
|
||||||
|
SimulatedCamera(
|
||||||
|
shape=(240, 320, 3), canvas_shape=(960, 1240, 3), frame_interval=0.01
|
||||||
|
),
|
||||||
|
"/camera/",
|
||||||
|
)
|
||||||
|
server.add_thing(DummyStage(step_time=0.000001), "/stage/")
|
||||||
|
server.add_thing(AutofocusThing(), "/autofocus/")
|
||||||
|
server.add_thing(CameraStageMapper(), "/camera_stage_mapping/")
|
||||||
|
assert os.path.exists(os.path.join(temp_folder.name, "camera/"))
|
||||||
|
# NB yield is important: otherwise, the temp folder gets deleted before the test runs
|
||||||
|
yield server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(thing_server):
|
||||||
|
with TestClient(thing_server.app) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def slower_client(thing_server):
|
||||||
|
thing_server.things["/stage/"].step_time = 0.0001
|
||||||
|
with TestClient(thing_server.app) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
def test_autofocus(slower_client):
|
||||||
|
client = slower_client
|
||||||
|
autofocus = ThingClient.from_url("/autofocus/", client)
|
||||||
|
_ = autofocus.fast_autofocus()
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_jpeg(client):
|
||||||
|
camera = ThingClient.from_url("/camera/", client)
|
||||||
|
blob = camera.grab_jpeg()
|
||||||
|
_image = Image.open(blob.open())
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_jpeg_metadata(client):
|
||||||
|
camera = ThingClient.from_url("/camera/", client)
|
||||||
|
blob = camera.capture_jpeg()
|
||||||
|
image = Image.open(blob.open())
|
||||||
|
exif_dict = piexif.load(image.info["exif"])
|
||||||
|
encoded_metadata = exif_dict["Exif"][piexif.ExifIFD.UserComment]
|
||||||
|
metadata = json.loads(encoded_metadata)
|
||||||
|
assert "position" in metadata["/stage/"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage(client):
|
||||||
|
stage = ThingClient.from_url("/stage/", client)
|
||||||
|
start = stage.position
|
||||||
|
move = {"x": 1, "y": 2, "z": 3}
|
||||||
|
stage.move_relative(**move)
|
||||||
|
pos = stage.position
|
||||||
|
for s, m, p in zip(start.values(), move.values(), pos.values()):
|
||||||
|
assert s + m == p
|
||||||
|
stage.move_relative(**{k: -v for k, v in move.items()})
|
||||||
|
pos = stage.position
|
||||||
|
for s, p in zip(start.values(), pos.values()):
|
||||||
|
assert s == p
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_array(client):
|
||||||
|
camera = ThingClient.from_url("/camera/", client)
|
||||||
|
array = np.asarray(camera.capture_array())
|
||||||
|
assert array.shape == (240, 320, 3)
|
||||||
|
|
||||||
|
|
||||||
|
# Currently this fails, not yet sure why.
|
||||||
|
def test_camera_stage_mapping_calibration(client):
|
||||||
|
camera_stage_mapping = ThingClient.from_url("/camera_stage_mapping/", client)
|
||||||
|
camera_stage_mapping.calibrate_xy()
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
## Creating releases
|
## Creating releases
|
||||||
|
|
||||||
* JS client is coupled to the API, and so are no longer separately built and deployed.
|
* JS client is coupled to the API, and is built and distributed with the server.
|
||||||
* See [openflexure-microscope-server/README.md](https://gitlab.com/openflexure/openflexure-microscope-server/-/blob/master/README.md) for details on creating new releases
|
* See [openflexure-microscope-server/README.md](https://gitlab.com/openflexure/openflexure-microscope-server/-/blob/master/README.md) for details on creating new releases
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue