-
Notifications
You must be signed in to change notification settings - Fork 6
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
Add cursors module #48
base: main
Are you sure you want to change the base?
Conversation
Hi @cgohlke I have this error with: Run python -m black --check cursors.py
And I thoght it was due to: single quotes and lines up to 79 characters are allowed, but I checked all the lines and there is no line over 79 characters. I there anything am I missing? Thanks |
Just run |
Let us know when this PR is ready to be reviewed. |
Thanks, It worked now, so without "check" the "black" command correct the mistakes, right? |
You can do it now |
- :py:func:`circular_cursor` | ||
- :py:func:`range_cursor` | ||
|
||
|
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.
Maybe give a preview of what other functions/functionalities are going to be in this module?
tests/test_cursors.py
Outdated
center = numpy.array([[-0.5, -0.5], [-0.5, 0.5], [0.5, -0.5], [0.5, 0.5]]) | ||
radius = [0.1, 0.1, 0.1, 0.1] | ||
mask = circular_cursor(real, imag, center, radius=radius, components=4) | ||
assert_array_equal(mask, [1., 2., 3., 4.]) |
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.
This file does not seem to be formatted with black?
src/phasorpy/cursors.py
Outdated
- use cursors to select region of interest in the phasor: | ||
|
||
- :py:func:`circular_cursor` | ||
- :py:func:`range_cursor` |
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.
range_cursor
does not seem to operate on phasor coordinates (?)
src/phasorpy/cursors.py
Outdated
|
||
Examples | ||
-------- | ||
Compute the range cursor: |
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.
Add blank line after.
In what sense is this a lookup table? This kind of data is usually encoded as a bit-field... It looks like none of us has a clear idea of what (and why) is the best "cursors" data structure for phasor analysis and visualization. Lets discuss on Monday. So far the options are labels and bit-fields. A 2-dimensional LUT (indexed with s and g) is another option. I propose to finish this PR (address all comments and pass tests) so it can be used already. Since we might want to add other data structures and shapes later, maybe rename the functions to something like |
I didn´t know about bit-field, I read about it now, and you are right. Let talk on monday about it!
Great, I´ll work on monday after we talk, we have the workshop next week at the unit and I still need to finish some things for my presentation this weekend. |
@cgohlke all tests passed, it is ready for review |
Can you merge the main branch and mark open conversations as resolved if they were addressed? |
I cannot merge, bc I need one approving review |
What I meant was to merge/pull the current |
Several of the previous comments have not been addressed and the tutorial needs to be tidied. See the patch below for suggested changes. I strongly recommend to exclude upper range values from the ranges. It is the default in Python and numpy, and no warning is printed in the tutorial. The types of the returned arrays should be Both cursor function are inefficient. This can be changed later. I still think the range cursor could be implemented using diff --git a/docs/api/index.rst b/docs/api/index.rst
index d9314d6..c20663c 100644
--- a/docs/api/index.rst
+++ b/docs/api/index.rst
@@ -14,6 +14,7 @@ PhasorPy library version |version|.
phasorpy
phasor
+ cursors
components
plot
io
@@ -21,4 +22,3 @@ PhasorPy library version |version|.
datasets
utils
cli
- cursors
diff --git a/src/phasorpy/cursors.py b/src/phasorpy/cursors.py
index 1a51a13..728260f 100644
--- a/src/phasorpy/cursors.py
+++ b/src/phasorpy/cursors.py
@@ -1,10 +1,11 @@
-""" Select phasor coordinates.
- The ``phasorpy.cursors`` module provides functions to:
+"""Select phasor coordinates.
+
+The ``phasorpy.cursors`` module provides functions to:
- create labels for region of interests in the phasor space:
- - :py:func:`circular_cursor`
- - :py:func:`range_cursor`
+ - :py:func:`label_from_phasor_circular`
+ - :py:func:`label_from_ranges`
"""
@@ -30,12 +31,14 @@ import numpy
def label_from_phasor_circular(
- real,
- imag,
+ real: ArrayLike,
+ imag: ArrayLike,
+ /,
center: ArrayLike,
radius: ArrayLike,
) -> NDArray[Any]:
r"""Return indices of circle to which each phasor coordinate belongs.
+
Phasor coordinates that do not fall in a circle have an index of zero.
Parameters
@@ -64,11 +67,14 @@ def label_from_phasor_circular(
--------
Compute label array for four circles:
- >>> label_from_phasor_circular(numpy.array([-0.5, -0.5, 0.5, 0.5]),
- ... numpy.array([-0.5, 0.5, -0.5, 0.5]),
- ... numpy.array([[-0.5, -0.5], [-0.5, 0.5], [0.5, -0.5], [0.5, 0.5]]),
- ... radius=[0.1, 0.1, 0.1, 0.1])
- array([1, 2, 3, 4])
+ >>> label_from_phasor_circular(
+ ... [-0.5, -0.5, 0.5, 0.5],
+ ... [-0.5, 0.5, -0.5, 0.5],
+ ... center=[[-0.5, -0.5], [-0.5, 0.5], [0.5, -0.5], [0.5, 0.5]],
+ ... radius=[0.1, 0.1, 0.1, 0.1]
+ ... )
+ array([1, 2, 3, 4], dtype=uint8)
+
"""
real = numpy.asarray(real)
imag = numpy.asarray(imag)
@@ -77,29 +83,37 @@ def label_from_phasor_circular(
if real.shape != imag.shape:
raise ValueError(f'{real.shape=} != {imag.shape=}')
+ if center.ndim != 2 or center.shape[1] != 2:
+ raise ValueError(f'invalid {center.shape=}')
+ if radius.ndim != 1 or radius.shape != (center.shape[0],):
+ raise ValueError(f'invalid {radius.shape=}')
if numpy.any(radius < 0):
raise ValueError('radius is < 0')
- label = numpy.zeros(real.shape, dtype=numpy.int8)
+
+ dtype = numpy.uint8 if len(center) < 256 else numpy.uint16
+ label = numpy.zeros(real.shape, dtype=dtype)
for i in range(len(center)):
condition = (
- (real - center[i][0]) ** 2
- + (imag - center[i][1]) ** 2
- - radius[i] ** 2
+ numpy.square(real - center[i][0])
+ + numpy.square(imag - center[i][1])
+ - numpy.square(radius[i])
)
label = numpy.where(
- condition > 0, label, numpy.ones(label.shape) * (i + 1)
+ condition > 0, label, numpy.full(label.shape, i + 1, dtype=dtype)
)
return label
-def label_from_ranges(values, ranges: ArrayLike) -> NDArray[Any]:
+def label_from_ranges(values: ArrayLike, /, ranges: ArrayLike) -> NDArray[Any]:
r"""Return indices of range to which each value belongs.
+
Values that do not fall in any range have an index of zero.
Parameters
----------
- values : array_like, shape (M, 2)
- ranges : array_like
+ values : array_like
+ Values to be labeled.
+ ranges : array_like, shape (M, 2)
Start and stop values of ranges.
Returns
@@ -114,52 +128,60 @@ def label_from_ranges(values, ranges: ArrayLike) -> NDArray[Any]:
Examples
--------
- Compute the range cursor:
+ Compute label array for three ranges:
+
+ >>> label_from_ranges(
+ ... [[3.3, 6, 8], [15, 20, 7]], ranges=[(2, 8), (10, 15), (20, 25)]
+ ... )
+ array([[1, 1, 0], [0, 3, 1]], dtype=uint8)
- >>> label_from_ranges(numpy.array([[3.3, 6, 8], [15, 20, 7]]),
- ... numpy.array([(2, 8), (10, 15), (20, 25)]))
- array([[1, 1, 1], [2, 3, 1]])
"""
values = numpy.asarray(values)
ranges = numpy.asarray(ranges)
+ if ranges.ndim != 2 or ranges.shape[1] != 2:
+ raise ValueError(f'invalid {ranges.shape=}')
if _overlapping_ranges(ranges):
- warnings.warn("Overlapping ranges", UserWarning)
- label = numpy.zeros_like(values, dtype=int)
+ warnings.warn('Overlapping ranges', UserWarning)
+
+ dtype = numpy.uint8 if ranges.shape[0] < 256 else numpy.uint16
+ label = numpy.zeros_like(values, dtype=dtype)
# Iterate over each value in the array
for index, value in numpy.ndenumerate(values):
# Iterate over each range
for range_index, (start, end) in enumerate(ranges):
# Check if the value falls within the current range
- if start <= value <= end:
+ if start <= value < end:
# Set the index of the current range
label[index] = range_index + 1
break
return label
-def _overlapping_ranges(ranges) -> bool:
+def _overlapping_ranges(ranges: ArrayLike) -> bool:
r"""Check if there are overlapping ranges in an array of ranges.
Parameters
----------
- ranges : array_like
- Start and stop values of ranges.
+ ranges : array_like
+ Start and stop values of ranges.
Returns
-------
- bool: True if there are overlapping ranges, False otherwise.
+ bool: True if there are overlapping ranges, False otherwise.
Example
-------
- Compute for some range with overlapping.
- >>> _overlapping_ranges([(1, 5), (3, 8), (6, 10), (9, 12)])
- True
+ Compute for some range with overlapping:
+
+ >>> _overlapping_ranges([(1, 5), (3, 8), (6, 10), (9, 12)])
+ True
+
"""
ranges = numpy.asarray(ranges)
for i in range(len(ranges)):
for j in range(i + 1, len(ranges)):
# Check if the ranges overlap
- if ranges[i][0] <= ranges[j][1] and ranges[j][0] <= ranges[i][1]:
+ if ranges[i][0] < ranges[j][1] and ranges[j][0] < ranges[i][1]:
return True # Ranges overlap
return False # No overlaps found
diff --git a/tests/test_cursors.py b/tests/test_cursors.py
index 3339ad8..1e27354 100644
--- a/tests/test_cursors.py
+++ b/tests/test_cursors.py
@@ -13,11 +13,12 @@ def test_label_from_phasor_circular():
imag = numpy.array([-0.5, 0.5, -0.5, 0.5])
center = numpy.array([[-0.5, -0.5], [-0.5, 0.5], [0.5, -0.5], [0.5, 0.5]])
radius = [0.1, 0.1, 0.1, 0.1]
- mask = label_from_phasor_circular(real, imag, center, radius=radius)
- assert_array_equal(mask, [1.0, 2.0, 3.0, 4.0])
+ labels = label_from_phasor_circular(real, imag, center, radius=radius)
+ assert labels.dtype == 'uint8'
+ assert_array_equal(labels, [1.0, 2.0, 3.0, 4.0])
-def test_label_from_phasor_circular_ValueErros():
+def test_label_from_phasor_circular_errors():
# Test ValueErrors
real = numpy.array([-0.5, -0.5, 0.5, 0.5])
imag = numpy.array([-0.5, 0.5, -0.5, 0.5])
@@ -37,5 +38,6 @@ def test_label_from_ranges():
# Test label from ranges
values = numpy.array([[3.3, 6, 8], [15, 20, 7]])
ranges = numpy.array([(2, 8), (10, 15), (20, 25)])
- mask = label_from_ranges(values, ranges)
- assert_array_equal(mask, [[1, 1, 1], [2, 3, 1]])
+ labels = label_from_ranges(values, ranges)
+ assert labels.dtype == 'uint8'
+ assert_array_equal(labels, [[1, 1, 0], [0, 3, 1]])
diff --git a/tutorials/phasorpy_cursors.py b/tutorials/phasorpy_cursors.py
index 2c782f8..2bf59ab 100644
--- a/tutorials/phasorpy_cursors.py
+++ b/tutorials/phasorpy_cursors.py
@@ -1,8 +1,8 @@
"""
-Phasor Cursors selectors
-========================
+Phasor cursors
+==============
-An introduction to cursors module.
+An introduction to selecting phasor coordinates using cursors.
"""
@@ -11,8 +11,8 @@ An introduction to cursors module.
import math
-import numpy
import tifffile
+from matplotlib import pyplot
from phasorpy.cursors import label_from_phasor_circular, label_from_ranges
from phasorpy.datasets import fetch
@@ -20,80 +20,83 @@ from phasorpy.phasor import phasor_from_signal, phasor_to_polar
from phasorpy.plot import PhasorPlot
# %%
-# Using circular cursors
-# ----------------------
+# Circular cursors
+# ----------------
#
-# Use circular cursors to select regions of interest.
+# Use circular cursors in the phasor space to segment the images:
signal = tifffile.imread(fetch('paramecium.lsm'))
mean, real, imag = phasor_from_signal(signal, axis=0)
-label = label_from_phasor_circular(
- real,
- imag,
- numpy.array(
- [[-0.48, -0.65], [-0.22, -0.75], [0.40, -0.80], [0.66, -0.68]]
- ),
- radius=[0.15, 0.15, 0.15, 0.15],
-)
+center = [(-0.48, -0.65), (-0.22, -0.75), (0.4, -0.8), (0.66, -0.68)]
+radius = [0.15, 0.15, 0.15, 0.15]
+label = label_from_phasor_circular(real, imag, center, radius)
# %%
-# Circular cursors
-# ----------------
-#
+# Show the circular cursors in the phasor plot:
mask = mean > 1
-real = real[mask]
-imag = imag[mask]
-
-import matplotlib.pyplot as plt
-
-
-def cart2pol(x, y):
- rho = numpy.sqrt(x**2 + y**2)
- phi = numpy.arctan2(y, x)
- return (phi, rho)
-
-
-# components centers in polars
-t1, r1 = cart2pol(-0.48, -0.65)
-t2, r2 = cart2pol(-0.22, -0.75)
-t3, r3 = cart2pol(0.40, -0.80)
-t4, r4 = cart2pol(0.66, -0.68)
-plotcursors = True
-if plotcursors:
- plot = PhasorPlot(allquadrants=True, title='Test cursors selection')
- plot.polar_cursor(t1, r1, radius=0.15)
- plot.polar_cursor(t2, r2, radius=0.15)
- plot.polar_cursor(t3, r3, radius=0.15)
- plot.polar_cursor(t4, r4, radius=0.15)
- plot.hist2d(real, imag, cmap='Blues')
+plot = PhasorPlot(allquadrants=True, title='Circular cursors')
+plot.hist2d(real[mask], imag[mask])
+plot.cursor(*center[0], radius=radius[0], color='tab:orange', linestyle='-')
+plot.cursor(*center[1], radius=radius[1], color='tab:green', linestyle='-')
+plot.cursor(*center[2], radius=radius[2], color='tab:red', linestyle='-')
+plot.cursor(*center[3], radius=radius[3], color='tab:purple', linestyle='-')
-plt.figure()
-plt.imshow(label)
-plt.show()
+# %%
+# Show the label image:
+fig, ax = pyplot.subplots()
+ax.set_title('Labels from circular cursors')
+plt = ax.imshow(label, vmin=0, vmax=10, cmap='tab10')
+fig.colorbar(plt)
+pyplot.show()
# %%
# Range cursors
# -------------
#
-# Compute phasor from signal and get the phase values.
+# Create labels from ranges of phase values computed from phasor coordinates:
mean, real, imag = phasor_from_signal(signal, axis=0)
phase, _ = phasor_to_polar(real, imag)
-rang = numpy.array(
- [(0, numpy.pi), (-numpy.pi / 2, 0), (-numpy.pi, -numpy.pi / 2)]
-)
-label = label_from_ranges(phase, ranges=rang)
+ranges = [(0, math.pi), (-math.pi / 2, 0), (-math.pi, -math.pi / 2)]
-plt.figure()
-plt.imshow(label)
+label = label_from_ranges(phase, ranges=ranges)
-plot = PhasorPlot(allquadrants=True, title='Raw phasor')
-plot.hist2d(real, imag, cmap='Blues')
+# %%
+# Show the label image:
+
+fig, ax = pyplot.subplots()
+ax.set_title('Labels from phase ranges')
+plt = ax.imshow(label, vmin=0, vmax=10, cmap='tab10')
+fig.colorbar(plt)
+pyplot.show()
-plt.show()
+# %%
+# Show the range cursors in the phasor plot:
+
+plot = PhasorPlot(allquadrants=True, title='Phase range cursors')
+plot.hist2d(real, imag, cmap='Blues')
+plot.polar_cursor(
+ phase=ranges[0][0],
+ phase_limit=ranges[0][1],
+ color='tab:orange',
+ linestyle='-',
+)
+plot.polar_cursor(
+ phase=ranges[1][0],
+ phase_limit=ranges[1][1],
+ color='tab:green',
+ linestyle='-',
+)
+plot.polar_cursor(
+ phase=ranges[2][0],
+ phase_limit=ranges[2][1],
+ color='tab:red',
+ linestyle='-',
+)
+plot.show()
|
thnaks @cgohlke I´ll apply the changes tonight and have a look at numpy.digitize bc I didn´t do it. I update everything tommorrow during the day so we keep interating until it gets ready. Also I didn´t try to optimize it or improve the functions bc you mention that eventually we could write them in cython. Do you think I should do it now? |
@cgohlke I changed the function label_from_ranges to label_from_lut so for any given lut that is created with some ranges that can overlap we can select ROI´s in the phasor. I add this in the tutorial with some plot using the polar cursors. Please have a look when you can and give me your feedback. |
############################################# | ||
############################################# |
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.
Remove
min_vals1: NDArray, | ||
max_vals1: NDArray, | ||
min_vals2: NDArray, | ||
max_vals2: NDArray, |
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.
Array inputs should be ArrayLike
, not NDArray
.
- min_vals1: NDArray | ||
Array of minimum values to binarize data1. | ||
- max_vals1: NDArray | ||
Array of maximum values to binarize data1. | ||
- min_vals2: NDArray | ||
Array of minimum values to binarize data2. | ||
- max_vals2: NDArray | ||
Array of maximum values to binarize data2. |
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.
Follow numpy docstring standard.
|
||
Returns | ||
------- | ||
- dict |
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.
Follow numpy docstring standard.
max_vals2: NDArray, | ||
) -> dict: | ||
""" | ||
Create a Lookup Table (LUT) with two pairs of minimum and maximum values. |
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.
-> """Return lookup table (LUT) with two pairs of minimum and maximum values.
- data1: NDArray | ||
The first data array. | ||
- data2: NDArray | ||
The second data array. | ||
- lut: dict | ||
Lookup Table (LUT) mapping input values to binarized output values. | ||
|
||
Returns | ||
------- | ||
- label: NDArray: | ||
The binarized array. |
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.
Follow numpy docstring standard.
Raises | ||
------ | ||
ValueError | ||
'Input arrays must have same shapes' |
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.
Remove '
#### EXAMPLES | ||
# Examples | ||
# -------- | ||
# Create a LUT based on ranges values: | ||
|
||
# >>> create_lut( | ||
# ... min_vals1 = numpy.array([0, 3, 6]), | ||
# ... max_vals1 = numpy.array([2, 5, 8]), | ||
# ... min_vals2 = numpy.array([1, 4, 7]), | ||
# ... max_vals2 = numpy.array([3, 6, 9])) | ||
# {((0, 2), (1, 3)): 1, ((0, 2), (4, 6)): 2, ((0, 2), (7, 9)): 3, | ||
# ... ((3, 5), (1, 3)): 4, ((3, 5), (4, 6)): 5, ((3, 5), (7, 9)): 6, | ||
# ... ((6, 8), (1, 3)): 7, ((6, 8), (4, 6)): 8, ((6, 8), (7, 9)): 9} | ||
|
||
# Example | ||
# ------- | ||
# >>> arr1 = numpy.array([[1.2, 2.4, 3.5], [4.7, 5.1, 6.9], [7.3, 8.6, 9.0]]) | ||
# >>> arr2 = numpy.array([[0.8, 2.1, 3.9], [4.2, 5.7, 6.3],[7.5, 8.2, 9.5]]) | ||
# >>> lut = {((0, 2), (1, 3)): 1, ((0, 2), (4, 6)): 2, ((0, 2), (7, 9)): 3, | ||
# ... ((3, 5), (1, 3)): 4, ((3, 5), (4, 6)): 5, ((3, 5), (7, 9)): 6, | ||
# ... ((6, 8), (1, 3)): 7, ((6, 8), (4, 6)): 8, ((6, 8), (7, 9)): 9} | ||
# >>> label = label_from_lut(arr1, arr2, lut) | ||
# array([[0, 0, 0], [5, 0, 0], [9, 0, 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.
Remove
------ | ||
ValueError | ||
'Input arrays must have same shapes' | ||
|
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.
Example is missing.
------ | ||
ValueError | ||
'Input array must have same shapes' | ||
""" |
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.
Example is missing
This PR contains changes which do not belong here. Please update your branch from the main phasorpy branch as discussed before. I find the use of |
@@ -0,0 +1,216 @@ | |||
""" Select phasor coordinates. | |||
|
|||
The ``phasorpy.cursors`` module provides functions to: |
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.
Remove indent.
Description
Cursors module to select component in regions
1- For circular selection
2- For to selec component in linear ranges
...
Release note
Summarize the changes in the code block below to be included in the
release notes:
Checklist