-
Notifications
You must be signed in to change notification settings - Fork 39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: improve laser autofocus with robust spot detection and error handling #101
Conversation
- Add type hints and comprehensive docstrings - Improve error handling and logging
(some unrelated changes due to linting; tried to revert in cursor but was not successful) |
For linting, it should always be safe (and acceptable) to just run:
from the If we all get in the habit of doing that, and don't merge PRs if the linting is failing, then the lint changes in a PR will only pertain to code changed in the PR. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I left a bunch of small comments, but overall it looks great in terms of accomplishing proper error handling in the autofocus code.
Before merging, it'd be good to rename the title to something that matches the intent of the PR a bit more closely. EG: autofocus: add error checking and logging throughout
self.microcontroller.turn_off_AF_laser() | ||
self.microcontroller.wait_till_operation_is_completed() | ||
|
||
# Calculate conversion factor | ||
if x1 - x0 == 0: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this do weird things if the spot detection coincidentally gives us two equal x spots in a real scenario? Or is that literally impossible in the non-sim case?
software/control/core/core.py
Outdated
else: | ||
# calculate the conversion factor | ||
self.pixel_to_um = 6.0 / (x1 - x0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where does this 6 come from? Is this something we should grab from the camera instead of having it hard coded?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was arbitrary. It should be large so that the conversion calculation is robust but not too large to be outside the "capture" range.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gotcha. In that case, pulling it out as a class constant (see example below) and leaving a constant explaining it would be helpful.
software/control/core/core.py
Outdated
return False | ||
|
||
self._log.debug(f"Final displacement: {final_displacement:.1f} μm") | ||
return abs(final_displacement - target_um) < 1.0 # Success if within 1 μm |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'd be good to break this 1.0
out to a class constant for clarity.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any suggestion where to specify these? Maybe another configuration objective (also for the 6 um)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd start by just defining it at the class level as a constant. That'll make it easy to see and pull out into a config object later if we want. EG:
class LaserAutofocusController(QObject):
DISPLACEMENT_SUCCESS_WINDOW_UM = 1
or something. Then, later, if we decide it should be a more configurable value on a per-call basis we can add it as an argument to the method and let the caller pull it from whatever config they're using.
self.camera.send_trigger() | ||
elif self.liveController.trigger_mode == TriggerMode.HARDWARE: | ||
# self.microcontroller.send_hardware_trigger(control_illumination=True,illumination_on_time_us=self.camera.exposure_time*1000) | ||
pass # to edit |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we make the HARDWARE
case return None
for now? I think if we don't, and we get here in HARDWARE
mode, the read_frame
might hang below.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes
software/control/core/core.py
Outdated
continue | ||
|
||
# Check if we got enough successful detections | ||
if successful_detections == 0: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From a "defensive programming" perspective, I'd normally make this an if successful_detections <= 0
check instead of ==
. Then it acts as a shield against accidentally making successful_detections negative somehow (it's obvious that never happens now, but you never know with refactoring...)
MULTI_SECOND_RIGHT: In multi-spot case, use spot immediately left of rightmost spot | ||
""" | ||
|
||
SINGLE = auto() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't know about auto()
! Nice! It looks like there's a pretty easy way to have auto
generate string values for you ( see https://docs.python.org/3/library/enum.html#enum.Enum._generate_next_value_ ).
The one downside of auto() in its int form I can think of is if we are using pydantic
and serializing Enum
. If someone comes and adds an entry between e.g. DUAL_RIGHT
and DUAL_LEFT
, and we're using ints from auto()
for enum values, then all the entries after DUAL_RIGHT
will get incremented. Then, any pydantic models we've serialized to file (in json, xml, or other format) will be deserialized incorrectly!
If you use the _generate_next_value_
--> name
value instead, though, we're resilient to re-ordering but would fail on re-naming. This is a pretty common problem with serialization formats. Things like protobuf handle it by separating name and serialization identifier, and forcing you to manually allocate/manage them. Here's the protobuf bit on this: https://protobuf.dev/programming-guides/proto3/#deleting
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any suggestion what we should use here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can make our own helper to use the names as values. For instance (in ipython):
In [16]: import enum
In [17]: class NameEnum(enum.Enum):
...: def _generate_next_value_(name, start, count, last_values):
...: return name
...:
In [18]: class SpotDetectionMode(NameEnum):
...: SINGLE = enum.auto()
...: DUAL_RIGHT = enum.auto()
...: ETC = enum.auto()
...:
In [19]: SpotDetectionMode.SINGLE.value
Out[19]: 'SINGLE'
Then anyone can use NameEnum
for this case.
|
||
def find_spot_location( | ||
image: np.ndarray, mode: SpotDetectionMode = SpotDetectionMode.SINGLE, params: Optional[dict] = None | ||
) -> Optional[Tuple[float, float]]: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'd be nice to make the params and result here pydantic models. That way we aren't reliant on raw string indexing.
That'd also let you define the param defaults in the same location as the model definition.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good
software/control/utils.py
Outdated
|
||
try: | ||
# Get the y position of the spots | ||
intensity_profile = np.sum(image, axis=1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think when I was testing laser autofocus, the dots were at visibly different y positions in the image. I think this will have trouble with that case (we're more likely to end up having the spot that doesn't get the y selection here too close to the edge in the check below).
I guess now that you've plumped all the error checking through we'll be able to see the failure mode better, so we can see if it's an issue or not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great point. We can adjust the camera orientation so that the spot only move around x. I'll add this one to linear.app so that we can get back to it later.
# Calculate centroid in window around selected peak | ||
return _calculate_spot_centroid(cropped_image, peak_x, peak_y, p) | ||
|
||
except Exception as e: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the intent of this is to catch the errors you've thrown above and convert them to None
, but probably not to catch other miscellaneous exceptions. If so, it'd be better to just catch ValueError
. But if we do want something like internal np errors getting caught here (if those are thrown, it probably indicates some error we don't know how to handle), this works.
I guess its a decision on our part of whether or not we're okay with squashing unpredicted errors or not.
return None | ||
|
||
|
||
def _calculate_spot_centroid(cropped_image: np.ndarray, peak_x: int, peak_y: int, params: dict) -> Tuple[float, float]: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The functions in this file are great candidates for unit tests. Even simple ones like making your own 5x5 cropped image with a known center and passing it in.
Major changes:
Technical improvements: