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:
Richard Bowman 2024-12-03 14:42:48 +00:00
commit d9c8a083fd
26 changed files with 954 additions and 3026 deletions

View file

@ -1,21 +1,37 @@
stages: stages:
- prepare
- analysis - analysis
- testing - testing
- build - build
- package - package
- deploy - deploy
meta-build-image:
image: docker:stable
services:
- docker:dind
stage: prepare
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- cd .meta-testimage-python
- docker build -t $CI_REGISTRY/openflexure/openflexure-microscope-server/testimage-python:latest .
- docker push $CI_REGISTRY/openflexure/openflexure-microscope-server/testimage-python:latest
only:
refs: # commented out for initial run
- v3
changes:
- .meta/Dockerfile
# Re-usable block to install (and cache) Poetry and openflexure-microscope-server # Re-usable block to install (and cache) Poetry and openflexure-microscope-server
.python: .python:
image: balenalib/raspberry-pi:buster image: registry.gitlab.com/openflexure/openflexure-microscope-server/testimage-python
retry: 1 retry: 1
before_script: before_script:
- apt-get update - python --version
- apt-get install apt-utils curl wget python3 python3-pip python3-venv raspi-gpio libgfortran3 libatlas3-base libffi-dev vim git-lfs libtiff5 libpango-1.0-0 libavcodec58 libgdk-pixbuf2.0-0 libjasper1 libswscale5 libpangocairo-1.0-0 libilmbase23 libatk1.0-0 libgtk-3-0 libwebp6 libcairo2 libavutil56 libcairo-gobject2 libopenexr23 libavformat58 libxslt1.1 - python -m venv .venv # NB this does not overwrite an existing cached venv
- python3 -m venv .venv # NB this does not overwrite an existing cached venv
- source .venv/bin/activate - source .venv/bin/activate
- pip install pipenv # Should be cached after first run - pip install -e .[dev]
- pipenv install --dev --deploy # Should be cached after first run - will just check packages are present
cache: cache:
key: "${CI_COMMIT_REF_SLUG}" key: "${CI_COMMIT_REF_SLUG}"
paths: paths:
@ -42,57 +58,66 @@ stages:
- tags - tags
- web - web
# Python static analysis with PyLint # Python formatting and static analysis with ruff
pylint: ruff:
stage: analysis stage: analysis
extends: .python extends: .python
script: script:
- poe pylint - ruff check
- ruff format --check
# Python type checking with Mypy # Python type checking with Mypy
mypy: mypy:
stage: analysis stage: analysis
extends: .python extends: .python
script: script:
- poe mypy - mypy --cobertura-xml-report mypy --no-error-summary src | tee mypy/stdout.txt
after_script:
- cat mypy/stdout.txt | mypy-gitlab-code-quality > codequality.json
allow_failure: true
artifacts: artifacts:
reports: reports:
codequality: codequality.json
coverage_report: coverage_report:
coverage_format: cobertura coverage_format: cobertura
path: openflexure_microscope/cobertura.xml path: mypy/cobertura.xml
# Python style analysis with Black
black:
stage: analysis
allow_failure: true
extends: .python
script:
- poe black_check
# Python unit tests with PyTest # Python unit tests with PyTest
pytest: pytest:
stage: testing stage: testing
extends: .python extends: .python
script: script:
- poe test - >
pytest --cov --cov-report term
--cov-report xml:coverage.xml
--junitxml=report.xml
# --gitlab-code-quality-report=pytest-warnings.json # can restore when it installs ok
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
artifacts: artifacts:
when: always when: always
reports:
junit: pytest_report.xml
# Generate and validate OpenAPI description
openapi:
stage: testing
extends: .python
script:
- mkdir -p docs/build/
- ofm-generate-openapi -o docs/build/swagger.yaml --validate
artifacts:
name: "openapi"
expose_as: "OpenAPI Description"
expire_in: 1 week
paths: paths:
- "./docs/build/swagger.yaml" - report.xml
reports:
junit: report.xml
# codequality: pytest-warnings.json
coverage_report:
coverage_format: cobertura
path: coverage.xml
# Generate and validate OpenAPI description - needs updating to work with v3
# openapi:
# stage: testing
# extends: .python
# script:
# - mkdir -p docs/build/
# - ofm-generate-openapi -o docs/build/swagger.yaml --validate
# artifacts:
# name: "openapi"
# expose_as: "OpenAPI Description"
# expire_in: 1 week
# paths:
# - "./docs/build/swagger.yaml"
# JavaScript linting with ESLint (via Vue CLI) # JavaScript linting with ESLint (via Vue CLI)
eslint: eslint:
@ -115,86 +140,86 @@ build:
paths: paths:
- "src/openflexure_microscope/static/" - "src/openflexure_microscope/static/"
# Package application into distribution tarball # # Package application into distribution tarball
package: # package:
stage: package # stage: package
dependencies: # dependencies:
- build # - build
- openapi # - openapi
image: ubuntu:latest # image: ubuntu:latest
script: # script:
# Build distribution archive # # Build distribution archive
- mkdir -p dist # - mkdir -p dist
- tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz . # - tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz .
- tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz ./openflexure_microscope_server/static/ # - tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz ./openflexure_microscope_server/static/
- cp docs/build/swagger.yaml dist/openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml # - cp docs/build/swagger.yaml dist/openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml
- cd dist/ # - cd dist/
- sha256sum openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz.sha256 # - sha256sum openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz.sha256
- sha256sum openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz.sha256 # - sha256sum openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz.sha256
- sha256sum openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml > openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml.sha256 # - sha256sum openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml > openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml.sha256
artifacts: # artifacts:
name: "dist" # name: "dist"
expire_in: 1 week # expire_in: 1 week
paths: # paths:
- "./dist/" # - "./dist/"
only: # only:
- master # - master
- merge_requests # - merge_requests
- tags # - tags
- web # - web
# Deploy to builds.openflexure.org # # Deploy to builds.openflexure.org
deploy: # deploy:
stage: deploy # stage: deploy
dependencies: # dependencies:
- package # - package
image: ubuntu:latest # image: ubuntu:latest
before_script: # before_script:
- "which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )" # - "which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )"
- eval $(ssh-agent -s) # - eval $(ssh-agent -s)
- ssh-add <(echo "$SSH_PRIVATE_KEY_BATH_OPENFLEXURE_BASE64" | base64 --decode) # - ssh-add <(echo "$SSH_PRIVATE_KEY_BATH_OPENFLEXURE_BASE64" | base64 --decode)
- mkdir -p ~/.ssh # - mkdir -p ~/.ssh
- echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config # - echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config
script: # script:
# Install rsync if not already installed # # Install rsync if not already installed
- "which rsync || ( apt-get update -y && apt-get install rsync -y )" # - "which rsync || ( apt-get update -y && apt-get install rsync -y )"
# Upload the builds folder to openflexure-microscope builds # # Upload the builds folder to openflexure-microscope builds
- rsync -hrvz -e ssh dist/ ci-user@openflexure.bath.ac.uk:/var/www/build/openflexure-microscope-server # - rsync -hrvz -e ssh dist/ ci-user@openflexure.bath.ac.uk:/var/www/build/openflexure-microscope-server
# Run update-latest.py on the build server # # Run update-latest.py on the build server
- ssh -t ci-user@openflexure.bath.ac.uk "/var/www/build/update-latest.py" # - ssh -t ci-user@openflexure.bath.ac.uk "/var/www/build/update-latest.py"
only: # only:
- tags # - tags
- web # - web
zenodo: # zenodo:
stage: deploy # stage: deploy
image: ubuntu:20.04 # image: ubuntu:20.04
before_script: # before_script:
- apt-get update -qq # - apt-get update -qq
- apt-get -y -qq install git python3-pip # - apt-get -y -qq install git python3-pip
- pip3 install -r scripts/zenodo/requirements.txt # - pip3 install -r scripts/zenodo/requirements.txt
variables: # variables:
ZENODO_USE_SANDBOX: "false" # ZENODO_USE_SANDBOX: "false"
script: # script:
- git archive "${CI_COMMIT_REF_NAME}" --format=zip --output="${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-source.zip" # - git archive "${CI_COMMIT_REF_NAME}" --format=zip --output="${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-source.zip"
- python3 scripts/zenodo/upload_to_zenodo.py "${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-source.zip" dist/* # - python3 scripts/zenodo/upload_to_zenodo.py "${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-source.zip" dist/*
artifacts: # artifacts:
# this is only a small html link, let's just keep it forever # # this is only a small html link, let's just keep it forever
# gitlab doesn't understand "expire_in: never" yet though # # gitlab doesn't understand "expire_in: never" yet though
expire_in: 100000 years # expire_in: 100000 years
name: zenodo-${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-link.html # name: zenodo-${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-link.html
paths: # paths:
- zenodo-link.html # - zenodo-link.html
only: # only:
- tags # - tags
- web # - web

View 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
View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -85,7 +85,7 @@ To set up a development version of the software (most likely using emulated came
### Run the server manually on a Raspberry Pi ### Run the server manually on a Raspberry Pi
``` ```
cd /var/openflexure cd /var/openflexure
sudo -u openflexure-ws PATH="/var/openflexure/application/openflexure-microscope-server/.venv/bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" /var/openflexure/application/openflexure-microscope-server/.venv/bin/uvicorn --reload --reload-dir=./application --host 0.0.0.0 --port 5000 openflexure_microscope_server.server:app sudo -u openflexure-ws PATH="/var/openflexure/application/openflexure-microscope-server/.venv/bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" /var/openflexure/application/openflexure-microscope-server/.venv/bin/openflexure-microscope-serve -c /var/openflexure/settings/ofm_config.json --host 0.0.0.0 --port 5000
``` ```
@ -107,70 +107,36 @@ sudo -u openflexure-ws PATH="/var/openflexure/application/openflexure-microscope
## Formatting, linting, and tests ## Formatting, linting, and tests
All of the commands below assume that you are running in the OFM virtual environment, i.e. you have run `ofm activate` on an OpenFlexure SD card, or `source .venv/bin/activate` on Linux, or `.venv/Scripts/activate`on Windows. All of the commands below assume that you are running in the OFM virtual environment, i.e. you have run `ofm activate` on an OpenFlexure SD card, or `source .venv/bin/activate` on Linux, or `.venv/Scripts/activate`on Windows.
**Before committing** you should auto-format your code: **Before committing** you should lint and auto-format your code:
* To auto-format the Python code run `poe format` * To lint run `ruff check`
* To auto-format the Python code run `ruff format`
* To auto-format the Javascript code, run * To auto-format the Javascript code, run
* `cd webapp` * `cd webapp`
* `npm run lint` * `npm run lint`
**Before submitting a merge request/merging** please auto-format your code and also run the quality checks (linting, static analysis, and unit tests) **Before merging** please auto-format your code and also run the quality checks (linting, static analysis, and unit tests)
* To auto-format and type-check the Python code run `poe check` * All commands above
* To auto-format the Javascript code, run * `mypy src` (currently fails)
* `cd webapp` * `pytest`
* `npm run lint`
### Details ### Details
We use several code analysis and formatting libraries in this project. **Please run all of these before submitting a merge request.** We use several code analysis and formatting libraries in this project. Our CI will check each of these automatically, so ensuring they pass locally will save you time. Currently `ruff` is used for linting/formatting and `pytest` for unit tests. `mypy` will be enabled once the codebase is ready.
Our CI will check each of these automatically, so ensuring they pass locally will save you time.
* **Black** - Code formatting with minimal configuration.
* While sometimes it's not perfect, its fine 90% of the time and prevents arguments about formatting.
* Automatically formats your code
* This will rewrite your files in-place, so if you want to be able to revert, make a backup first!
* `poe black`
* **Pylint** - Static code analysis
* Analyses your code, failing if issues are detected.
* We've disabled some less severe warnings, so _if anything fails your merge request will be blocked_
* `poe pylint`
* **Mypy** - Type checking
* Analyses your type hints and annotations to flag up potential bugs
* Where possible, use type hints in your code. Even if dependencies don't support it, it'll help identify issues.
* `poe mypy`
* **Pytest** - Unit testing
* While unit testing is of limited use due to our dependence on real hardware, some simple isolated functions can (and should) be unit tested.
* `poe test`
Though not in the CI, our `format` script also runs isort:
* **Isort** - Import sorting
* Automatically organises your imports to stop things getting out of hand
* `poe isort`
## Python environment, build, and dependencies ## Python environment, build, and dependencies
As of `2.10.0b0` we have switched to using `pipenv` for managing dependencies, and a standard `setuptools` based build system. See "local installation" above for instructions on how to install the project. Earlier versions of the project used `poetry` but we moved away because of [difficulties](https://gitlab.com/openflexure/openflexure-microscope-server/-/merge_requests/124) getting it to work both on the Raspberry Pi and in our CI pipeline. The new arrangement for configuration files is: As of `v3` we specify dependencies in `pyproject.toml`. These are not currently frozen due to difficulties matching versions on different platforms. On Raspberry Pi, it's best to specify `--only-binary=:all:` to ensure libraries like `numpy` and `scipy` are not compiled from source (which takes many hours, and/or fails). Pinning dependencies with `requirements.txt` and/or `requirements.in` may happen in the future.
* Dependencies, and dev-dependencies, are specified in `setup.py` in the usual way (using `install_requires` and `extra_requires[dev]`).
* Package metadata is specified in `setup.py` in the usual way.
* `pyproject.toml` is retained, but *no longer includes package metadata or dependencies* and does not have a `[tool.poetry]` table. It does define the build system as per PEP517, which is `setuptools`, and it also contains settings for `black`, `poe` and `isort`.
* `Pipfile` is very minimal, and only declares dependencies on the current module, i.e. the dependencies declared in `setup.py`. It also specifies the Python version. This allows us to single-source dependency information from `setup.py` but use the dependency resolution/locking functionality of `pipenv`
* `Pipfile.lock` locks the dependency versions in the same way as `poetry.lock` used to, i.e. it specifies exact versions of everything, derived from the looser specifications in `setup.py`.
* Dependency resolution is a pain, in part because compiling packages like scipy and numpy from source on a Pi is slow and unreliable. To avoid this, we usually lock the dependencies on a Raspberry Pi, using:
`PIP_ONLY_BINARY="numpy, scipy, matplotlib" pipenv lock --dev`
This forces the use of wheels only, and thus restricts us to things that built OK on Piwheels. The resulting Pipfile.lock should then work nicely on other Raspberry Pis.
* **The Pipfile we use is now Raspberry Pi-specific** so if you attempt to use it on other platforms it will probably fail. This is because we've removed `pypi` to avoid hash conflicts. The CI now runs on a Raspberry Pi image, so that the tests use a similar environment to our deployment. Currently, changing the `[source]` entry back to PyPi will allow you to re-lock the dependencies on other platforms. In the future we may need to maintain two Pipfiles and two Pipfile.lock files.
## Creating releases ## Creating releases
* Update the application's internal version number * Update the application's internal version number
* Edit `setup.py` to update the version number * Edit `pyproject.toml` to update the version number
* Git commit and git push * Update the changelog
* Create a new version tag on GitLab (e.g. `v2.6.11`) * Git commit and git push
* Create a new version tag on GitLab (e.g. `v2.6.11`) that matches the `pyproject.toml` version number.
* Make sure you prefix a lower case 'v', otherwise it won't be recognised as a release! * Make sure you prefix a lower case 'v', otherwise it won't be recognised as a release!
* This tagging will trigger a CI pipeline that builds the JS client, tarballs up the server, and deploys it * This tagging will trigger a CI pipeline that builds the JS client, tarballs up the server, and deploys it
* Note: This also updates the build server's nginx redirect map file * Note: This also updates the build server's nginx redirect map file
@ -180,10 +146,8 @@ As of `2.10.0b0` we have switched to using `pipenv` for managing dependencies, a
* `npm install -g conventional-changelog-cli` * `npm install -g conventional-changelog-cli`
* `npx conventional-changelog -r 1 --config ./changelog.config.js -i CHANGELOG.md -s` * `npx conventional-changelog -r 1 --config ./changelog.config.js -i CHANGELOG.md -s`
## Microscope extensions ## Adding functionality
The Microscope module, and Flask app, both support plugins for extending lower-level functionality not well suited to web API calls. The current documentation can be found [here](https://openflexure-microscope-software.readthedocs.io/en/latest/plugins.html). The microscope comprises a number of `Thing` instances, which provide actions and properties to implement hardware control and software features. These may be imported from any Python module, using `ofm_config.json`. See the LabThings-FastAPI documentation for how to create a `Thing`.
If you want to add functions to the microscope software, this is probably the best mechanism to use if it works for you.
Currently, the camera and stage classes are provided by external libraries, `labthings-picamera2` and `labthings-sangaboard`. Replacing these is the easiest way to use alternative hardware: interfaces are defined in `openflexure_microscope_server.things.camera` and `openflexure_microscope_server.things.stage` respectively.
```

View file

@ -29,6 +29,11 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"labthings-fastapi[dev]", "labthings-fastapi[dev]",
"ruff",
"mypy[reports]",
"mypy-gitlab-code-quality",
# "pytest-gitlab-code-quality", # pytest version constraint clashes with labthings
"pytest",
] ]
pi = [ pi = [
"labthings-picamera2 == 0.0.1-dev2", "labthings-picamera2 == 0.0.1-dev2",
@ -58,49 +63,13 @@ build-backend = "setuptools.build_meta"
# don't believe it's critical at present, because we don't use # don't believe it's critical at present, because we don't use
# the Python package - we distribute a tarball directly. # the Python package - we distribute a tarball directly.
[tool.black]
exclude = '(\.eggs|\.git|\.venv|\venv|node_modules/)'
[tool.isort]
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
ensure_newline_before_comments = true
line_length = 88
[tool.pylint.'MESSAGES CONTROL']
# W1203 warns about using f strings in logging statements.
# I'm really not concerned about the performance implications of this,
# particularly as we often have the log level set quite high. I think
# for our current purposes, using f strings in logging statements is
# more readable and thus a good idea in many places - I've disabled
# this warning code globally for that reason.
disable = "fixme,C,R,W1203"
max-line-length = 88
[tool.mypy] [tool.mypy]
plugins = ["pydantic.mypy"] plugins = ["pydantic.mypy"]
[tool.ruff] [tool.ruff]
target-version = "py39" target-version = "py39"
[tool.poe.executor] [tool.pytest.ini_options]
type = "virtualenv" addopts = [
location = ".venv" "--import-mode=importlib",
]
[tool.poe.tasks]
black = "black ."
black_check = "black --check ."
ruff = "ruff . --fix"
ruff_check = "ruff ."
isort = "isort openflexure_microscope"
pylint = "pylint openflexure_microscope"
mypy = "mypy --cobertura-xml-report openflexure_microscope openflexure_microscope"
test = "pytest . --junitxml=pytest_report.xml"
serve = "python -m openflexure_microscope.api.app"
format = ["black", "isort", "ruff"]
lint = ["ruff_check", "pylint", "mypy"]
check = ["format", "lint", "test"]

View file

@ -5,6 +5,7 @@ Copyright (c) 2018 German Aerospace Center (DLR). All rights reserved.
SPDX-License-Identifier: MIT-DLR SPDX-License-Identifier: MIT-DLR
""" """
import json import json
import os import os
@ -21,7 +22,7 @@ class Zenodo:
self.zenodo_url = "https://zenodo.org/api/deposit/depositions" self.zenodo_url = "https://zenodo.org/api/deposit/depositions"
def create_new_deposit(self): def create_new_deposit(self):
""" Creates a new (unpublished) Zenodo deposit and return its deposition ID. """ """Creates a new (unpublished) Zenodo deposit and return its deposition ID."""
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
r = requests.post( r = requests.post(
@ -35,7 +36,7 @@ class Zenodo:
return r.json() return r.json()
def set_metadata(self, deposition_id, metadata): def set_metadata(self, deposition_id, metadata):
""" Sets the given metadata for the specified deposit. """ """Sets the given metadata for the specified deposit."""
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
r = requests.put( r = requests.put(
@ -48,7 +49,7 @@ class Zenodo:
print(r.json()) print(r.json())
def upload_file(self, deposition_id, file_path): def upload_file(self, deposition_id, file_path):
""" Uploads a new file for the given deposit. """ """Uploads a new file for the given deposit."""
file_name = os.path.basename(file_path) file_name = os.path.basename(file_path)
data = {"filename": file_name} data = {"filename": file_name}
@ -63,7 +64,7 @@ class Zenodo:
print(r.json()) print(r.json())
def publish_deposit(self, deposition_id): def publish_deposit(self, deposition_id):
""" Publishes the given deposit. BEWARE: It is now visible to all!!! """ """Publishes the given deposit. BEWARE: It is now visible to all!!!"""
r = requests.post( r = requests.post(
self.zenodo_url + "/{}/actions/publish".format(deposition_id), self.zenodo_url + "/{}/actions/publish".format(deposition_id),
@ -73,7 +74,7 @@ class Zenodo:
print(r.json()) print(r.json())
def create_new_version(self, deposition_id): def create_new_version(self, deposition_id):
""" Creates a new version of an already published deposit. """ """Creates a new version of an already published deposit."""
r = requests.post( r = requests.post(
self.zenodo_url + "/{}/actions/newversion".format(deposition_id), self.zenodo_url + "/{}/actions/newversion".format(deposition_id),
@ -84,7 +85,7 @@ class Zenodo:
return os.path.basename(r.json()["links"]["latest_draft"]) return os.path.basename(r.json()["links"]["latest_draft"])
def remove_all_files(self, deposition_id): def remove_all_files(self, deposition_id):
""" Removes all uploaded files of a unpublished deposit. """ """Removes all uploaded files of a unpublished deposit."""
r = requests.get( r = requests.get(
self.zenodo_url + "/{}/files".format(deposition_id), self.zenodo_url + "/{}/files".format(deposition_id),

View file

@ -4,9 +4,10 @@ import os
from fastapi.responses import FileResponse, PlainTextResponse from fastapi.responses import FileResponse, PlainTextResponse
OFM_LOG_FOLDER = "/var/openflexure/logs/" OFM_LOG_FOLDER = "/var/openflexure/logs/"
OFM_LOG_FILE = os.path.join(OFM_LOG_FOLDER, "openflexure_microscope.log") OFM_LOG_FILE = os.path.join(OFM_LOG_FOLDER, "openflexure_microscope.log")
def configure_logging(): def configure_logging():
root_logger = logging.getLogger() root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO) root_logger.setLevel(logging.INFO)
@ -14,15 +15,13 @@ def configure_logging():
if not os.path.exists(OFM_LOG_FOLDER): if not os.path.exists(OFM_LOG_FOLDER):
os.makedirs(OFM_LOG_FOLDER) os.makedirs(OFM_LOG_FOLDER)
handler = RotatingFileHandler( handler = RotatingFileHandler(
filename = OFM_LOG_FILE, filename=OFM_LOG_FILE,
mode = "a", mode="a",
maxBytes = 1000000, maxBytes=1000000,
backupCount = 10, backupCount=10,
) )
handler.setFormatter( handler.setFormatter(
logging.Formatter( logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s")
"[%(asctime)s] [%(levelname)s] %(message)s"
)
) )
root_logger.addHandler(handler) root_logger.addHandler(handler)
except PermissionError as e: except PermissionError as e:

View file

@ -43,5 +43,3 @@ def serve_from_cli(argv: Optional[list[str]] = None):
uvicorn.run(app, host=args.host, port=args.port) uvicorn.run(app, host=args.host, port=args.port)
else: else:
raise e raise e

View file

@ -5,6 +5,7 @@ from socket import gethostname
def add_v2_endpoints(thing_server: ThingServer): def add_v2_endpoints(thing_server: ThingServer):
app = thing_server.app app = thing_server.app
# TODO: update openflexure connect to make this unnecessary!! # TODO: update openflexure connect to make this unnecessary!!
# The endpoints below fool OpenFlexure Connect into thinking we are a # The endpoints below fool OpenFlexure Connect into thinking we are a
# v2 microscope, so we show up correctly. # v2 microscope, so we show up correctly.
@ -15,7 +16,7 @@ def add_v2_endpoints(thing_server: ThingServer):
fake_routes = [ fake_routes = [
"/api/v2/", "/api/v2/",
"/api/v2/streams/snapshot", "/api/v2/streams/snapshot",
"/api/v2/instrument/settings/name" "/api/v2/instrument/settings/name",
] ]
return {url: {"url": url, "methods": ["GET"]} for url in fake_routes} return {url: {"url": url, "methods": ["GET"]} for url in fake_routes}

View file

@ -4,22 +4,23 @@ from fastapi import FastAPI
import os import os
import pathlib import pathlib
def add_static_file(app: FastAPI, fname: str, folder: str): def add_static_file(app: FastAPI, fname: str, folder: str):
print(f"Adding route for /{fname}") print(f"Adding route for /{fname}")
p=os.path.join(folder, fname) p = os.path.join(folder, fname)
app.get( app.get(f"/{fname}", response_class=FileResponse, include_in_schema=False)(
f"/{fname}", lambda: FileResponse(p)
response_class=FileResponse, )
include_in_schema=False
)(lambda: FileResponse(p))
def add_static_files(app: FastAPI): def add_static_files(app: FastAPI):
#with importlib.resources.as_file(openflexure_microscope_server) as p: # with importlib.resources.as_file(openflexure_microscope_server) as p:
# static_path = p.join("/static/") # static_path = p.join("/static/")
#TODO: don't hard code this! # TODO: don't hard code this!
search_paths = [ search_paths = [
"/var/openflexure/application/openflexure-microscope-server/src/openflexure_microscope_server/static", "/var/openflexure/application/openflexure-microscope-server/src/openflexure_microscope_server/static",
pathlib.Path().absolute() / "application/openflexure-microscope-server/src/openflexure_microscope_server/static", pathlib.Path().absolute()
/ "application/openflexure-microscope-server/src/openflexure_microscope_server/static",
] ]
if __file__: if __file__:
search_paths.append(pathlib.Path(__file__).parent.parent / "static") search_paths.append(pathlib.Path(__file__).parent.parent / "static")
@ -34,6 +35,7 @@ def add_static_files(app: FastAPI):
@app.get("/", response_class=RedirectResponse) @app.get("/", response_class=RedirectResponse)
async def redirect_fastapi(): async def redirect_fastapi():
return "/index.html" return "/index.html"
for fname in os.listdir(static_path): for fname in os.listdir(static_path):
fpath = os.path.join(static_path, fname) fpath = os.path.join(static_path, fname)
if os.path.isfile(fpath): if os.path.isfile(fpath):

View file

@ -11,6 +11,7 @@ from openflexure_microscope_server.things.camera_stage_mapping import CameraStag
CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/")
AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/")
class RecentringThing(Thing): class RecentringThing(Thing):
@thing_action @thing_action
def recentre( def recentre(

View file

@ -5,6 +5,7 @@ camera together to perform an autofocus routine.
See repository root for licensing information. See repository root for licensing information.
""" """
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager from contextlib import contextmanager
import logging import logging
@ -14,8 +15,6 @@ from typing import Annotated, Mapping, Optional, Sequence
from fastapi import Depends from fastapi import Depends
from labthings_fastapi.thing import Thing from labthings_fastapi.thing import Thing
from labthings_fastapi.dependencies.raw_thing import raw_thing_dependency
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
from labthings_fastapi.dependencies.blocking_portal import BlockingPortal from labthings_fastapi.dependencies.blocking_portal import BlockingPortal
from labthings_fastapi.decorators import thing_action from labthings_fastapi.decorators import thing_action
from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.types.numpy import NDArray
@ -28,8 +27,10 @@ from pydantic import BaseModel
### Autofocus utilities ### Autofocus utilities
class JPEGSharpnessMonitor: class JPEGSharpnessMonitor:
__globals__ = globals() # Required for FastAPI dependency __globals__ = globals() # Required for FastAPI dependency
def __init__(self, stage: Stage, camera: Camera, portal: BlockingPortal): def __init__(self, stage: Stage, camera: Camera, portal: BlockingPortal):
self.camera = camera self.camera = camera
self.stage = stage self.stage = stage
@ -75,7 +76,7 @@ class JPEGSharpnessMonitor:
# Index of the data for this movement # Index of the data for this movement
data_index: int = len(self.stage_positions) - 2 data_index: int = len(self.stage_positions) - 2
# Final z position after move # Final z position after move
final_z_position: int = self.stage_positions[-1]['z'] final_z_position: int = self.stage_positions[-1]["z"]
return data_index, final_z_position return data_index, final_z_position
def move_data( def move_data(
@ -86,12 +87,10 @@ class JPEGSharpnessMonitor:
istop = istart + 2 istop = istart + 2
jpeg_times: np.ndarray = np.array(self.jpeg_times) jpeg_times: np.ndarray = np.array(self.jpeg_times)
jpeg_sizes: np.ndarray = np.array(self.jpeg_sizes) jpeg_sizes: np.ndarray = np.array(self.jpeg_sizes)
stage_times: np.ndarray = np.array(self.stage_times)[ stage_times: np.ndarray = np.array(self.stage_times)[istart:istop]
istart:istop
]
stage_zs: np.ndarray = np.array( stage_zs: np.ndarray = np.array(
[p['z'] for p in self.stage_positions[istart:istop]] [p["z"] for p in self.stage_positions[istart:istop]]
) )
try: try:
start: int = int(np.argmax(jpeg_times > stage_times[0])) start: int = int(np.argmax(jpeg_times > stage_times[0]))
stop: int = int(np.argmax(jpeg_times > stage_times[1])) stop: int = int(np.argmax(jpeg_times > stage_times[1]))
@ -130,6 +129,7 @@ class JPEGSharpnessMonitor:
SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()] SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()]
class SharpnessDataArrays(BaseModel): class SharpnessDataArrays(BaseModel):
jpeg_times: NDArray jpeg_times: NDArray
jpeg_sizes: NDArray jpeg_sizes: NDArray
@ -142,8 +142,8 @@ class AutofocusThing(Thing):
def fast_autofocus( def fast_autofocus(
self, self,
m: SharpnessMonitorDep, m: SharpnessMonitorDep,
dz: int=2000, dz: int = 2000,
start: str='centre', start: str = "centre",
) -> SharpnessDataArrays: ) -> SharpnessDataArrays:
"""Sweep the stage up and down, then move to the sharpest point """Sweep the stage up and down, then move to the sharpest point
@ -153,7 +153,7 @@ class AutofocusThing(Thing):
""" """
with m.run(): with m.run():
# Move to (-dz / 2) # Move to (-dz / 2)
if start == 'centre': if start == "centre":
m.focus_rel(-dz / 2) m.focus_rel(-dz / 2)
# Move to dz while monitoring sharpness # Move to dz while monitoring sharpness
# i: Sharpness monitor index for this move # i: Sharpness monitor index for this move
@ -173,7 +173,7 @@ class AutofocusThing(Thing):
self, self,
m: SharpnessMonitorDep, m: SharpnessMonitorDep,
dz: Sequence[int], dz: Sequence[int],
wait: float=0, wait: float = 0,
) -> SharpnessDataArrays: ) -> SharpnessDataArrays:
"""Make a move (or a series of moves) and monitor sharpness """Make a move (or a series of moves) and monitor sharpness
@ -195,7 +195,9 @@ class AutofocusThing(Thing):
return m.data_dict() return m.data_dict()
@thing_action @thing_action
def looping_autofocus(self, stage: Stage, m: SharpnessMonitorDep, dz=2000, start='centre'): def looping_autofocus(
self, stage: Stage, m: SharpnessMonitorDep, dz=2000, start="centre"
):
"""Repeatedly autofocus the stage until it looks focused. """Repeatedly autofocus the stage until it looks focused.
This action will run the `fast_autofocus` action until it settles on a point This action will run the `fast_autofocus` action until it settles on a point
@ -209,10 +211,9 @@ class AutofocusThing(Thing):
with m.run(): with m.run():
while repeat and attempts < 10: while repeat and attempts < 10:
if start == "centre":
if start == 'centre': stage.move_relative(x=0, y=0, z=-(backlash + dz / 2))
stage.move_relative(x = 0, y = 0, z = -(backlash + dz / 2)) stage.move_relative(x=0, y=0, z=backlash)
stage.move_relative(x = 0, y = 0, z = backlash)
i, z = m.focus_rel(dz, block_cancellation=True) i, z = m.focus_rel(dz, block_cancellation=True)
_, heights, sizes = m.move_data(i) _, heights, sizes = m.move_data(i)
@ -226,22 +227,24 @@ class AutofocusThing(Thing):
or height_max - peak_height < dz / 5 or height_max - peak_height < dz / 5
): ):
attempts += 1 attempts += 1
start = 'centre' start = "centre"
stage.move_absolute(z = peak_height-backlash) stage.move_absolute(z=peak_height - backlash)
stage.move_absolute(z = peak_height) stage.move_absolute(z=peak_height)
else: else:
repeat = False repeat = False
stage.move_relative(x = 0, y = 0, z = -(dz+backlash)) stage.move_relative(x=0, y=0, z=-(dz + backlash))
stage.move_absolute(z = peak_height) stage.move_absolute(z=peak_height)
return heights.tolist(), sizes.tolist() return heights.tolist(), sizes.tolist()
@thing_action @thing_action
def verify_focus_sharpness(self, sweep_sizes: list, camera: WrappedCamera, threshold: float = 0.95): def verify_focus_sharpness(
'''Take the sharpness curve of the autofocus, and the size of the current frame self, sweep_sizes: list, camera: WrappedCamera, threshold: float = 0.95
):
"""Take the sharpness curve of the autofocus, and the size of the current frame
to see if the autofocus completed successfully. Returns True if current sharpness to see if the autofocus completed successfully. Returns True if current sharpness
is within "leniency" number of frames from the peak of the autofocus''' is within "leniency" number of frames from the peak of the autofocus"""
current_sharpness = camera.grab_jpeg_size(stream_name='lores') current_sharpness = camera.grab_jpeg_size(stream_name="lores")
peak = np.max(sweep_sizes) peak = np.max(sweep_sizes)
base = np.min(sweep_sizes) base = np.min(sweep_sizes)

View file

@ -5,6 +5,7 @@ should enabe the server to work.
See repository root for licensing information. See repository root for licensing information.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Literal, Protocol, runtime_checkable from typing import Literal, Protocol, runtime_checkable
@ -23,15 +24,14 @@ from labthings_fastapi.types.numpy import NDArray
class JPEGBlob(Blob): class JPEGBlob(Blob):
media_type: str = "image/jpeg" media_type: str = "image/jpeg"
@runtime_checkable @runtime_checkable
class CameraProtocol(Protocol): class CameraProtocol(Protocol):
"""A Thing representing a camera""" """A Thing representing a camera"""
def __enter__(self) -> None: def __enter__(self) -> None: ...
...
def __exit__(self, _exc_type, _exc_value, _traceback) -> None: def __exit__(self, _exc_type, _exc_value, _traceback) -> None: ...
...
@property @property
def stream_active(self) -> bool: def stream_active(self) -> bool:
@ -45,8 +45,7 @@ class CameraProtocol(Protocol):
def capture_array( def capture_array(
self, self,
resolution: Literal["lores", "main", "full"] = "main", resolution: Literal["lores", "main", "full"] = "main",
) -> NDArray: ) -> NDArray: ...
...
def capture_jpeg( def capture_jpeg(
self, self,
@ -78,6 +77,7 @@ class CameraProtocol(Protocol):
"""Acquire one image from the preview stream and return its size""" """Acquire one image from the preview stream and return its size"""
... ...
class BaseCamera(Thing): class BaseCamera(Thing):
"""A Thing representing a camera """A Thing representing a camera
@ -110,12 +110,16 @@ class BaseCamera(Thing):
stream (either "main" for the preview stream, or "lores" for the low stream (either "main" for the preview stream, or "lores" for the low
resolution preview). No metadata is returned. resolution preview). No metadata is returned.
""" """
logging.info(f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) starting") logging.info(
f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) starting"
)
stream = ( stream = (
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
) )
frame = portal.call(stream.grab_frame) frame = portal.call(stream.grab_frame)
logging.info(f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) got frame") logging.info(
f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) got frame"
)
return JPEGBlob.from_bytes(frame) return JPEGBlob.from_bytes(frame)
@thing_action @thing_action
@ -142,6 +146,7 @@ class CameraStub(BaseCamera):
This stub class should be used for dependencies on the CameraProtocol. This stub class should be used for dependencies on the CameraProtocol.
""" """
def __enter__(self) -> None: def __enter__(self) -> None:
raise NotImplementedError("Cameras must not inherit from CameraStub") raise NotImplementedError("Cameras must not inherit from CameraStub")

View file

@ -5,6 +5,7 @@ This module defines a camera Thing that uses OpenCV's
See repository root for licensing information. See repository root for licensing information.
""" """
from __future__ import annotations from __future__ import annotations
import io import io
import json import json
@ -26,7 +27,8 @@ from . import BaseCamera, JPEGBlob
class OpenCVCamera(BaseCamera): class OpenCVCamera(BaseCamera):
"""A Thing representing an OpenCV camera""" """A Thing representing an OpenCV camera"""
def __init__(self, camera_index: int=0):
def __init__(self, camera_index: int = 0):
self.camera_index = camera_index self.camera_index = camera_index
self._capture_thread: Optional[Thread] = None self._capture_thread: Optional[Thread] = None
self._capture_enabled = False self._capture_enabled = False
@ -50,6 +52,7 @@ class OpenCVCamera(BaseCamera):
if self._capture_enabled and self._capture_thread: if self._capture_enabled and self._capture_thread:
return self._capture_thread.is_alive() return self._capture_thread.is_alive()
return False return False
mjpeg_stream = MJPEGStreamDescriptor() mjpeg_stream = MJPEGStreamDescriptor()
lores_mjpeg_stream = MJPEGStreamDescriptor() lores_mjpeg_stream = MJPEGStreamDescriptor()
@ -58,11 +61,15 @@ class OpenCVCamera(BaseCamera):
while self._capture_enabled: while self._capture_enabled:
ret, frame = self.cap.read() ret, frame = self.cap.read()
if not ret: if not ret:
logging.error(f"Failed to capture frame from camera {self.camera_index}") logging.error(
f"Failed to capture frame from camera {self.camera_index}"
)
break break
jpeg = cv2.imencode(".jpg", frame)[1].tobytes() jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
self.mjpeg_stream.add_frame(jpeg, portal) self.mjpeg_stream.add_frame(jpeg, portal)
jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[1].tobytes() jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[
1
].tobytes()
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal) self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
@thing_action @thing_action
@ -78,7 +85,9 @@ class OpenCVCamera(BaseCamera):
""" """
ret, frame = self.cap.read() ret, frame = self.cap.read()
if not ret: if not ret:
raise RuntimeError(f"Failed to capture frame from camera {self.camera_index}") raise RuntimeError(
f"Failed to capture frame from camera {self.camera_index}"
)
return frame return frame
@thing_action @thing_action
@ -95,7 +104,9 @@ class OpenCVCamera(BaseCamera):
jpeg = cv2.imencode(".jpg", frame)[1].tobytes() jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
exif_dict = { exif_dict = {
"Exif": { "Exif": {
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode("utf-8") piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode(
"utf-8"
)
}, },
"GPS": {}, "GPS": {},
"Interop": {}, "Interop": {},

View file

@ -5,6 +5,7 @@ camera together to perform an autofocus routine.
See repository root for licensing information. See repository root for licensing information.
""" """
from __future__ import annotations from __future__ import annotations
import io import io
import json import json
@ -23,21 +24,35 @@ from labthings_fastapi.dependencies.metadata import GetThingStates
from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor
from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.types.numpy import NDArray
from labthings_fastapi.server import ThingServer from labthings_fastapi.server import ThingServer
from pydantic import RootModel
from . import BaseCamera, JPEGBlob from . import BaseCamera, JPEGBlob
from ..stage import StageProtocol as Stage from ..stage import StageProtocol as Stage
class ArrayModel(RootModel):
"""A model for an array"""
root: NDArray
class SimulatedCamera(BaseCamera): class SimulatedCamera(BaseCamera):
"""A Thing representing an OpenCV camera""" """A Thing representing an OpenCV camera"""
shape = (600, 800, 3)
glyph_shape = (51, 51, 3)
canvas_shape = (3000, 4000, 3)
frame_interval = 0.1
_stage: Optional[Stage] = None _stage: Optional[Stage] = None
_server: Optional[ThingServer] = None _server: Optional[ThingServer] = None
def __init__(self): def __init__(
self,
shape: tuple[int, int, int] = (600, 800, 3),
glyph_shape: tuple[int, int, int] = (51, 51, 3),
canvas_shape: tuple[int, int, int] = (3000, 4000, 3),
frame_interval: float = 0.1,
):
self.shape = shape
self.glyph_shape = glyph_shape
self.canvas_shape = canvas_shape
self.frame_interval = frame_interval
self._capture_thread: Optional[Thread] = None self._capture_thread: Optional[Thread] = None
self._capture_enabled = False self._capture_enabled = False
self.generate_sprites() self.generate_sprites()
@ -50,7 +65,7 @@ class SimulatedCamera(BaseCamera):
black = np.zeros(self.glyph_shape, dtype=np.uint8) black = np.zeros(self.glyph_shape, dtype=np.uint8)
x = np.arange(black.shape[0]) x = np.arange(black.shape[0])
y = np.arange(black.shape[1]) y = np.arange(black.shape[1])
rr = np.sqrt((x[:, None] - np.mean(x))**2 + (y[None, :] - np.mean(y))**2) rr = np.sqrt((x[:, None] - np.mean(x)) ** 2 + (y[None, :] - np.mean(y)) ** 2)
for i in [5, 7, 9, 11, 13, 15]: for i in [5, 7, 9, 11, 13, 15]:
sprite = black.copy() sprite = black.copy()
sprite[rr < i] = 255 sprite[rr < i] = 255
@ -65,8 +80,8 @@ class SimulatedCamera(BaseCamera):
self.blobs = np.zeros((N, 3)) self.blobs = np.zeros((N, 3))
rng = np.random.default_rng() rng = np.random.default_rng()
w = np.max(self.glyph_shape) w = np.max(self.glyph_shape)
self.blobs[:, 0] = rng.uniform(w/2, self.canvas_shape[0]-w/2, N) self.blobs[:, 0] = rng.uniform(w / 2, self.canvas_shape[0] - w / 2, N)
self.blobs[:, 1] = rng.uniform(w/2, self.canvas_shape[1]-w/2, N) self.blobs[:, 1] = rng.uniform(w / 2, self.canvas_shape[1] - w / 2, N)
self.blobs[:, 2] = rng.choice(len(self.sprites), N) self.blobs[:, 2] = rng.choice(len(self.sprites), N)
def generate_canvas(self): def generate_canvas(self):
@ -76,18 +91,24 @@ class SimulatedCamera(BaseCamera):
w, h, _ = self.glyph_shape w, h, _ = self.glyph_shape
for x, y, sprite in self.blobs: for x, y, sprite in self.blobs:
self.canvas[ self.canvas[
int(x) - w//2:int(x) - w//2 + w, int(x) - w // 2 : int(x) - w // 2 + w,
int(y) - h//2:int(y) - h//2 + h, int(y) - h // 2 : int(y) - h // 2 + h,
] -= self.sprites[int(sprite)] ] -= self.sprites[int(sprite)]
def generate_image(self, pos: tuple[int, int]): def generate_image(self, pos: tuple[int, int]):
"""Generate an image with blobs based on supplied coordinates""" """Generate an image with blobs based on supplied coordinates"""
cw, ch, _ = self.canvas_shape cw, ch, _ = self.canvas_shape
w, h, _ = self.shape w, h, _ = self.shape
tl = (int(pos[0]) - w//2 - cw//2, int(pos[1]) - h//2 - ch//2) tl = (int(pos[0]) - w // 2 - cw // 2, int(pos[1]) - h // 2 - ch // 2)
return self.canvas[ image = self.canvas[
tuple(slice(tl[i],tl[i] + self.shape[i]) for i in range(2)) + (slice(None),) tuple(slice(tl[i], tl[i] + self.shape[i]) for i in range(2))
+ (slice(None),)
] ]
if image.shape != self.shape:
raise ValueError(
f"Image shape {image.shape} does not match intended shape {self.shape}"
)
return image
def attach_to_server(self, server: ThingServer, path: str): def attach_to_server(self, server: ThingServer, path: str):
self._server = server self._server = server
@ -105,7 +126,7 @@ class SimulatedCamera(BaseCamera):
except Exception as e: except Exception as e:
print(f"Failed to get stage position: {e}") print(f"Failed to get stage position: {e}")
pos = {"x": 0, "y": 0} pos = {"x": 0, "y": 0}
return self.generate_image((pos["x"]/10, pos["y"]/10)) return self.generate_image((pos["x"] / 10, pos["y"] / 10))
def __enter__(self): def __enter__(self):
self._capture_enabled = True self._capture_enabled = True
@ -124,6 +145,7 @@ class SimulatedCamera(BaseCamera):
if self._capture_enabled and self._capture_thread: if self._capture_enabled and self._capture_thread:
return self._capture_thread.is_alive() return self._capture_thread.is_alive()
return False return False
mjpeg_stream = MJPEGStreamDescriptor() mjpeg_stream = MJPEGStreamDescriptor()
lores_mjpeg_stream = MJPEGStreamDescriptor() lores_mjpeg_stream = MJPEGStreamDescriptor()
@ -131,17 +153,22 @@ class SimulatedCamera(BaseCamera):
portal = get_blocking_portal(self) portal = get_blocking_portal(self)
while self._capture_enabled: while self._capture_enabled:
time.sleep(self.frame_interval) time.sleep(self.frame_interval)
frame = self.generate_frame() try:
jpeg = cv2.imencode(".jpg", frame)[1].tobytes() frame = self.generate_frame()
self.mjpeg_stream.add_frame(jpeg, portal) jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[1].tobytes() self.mjpeg_stream.add_frame(jpeg, portal)
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal) jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[
1
].tobytes()
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
except Exception as e:
logging.error(f"Failed to capture frame: {e}, retrying...")
@thing_action @thing_action
def capture_array( def capture_array(
self, self,
resolution: Literal["main", "full"] = "full", resolution: Literal["main", "full"] = "full",
) -> NDArray: ) -> ArrayModel:
"""Acquire one image from the camera and return as an array """Acquire one image from the camera and return as an array
This function will produce a nested list containing an uncompressed RGB image. This function will produce a nested list containing an uncompressed RGB image.
@ -164,7 +191,9 @@ class SimulatedCamera(BaseCamera):
jpeg = cv2.imencode(".jpg", frame)[1].tobytes() jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
exif_dict = { exif_dict = {
"Exif": { "Exif": {
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode("utf-8") piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode(
"utf-8"
)
}, },
"GPS": {}, "GPS": {},
"Interop": {}, "Interop": {},

View file

@ -10,9 +10,20 @@ and return the calibration data.
This module is only intended to be called from the OpenFlexure Microscope This module is only intended to be called from the OpenFlexure Microscope
server, and depends on that server and its underlying LabThings library. server, and depends on that server and its underlying LabThings library.
""" """
import logging
import time import time
from typing import Annotated, Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Sequence, Tuple from typing import (
Annotated,
Any,
Callable,
Dict,
List,
Mapping,
NamedTuple,
Optional,
Sequence,
Tuple,
)
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
import numpy as np import numpy as np
@ -21,7 +32,10 @@ from camera_stage_mapping.camera_stage_calibration_1d import (
calibrate_backlash_1d, calibrate_backlash_1d,
image_to_stage_displacement_from_1d, image_to_stage_displacement_from_1d,
) )
from labthings_fastapi.dependencies.invocation import InvocationCancelledError, InvocationLogger from labthings_fastapi.dependencies.invocation import (
InvocationCancelledError,
InvocationLogger,
)
from labthings_fastapi.types.numpy import NDArray, denumpify, DenumpifyingDict from labthings_fastapi.types.numpy import NDArray, denumpify, DenumpifyingDict
from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.decorators import thing_action, thing_property
from labthings_fastapi.thing import Thing from labthings_fastapi.thing import Thing
@ -32,6 +46,7 @@ from .stage import StageDependency as Stage
CoordinateType = Tuple[float, float, float] CoordinateType = Tuple[float, float, float]
XYCoordinateType = Tuple[float, float] XYCoordinateType = Tuple[float, float]
class HardwareInterfaceModel(BaseModel): class HardwareInterfaceModel(BaseModel):
move: Callable[[NDArray], None] move: Callable[[NDArray], None]
get_position: Callable[[], NDArray] get_position: Callable[[], NDArray]
@ -54,43 +69,60 @@ def downsample(factor: int, image: np.ndarray) -> np.ndarray:
new_size = [d // factor for d in image.shape[:2]] new_size = [d // factor for d in image.shape[:2]]
# First, we ensure we have something that's an integer multiple # First, we ensure we have something that's an integer multiple
# of `factor` # of `factor`
cropped = image[:new_size[0] * factor, :new_size[1] * factor, ...] cropped = image[: new_size[0] * factor, : new_size[1] * factor, ...]
reshaped = cropped.reshape( reshaped = cropped.reshape(
(new_size[0], factor, new_size[1], factor) + image.shape[2:] (new_size[0], factor, new_size[1], factor) + image.shape[2:]
) )
return reshaped.mean(axis=(1,3)) return reshaped.mean(axis=(1, 3))
DEFAULT_SETTLING_TIME = 0.2
def make_hardware_interface( def make_hardware_interface(
stage: Stage, camera: Camera, downsample_factor: int = 2 stage: Stage, camera: Camera, downsample_factor: int = 2
) -> HardwareInterfaceModel: ) -> HardwareInterfaceModel:
"""Construct the functions we need to interface with the hardware""" """Construct the functions we need to interface with the hardware"""
axes = stage.axis_names axes = stage.axis_names
def pos2dict(pos: Sequence[float]) -> Mapping[str, float]: def pos2dict(pos: Sequence[float]) -> Mapping[str, float]:
return {k: p for k, p in zip(axes, pos)} return {k: p for k, p in zip(axes, pos)}
def dict2pos(posd: Mapping[str, float]) -> Sequence[float]: def dict2pos(posd: Mapping[str, float]) -> Sequence[float]:
return tuple(posd[k] for k in axes if k in posd) return tuple(posd[k] for k in axes if k in posd)
def move(pos: CoordinateType) -> None: def move(pos: CoordinateType) -> None:
current_pos = stage.position current_pos = stage.position
new_pos = pos2dict(pos) new_pos = pos2dict(pos)
displacement = {k: new_pos[k] - current_pos[k] for k in new_pos.keys()} displacement = {k: new_pos[k] - current_pos[k] for k in new_pos.keys()}
stage.move_relative(**displacement) stage.move_relative(**displacement)
def get_position() -> CoordinateType: def get_position() -> CoordinateType:
return dict2pos(stage.position) return dict2pos(stage.position)
def grab_image() -> np.ndarray: def grab_image() -> np.ndarray:
img = camera.capture_array() img = camera.capture_array()
return downsample(downsample_factor, img) return downsample(downsample_factor, img)
def settle() -> None: def settle() -> None:
time.sleep(0.2) time.sleep(DEFAULT_SETTLING_TIME)
try: try:
camera.capture_metadata # This discards frames on a picamera camera.capture_metadata # This discards frames on a picamera
except AttributeError: except AttributeError:
pass # Don't raise an error for other cameras (may consider grabbing a frame) pass # Don't raise an error for other cameras (may consider grabbing a frame)
return HardwareInterfaceModel( return HardwareInterfaceModel(
move=move, get_position=get_position, grab_image=grab_image, settle=settle, grab_image_downsampling=downsample_factor move=move,
get_position=get_position,
grab_image=grab_image,
settle=settle,
grab_image_downsampling=downsample_factor,
) )
HardwareInterfaceDep = Annotated[HardwareInterfaceModel, Depends(make_hardware_interface)]
HardwareInterfaceDep = Annotated[
HardwareInterfaceModel, Depends(make_hardware_interface)
]
class MoveHistory(NamedTuple): class MoveHistory(NamedTuple):
@ -141,14 +173,16 @@ class CSMUncalibratedError(HTTPException):
( (
"The camera_stage_mapping calibration is not yet available. " "The camera_stage_mapping calibration is not yet available. "
"This probably means you need to run the calibration routine." "This probably means you need to run the calibration routine."
) ),
) )
class CameraStageMapper(Thing): class CameraStageMapper(Thing):
"""A Thing to manage mapping between image and stage coordinates""" """A Thing to manage mapping between image and stage coordinates"""
def __enter__(self): def __enter__(self):
pass pass
def __exit__(self, exc_type, exc_value, traceback): def __exit__(self, exc_type, exc_value, traceback):
self.thing_settings.write_to_file() self.thing_settings.write_to_file()
@ -161,13 +195,17 @@ class CameraStageMapper(Thing):
direction: Tuple[float, float, float], direction: Tuple[float, float, float],
) -> DenumpifyingDict: ) -> DenumpifyingDict:
"""Move a microscope's stage in 1D, and figure out the relationship with the camera""" """Move a microscope's stage in 1D, and figure out the relationship with the camera"""
move = LoggingMoveWrapper(hw.move) # log positions and times for stage calibration move = LoggingMoveWrapper(
hw.move
) # log positions and times for stage calibration
tracker = Tracker(hw.grab_image, hw.get_position, settle=hw.settle) tracker = Tracker(hw.grab_image, hw.get_position, settle=hw.settle)
direction_array: np.ndarray = np.array(direction) direction_array: np.ndarray = np.array(direction)
starting_position = stage.position starting_position = stage.position
try: try:
result: dict = calibrate_backlash_1d(tracker, move, direction_array, logger=logger) result: dict = calibrate_backlash_1d(
tracker, move, direction_array, logger=logger
)
except InvocationCancelledError as e: except InvocationCancelledError as e:
logger.info("Returning to starting position") logger.info("Returning to starting position")
stage.move_absolute(**starting_position, block_cancellation=True) stage.move_absolute(**starting_position, block_cancellation=True)
@ -202,7 +240,7 @@ class CameraStageMapper(Thing):
self.thing_settings["image_resolution"] = corrected_resolution self.thing_settings["image_resolution"] = corrected_resolution
csm_matrix = cal_xy["image_to_stage_displacement"] csm_matrix = cal_xy["image_to_stage_displacement"]
csm_as_string = f'[{round(csm_matrix[0][0], 2)}, {round(csm_matrix[0][1], 2)},],[{round(csm_matrix[1][0], 2)}, {round(csm_matrix[1][1], 2)}]' csm_as_string = f"[{round(csm_matrix[0][0], 2)}, {round(csm_matrix[0][1], 2)},],[{round(csm_matrix[1][0], 2)}, {round(csm_matrix[1][1], 2)}]"
logger.info(f"CSM matrix is {csm_as_string}.") logger.info(f"CSM matrix is {csm_as_string}.")
data: Dict[str, dict] = { data: Dict[str, dict] = {
@ -219,7 +257,9 @@ class CameraStageMapper(Thing):
return data return data
@thing_property @thing_property
def image_to_stage_displacement_matrix(self) -> Optional[List[List[float]]]: # 2x2 integer array def image_to_stage_displacement_matrix(
self,
) -> Optional[List[List[float]]]: # 2x2 integer array
"""A 2x2 matrix that converts displacement in image coordinates to stage coordinates. """A 2x2 matrix that converts displacement in image coordinates to stage coordinates.
Note that this matrix is defined using "matrix coordinates", i.e. image coordinates Note that this matrix is defined using "matrix coordinates", i.e. image coordinates
@ -254,8 +294,7 @@ class CameraStageMapper(Thing):
@thing_property @thing_property
def last_calibration(self) -> Optional[Dict]: def last_calibration(self) -> Optional[Dict]:
"""The results of the last calibration that was run """The results of the last calibration that was run"""
"""
return self.thing_settings.get("last_calibration", None) return self.thing_settings.get("last_calibration", None)
@thing_action @thing_action
@ -278,8 +317,7 @@ class CameraStageMapper(Thing):
""" """
self.assert_calibrated() self.assert_calibrated()
relative_move: np.ndarray = np.dot( relative_move: np.ndarray = np.dot(
np.array([y, x]), np.array([y, x]), np.array(self.image_to_stage_displacement_matrix)
np.array(self.image_to_stage_displacement_matrix)
) )
stage.move_relative(x=relative_move[0], y=relative_move[1]) stage.move_relative(x=relative_move[0], y=relative_move[1])

View file

@ -5,6 +5,7 @@ This module provides some settings management across the other Things, and
for code that currently lives in clients but needs to persist settings on for code that currently lives in clients but needs to persist settings on
the server. the server.
""" """
from collections.abc import Mapping from collections.abc import Mapping
from socket import gethostname from socket import gethostname
from typing import Annotated, Any, MutableMapping, Optional, Sequence from typing import Annotated, Any, MutableMapping, Optional, Sequence
@ -29,10 +30,7 @@ def recursive_update(old_dict: MutableMapping, update: Mapping):
"""Update a dictionary recursively""" """Update a dictionary recursively"""
for k, v in update.items(): for k, v in update.items():
if isinstance(v, Mapping): if isinstance(v, Mapping):
if ( if k in old_dict and isinstance(old_dict[k], MutableMapping):
k in old_dict
and isinstance(old_dict[k], MutableMapping)
):
recursive_update(old_dict[k], v) recursive_update(old_dict[k], v)
else: else:
old_dict[k] = dict(**v) old_dict[k] = dict(**v)
@ -57,7 +55,9 @@ class SettingsManager(Thing):
return self.thing_settings.get("external_metadata", {}) return self.thing_settings.get("external_metadata", {})
@thing_action @thing_action
def update_external_metadata(self, data: Mapping, key: Optional[str] = None) -> None: def update_external_metadata(
self, data: Mapping, key: Optional[str] = None
) -> None:
"""Add or replace keys in the external metadata. """Add or replace keys in the external metadata.
The data supplied will be merged into the existing dictionary The data supplied will be merged into the existing dictionary
@ -91,8 +91,7 @@ class SettingsManager(Thing):
del subdict[key.split("/")[-1]] del subdict[key.split("/")[-1]]
except KeyError: except KeyError:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404, detail="The specified key '{key}' was not found"
detail="The specified key '{key}' was not found"
) )
self.thing_settings["external_metadata"] = metadata self.thing_settings["external_metadata"] = metadata
@ -117,6 +116,7 @@ class SettingsManager(Thing):
def external_metadata_in_state(self) -> Sequence[str]: def external_metadata_in_state(self) -> Sequence[str]:
"""A list of strings that are included in the "state" metadata""" """A list of strings that are included in the "state" metadata"""
return self.thing_settings.get("external_metadata_in_state", []) return self.thing_settings.get("external_metadata_in_state", [])
@external_metadata_in_state.setter @external_metadata_in_state.setter
def external_metadata_in_state(self, keys: Sequence[str]): def external_metadata_in_state(self, keys: Sequence[str]):
"""Set the keys from external metadata that are returned in state""" """Set the keys from external metadata that are returned in state"""
@ -140,7 +140,9 @@ class SettingsManager(Thing):
return state return state
@thing_action @thing_action
def save_all_thing_settings(self, thing_server: ThingServerDep, logger: InvocationLogger) -> None: def save_all_thing_settings(
self, thing_server: ThingServerDep, logger: InvocationLogger
) -> None:
"""Ensure all the Things sync their settings to disk. """Ensure all the Things sync their settings to disk.
Normally, each Thing has a `thing_settings` attribute that can be Normally, each Thing has a `thing_settings` attribute that can be
@ -161,7 +163,4 @@ class SettingsManager(Thing):
f"Could not write {name} settings to disk: permission error." f"Could not write {name} settings to disk: permission error."
) )
except FileNotFoundError: except FileNotFoundError:
logger.warning( logger.warning(f"Could not write {name} settings, folder not found")
f"Could not write {name} settings, folder not found"
)

View file

@ -1,3 +1,5 @@
# ruff: noqa: E722
import shutil import shutil
import zipfile import zipfile
import threading import threading
@ -13,19 +15,21 @@ from pydantic import BaseModel
from scipy.stats import norm from scipy.stats import norm
from scipy.ndimage import zoom from scipy.ndimage import zoom
from scipy.interpolate import interp1d from scipy.interpolate import interp1d
from copy import deepcopy
from datetime import datetime 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 from threading import Event, Thread
import glob import glob
import zipfile
import json import json
import piexif import piexif
from labthings_fastapi.thing import Thing from labthings_fastapi.thing import Thing
from labthings_fastapi.dependencies.metadata import GetThingStates from labthings_fastapi.dependencies.metadata import GetThingStates
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency 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.decorators import thing_action, thing_property, fastapi_endpoint
from labthings_fastapi.outputs.blob import blob_type from labthings_fastapi.outputs.blob import blob_type
from .camera import CameraDependency as CamDep 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" return "accept"
def distance_to_site(current, next): # def distance_to_site(current, next):
current = np.array(current, dtype="float64") # current = np.array(current, dtype="float64")
next = np.array(next, dtype="float64") # next = np.array(next, dtype="float64")
if (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2 < 0: # if (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2 < 0:
print(f"Negative distance between {next} and {current}") # print(f"Negative distance between {next} and {current}")
return np.sqrt( # return np.sqrt(
(next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2, dtype="float64" # (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2, dtype="float64"
) # )
def steps_from_centre(current_loc, starting_loc, dx, dy): 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))) return np.max(np.abs(np.divide(np.subtract(current_loc, starting_loc), step_size)))
# def set_template(microscope, pos): # def set_template(microscope, pos):
# microscope.move(pos) # microscope.move(pos)
# background = microscope.grab_image_array() # background = microscope.grab_image_array()
@ -145,41 +151,60 @@ def distance_to_site(current, next):
current = np.array(current, dtype="float64") current = np.array(current, dtype="float64")
return np.sqrt((next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2) return np.sqrt((next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2)
def scale_csm(csm_matrix, calibration_width, img_width): def scale_csm(csm_matrix, calibration_width, img_width):
"Account for a calibration width that may differ from image 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 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] csm = np.array(csm_matrix) / scale # Decrease the CSM if pixels are smaller]
return csm 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) 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: 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') 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)): for i in range(len(names)):
loc = np.dot((positions[i] - mean_loc), np.linalg.inv(camera_to_sample_matrix)) loc = np.dot(
fp.write(f'{names[i]}; ; {loc[1], loc[0]} \n') (positions[i] - mean_loc), np.linalg.inv(camera_to_sample_matrix)
)
fp.write(f"{names[i]}; ; {loc[1], loc[0]} \n")
def raw2rggb(raw): def raw2rggb(raw):
"""Convert packed 10 bit raw to RGGB 8 bit""" """Convert packed 10 bit raw to RGGB 8 bit"""
raw = np.asarray(raw) # ensure it's an array raw = np.asarray(raw) # ensure it's an array
rggb = np.empty((616, 820, 4), dtype=np.uint8) rggb = np.empty((616, 820, 4), dtype=np.uint8)
raw_w = rggb.shape[1]//2*5 raw_w = rggb.shape[1] // 2 * 5
for plane, offset in enumerate([(1,1), (0,1), (1,0), (0,0)]): 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[:, ::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] rggb[:, 1::2, plane] = raw[
offset[0] :: 2, offset[1] + 2 : raw_w + offset[1] + 2 : 5
]
return rggb return rggb
def rggb2rgb(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): class ChannelDistributions(BaseModel):
@ -187,6 +212,7 @@ class ChannelDistributions(BaseModel):
standard_deviations: list[float] standard_deviations: list[float]
colorspace: str = "LUV" colorspace: str = "LUV"
class BackgroundDetectThing(Thing): class BackgroundDetectThing(Thing):
@thing_property @thing_property
def background_distributions(self) -> Optional[ChannelDistributions]: def background_distributions(self) -> Optional[ChannelDistributions]:
@ -230,10 +256,13 @@ class BackgroundDetectThing(Thing):
""" """
d = self.background_distributions d = self.background_distributions
if not d: 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( return np.all(
np.abs(image - np.array(d.means)[np.newaxis, np.newaxis, :]) 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, axis=2,
) )
@ -289,9 +318,9 @@ class BackgroundDetectThing(Thing):
mu, std = np.apply_along_axis(norm.fit, 0, points) mu, std = np.apply_along_axis(norm.fit, 0, points)
self.background_distributions = ChannelDistributions( self.background_distributions = ChannelDistributions(
means = mu.tolist(), means=mu.tolist(),
standard_deviations = std.tolist(), standard_deviations=std.tolist(),
colorspace = "LUV", colorspace="LUV",
) )
@property @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): class NotEnoughFreeSpaceError(IOError):
@ -322,20 +353,20 @@ def ensure_free_disk_space(path: str, min_space: int = 500000000) -> None:
class ScanInfo(BaseModel): class ScanInfo(BaseModel):
""""Summary information about a scan folder""" """ "Summary information about a scan folder"""
name: str name: str
created: datetime created: datetime
modified: datetime modified: datetime
number_of_images: int number_of_images: int
DOWNLOADABLE_SCAN_FILES = ( DOWNLOADABLE_SCAN_FILES = ("images.zip",)
"images.zip",
)
JPEGBlob = blob_type("image/jpeg") JPEGBlob = blob_type("image/jpeg")
ZipBlob = blob_type("application/zip") ZipBlob = blob_type("application/zip")
class SmartScanThing(Thing): class SmartScanThing(Thing):
def __init__(self, path_to_openflexure_stitch: str): def __init__(self, path_to_openflexure_stitch: str):
self._script = path_to_openflexure_stitch self._script = path_to_openflexure_stitch
@ -352,12 +383,13 @@ class SmartScanThing(Thing):
return "scans" return "scans"
_latest_scan_name = None _latest_scan_name = None
@thing_property @thing_property
def latest_scan_name(self) -> Optional[str]: def latest_scan_name(self) -> Optional[str]:
"""The name of the last scan to be started.""" """The name of the last scan to be started."""
return self._latest_scan_name 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""" """The path to the scan folder with a given name"""
if not scan_name: if not scan_name:
if not self.latest_scan_name: if not self.latest_scan_name:
@ -365,7 +397,7 @@ class SmartScanThing(Thing):
scan_name = self.latest_scan_name scan_name = self.latest_scan_name
return os.path.join(self.scans_folder_path, 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 """Create a new empty folder, into which we can save scan images
The folder will be named `{scan_name}_000001/` where the number is The folder will be named `{scan_name}_000001/` where the number is
@ -389,14 +421,13 @@ class SmartScanThing(Thing):
return folder_path return folder_path
raise FileExistsError("Could not create a new scan folder: all names in use!") raise FileExistsError("Could not create a new scan folder: all names in use!")
def move_to_next_point( def move_to_next_point(
self, self,
stage: StageDep, stage: StageDep,
logger: InvocationLogger, logger: InvocationLogger,
path: list[list[int]], path: list[list[int]],
focused_path: list[list[int]], focused_path: list[list[int]],
) -> list[int]: ) -> list[int]:
"""Remove the first point from the path, and move there. """Remove the first point from the path, and move there.
This will move to the next XY position in `path`, taking the `z` value This will move to the next XY position in `path`, taking the `z` value
@ -412,9 +443,7 @@ class SmartScanThing(Thing):
else: else:
z = stage.position["z"] z = stage.position["z"]
logger.info(f"Moving to {loc}") logger.info(f"Moving to {loc}")
stage.move_absolute( stage.move_absolute(x=int(loc[0]), y=int(loc[1]), z=z - self.autofocus_dz / 2)
x=int(loc[0]), y=int(loc[1]), z = z - self.autofocus_dz / 2
)
return loc + [z] return loc + [z]
@thing_action @thing_action
@ -429,7 +458,7 @@ class SmartScanThing(Thing):
csm: CSMDep, csm: CSMDep,
background_detect: BackgroundDep, background_detect: BackgroundDep,
recentre: RecentreStage, recentre: RecentreStage,
scan_name: str="", scan_name: str = "",
): ):
"""Move the stage to cover an area, taking images that can be tiled together. """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 max_dist = self.max_range
if self.autofocus_dz == 0: if self.autofocus_dz == 0:
logger.info(f'Running scan without autofocus') logger.info("Running scan without autofocus")
elif self.autofocus_dz <= 200: 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: if self.skip_background:
d = background_detect.background_distributions d = background_detect.background_distributions
if not d: 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: else:
logger.warning( logger.warning(
"This scan will run in a spiral from the starting point " "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 " "in every direction. Make sure you watch it run to stop it leaving "
"the area of interest, or (worse) leading the microscope's range " "the area of interest, or (worse) leading the microscope's range "
"of motion." "of motion."
) )
names = [] names = []
positions = [] positions = []
@ -495,14 +528,20 @@ class SmartScanThing(Thing):
# TODO: generalise to have 2D displacements for x and y (as the # TODO: generalise to have 2D displacements for x and y (as the
# camera and stage may not be aligned). # camera and stage may not be aligned).
CSM = csm.image_to_stage_displacement_matrix 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 overlap = self.overlap
dx = int(np.abs(np.dot(np.array([0, arr.shape[1] * (1 - overlap)]), CSM)[0])) dx = int(
dy = int(np.abs(np.dot(np.array([arr.shape[0] * (1 - overlap), 0]), CSM)[1])) 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 # construct a 2D scan path
path = [[stage.position["x"], stage.position["y"]]] 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 focused_path = [] # This holds a list of all points where focus succeeded
true_path = [] # This holds a list of all points visited true_path = [] # This holds a list of all points visited
i = 0 i = 0
ids = []
start_time = time.strftime("%H_%M_%S-%d_%m_%Y") start_time = time.strftime("%H_%M_%S-%d_%m_%Y")
scan_folder = self.new_scan_folder(scan_name) scan_folder = self.new_scan_folder(scan_name)
@ -521,16 +559,18 @@ class SmartScanThing(Thing):
logger.info(f"Saving images to {images_folder}") logger.info(f"Saving images to {images_folder}")
data = { data = {
'scan_name' : scan_name, "scan_name": scan_name,
'overlap' : overlap, "overlap": overlap,
'autofocus range' : self.autofocus_dz, "autofocus range": self.autofocus_dz,
'dx' : dx, "dx": dx,
'dy' : dy, "dy": dy,
'start time' : start_time, "start time": start_time,
'skipping background' : self.skip_background "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) json.dump(data, f, ensure_ascii=False, indent=4)
# We will capture images and process them with this function, defined once here. # We will capture images and process them with this function, defined once here.
@ -538,29 +578,41 @@ class SmartScanThing(Thing):
# that change each iteration. # that change each iteration.
# We also pre-calculate a normalisation image based on the LST and white balance # We also pre-calculate a normalisation image based on the LST and white balance
raw_image = cam.capture_array(stream_name="raw") 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)) rgb = rggb2rgb(raw2rggb(raw_image))
lst = dict(cam.lens_shading_tables) lst = dict(cam.lens_shading_tables)
lum = np.array(lst["luminance"]) lum = np.array(lst["luminance"])
Cr = np.array(lst["Cr"]) Cr = np.array(lst["Cr"])
Cb = np.array(lst["Cb"]) Cb = np.array(lst["Cb"])
gr, gb = cam.colour_gains gr, gb = cam.colour_gains
G = 1/lum G = 1 / lum
R = G/Cr/gr*np.min(Cr) # The extra /np.max(Cr) emulates the quirky handling of Cr in R = (
B = G/Cb/gb*np.min(Cb) # the picamera2 pipeline 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) 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)] zoom_factors = [
white_norm = zoom(white_norm_lores, zoom_factors, order=1)[:rgb.shape[0], :rgb.shape[1], :] # Could use some work i / n for i, n in zip(rgb[..., :3].shape, white_norm_lores.shape)
colour_correction_matrix = np.array(cam.colour_correction_matrix).reshape((3,3)) ]
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"] contrast_algorithm = cam.tuning["algorithms"][9]["rpi.contrast"]
gamma = np.array(contrast_algorithm["gamma_curve"]).reshape((-1,2)) gamma = np.array(contrast_algorithm["gamma_curve"]).reshape((-1, 2))
gamma_8bit = interp1d(gamma[:, 0]/255, gamma[:, 1]/255) gamma_8bit = interp1d(gamma[:, 0] / 255, gamma[:, 1] / 255)
def process_raw_image(img): def process_raw_image(img):
normed = img/white_norm normed = img / white_norm
corrected = np.dot(colour_correction_matrix, normed.reshape((-1, 3)).T).T.reshape(normed.shape) corrected = np.dot(
colour_correction_matrix, normed.reshape((-1, 3)).T
).T.reshape(normed.shape)
corrected[corrected < 0] = 0 corrected[corrected < 0] = 0
corrected[corrected > 255] = 255 corrected[corrected > 255] = 255
return gamma_8bit(corrected) return gamma_8bit(corrected)
logger.info( logger.info(
f"Generated normalisation image with shape {white_norm.shape}, " f"Generated normalisation image with shape {white_norm.shape}, "
f"max {white_norm.max(axis=(0,1))}, min {white_norm.min(axis=(0,1))}" 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_red": gr,
"gain_blue": gb, "gain_blue": gb,
} }
def capture_and_save(acquired: Event, name: str) -> None: def capture_and_save(acquired: Event, name: str) -> None:
"""Capture an image and save it to disk """Capture an image and save it to disk
@ -585,32 +638,42 @@ class SmartScanThing(Thing):
acquired.set() acquired.set()
acquisition_time = time.time() acquisition_time = time.time()
# Save the raw image # 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 # Process it into 8 bit RGB
processed = process_raw_image(rggb2rgb(raw2rggb(raw_image))) processed = process_raw_image(rggb2rgb(raw2rggb(raw_image)))
processed[processed > 255] = 255 processed[processed > 255] = 255
processed[processed < 0] = 0 processed[processed < 0] = 0
img = Image.fromarray(processed.astype(np.uint8), mode="RGB") img = Image.fromarray(processed.astype(np.uint8), mode="RGB")
img.save( img.save(
os.path.join(images_folder, name), os.path.join(images_folder, name), quality=95, subsampling=0
quality=95,
subsampling=0
) )
exif_dict = piexif.load(os.path.join(images_folder, name)) exif_dict = piexif.load(os.path.join(images_folder, name))
exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps( exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps(
metadata metadata
).encode("utf-8") ).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() 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: 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. # 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 # We skip capturing on the first run, because we've not focused yet - and also we skip capturing if
# it looks like background. # it looks like background.
while len(path) > 0: 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(): if not self.preview_stitch_running():
self.preview_stitch_start(images_folder) self.preview_stitch_start(images_folder)
if self.stitch_automatically: 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 more than 92% of the image is background, treat it as background and continue
if not image_is_sample: if not image_is_sample:
logger.info(f"Skipping {stage.position} as it is {round(background_detect.background_fraction(),0)}% background.") logger.info(
capture_image = False f"Skipping {stage.position} as it is {round(background_detect.background_fraction(),0)}% background."
)
else: else:
capture_image = True
# if not, it's sample. run an autofocus and use the updated height # if not, it's sample. run an autofocus and use the updated height
new_pos = [ new_pos = [
[stage.position["x"] - dx, stage.position["y"]], [stage.position["x"] - dx, stage.position["y"]],
@ -648,17 +711,25 @@ class SmartScanThing(Thing):
attempts = 0 attempts = 0
if self.autofocus_dz > 200: if self.autofocus_dz > 200:
while True: 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"] current_height = stage.position["z"]
time.sleep(0.2) time.sleep(0.2)
autofocus_success = autofocus.verify_focus_sharpness(sweep_sizes = jpeg_sizes, camera = CamDep, threshold = 0.92) autofocus_success = autofocus.verify_focus_sharpness(
logger.info(f"We just tested the focus! Result was {autofocus_success}") 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 autofocus_success:
# if there have been successful autofocuses in this scan, find the closest one in x-y # 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) # test if the change in z between them exceeds a ratio (indicating a failed autofocus)
if len(focused_path) > 0: 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( result = limit_focus_change(
nearest_focused_site[0:2], nearest_focused_site[0:2],
nearest_focused_site[-1], nearest_focused_site[-1],
@ -694,31 +765,33 @@ class SmartScanThing(Thing):
wait_start = time.time() wait_start = time.time()
capture_thread.join() capture_thread.join()
wait_time = time.time() - wait_start 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() acquired = Event()
name = f"image_{loc[0]}_{loc[1]}.jpg" name = f"image_{loc[0]}_{loc[1]}.jpg"
time.sleep(0.2) time.sleep(0.2)
capture_thread = Thread( capture_thread = Thread(
target=capture_and_save, target=capture_and_save,
kwargs={ kwargs={
# "cam": cam, # "cam": cam,
# "logger": logger, # "logger": logger,
"acquired": acquired, "acquired": acquired,
"name": name, "name": name,
# "images_folder": images_folder, # "images_folder": images_folder,
# "raw_images_folder": raw_images_folder, # "raw_images_folder": raw_images_folder,
} },
) )
capture_thread.start() capture_thread.start()
acquired.wait() # wait until the image is acquired acquired.wait() # wait until the image is acquired
#time.sleep(0.5) # time.sleep(0.5)
positions.append(loc[:2]) positions.append(loc[:2])
names.append(name) names.append(name)
# add the current position to the list of all positions visited # add the current position to the list of all positions visited
true_path.append(loc) 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) # generate_config(images_folder, positions, names, CSM, csm_calibration_width, img_width, logger)
temp_path = [] temp_path = []
@ -727,10 +800,20 @@ class SmartScanThing(Thing):
if distance_to_site(i, true_path[0][:2]) < max_dist: if distance_to_site(i, true_path[0][:2]) < max_dist:
temp_path.append(i) temp_path.append(i)
else: 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 = 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))) path = sorted(
self.create_zip_of_scan(logger = logger, scan_name = scan_folder.split('scans/')[1], download_zip = False) 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: except InvocationCancelledError:
logger.error("Stopping scan because it was cancelled.") logger.error("Stopping scan because it was cancelled.")
@ -743,8 +826,7 @@ class SmartScanThing(Thing):
except Exception as e: except Exception as e:
logger.error( logger.error(
f"The scan stopped because of an error: {e}", f"The scan stopped because of an error: {e}",
"We will attempt to stitch and archive the images acquired " "We will attempt to stitch and archive the images acquired " "so far.",
"so far.",
exc_info=e, exc_info=e,
) )
raise e raise e
@ -757,14 +839,20 @@ class SmartScanThing(Thing):
stage.move_absolute(**starting_position, block_cancellation=True) stage.move_absolute(**starting_position, block_cancellation=True)
finally: finally:
self._scan_lock.release() 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...") logger.info("Waiting for background processes to finish...")
self.preview_stitch_wait() self.preview_stitch_wait()
self.correlate_wait() self.correlate_wait()
try: try:
if scan_folder and self.stitch_automatically: if scan_folder and self.stitch_automatically:
logger.info("Stitching final image (may take some time)...") 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: except SubprocessError as e:
logger.error(f"Stitching failed: {e}", exc_info=e) logger.error(f"Stitching failed: {e}", exc_info=e)
@ -848,26 +936,26 @@ class SmartScanThing(Thing):
number_of_images = 0 number_of_images = 0
scans.append( scans.append(
ScanInfo( ScanInfo(
name = f, name=f,
created = os.path.getctime(path), created=os.path.getctime(path),
modified = os.path.getmtime(path), modified=os.path.getmtime(path),
number_of_images = number_of_images, number_of_images=number_of_images,
) )
) )
return scans return scans
@fastapi_endpoint( @fastapi_endpoint(
"get", "get",
"scans/{scan_name}/{file}", "scans/{scan_name}/{file}",
responses = { responses={
200: { 200: {
"description": "Successfully downloading file", "description": "Successfully downloading file",
"content": {"*/*": {}} "content": {"*/*": {}},
},
403: {"description": "Filename not permitted"},
404: {"description": "File not found"}
}, },
) 403: {"description": "Filename not permitted"},
404: {"description": "File not found"},
},
)
def get_scan_file(self, scan_name: str, file: str) -> FileResponse: def get_scan_file(self, scan_name: str, file: str) -> FileResponse:
"""Retrieve a file from a scan. """Retrieve a file from a scan.
@ -887,7 +975,7 @@ class SmartScanThing(Thing):
@fastapi_endpoint( @fastapi_endpoint(
"delete", "delete",
"scans/{scan_name}", "scans/{scan_name}",
responses = { responses={
200: {"description": "Successfully deleted scan"}, 200: {"description": "Successfully deleted scan"},
404: {"description": "Scan not found"}, 404: {"description": "Scan not found"},
}, },
@ -917,7 +1005,7 @@ class SmartScanThing(Thing):
for scan in self.scans: for scan in self.scans:
self.delete_scan(scan.name) 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) scan_folder = self.scan_folder_path(scan_name=scan_name)
return os.path.join(scan_folder, "images") return os.path.join(scan_folder, "images")
@ -941,25 +1029,25 @@ class SmartScanThing(Thing):
return None return None
@fastapi_endpoint( @fastapi_endpoint(
"get", "get",
"latest_preview_stitch.jpg", "latest_preview_stitch.jpg",
responses = { responses={
200: { 200: {
"description": "A preview-quality stitched image", "description": "A preview-quality stitched image",
"content": {"image/jpeg": {}} "content": {"image/jpeg": {}},
},
404: {"description": "File not found"}
}, },
) 404: {"description": "File not found"},
},
)
def get_latest_preview(self) -> FileResponse: def get_latest_preview(self) -> FileResponse:
"""Retrieve the latest preview image. """Retrieve the latest preview image."""
"""
path = self.latest_preview_stitch_path path = self.latest_preview_stitch_path
if not os.path.isfile(path): if not os.path.isfile(path):
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
return FileResponse(path) return FileResponse(path)
_preview_stitch_popen = None _preview_stitch_popen = None
def preview_stitch_start(self, images_folder: str) -> None: def preview_stitch_start(self, images_folder: str) -> None:
"""Start stitching a preview of the scan in a subprocess""" """Start stitching a preview of the scan in a subprocess"""
if self.preview_stitch_running(): if self.preview_stitch_running():
@ -984,13 +1072,21 @@ class SmartScanThing(Thing):
self._preview_stitch_popen.wait() self._preview_stitch_popen.wait()
_correlate_popen = None _correlate_popen = None
def correlate_start(self, images_folder: str, overlap: float = 0.1) -> None: def correlate_start(self, images_folder: str, overlap: float = 0.1) -> None:
"""Start stitching a preview of the scan in a subprocess""" """Start stitching a preview of the scan in a subprocess"""
if self.correlate_running(): if self.correlate_running():
raise RuntimeError("Only one subprocess is allowed at a time") raise RuntimeError("Only one subprocess is allowed at a time")
with self._correlate_popen_lock: with self._correlate_popen_lock:
self._correlate_popen = Popen( 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: def correlate_running(self) -> bool:
@ -1008,19 +1104,20 @@ class SmartScanThing(Thing):
self._correlate_popen.wait() self._correlate_popen.wait()
def run_subprocess( def run_subprocess(
self, logger: InvocationLogger, cmd: list[str], self,
) -> CompletedProcess: logger: InvocationLogger,
cmd: list[str],
) -> CompletedProcess:
"""Run a subprocess and log any output""" """Run a subprocess and log any output"""
logger.info(f"Running command in subprocess: `{' '.join(cmd)}`") logger.info(f"Running command in subprocess: `{' '.join(cmd)}`")
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) os.set_blocking(p.stdout.fileno(), False)
logger.info(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))) logger.info(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())))
while p.poll() == None: while p.poll() is None:
try: try:
output = p.stdout.readline() output = p.stdout.readline()
if output != "" and output != None: if output != "" and output is not None:
logger.info(output) logger.info(output)
except: except:
pass pass
@ -1028,46 +1125,72 @@ class SmartScanThing(Thing):
for line in p.stdout: for line in p.stdout:
try: try:
output = p.stdout.readline() output = p.stdout.readline()
if output != "" and output != None: if output != "" and output is not None:
logger.info(output) logger.info(output)
except: except:
pass pass
logger.info('Stitching complete') logger.info("Stitching complete")
return p return p
@thing_action @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""" """Generate a stitched image based on stage position metadata"""
images_folder = self.images_folder(scan_name=scan_name) images_folder = self.images_folder(scan_name=scan_name)
if self.stitch_tiff: if self.stitch_tiff:
tiff_arg = '--stitch_tiff' tiff_arg = "--stitch_tiff"
else: else:
tiff_arg = '--no-stitch_tiff' tiff_arg = "--no-stitch_tiff"
if overlap == 0.0: if overlap == 0.0:
try: 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) data_loaded = json.load(data_file)
logger.info(data_loaded) logger.info(data_loaded)
overlap = data_loaded['overlap'] overlap = data_loaded["overlap"]
except: except:
overlap = 0.1 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 @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.""" """Generate a zip file that can be downloaded, with all the scan files in it."""
images_folder = self.images_folder(scan_name=scan_name) images_folder = self.images_folder(scan_name=scan_name)
scan_folder = self.scan_folder_path(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( logger.error(
"There is a problem with filenames, the archive may be incorrect." "There is a problem with filenames, the archive may be incorrect."
f"scan_folder: {scan_folder}, images_folder: {images_folder}." f"scan_folder: {scan_folder}, images_folder: {images_folder}."
) )
if not os.path.isdir(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)...") # logger.info("Creating zip archive of images (may take some time)...")
zip_fname = f'{os.path.join(scan_folder, "images")}.zip' 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 # get a list of files in the folder we're zipping
folder_path = self.scan_folder_path(scan_name) folder_path = self.scan_folder_path(scan_name)
files = glob.glob(folder_path + '/**/*', recursive=True) files = glob.glob(folder_path + "/**/*", recursive=True)
files = [i.split(f'{folder_path}/')[1] for i in files] 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, # 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 # 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 # 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 = "" tiff_name = ""
with zipfile.ZipFile(zip_fname, mode="a") as zip: with zipfile.ZipFile(zip_fname, mode="a") as zip:
for file in files: for file in files:
if 'stitched.jp' in file: if "stitched.jp" in file:
stitch_name = os.path.split(file)[1] stitch_name = os.path.split(file)[1]
if '.ome.tiff' in file: if ".ome.tiff" in file:
tiff_name = os.path.split(file)[1] tiff_name = os.path.split(file)[1]
if any(banned_name in file for banned_name in files_to_delay): 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') # 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: elif file in current_zip:
# logger.info(f'{file} is already in zip') # logger.info(f'{file} is already in zip')
pass 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') # logger.info('Not adding the .zip to itself')
pass pass
else: 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) 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) # 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 # and finally zip some of the final files
# TODO: if you download multiple times, you get duplicate files - is this a problem? # 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]: for fname in ["stitched_from_stage.jpg", stitch_name, tiff_name]:
fpath = os.path.join(images_folder, fname) fpath = os.path.join(images_folder, fname)
if os.path.exists(fpath): 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) zip.write(fpath, arcname=fname)
for file in files: for file in files:
if any(banned_name in file for banned_name in files_to_delay): 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) 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) return ZipBlob.from_file(zip_fname)
@thing_action @thing_action
@ -1138,4 +1266,3 @@ class SmartScanThing(Thing):
zip = [os.path.normpath(i) for i in zip.namelist()] zip = [os.path.normpath(i) for i in zip.namelist()]
return zip return zip

View file

@ -7,10 +7,11 @@ from labthings_fastapi.dependencies.invocation import CancelHook
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
from collections.abc import Sequence, Mapping from collections.abc import Sequence, Mapping
@runtime_checkable @runtime_checkable
class StageProtocol(Protocol): class StageProtocol(Protocol):
"""A protocol for the OpenFlexure translation stage """A protocol for the OpenFlexure translation stage"""
"""
_axis_names: Sequence[str] _axis_names: Sequence[str]
@property @property
@ -33,11 +34,21 @@ class StageProtocol(Protocol):
"""Summary metadata describing the current state of the stage""" """Summary metadata describing the current state of the stage"""
... ...
def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): def move_relative(
self,
cancel: CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
"""Make a relative move. Keyword arguments should be axis names.""" """Make a relative move. Keyword arguments should be axis names."""
... ...
def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): def move_absolute(
self,
cancel: CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
"""Make an absolute move. Keyword arguments should be axis names.""" """Make an absolute move. Keyword arguments should be axis names."""
... ...
@ -59,6 +70,7 @@ class BaseStage(Thing):
`move_relative` and `move_absolute` actions, which update the `move_relative` and `move_absolute` actions, which update the
`position` property on completion, and provide `set_zero_position`. `position` property on completion, and provide `set_zero_position`.
""" """
_axis_names = ("x", "y", "z") _axis_names = ("x", "y", "z")
@thing_property @thing_property
@ -85,9 +97,7 @@ class BaseStage(Thing):
@property @property
def thing_state(self): def thing_state(self):
"""Summary metadata describing the current state of the stage""" """Summary metadata describing the current state of the stage"""
return { return {"position": self.position}
"position": self.position
}
class StageStub(BaseStage): class StageStub(BaseStage):
@ -98,13 +108,24 @@ class StageStub(BaseStage):
methods/properties are not decorated as Affordances. This stub class methods/properties are not decorated as Affordances. This stub class
is a workaround for that limitation, and should not be used directly. is a workaround for that limitation, and should not be used directly.
""" """
@thing_action @thing_action
def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): def move_relative(
self,
cancel: CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
"""Make a relative move. Keyword arguments should be axis names.""" """Make a relative move. Keyword arguments should be axis names."""
raise NotImplementedError("StageStub should not be used directly") raise NotImplementedError("StageStub should not be used directly")
@thing_action @thing_action
def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): def move_absolute(
self,
cancel: CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
"""Make an absolute move. Keyword arguments should be axis names.""" """Make an absolute move. Keyword arguments should be axis names."""
raise NotImplementedError("StageStub should not be used directly") raise NotImplementedError("StageStub should not be used directly")

View file

@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from labthings_fastapi.decorators import thing_action from labthings_fastapi.decorators import thing_action
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationCancelledError from labthings_fastapi.dependencies.invocation import (
CancelHook,
InvocationCancelledError,
)
from collections.abc import Mapping from collections.abc import Mapping
import time import time
@ -14,6 +17,10 @@ class DummyStage(BaseStage):
hardware attached. hardware attached.
""" """
def __init__(self, step_time: float = 0.001, **kwargs):
super().__init__(**kwargs)
self.step_time = step_time
def __enter__(self): def __enter__(self):
self.instantaneous_position = self.position self.instantaneous_position = self.position
@ -21,13 +28,18 @@ class DummyStage(BaseStage):
pass pass
@thing_action @thing_action
def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): def move_relative(
self,
cancel: CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
"""Make a relative move. Keyword arguments should be axis names.""" """Make a relative move. Keyword arguments should be axis names."""
displacement = [kwargs.get(k, 0) for k in self.axis_names] displacement = [kwargs.get(k, 0) for k in self.axis_names]
self.moving = True self.moving = True
try: try:
fraction_complete = 0.0 fraction_complete = 0.0
dt = 0.001 dt = self.step_time
max_displacement = max(abs(v) for v in displacement) max_displacement = max(abs(v) for v in displacement)
start_time = time.time() start_time = time.time()
while time.time() - start_time < dt * max_displacement: while time.time() - start_time < dt * max_displacement:
@ -47,7 +59,7 @@ class DummyStage(BaseStage):
# and to mark the invocation as "cancelled" rather than stopped. # and to mark the invocation as "cancelled" rather than stopped.
raise e raise e
finally: finally:
self.moving=False self.moving = False
self.position = { self.position = {
k: self.position[k] + int(fraction_complete * v) k: self.position[k] + int(fraction_complete * v)
for k, v in zip(self.axis_names, displacement) for k, v in zip(self.axis_names, displacement)
@ -55,14 +67,21 @@ class DummyStage(BaseStage):
self.instantaneous_position = self.position self.instantaneous_position = self.position
@thing_action @thing_action
def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): def move_absolute(
self,
cancel: CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
"""Make an absolute move. Keyword arguments should be axis names.""" """Make an absolute move. Keyword arguments should be axis names."""
displacement = { displacement = {
k: int(v) - self.position[k] k: int(v) - self.position[k]
for k, v in kwargs.items() for k, v in kwargs.items()
if k in self.axis_names if k in self.axis_names
} }
self.move_relative(cancel, block_cancellation=block_cancellation, **displacement) self.move_relative(
cancel, block_cancellation=block_cancellation, **displacement
)
@thing_action @thing_action
def set_zero_position(self): def set_zero_position(self):

View file

@ -21,8 +21,10 @@ class Stitcher(Thing):
self._script = path_to_openflexure_stitch self._script = path_to_openflexure_stitch
def run_subprocess( def run_subprocess(
self, logger: InvocationLogger, cmd: list[str], self,
) -> CompletedProcess: logger: InvocationLogger,
cmd: list[str],
) -> CompletedProcess:
"""Run a subprocess and log any output""" """Run a subprocess and log any output"""
logger.info(f"Running command in subprocess: `{' '.join(cmd)}") logger.info(f"Running command in subprocess: `{' '.join(cmd)}")
output = run(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True) output = run(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True)
@ -32,12 +34,19 @@ class Stitcher(Thing):
output.check_returncode() output.check_returncode()
return output return output
def images_folder(self, smart_scan: SmartScanDep, scan_name: Optional[str]=None) -> str: def images_folder(
self, smart_scan: SmartScanDep, scan_name: Optional[str] = None
) -> str:
scan_folder = smart_scan.scan_folder_path(scan_name=scan_name) scan_folder = smart_scan.scan_folder_path(scan_name=scan_name)
return os.path.join(scan_folder, "images") return os.path.join(scan_folder, "images")
@staticmethod @staticmethod
def output_up_to_date(folder: str, output_filename: str, image_prefix: str="image", image_suffix: str=".jpg") -> bool: def output_up_to_date(
folder: str,
output_filename: str,
image_prefix: str = "image",
image_suffix: str = ".jpg",
) -> bool:
"""Check if any of the images in a folder are newer than a file """Check if any of the images in a folder are newer than a file
If there are no images (files with the prefix and suffix) newer than the If there are no images (files with the prefix and suffix) newer than the
@ -61,24 +70,48 @@ class Stitcher(Thing):
return True return True
@thing_action @thing_action
def stitch_scan_from_stage(self, logger: InvocationLogger, smart_scan: SmartScanDep, scan_name: Optional[str]=None, downsample: float=1.0) -> JPEGBlob: def stitch_scan_from_stage(
self,
logger: InvocationLogger,
smart_scan: SmartScanDep,
scan_name: Optional[str] = None,
downsample: float = 1.0,
) -> JPEGBlob:
"""Generate a stitched image based on stage position metadata""" """Generate a stitched image based on stage position metadata"""
output_fname = "stitched_from_stage.jpg" output_fname = "stitched_from_stage.jpg"
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name) images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
if self.output_up_to_date(images_folder, output_fname): if self.output_up_to_date(images_folder, output_fname):
logger.info(f"No images are newer than {output_fname}, skipping.") logger.info(f"No images are newer than {output_fname}, skipping.")
else: else:
self.run_subprocess(logger, [self._script, "--stitching_mode", "only_stage_stitch", images_folder]) self.run_subprocess(
logger,
[self._script, "--stitching_mode", "only_stage_stitch", images_folder],
)
return JPEGBlob.from_file(os.path.join(images_folder, output_fname)) return JPEGBlob.from_file(os.path.join(images_folder, output_fname))
@thing_action @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""" """Generate a stitched image based on stage position metadata"""
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name) images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
self.run_subprocess(logger, [self._script, "--stitching_mode", "only_correlate", images_folder]) self.run_subprocess(
logger, [self._script, "--stitching_mode", "only_correlate", images_folder]
)
@thing_action @thing_action
def stitch_scan(self, logger: InvocationLogger, smart_scan: SmartScanDep, scan_name: Optional[str]=None, downsample: float=1.0) -> None: def 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""" """Generate a stitched image based on stage position metadata"""
images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name) images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name)
self.run_subprocess(logger, [self._script, "--stitching_mode", "all", images_folder]) self.run_subprocess(
logger, [self._script, "--stitching_mode", "all", images_folder]
)

View file

@ -3,12 +3,14 @@ OpenFlexure Microscope system control Thing
This module defines a Thing that can shut down or restart the host computer. This module defines a Thing that can shut down or restart the host computer.
""" """
import subprocess import subprocess
import os import os
from labthings_fastapi.thing import Thing from labthings_fastapi.thing import Thing
from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.decorators import thing_action, thing_property
from pydantic import BaseModel from pydantic import BaseModel
class CommandOutput(BaseModel): class CommandOutput(BaseModel):
output: str output: str
error: str error: str

View file

@ -3,6 +3,7 @@ OpenFlexure Microscope API test Thing
This Thing is intended only for use testing out the API and client(s). This Thing is intended only for use testing out the API and client(s).
""" """
from labthings_fastapi.thing import Thing from labthings_fastapi.thing import Thing
from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.decorators import thing_action, thing_property
from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger

View file

@ -1,22 +1,98 @@
import json
import os
import tempfile import tempfile
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from labthings_fastapi.client import ThingClient from labthings_fastapi.client import ThingClient
from PIL import Image
import numpy as np
import piexif
import pytest
from openflexure_microscope_server.server import ThingServer from openflexure_microscope_server.server import ThingServer
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
from openflexure_microscope_server.things.stage.dummy import DummyStage from openflexure_microscope_server.things.stage.dummy import DummyStage
from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.autofocus import AutofocusThing
from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper
from openflexure_microscope_server.things import camera_stage_mapping
temp_folder = tempfile.TemporaryDirectory() camera_stage_mapping.DEFAULT_SETTLING_TIME = 0 # skip the settling time for tests
server = ThingServer(temp_folder.name)
server.add_thing(SimulatedCamera(), "/camera/")
server.add_thing(DummyStage(), "/stage/")
server.add_thing(AutofocusThing(), "/autofocus/")
def test_autofocus():
with TestClient(server.app) as client: @pytest.fixture
autofocus = ThingClient.from_url("/autofocus/", client) def thing_server():
_ = autofocus.fast_autofocus() temp_folder = tempfile.TemporaryDirectory()
server = ThingServer(settings_folder=temp_folder.name)
server.add_thing(
SimulatedCamera(
shape=(240, 320, 3), canvas_shape=(960, 1240, 3), frame_interval=0.01
),
"/camera/",
)
server.add_thing(DummyStage(step_time=0.000001), "/stage/")
server.add_thing(AutofocusThing(), "/autofocus/")
server.add_thing(CameraStageMapper(), "/camera_stage_mapping/")
assert os.path.exists(os.path.join(temp_folder.name, "camera/"))
# NB yield is important: otherwise, the temp folder gets deleted before the test runs
yield server
@pytest.fixture
def client(thing_server):
with TestClient(thing_server.app) as client:
yield client
@pytest.fixture
def slower_client(thing_server):
thing_server.things["/stage/"].step_time = 0.0001
with TestClient(thing_server.app) as client:
yield client
def test_autofocus(slower_client):
client = slower_client
autofocus = ThingClient.from_url("/autofocus/", client)
_ = autofocus.fast_autofocus()
def test_grab_jpeg(client):
camera = ThingClient.from_url("/camera/", client)
blob = camera.grab_jpeg()
_image = Image.open(blob.open())
def test_capture_jpeg_metadata(client):
camera = ThingClient.from_url("/camera/", client)
blob = camera.capture_jpeg()
image = Image.open(blob.open())
exif_dict = piexif.load(image.info["exif"])
encoded_metadata = exif_dict["Exif"][piexif.ExifIFD.UserComment]
metadata = json.loads(encoded_metadata)
assert "position" in metadata["/stage/"]
def test_stage(client):
stage = ThingClient.from_url("/stage/", client)
start = stage.position
move = {"x": 1, "y": 2, "z": 3}
stage.move_relative(**move)
pos = stage.position
for s, m, p in zip(start.values(), move.values(), pos.values()):
assert s + m == p
stage.move_relative(**{k: -v for k, v in move.items()})
pos = stage.position
for s, p in zip(start.values(), pos.values()):
assert s == p
def test_capture_array(client):
camera = ThingClient.from_url("/camera/", client)
array = np.asarray(camera.capture_array())
assert array.shape == (240, 320, 3)
# Currently this fails, not yet sure why.
def test_camera_stage_mapping_calibration(client):
camera_stage_mapping = ThingClient.from_url("/camera_stage_mapping/", client)
camera_stage_mapping.calibrate_xy()

View file

@ -11,7 +11,7 @@
## Creating releases ## Creating releases
* JS client is coupled to the API, and so are no longer separately built and deployed. * JS client is coupled to the API, and is built and distributed with the server.
* See [openflexure-microscope-server/README.md](https://gitlab.com/openflexure/openflexure-microscope-server/-/blob/master/README.md) for details on creating new releases * See [openflexure-microscope-server/README.md](https://gitlab.com/openflexure/openflexure-microscope-server/-/blob/master/README.md) for details on creating new releases
## Installing ## Installing