diff --git a/hardware-specific-tests/picamera2/test_calibration.py b/hardware-specific-tests/picamera2/test_calibration.py index 6c803aed..909b533e 100644 --- a/hardware-specific-tests/picamera2/test_calibration.py +++ b/hardware-specific-tests/picamera2/test_calibration.py @@ -75,6 +75,8 @@ def test_get_tuning_algo(picamera_thing): def test_calibration(picamera_thing, client): """Check that full auto calibrate completes and set the expected values.""" + # Check the calibration_required property used by the calibration wizard + assert picamera_thing.calibration_required # Save copy of default tuning file for end of test original_default = deepcopy(picamera_thing.default_tuning) # Tuning should start the same as the server is loading with no settings. @@ -88,7 +90,9 @@ def test_calibration(picamera_thing, client): # Run full auto calibrate client.full_auto_calibrate() - # After calibration the tuning files should be different + # After calibration it should report that calibration is no longer required + assert not picamera_thing.calibration_required + # The tuning files should be different assert picamera_thing.default_tuning != picamera_thing.tuning # The default should be unchanged assert picamera_thing.default_tuning == original_default diff --git a/picamera_coverage.zip b/picamera_coverage.zip index e3c3946d..9592a404 100644 Binary files a/picamera_coverage.zip and b/picamera_coverage.zip differ diff --git a/simulation_guide.md b/simulation_guide.md index 22ac0004..258f1e3d 100644 --- a/simulation_guide.md +++ b/simulation_guide.md @@ -37,15 +37,11 @@ For a step-by-step demonstration, watch the [full video walkthrough on running t ## Running a simulated scan -To run a simulated scan, you first need to set a background image: +To run a simulated scan, you must first complete the calibration wizard. -1. Click on the settings tab on the left -2. Navigate to the **Camera** settings under **Microscope Settings** -3. Click `Remove Sample`. This removes the simulated sample from the camera stream -4. Click on the **Background Detect** tab on the left and click `Set Background` -5. Repeat the steps 1 and 2 to return to the camera settings and click `Add Sample` - -You are now ready to start a simulated scan in the Slide Scan tab. +1. Go to the **Slide Scan** tab +2. Click the **Start Smart Scan** Button. +3. This will scan until the whole sample has been imaged. You can cancel the scan at any time, with the **Cancel** button. --- @@ -53,27 +49,31 @@ You are now ready to start a simulated scan in the Slide Scan tab. --- -### Autocalibration +### Auto-calibration * Your simulated microscope should prompt you to complete the auto-calibration wizard upon your first use of the simulated server. -* If the stage moves in the wrong direction in the **View** or **Navigate** tabs, you can re-run **Auto-calibration** from the UI to correct it. -* To auto-calibrate your microscope, go to: **Settings → Camera to Stage Mapping → Auto-Calibrate Using Camera**. +* You can press the **Escape** key at any point to exit the wizard, but this will leave your simulated microscope in an uncalibrated state. +* If your microscope is not calibrated, the wizard will launch each time you load the UI, prompting you to complete remaining calibrations. +* You can always launch the full calibration wizard by going to: **Settings → Launch Calibration Wizard**. + +The calibration steps are: + +1. The Camera Calibration will set the background image needed for background detect. + * You can rerun this calibration by going to: **Settings → Camera → Full Auto-Calibrate**. +2. The Camera-Stage Mapping calibration is used to find the relationship between stage and image coordinates. + * If the stage moves in the wrong direction when double clicking on the camera feed in the **View** or **Navigate** tabs, you may need to re-run Camera-Stage Mapping. + * To re-run Camera-Stage Mapping, go to: **Settings → Camera to Stage Mapping → Auto-Calibrate Using Camera**. + --- -### Sample Coverage - -* The default **sample coverage** of 25% is often too high for simulations. A figure closer to 10% performs better. -* You can adjust this in the **Background Detect tab** under: - **Configure → Sample Coverage** - ---- - -### Background and Samples +### Background Detect * You can **load a sample** to simulate imaging conditions, using the **Load Sample** options in the **Settings tab**. * To remove a sample and reset the view, use the **Remove Sample** option in the **Settings tab**. -* You can load and remove simulated samples under: **Settings → Camera → Load/Remove Sample** +* You can load and remove simulated samples under: **Settings → Camera → Load/Remove Sample**. +* The Full Auto-Calibrate option in camera settings can be used to **Remove the sample → Run background detection → Re-load the sample**. +* You can adjust the sample coverage needed for the image not to be detected as background in the **Background Detect tab** under: **Configure → Sample Coverage**. The default value is 25%. --- diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index bca3c63f..fc11ca1f 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -198,6 +198,15 @@ class BaseCamera(lt.Thing): """Close hardware connection when the Thing context manager is closed.""" raise NotImplementedError("CameraThings must define their own __exit__ method") + @lt.thing_property + def calibration_required(self) -> bool: + """Whether the camera needs calibrating. + + This always returns False in BaseCamera. It should be reimplemented by child + classes if calibration is required. + """ + return False + @lt.thing_action def start_streaming( self, main_resolution: tuple[int, int], buffer_count: int diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 2afc1d7b..68f5c281 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -213,6 +213,11 @@ class StreamingPiCamera2(BaseCamera): finally: self._setting_save_in_progress = False + @lt.thing_property + def calibration_required(self) -> bool: + """Whether the camera needs calibrating.""" + return not self.lens_shading_is_static + ## Persistent controls! These are settings _analogue_gain: float = 1.0 diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 7c32aa08..31a0a32f 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -86,6 +86,11 @@ class SimulatedCamera(BaseCamera): self.generate_blobs() self.generate_canvas() + @lt.thing_property + def calibration_required(self) -> bool: + """Whether the camera needs calibrating.""" + return not self.background_detector_status.ready + def validate_inputs(self) -> None: """Validate the inputs passed to the simulation, and raises an error if invalid. @@ -367,6 +372,22 @@ class SimulatedCamera(BaseCamera): LOGGER.warning(f"Simulation camera camera doesn't respect {stream_name=}") return Image.fromarray(self.generate_frame()) + @lt.thing_action + def full_auto_calibrate(self, portal: lt.deps.BlockingPortal) -> None: + """Perform a full auto-calibration. + + For the simulation microscope the process is: + + * ``remove_sample`` + * ``set_background`` + * ``load_sample`` + """ + self.remove_sample() + time.sleep(0.2) + self.set_background(portal) + time.sleep(0.2) + self.load_sample() + @lt.thing_action def remove_sample(self) -> None: """Show the simulated background with no sample.""" @@ -381,6 +402,15 @@ class SimulatedCamera(BaseCamera): raise RuntimeError("Sample is already in place.") self._show_sample = True + @lt.thing_property + def primary_calibration_actions(self) -> list[ActionButton]: + """The calibration actions for both calibration wizard and settings panel.""" + return [ + action_button_for( + self.full_auto_calibrate, submit_label="Full Auto-Calibrate" + ), + ] + @lt.thing_property def secondary_calibration_actions(self) -> list[ActionButton]: """The calibration actions that appear only in settings panel.""" diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index 57f3e742..75ce1968 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -242,6 +242,11 @@ class CameraStageMapper(lt.Thing): return None return self.last_calibration["image_resolution"] + @lt.thing_property + def calibration_required(self) -> bool: + """Whether the camera stage mapper needs calibrating.""" + return self.image_to_stage_displacement_matrix is None + def assert_calibrated(self) -> None: """Raise an exception if the image_to_stage_displacement matrix is not set.""" if self.image_to_stage_displacement_matrix is None: diff --git a/tests/test_camera.py b/tests/test_camera.py index 39a35d89..66091593 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -68,3 +68,14 @@ def test_handle_broken_frame(): for _i in range(15): array = camera.grab_as_array(portal) assert isinstance(array, np.ndarray) + + +def test_simulation_cam_calibration(): + """Test that the simulated camera can be calibrated and reports calibration correctly.""" + camera = SimulatedCamera() + with camera_server(camera): + portal = lt.get_blocking_portal(camera) + assert camera.calibration_required + camera.full_auto_calibrate(portal) + assert not camera.calibration_required + assert camera.background_detector_status.ready diff --git a/webapp/src/components/appContent.vue b/webapp/src/components/appContent.vue index a466ab61..31d7b50d 100644 --- a/webapp/src/components/appContent.vue +++ b/webapp/src/components/appContent.vue @@ -5,10 +5,10 @@ uk-grid > - + >
- - - - - - diff --git a/webapp/src/components/modalComponents/calibrationWizard.vue b/webapp/src/components/modalComponents/calibrationWizard.vue new file mode 100644 index 00000000..72f712b5 --- /dev/null +++ b/webapp/src/components/modalComponents/calibrationWizard.vue @@ -0,0 +1,198 @@ + + + diff --git a/webapp/src/components/modalComponents/calibrationWizardComponents/README.md b/webapp/src/components/modalComponents/calibrationWizardComponents/README.md new file mode 100644 index 00000000..65b58c1e --- /dev/null +++ b/webapp/src/components/modalComponents/calibrationWizardComponents/README.md @@ -0,0 +1,77 @@ +# The Calibration Wizard Components + +## Tasks vs Steps + +The components for the wizard uses the terms "Task" and "Step" to divide up the wizard's behaviour: + +* **Task**: A task is what we want to achieve. Such as "Calibrate the camera" or "Run camera stage mapping", there are multiple things to do for these complex tasks. A task can also be as simple as "welcome the user to their microscope." +* **Step**: A single page/pane of information shown to the user. The components for these can be as simple as a single `
` with no javascript logic. + +This is done to allow complex tasks like camera stage mapping to have multiple steps: + +1. An explanation and telling the user what sample to get +2. An interface for focusing +3. Actually running camera stage mapping + +None of these individual steps makes sense in the wizard on their own, hence the need for the "Task" grouping. By grouping the steps we can: + +- Easily include/exclude tasks based on state +- Have a consistent subtitle during a task +- Skip entire task rather than make the user click next through each pane of the task (not yet implemented) + +Some simple tasks such as the welcome screen will have only one step. + + +## Components + +### The main wizard component + +The calibration wizard is a modal. The top level component in `calibrationWizard.vue` handles: + +* Creating a modal +* Checking which Things that have calibration tasks are on the server +* Checking which of these Things need calibrating +* Starting modal on startup if not all Things are calibrated (with a welcome screen and just those tasks) +* Starting the modal when launched from settings, with all tasks but no welcome screen. +* Before starting the modal, a list of task components is dynamically generated. + +### The main task component + +The main task component `calibrationWizardTask.vue` is used to create multi-step tasks. Rather than using it directly it should be wrapped by a new component that: + +* Forwards all the props +* Forwards the events back to the wizard +* Creates a list of steps. + +Examples of this pattern are in `cameraCalibrationTask.vue` and `cameraStageMappingTask.vue`. + +### The single step task component + +For simple tasks with only one step there is no need to make both a specific task component. Instead `singleStepTask.vue` can be used, the step component can be supplied as a prop. + +This is used in the main wizard to create the welcome screen and the final page. + +## stepTemplateWithStream + +Step components are simple enough that generally there is no need for a template. However, a number of steps have mini-stream views. To keep these consistent `stepTemplateWithStream.vue` can be used. + +The template puts component content above the stream view, by default. But extra information can be added to the area below the stream by using the `