Skip to content
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

Merged
merged 11 commits into from
Feb 15, 2025

Conversation

hongquanli
Copy link
Contributor

@hongquanli hongquanli commented Feb 11, 2025

Major changes:

  • Add comprehensive error handling and logging to laser autofocus
  • Implement robust spot detection with multiple detection modes
  • Add type hints and docstrings throughout
  • Add class constants for configuration values
  • Add laser autofocus testing and plotting capabilities

Technical improvements:

  • Move SpotDetectionMode from utils.py to _def.py
  • Add image cross correlation-based error detection
  • Improve defensive programming practices
  • Remove obsolete variables

- Add type hints and comprehensive docstrings
- Improve error handling and logging
@hongquanli
Copy link
Contributor Author

(some unrelated changes due to linting; tried to revert in cursor but was not successful)

@ianohara
Copy link
Collaborator

(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:

black --config pyproject.toml .

from the software/ repo.

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.

Copy link
Collaborator

@ianohara ianohara left a 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:
Copy link
Collaborator

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?

else:
# calculate the conversion factor
self.pixel_to_um = 6.0 / (x1 - x0)
Copy link
Collaborator

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?

Copy link
Contributor Author

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.

Copy link
Collaborator

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.

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
Copy link
Collaborator

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.

Copy link
Contributor Author

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)?

Copy link
Collaborator

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
Copy link
Collaborator

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

continue

# Check if we got enough successful detections
if successful_detections == 0:
Copy link
Collaborator

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()
Copy link
Collaborator

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

Copy link
Contributor Author

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?

Copy link
Collaborator

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]]:
Copy link
Collaborator

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good


try:
# Get the y position of the spots
intensity_profile = np.sum(image, axis=1)
Copy link
Collaborator

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.

Copy link
Contributor Author

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:
Copy link
Collaborator

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]:
Copy link
Collaborator

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.

@hongquanli hongquanli changed the title Improve laser autofocus laser autofocus: update spot finding, add error checking and logging throughout Feb 15, 2025
@hongquanli hongquanli marked this pull request as ready for review February 15, 2025 07:31
@hongquanli hongquanli changed the title laser autofocus: update spot finding, add error checking and logging throughout feat: improve laser autofocus with robust spot detection and error handling Feb 15, 2025
@hongquanli hongquanli merged commit f0c5e17 into master Feb 15, 2025
0 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants