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

Support SofastFixed point light #66

Merged
merged 17 commits into from
Apr 4, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions example/sofast_fixed/example_measurement_from_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from os.path import join, dirname

import cv2 as cv

import opencsp.app.sofast.lib.image_processing as ip
from opencsp.app.sofast.lib.MeasurementSofastFixed import MeasurementSofastFixed
from opencsp.common.lib.geometry.Vxyz import Vxyz
from opencsp.common.lib.opencsp_path.opencsp_root_path import opencsp_code_dir
import opencsp.common.lib.render.figure_management as fm
import opencsp.common.lib.render_control.RenderControlAxis as rca
import opencsp.common.lib.render_control.RenderControlFigure as rcfg
import opencsp.common.lib.tool.file_tools as ft
import opencsp.common.lib.tool.log_tools as lt


def example_create_measurement_file_from_image():
"""Example that creates a SofastFixed measurement file from an image. The image has
a point LED light near origin dot.
1. Load image
2. Define measurement parameters
3. Find location of origin point
4. Create measurement object
5. Save measurement as HDF5 file
6. Plot image with annotated origin dot
"""
# General setup
# =============
dir_save = join(dirname(__file__), 'data/output/measurement_file')
ft.create_directories_if_necessary(dir_save)
lt.logger(join(dir_save, 'log.txt'), lt.log.INFO)

# 1. Load image
# =============
Copy link
Collaborator

Choose a reason for hiding this comment

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

I hadn't considered also including this style of visual separator. Makes this really easy to read!

file_meas = join(opencsp_code_dir(), 'test/data/sofast_fixed/data_measurement/measurement_facet.h5')
measurement_old = MeasurementSofastFixed.load_from_hdf(file_meas)
image = measurement_old.image
Copy link
Collaborator

Choose a reason for hiding this comment

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

I know it's silly, but it might make this more useful as an example if there were some commented out code for loading the image using opencv or pillow, as if you have an image jpg file instead of a measurement h5 file.

# image = cv2.imread(file_jpg, cv2.IMREAD_GRAYSCALE)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I like it! Added!


# 2. Define measurement parameters
# ================================
v_measure_point_facet = Vxyz((0, 0, 0)) # meters
dist_optic_screen = 10.008 # meters
name = 'NSTTF Facet'

# 3. Find location of origin point
# ================================

# Find point light
params = cv.SimpleBlobDetector_Params()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Idea time! It would be nice if you could retrieve these parameters from image_processing with some values such as filterByCircularity set for you:

params = ip.dot_target_blob_params(minDistBetweenBlobs=2, minArea=3, maxArea=30)

def dot_target_blob_params(
    minDistBetweenBlobs=2,
    filterByArea=True,
    minArea=3,
    maxArea=30,
    filterByCircularity=True,
    minCircularity=0.8,
    filterByConvexity=False,
    filterByInertia=False,
):
    params = cv.SimpleBlobDetector_Params()
    params.minDistBetweenBlobs = minDistBetweenBlobs
    params.filterByArea = filterByArea
    params.minArea = minArea
    params.maxArea = maxArea
    params.filterByCircularity = filterByCircularity
    params.minCircularity = minCircularity
    params.filterByConvexity = filterByConvexity
    params.filterByInertia = filterByInertia
    return params

To be clear, not necessary for this PR, and not necessary generally. Just an idea.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is an interesting idea! There are other inputs that SimpleBlobDetector_Params takes that I haven't typically used. Perhaps have a default_blob_detector_params() function or something? I'll have to think about how best to implement it, but I do really like it. Will probably wait until a future PR.

params.minDistBetweenBlobs = 2
params.filterByArea = True
params.minArea = 3
params.maxArea = 30
params.filterByCircularity = True
params.minCircularity = 0.8
params.filterByConvexity = False
params.filterByInertia = False
origin = ip.detect_blobs_inverse(image, params)

# Check only one origin point was found
if len(origin) != 1:
lt.error_and_raise(ValueError, f'Expected 1 origin point, found {len(origin):d}.')

# 4. Create measurement object
# ============================
measurement = MeasurementSofastFixed(image, v_measure_point_facet, dist_optic_screen, origin, name=name)

# 5. Save measurement as HDF5 file
# ================================
measurement.save_to_hdf(join(dir_save, 'measurement.h5'))

# 6. Plot image with annotated origin dot
# =======================================
image_annotated = ip.detect_blobs_inverse_annotate(image, params)

figure_control = rcfg.RenderControlFigure(tile_array=(1, 1), tile_square=True)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Unified render control style! 👍

axis_control = rca.image()
fig_record = fm.setup_figure(figure_control, axis_control, title='Annotated Origin Point')
fig_record.axis.imshow(image_annotated)
fig_record.save(dir_save, 'annotated_image_with_origin_point', 'png')


if __name__ == '__main__':
example_create_measurement_file_from_image()
5 changes: 4 additions & 1 deletion opencsp/app/sofast/lib/BlobIndex.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@ def _nearest_unassigned_idx_from_xy_point_direction(
dists_axis = v_search.dot(points_rel) # Distance of points along search axis
dists_perp = np.abs(v_perp.dot(points_rel)) # Distance of points from line
# Make mask of valid points
mask = np.logical_and(dists_axis > 0, dists_perp / dists_axis <= self.search_perp_axis_ratio)
mask_dist_positive = dists_axis > 0
dists_axis[dists_axis == 0] = np.nan
mask_ratio = (dists_perp / dists_axis) <= self.search_perp_axis_ratio
mask = np.logical_and(mask_dist_positive, mask_ratio)
# Check there are points to find
if mask.sum() == 0:
return False, (None, None)
Expand Down
2 changes: 0 additions & 2 deletions opencsp/app/sofast/lib/CalibrateDisplayShape.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
"""

from dataclasses import dataclass
import os

import cv2 as cv
import h5py
import matplotlib.pyplot as plt
import numpy as np
from numpy import ndarray
Expand Down
60 changes: 56 additions & 4 deletions opencsp/app/sofast/lib/image_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ def calc_mask_raw(
raise ValueError('Not enough distinction between dark and light pixels in mask images.')

# Calculate minimum between two peaks
# fmt: off
idx_hist_min = np.argmin(hist[peaks[0] : peaks[1]]) + peaks[0]
# fmt: on

# Find index of histogram that is "hist_thresh" the way between the min and max
thresh_hist_min = edges[idx_hist_min + 1]
Expand Down Expand Up @@ -422,8 +424,8 @@ def rectangle_loop_from_two_points(p1: Vxy, p2: Vxy, d_ax: float, d_perp: float)
# Calculate axial and perpendicular directions
v_axial = (p2 - p1).normalize()

R = np.array([[0, 1], [-1, 0]])
v_perp = v_axial.rotate(R)
rot_mat_90 = np.array([[0, 1], [-1, 0]])
Copy link
Collaborator

Choose a reason for hiding this comment

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

🥳

v_perp = v_axial.rotate(rot_mat_90)

# Create points
points = []
Expand All @@ -440,7 +442,8 @@ def rectangle_loop_from_two_points(p1: Vxy, p2: Vxy, d_ax: float, d_perp: float)


def detect_blobs(image: np.ndarray, params: cv.SimpleBlobDetector_Params) -> Vxy:
"""Detects blobs in image
"""Detects blobs in image. Blobs are defined as local dark regions in
neighboring light background.

Parameters
----------
Expand All @@ -462,8 +465,35 @@ def detect_blobs(image: np.ndarray, params: cv.SimpleBlobDetector_Params) -> Vxy
return Vxy(np.array(pts).T)


def detect_blobs_inverse(image: np.ndarray, params: cv.SimpleBlobDetector_Params) -> Vxy:
"""Detect blobs in image. Blobs are defined as local light regions in
neighboring dark background.

NOTE: This definition of blobs is the inverse as in `image_processing.detect_blobs()`

Parameters
----------
image : np.ndarray
Input image, uint8
Copy link
Collaborator

Choose a reason for hiding this comment

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

Presumably this should be a 2d image with a single color channel? AKA:

2D input image, single color channel, NxM or NxMx1, uint8

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good idea! Changed!

params : cv.SimpleBlobDetector_Params
Blob parameters

Returns
-------
Vxy
Centroids of blobs
"""
keypoints = _detect_blobs_keypoints(image.max() - image, params)

pts = []
for pt in keypoints:
pts.append(pt.pt)
return Vxy(np.array(pts).T)


def detect_blobs_annotate(image: np.ndarray, params: cv.SimpleBlobDetector_Params) -> np.ndarray:
"""Detects blobs in image
"""Detects blobs in image and annotates locations. Blobs are defined as local dark regions in
neighboring light background.

Parameters
----------
Expand All @@ -481,6 +511,28 @@ def detect_blobs_annotate(image: np.ndarray, params: cv.SimpleBlobDetector_Param
return cv.drawKeypoints(image, keypoints, np.array([]), (0, 0, 255), cv.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)


def detect_blobs_inverse_annotate(image: np.ndarray, params: cv.SimpleBlobDetector_Params) -> np.ndarray:
"""Detects blobs in image and annotates locations. Blobs are defined as local light regions in
neighboring dark background.

NOTE: This definition of blobs is the inverse as in `image_processing.detect_blobs()`

Parameters
----------
image : np.ndarray
Input image, uint8
params : cv.SimpleBlobDetector_Params
Blob parameters

Returns
-------
ndarray
Annotated image of blobs
"""
keypoints = _detect_blobs_keypoints(image.max() - image, params)
return cv.drawKeypoints(image, keypoints, np.array([]), (0, 0, 255), cv.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)


def _detect_blobs_keypoints(image: np.ndarray, params: cv.SimpleBlobDetector_Params) -> list[cv.KeyPoint]:
"""Detects blobs in image

Expand Down
55 changes: 53 additions & 2 deletions opencsp/app/sofast/test/test_image_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
from os.path import join
import unittest

import cv2 as cv
import numpy as np
from scipy.spatial.transform import Rotation

from opencsp.app.sofast.lib.ImageCalibrationScaling import ImageCalibrationScaling
from opencsp.app.sofast.lib.MeasurementSofastFringe import MeasurementSofastFringe as Measurement
from opencsp.app.sofast.lib.MeasurementSofastFringe import MeasurementSofastFringe
from opencsp.app.sofast.lib.MeasurementSofastFixed import MeasurementSofastFixed
from opencsp.app.sofast.lib.ParamsSofastFringe import ParamsSofastFringe
from opencsp.common.lib.camera.Camera import Camera
import opencsp.app.sofast.lib.image_processing as ip
Expand Down Expand Up @@ -36,6 +38,11 @@ def setUpClass(cls):
cls.data_file_measurement_ensemble = join(dir_sofast_fringe, 'data_measurement/measurement_ensemble.h5')
cls.data_file_calibration = join(dir_sofast_fringe, 'data_measurement/image_calibration.h5')

# Sofast fixed dot image
cls.sofast_fixed_meas = MeasurementSofastFixed.load_from_hdf(
join(opencsp_code_dir(), 'test/data/sofast_fixed/data_measurement/measurement_facet.h5')
)

def test_calc_mask_raw(self):
"""Tests image_processing.calc_mask_raw()"""

Expand Down Expand Up @@ -187,7 +194,7 @@ def test_unwrap_phase(self):
'DataSofastCalculation/image_processing/facet_000/mask_processed',
]
data = load_hdf5_datasets(datasets, self.data_file_facet)
measurement = Measurement.load_from_hdf(self.data_file_measurement_facet)
measurement = MeasurementSofastFringe.load_from_hdf(self.data_file_measurement_facet)
calibration = ImageCalibrationScaling.load_from_hdf(self.data_file_calibration)

measurement.calibrate_fringe_images(calibration)
Expand Down Expand Up @@ -228,6 +235,50 @@ def test_calculate_active_pixel_pointing_vectors(self):
# Test
np.testing.assert_allclose(data['u_pixel_pointing_facet'], u_pixel_pointing_optic)

def test_detect_blobs(self):
Copy link
Collaborator

Choose a reason for hiding this comment

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

unit tests! 🥳

params = cv.SimpleBlobDetector_Params()
params.minDistBetweenBlobs = 2
params.filterByArea = True
params.minArea = 3
params.maxArea = 30
params.filterByCircularity = True
params.minCircularity = 0.8
params.filterByConvexity = False
params.filterByInertia = False

blobs = ip.detect_blobs(self.sofast_fixed_meas.image, params)

self.assertEqual(len(blobs), 3761, 'Test number of blobs')
np.testing.assert_allclose(
blobs[0].data.squeeze(),
np.array([672.20654297, 1138.20654297]),
rtol=0,
atol=1e-6,
err_msg='First blob pixel location does not match expected',
)

def test_detect_blobs_inverse(self):
params = cv.SimpleBlobDetector_Params()
params.minDistBetweenBlobs = 2
params.filterByArea = True
params.minArea = 3
params.maxArea = 30
params.filterByCircularity = True
params.minCircularity = 0.8
params.filterByConvexity = False
params.filterByInertia = False

blobs = ip.detect_blobs_inverse(self.sofast_fixed_meas.image, params)

self.assertEqual(len(blobs), 1, 'Test number of blobs')
np.testing.assert_allclose(
blobs[0].data.squeeze(),
np.array([960.590515, 796.387695]),
rtol=0,
atol=1e-6,
err_msg='blob pixel location does not match expected',
)


if __name__ == '__main__':
unittest.main()
Binary file not shown.
Binary file not shown.