Skip to content

Refactor transform initialization#7754

Merged
jni merged 39 commits intoscikit-image:mainfrom
matthew-brett:transform-cleanups
Apr 4, 2025
Merged

Refactor transform initialization#7754
jni merged 39 commits intoscikit-image:mainfrom
matthew-brett:transform-cleanups

Conversation

@matthew-brett
Copy link
Contributor

@matthew-brett matthew-brett commented Mar 13, 2025

Standardize and refactor initialization of transform classes, formalize rules for specifying dimensionality.

API break: implicit (scale, shear, rotation, translation) parameters are now all keyword-only, to avoid confusion from differences in previous argument ordering and application order of the transforms.

Add and test identity class constructor.

Deprecate use of scalar scale parameter with dimensionality=3.

In due course, perhaps warn on use of dimensionality when calling class constructor with other arguments, to avoid confusion, and prefer identity class constructor.

Release note

In `skimage.transform`, deprecate the use of scalar `scale`, with `dimensionality=3` 
where this can be passed to a geometric transform contructor. This allows us to
generalize the use of the constructors to the case where the parameters must specify
the dimensionality, unless you mean to construct an identity transform.
{label="API"}
In `skimage.transform`, add the `identity` class constructor to all geometric
transforms. For example, you can now use 
`skimage.transform.PolynomialTransform(dimensionality=2)`.
{label="New feature"}
In `skimage.transform`, turn all input parameters to transform constructors keyword-only (other than `matrix`). This avoids confusion due to the positional parameter order being
different from the order by which they are applied in `AffineTransform`.
{label="API"}

Standardize and refactor initialization of transform classes by adding
new _MatrixTransform base class, and formalizing rules for
dimensionality.

Add and test `identity` class constructor.

Deprecate use of scalar ``scale`` parameter with dimensionality=3.

In due course, perhaps warn on use of `dimensionality` when calling
class constructor with other arguments, to avoid confusion, and prefer
``identity`` class constructor.
@matthew-brett matthew-brett added 🔧 type: Maintenance Refactoring and maintenance of internals 🔽 Deprecation Involves deprecation labels Mar 13, 2025
Maybe a result of platform linalg etc differences.
(To my eye) Simplify logic in creating homogenous transform matrix for
center and normalize points routine.  Also work round ruff's rather
extreme reformatting of an array literal.
Move application of homogenous matrix application to its own function.

Use instead of `_apply_mat` method.
This also shows that we are not testing this properly.
Check some error messages.  Cover case of not-identity rotation.
Test AffineTransform constructor against 2D algorithm in docstring.
Confirm that order of application of transforms is as specified in the
docstring.  It is.  But - oh dear - that's a different order than the
parameter position.  We'll need to address that at some point.
Test rigid and scale + rigid transforms.

Document transformation order.
As discussed on Signal.  Also, reorder parameters for AffineTransform,
that did not match the order of application.
Test that transforms with implicit parameters must be passed as keyword
arguments.
@matthew-brett
Copy link
Contributor Author

As discussed on Signal, I've made all implicit parameters keyword only, because there was a mismatch between parameter order and application order in AffineImage.

