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:
|
||||
- prepare
|
||||
- analysis
|
||||
- testing
|
||||
- build
|
||||
- package
|
||||
- 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
|
||||
.python:
|
||||
image: balenalib/raspberry-pi:buster
|
||||
image: registry.gitlab.com/openflexure/openflexure-microscope-server/testimage-python
|
||||
retry: 1
|
||||
before_script:
|
||||
- apt-get update
|
||||
- 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
|
||||
- python3 -m venv .venv # NB this does not overwrite an existing cached venv
|
||||
- python --version
|
||||
- python -m venv .venv # NB this does not overwrite an existing cached venv
|
||||
- source .venv/bin/activate
|
||||
- pip install pipenv # Should be cached after first run
|
||||
- pipenv install --dev --deploy # Should be cached after first run - will just check packages are present
|
||||
- pip install -e .[dev]
|
||||
cache:
|
||||
key: "${CI_COMMIT_REF_SLUG}"
|
||||
paths:
|
||||
|
|
@ -42,57 +58,66 @@ stages:
|
|||
- tags
|
||||
- web
|
||||
|
||||
# Python static analysis with PyLint
|
||||
pylint:
|
||||
# Python formatting and static analysis with ruff
|
||||
ruff:
|
||||
stage: analysis
|
||||
extends: .python
|
||||
script:
|
||||
- poe pylint
|
||||
- ruff check
|
||||
- ruff format --check
|
||||
|
||||
# Python type checking with Mypy
|
||||
mypy:
|
||||
stage: analysis
|
||||
extends: .python
|
||||
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:
|
||||
reports:
|
||||
codequality: codequality.json
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: openflexure_microscope/cobertura.xml
|
||||
|
||||
# Python style analysis with Black
|
||||
black:
|
||||
stage: analysis
|
||||
allow_failure: true
|
||||
extends: .python
|
||||
script:
|
||||
- poe black_check
|
||||
path: mypy/cobertura.xml
|
||||
|
||||
# Python unit tests with PyTest
|
||||
pytest:
|
||||
stage: testing
|
||||
extends: .python
|
||||
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:
|
||||
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:
|
||||
- "./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)
|
||||
eslint:
|
||||
|
|
@ -115,86 +140,86 @@ build:
|
|||
paths:
|
||||
- "src/openflexure_microscope/static/"
|
||||
|
||||
# Package application into distribution tarball
|
||||
package:
|
||||
stage: package
|
||||
dependencies:
|
||||
- build
|
||||
- openapi
|
||||
# # Package application into distribution tarball
|
||||
# package:
|
||||
# stage: package
|
||||
# dependencies:
|
||||
# - build
|
||||
# - openapi
|
||||
|
||||
image: ubuntu:latest
|
||||
# image: ubuntu:latest
|
||||
|
||||
script:
|
||||
# Build distribution archive
|
||||
- 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-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
|
||||
- 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-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
|
||||
# script:
|
||||
# # Build distribution archive
|
||||
# - 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-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
|
||||
# - 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-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
|
||||
|
||||
artifacts:
|
||||
name: "dist"
|
||||
expire_in: 1 week
|
||||
paths:
|
||||
- "./dist/"
|
||||
# artifacts:
|
||||
# name: "dist"
|
||||
# expire_in: 1 week
|
||||
# paths:
|
||||
# - "./dist/"
|
||||
|
||||
only:
|
||||
- master
|
||||
- merge_requests
|
||||
- tags
|
||||
- web
|
||||
# only:
|
||||
# - master
|
||||
# - merge_requests
|
||||
# - tags
|
||||
# - web
|
||||
|
||||
# Deploy to builds.openflexure.org
|
||||
deploy:
|
||||
stage: deploy
|
||||
dependencies:
|
||||
- package
|
||||
# # Deploy to builds.openflexure.org
|
||||
# deploy:
|
||||
# stage: deploy
|
||||
# dependencies:
|
||||
# - package
|
||||
|
||||
image: ubuntu:latest
|
||||
# image: ubuntu:latest
|
||||
|
||||
before_script:
|
||||
- "which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )"
|
||||
- eval $(ssh-agent -s)
|
||||
- ssh-add <(echo "$SSH_PRIVATE_KEY_BATH_OPENFLEXURE_BASE64" | base64 --decode)
|
||||
- mkdir -p ~/.ssh
|
||||
- echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config
|
||||
# before_script:
|
||||
# - "which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )"
|
||||
# - eval $(ssh-agent -s)
|
||||
# - ssh-add <(echo "$SSH_PRIVATE_KEY_BATH_OPENFLEXURE_BASE64" | base64 --decode)
|
||||
# - mkdir -p ~/.ssh
|
||||
# - echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config
|
||||
|
||||
script:
|
||||
# Install rsync if not already installed
|
||||
- "which rsync || ( apt-get update -y && apt-get install rsync -y )"
|
||||
# script:
|
||||
# # Install rsync if not already installed
|
||||
# - "which rsync || ( apt-get update -y && apt-get install rsync -y )"
|
||||
|
||||
# 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
|
||||
# # 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
|
||||
|
||||
# Run update-latest.py on the build server
|
||||
- ssh -t ci-user@openflexure.bath.ac.uk "/var/www/build/update-latest.py"
|
||||
# # Run update-latest.py on the build server
|
||||
# - ssh -t ci-user@openflexure.bath.ac.uk "/var/www/build/update-latest.py"
|
||||
|
||||
only:
|
||||
- tags
|
||||
- web
|
||||
# only:
|
||||
# - tags
|
||||
# - web
|
||||
|
||||
zenodo:
|
||||
stage: deploy
|
||||
image: ubuntu:20.04
|
||||
before_script:
|
||||
- apt-get update -qq
|
||||
- apt-get -y -qq install git python3-pip
|
||||
- pip3 install -r scripts/zenodo/requirements.txt
|
||||
variables:
|
||||
ZENODO_USE_SANDBOX: "false"
|
||||
script:
|
||||
- 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/*
|
||||
artifacts:
|
||||
# this is only a small html link, let's just keep it forever
|
||||
# gitlab doesn't understand "expire_in: never" yet though
|
||||
expire_in: 100000 years
|
||||
name: zenodo-${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-link.html
|
||||
paths:
|
||||
- zenodo-link.html
|
||||
only:
|
||||
- tags
|
||||
- web
|
||||
# zenodo:
|
||||
# stage: deploy
|
||||
# image: ubuntu:20.04
|
||||
# before_script:
|
||||
# - apt-get update -qq
|
||||
# - apt-get -y -qq install git python3-pip
|
||||
# - pip3 install -r scripts/zenodo/requirements.txt
|
||||
# variables:
|
||||
# ZENODO_USE_SANDBOX: "false"
|
||||
# script:
|
||||
# - 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/*
|
||||
# artifacts:
|
||||
# # this is only a small html link, let's just keep it forever
|
||||
# # gitlab doesn't understand "expire_in: never" yet though
|
||||
# expire_in: 100000 years
|
||||
# name: zenodo-${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-link.html
|
||||
# paths:
|
||||
# - zenodo-link.html
|
||||
# only:
|
||||
# - tags
|
||||
# - 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
|
||||
```
|
||||
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
|
||||
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
|
||||
* `cd webapp`
|
||||
* `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`
|
||||
* To auto-format the Javascript code, run
|
||||
* `cd webapp`
|
||||
* `npm run lint`
|
||||
* All commands above
|
||||
* `mypy src` (currently fails)
|
||||
* `pytest`
|
||||
|
||||
|
||||
### Details
|
||||
|
||||
We use several code analysis and formatting libraries in this project. **Please run all of these before submitting a merge request.**
|
||||
|
||||
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`
|
||||
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.
|
||||
|
||||
## 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:
|
||||
|
||||
* 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.
|
||||
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.
|
||||
|
||||
## Creating releases
|
||||
|
||||
* Update the application's internal version number
|
||||
* Edit `setup.py` to update the version number
|
||||
* Git commit and git push
|
||||
* Create a new version tag on GitLab (e.g. `v2.6.11`)
|
||||
* Edit `pyproject.toml` to update the version number
|
||||
* Update the changelog
|
||||
* 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!
|
||||
* 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
|
||||
|
|
@ -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`
|
||||
* `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).
|
||||
If you want to add functions to the microscope software, this is probably the best mechanism to use if it works for you.
|
||||
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`.
|
||||
|
||||
|
||||
```
|
||||
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]
|
||||
dev = [
|
||||
"labthings-fastapi[dev]",
|
||||
"ruff",
|
||||
"mypy[reports]",
|
||||
"mypy-gitlab-code-quality",
|
||||
# "pytest-gitlab-code-quality", # pytest version constraint clashes with labthings
|
||||
"pytest",
|
||||
]
|
||||
pi = [
|
||||
"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
|
||||
# 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]
|
||||
plugins = ["pydantic.mypy"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py39"
|
||||
|
||||
[tool.poe.executor]
|
||||
type = "virtualenv"
|
||||
location = ".venv"
|
||||
|
||||
[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"]
|
||||
[tool.pytest.ini_options]
|
||||
addopts = [
|
||||
"--import-mode=importlib",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Copyright (c) 2018 German Aerospace Center (DLR). All rights reserved.
|
|||
SPDX-License-Identifier: MIT-DLR
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ class Zenodo:
|
|||
self.zenodo_url = "https://zenodo.org/api/deposit/depositions"
|
||||
|
||||
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"}
|
||||
r = requests.post(
|
||||
|
|
@ -35,7 +36,7 @@ class Zenodo:
|
|||
return r.json()
|
||||
|
||||
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"}
|
||||
r = requests.put(
|
||||
|
|
@ -48,7 +49,7 @@ class Zenodo:
|
|||
print(r.json())
|
||||
|
||||
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)
|
||||
data = {"filename": file_name}
|
||||
|
|
@ -63,7 +64,7 @@ class Zenodo:
|
|||
print(r.json())
|
||||
|
||||
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(
|
||||
self.zenodo_url + "/{}/actions/publish".format(deposition_id),
|
||||
|
|
@ -73,7 +74,7 @@ class Zenodo:
|
|||
print(r.json())
|
||||
|
||||
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(
|
||||
self.zenodo_url + "/{}/actions/newversion".format(deposition_id),
|
||||
|
|
@ -84,7 +85,7 @@ class Zenodo:
|
|||
return os.path.basename(r.json()["links"]["latest_draft"])
|
||||
|
||||
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(
|
||||
self.zenodo_url + "/{}/files".format(deposition_id),
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ import os
|
|||
|
||||
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")
|
||||
|
||||
|
||||
def configure_logging():
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.INFO)
|
||||
|
|
@ -14,15 +15,13 @@ def configure_logging():
|
|||
if not os.path.exists(OFM_LOG_FOLDER):
|
||||
os.makedirs(OFM_LOG_FOLDER)
|
||||
handler = RotatingFileHandler(
|
||||
filename = OFM_LOG_FILE,
|
||||
mode = "a",
|
||||
maxBytes = 1000000,
|
||||
backupCount = 10,
|
||||
filename=OFM_LOG_FILE,
|
||||
mode="a",
|
||||
maxBytes=1000000,
|
||||
backupCount=10,
|
||||
)
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"[%(asctime)s] [%(levelname)s] %(message)s"
|
||||
)
|
||||
logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s")
|
||||
)
|
||||
root_logger.addHandler(handler)
|
||||
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)
|
||||
else:
|
||||
raise e
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from socket import gethostname
|
|||
|
||||
def add_v2_endpoints(thing_server: ThingServer):
|
||||
app = thing_server.app
|
||||
|
||||
# TODO: update openflexure connect to make this unnecessary!!
|
||||
# The endpoints below fool OpenFlexure Connect into thinking we are a
|
||||
# v2 microscope, so we show up correctly.
|
||||
|
|
@ -15,7 +16,7 @@ def add_v2_endpoints(thing_server: ThingServer):
|
|||
fake_routes = [
|
||||
"/api/v2/",
|
||||
"/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}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,22 +4,23 @@ from fastapi import FastAPI
|
|||
import os
|
||||
import pathlib
|
||||
|
||||
|
||||
def add_static_file(app: FastAPI, fname: str, folder: str):
|
||||
print(f"Adding route for /{fname}")
|
||||
p=os.path.join(folder, fname)
|
||||
app.get(
|
||||
f"/{fname}",
|
||||
response_class=FileResponse,
|
||||
include_in_schema=False
|
||||
)(lambda: FileResponse(p))
|
||||
p = os.path.join(folder, fname)
|
||||
app.get(f"/{fname}", response_class=FileResponse, include_in_schema=False)(
|
||||
lambda: FileResponse(p)
|
||||
)
|
||||
|
||||
|
||||
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/")
|
||||
#TODO: don't hard code this!
|
||||
# TODO: don't hard code this!
|
||||
search_paths = [
|
||||
"/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__:
|
||||
search_paths.append(pathlib.Path(__file__).parent.parent / "static")
|
||||
|
|
@ -34,6 +35,7 @@ def add_static_files(app: FastAPI):
|
|||
@app.get("/", response_class=RedirectResponse)
|
||||
async def redirect_fastapi():
|
||||
return "/index.html"
|
||||
|
||||
for fname in os.listdir(static_path):
|
||||
fpath = os.path.join(static_path, fname)
|
||||
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/")
|
||||
AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/")
|
||||
|
||||
|
||||
class RecentringThing(Thing):
|
||||
@thing_action
|
||||
def recentre(
|
||||
|
|
@ -27,7 +28,7 @@ class RecentringThing(Thing):
|
|||
corresponds to the centre of the stage. This exploits the
|
||||
fact that the OpenFlexure stage moves in an arc, i.e. its
|
||||
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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ camera together to perform an autofocus routine.
|
|||
|
||||
See repository root for licensing information.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from contextlib import contextmanager
|
||||
import logging
|
||||
|
|
@ -14,8 +15,6 @@ from typing import Annotated, Mapping, Optional, Sequence
|
|||
from fastapi import Depends
|
||||
|
||||
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.decorators import thing_action
|
||||
from labthings_fastapi.types.numpy import NDArray
|
||||
|
|
@ -28,8 +27,10 @@ from pydantic import BaseModel
|
|||
|
||||
### Autofocus utilities
|
||||
|
||||
|
||||
class JPEGSharpnessMonitor:
|
||||
__globals__ = globals() # Required for FastAPI dependency
|
||||
|
||||
def __init__(self, stage: Stage, camera: Camera, portal: BlockingPortal):
|
||||
self.camera = camera
|
||||
self.stage = stage
|
||||
|
|
@ -50,7 +51,7 @@ class JPEGSharpnessMonitor:
|
|||
self.jpeg_sizes.append(len(frame))
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
|
||||
@contextmanager
|
||||
def run(self):
|
||||
"""Context manager, during which we will monitor sharpness from the camera"""
|
||||
|
|
@ -75,7 +76,7 @@ class JPEGSharpnessMonitor:
|
|||
# Index of the data for this movement
|
||||
data_index: int = len(self.stage_positions) - 2
|
||||
# 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
|
||||
|
||||
def move_data(
|
||||
|
|
@ -86,12 +87,10 @@ class JPEGSharpnessMonitor:
|
|||
istop = istart + 2
|
||||
jpeg_times: np.ndarray = np.array(self.jpeg_times)
|
||||
jpeg_sizes: np.ndarray = np.array(self.jpeg_sizes)
|
||||
stage_times: np.ndarray = np.array(self.stage_times)[
|
||||
istart:istop
|
||||
]
|
||||
stage_times: np.ndarray = np.array(self.stage_times)[istart:istop]
|
||||
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:
|
||||
start: int = int(np.argmax(jpeg_times > stage_times[0]))
|
||||
stop: int = int(np.argmax(jpeg_times > stage_times[1]))
|
||||
|
|
@ -130,6 +129,7 @@ class JPEGSharpnessMonitor:
|
|||
|
||||
SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()]
|
||||
|
||||
|
||||
class SharpnessDataArrays(BaseModel):
|
||||
jpeg_times: NDArray
|
||||
jpeg_sizes: NDArray
|
||||
|
|
@ -142,18 +142,18 @@ class AutofocusThing(Thing):
|
|||
def fast_autofocus(
|
||||
self,
|
||||
m: SharpnessMonitorDep,
|
||||
dz: int=2000,
|
||||
start: str='centre',
|
||||
dz: int = 2000,
|
||||
start: str = "centre",
|
||||
) -> SharpnessDataArrays:
|
||||
"""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
|
||||
the position where the image was sharpest. We'll then move back down, and
|
||||
finally up to the sharpest point.
|
||||
"""
|
||||
with m.run():
|
||||
# Move to (-dz / 2)
|
||||
if start == 'centre':
|
||||
if start == "centre":
|
||||
m.focus_rel(-dz / 2)
|
||||
# Move to dz while monitoring sharpness
|
||||
# i: Sharpness monitor index for this move
|
||||
|
|
@ -167,16 +167,16 @@ class AutofocusThing(Thing):
|
|||
m.focus_rel(fz - z)
|
||||
# Return all focus data
|
||||
return m.data_dict()
|
||||
|
||||
|
||||
@thing_action
|
||||
def move_and_measure(
|
||||
self,
|
||||
m: SharpnessMonitorDep,
|
||||
dz: Sequence[int],
|
||||
wait: float=0,
|
||||
wait: float = 0,
|
||||
) -> SharpnessDataArrays:
|
||||
"""Make a move (or a series of moves) and monitor sharpness
|
||||
|
||||
|
||||
This method will will make a series of relative moves in z, and
|
||||
return the sharpness (JPEG size) vs time, along with timestamps
|
||||
for the moves. This can be used to calibrate autofocus.
|
||||
|
|
@ -195,9 +195,11 @@ class AutofocusThing(Thing):
|
|||
return m.data_dict()
|
||||
|
||||
@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.
|
||||
|
||||
|
||||
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
|
||||
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():
|
||||
while repeat and attempts < 10:
|
||||
|
||||
if start == 'centre':
|
||||
stage.move_relative(x = 0, y = 0, z = -(backlash + dz / 2))
|
||||
stage.move_relative(x = 0, y = 0, z = backlash)
|
||||
if start == "centre":
|
||||
stage.move_relative(x=0, y=0, z=-(backlash + dz / 2))
|
||||
stage.move_relative(x=0, y=0, z=backlash)
|
||||
|
||||
i, z = m.focus_rel(dz, block_cancellation=True)
|
||||
_, heights, sizes = m.move_data(i)
|
||||
|
||||
|
||||
peak_height = heights[np.argmax(sizes)]
|
||||
height_min = np.min(heights)
|
||||
height_max = np.max(heights)
|
||||
|
|
@ -226,25 +227,27 @@ class AutofocusThing(Thing):
|
|||
or height_max - peak_height < dz / 5
|
||||
):
|
||||
attempts += 1
|
||||
start = 'centre'
|
||||
stage.move_absolute(z = peak_height-backlash)
|
||||
stage.move_absolute(z = peak_height)
|
||||
start = "centre"
|
||||
stage.move_absolute(z=peak_height - backlash)
|
||||
stage.move_absolute(z=peak_height)
|
||||
else:
|
||||
repeat = False
|
||||
stage.move_relative(x = 0, y = 0, z = -(dz+backlash))
|
||||
stage.move_absolute(z = peak_height)
|
||||
stage.move_relative(x=0, y=0, z=-(dz + backlash))
|
||||
stage.move_absolute(z=peak_height)
|
||||
return heights.tolist(), sizes.tolist()
|
||||
|
||||
@thing_action
|
||||
def verify_focus_sharpness(self, sweep_sizes: list, camera: WrappedCamera, threshold: float = 0.95):
|
||||
'''Take the sharpness curve of the autofocus, and the size of the current frame
|
||||
def verify_focus_sharpness(
|
||||
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
|
||||
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)
|
||||
base = np.min(sweep_sizes)
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
|
@ -23,15 +24,14 @@ from labthings_fastapi.types.numpy import NDArray
|
|||
class JPEGBlob(Blob):
|
||||
media_type: str = "image/jpeg"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CameraProtocol(Protocol):
|
||||
"""A Thing representing a camera"""
|
||||
|
||||
def __enter__(self) -> None:
|
||||
...
|
||||
|
||||
def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
|
||||
...
|
||||
def __enter__(self) -> None: ...
|
||||
|
||||
def __exit__(self, _exc_type, _exc_value, _traceback) -> None: ...
|
||||
|
||||
@property
|
||||
def stream_active(self) -> bool:
|
||||
|
|
@ -45,9 +45,8 @@ class CameraProtocol(Protocol):
|
|||
def capture_array(
|
||||
self,
|
||||
resolution: Literal["lores", "main", "full"] = "main",
|
||||
) -> NDArray:
|
||||
...
|
||||
|
||||
) -> NDArray: ...
|
||||
|
||||
def capture_jpeg(
|
||||
self,
|
||||
metadata_getter: GetThingStates,
|
||||
|
|
@ -78,9 +77,10 @@ class CameraProtocol(Protocol):
|
|||
"""Acquire one image from the preview stream and return its size"""
|
||||
...
|
||||
|
||||
|
||||
class BaseCamera(Thing):
|
||||
"""A Thing representing a camera
|
||||
|
||||
|
||||
This is a concrete base class for `Thing`s implementing the `CameraProtocol`.
|
||||
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
|
||||
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 = (
|
||||
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
|
||||
)
|
||||
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)
|
||||
|
||||
@thing_action
|
||||
|
|
@ -129,12 +133,12 @@ class BaseCamera(Thing):
|
|||
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
|
||||
)
|
||||
return portal.call(stream.next_frame_size)
|
||||
|
||||
|
||||
|
||||
class CameraStub(BaseCamera):
|
||||
"""A stub for a camera, to allow dependencies
|
||||
|
||||
|
||||
|
||||
|
||||
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
|
||||
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.
|
||||
"""
|
||||
|
||||
def __enter__(self) -> None:
|
||||
raise NotImplementedError("Cameras must not inherit from CameraStub")
|
||||
|
||||
|
||||
def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
|
||||
raise NotImplementedError("Cameras must not inherit from CameraStub")
|
||||
|
||||
|
|
@ -164,7 +169,7 @@ class CameraStub(BaseCamera):
|
|||
resolution: Literal["lores", "main", "full"] = "main",
|
||||
) -> NDArray:
|
||||
raise NotImplementedError("Cameras must not inherit from CameraStub")
|
||||
|
||||
|
||||
@thing_action
|
||||
def capture_jpeg(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"""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`.
|
||||
|
||||
See repository root for licensing information.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import io
|
||||
import json
|
||||
|
|
@ -26,7 +27,8 @@ from . import BaseCamera, JPEGBlob
|
|||
|
||||
class OpenCVCamera(BaseCamera):
|
||||
"""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._capture_thread: Optional[Thread] = None
|
||||
self._capture_enabled = False
|
||||
|
|
@ -37,7 +39,7 @@ class OpenCVCamera(BaseCamera):
|
|||
self._capture_thread = Thread(target=self._capture_frames)
|
||||
self._capture_thread.start()
|
||||
return self
|
||||
|
||||
|
||||
def __exit__(self, _exc_type, _exc_value, _traceback):
|
||||
if self.stream_active:
|
||||
self._capture_enabled = False
|
||||
|
|
@ -50,6 +52,7 @@ class OpenCVCamera(BaseCamera):
|
|||
if self._capture_enabled and self._capture_thread:
|
||||
return self._capture_thread.is_alive()
|
||||
return False
|
||||
|
||||
mjpeg_stream = MJPEGStreamDescriptor()
|
||||
lores_mjpeg_stream = MJPEGStreamDescriptor()
|
||||
|
||||
|
|
@ -58,11 +61,15 @@ class OpenCVCamera(BaseCamera):
|
|||
while self._capture_enabled:
|
||||
ret, frame = self.cap.read()
|
||||
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
|
||||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||
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)
|
||||
|
||||
@thing_action
|
||||
|
|
@ -78,9 +85,11 @@ class OpenCVCamera(BaseCamera):
|
|||
"""
|
||||
ret, frame = self.cap.read()
|
||||
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
|
||||
|
||||
|
||||
@thing_action
|
||||
def capture_jpeg(
|
||||
self,
|
||||
|
|
@ -95,7 +104,9 @@ class OpenCVCamera(BaseCamera):
|
|||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||
exif_dict = {
|
||||
"Exif": {
|
||||
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode("utf-8")
|
||||
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode(
|
||||
"utf-8"
|
||||
)
|
||||
},
|
||||
"GPS": {},
|
||||
"Interop": {},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ camera together to perform an autofocus routine.
|
|||
|
||||
See repository root for licensing information.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import io
|
||||
import json
|
||||
|
|
@ -23,34 +24,48 @@ from labthings_fastapi.dependencies.metadata import GetThingStates
|
|||
from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor
|
||||
from labthings_fastapi.types.numpy import NDArray
|
||||
from labthings_fastapi.server import ThingServer
|
||||
from pydantic import RootModel
|
||||
|
||||
from . import BaseCamera, JPEGBlob
|
||||
from ..stage import StageProtocol as Stage
|
||||
|
||||
|
||||
class ArrayModel(RootModel):
|
||||
"""A model for an array"""
|
||||
|
||||
root: NDArray
|
||||
|
||||
|
||||
class SimulatedCamera(BaseCamera):
|
||||
"""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
|
||||
_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_enabled = False
|
||||
self.generate_sprites()
|
||||
self.generate_blobs()
|
||||
self.generate_canvas()
|
||||
|
||||
|
||||
def generate_sprites(self):
|
||||
"""Generate sprites to populate the image"""
|
||||
self.sprites = []
|
||||
black = np.zeros(self.glyph_shape, dtype=np.uint8)
|
||||
x = np.arange(black.shape[0])
|
||||
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]:
|
||||
sprite = black.copy()
|
||||
sprite[rr < i] = 255
|
||||
|
|
@ -58,15 +73,15 @@ class SimulatedCamera(BaseCamera):
|
|||
|
||||
def generate_blobs(self, N: int = 1000):
|
||||
"""Generate coordinates of blobs
|
||||
|
||||
|
||||
Blobs are characterised by X, Y, sprite
|
||||
We also generate a KD tree to rapidly find blobs in an image
|
||||
"""
|
||||
self.blobs = np.zeros((N, 3))
|
||||
rng = np.random.default_rng()
|
||||
w = np.max(self.glyph_shape)
|
||||
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[:, 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[:, 2] = rng.choice(len(self.sprites), N)
|
||||
|
||||
def generate_canvas(self):
|
||||
|
|
@ -76,28 +91,34 @@ class SimulatedCamera(BaseCamera):
|
|||
w, h, _ = self.glyph_shape
|
||||
for x, y, sprite in self.blobs:
|
||||
self.canvas[
|
||||
int(x) - w//2:int(x) - w//2 + w,
|
||||
int(y) - h//2:int(y) - h//2 + h,
|
||||
int(x) - w // 2 : int(x) - w // 2 + w,
|
||||
int(y) - h // 2 : int(y) - h // 2 + h,
|
||||
] -= self.sprites[int(sprite)]
|
||||
|
||||
|
||||
def generate_image(self, pos: tuple[int, int]):
|
||||
"""Generate an image with blobs based on supplied coordinates"""
|
||||
cw, ch, _ = self.canvas_shape
|
||||
w, h, _ = self.shape
|
||||
tl = (int(pos[0]) - w//2 - cw//2, int(pos[1]) - h//2 - ch//2)
|
||||
return self.canvas[
|
||||
tuple(slice(tl[i],tl[i] + self.shape[i]) for i in range(2)) + (slice(None),)
|
||||
tl = (int(pos[0]) - w // 2 - cw // 2, int(pos[1]) - h // 2 - ch // 2)
|
||||
image = self.canvas[
|
||||
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):
|
||||
self._server = server
|
||||
return super().attach_to_server(server, path)
|
||||
|
||||
|
||||
def get_stage_position(self):
|
||||
if not self._stage and self._server:
|
||||
self._stage = self._server.things["/stage/"]
|
||||
return self._stage.instantaneous_position
|
||||
|
||||
|
||||
def generate_frame(self):
|
||||
"""Generate a frame with blobs based on the stage coordinates"""
|
||||
try:
|
||||
|
|
@ -105,14 +126,14 @@ class SimulatedCamera(BaseCamera):
|
|||
except Exception as e:
|
||||
print(f"Failed to get stage position: {e}")
|
||||
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):
|
||||
self._capture_enabled = True
|
||||
self._capture_thread = Thread(target=self._capture_frames)
|
||||
self._capture_thread.start()
|
||||
return self
|
||||
|
||||
|
||||
def __exit__(self, _exc_type, _exc_value, _traceback):
|
||||
if self.stream_active:
|
||||
self._capture_enabled = False
|
||||
|
|
@ -124,6 +145,7 @@ class SimulatedCamera(BaseCamera):
|
|||
if self._capture_enabled and self._capture_thread:
|
||||
return self._capture_thread.is_alive()
|
||||
return False
|
||||
|
||||
mjpeg_stream = MJPEGStreamDescriptor()
|
||||
lores_mjpeg_stream = MJPEGStreamDescriptor()
|
||||
|
||||
|
|
@ -131,17 +153,22 @@ class SimulatedCamera(BaseCamera):
|
|||
portal = get_blocking_portal(self)
|
||||
while self._capture_enabled:
|
||||
time.sleep(self.frame_interval)
|
||||
frame = self.generate_frame()
|
||||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||
self.mjpeg_stream.add_frame(jpeg, portal)
|
||||
jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[1].tobytes()
|
||||
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
|
||||
try:
|
||||
frame = self.generate_frame()
|
||||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||
self.mjpeg_stream.add_frame(jpeg, 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
|
||||
def capture_array(
|
||||
self,
|
||||
resolution: Literal["main", "full"] = "full",
|
||||
) -> NDArray:
|
||||
) -> ArrayModel:
|
||||
"""Acquire one image from the camera and return as an array
|
||||
|
||||
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.
|
||||
"""
|
||||
return self.generate_frame()
|
||||
|
||||
|
||||
@thing_action
|
||||
def capture_jpeg(
|
||||
self,
|
||||
|
|
@ -164,7 +191,9 @@ class SimulatedCamera(BaseCamera):
|
|||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||
exif_dict = {
|
||||
"Exif": {
|
||||
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode("utf-8")
|
||||
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode(
|
||||
"utf-8"
|
||||
)
|
||||
},
|
||||
"GPS": {},
|
||||
"Interop": {},
|
||||
|
|
|
|||
|
|
@ -10,9 +10,20 @@ and return the calibration data.
|
|||
This module is only intended to be called from the OpenFlexure Microscope
|
||||
server, and depends on that server and its underlying LabThings library.
|
||||
"""
|
||||
import logging
|
||||
|
||||
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
|
||||
|
||||
import numpy as np
|
||||
|
|
@ -21,7 +32,10 @@ from camera_stage_mapping.camera_stage_calibration_1d import (
|
|||
calibrate_backlash_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.decorators import thing_action, thing_property
|
||||
from labthings_fastapi.thing import Thing
|
||||
|
|
@ -32,6 +46,7 @@ from .stage import StageDependency as Stage
|
|||
CoordinateType = Tuple[float, float, float]
|
||||
XYCoordinateType = Tuple[float, float]
|
||||
|
||||
|
||||
class HardwareInterfaceModel(BaseModel):
|
||||
move: Callable[[NDArray], None]
|
||||
get_position: Callable[[], NDArray]
|
||||
|
|
@ -42,7 +57,7 @@ class HardwareInterfaceModel(BaseModel):
|
|||
|
||||
def downsample(factor: int, image: np.ndarray) -> np.ndarray:
|
||||
"""Downsample an image by taking the mean of each nxn region
|
||||
|
||||
|
||||
This should be very efficient: we calculate the mean of each
|
||||
`factor * factor` square, no interpolation. If the image is
|
||||
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]]
|
||||
# First, we ensure we have something that's an integer multiple
|
||||
# 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(
|
||||
(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(
|
||||
stage: Stage, camera: Camera, downsample_factor: int = 2
|
||||
) -> HardwareInterfaceModel:
|
||||
stage: Stage, camera: Camera, downsample_factor: int = 2
|
||||
) -> HardwareInterfaceModel:
|
||||
"""Construct the functions we need to interface with the hardware"""
|
||||
axes = stage.axis_names
|
||||
|
||||
def pos2dict(pos: Sequence[float]) -> Mapping[str, float]:
|
||||
return {k: p for k, p in zip(axes, pos)}
|
||||
|
||||
def dict2pos(posd: Mapping[str, float]) -> Sequence[float]:
|
||||
return tuple(posd[k] for k in axes if k in posd)
|
||||
|
||||
def move(pos: CoordinateType) -> None:
|
||||
current_pos = stage.position
|
||||
new_pos = pos2dict(pos)
|
||||
displacement = {k: new_pos[k] - current_pos[k] for k in new_pos.keys()}
|
||||
stage.move_relative(**displacement)
|
||||
|
||||
def get_position() -> CoordinateType:
|
||||
return dict2pos(stage.position)
|
||||
|
||||
def grab_image() -> np.ndarray:
|
||||
img = camera.capture_array()
|
||||
return downsample(downsample_factor, img)
|
||||
|
||||
def settle() -> None:
|
||||
time.sleep(0.2)
|
||||
time.sleep(DEFAULT_SETTLING_TIME)
|
||||
try:
|
||||
camera.capture_metadata # This discards frames on a picamera
|
||||
except AttributeError:
|
||||
pass # Don't raise an error for other cameras (may consider grabbing a frame)
|
||||
|
||||
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):
|
||||
|
|
@ -100,11 +132,11 @@ class MoveHistory(NamedTuple):
|
|||
|
||||
class LoggingMoveWrapper:
|
||||
"""Wrap a move function, and maintain a log position/time.
|
||||
|
||||
|
||||
This class is callable, so it doesn't change the signature
|
||||
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.
|
||||
|
||||
|
||||
Said list is intended to be useful for calibrating the stage
|
||||
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. "
|
||||
"This probably means you need to run the calibration routine."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CameraStageMapper(Thing):
|
||||
"""A Thing to manage mapping between image and stage coordinates"""
|
||||
|
||||
def __enter__(self):
|
||||
pass
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.thing_settings.write_to_file()
|
||||
|
||||
@thing_action
|
||||
def calibrate_1d(
|
||||
self,
|
||||
self,
|
||||
hw: HardwareInterfaceDep,
|
||||
stage: Stage,
|
||||
logger: InvocationLogger,
|
||||
direction: Tuple[float, float, float],
|
||||
) -> DenumpifyingDict:
|
||||
"""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)
|
||||
direction_array: np.ndarray = np.array(direction)
|
||||
|
||||
starting_position = stage.position
|
||||
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:
|
||||
logger.info("Returning to starting position")
|
||||
stage.move_absolute(**starting_position, block_cancellation=True)
|
||||
|
|
@ -181,7 +219,7 @@ class CameraStageMapper(Thing):
|
|||
self, hw: HardwareInterfaceDep, stage: Stage, logger: InvocationLogger
|
||||
) -> DenumpifyingDict:
|
||||
"""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.
|
||||
"""
|
||||
logger.info("Calibrating X axis:")
|
||||
|
|
@ -202,7 +240,7 @@ class CameraStageMapper(Thing):
|
|||
self.thing_settings["image_resolution"] = corrected_resolution
|
||||
|
||||
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}.")
|
||||
|
||||
data: Dict[str, dict] = {
|
||||
|
|
@ -219,14 +257,16 @@ class CameraStageMapper(Thing):
|
|||
return data
|
||||
|
||||
@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.
|
||||
|
||||
|
||||
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
|
||||
you are making use of this matrix in your own code, you will need to take care of
|
||||
that conversion.
|
||||
|
||||
|
||||
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
|
||||
should move the stage by:
|
||||
|
|
@ -241,12 +281,12 @@ class CameraStageMapper(Thing):
|
|||
if not displacement_matrix:
|
||||
return None
|
||||
return np.array(displacement_matrix).tolist()
|
||||
|
||||
|
||||
@thing_property
|
||||
def image_resolution(self) -> Optional[Tuple[float, float]]:
|
||||
"""The image size used to calibrate the image_to_stage_displacement_matrix"""
|
||||
return self.thing_settings.get("image_resolution", None)
|
||||
|
||||
|
||||
def assert_calibrated(self):
|
||||
"""Raise an exception if the image_to_stage_displacement matrix is not set"""
|
||||
if self.image_to_stage_displacement_matrix is None:
|
||||
|
|
@ -254,10 +294,9 @@ class CameraStageMapper(Thing):
|
|||
|
||||
@thing_property
|
||||
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)
|
||||
|
||||
|
||||
@thing_action
|
||||
def move_in_image_coordinates(
|
||||
self,
|
||||
|
|
@ -266,7 +305,7 @@ class CameraStageMapper(Thing):
|
|||
y: float,
|
||||
):
|
||||
"""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
|
||||
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
|
||||
|
|
@ -278,8 +317,7 @@ class CameraStageMapper(Thing):
|
|||
"""
|
||||
self.assert_calibrated()
|
||||
relative_move: np.ndarray = np.dot(
|
||||
np.array([y, x]),
|
||||
np.array(self.image_to_stage_displacement_matrix)
|
||||
np.array([y, x]), np.array(self.image_to_stage_displacement_matrix)
|
||||
)
|
||||
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
|
||||
the server.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from socket import gethostname
|
||||
from typing import Annotated, Any, MutableMapping, Optional, Sequence
|
||||
|
|
@ -29,10 +30,7 @@ def recursive_update(old_dict: MutableMapping, update: Mapping):
|
|||
"""Update a dictionary recursively"""
|
||||
for k, v in update.items():
|
||||
if isinstance(v, Mapping):
|
||||
if (
|
||||
k in old_dict
|
||||
and isinstance(old_dict[k], MutableMapping)
|
||||
):
|
||||
if k in old_dict and isinstance(old_dict[k], MutableMapping):
|
||||
recursive_update(old_dict[k], v)
|
||||
else:
|
||||
old_dict[k] = dict(**v)
|
||||
|
|
@ -55,11 +53,13 @@ class SettingsManager(Thing):
|
|||
def external_metadata(self) -> Mapping:
|
||||
"""External metadata stored in the server's settings"""
|
||||
return self.thing_settings.get("external_metadata", {})
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
|
||||
The data supplied will be merged into the existing dictionary
|
||||
recursively, i.e. if a key exists, it will be added to rather than
|
||||
replaced.
|
||||
|
|
@ -80,7 +80,7 @@ class SettingsManager(Thing):
|
|||
@thing_action
|
||||
def delete_external_metadata(self, key: str) -> None:
|
||||
"""Delete a key from the stored metadata.
|
||||
|
||||
|
||||
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
|
||||
dictionary that looks like: `{'a': {'c': 1}, 'b': {'d': 2}}`
|
||||
|
|
@ -91,11 +91,10 @@ class SettingsManager(Thing):
|
|||
del subdict[key.split("/")[-1]]
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="The specified key '{key}' was not found"
|
||||
status_code=404, detail="The specified key '{key}' was not found"
|
||||
)
|
||||
self.thing_settings["external_metadata"] = metadata
|
||||
|
||||
|
||||
@thing_property
|
||||
def microscope_id(self) -> UUID:
|
||||
"""A unique identifier for this microscope"""
|
||||
|
|
@ -117,6 +116,7 @@ class SettingsManager(Thing):
|
|||
def external_metadata_in_state(self) -> Sequence[str]:
|
||||
"""A list of strings that are included in the "state" metadata"""
|
||||
return self.thing_settings.get("external_metadata_in_state", [])
|
||||
|
||||
@external_metadata_in_state.setter
|
||||
def external_metadata_in_state(self, keys: Sequence[str]):
|
||||
"""Set the keys from external metadata that are returned in state"""
|
||||
|
|
@ -140,7 +140,9 @@ class SettingsManager(Thing):
|
|||
return state
|
||||
|
||||
@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.
|
||||
|
||||
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."
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.warning(
|
||||
f"Could not write {name} settings, folder not found"
|
||||
)
|
||||
|
||||
logger.warning(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 collections.abc import Sequence, Mapping
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class StageProtocol(Protocol):
|
||||
"""A protocol for the OpenFlexure translation stage
|
||||
"""
|
||||
"""A protocol for the OpenFlexure translation stage"""
|
||||
|
||||
_axis_names: Sequence[str]
|
||||
|
||||
@property
|
||||
|
|
@ -32,18 +33,28 @@ class StageProtocol(Protocol):
|
|||
def thing_state(self) -> Mapping[str, Any]:
|
||||
"""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."""
|
||||
...
|
||||
|
||||
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."""
|
||||
...
|
||||
|
||||
|
||||
def set_zero_position(self):
|
||||
"""Make the current position zero in all axes
|
||||
|
||||
|
||||
This action does not move the stage, but resets the position to zero.
|
||||
It is intended for use after manually or automatically recentring the
|
||||
stage.
|
||||
|
|
@ -53,12 +64,13 @@ class StageProtocol(Protocol):
|
|||
|
||||
class BaseStage(Thing):
|
||||
"""A base stage class for OpenFlexure translation stages
|
||||
|
||||
|
||||
This can't be used directly but should reduce boilerplate code when
|
||||
implementing new stages. A minimal working stage must implement
|
||||
`move_relative` and `move_absolute` actions, which update the
|
||||
`position` property on completion, and provide `set_zero_position`.
|
||||
"""
|
||||
|
||||
_axis_names = ("x", "y", "z")
|
||||
|
||||
@thing_property
|
||||
|
|
@ -85,9 +97,7 @@ class BaseStage(Thing):
|
|||
@property
|
||||
def thing_state(self):
|
||||
"""Summary metadata describing the current state of the stage"""
|
||||
return {
|
||||
"position": self.position
|
||||
}
|
||||
return {"position": self.position}
|
||||
|
||||
|
||||
class StageStub(BaseStage):
|
||||
|
|
@ -98,20 +108,31 @@ class StageStub(BaseStage):
|
|||
methods/properties are not decorated as Affordances. This stub class
|
||||
is a workaround for that limitation, and should not be used directly.
|
||||
"""
|
||||
|
||||
@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."""
|
||||
raise NotImplementedError("StageStub should not be used directly")
|
||||
|
||||
@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."""
|
||||
raise NotImplementedError("StageStub should not be used directly")
|
||||
|
||||
@thing_action
|
||||
def set_zero_position(self):
|
||||
"""Make the current position zero in all axes
|
||||
|
||||
|
||||
This action does not move the stage, but resets the position to zero.
|
||||
It is intended for use after manually or automatically recentring the
|
||||
stage.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from __future__ import annotations
|
||||
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
|
||||
import time
|
||||
|
||||
|
|
@ -9,11 +12,15 @@ from . import BaseStage
|
|||
|
||||
class DummyStage(BaseStage):
|
||||
"""A dummy stage for testing purposes
|
||||
|
||||
|
||||
This stage should work similarly to a Sangaboard stage, but without any
|
||||
hardware attached.
|
||||
"""
|
||||
|
||||
def __init__(self, step_time: float = 0.001, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.step_time = step_time
|
||||
|
||||
def __enter__(self):
|
||||
self.instantaneous_position = self.position
|
||||
|
||||
|
|
@ -21,13 +28,18 @@ class DummyStage(BaseStage):
|
|||
pass
|
||||
|
||||
@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."""
|
||||
displacement = [kwargs.get(k, 0) for k in self.axis_names]
|
||||
self.moving = True
|
||||
try:
|
||||
fraction_complete = 0.0
|
||||
dt = 0.001
|
||||
dt = self.step_time
|
||||
max_displacement = max(abs(v) for v in displacement)
|
||||
start_time = time.time()
|
||||
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.
|
||||
raise e
|
||||
finally:
|
||||
self.moving=False
|
||||
self.moving = False
|
||||
self.position = {
|
||||
k: self.position[k] + int(fraction_complete * v)
|
||||
for k, v in zip(self.axis_names, displacement)
|
||||
|
|
@ -55,19 +67,26 @@ class DummyStage(BaseStage):
|
|||
self.instantaneous_position = self.position
|
||||
|
||||
@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."""
|
||||
displacement = {
|
||||
k: int(v) - self.position[k]
|
||||
k: int(v) - self.position[k]
|
||||
for k, v in kwargs.items()
|
||||
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
|
||||
def set_zero_position(self):
|
||||
"""Make the current position zero in all axes
|
||||
|
||||
|
||||
This action does not move the stage, but resets the position to zero.
|
||||
It is intended for use after manually or automatically recentring the
|
||||
stage.
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ class Stitcher(Thing):
|
|||
self._script = path_to_openflexure_stitch
|
||||
|
||||
def run_subprocess(
|
||||
self, logger: InvocationLogger, cmd: list[str],
|
||||
) -> CompletedProcess:
|
||||
self,
|
||||
logger: InvocationLogger,
|
||||
cmd: list[str],
|
||||
) -> CompletedProcess:
|
||||
"""Run a subprocess and log any output"""
|
||||
logger.info(f"Running command in subprocess: `{' '.join(cmd)}")
|
||||
output = run(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True)
|
||||
|
|
@ -31,19 +33,26 @@ class Stitcher(Thing):
|
|||
logger.info(pipe)
|
||||
output.check_returncode()
|
||||
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)
|
||||
return os.path.join(scan_folder, "images")
|
||||
|
||||
|
||||
@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
|
||||
|
||||
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
|
||||
any image in the folder is newer, we return `False`.
|
||||
|
||||
|
||||
This is not flawless logic - if an update process is slow, images might be
|
||||
saved between starting that process and saving the output. Consequently,
|
||||
a `True` from this function does not guarantee the output is up to date.
|
||||
|
|
@ -61,24 +70,48 @@ class Stitcher(Thing):
|
|||
return True
|
||||
|
||||
@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"""
|
||||
output_fname = "stitched_from_stage.jpg"
|
||||
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
|
||||
if self.output_up_to_date(images_folder, output_fname):
|
||||
logger.info(f"No images are newer than {output_fname}, skipping.")
|
||||
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))
|
||||
|
||||
@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
|
||||
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"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import os
|
||||
from labthings_fastapi.thing import Thing
|
||||
from labthings_fastapi.decorators import thing_action, thing_property
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CommandOutput(BaseModel):
|
||||
output: str
|
||||
error: str
|
||||
|
|
@ -16,7 +18,7 @@ class CommandOutput(BaseModel):
|
|||
|
||||
class SystemControlThing(Thing):
|
||||
"""
|
||||
Attempt to shutdown the device
|
||||
Attempt to shutdown the device
|
||||
"""
|
||||
|
||||
@thing_action
|
||||
|
|
@ -39,7 +41,7 @@ class SystemControlThing(Thing):
|
|||
Checks if we are running on a Raspberry Pi.
|
||||
"""
|
||||
return os.path.exists("/usr/bin/raspi-config")
|
||||
|
||||
|
||||
@thing_action
|
||||
def reboot(self) -> CommandOutput:
|
||||
"""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).
|
||||
"""
|
||||
|
||||
from labthings_fastapi.thing import Thing
|
||||
from labthings_fastapi.decorators import thing_action, thing_property
|
||||
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger
|
||||
|
|
|
|||
|
|
@ -1,22 +1,98 @@
|
|||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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.things.camera.simulation import SimulatedCamera
|
||||
from openflexure_microscope_server.things.stage.dummy import DummyStage
|
||||
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()
|
||||
server = ThingServer(temp_folder.name)
|
||||
server.add_thing(SimulatedCamera(), "/camera/")
|
||||
server.add_thing(DummyStage(), "/stage/")
|
||||
server.add_thing(AutofocusThing(), "/autofocus/")
|
||||
camera_stage_mapping.DEFAULT_SETTLING_TIME = 0 # skip the settling time for tests
|
||||
|
||||
def test_autofocus():
|
||||
with TestClient(server.app) as client:
|
||||
autofocus = ThingClient.from_url("/autofocus/", client)
|
||||
_ = autofocus.fast_autofocus()
|
||||
|
||||
@pytest.fixture
|
||||
def thing_server():
|
||||
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
|
||||
|
||||
* 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
|
||||
|
||||
## Installing
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue