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

Vectorize geometries #1059

Merged
merged 15 commits into from
Sep 26, 2017
Merged

Vectorize geometries #1059

merged 15 commits into from
Sep 26, 2017

Conversation

kohr-h
Copy link
Member

@kohr-h kohr-h commented Jul 4, 2017

Incomplete PR for vectorization of geometry methods.

TODO:

  • Propagate check_bounds to detectors
  • Update doc for detectors
  • Improve repr of detectors
  • Decide on what to do with nonsense parameters (e.g. normalized when only one choice is valid, but the parameter is there for API consistency)
    Solution: I removed normalized from ParallelGeometry.det_to_src, can't seem to remember what other cases I had in mind.
  • Replace super() calls
    Solution: I did like suggested n Use super(cls, self) instead of super() or hard-coded class? #1061
  • Check if the custom all_contained() is really needed, seems fishy
    Solution: Turned out a simple transpose did the job
  • Fix methods depending on two variables (as in 56393e6)
  • Add unit tests Have already a bunch of doctests, and ASTRA vec geometry generation already uses vectorization, should be good enough?

@kohr-h kohr-h force-pushed the vectorize_geometries branch from 1e65f17 to e4176b1 Compare July 5, 2017 14:59
@kohr-h kohr-h changed the title WIP: Vectorize geometries Vectorize geometries Jul 5, 2017
@kohr-h
Copy link
Member Author

kohr-h commented Jul 5, 2017

Ready for review. As often before when working on geometries, there's a lot of new documentation, including doctests, which accounts for the biggest part of the PR. Documentation is way improved now IMO.

"""Parametrization of the detector reference surface.
"""Return the detector surface point corresponding to ``param``.

The surface point lies on a circle around ``radius * center_dir``
Copy link
Member Author

@kohr-h kohr-h Jul 5, 2017

Choose a reason for hiding this comment

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

`center`

infinity).
The shape of the returned array is as follows:

- ``mparam`` and ``dparam`` single: ``(ndim,)``
Copy link
Member Author

Choose a reason for hiding this comment

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

angle

contained in `motion_params`.
angle : float or `array-like`
Angle(s) in radians describing the counter-clockwise
rotation of the detector.
Copy link
Member Author

Choose a reason for hiding this comment

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

Needs more general formulation that includes multiple angles.


# Need custom check here until #861 is in because arrays are
# assumed to be flat in the "point enumeration" axis
# TODO: replace when #861 is in
Copy link
Member Author

Choose a reason for hiding this comment

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

Missed this old check, needs to be replaced.

Copy link
Member

@adler-j adler-j left a comment

Choose a reason for hiding this comment

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

One big overall comment about how we should handle arrays of input that needs to be discussed.

Other than that only minor stuff, good code quality as usual.

@@ -431,7 +430,7 @@ def astra_projection_geometry(geometry):
raise ValueError('non-uniform detector sampling is not supported')

if (isinstance(geometry, ParallelBeamGeometry) and
isinstance(geometry.detector, FlatDetector) and
isinstance(geometry.detector, (Flat1dDetector, Flat2dDetector)) and
Copy link
Member

Choose a reason for hiding this comment

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

Wait wat. The next line guarantees that its a 1d detector, no?

Copy link
Member Author

Choose a reason for hiding this comment

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

Well not really. It just says that the whole geometry is embedded in a 2D space, the detector can be anything. This was probably supposed to be the duck-typed version of isinstance(geometry, Parallel2dGeometry).
But honestly if somebody decides to write a new geometry that doesn't subclass Parallel2dGeometry but still fulfills these conditions (whatever that may be), it would require a new conversion implementation anyway. So we might as well just do isinstance(geometry, Parallel2dGeometry).

if angle not in self.motion_params:
raise ValueError('`angle` {} is not in the valid range {}'
''.format(angle, self.motion_params))
squeeze_out = np.isscalar(angle)
Copy link
Member

Choose a reason for hiding this comment

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

>>> np.isscalar(np.array(1))
False

is this intended?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point. I guess I would like to change this to np.shape(angle) == () which covers both cases.

if np.linalg.norm(src_to_det_init) <= 1e-10:
raise ValueError('`src_to_det_init` norm {} too close to 0'
''.format(np.linalg.norm(src_to_det_init)))
if np.linalg.norm(src_to_det_init) == 0:
Copy link
Member

Choose a reason for hiding this comment

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

we removed the close-ness check for what reason?

Copy link
Member Author

Choose a reason for hiding this comment

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

I expect issues regarding scales. If somebody works on electron tomography where lengths are in the range of nanometers, and they decide to not (or forget to) normalize the vector, the old check will produce an unreasonable error.

With all these closeness checks on user input my attitude is to let the user figure out what to provide and how to deal with numerical accuracy issues, instead of us imposing some arbitrary bounds.

Copy link
Member

Choose a reason for hiding this comment

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

Agree. Good change.


Returns
-------
axes : tuple of `numpy.ndarray`'s
Copy link
Member

Choose a reason for hiding this comment

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

Why return a tuple of arrays instead of simply one large array? Seems much more reasonable and makes the note much more simple

Copy link
Member Author

Choose a reason for hiding this comment

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

Makes sense, yes. I mainly did it in the original version to resemble the input for axes parameters, i.e., a sequence of vectors.
I'll change it to a big array with shape (num_axes, num_params, ndim).

Copy link
Member Author

Choose a reason for hiding this comment

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

Do we want the same for the axes property of detectors? Single array instead of tuple?

Copy link
Member

Choose a reason for hiding this comment

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

For consistency yeah i think so

Copy link
Member Author

Choose a reason for hiding this comment

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

OK I'll go with it

Other Parameters
----------------
check_bounds : bool, optional
If ``True``, methods perform sanity checks on provided input
Copy link
Member

@adler-j adler-j Jul 9, 2017

Choose a reason for hiding this comment

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

Somewhat ambiguous what parameters are refered to here, I'd read it as the parameters to this method.

Copy link
Member Author

Choose a reason for hiding this comment

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

Tried to come up with a better yet short explanation.

@@ -1014,9 +1196,9 @@ def frommatrix(cls, apart, dpart, init_matrix):
# Use the standard constructor with these vectors
axis, det_pos, det_axis_0, det_axis_1 = transformed_vecs
if translation.size == 0:
kwargs = {}
pass
Copy link
Member

Choose a reason for hiding this comment

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

just make an if translation.size != 0:

Copy link
Member Author

Choose a reason for hiding this comment

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

Sure, this was the work-in-progress version just in case I want to switch back. Forgot to change to final.

if np.isscalar(angles[0]):
return mat.squeeze()
else:
# Move third axis to the beginning
Copy link
Member

Choose a reason for hiding this comment

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

This is obvious from the code. But why do we do it?

Copy link
Member Author

Choose a reason for hiding this comment

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

We want mat[0] to be the first matrix etc. But you're right, the comment should say that rather than state the obvious.

raise ValueError('zero vector')

result = np.zeros(vec.shape)
cond = np.any(vec[:, :2] != 0, axis=1)
if np.any(cond):
Copy link
Member

Choose a reason for hiding this comment

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

does this "any" make a difference in any way? I guess performance but I'd not over-optimize this right now.

Copy link
Member Author

Choose a reason for hiding this comment

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

Well this any goes along the first axis whose size is arbitrary (and can be large) so I think using np.any makes sense there.

Copy link
Member

Choose a reason for hiding this comment

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

My point was if the if statement is needed, or if we can simply go to the next line, where we get an empty assignment. I guess performance wise it shouldn't matter?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, no that actually doesn't work. If the condition is empty, the array cannot be used for indexing.
Hm, now it works. Must have been some issue during the implementation. Will remove the checks.

result[cond, 1] = vec[cond, 0]
if np.any(~cond):
result[~cond, 0] = 1
result / np.linalg.norm(result, axis=1, keepdims=True)
Copy link
Member

Choose a reason for hiding this comment

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

We're not afraid of zero division here, right?

Copy link
Member Author

Choose a reason for hiding this comment

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

This cannot happen. The rows in which there are nonzero entries in the first two coordinates we switch them, so the norm cannot be zero. In the other rows we insert a 1 so no way to get zero norm either (including rows that are all zeros).

except ImportError:
PYFFTW_AVAILABLE = False
else:
import pip
Copy link
Member

Choose a reason for hiding this comment

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

I don't like adding pip as a dependency to ODL, and further you mention this taking some performance.

I'd rather remove this, or as mentioned somewhere else explcitly check against something like

if parse_version(pyfftw.__version__) < parse_version('0.10.3.devSOMETHING'):
    warnings.warn('PyFFTW < 0.10.4 is known to cause problems with some '
                  'ODL functionality, see issue #1002.',
                  RuntimeWarning)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah this was just a fix for myself to reduce annoyance. I'll just remove the commit.

Unit vector(s) along which the detector is aligned.
If ``angles`` is a single pair (or triplet) of Euler angles,
the returned array has shape ``(2, 3)``, otherwise
is ``(2,) + broadcast(*angles).shape + (3,)``.
Copy link
Member Author

Choose a reason for hiding this comment

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

Not quite sure about this ordering. Opinion? Is it better to just add the extra dimensions to the left?

Copy link
Member

Choose a reason for hiding this comment

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

I'd say that they should go to the left, more consistent with other stuff

@kohr-h kohr-h force-pushed the vectorize_geometries branch 3 times, most recently from 58b021d to 116a545 Compare July 14, 2017 08:30
@kohr-h
Copy link
Member Author

kohr-h commented Jul 14, 2017

Rebased, ready for the final round.

@kohr-h kohr-h force-pushed the vectorize_geometries branch from 116a545 to c412be8 Compare July 14, 2017 08:55
Copy link
Member

@adler-j adler-j left a comment

Choose a reason for hiding this comment

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

Looking mighty fine!

This default (``space_ndim = ndim + 1``) can be overridden by
subclasses.
"""
return self.ndim + 1
Copy link
Member

Choose a reason for hiding this comment

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

I would prefer if __init__ took an argument space_ndim, that would make overriding this easier and consistent with e.g. ndim

raise NotImplementedError('normal not defined for ndim >= 3')
raise NotImplementedError(
'no default implementation of `surface_normal` available '
'for spaces with 4 or more dimensions')
Copy link
Member

Choose a reason for hiding this comment

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

Also 0d and 1d space ^.^

zeros_shape = [m if n == 1 and m != 1 else 1
for n, m in zip(axis_arr.shape, bcast_shape)]
return axis_arr + np.zeros(zeros_shape)
# TODO: use broadcast_to from Numpy when v1.10 is required
Copy link
Member

Choose a reason for hiding this comment

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

we have a utility for that

# to broadcast along all axes
center_part = np.multiply.outer(1 - np.cos(param), self.center_dir)
tangent_part = np.multiply.outer(np.sin(param), self.tangent_at_0)
surf = self.radius * (center_part + tangent_part)
if squeeze_out:
Copy link
Member

Choose a reason for hiding this comment

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

will this ever be needed given the current format?

- ``param.shape[:-1] + (space_ndim,)`` otherwise.
"""
if self.ndim == 1 and self.space_ndim == 2:
return -perpendicular_vector(self.surface_deriv(param))
Copy link
Member

Choose a reason for hiding this comment

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

We could extend perpendicular_vector to take several arguments as input and cover both of these cases

else:
# Produce array of shape `param.shape + (ndim,)` by broadcasting
axis_slc = (None,) * param.ndim + (slice(None),)
# TODO: use broadcast_to from Numpy when v1.10 is required
Copy link
Member

Choose a reason for hiding this comment

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

Don't we have a util for this somewhere?

Copy link
Member Author

Choose a reason for hiding this comment

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

No, it's currently buried somewhere internally. It only exists on the tensor branch.

Unit vector(s) along which the detector is aligned.
If ``angles`` is a single pair (or triplet) of Euler angles,
the returned array has shape ``(2, 3)``, otherwise
is ``(2,) + broadcast(*angles).shape + (3,)``.
Copy link
Member

Choose a reason for hiding this comment

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

I'd say that they should go to the left, more consistent with other stuff

@kohr-h kohr-h force-pushed the vectorize_geometries branch from c412be8 to 05777bf Compare September 5, 2017 11:10
@kohr-h kohr-h force-pushed the vectorize_geometries branch from 1fa0054 to 6292fa7 Compare September 26, 2017 14:51
@kohr-h
Copy link
Member Author

kohr-h commented Sep 26, 2017

Added the space_ndim thing to the abstract Detector class and reordered the det_axes and surface_deriv things.

I'll rebase and if CI is happy, I'll merge.

Holger Kohr added 11 commits September 26, 2017 18:47
The following changes are part of this commit:

- All detector methods are now vectorized.
- Detectors now take a `check_bounds` parameter to switch off
  bounds checking for cases where milliseconds count.
- The majority of methods has doctests now.
- `Geometry` has a new method `surface_normal` which also
  replaces `normal` in flat detectors.
- Implementation of `repr` for detectors is improved.
- `CircleSectionDetector` has gained some flexibility (can be
  defined with a custom circle radius now), plus doctests.
@kohr-h kohr-h force-pushed the vectorize_geometries branch from b72b65a to 4e42a1b Compare September 26, 2017 16:55
@adler-j
Copy link
Member

adler-j commented Sep 26, 2017

Great work!

@kohr-h kohr-h merged commit ee4ad6d into odlgroup:master Sep 26, 2017
@kohr-h kohr-h deleted the vectorize_geometries branch September 26, 2017 17:04
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