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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,8 +142,8 @@ 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
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ class AutofocusThing(Thing):
|
|||
"""
|
||||
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
|
||||
|
|
@ -173,7 +173,7 @@ class AutofocusThing(Thing):
|
|||
self,
|
||||
m: SharpnessMonitorDep,
|
||||
dz: Sequence[int],
|
||||
wait: float=0,
|
||||
wait: float = 0,
|
||||
) -> SharpnessDataArrays:
|
||||
"""Make a move (or a series of moves) and monitor sharpness
|
||||
|
||||
|
|
@ -195,7 +195,9 @@ 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
|
||||
|
|
@ -209,10 +211,9 @@ 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)
|
||||
|
|
@ -226,22 +227,24 @@ 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)
|
||||
|
|
|
|||
|
|
@ -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 __enter__(self) -> None: ...
|
||||
|
||||
def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
|
||||
...
|
||||
def __exit__(self, _exc_type, _exc_value, _traceback) -> None: ...
|
||||
|
||||
@property
|
||||
def stream_active(self) -> bool:
|
||||
|
|
@ -45,8 +45,7 @@ class CameraProtocol(Protocol):
|
|||
def capture_array(
|
||||
self,
|
||||
resolution: Literal["lores", "main", "full"] = "main",
|
||||
) -> NDArray:
|
||||
...
|
||||
) -> NDArray: ...
|
||||
|
||||
def capture_jpeg(
|
||||
self,
|
||||
|
|
@ -78,6 +77,7 @@ class CameraProtocol(Protocol):
|
|||
"""Acquire one image from the preview stream and return its size"""
|
||||
...
|
||||
|
||||
|
||||
class BaseCamera(Thing):
|
||||
"""A Thing representing a camera
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -142,6 +146,7 @@ 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")
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ This module defines a camera Thing that uses OpenCV's
|
|||
|
||||
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
|
||||
|
|
@ -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,7 +85,9 @@ 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
|
||||
|
|
@ -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,21 +24,35 @@ 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()
|
||||
|
|
@ -50,7 +65,7 @@ class SimulatedCamera(BaseCamera):
|
|||
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
|
||||
|
|
@ -65,8 +80,8 @@ class SimulatedCamera(BaseCamera):
|
|||
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,18 +91,24 @@ 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
|
||||
|
|
@ -105,7 +126,7 @@ 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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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]
|
||||
|
|
@ -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):
|
||||
|
|
@ -141,14 +173,16 @@ 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()
|
||||
|
||||
|
|
@ -161,13 +195,17 @@ class CameraStageMapper(Thing):
|
|||
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)
|
||||
|
|
@ -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,7 +257,9 @@ 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
|
||||
|
|
@ -254,8 +294,7 @@ 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
|
||||
|
|
@ -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)
|
||||
|
|
@ -57,7 +55,9 @@ class SettingsManager(Thing):
|
|||
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
|
||||
|
|
@ -91,8 +91,7 @@ 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
|
||||
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# ruff: noqa: E722
|
||||
|
||||
import shutil
|
||||
import zipfile
|
||||
import threading
|
||||
|
|
@ -13,19 +15,21 @@ from pydantic import BaseModel
|
|||
from scipy.stats import norm
|
||||
from scipy.ndimage import zoom
|
||||
from scipy.interpolate import interp1d
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from subprocess import CompletedProcess, Popen, PIPE, SubprocessError, run, STDOUT
|
||||
from subprocess import CompletedProcess, Popen, PIPE, SubprocessError, STDOUT
|
||||
from threading import Event, Thread
|
||||
import glob
|
||||
import zipfile
|
||||
import json
|
||||
import piexif
|
||||
|
||||
from labthings_fastapi.thing import Thing
|
||||
from labthings_fastapi.dependencies.metadata import GetThingStates
|
||||
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
|
||||
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger, InvocationCancelledError
|
||||
from labthings_fastapi.dependencies.invocation import (
|
||||
CancelHook,
|
||||
InvocationLogger,
|
||||
InvocationCancelledError,
|
||||
)
|
||||
from labthings_fastapi.decorators import thing_action, thing_property, fastapi_endpoint
|
||||
from labthings_fastapi.outputs.blob import blob_type
|
||||
from .camera import CameraDependency as CamDep
|
||||
|
|
@ -109,19 +113,21 @@ def limit_focus_change(prev_pos, prev_z, new_pos, new_z, limit):
|
|||
return "accept"
|
||||
|
||||
|
||||
def distance_to_site(current, next):
|
||||
current = np.array(current, dtype="float64")
|
||||
next = np.array(next, dtype="float64")
|
||||
if (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2 < 0:
|
||||
print(f"Negative distance between {next} and {current}")
|
||||
return np.sqrt(
|
||||
(next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2, dtype="float64"
|
||||
)
|
||||
# def distance_to_site(current, next):
|
||||
# current = np.array(current, dtype="float64")
|
||||
# next = np.array(next, dtype="float64")
|
||||
# if (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2 < 0:
|
||||
# print(f"Negative distance between {next} and {current}")
|
||||
# return np.sqrt(
|
||||
# (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2, dtype="float64"
|
||||
# )
|
||||
|
||||
|
||||
def steps_from_centre(current_loc, starting_loc, dx, dy):
|
||||
step_size = np.array([dx,dy])
|
||||
step_size = np.array([dx, dy])
|
||||
return np.max(np.abs(np.divide(np.subtract(current_loc, starting_loc), step_size)))
|
||||
|
||||
|
||||
# def set_template(microscope, pos):
|
||||
# microscope.move(pos)
|
||||
# background = microscope.grab_image_array()
|
||||
|
|
@ -145,41 +151,60 @@ def distance_to_site(current, next):
|
|||
current = np.array(current, dtype="float64")
|
||||
return np.sqrt((next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2)
|
||||
|
||||
|
||||
def scale_csm(csm_matrix, calibration_width, img_width):
|
||||
"Account for a calibration width that may differ from image width"
|
||||
scale = img_width / calibration_width # Usually >1, if we calibrated at low res
|
||||
csm = np.array(csm_matrix) / scale # Decrease the CSM if pixels are smaller]
|
||||
return csm
|
||||
|
||||
def generate_config(folder_path: str, positions: list, names: list, camera_to_sample_matrix, csm_calibration_width, img_width, logger):
|
||||
|
||||
def generate_config(
|
||||
folder_path: str,
|
||||
positions: list,
|
||||
names: list,
|
||||
camera_to_sample_matrix,
|
||||
csm_calibration_width,
|
||||
img_width,
|
||||
logger,
|
||||
):
|
||||
positions = np.array(positions)
|
||||
mean_loc = np.mean(positions, axis = 0)
|
||||
mean_loc = np.mean(positions, axis=0)
|
||||
|
||||
#TODO: positions from recent scans need to be 2x bigger - change to CSM res?
|
||||
# TODO: positions from recent scans need to be 2x bigger - change to CSM res?
|
||||
|
||||
camera_to_sample_matrix = scale_csm(camera_to_sample_matrix, csm_calibration_width, img_width)
|
||||
camera_to_sample_matrix = scale_csm(
|
||||
camera_to_sample_matrix, csm_calibration_width, img_width
|
||||
)
|
||||
|
||||
with open(os.path.join(folder_path, 'TileConfiguration.txt'), 'w') as fp:
|
||||
fp.write('# Define the number of dimensions we are working on\ndim = 2\n\n# Define the image coordinates\n')
|
||||
with open(os.path.join(folder_path, "TileConfiguration.txt"), "w") as fp:
|
||||
fp.write(
|
||||
"# Define the number of dimensions we are working on\ndim = 2\n\n# Define the image coordinates\n"
|
||||
)
|
||||
for i in range(len(names)):
|
||||
loc = np.dot((positions[i] - mean_loc), np.linalg.inv(camera_to_sample_matrix))
|
||||
fp.write(f'{names[i]}; ; {loc[1], loc[0]} \n')
|
||||
loc = np.dot(
|
||||
(positions[i] - mean_loc), np.linalg.inv(camera_to_sample_matrix)
|
||||
)
|
||||
fp.write(f"{names[i]}; ; {loc[1], loc[0]} \n")
|
||||
|
||||
|
||||
def raw2rggb(raw):
|
||||
"""Convert packed 10 bit raw to RGGB 8 bit"""
|
||||
raw = np.asarray(raw) # ensure it's an array
|
||||
rggb = np.empty((616, 820, 4), dtype=np.uint8)
|
||||
raw_w = rggb.shape[1]//2*5
|
||||
for plane, offset in enumerate([(1,1), (0,1), (1,0), (0,0)]):
|
||||
rggb[:, ::2, plane] = raw[offset[0]::2, offset[1]:raw_w+offset[1]:5]
|
||||
rggb[:, 1::2, plane] = raw[offset[0]::2, offset[1]+2:raw_w+offset[1]+2:5]
|
||||
raw_w = rggb.shape[1] // 2 * 5
|
||||
for plane, offset in enumerate([(1, 1), (0, 1), (1, 0), (0, 0)]):
|
||||
rggb[:, ::2, plane] = raw[offset[0] :: 2, offset[1] : raw_w + offset[1] : 5]
|
||||
rggb[:, 1::2, plane] = raw[
|
||||
offset[0] :: 2, offset[1] + 2 : raw_w + offset[1] + 2 : 5
|
||||
]
|
||||
return rggb
|
||||
|
||||
|
||||
def rggb2rgb(rggb):
|
||||
return np.stack([rggb[..., 0], rggb[..., 1]//2 + rggb[..., 2]//2, rggb[...,3]], axis=2)
|
||||
return np.stack(
|
||||
[rggb[..., 0], rggb[..., 1] // 2 + rggb[..., 2] // 2, rggb[..., 3]], axis=2
|
||||
)
|
||||
|
||||
|
||||
class ChannelDistributions(BaseModel):
|
||||
|
|
@ -187,6 +212,7 @@ class ChannelDistributions(BaseModel):
|
|||
standard_deviations: list[float]
|
||||
colorspace: str = "LUV"
|
||||
|
||||
|
||||
class BackgroundDetectThing(Thing):
|
||||
@thing_property
|
||||
def background_distributions(self) -> Optional[ChannelDistributions]:
|
||||
|
|
@ -230,10 +256,13 @@ class BackgroundDetectThing(Thing):
|
|||
"""
|
||||
d = self.background_distributions
|
||||
if not d:
|
||||
raise RuntimeError("Background is not set: you need to calibrate background detection.")
|
||||
raise RuntimeError(
|
||||
"Background is not set: you need to calibrate background detection."
|
||||
)
|
||||
return np.all(
|
||||
np.abs(image - np.array(d.means)[np.newaxis, np.newaxis, :])
|
||||
< np.array(d.standard_deviations)[np.newaxis, np.newaxis, :] * self.tolerance,
|
||||
< np.array(d.standard_deviations)[np.newaxis, np.newaxis, :]
|
||||
* self.tolerance,
|
||||
axis=2,
|
||||
)
|
||||
|
||||
|
|
@ -289,9 +318,9 @@ class BackgroundDetectThing(Thing):
|
|||
mu, std = np.apply_along_axis(norm.fit, 0, points)
|
||||
|
||||
self.background_distributions = ChannelDistributions(
|
||||
means = mu.tolist(),
|
||||
standard_deviations = std.tolist(),
|
||||
colorspace = "LUV",
|
||||
means=mu.tolist(),
|
||||
standard_deviations=std.tolist(),
|
||||
colorspace="LUV",
|
||||
)
|
||||
|
||||
@property
|
||||
|
|
@ -304,7 +333,9 @@ class BackgroundDetectThing(Thing):
|
|||
}
|
||||
|
||||
|
||||
BackgroundDep = direct_thing_client_dependency(BackgroundDetectThing, "/background_detect/")
|
||||
BackgroundDep = direct_thing_client_dependency(
|
||||
BackgroundDetectThing, "/background_detect/"
|
||||
)
|
||||
|
||||
|
||||
class NotEnoughFreeSpaceError(IOError):
|
||||
|
|
@ -322,20 +353,20 @@ def ensure_free_disk_space(path: str, min_space: int = 500000000) -> None:
|
|||
|
||||
|
||||
class ScanInfo(BaseModel):
|
||||
""""Summary information about a scan folder"""
|
||||
""" "Summary information about a scan folder"""
|
||||
|
||||
name: str
|
||||
created: datetime
|
||||
modified: datetime
|
||||
number_of_images: int
|
||||
|
||||
|
||||
DOWNLOADABLE_SCAN_FILES = (
|
||||
"images.zip",
|
||||
)
|
||||
DOWNLOADABLE_SCAN_FILES = ("images.zip",)
|
||||
|
||||
JPEGBlob = blob_type("image/jpeg")
|
||||
ZipBlob = blob_type("application/zip")
|
||||
|
||||
|
||||
class SmartScanThing(Thing):
|
||||
def __init__(self, path_to_openflexure_stitch: str):
|
||||
self._script = path_to_openflexure_stitch
|
||||
|
|
@ -352,12 +383,13 @@ class SmartScanThing(Thing):
|
|||
return "scans"
|
||||
|
||||
_latest_scan_name = None
|
||||
|
||||
@thing_property
|
||||
def latest_scan_name(self) -> Optional[str]:
|
||||
"""The name of the last scan to be started."""
|
||||
return self._latest_scan_name
|
||||
|
||||
def scan_folder_path(self, scan_name: Optional[str]=None):
|
||||
def scan_folder_path(self, scan_name: Optional[str] = None):
|
||||
"""The path to the scan folder with a given name"""
|
||||
if not scan_name:
|
||||
if not self.latest_scan_name:
|
||||
|
|
@ -365,7 +397,7 @@ class SmartScanThing(Thing):
|
|||
scan_name = self.latest_scan_name
|
||||
return os.path.join(self.scans_folder_path, scan_name)
|
||||
|
||||
def new_scan_folder(self, scan_name: str="scan") -> str:
|
||||
def new_scan_folder(self, scan_name: str = "scan") -> str:
|
||||
"""Create a new empty folder, into which we can save scan images
|
||||
|
||||
The folder will be named `{scan_name}_000001/` where the number is
|
||||
|
|
@ -389,14 +421,13 @@ class SmartScanThing(Thing):
|
|||
return folder_path
|
||||
raise FileExistsError("Could not create a new scan folder: all names in use!")
|
||||
|
||||
|
||||
def move_to_next_point(
|
||||
self,
|
||||
stage: StageDep,
|
||||
logger: InvocationLogger,
|
||||
path: list[list[int]],
|
||||
focused_path: list[list[int]],
|
||||
) -> list[int]:
|
||||
self,
|
||||
stage: StageDep,
|
||||
logger: InvocationLogger,
|
||||
path: list[list[int]],
|
||||
focused_path: list[list[int]],
|
||||
) -> list[int]:
|
||||
"""Remove the first point from the path, and move there.
|
||||
|
||||
This will move to the next XY position in `path`, taking the `z` value
|
||||
|
|
@ -412,9 +443,7 @@ class SmartScanThing(Thing):
|
|||
else:
|
||||
z = stage.position["z"]
|
||||
logger.info(f"Moving to {loc}")
|
||||
stage.move_absolute(
|
||||
x=int(loc[0]), y=int(loc[1]), z = z - self.autofocus_dz / 2
|
||||
)
|
||||
stage.move_absolute(x=int(loc[0]), y=int(loc[1]), z=z - self.autofocus_dz / 2)
|
||||
return loc + [z]
|
||||
|
||||
@thing_action
|
||||
|
|
@ -429,7 +458,7 @@ class SmartScanThing(Thing):
|
|||
csm: CSMDep,
|
||||
background_detect: BackgroundDep,
|
||||
recentre: RecentreStage,
|
||||
scan_name: str="",
|
||||
scan_name: str = "",
|
||||
):
|
||||
"""Move the stage to cover an area, taking images that can be tiled together.
|
||||
|
||||
|
|
@ -455,14 +484,18 @@ class SmartScanThing(Thing):
|
|||
max_dist = self.max_range
|
||||
|
||||
if self.autofocus_dz == 0:
|
||||
logger.info(f'Running scan without autofocus')
|
||||
logger.info("Running scan without autofocus")
|
||||
elif self.autofocus_dz <= 200:
|
||||
logger.warning(f'Your dz range is {self.autofocus_dz} steps, which is too short to attempt to focus. Running without autofocus')
|
||||
logger.warning(
|
||||
f"Your dz range is {self.autofocus_dz} steps, which is too short to attempt to focus. Running without autofocus"
|
||||
)
|
||||
|
||||
if self.skip_background:
|
||||
d = background_detect.background_distributions
|
||||
if not d:
|
||||
raise RuntimeError("Background is not set: you need to calibrate background detection.")
|
||||
raise RuntimeError(
|
||||
"Background is not set: you need to calibrate background detection."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"This scan will run in a spiral from the starting point "
|
||||
|
|
@ -470,7 +503,7 @@ class SmartScanThing(Thing):
|
|||
"in every direction. Make sure you watch it run to stop it leaving "
|
||||
"the area of interest, or (worse) leading the microscope's range "
|
||||
"of motion."
|
||||
)
|
||||
)
|
||||
names = []
|
||||
positions = []
|
||||
|
||||
|
|
@ -495,14 +528,20 @@ class SmartScanThing(Thing):
|
|||
# TODO: generalise to have 2D displacements for x and y (as the
|
||||
# camera and stage may not be aligned).
|
||||
CSM = csm.image_to_stage_displacement_matrix
|
||||
csm_calibration_width = csm.last_calibration["image_resolution"][1]
|
||||
# csm_calibration_width = csm.last_calibration["image_resolution"][1]
|
||||
|
||||
overlap = self.overlap
|
||||
|
||||
dx = int(np.abs(np.dot(np.array([0, arr.shape[1] * (1 - overlap)]), CSM)[0]))
|
||||
dy = int(np.abs(np.dot(np.array([arr.shape[0] * (1 - overlap), 0]), CSM)[1]))
|
||||
dx = int(
|
||||
np.abs(np.dot(np.array([0, arr.shape[1] * (1 - overlap)]), CSM)[0])
|
||||
)
|
||||
dy = int(
|
||||
np.abs(np.dot(np.array([arr.shape[0] * (1 - overlap), 0]), CSM)[1])
|
||||
)
|
||||
|
||||
logger.info(f"Based on an overlap of {overlap}, we will make steps of {dx}, {dy}")
|
||||
logger.info(
|
||||
f"Based on an overlap of {overlap}, we will make steps of {dx}, {dy}"
|
||||
)
|
||||
|
||||
# construct a 2D scan path
|
||||
path = [[stage.position["x"], stage.position["y"]]]
|
||||
|
|
@ -510,7 +549,6 @@ class SmartScanThing(Thing):
|
|||
focused_path = [] # This holds a list of all points where focus succeeded
|
||||
true_path = [] # This holds a list of all points visited
|
||||
i = 0
|
||||
ids = []
|
||||
start_time = time.strftime("%H_%M_%S-%d_%m_%Y")
|
||||
|
||||
scan_folder = self.new_scan_folder(scan_name)
|
||||
|
|
@ -521,16 +559,18 @@ class SmartScanThing(Thing):
|
|||
logger.info(f"Saving images to {images_folder}")
|
||||
|
||||
data = {
|
||||
'scan_name' : scan_name,
|
||||
'overlap' : overlap,
|
||||
'autofocus range' : self.autofocus_dz,
|
||||
'dx' : dx,
|
||||
'dy' : dy,
|
||||
'start time' : start_time,
|
||||
'skipping background' : self.skip_background
|
||||
"scan_name": scan_name,
|
||||
"overlap": overlap,
|
||||
"autofocus range": self.autofocus_dz,
|
||||
"dx": dx,
|
||||
"dy": dy,
|
||||
"start time": start_time,
|
||||
"skipping background": self.skip_background,
|
||||
}
|
||||
|
||||
with open(os.path.join(images_folder, 'scan_inputs.json'), 'w', encoding='utf-8') as f:
|
||||
with open(
|
||||
os.path.join(images_folder, "scan_inputs.json"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=4)
|
||||
|
||||
# We will capture images and process them with this function, defined once here.
|
||||
|
|
@ -538,29 +578,41 @@ class SmartScanThing(Thing):
|
|||
# that change each iteration.
|
||||
# We also pre-calculate a normalisation image based on the LST and white balance
|
||||
raw_image = cam.capture_array(stream_name="raw")
|
||||
#TODO: assert the image is 10-bit packed, or deal with other formats!
|
||||
# TODO: assert the image is 10-bit packed, or deal with other formats!
|
||||
rgb = rggb2rgb(raw2rggb(raw_image))
|
||||
lst = dict(cam.lens_shading_tables)
|
||||
lum = np.array(lst["luminance"])
|
||||
Cr = np.array(lst["Cr"])
|
||||
Cb = np.array(lst["Cb"])
|
||||
gr, gb = cam.colour_gains
|
||||
G = 1/lum
|
||||
R = G/Cr/gr*np.min(Cr) # The extra /np.max(Cr) emulates the quirky handling of Cr in
|
||||
B = G/Cb/gb*np.min(Cb) # the picamera2 pipeline
|
||||
G = 1 / lum
|
||||
R = (
|
||||
G / Cr / gr * np.min(Cr)
|
||||
) # The extra /np.max(Cr) emulates the quirky handling of Cr in
|
||||
B = G / Cb / gb * np.min(Cb) # the picamera2 pipeline
|
||||
white_norm_lores = np.stack([R, G, B], axis=2)
|
||||
zoom_factors = [i/n for i, n in zip(rgb[...,:3].shape, white_norm_lores.shape)]
|
||||
white_norm = zoom(white_norm_lores, zoom_factors, order=1)[:rgb.shape[0], :rgb.shape[1], :] # Could use some work
|
||||
colour_correction_matrix = np.array(cam.colour_correction_matrix).reshape((3,3))
|
||||
zoom_factors = [
|
||||
i / n for i, n in zip(rgb[..., :3].shape, white_norm_lores.shape)
|
||||
]
|
||||
white_norm = zoom(white_norm_lores, zoom_factors, order=1)[
|
||||
: rgb.shape[0], : rgb.shape[1], :
|
||||
] # Could use some work
|
||||
colour_correction_matrix = np.array(cam.colour_correction_matrix).reshape(
|
||||
(3, 3)
|
||||
)
|
||||
contrast_algorithm = cam.tuning["algorithms"][9]["rpi.contrast"]
|
||||
gamma = np.array(contrast_algorithm["gamma_curve"]).reshape((-1,2))
|
||||
gamma_8bit = interp1d(gamma[:, 0]/255, gamma[:, 1]/255)
|
||||
gamma = np.array(contrast_algorithm["gamma_curve"]).reshape((-1, 2))
|
||||
gamma_8bit = interp1d(gamma[:, 0] / 255, gamma[:, 1] / 255)
|
||||
|
||||
def process_raw_image(img):
|
||||
normed = img/white_norm
|
||||
corrected = np.dot(colour_correction_matrix, normed.reshape((-1, 3)).T).T.reshape(normed.shape)
|
||||
normed = img / white_norm
|
||||
corrected = np.dot(
|
||||
colour_correction_matrix, normed.reshape((-1, 3)).T
|
||||
).T.reshape(normed.shape)
|
||||
corrected[corrected < 0] = 0
|
||||
corrected[corrected > 255] = 255
|
||||
return gamma_8bit(corrected)
|
||||
|
||||
logger.info(
|
||||
f"Generated normalisation image with shape {white_norm.shape}, "
|
||||
f"max {white_norm.max(axis=(0,1))}, min {white_norm.min(axis=(0,1))}"
|
||||
|
|
@ -572,6 +624,7 @@ class SmartScanThing(Thing):
|
|||
"gain_red": gr,
|
||||
"gain_blue": gb,
|
||||
}
|
||||
|
||||
def capture_and_save(acquired: Event, name: str) -> None:
|
||||
"""Capture an image and save it to disk
|
||||
|
||||
|
|
@ -585,32 +638,42 @@ class SmartScanThing(Thing):
|
|||
acquired.set()
|
||||
acquisition_time = time.time()
|
||||
# Save the raw image
|
||||
np.savez(os.path.join(raw_images_folder, name + ".npz"), raw_image=raw_image, **norm_inputs)
|
||||
np.savez(
|
||||
os.path.join(raw_images_folder, name + ".npz"),
|
||||
raw_image=raw_image,
|
||||
**norm_inputs,
|
||||
)
|
||||
# Process it into 8 bit RGB
|
||||
processed = process_raw_image(rggb2rgb(raw2rggb(raw_image)))
|
||||
processed[processed > 255] = 255
|
||||
processed[processed < 0] = 0
|
||||
img = Image.fromarray(processed.astype(np.uint8), mode="RGB")
|
||||
img.save(
|
||||
os.path.join(images_folder, name),
|
||||
quality=95,
|
||||
subsampling=0
|
||||
os.path.join(images_folder, name), quality=95, subsampling=0
|
||||
)
|
||||
exif_dict = piexif.load(os.path.join(images_folder, name))
|
||||
exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps(
|
||||
metadata
|
||||
).encode("utf-8")
|
||||
piexif.insert(piexif.dump(exif_dict), os.path.join(images_folder, name))
|
||||
piexif.insert(
|
||||
piexif.dump(exif_dict), os.path.join(images_folder, name)
|
||||
)
|
||||
save_time = time.time()
|
||||
logger.info(f"Acquired {name} in {acquisition_time-capture_start:.1f}s then {save_time-acquisition_time:.1f}s saving to disk")
|
||||
logger.info(
|
||||
f"Acquired {name} in {acquisition_time-capture_start:.1f}s then {save_time-acquisition_time:.1f}s saving to disk"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred while saving {name}: {e}", exc_info=e)
|
||||
logger.error(
|
||||
f"An error occurred while saving {name}: {e}", exc_info=e
|
||||
)
|
||||
|
||||
# At the start of the loop, we simultaneously capture an image and move to the next scan point.
|
||||
# We skip capturing on the first run, because we've not focused yet - and also we skip capturing if
|
||||
# it looks like background.
|
||||
while len(path) > 0:
|
||||
loc = self.move_to_next_point(stage, logger, path=path, focused_path=focused_path)
|
||||
loc = self.move_to_next_point(
|
||||
stage, logger, path=path, focused_path=focused_path
|
||||
)
|
||||
if not self.preview_stitch_running():
|
||||
self.preview_stitch_start(images_folder)
|
||||
if self.stitch_automatically:
|
||||
|
|
@ -627,10 +690,10 @@ class SmartScanThing(Thing):
|
|||
|
||||
# if more than 92% of the image is background, treat it as background and continue
|
||||
if not image_is_sample:
|
||||
logger.info(f"Skipping {stage.position} as it is {round(background_detect.background_fraction(),0)}% background.")
|
||||
capture_image = False
|
||||
logger.info(
|
||||
f"Skipping {stage.position} as it is {round(background_detect.background_fraction(),0)}% background."
|
||||
)
|
||||
else:
|
||||
capture_image = True
|
||||
# if not, it's sample. run an autofocus and use the updated height
|
||||
new_pos = [
|
||||
[stage.position["x"] - dx, stage.position["y"]],
|
||||
|
|
@ -648,17 +711,25 @@ class SmartScanThing(Thing):
|
|||
attempts = 0
|
||||
if self.autofocus_dz > 200:
|
||||
while True:
|
||||
jpeg_zs, jpeg_sizes = autofocus.looping_autofocus(dz=self.autofocus_dz, start = 'base')
|
||||
jpeg_zs, jpeg_sizes = autofocus.looping_autofocus(
|
||||
dz=self.autofocus_dz, start="base"
|
||||
)
|
||||
current_height = stage.position["z"]
|
||||
time.sleep(0.2)
|
||||
autofocus_success = autofocus.verify_focus_sharpness(sweep_sizes = jpeg_sizes, camera = CamDep, threshold = 0.92)
|
||||
logger.info(f"We just tested the focus! Result was {autofocus_success}")
|
||||
autofocus_success = autofocus.verify_focus_sharpness(
|
||||
sweep_sizes=jpeg_sizes, camera=CamDep, threshold=0.92
|
||||
)
|
||||
logger.info(
|
||||
f"We just tested the focus! Result was {autofocus_success}"
|
||||
)
|
||||
|
||||
if autofocus_success:
|
||||
# if there have been successful autofocuses in this scan, find the closest one in x-y
|
||||
# test if the change in z between them exceeds a ratio (indicating a failed autofocus)
|
||||
if len(focused_path) > 0:
|
||||
nearest_focused_site = focused_path[closest(loc, focused_path)]
|
||||
nearest_focused_site = focused_path[
|
||||
closest(loc, focused_path)
|
||||
]
|
||||
result = limit_focus_change(
|
||||
nearest_focused_site[0:2],
|
||||
nearest_focused_site[-1],
|
||||
|
|
@ -694,31 +765,33 @@ class SmartScanThing(Thing):
|
|||
wait_start = time.time()
|
||||
capture_thread.join()
|
||||
wait_time = time.time() - wait_start
|
||||
logger.info(f"Waited {wait_time:.1f}s for the previous capture to finish saving.")
|
||||
logger.info(
|
||||
f"Waited {wait_time:.1f}s for the previous capture to finish saving."
|
||||
)
|
||||
acquired = Event()
|
||||
name = f"image_{loc[0]}_{loc[1]}.jpg"
|
||||
time.sleep(0.2)
|
||||
capture_thread = Thread(
|
||||
target=capture_and_save,
|
||||
kwargs={
|
||||
# "cam": cam,
|
||||
# "logger": logger,
|
||||
# "cam": cam,
|
||||
# "logger": logger,
|
||||
"acquired": acquired,
|
||||
"name": name,
|
||||
# "images_folder": images_folder,
|
||||
# "raw_images_folder": raw_images_folder,
|
||||
}
|
||||
# "images_folder": images_folder,
|
||||
# "raw_images_folder": raw_images_folder,
|
||||
},
|
||||
)
|
||||
capture_thread.start()
|
||||
acquired.wait() # wait until the image is acquired
|
||||
#time.sleep(0.5)
|
||||
# time.sleep(0.5)
|
||||
positions.append(loc[:2])
|
||||
names.append(name)
|
||||
|
||||
# add the current position to the list of all positions visited
|
||||
true_path.append(loc)
|
||||
|
||||
#if len(names) > 1:
|
||||
# if len(names) > 1:
|
||||
# generate_config(images_folder, positions, names, CSM, csm_calibration_width, img_width, logger)
|
||||
|
||||
temp_path = []
|
||||
|
|
@ -727,10 +800,20 @@ class SmartScanThing(Thing):
|
|||
if distance_to_site(i, true_path[0][:2]) < max_dist:
|
||||
temp_path.append(i)
|
||||
else:
|
||||
logger.info(f'Rejected moving to {i} as it is out of range')
|
||||
logger.info(f"Rejected moving to {i} as it is out of range")
|
||||
path = temp_path.copy()
|
||||
path = sorted(path, key=lambda x: (steps_from_centre(x, true_path[0][:2], dx, dy), distance_to_site(loc[:2], x)))
|
||||
self.create_zip_of_scan(logger = logger, scan_name = scan_folder.split('scans/')[1], download_zip = False)
|
||||
path = sorted(
|
||||
path,
|
||||
key=lambda x: (
|
||||
steps_from_centre(x, true_path[0][:2], dx, dy),
|
||||
distance_to_site(loc[:2], x),
|
||||
),
|
||||
)
|
||||
self.create_zip_of_scan(
|
||||
logger=logger,
|
||||
scan_name=scan_folder.split("scans/")[1],
|
||||
download_zip=False,
|
||||
)
|
||||
|
||||
except InvocationCancelledError:
|
||||
logger.error("Stopping scan because it was cancelled.")
|
||||
|
|
@ -743,8 +826,7 @@ class SmartScanThing(Thing):
|
|||
except Exception as e:
|
||||
logger.error(
|
||||
f"The scan stopped because of an error: {e}",
|
||||
"We will attempt to stitch and archive the images acquired "
|
||||
"so far.",
|
||||
"We will attempt to stitch and archive the images acquired " "so far.",
|
||||
exc_info=e,
|
||||
)
|
||||
raise e
|
||||
|
|
@ -757,14 +839,20 @@ class SmartScanThing(Thing):
|
|||
stage.move_absolute(**starting_position, block_cancellation=True)
|
||||
finally:
|
||||
self._scan_lock.release()
|
||||
self.create_zip_of_scan(logger = logger, scan_name = scan_folder.split('scans/')[1], download_zip = False)
|
||||
self.create_zip_of_scan(
|
||||
logger=logger,
|
||||
scan_name=scan_folder.split("scans/")[1],
|
||||
download_zip=False,
|
||||
)
|
||||
logger.info("Waiting for background processes to finish...")
|
||||
self.preview_stitch_wait()
|
||||
self.correlate_wait()
|
||||
try:
|
||||
if scan_folder and self.stitch_automatically:
|
||||
logger.info("Stitching final image (may take some time)...")
|
||||
self.stitch_scan(logger, os.path.basename(scan_folder), overlap=overlap)
|
||||
self.stitch_scan(
|
||||
logger, os.path.basename(scan_folder), overlap=overlap
|
||||
)
|
||||
except SubprocessError as e:
|
||||
logger.error(f"Stitching failed: {e}", exc_info=e)
|
||||
|
||||
|
|
@ -848,26 +936,26 @@ class SmartScanThing(Thing):
|
|||
number_of_images = 0
|
||||
scans.append(
|
||||
ScanInfo(
|
||||
name = f,
|
||||
created = os.path.getctime(path),
|
||||
modified = os.path.getmtime(path),
|
||||
number_of_images = number_of_images,
|
||||
name=f,
|
||||
created=os.path.getctime(path),
|
||||
modified=os.path.getmtime(path),
|
||||
number_of_images=number_of_images,
|
||||
)
|
||||
)
|
||||
return scans
|
||||
|
||||
@fastapi_endpoint(
|
||||
"get",
|
||||
"scans/{scan_name}/{file}",
|
||||
responses = {
|
||||
200: {
|
||||
"description": "Successfully downloading file",
|
||||
"content": {"*/*": {}}
|
||||
},
|
||||
403: {"description": "Filename not permitted"},
|
||||
404: {"description": "File not found"}
|
||||
"get",
|
||||
"scans/{scan_name}/{file}",
|
||||
responses={
|
||||
200: {
|
||||
"description": "Successfully downloading file",
|
||||
"content": {"*/*": {}},
|
||||
},
|
||||
)
|
||||
403: {"description": "Filename not permitted"},
|
||||
404: {"description": "File not found"},
|
||||
},
|
||||
)
|
||||
def get_scan_file(self, scan_name: str, file: str) -> FileResponse:
|
||||
"""Retrieve a file from a scan.
|
||||
|
||||
|
|
@ -887,7 +975,7 @@ class SmartScanThing(Thing):
|
|||
@fastapi_endpoint(
|
||||
"delete",
|
||||
"scans/{scan_name}",
|
||||
responses = {
|
||||
responses={
|
||||
200: {"description": "Successfully deleted scan"},
|
||||
404: {"description": "Scan not found"},
|
||||
},
|
||||
|
|
@ -917,7 +1005,7 @@ class SmartScanThing(Thing):
|
|||
for scan in self.scans:
|
||||
self.delete_scan(scan.name)
|
||||
|
||||
def images_folder(self, scan_name: Optional[str]=None) -> str:
|
||||
def images_folder(self, scan_name: Optional[str] = None) -> str:
|
||||
scan_folder = self.scan_folder_path(scan_name=scan_name)
|
||||
return os.path.join(scan_folder, "images")
|
||||
|
||||
|
|
@ -941,25 +1029,25 @@ class SmartScanThing(Thing):
|
|||
return None
|
||||
|
||||
@fastapi_endpoint(
|
||||
"get",
|
||||
"latest_preview_stitch.jpg",
|
||||
responses = {
|
||||
200: {
|
||||
"description": "A preview-quality stitched image",
|
||||
"content": {"image/jpeg": {}}
|
||||
},
|
||||
404: {"description": "File not found"}
|
||||
"get",
|
||||
"latest_preview_stitch.jpg",
|
||||
responses={
|
||||
200: {
|
||||
"description": "A preview-quality stitched image",
|
||||
"content": {"image/jpeg": {}},
|
||||
},
|
||||
)
|
||||
404: {"description": "File not found"},
|
||||
},
|
||||
)
|
||||
def get_latest_preview(self) -> FileResponse:
|
||||
"""Retrieve the latest preview image.
|
||||
"""
|
||||
"""Retrieve the latest preview image."""
|
||||
path = self.latest_preview_stitch_path
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(404, "File not found")
|
||||
return FileResponse(path)
|
||||
|
||||
_preview_stitch_popen = None
|
||||
|
||||
def preview_stitch_start(self, images_folder: str) -> None:
|
||||
"""Start stitching a preview of the scan in a subprocess"""
|
||||
if self.preview_stitch_running():
|
||||
|
|
@ -984,13 +1072,21 @@ class SmartScanThing(Thing):
|
|||
self._preview_stitch_popen.wait()
|
||||
|
||||
_correlate_popen = None
|
||||
|
||||
def correlate_start(self, images_folder: str, overlap: float = 0.1) -> None:
|
||||
"""Start stitching a preview of the scan in a subprocess"""
|
||||
if self.correlate_running():
|
||||
raise RuntimeError("Only one subprocess is allowed at a time")
|
||||
with self._correlate_popen_lock:
|
||||
self._correlate_popen = Popen(
|
||||
[self._script, "--stitching_mode", "only_correlate", "--minimum_overlap", f"{round(overlap*0.9, 2)}", images_folder]
|
||||
[
|
||||
self._script,
|
||||
"--stitching_mode",
|
||||
"only_correlate",
|
||||
"--minimum_overlap",
|
||||
f"{round(overlap*0.9, 2)}",
|
||||
images_folder,
|
||||
]
|
||||
)
|
||||
|
||||
def correlate_running(self) -> bool:
|
||||
|
|
@ -1008,19 +1104,20 @@ class SmartScanThing(Thing):
|
|||
self._correlate_popen.wait()
|
||||
|
||||
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)}`")
|
||||
|
||||
|
||||
p = Popen(cmd, stdout=PIPE, stderr = STDOUT, bufsize=1, universal_newlines=True)
|
||||
p = Popen(cmd, stdout=PIPE, stderr=STDOUT, bufsize=1, universal_newlines=True)
|
||||
os.set_blocking(p.stdout.fileno(), False)
|
||||
logger.info(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())))
|
||||
while p.poll() == None:
|
||||
logger.info(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())))
|
||||
while p.poll() is None:
|
||||
try:
|
||||
output = p.stdout.readline()
|
||||
if output != "" and output != None:
|
||||
if output != "" and output is not None:
|
||||
logger.info(output)
|
||||
except:
|
||||
pass
|
||||
|
|
@ -1028,46 +1125,72 @@ class SmartScanThing(Thing):
|
|||
for line in p.stdout:
|
||||
try:
|
||||
output = p.stdout.readline()
|
||||
if output != "" and output != None:
|
||||
if output != "" and output is not None:
|
||||
logger.info(output)
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.info('Stitching complete')
|
||||
logger.info("Stitching complete")
|
||||
return p
|
||||
|
||||
@thing_action
|
||||
def stitch_scan(self, logger: InvocationLogger, scan_name: Optional[str]=None, overlap: float = 0.0) -> None:
|
||||
def stitch_scan(
|
||||
self,
|
||||
logger: InvocationLogger,
|
||||
scan_name: Optional[str] = None,
|
||||
overlap: float = 0.0,
|
||||
) -> None:
|
||||
"""Generate a stitched image based on stage position metadata"""
|
||||
images_folder = self.images_folder(scan_name=scan_name)
|
||||
|
||||
if self.stitch_tiff:
|
||||
tiff_arg = '--stitch_tiff'
|
||||
tiff_arg = "--stitch_tiff"
|
||||
else:
|
||||
tiff_arg = '--no-stitch_tiff'
|
||||
tiff_arg = "--no-stitch_tiff"
|
||||
|
||||
if overlap == 0.0:
|
||||
try:
|
||||
with open(os.path.join(images_folder, 'scan_inputs.json')) as data_file:
|
||||
with open(os.path.join(images_folder, "scan_inputs.json")) as data_file:
|
||||
data_loaded = json.load(data_file)
|
||||
logger.info(data_loaded)
|
||||
overlap = data_loaded['overlap']
|
||||
overlap = data_loaded["overlap"]
|
||||
except:
|
||||
overlap = 0.1
|
||||
self.run_subprocess(logger, [self._script, "--stitching_mode", "all", f"{tiff_arg}", "--minimum_overlap", f"{round(overlap*0.9,2)}", images_folder])
|
||||
self.run_subprocess(
|
||||
logger,
|
||||
[
|
||||
self._script,
|
||||
"--stitching_mode",
|
||||
"all",
|
||||
f"{tiff_arg}",
|
||||
"--minimum_overlap",
|
||||
f"{round(overlap*0.9,2)}",
|
||||
images_folder,
|
||||
],
|
||||
)
|
||||
|
||||
@thing_action
|
||||
def create_zip_of_scan(self, logger: InvocationLogger, scan_name: Optional[str]=None, download_zip = True) -> ZipBlob:
|
||||
def create_zip_of_scan(
|
||||
self,
|
||||
logger: InvocationLogger,
|
||||
scan_name: Optional[str] = None,
|
||||
download_zip=True,
|
||||
) -> ZipBlob:
|
||||
"""Generate a zip file that can be downloaded, with all the scan files in it."""
|
||||
images_folder = self.images_folder(scan_name=scan_name)
|
||||
scan_folder = self.scan_folder_path(scan_name=scan_name)
|
||||
if scan_folder != os.path.dirname(images_folder) or os.path.basename(images_folder) != "images":
|
||||
if (
|
||||
scan_folder != os.path.dirname(images_folder)
|
||||
or os.path.basename(images_folder) != "images"
|
||||
):
|
||||
logger.error(
|
||||
"There is a problem with filenames, the archive may be incorrect."
|
||||
f"scan_folder: {scan_folder}, images_folder: {images_folder}."
|
||||
)
|
||||
if not os.path.isdir(images_folder):
|
||||
raise FileNotFoundError(f"Tried to make a zip archive of {images_folder} but it does not exist.")
|
||||
raise FileNotFoundError(
|
||||
f"Tried to make a zip archive of {images_folder} but it does not exist."
|
||||
)
|
||||
# logger.info("Creating zip archive of images (may take some time)...")
|
||||
|
||||
zip_fname = f'{os.path.join(scan_folder, "images")}.zip'
|
||||
|
|
@ -1084,20 +1207,26 @@ class SmartScanThing(Thing):
|
|||
|
||||
# get a list of files in the folder we're zipping
|
||||
folder_path = self.scan_folder_path(scan_name)
|
||||
files = glob.glob(folder_path + '/**/*', recursive=True)
|
||||
files = [i.split(f'{folder_path}/')[1] for i in files]
|
||||
files = glob.glob(folder_path + "/**/*", recursive=True)
|
||||
files = [i.split(f"{folder_path}/")[1] for i in files]
|
||||
|
||||
# This is a list of file names that are updated as the scan goes,
|
||||
# and should only be zipped at the end of the scan - otherwise they'll
|
||||
# be appended on every loop as we can't overwrite files in the zip
|
||||
files_to_delay = ['TileConfiguration', 'tiling_cache', 'stitched.jp', 'stitched_from', 'stitched.om']
|
||||
files_to_delay = [
|
||||
"TileConfiguration",
|
||||
"tiling_cache",
|
||||
"stitched.jp",
|
||||
"stitched_from",
|
||||
"stitched.om",
|
||||
]
|
||||
tiff_name = ""
|
||||
|
||||
with zipfile.ZipFile(zip_fname, mode="a") as zip:
|
||||
for file in files:
|
||||
if 'stitched.jp' in file:
|
||||
if "stitched.jp" in file:
|
||||
stitch_name = os.path.split(file)[1]
|
||||
if '.ome.tiff' in file:
|
||||
if ".ome.tiff" in file:
|
||||
tiff_name = os.path.split(file)[1]
|
||||
if any(banned_name in file for banned_name in files_to_delay):
|
||||
# logger.info(f'we only add {file} into zip at the end of the scan')
|
||||
|
|
@ -1105,15 +1234,14 @@ class SmartScanThing(Thing):
|
|||
elif file in current_zip:
|
||||
# logger.info(f'{file} is already in zip')
|
||||
pass
|
||||
elif ".zip" in file or 'raw' in file:
|
||||
elif ".zip" in file or "raw" in file:
|
||||
# logger.info('Not adding the .zip to itself')
|
||||
pass
|
||||
else:
|
||||
logger.info(f'appending {file} to zip')
|
||||
logger.info(f"appending {file} to zip")
|
||||
zip.write(os.path.join(folder_path, file), arcname=file)
|
||||
|
||||
|
||||
images_folder = os.path.join(folder_path, 'images')
|
||||
images_folder = os.path.join(folder_path, "images")
|
||||
# Promote key files to the top level of the zip only at the end of the scan (when downloading)
|
||||
# and finally zip some of the final files
|
||||
# TODO: if you download multiple times, you get duplicate files - is this a problem?
|
||||
|
|
@ -1122,13 +1250,13 @@ class SmartScanThing(Thing):
|
|||
for fname in ["stitched_from_stage.jpg", stitch_name, tiff_name]:
|
||||
fpath = os.path.join(images_folder, fname)
|
||||
if os.path.exists(fpath):
|
||||
logger.info(f'copying {fpath} to upper level')
|
||||
logger.info(f"copying {fpath} to upper level")
|
||||
zip.write(fpath, arcname=fname)
|
||||
for file in files:
|
||||
if any(banned_name in file for banned_name in files_to_delay):
|
||||
logger.info(f'we are finally adding {file} into zip')
|
||||
logger.info(f"we are finally adding {file} into zip")
|
||||
zip.write(os.path.join(folder_path, file), arcname=file)
|
||||
logger.info('about to download zip')
|
||||
logger.info("about to download zip")
|
||||
return ZipBlob.from_file(zip_fname)
|
||||
|
||||
@thing_action
|
||||
|
|
@ -1138,4 +1266,3 @@ class SmartScanThing(Thing):
|
|||
zip = [os.path.normpath(i) for i in zip.namelist()]
|
||||
|
||||
return zip
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -33,11 +34,21 @@ class StageProtocol(Protocol):
|
|||
"""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."""
|
||||
...
|
||||
|
||||
|
|
@ -59,6 +70,7 @@ class BaseStage(Thing):
|
|||
`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,13 +108,24 @@ 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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -14,6 +17,10 @@ class DummyStage(BaseStage):
|
|||
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,14 +67,21 @@ 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]
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -32,12 +34,19 @@ class Stitcher(Thing):
|
|||
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
|
||||
|
|
@ -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:
|
||||
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])
|
||||
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 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])
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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