Specifically, the order in which transformations are applied differs from the parameter order in AffineTransform. As noted in the docstring, transformations applied are in order scale then shear then rotation then translation, as confirmed by new test, but the parameter order is scale, rotation, shear, translation. That is very confusing, and should be fixed. The PR here goes straight to keyword-only for all classes with implicit parameters, for consistency. A search on Github for e.g. /(?-i)Affine(?-i)Transform\(None/ AND (path:*.py OR path:*.ipynb) found only one hit across all the involved classes. I've made a pull-request to that repo : guillermo-jimenez/sak#3

@matthew-brett matthew-brett marked this pull request as draft March 15, 2025 12:08
@matthew-brett matthew-brett added the 📜 type: API Involves API change(s) label Mar 15, 2025
@matthew-brett
Copy link
Contributor Author

I checked the tests for this PR against the main branch. The differences are:

For SimilarityTransform(scale=4, translation=(0, 0, 0)) main gives ValueError: could not broadcast input array from shape (3,) into shape (2,) (passes on PR) - PR correct.

PolynomialTransform(0) gives *** IndexError: tuple index out of range, rather than ValueError for PR - PR correct.

AffineTransform(rotation=(0.1, 0.2)) gives TypeError: must be real number, not tuple rather than ValueError - PR correct.

AffineTransform(translation=1) gives TypeError: 'int' object is not subscriptable rather than ValueError - PR correct.

AffineTransform(translation=(1, 2, 3)) gives:

  <AffineTransform(matrix=
  [[ 1., -0.,  1.],
   [ 0.,  1.,  2.],
   [ 0.,  0.,  1.]])

rather than raising a ValueError for non-2D inputs - PR correct.

EuclideanTransform(translation=(5, 6, 7, 8)) gives ValueError: Parameters cannot be specified for dimension 4 transforms whereas PR allows this, as implied by docstring - PR correct.

AffineTransform, ProjectiveTransform, EuclideanTransform, SimilarityTransform do not raise error for tform_class(np.eye(2)) - this is a 1D transform, the PR raises a NotImplementedError, I think the PR is correct.

@matthew-brett matthew-brett marked this pull request as ready for review March 15, 2025 18:41
@matthew-brett matthew-brett removed the 🔧 type: Maintenance Refactoring and maintenance of internals label Mar 15, 2025
Copy link
Member

@jni jni left a comment

Choose a reason for hiding this comment

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

Hi @matthew-brett! I've left some minor comments here and there. I'm not sure about some of the decisions — it might be worth moving discussion from Signal to our public Zulip for visibility — but happy to be convinced!

translation = np.asarray(translation)
if rotation.shape != (3, 3):
raise ValueError("Invalid shape of rotation matrix")
if abs(np.linalg.det(rotation) - 1) > 1e-6:
Copy link
Member

Choose a reason for hiding this comment

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

Can we make 1e-6 a parameter "abs_tolerance" of the method with a default value? Or it could be a private attribute of the class. Either way, I don't like seeing them scattered across various if-statements.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can we make 1e-6 a parameter "abs_tolerance" of the method with a default value? Or it could be a private attribute of the class. Either way, I don't like seeing them scattered across various if-statements.

Done - that was in the code before refactor.

points_h = np.vstack([points.T, np.ones(n)])

new_points_h = (matrix @ points_h).T
def _apply_homogenous(matrix, points):
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yup - fixed.

Parameters
----------
matrix : (D+1, D+1) array_like
The transformation matrix to obtain the new points.
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 add a note here that any object with an __array__ method will work, as long as the array shape matches.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is there a policy for how to say that in a docstring? I am used to array-like for that - as in anything that can become an array with np.array(param) - but perhaps that is not your tradition.

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, good point! 😅 I am used to thinking as array-likes being:

  • numpy arrays
  • lists and tuples (including nested lists)
  • other arrays, such as dask arrays

I guess what I was trying to say is that I wouldn't think of skimage.transform.AffineTransform as an "array-like" without further prodding. So I think "array-like" in the type section is fine, but I would add a sentence to the description like:

Note that any object with an `__array__` method [1]_ that returns a matrix
with the correct dimensions can be used as input here. This includes all
subclasses of `skimage.transform.ProjectiveTransform`, for example.

[...]

References
----------
.. [1]: https://numpy.org/doc/stable/user/basics.interoperability.html#using-arbitrary-objects-in-numpy

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah yes - I see what you mean - I've added your change. Do you think this should go everywhere we accept an array-like for a transformation matrix? Such as the class constructors?


def __nice__(self):
"""common 'paramstr' used by __str__ and __repr__"""
if not hasattr(self, 'params'):
Copy link
Member

Choose a reason for hiding this comment

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

Personally, I prefer classes to have all their expected attributes even if they can be null, rather than not have the attribute. ie, I would make sure that self.params is at least set to None. (I'd consider using dataclasses in this refactor.)

Since params is a public attribute, I'd consider this a breaking change...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The only purpose of this line is to avoid confusing warnings when object construction fails - and the only situation in which the object would lack self.params is if object construction fails. So I don't think this code could break anything out there; it would only improve the repr displayed when the object generates an error during construction.

Copy link
Member

Choose a reason for hiding this comment

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

avoid confusing warnings when object construction fails

under what circumstances can construction fail but still return an instance?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

under what circumstances can construction fail but still return an instance?

This was happening in the tests, when the constructor failed, and the error message displayed a repr of the half-initialized object. In that case you'd get two errors, one displaying the repr - not relevant to the problem - and the other for the actual problem in initialization. Because I found that distracting, I made this change to prevent the first error. But I wouldn't die on a hill to keep it - it shouldn't get triggered in ordinary practice.

Copy link
Member

Choose a reason for hiding this comment

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

@matthew-brett what do you think about setting params=None first thing in the constructor so we don't have to hasattr here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I would rather avoid putting params=None at the top of all the __init__s, because people are more likely to read that code, and be confused by it - when it is only solving a problem they would not normally see. So my preference order would be:

  1. The current fix in the __repr__ / __nice__ function.
  2. No fix at all, just let the double error happen in the rare case where initialisation fails and we generate a __repr__ of the result.
  3. params=None in all __init__s.

Comment on lines 249 to 251
# With scalar scale and 3D, we get a deprecationwarning. This is to
# generalize the rule that dimensionality should be implied by
# input parameters, when given.
Copy link
Member

Choose a reason for hiding this comment

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

Has there been much discussion about this rule? I'm not sure how I feel about it, and could be convinced that it's good, but AffineTransform(scale=2, dimensionality=3) seems like an ok thing to me a priori?

Copy link
Member

Choose a reason for hiding this comment

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

I personally slightly prefer a more explicit API such as

AffineTransform(scale=(2, 2, 2))

that can do away with an additional parameter dimensionality=. That simplifies input validation and documentation.

Copy link
Member

Choose a reason for hiding this comment

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

I think I added it when making transforms nD-compatible because it was possible before to do AffineTransform().estimate(...), so we want to still be able to do that for higher dimensions, as in AffineTransform(dimensionality=3).estimate(...). If we get rid of dimensionality, this would become (presumably) AffineTransform.identity(ndim=3).estimate(...), which is clunky.

We could instead make estimate a staticmethod, since it doesn't use starting parameters anyway. But that's a breaking change. Perhaps something to make an issue for skimage2?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I presume that AffineTransform().estimate(...) was always a 2D identity transform? If so, there is no change if we remove dimensionality from the constructor.

One could argue about which of AffineTransform(dimensionality=3).estimate(...) or AffineTransform.identity(3).estimate(...) is more clunky - I personally find the second easier to read, because I have to translate the first into "Make an identity transform of dimension 3" in my head. But the problem with the dimensionality= constructor call is that, in order to have this convenience in using the constructor for the identity, we have a constructor where one argument is nearly always redundant or incorrect, if any other parameters are specified.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We could instead make estimate a staticmethod, since it doesn't use starting parameters anyway. But that's a breaking change. Perhaps something to make an issue for skimage2?

Are you suggesting that we have the constructor with no input arguments generate a self.params=None not-initialized object, and then, if estimate finds such an object, it works out what identity transform to initialize with?

We could make a constructor such that it would be possible to tform = AffineTransform.from_estimate(src, dst).

Of course that would just boil down to a slight polishing of:

@classmethod
def from_estimate(cls, src, dst):
    obj = cls.identity(src.shape[1])
    if not obj.estimate(src, dst):
        raise SomeError('Estimation failed')
    return obj

That doesn't seem unreasonable. And it would make it less common to need to construct the identity transform explicitly.

Copy link
Member

Choose a reason for hiding this comment

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

Well, for 2D Transform().estimate is already working and the way we teach it, so why not just make that work with other dimensions as well?

It's a bit unreasonable to expect a transform to function before either initializing it with parameters, or estimating those parameters.

I think getting rid of dimensions is fine, as is introducing identity.

But, ok, I'll let @jni take another look. I'm pretty sleep deprived atm.

Copy link
Member

Choose a reason for hiding this comment

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

Chiming in late to the conversation, but I'd also prefer making use of classmethods. Both in a vacuum and with the current API Transform.from_estimate seems like a good idea for already stated reasons. Transform.estimate() feels unnecessarily confusing, and I'd be fine with deprecating Transform().estimate(). It's always been slightly bugging me that the API allows for an "empty" transform and a readonly instance (think frozen dataclass) seems like a very good fit to me.

Copy link
Member

Choose a reason for hiding this comment

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

I am still puzzled by the staticmethod idea. Given that the callable produces a class, and needs to know its own class, surely it should be classmethod.

Sorry, you are right. Technically it needs to know its own class but, looking at the code, literally every class has its own estimate method, so it could be hardcoded, which is where my brain was going. But I think that is not as nice as using cls(params), so indeed classmethod is the way to go.

It's too fancy and magical (see advice above). It will make even experienced developers do a double take, and try and work out what is going on.

This would only be a temporary state, because instance.estimate() would be deprecated. So I don't think it's a big deal.

I don't think estimate is the right name for a classmethod (see below) that returns a new transform instance. I think it needs to be from_something. I personally find from_correspondence ugly, and still prefer from_estimation - it doesn't worry me that you pass points rather than an estimation, that will be obvious from inspecting the signature, and not surprising.

I acknowledge that from_something is the most common pattern for classmethod naming, but it's certainly not a hard and fast rule, and for me tf = AffineTransform.estimate(src, dst) is the most natural incantation by a long shot. I think @stefanv also prefers it but I'll let him speak for himself when he's awake. 😂 And again, I am in no way advocating to keep both the instance and the class methods around, I'm thinking about what to do in the future. Having the instance method turns out to make no sense so imho keeping it is out of the question.

I find AffineTransform.from_estimation awkward, and would potentially even prefer registration.from_points(src, dst, tf_class=AffineTransform).

Incidentally I don't see why identity(n) gets to avoid the from prefix. 😉

I'm on board with identity and I'm on board with dropping dimensionality.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jni - can you live with estimated as the class constructor name? If so, I think we're very close ...

Copy link
Member

Choose a reason for hiding this comment

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

I can live with from_estimate as the class constructor name.

Comment on lines +255 to +256
# Not so if we specify some other input giving dimensionality.
tf = SimilarityTransform(scale=4, translation=(0, 0, 0))
Copy link
Member

Choose a reason for hiding this comment

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

And this feels super weird... imho either we allow scalars, or we don't, I don't see why the dimensionality needs to be "implied"?

Copy link
Member

Choose a reason for hiding this comment

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

I concur, either we deprecate implicit broadcasting of scalars to the appropriate shape everywhere (my preference), or we keep dimensionality= around.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See below.

Copy link
Contributor Author

@matthew-brett matthew-brett Mar 17, 2025

Choose a reason for hiding this comment

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

My reasoning for this change was that I found it hard to grok the meaning of e.g dimensionality=3 in a constructor call like SimilarityTransform(np.eye(4), dimensionality=3), or SimilarityTransform(rotation=(0.1, 0.2), dimensionality=3). That is - it seemed to be, nearly always, either redundant, or to require an error on construction, when the dimensionality does not match the inputs.

On further reflection, it became clear that the dimensionality=3 parameter was almost invariably used to construct an identity transform - as in SimilarityTransform(dimensionality=3). So this leads us to the odd situation where, for a huge majority of calls (actually, I think all but one in the test suite) dimensionality, when present, is redundant, a source of error, or intended to construct an identity transform.

Put another way, in the vast majority of cases, when passing any non-default argument to the constructor, dimensionality is implied by the arguments, and dimensionality is redundant, or should cause an error because it does not match.

I thought this was confusing and unsatisfying as an API, so I planned to, in due course, remove the dimensionality parameter entirely from the constructor API, and ask users to write SimilarityTransform.identity(3) instead, for the vast majority of cases where dimensionality should be specified.

This left only one valid use-case for non-identity transforms, non-default dimensionality, and that was the scalar scale case you've referred to. As I say - I think that was used only once in the whole test suite. Of course, it will only be useful specifically in the 3 or greater dimension case - that is - the only place where dimensionality makes sense, for not-default input arguments is specifically e.g. SimilarityTransform(scale=4, dimensionality=3).

So it seemed better to me, from the point of view of the API, to plan, in due course, to remove dimensionality from the constructor, because it is nearly always redundant, and for the only case where it was not redundant, ask users to specify the dimensionality by SimilarityTransform(scale=(4, 4, 4)) instead. This, along with the .identity constructor, allows us to remove a huge number of redundant uses of dimensionality, with very small loss of convenience, for this scalar scale case.

Copy link
Contributor Author

@matthew-brett matthew-brett Mar 17, 2025

Choose a reason for hiding this comment

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

Or to put it yet another way - I think the current class constructor API is a fusion of two different constructors, which the current API fuses into one constructor:

# Transform from parameters.
SimilarityTransform(rotations=(0.1, 0.2, 0.3))
# Identity transform.
SimilarityTransform(dimensionality=3)

leading to confusing calls like:

# In user code, but also occurs in the scikit-image codebase.
SimilarityTransform(rotations=(0.1, 0.2, 0.3), dimensionality=3)

I think this should be, in due course:

# Transform from parameters.
SimilarityTransform(rotations=(0.1, 0.2, 0.3))
# This will raise an error, because `dimensionality` will not be an argument to constructor.
SimilarityTransform(rotations=(0.1, 0.2, 0.3), dimensionality=3)
# Identity transform.
SimilarityTransform.identity(3)

Therefore, it separates the two APIs.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks for the rationale @matthew-brett. See this comment re APIs — I think AffineTransform(dimensionality=3).estimate(src, dst) reads better than AffineTransform.identity(3).estimate(src, dst). However, even better is AffineTransform.estimate(src, dst), with the dimensionality implicit in the shapes of src and dst. Therefore, I think forge ahead with your proposal, but with an eye towards making estimation a static method. Thoughts?

Responding to Juan's comments.
Responding to JNI's comments.
@jni jni added this to the 0.26 milestone Apr 1, 2025
@mkcor
Copy link
Member

mkcor commented Apr 1, 2025

@matthew-brett on a side note, are you aware that you can apply multiple suggestions as a batch?

Comment on lines 1351 to 1352
Fallback number of dimensions for transform when no other paremeters
are specified. Otherwise ignored, and we infer dimensionality from the
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Fallback number of dimensions for transform when no other paremeters
are specified. Otherwise ignored, and we infer dimensionality from the
Fallback number of dimensions for transform when no other parameter
is specified. Otherwise ignored, and we infer dimensionality from the

typo + singular (thinking, there's not even a single other parameter)

@matthew-brett
Copy link
Contributor Author

Have I got to everything here?

@matthew-brett
Copy link
Contributor Author

matthew-brett commented Apr 1, 2025 via email

@matthew-brett
Copy link
Contributor Author

matthew-brett commented Apr 1, 2025 via email

Copy link
Member

@mkcor mkcor left a comment

Choose a reason for hiding this comment

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

I have left only minor stylistic suggestions/questions. Thank you!

),
)
def test_affine_params(pts, params):
# Test AffineTransform against docstring algorithm.
Copy link
Member

Choose a reason for hiding this comment

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

I really like this idea! ❤️

Copy link
Contributor Author

@matthew-brett matthew-brett left a comment

Choose a reason for hiding this comment

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

Thanks - applied - I believe.

@jni
Copy link
Member

jni commented Apr 4, 2025

Since @stefanv is currently afk, I'm going to merge this one, and @matthew-brett could you please open an issue to check on the deprecation before releasing 0.26?

@jni jni merged commit 866c879 into scikit-image:main Apr 4, 2025
23 checks passed
matthew-brett added a commit to matthew-brett/scikit-image that referenced this pull request Apr 4, 2025
* Refactor transform initialization

Standardize and refactor initialization of transform classes by adding
new _MatrixTransform base class, and formalizing rules for
dimensionality.

Add and test `identity` class constructor.

Deprecate use of scalar ``scale`` parameter with dimensionality=3.

In due course, perhaps warn on use of `dimensionality` when calling
class constructor with other arguments, to avoid confusion, and prefer
``identity`` class constructor.

* Allow for negative zero in doctest output

Maybe a result of platform linalg etc differences.

* Refector center_and_normalize points matrix

(To my eye) Simplify logic in creating homogenous transform matrix for
center and normalize points routine.  Also work round ruff's rather
extreme reformatting of an array literal.

* Refactor homogenous matrix application to function

Move application of homogenous matrix application to its own function.

Use instead of `_apply_mat` method.

* Rotation was missing from EssentialMatrixTransform

This also shows that we are not testing this properly.

* Add test of rotations and EssentialMatrixTransform

Check some error messages.  Cover case of not-identity rotation.

* Add test against AffineTransform docstring

Test AffineTransform constructor against 2D algorithm in docstring.

* Add test of AffineTransform application order

Confirm that order of application of transforms is as specified in the
docstring.  It is.  But - oh dear - that's a different order than the
parameter position.  We'll need to address that at some point.

* Extend test of parameter application order

Test rigid and scale + rigid transforms.

Document transformation order.

* Make implicit parameters keyword only

As discussed on Signal.  Also, reorder parameters for AffineTransform,
that did not match the order of application.

* Docstrings for identity transform.

* Add test for kw-only parameters

Test that transforms with implicit parameters must be passed as keyword
arguments.

* Rewrite identity docstrings

* Refactor assembly of rotation matrix

* Refactor init checking

Simplify code somewhat, and extend tests.

* Extend tests, adapt inits to docstring

* Move calculation after check for efficiency

* Drop unnecessary check for PolynomialTransform

* Move tolerances to private class attribute

Responding to Juan's comments.

* Fix spelling of homogeneous

Responding to JNI's comments.

* Convert DeprecatonWarning to FutureWarning

From JNI review.

* Expand documentation of array_like for function

From review by JNI.

* Add and use _as_h helper routine

``_as_h`` function adds ones to right of (n, d) points array.

* Update skimage/transform/_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Update skimage/transform/_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Formalize tests on broadcasting.

* Raise for identity on base class

NotImplementedError - inheritors must define the identity.

* Update skimage/transform/_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Update skimage/transform/_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Rename homogeneous coordinate helper function

From review by @lagru and @Joi.

* Single backticks for parameter in error message

From @mkcor review.

* Rework `dimensionality` docstring.

From @mkcor review.

* Convert base class `identity` to abstract method

Move from NotImplemented to abstract class method.

* Update skimage/transform/_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Fix docstring typo in skimage/transform/_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Update skimage/transform/tests/test_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Update skimage/transform/tests/test_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Update skimage/transform/tests/test_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Update skimage/transform/tests/test_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

---------

Co-authored-by: Marianne Corvellec <[email protected]>
Co-authored-by: Juan Nunez-Iglesias <[email protected]>
matthew-brett added a commit to matthew-brett/scikit-image that referenced this pull request Apr 4, 2025
* Refactor transform initialization

Standardize and refactor initialization of transform classes by adding
new _MatrixTransform base class, and formalizing rules for
dimensionality.

Add and test `identity` class constructor.

Deprecate use of scalar ``scale`` parameter with dimensionality=3.

In due course, perhaps warn on use of `dimensionality` when calling
class constructor with other arguments, to avoid confusion, and prefer
``identity`` class constructor.

* Allow for negative zero in doctest output

Maybe a result of platform linalg etc differences.

* Refector center_and_normalize points matrix

(To my eye) Simplify logic in creating homogenous transform matrix for
center and normalize points routine.  Also work round ruff's rather
extreme reformatting of an array literal.

* Refactor homogenous matrix application to function

Move application of homogenous matrix application to its own function.

Use instead of `_apply_mat` method.

* Rotation was missing from EssentialMatrixTransform

This also shows that we are not testing this properly.

* Add test of rotations and EssentialMatrixTransform

Check some error messages.  Cover case of not-identity rotation.

* Add test against AffineTransform docstring

Test AffineTransform constructor against 2D algorithm in docstring.

* Add test of AffineTransform application order

Confirm that order of application of transforms is as specified in the
docstring.  It is.  But - oh dear - that's a different order than the
parameter position.  We'll need to address that at some point.

* Extend test of parameter application order

Test rigid and scale + rigid transforms.

Document transformation order.

* Make implicit parameters keyword only

As discussed on Signal.  Also, reorder parameters for AffineTransform,
that did not match the order of application.

* Docstrings for identity transform.

* Add test for kw-only parameters

Test that transforms with implicit parameters must be passed as keyword
arguments.

* Rewrite identity docstrings

* Refactor assembly of rotation matrix

* Refactor init checking

Simplify code somewhat, and extend tests.

* Extend tests, adapt inits to docstring

* Move calculation after check for efficiency

* Drop unnecessary check for PolynomialTransform

* Move tolerances to private class attribute

Responding to Juan's comments.

* Fix spelling of homogeneous

Responding to JNI's comments.

* Convert DeprecatonWarning to FutureWarning

From JNI review.

* Expand documentation of array_like for function

From review by JNI.

* Add and use _as_h helper routine

``_as_h`` function adds ones to right of (n, d) points array.

* Update skimage/transform/_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Update skimage/transform/_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Formalize tests on broadcasting.

* Raise for identity on base class

NotImplementedError - inheritors must define the identity.

* Update skimage/transform/_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Update skimage/transform/_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Rename homogeneous coordinate helper function

From review by @lagru and @Joi.

* Single backticks for parameter in error message

From @mkcor review.

* Rework `dimensionality` docstring.

From @mkcor review.

* Convert base class `identity` to abstract method

Move from NotImplemented to abstract class method.

* Update skimage/transform/_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Fix docstring typo in skimage/transform/_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Update skimage/transform/tests/test_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Update skimage/transform/tests/test_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Update skimage/transform/tests/test_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

* Update skimage/transform/tests/test_geometric.py

Co-authored-by: Marianne Corvellec <[email protected]>

---------

Co-authored-by: Marianne Corvellec <[email protected]>
Co-authored-by: Juan Nunez-Iglesias <[email protected]>
@lagru lagru added the 🥾 Path to skimage2 A step towards the new "API 2.0" label Apr 22, 2025
lagru added a commit that referenced this pull request Jul 7, 2025
## Description

This is the promised follow-on PR from the discussion more or less
starting at
#7754 (comment).

To recap briefly:

There is a wart in the current API, in that the `estimate` method of
Transforms ignores any stored state of the initialized Transform, and
re-estimates. So, for example:

```python
tf = SimilarityTransform(rotation=(0.2, 0.3))
tf.estimate(src, dst)
```

wil discard the initial state with the specified rotations. Another
problem is that one generally has to first initialize the transform,
most sensibly as an identity transform, and then estimate, thus:

```python
tf = SimilarityTransform.identity(3)
tf.estimate(src, dst)
```

However, the argument to the identity transform is redundant, because we
can infer the dimensionality of the points (3) from `src` and `dst`
point arrays. So, this PR deprecates the `estimate` method, in favor of
a class constructor, like this:

```python
tf = SimilarityTransform.from_estimate(src, pts)
```

### For discussion

* We floated various names for the class constructor. `from_estimate`
gained favor from me (MB) (I proposed it) and @stefanv. @jni leaned
towards some fancy footwork to allow `estimate` to be *both* the
original estimation method, *and* the class constructor, using the
[trick
here](https://stackoverflow.com/questions/20533349/python-regular-method-and-static-method-with-same-name/28766809#28766809).
I [thought that was going to be
confusing](#7754 (comment)).
We can rehearse that argument more.
* In writing the tests, I (MB) discovered an error in the check for a
degenerate tranform, [discussed
here](#7770). I used
an obvious but quite possibly wrong workaround (mentioned in the linked
issue). I would be happy to be corrected.
* One wrinkle is that the `from_estimate` API returns `None` for failed
estimation. This is generally just as informative as the `estimate`
method, except in the case of `PiecewiseAffineTransform`, where the
estimation involves multiple steps, and resulting affines. The
`estimate` method collects an affine for each step, recording an all-NaN
affine for steps that fail estimation, so you can track, from the
resulting `tform.affines` attribute, where the estimation failed. The
returned `None` does not give you that granular information. Is there a
use for that information? How should it reach the user?
* Should we also adapt the API-a-like classes `LineModelND`,
`CircleModel`, `EllipseModel` at
https://github.com/scikit-image/scikit-image/blob/main/skimage/measure/fit.py#L27
to have a `from_estimate` class method (instead of an `esimate` method)?
* Similarly, should we also adapt the `ThinPlateSpline` class
(https://github.com/scikit-image/scikit-image/blob/main/skimage/transform/_thin_plate_splines.py#L7)?
(I think so).

## Release note

For maintainers and optionally contributors, please refer to the
[instructions](https://scikit-image.org/docs/stable/development/contribute.html#documenting-changes)
on how to document this PR for the release notes.

```release-note
In `skimage.measure`, add a new class method and constructor `from_estimate` for 
`LineModelND`, `CircleModel`, and `EllipseModel`. This replaces the old more un-intuitive
API that used the now deprecated `estimate` method, which required initalizing a model
with undefined parameters before calling `estimate`. 
```

```release-note
In `skimage.transform`, add a new class method and constructor `from_estimate` for
`AffineTransform`, `EssentialMatrixTransform`, `EuclideanTransform`,
FundamentalMatrixTransform`, `PiecewiseAffineTransform`, `PolynomialTransform`, 
`ProjectiveTransform`, `SimilarityTransform`, and `ThinPlateSplineTransform`. This
replaces the old more un-intuitive API that used the now deprecated `estimate` method,
which required initalizing an undefined transform before calling `estimate`.
```

---------

Co-authored-by: Lars Grüter <[email protected]>
Co-authored-by: Stefan van der Walt <[email protected]>
@lagru
Copy link
Member

lagru commented Nov 29, 2025

I tried to make the release notes a bit more end user friendly in the context of our release notes. Will be updated in #7967.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🔽 Deprecation Involves deprecation 🥾 Path to skimage2 A step towards the new "API 2.0" 📜 type: API Involves API change(s)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants