Merge branch 'scanning-cleanup' into 'v3'
Scanning cleanup See merge request openflexure/openflexure-microscope-server!239
This commit is contained in:
commit
5dd23f6ef4
3 changed files with 241 additions and 304 deletions
|
|
@ -13,7 +13,7 @@
|
||||||
"path_to_openflexure_stitch": "application/openflexure-stitching/.venv/bin/openflexure-stitch"
|
"path_to_openflexure_stitch": "application/openflexure-stitching/.venv/bin/openflexure-stitch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/background_detect/": "openflexure_microscope_server.things.smart_scan:BackgroundDetectThing"
|
"/background_detect/": "openflexure_microscope_server.things.background_detect:BackgroundDetectThing"
|
||||||
},
|
},
|
||||||
"settings_folder": "/var/openflexure/settings/"
|
"settings_folder": "/var/openflexure/settings/"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
141
src/openflexure_microscope_server/things/background_detect.py
Normal file
141
src/openflexure_microscope_server/things/background_detect.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
# ruff: noqa: E722
|
||||||
|
|
||||||
|
from typing import Mapping, Optional
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from scipy.stats import norm
|
||||||
|
|
||||||
|
from labthings_fastapi.thing import Thing
|
||||||
|
from labthings_fastapi.decorators import thing_action, thing_property
|
||||||
|
from .camera import CameraDependency as CamDep
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelDistributions(BaseModel):
|
||||||
|
means: list[float]
|
||||||
|
standard_deviations: list[float]
|
||||||
|
colorspace: str = "LUV"
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundDetectThing(Thing):
|
||||||
|
@thing_property
|
||||||
|
def background_distributions(self) -> Optional[ChannelDistributions]:
|
||||||
|
"""The statistics of the background image"""
|
||||||
|
bd = self.thing_settings.get("background_distributions", None)
|
||||||
|
if bd:
|
||||||
|
return ChannelDistributions(**bd)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@background_distributions.setter
|
||||||
|
def background_distributions(self, value: Optional[ChannelDistributions]) -> None:
|
||||||
|
try:
|
||||||
|
self.thing_settings["background_distributions"] = value.model_dump()
|
||||||
|
except AttributeError:
|
||||||
|
self.thing_settings["background_distributions"] = None
|
||||||
|
|
||||||
|
@thing_property
|
||||||
|
def tolerance(self) -> float:
|
||||||
|
"""How many standard deviations to allow for the background"""
|
||||||
|
return self.thing_settings.get("tolerance", 7)
|
||||||
|
|
||||||
|
@tolerance.setter
|
||||||
|
def tolerance(self, value: float) -> None:
|
||||||
|
self.thing_settings["tolerance"] = value
|
||||||
|
|
||||||
|
@thing_property
|
||||||
|
def fraction(self) -> float:
|
||||||
|
"""How much of the image needs to be not background to label as sample"""
|
||||||
|
return self.thing_settings.get("fraction", 25)
|
||||||
|
|
||||||
|
@fraction.setter
|
||||||
|
def fraction(self, value: float) -> None:
|
||||||
|
self.thing_settings["fraction"] = value
|
||||||
|
|
||||||
|
def background_mask(self, image: np.ndarray) -> np.ndarray:
|
||||||
|
"""Calculate a binary image, showing whether each pixel is background
|
||||||
|
|
||||||
|
The image should be in LUV format, the ouput will be binary with the
|
||||||
|
same shape in the first two dimensions.
|
||||||
|
"""
|
||||||
|
d = self.background_distributions
|
||||||
|
if not d:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Background is not set: you need to calibrate background detection."
|
||||||
|
)
|
||||||
|
# This image is in LUV space. But the brightness (L) often changes as the
|
||||||
|
# height of the sample changes. Hence in the line below we are only using
|
||||||
|
# the UV (colour) channels.
|
||||||
|
return np.all(
|
||||||
|
np.abs(image[:, :, 1:] - np.array(d.means[1:])[np.newaxis, np.newaxis, :])
|
||||||
|
< np.array(d.standard_deviations[1:])[np.newaxis, np.newaxis, :]
|
||||||
|
* self.tolerance,
|
||||||
|
axis=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
@thing_action
|
||||||
|
def background_fraction(self, cam: CamDep) -> float:
|
||||||
|
"""Determine what fraction of the current image is background
|
||||||
|
|
||||||
|
This action will acquire a new image from the preview stream, then
|
||||||
|
evaluate whether it is foreground or background, by comparing it
|
||||||
|
too the saved statistics. This is done on a per-pixel basis, and
|
||||||
|
the returned value (between 0 and 100) is the fraction of the image
|
||||||
|
that is background.
|
||||||
|
"""
|
||||||
|
current_image = cam.grab_jpeg()
|
||||||
|
current_image = np.array(Image.open(current_image.open()))
|
||||||
|
|
||||||
|
# we're working in the LUV colourspace as it collect colours together in a human-intuitive way
|
||||||
|
current_image_LUV = cv2.cvtColor(current_image, cv2.COLOR_RGB2LUV)
|
||||||
|
mask = self.background_mask(current_image_LUV)
|
||||||
|
return np.count_nonzero(mask) / np.prod(mask.shape) * 100
|
||||||
|
|
||||||
|
@thing_action
|
||||||
|
def image_is_sample(self, cam: CamDep) -> bool:
|
||||||
|
"""Label the current image as either background or sample"""
|
||||||
|
b_fraction = self.background_fraction(cam)
|
||||||
|
fraction_threshold = self.fraction
|
||||||
|
|
||||||
|
return (100 - b_fraction) > fraction_threshold
|
||||||
|
|
||||||
|
@thing_action
|
||||||
|
def set_background(self, cam: CamDep):
|
||||||
|
"""Grab an image, and use its statistics to set the background
|
||||||
|
|
||||||
|
This should be run when the microscope is looking at an empty region,
|
||||||
|
and will calculate the mean and standard deviation of the pixel values
|
||||||
|
in the LUV colourspace. These values will then be used to compare
|
||||||
|
future images to the distribution, to determine if each pixel is
|
||||||
|
foreground or background.
|
||||||
|
"""
|
||||||
|
background = cam.grab_jpeg()
|
||||||
|
background = np.array(Image.open(background.open()))
|
||||||
|
|
||||||
|
# we're working in the LUV colourspace as it collect colours together in a human-intuitive way
|
||||||
|
background_LUV = cv2.cvtColor(background, cv2.COLOR_RGB2LUV)
|
||||||
|
|
||||||
|
ch1 = (background_LUV.T[0]).flatten()
|
||||||
|
ch2 = (background_LUV.T[1]).flatten()
|
||||||
|
ch3 = (background_LUV.T[2]).flatten()
|
||||||
|
|
||||||
|
points = np.array([np.asarray(ch1), np.asarray(ch2), np.asarray(ch3)]).T
|
||||||
|
|
||||||
|
# we get the mean and standard deviation of values in each channel
|
||||||
|
mu, std = np.apply_along_axis(norm.fit, 0, points)
|
||||||
|
|
||||||
|
self.background_distributions = ChannelDistributions(
|
||||||
|
means=mu.tolist(),
|
||||||
|
standard_deviations=std.tolist(),
|
||||||
|
colorspace="LUV",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def thing_state(self) -> Mapping:
|
||||||
|
bd = self.background_distributions
|
||||||
|
return {
|
||||||
|
"background_distributions": bd.model_dump() if bd else None,
|
||||||
|
"tolerance": self.tolerance,
|
||||||
|
"fraction": self.fraction,
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
import threading
|
import threading
|
||||||
from typing import Mapping, Optional
|
from typing import Optional
|
||||||
import cv2
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -12,9 +11,6 @@ import os
|
||||||
import time
|
import time
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from scipy.stats import norm
|
|
||||||
from scipy.ndimage import zoom
|
|
||||||
from scipy.interpolate import interp1d
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from subprocess import CompletedProcess, Popen, PIPE, SubprocessError, STDOUT
|
from subprocess import CompletedProcess, Popen, PIPE, SubprocessError, STDOUT
|
||||||
from threading import Event, Thread
|
from threading import Event, Thread
|
||||||
|
|
@ -36,12 +32,14 @@ from .camera import CameraDependency as CamDep
|
||||||
from .stage import StageDependency as StageDep
|
from .stage import StageDependency as StageDep
|
||||||
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.camera_stage_mapping import CameraStageMapper
|
||||||
from openflexure_microscope_server.things.auto_recentre_stage import RecentringThing
|
from openflexure_microscope_server.things.background_detect import BackgroundDetectThing
|
||||||
|
|
||||||
|
|
||||||
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/")
|
||||||
RecentreStage = direct_thing_client_dependency(RecentringThing, "/auto_recentre_stage/")
|
BackgroundDep = direct_thing_client_dependency(
|
||||||
|
BackgroundDetectThing, "/background_detect/"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def closest(current, focused_path):
|
def closest(current, focused_path):
|
||||||
|
|
@ -104,48 +102,17 @@ def limit_focus_change(prev_pos, prev_z, new_pos, new_z, limit):
|
||||||
else:
|
else:
|
||||||
movement_ratio = np.divide(focus_change, dist, dtype="float64")
|
movement_ratio = np.divide(focus_change, dist, dtype="float64")
|
||||||
|
|
||||||
# print('Movement ratio is {0} in z per lateral step. The limit is {1}'.format(round(movement_ratio, 4), round(limit,4)))
|
|
||||||
# print(f'This is the distance between {prev_pos}, {prev_z} and {new_pos}, {new_z}')
|
|
||||||
|
|
||||||
if movement_ratio > limit:
|
if movement_ratio > limit:
|
||||||
return "reject"
|
return "reject"
|
||||||
else:
|
else:
|
||||||
return "accept"
|
return "accept"
|
||||||
|
|
||||||
|
|
||||||
# def distance_to_site(current, next):
|
|
||||||
# current = np.array(current, dtype="float64")
|
|
||||||
# next = np.array(next, dtype="float64")
|
|
||||||
# if (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2 < 0:
|
|
||||||
# print(f"Negative distance between {next} and {current}")
|
|
||||||
# return np.sqrt(
|
|
||||||
# (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2, dtype="float64"
|
|
||||||
# )
|
|
||||||
|
|
||||||
|
|
||||||
def 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):
|
|
||||||
# microscope.move(pos)
|
|
||||||
# background = microscope.grab_image_array()
|
|
||||||
# background_LUV = cv2.cvtColor(background, cv2.COLOR_RGB2LUV)
|
|
||||||
|
|
||||||
# ch1 = (background_LUV.T[0]).flatten()
|
|
||||||
# ch2 = (background_LUV.T[1]).flatten()
|
|
||||||
# ch3 = (background_LUV.T[2]).flatten()
|
|
||||||
|
|
||||||
# points = np.array([np.asarray(ch1),np.asarray(ch2),np.asarray(ch3)]).T
|
|
||||||
|
|
||||||
# # we get the mean and standard deviation of values in each channel
|
|
||||||
|
|
||||||
# mu, std = np.apply_along_axis(norm.fit, 0, points)
|
|
||||||
# stats_list = np.vstack([mu, std])
|
|
||||||
# return stats_list
|
|
||||||
|
|
||||||
|
|
||||||
def distance_to_site(current, next):
|
def distance_to_site(current, next):
|
||||||
next = np.array(next, dtype="float64")
|
next = np.array(next, dtype="float64")
|
||||||
current = np.array(current, dtype="float64")
|
current = np.array(current, dtype="float64")
|
||||||
|
|
@ -172,6 +139,7 @@ def generate_config(
|
||||||
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?
|
||||||
|
# TODO: fully test this with whether it works in Fiji as expected
|
||||||
|
|
||||||
camera_to_sample_matrix = scale_csm(
|
camera_to_sample_matrix = scale_csm(
|
||||||
camera_to_sample_matrix, csm_calibration_width, img_width
|
camera_to_sample_matrix, csm_calibration_width, img_width
|
||||||
|
|
@ -188,159 +156,6 @@ def generate_config(
|
||||||
fp.write(f"{names[i]}; ; {loc[1], loc[0]} \n")
|
fp.write(f"{names[i]}; ; {loc[1], loc[0]} \n")
|
||||||
|
|
||||||
|
|
||||||
def raw2rggb(raw):
|
|
||||||
"""Convert packed 10 bit raw to RGGB 8 bit"""
|
|
||||||
raw = np.asarray(raw) # ensure it's an array
|
|
||||||
rggb = np.empty((616, 820, 4), dtype=np.uint8)
|
|
||||||
raw_w = rggb.shape[1] // 2 * 5
|
|
||||||
for plane, offset in enumerate([(1, 1), (0, 1), (1, 0), (0, 0)]):
|
|
||||||
rggb[:, ::2, plane] = raw[offset[0] :: 2, offset[1] : raw_w + offset[1] : 5]
|
|
||||||
rggb[:, 1::2, plane] = raw[
|
|
||||||
offset[0] :: 2, offset[1] + 2 : raw_w + offset[1] + 2 : 5
|
|
||||||
]
|
|
||||||
return rggb
|
|
||||||
|
|
||||||
|
|
||||||
def rggb2rgb(rggb):
|
|
||||||
return np.stack(
|
|
||||||
[rggb[..., 0], rggb[..., 1] // 2 + rggb[..., 2] // 2, rggb[..., 3]], axis=2
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelDistributions(BaseModel):
|
|
||||||
means: list[float]
|
|
||||||
standard_deviations: list[float]
|
|
||||||
colorspace: str = "LUV"
|
|
||||||
|
|
||||||
|
|
||||||
class BackgroundDetectThing(Thing):
|
|
||||||
@thing_property
|
|
||||||
def background_distributions(self) -> Optional[ChannelDistributions]:
|
|
||||||
"""The statistics of the background image"""
|
|
||||||
bd = self.thing_settings.get("background_distributions", None)
|
|
||||||
if bd:
|
|
||||||
return ChannelDistributions(**bd)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@background_distributions.setter
|
|
||||||
def background_distributions(self, value: Optional[ChannelDistributions]) -> None:
|
|
||||||
try:
|
|
||||||
self.thing_settings["background_distributions"] = value.model_dump()
|
|
||||||
except AttributeError:
|
|
||||||
self.thing_settings["background_distributions"] = None
|
|
||||||
|
|
||||||
@thing_property
|
|
||||||
def tolerance(self) -> float:
|
|
||||||
"""How many standard deviations to allow for the background"""
|
|
||||||
return self.thing_settings.get("tolerance", 7)
|
|
||||||
|
|
||||||
@tolerance.setter
|
|
||||||
def tolerance(self, value: float) -> None:
|
|
||||||
self.thing_settings["tolerance"] = value
|
|
||||||
|
|
||||||
@thing_property
|
|
||||||
def fraction(self) -> float:
|
|
||||||
"""How much of the image needs to be not background to label as sample"""
|
|
||||||
return self.thing_settings.get("fraction", 25)
|
|
||||||
|
|
||||||
@fraction.setter
|
|
||||||
def fraction(self, value: float) -> None:
|
|
||||||
self.thing_settings["fraction"] = value
|
|
||||||
|
|
||||||
def background_mask(self, image: np.ndarray) -> np.ndarray:
|
|
||||||
"""Calculate a binary image, showing whether each pixel is background
|
|
||||||
|
|
||||||
The image should be in LUV format, the ouput will be binary with the
|
|
||||||
same shape in the first two dimensions.
|
|
||||||
"""
|
|
||||||
d = self.background_distributions
|
|
||||||
if not d:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Background is not set: you need to calibrate background detection."
|
|
||||||
)
|
|
||||||
# This image is in LUV space. But the brightness (L) often changes as the
|
|
||||||
# height of the sample changes. Hence in the line below we are only using
|
|
||||||
# the UV (colour) channels.
|
|
||||||
return np.all(
|
|
||||||
np.abs(image[:, :, 1:] - np.array(d.means[1:])[np.newaxis, np.newaxis, :])
|
|
||||||
< np.array(d.standard_deviations[1:])[np.newaxis, np.newaxis, :]
|
|
||||||
* self.tolerance,
|
|
||||||
axis=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
@thing_action
|
|
||||||
def background_fraction(self, cam: CamDep) -> float:
|
|
||||||
"""Determine what fraction of the current image is background
|
|
||||||
|
|
||||||
This action will acquire a new image from the preview stream, then
|
|
||||||
evaluate whether it is foreground or background, by comparing it
|
|
||||||
too the saved statistics. This is done on a per-pixel basis, and
|
|
||||||
the returned value (between 0 and 100) is the fraction of the image
|
|
||||||
that is background.
|
|
||||||
"""
|
|
||||||
current_image = cam.grab_jpeg()
|
|
||||||
current_image = np.array(Image.open(current_image.open()))
|
|
||||||
|
|
||||||
# we're working in the LUV colourspace as it collect colours together in a human-intuitive way
|
|
||||||
current_image_LUV = cv2.cvtColor(current_image, cv2.COLOR_RGB2LUV)
|
|
||||||
mask = self.background_mask(current_image_LUV)
|
|
||||||
return np.count_nonzero(mask) / np.prod(mask.shape) * 100
|
|
||||||
|
|
||||||
@thing_action
|
|
||||||
def image_is_sample(self, cam: CamDep) -> bool:
|
|
||||||
"""Label the current image as either background or sample"""
|
|
||||||
b_fraction = self.background_fraction(cam)
|
|
||||||
fraction_threshold = self.fraction
|
|
||||||
|
|
||||||
return (100 - b_fraction) > fraction_threshold
|
|
||||||
|
|
||||||
@thing_action
|
|
||||||
def set_background(self, cam: CamDep):
|
|
||||||
"""Grab an image, and use its statistics to set the background
|
|
||||||
|
|
||||||
This should be run when the microscope is looking at an empty region,
|
|
||||||
and will calculate the mean and standard deviation of the pixel values
|
|
||||||
in the LUV colourspace. These values will then be used to compare
|
|
||||||
future images to the distribution, to determine if each pixel is
|
|
||||||
foreground or background.
|
|
||||||
"""
|
|
||||||
background = cam.grab_jpeg()
|
|
||||||
background = np.array(Image.open(background.open()))
|
|
||||||
|
|
||||||
# we're working in the LUV colourspace as it collect colours together in a human-intuitive way
|
|
||||||
background_LUV = cv2.cvtColor(background, cv2.COLOR_RGB2LUV)
|
|
||||||
|
|
||||||
ch1 = (background_LUV.T[0]).flatten()
|
|
||||||
ch2 = (background_LUV.T[1]).flatten()
|
|
||||||
ch3 = (background_LUV.T[2]).flatten()
|
|
||||||
|
|
||||||
points = np.array([np.asarray(ch1), np.asarray(ch2), np.asarray(ch3)]).T
|
|
||||||
|
|
||||||
# we get the mean and standard deviation of values in each channel
|
|
||||||
mu, std = np.apply_along_axis(norm.fit, 0, points)
|
|
||||||
|
|
||||||
self.background_distributions = ChannelDistributions(
|
|
||||||
means=mu.tolist(),
|
|
||||||
standard_deviations=std.tolist(),
|
|
||||||
colorspace="LUV",
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def thing_state(self) -> Mapping:
|
|
||||||
bd = self.background_distributions
|
|
||||||
return {
|
|
||||||
"background_distributions": bd.model_dump() if bd else None,
|
|
||||||
"tolerance": self.tolerance,
|
|
||||||
"fraction": self.fraction,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
BackgroundDep = direct_thing_client_dependency(
|
|
||||||
BackgroundDetectThing, "/background_detect/"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class NotEnoughFreeSpaceError(IOError):
|
class NotEnoughFreeSpaceError(IOError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -460,7 +275,6 @@ class SmartScanThing(Thing):
|
||||||
metadata_getter: GetThingStates,
|
metadata_getter: GetThingStates,
|
||||||
csm: CSMDep,
|
csm: CSMDep,
|
||||||
background_detect: BackgroundDep,
|
background_detect: BackgroundDep,
|
||||||
recentre: RecentreStage,
|
|
||||||
scan_name: str = "",
|
scan_name: str = "",
|
||||||
):
|
):
|
||||||
"""Move the stage to cover an area, taking images that can be tiled together.
|
"""Move the stage to cover an area, taking images that can be tiled together.
|
||||||
|
|
@ -482,8 +296,6 @@ class SmartScanThing(Thing):
|
||||||
capture_thread = None
|
capture_thread = None
|
||||||
self._scan_lock.acquire(timeout=0.1)
|
self._scan_lock.acquire(timeout=0.1)
|
||||||
try:
|
try:
|
||||||
# Before anything else, check that we've got a background set
|
|
||||||
# It's annoying to have to wait to find out!
|
|
||||||
max_dist = self.max_range
|
max_dist = self.max_range
|
||||||
|
|
||||||
if self.autofocus_dz == 0:
|
if self.autofocus_dz == 0:
|
||||||
|
|
@ -493,6 +305,8 @@ class SmartScanThing(Thing):
|
||||||
f"Your dz range is {self.autofocus_dz} steps, which is too short to attempt to focus. Running without autofocus"
|
f"Your dz range is {self.autofocus_dz} steps, which is too short to attempt to focus. Running without autofocus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Before anything else, check that we've got a background set
|
||||||
|
# It's annoying to have to wait to find out!
|
||||||
if self.skip_background:
|
if self.skip_background:
|
||||||
d = background_detect.background_distributions
|
d = background_detect.background_distributions
|
||||||
if not d:
|
if not d:
|
||||||
|
|
@ -531,7 +345,6 @@ 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]
|
|
||||||
|
|
||||||
overlap = self.overlap
|
overlap = self.overlap
|
||||||
|
|
||||||
|
|
@ -576,100 +389,6 @@ class SmartScanThing(Thing):
|
||||||
) as f:
|
) 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.
|
|
||||||
# Most of the variables it needs will be "baked in" so the arguments are just the ones
|
|
||||||
# that change each iteration.
|
|
||||||
# We also pre-calculate a normalisation image based on the LST and white balance
|
|
||||||
raw_image = cam.capture_array(stream_name="raw")
|
|
||||||
# TODO: assert the image is 10-bit packed, or deal with other formats!
|
|
||||||
rgb = rggb2rgb(raw2rggb(raw_image))
|
|
||||||
lst = dict(cam.lens_shading_tables)
|
|
||||||
lum = np.array(lst["luminance"])
|
|
||||||
Cr = np.array(lst["Cr"])
|
|
||||||
Cb = np.array(lst["Cb"])
|
|
||||||
gr, gb = cam.colour_gains
|
|
||||||
G = 1 / lum
|
|
||||||
R = (
|
|
||||||
G / Cr / gr * np.min(Cr)
|
|
||||||
) # The extra /np.max(Cr) emulates the quirky handling of Cr in
|
|
||||||
B = G / Cb / gb * np.min(Cb) # the picamera2 pipeline
|
|
||||||
white_norm_lores = np.stack([R, G, B], axis=2)
|
|
||||||
zoom_factors = [
|
|
||||||
i / n for i, n in zip(rgb[..., :3].shape, white_norm_lores.shape)
|
|
||||||
]
|
|
||||||
white_norm = zoom(white_norm_lores, zoom_factors, order=1)[
|
|
||||||
: rgb.shape[0], : rgb.shape[1], :
|
|
||||||
] # Could use some work
|
|
||||||
colour_correction_matrix = np.array(cam.colour_correction_matrix).reshape(
|
|
||||||
(3, 3)
|
|
||||||
)
|
|
||||||
contrast_algorithm = cam.tuning["algorithms"][9]["rpi.contrast"]
|
|
||||||
gamma = np.array(contrast_algorithm["gamma_curve"]).reshape((-1, 2))
|
|
||||||
gamma_8bit = interp1d(gamma[:, 0] / 255, gamma[:, 1] / 255)
|
|
||||||
|
|
||||||
def process_raw_image(img):
|
|
||||||
normed = img / white_norm
|
|
||||||
corrected = np.dot(
|
|
||||||
colour_correction_matrix, normed.reshape((-1, 3)).T
|
|
||||||
).T.reshape(normed.shape)
|
|
||||||
corrected[corrected < 0] = 0
|
|
||||||
corrected[corrected > 255] = 255
|
|
||||||
return gamma_8bit(corrected)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Generated normalisation image with shape {white_norm.shape}, "
|
|
||||||
f"max {white_norm.max(axis=(0, 1))}, min {white_norm.min(axis=(0, 1))}"
|
|
||||||
)
|
|
||||||
norm_inputs = {
|
|
||||||
"luminance": lum,
|
|
||||||
"Cr": Cr,
|
|
||||||
"Cb": Cb,
|
|
||||||
"gain_red": gr,
|
|
||||||
"gain_blue": gb,
|
|
||||||
}
|
|
||||||
|
|
||||||
def capture_and_save(acquired: Event, name: str) -> None:
|
|
||||||
"""Capture an image and save it to disk
|
|
||||||
|
|
||||||
This will set the event `acquired` once the image has been acquired, so
|
|
||||||
that the stage may be moved while it's saved.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
capture_start = time.time()
|
|
||||||
metadata = metadata_getter()
|
|
||||||
raw_image = cam.capture_array(stream_name="raw")
|
|
||||||
acquired.set()
|
|
||||||
acquisition_time = time.time()
|
|
||||||
# Save the raw image
|
|
||||||
np.savez(
|
|
||||||
os.path.join(raw_images_folder, name + ".npz"),
|
|
||||||
raw_image=raw_image,
|
|
||||||
**norm_inputs,
|
|
||||||
)
|
|
||||||
# Process it into 8 bit RGB
|
|
||||||
processed = process_raw_image(rggb2rgb(raw2rggb(raw_image)))
|
|
||||||
processed[processed > 255] = 255
|
|
||||||
processed[processed < 0] = 0
|
|
||||||
img = Image.fromarray(processed.astype(np.uint8), mode="RGB")
|
|
||||||
img.save(
|
|
||||||
os.path.join(images_folder, name), quality=95, subsampling=0
|
|
||||||
)
|
|
||||||
exif_dict = piexif.load(os.path.join(images_folder, name))
|
|
||||||
exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps(
|
|
||||||
metadata
|
|
||||||
).encode("utf-8")
|
|
||||||
piexif.insert(
|
|
||||||
piexif.dump(exif_dict), os.path.join(images_folder, name)
|
|
||||||
)
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
except Exception as 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.
|
||||||
|
|
@ -773,30 +492,26 @@ class SmartScanThing(Thing):
|
||||||
)
|
)
|
||||||
acquired = Event()
|
acquired = Event()
|
||||||
name = f"image_{loc[0]}_{loc[1]}.jpg"
|
name = f"image_{loc[0]}_{loc[1]}.jpg"
|
||||||
|
jpeg_path = os.path.join(images_folder, name)
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
capture_thread = Thread(
|
capture_thread = Thread(
|
||||||
target=capture_and_save,
|
target=self.capture_and_save,
|
||||||
kwargs={
|
kwargs={
|
||||||
# "cam": cam,
|
|
||||||
# "logger": logger,
|
|
||||||
"acquired": acquired,
|
"acquired": acquired,
|
||||||
"name": name,
|
"jpeg_path": jpeg_path,
|
||||||
# "images_folder": images_folder,
|
"cam": cam,
|
||||||
# "raw_images_folder": raw_images_folder,
|
"logger": logger,
|
||||||
|
"metadata_getter": metadata_getter,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
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)
|
|
||||||
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:
|
|
||||||
# generate_config(images_folder, positions, names, CSM, csm_calibration_width, img_width, logger)
|
|
||||||
|
|
||||||
temp_path = []
|
temp_path = []
|
||||||
|
|
||||||
for i in path:
|
for i in path:
|
||||||
|
|
@ -859,6 +574,87 @@ class SmartScanThing(Thing):
|
||||||
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)
|
||||||
|
|
||||||
|
def capture_and_save(
|
||||||
|
self,
|
||||||
|
acquired: Event,
|
||||||
|
jpeg_path: str,
|
||||||
|
cam: CamDep,
|
||||||
|
metadata_getter: GetThingStates,
|
||||||
|
logger: InvocationLogger,
|
||||||
|
) -> None:
|
||||||
|
"""Capture an image and save it to disk
|
||||||
|
|
||||||
|
This will set the event `acquired` once the image has been acquired, so
|
||||||
|
that the stage may be moved while it's saved.
|
||||||
|
"""
|
||||||
|
capture_start = time.time()
|
||||||
|
image, metadata = self.capture_image(cam, metadata_getter, logger)
|
||||||
|
acquired.set()
|
||||||
|
acquisition_time = time.time()
|
||||||
|
self.save_capture(jpeg_path, image, metadata, logger)
|
||||||
|
save_time = time.time()
|
||||||
|
acquisition_duration = round(acquisition_time - capture_start, 1)
|
||||||
|
saving_duration = round(save_time - acquisition_time, 1)
|
||||||
|
logger.debug(
|
||||||
|
f"Acquired {jpeg_path} in {acquisition_duration}s then {saving_duration}s saving to disk"
|
||||||
|
)
|
||||||
|
|
||||||
|
def capture_image(
|
||||||
|
self,
|
||||||
|
cam: CamDep,
|
||||||
|
metadata_getter: GetThingStates,
|
||||||
|
logger: InvocationLogger,
|
||||||
|
) -> tuple[np.ndarray, dict]:
|
||||||
|
"""Capture an image in memory and return it with metadata
|
||||||
|
|
||||||
|
This will set the event `acquired` once the image has been acquired, so
|
||||||
|
that the stage may be moved while it's saved.
|
||||||
|
|
||||||
|
CaptureError raised if the capture fails for any reason
|
||||||
|
|
||||||
|
returns tuple with numpy array of image data, and dict of metadata
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
metadata = metadata_getter()
|
||||||
|
image = cam.capture_array()[..., :3]
|
||||||
|
except Exception as e:
|
||||||
|
raise CaptureError(
|
||||||
|
"An error occurred while capturing: {}".format(e), exc_info=e
|
||||||
|
)
|
||||||
|
return image, metadata
|
||||||
|
|
||||||
|
def save_capture(
|
||||||
|
self,
|
||||||
|
jpeg_path: str,
|
||||||
|
image: np.ndarray,
|
||||||
|
metadata: dict,
|
||||||
|
logger: InvocationLogger,
|
||||||
|
) -> None:
|
||||||
|
"""Saving the captured image and metadata to disk
|
||||||
|
|
||||||
|
logger warning (via InvocationLogger) is raised if metadata is failed to be added
|
||||||
|
|
||||||
|
IOError is raised if the file cannot be saved
|
||||||
|
|
||||||
|
nothing is returned on success"""
|
||||||
|
try:
|
||||||
|
Image.fromarray(image.astype("uint8"), "RGB").save(
|
||||||
|
jpeg_path, quality=95, subsampling=0
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
exif_dict = piexif.load(jpeg_path)
|
||||||
|
exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps(
|
||||||
|
metadata
|
||||||
|
).encode("utf-8")
|
||||||
|
piexif.insert(piexif.dump(exif_dict), jpeg_path)
|
||||||
|
except:
|
||||||
|
logger.warning(f"Failed to add metadata to {jpeg_path}")
|
||||||
|
except Exception as e:
|
||||||
|
raise IOError(
|
||||||
|
f"An error occurred while saving {jpeg_path}: {e}",
|
||||||
|
exc_info=e,
|
||||||
|
)
|
||||||
|
|
||||||
@thing_property
|
@thing_property
|
||||||
def max_range(self) -> int:
|
def max_range(self) -> int:
|
||||||
"""The maximum distance from the centre of the scan before we break"""
|
"""The maximum distance from the centre of the scan before we break"""
|
||||||
|
|
@ -1194,7 +990,6 @@ class SmartScanThing(Thing):
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
f"Tried to make a zip archive of {images_folder} but it does not exist."
|
f"Tried to make a zip archive of {images_folder} but it does not exist."
|
||||||
)
|
)
|
||||||
# logger.info("Creating zip archive of images (may take some time)...")
|
|
||||||
|
|
||||||
zip_fname = f"{os.path.join(scan_folder, 'images')}.zip"
|
zip_fname = f"{os.path.join(scan_folder, 'images')}.zip"
|
||||||
|
|
||||||
|
|
@ -1232,13 +1027,10 @@ class SmartScanThing(Thing):
|
||||||
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')
|
|
||||||
pass
|
pass
|
||||||
elif file in current_zip:
|
elif file in current_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')
|
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
logger.info(f"appending {file} to zip")
|
logger.info(f"appending {file} to zip")
|
||||||
|
|
@ -1269,3 +1061,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureError(RuntimeError):
|
||||||
|
"""An error trying to capture from Picamera"""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue