Skip to content

Avoid using np.linalg.inv() for transform inversion#1418

Closed
goodboy wants to merge 2 commits intopyqtgraph:masterfrom
pikers:avoid_np.linalg.inv
Closed

Avoid using np.linalg.inv() for transform inversion#1418
goodboy wants to merge 2 commits intopyqtgraph:masterfrom
pikers:avoid_np.linalg.inv

Conversation

@goodboy
Copy link
Copy Markdown

@goodboy goodboy commented Oct 24, 2020

This fixes not only latency but high CPU usage when inverting a Qt transform matrix.

There is a known recent issue in numpy pertaining to this:
numpy/numpy/issues/17166

I've ripped off the "hard coded" solution from that issue verbatim and using it has reduced both latency and CPU usage in pretty much all mouse related interaction with ViewBox.
I'm happy to write up profiling and test code for this in case there are any doubts.

There are even faster solutions inside the referenced issue that might be worth exploring as well; I know we have to keep py2 in mind but it might be worth some flag logic in this case since invertQTransform() seems to be used throughout ViewBox and GraphicsItem. This would explain why I'm seeing improvements outside my original use case (calling GraphicsObject.mapFromView() on label coordinates fed from the mouse).

I also would like to benchmark using *np.ravel() to pass the matrix results to QtGui.QTransform() since I've done that with good results in QPicture() drawings and it should in theory be faster then the existing multiple element access.

Update

Commit that changed away from using the internal transform is a41d330
Relevant Qt bug reports are:

@goodboy
Copy link
Copy Markdown
Author

goodboy commented Oct 24, 2020

Seeing stuff like this from draw threads:

/home/goodboy/repos/pyqtgraph/pyqtgraph/functions.py:2329: RuntimeWarning: invalid value encountered in double_scalars
  invA[0, 0] = (-A[1, 2] * A[2, 1] +
/home/goodboy/repos/pyqtgraph/pyqtgraph/functions.py:2331: RuntimeWarning: invalid value encountered in double_scalars
  invA[1, 0] = (A[1, 2] * A[2, 0] -
/home/goodboy/repos/pyqtgraph/pyqtgraph/functions.py:2333: RuntimeWarning: invalid value encountered in double_scalars
  invA[2, 0] = (-A[1, 1] * A[2, 0] +
/home/goodboy/repos/pyqtgraph/pyqtgraph/functions.py:2335: RuntimeWarning: invalid value encountered in double_scalars
  invA[0, 1] = (A[0, 2] * A[2, 1] -
/home/goodboy/repos/pyqtgraph/pyqtgraph/functions.py:2337: RuntimeWarning: divide by zero encountered in double_scalars
  invA[1, 1] = (-A[0, 2] * A[2, 0] +
/home/goodboy/repos/pyqtgraph/pyqtgraph/functions.py:2339: RuntimeWarning: invalid value encountered in double_scalars
  invA[2, 1] = (A[0, 1] * A[2, 0] -
/home/goodboy/repos/pyqtgraph/pyqtgraph/functions.py:2341: RuntimeWarning: invalid value encountered in double_scalars
  invA[0, 2] = (-A[0, 2] * A[1, 1] +
/home/goodboy/repos/pyqtgraph/pyqtgraph/functions.py:2343: RuntimeWarning: invalid value encountered in double_scalars
  invA[1, 2] = (A[0, 2] * A[1, 0] -
/home/goodboy/repos/pyqtgraph/pyqtgraph/functions.py:2345: RuntimeWarning: invalid value encountered in double_scalars
  invA[2, 2] = (-A[0, 1] * A[1, 0] +

So obviously this impl is a bit naive 😸

I can confirm this is drastically improving performance of my app including latency and CPU usage especially with loading larger data sets.

@@ -2320,7 +2358,8 @@ def invertQTransform(tr):
try:
import numpy.linalg
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Import can be omitted in this implementation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actually, if the import is omitted, the entire try/except can bee omitted...

Copy link
Copy Markdown
Author

@goodboy goodboy Oct 24, 2020

Choose a reason for hiding this comment

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

Sweet! Will do.

I just put this code up quick it's by no means refined yet.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@ksunden i'm seeing the tr.inverted() use in the except: and reading the docs for it I'm wondering do we even need our own inversion at all?

Gonna put up benchmarks against both.

@goodboy
Copy link
Copy Markdown
Author

goodboy commented Oct 24, 2020

I need to dig into the numpy issue a little deeper but it may be that we might want to add singularity detection (aka zero determinant) to this implementation. I'm only seeing the error I posted above once at startup in my app.

Also, there may be a faster version with numba if that's something we want to consider adding as a dep now (or eventually).

It's interesting to me that this current implementation is afaict geared toward processing a tensor of 3x3 matrices, batchwise using vector opts, which we're not doing here yet, there's still this drastic improvement (at least for me)?

return detA


# stolen verbatim from:
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

woops left this in.

# stolen verbatim from:
def hdinv(A):
invA = np.zeros_like(A)
detA = vdet(A)
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

can we just do a zero check here and then fail gracefully?

arr = np.array([[tr.m11(), tr.m12(), tr.m13()], [tr.m21(), tr.m22(), tr.m23()], [tr.m31(), tr.m32(), tr.m33()]])
inv = numpy.linalg.inv(arr)
inv = hdinv(arr)
# TODO: use `*np.ravel()` below instead?
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is faster btw.

Putting up some profile reports shortly.

@goodboy
Copy link
Copy Markdown
Author

goodboy commented Oct 24, 2020

Here's a randomly selected sample of profiling messages generated from the commit to come just after writing this.

(Click to see) Log messages from a bunch of scrolling/panning interaction
> Entering functions.invertQTransform
  pre transform calcs: 0.0179 ms
  hdinv result: 0.0620 ms
  ravel speed: 0.0291 ms
  indexing speed: 0.0222 ms
  .inverted transform result: <PyQt5.QtGui.QTransform object at 0x7fad5436e350>: 0.0198 ms
< Exiting functions.invertQTransform, total time: 0.1643 ms
> Entering functions.invertQTransform
  pre transform calcs: 0.0155 ms
  hdinv result: 0.0529 ms
  ravel speed: 0.0231 ms
  indexing speed: 0.0165 ms
  .inverted transform result: <PyQt5.QtGui.QTransform object at 0x7fad5436e350>: 0.0162 ms
< Exiting functions.invertQTransform, total time: 0.1400 ms
> Entering functions.invertQTransform
  pre transform calcs: 0.0143 ms
  hdinv result: 0.0513 ms
  ravel speed: 0.0229 ms
  indexing speed: 0.0148 ms
  .inverted transform result: <PyQt5.QtGui.QTransform object at 0x7fad5436ea50>: 0.0143 ms
< Exiting functions.invertQTransform, total time: 0.1278 ms
> Entering functions.invertQTransform
  pre transform calcs: 0.0169 ms
  hdinv result: 0.0565 ms
  ravel speed: 0.0250 ms
  indexing speed: 0.0157 ms
  .inverted transform result: <PyQt5.QtGui.QTransform object at 0x7fad5436eeb0>: 0.0167 ms
< Exiting functions.invertQTransform, total time: 0.1423 ms
> Entering functions.invertQTransform
  pre transform calcs: 0.0119 ms
  hdinv result: 0.0482 ms
  ravel speed: 0.0219 ms
  indexing speed: 0.0148 ms
  .inverted transform result: <PyQt5.QtGui.QTransform object at 0x7fad5436ea50>: 0.0145 ms
< Exiting functions.invertQTransform, total time: 0.1218 ms
> Entering functions.invertQTransform
  pre transform calcs: 0.0360 ms
  hdinv result: 0.0944 ms
  ravel speed: 0.0486 ms
  indexing speed: 0.0200 ms
  .inverted transform result: <PyQt5.QtGui.QTransform object at 0x7fad54346660>: 0.0191 ms
< Exiting functions.invertQTransform, total time: 0.2313 ms
> Entering functions.invertQTransform
  pre transform calcs: 0.0229 ms
  hdinv result: 0.0658 ms
  ravel speed: 0.0300 ms
  indexing speed: 0.0176 ms
  .inverted transform result: <PyQt5.QtGui.QTransform object at 0x7fad54346c10>: 0.0176 ms
< Exiting functions.invertQTransform, total time: 0.1712 ms
> Entering functions.invertQTransform
  pre transform calcs: 0.0114 ms
  hdinv result: 0.0510 ms
  ravel speed: 0.0248 ms
  indexing speed: 0.0145 ms
  .inverted transform result: <PyQt5.QtGui.QTransform object at 0x7fad54346b30>: 0.0160 ms
< Exiting functions.invertQTransform, total time: 0.1283 ms
> Entering functions.invertQTransform
  pre transform calcs: 0.0353 ms
  hdinv result: 0.1254 ms
  ravel speed: 0.0658 ms
  indexing speed: 0.0184 ms
  .inverted transform result: <PyQt5.QtGui.QTransform object at 0x7fad54367120>: 0.0196 ms
< Exiting functions.invertQTransform, total time: 0.2787 ms
> Entering functions.invertQTransform
  pre transform calcs: 0.0360 ms
  hdinv result: 0.1049 ms
  ravel speed: 0.0496 ms
  indexing speed: 0.0188 ms
  .inverted transform result: <PyQt5.QtGui.QTransform object at 0x7fad6633a3c0>: 0.0174 ms
< Exiting functions.invertQTransform, total time: 0.2384 ms
> Entering functions.invertQTransform
  pre transform calcs: 0.0308 ms
  hdinv result: 0.0856 ms
  ravel speed: 0.0391 ms
  indexing speed: 0.0174 ms
  .inverted transform result: <PyQt5.QtGui.QTransform object at 0x7fad54367a50>: 0.0219 ms
< Exiting functions.invertQTransform, total time: 0.2120 ms
> Entering functions.invertQTransform
  pre transform calcs: 0.0348 ms
  hdinv result: 0.0982 ms
  ravel speed: 0.0458 ms
  indexing speed: 0.0315 ms
  .inverted transform result: <PyQt5.QtGui.QTransform object at 0x7fad543c26d0>: 0.0355 ms
< Exiting functions.invertQTransform, total time: 0.2675 ms
> Entering functions.invertQTransform
  pre transform calcs: 0.0339 ms
  hdinv result: 0.0844 ms
  ravel speed: 0.0372 ms
  indexing speed: 0.0229 ms
  .inverted transform result: <PyQt5.QtGui.QTransform object at 0x7fad54367740>: 0.0257 ms
< Exiting functions.invertQTransform, total time: 0.2203 ms

I find this pretty interesting because I can't see a need to compute the inverse matrix manually at all?

The .inverted() call is not only faster but avoids conversion in and out of numpy types.

Maybe Qt5 added this method and that's why we have this original implementation?
Seems like it's in docs for Qt4:
- https://doc.qt.io/archives/qt-4.8/qtransform.html#inverted
- https://het.as.utexas.edu/HET/Software/html/qtransform.html#inverted

So 🤷


EDIT 🤦 :

it's all in the doc string -> floating point errors when determining if matrix is invertible, something something.
Maybe this has been fixed now?

Here's a possibly relevant bug tracker list.

I'm suspicious this was the original actual set of issues:

The latter seems to have been resolved in 4.6.3 but haven't checked he sources for that git hash.

To me we should just drop all the numpy calcs and simplify this whole thing 🚄

@goodboy
Copy link
Copy Markdown
Author

goodboy commented Oct 24, 2020

Oh, and I guessed wrong, looks like *np.ravel() is slower then the manual indexing; wild.

@goodboy
Copy link
Copy Markdown
Author

goodboy commented Oct 24, 2020

OH**2, I also added singularity detection to the manual inverse calc if we decide to keep a manual version.
The singularity detection logic I just made match QTranform.inverted(), ish.

@goodboy
Copy link
Copy Markdown
Author

goodboy commented Oct 24, 2020

I'm also ok to assume these CI failures are unrelated right?

I see:

Task Insights
This task has failed 10 times across 79 pipeline runs in the last 14 days.

For Linux py2.7 in the azure UI.

Raises an exception if tr is not invertible.

Note that this function is preferred over QTransform.inverted() due to
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🤦, I guess the reason is right here lol.

Clearly need to read docs summaries better 😄

goodboy added a commit to pikers/piker that referenced this pull request Oct 25, 2020
Avoid drawing a new new sticky position if the mouse hasn't moved to the
next (rounded) index in terms of the scene's coordinates. This completes
the "discrete-ization" of the mouse/cursor UX.

Finalizing this feature helped discover and solve
pyqtgraph/pyqtgraph#1418 which masssively improves interaction
performance throughout the whole lib!

Hide stickys on startup until cursor shows up on plot.
@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Oct 26, 2020

Thanks for the PR @goodboy

CI is failing on all Qt4 builds; would suggest testing locally to see if you can replicate (good luck configuring Qt4 locally w/o conda).

Doing a search for the issue reported in the Qt bug tracker I came up with QTBUG-52070. As the issue appears to have been fixed with newer versions of Qt, if QTransform.inverted() is faster than the numpy implementation, perhaps we should only use the numpy implementation with the when Qt4 is being utilized.

So instead of try/except for numpy import error, instead do:

if QT_LIB in ["pyside", "pyqt"]:
    # use numpy method
else:
    # use qtransform method

@goodboy
Copy link
Copy Markdown
Author

goodboy commented Oct 26, 2020

CI is failing on all Qt4 builds;

@j9ac9k I peeked (very briefly) at these builds and they don't seem to be failing due to this change?
Is it possible the CI is flaky / failing for some other reason?

Doing a search for the issue reported in the Qt bug tracker I came up with QTBUG-52070.

See the list I linked above (of which your issue is a member).
The one you posted I think is dependent on the other 2.
8557 seems to have been closed without a fix if you look at the all tab for history?
I'm not sure a fix for the logic that was requested every got in actually; the issue might still persist.

if QTransform.inverted() is faster than the numpy implementation, perhaps we should only use the numpy implementation with the when Qt4 is being utilized.

It's definitely faster then numpy; I don't think you're going to beat not leaving C++ land speed wise.

So instead of try/except for numpy import error, instead do:

Sounds good 👍

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Oct 26, 2020

Two tests that fail:

pyqtgraph/graphicsItems/tests/test_ROI.py::test_getArrayRegion 
[gw0] [ 74%] FAILED pyqtgraph/graphicsItems/tests/test_ROI.py::test_getArrayRegion 
pyqtgraph/graphicsItems/tests/test_ROI.py::test_getArrayRegion_axisorder 
[gw0] [ 75%] FAILED pyqtgraph/graphicsItems/tests/test_ROI.py::test_getArrayRegion_axisorder 
pyqtgraph/graphicsItems/tests/test_ROI.py::test_PolyLineROI

Those tests have historically been very sensitive to failure; I would be surprised if the failures are unrelated to this PR, but the way to check that would be to revert the invertQTransform function and see if you get the same results.

@goodboy
Copy link
Copy Markdown
Author

goodboy commented Dec 22, 2020

@j9ac9k so given #1473 can I rebase onto that branch and then we can forget about Qt4 and py2?

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Dec 22, 2020

@goodboy I think I'm about to merge that PR, so I would go ahead and rebase master after I do so

This fixes not only latency but high CPU usage when inverting a Qt
transform matrix.

There is a known recent issue in ``numpy`` pertaining to this:
numpy/numpy#17166 (comment)
@goodboy goodboy force-pushed the avoid_np.linalg.inv branch from 94c3153 to f9b494d Compare December 23, 2020 13:55
@goodboy
Copy link
Copy Markdown
Author

goodboy commented Dec 23, 2020

@j9ac9k bumped.

@goodboy
Copy link
Copy Markdown
Author

goodboy commented Dec 23, 2020

All in all looking much better and making me more confident that this change is nbd in terms of floating point errors.

CI TLDR for y'all:

  • py3.7 OS X failed due to some pyqtgraph/pyqtgraph/pyqtgraph/tests/image_testing.py:557
______________________________ test_PlotCurveItem ______________________________
[gw1] darwin -- Python 3.7.9 /Users/runner/hostedtoolcache/Python/3.7.9/x64/bin/python

path = '/Users/runner/.pyqtgraph/test-data', ref = 'HEAD'

    def gitCommitId(path, ref):
        """Return the commit id of *ref* in the git repository at *path*.
        """
        cmd = gitCmdBase(path) + ['show', ref]
        try:
>           output = runSubprocess(cmd, stderr=None, universal_newlines=True)

/Users/runner/work/pyqtgraph/pyqtgraph/pyqtgraph/tests/image_testing.py:557: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

command = ['git', '--git-dir=/Users/runner/.pyqtgraph/test-data/.git', '--work-tree=/Users/runner/.pyqtgraph/test-data', 'show', 'HEAD']
return_code = False, kwargs = {'stderr': None, 'universal_newlines': True}
use_kwargs = {'stderr': None, 'stdout': -1, 'universal_newlines': True}
p = <subprocess.Popen object at 0x11d0794d0>, output = ''
err_fun = <function CalledProcessError.__init__ at 0x10da07ef0>

    def runSubprocess(command, return_code=False, **kwargs):
        """Run command using subprocess.Popen
    
        Similar to subprocess.check_output(), which is not available in 2.6.
    
        Run command and wait for command to complete. If the return code was zero
        then return, otherwise raise CalledProcessError.
        By default, this will also add stdout= and stderr=subproces.PIPE
        to the call to Popen to suppress printing to the terminal.
    
        Parameters
        ----------
        command : list of str
            Command to run as subprocess (see subprocess.Popen documentation).
        **kwargs : dict
            Additional kwargs to pass to ``subprocess.Popen``.
    
        Returns
        -------
        stdout : str
            Stdout returned by the process.
        """
        # code adapted with permission from mne-python
        use_kwargs = dict(stderr=None, stdout=sp.PIPE)
        use_kwargs.update(kwargs)
    
        p = sp.Popen(command, **use_kwargs)
        output = p.communicate()[0]
    
        # communicate() may return bytes, str, or None depending on the kwargs
        # passed to Popen(). Convert all to unicode str:
        output = '' if output is None else output
        output = output.decode('utf-8') if isinstance(output, bytes) else output
    
        if p.returncode != 0:
            print(output)
            err_fun = sp.CalledProcessError.__init__
            if 'output' in inspect.getargspec(err_fun).args:
>               raise sp.CalledProcessError(p.returncode, command, output)
E               subprocess.CalledProcessError: Command '['git', '--git-dir=/Users/runner/.pyqtgraph/test-data/.git', '--work-tree=/Users/runner/.pyqtgraph/test-data', 'show', 'HEAD']' returned non-zero exit status 128.
  • linter run is a massacre
    • I presume there's some branch I can also rebase from to fix this?

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Dec 23, 2020

For the images used for testing, we actually keep those in a separate repo here: https://github.com/pyqtgraph/test-data

As part of the test suite, we clone that repo to get the images.

Frankly I hate this structure, and think the images should be part of the test data, and the test data should not be part of the .whl that's built.

I have no idea why the CI is blowing up on this suddenly though.

@goodboy
Copy link
Copy Markdown
Author

goodboy commented Dec 23, 2020

I have no idea why the CI is blowing up on this suddenly though.

In theory it could be the floating point issues?
We might need someone with more expertise in what these tests check to comment.

@goodboy
Copy link
Copy Markdown
Author

goodboy commented Dec 28, 2020

@j9ac9k so I guess just rebase again here?

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 4, 2021

@j9ac9k so I guess just rebase again here?

I had to do a rebase to launch the example app; ... I'm going to try and script some kind of profiling code for all the different ways we can do this. I think you doing a rebase from master and pushing to your repo would be a good idea (FWIW I had no conflicts doing the rebase)

EDIT: scratch that, error is unrelated to this PR...I'll try and fix now-ish.

EDIT2: 🤦 error was due to naming my profiling file profile.py, ...it got confused with the profile class.... why am I so bad at this

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 4, 2021

Looked at this a bit more, and looks like QTransform.inverted() is using qFuzzyIsNull under the hood here:

https://doc.qt.io/qt-5/qtglobal.html#qFuzzyIsNull

Returns true if the absolute value of d is within 0.000000000001 of 0.0.

Unfortunately the precision that method offers has been determined to be insufficient; I'm still working on some profiling code here so hopefully I'll have something soon-ish

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 4, 2021

I just want to extra emphasize that it wasn't soo so much the latency that made me notice this as it was the overuse of multi-core resources for such a simpler operation. If you go back to the np version you use way more CPU (at least on a non-GPU system) then the Qt-native version. Like, the latency is bad, but the resource consumption is just plain horrendous.

So if it's not the latency, then we'll need to figure out another method of measuring the resource consumption that's the concern, and see if any of the tested methods here offer sufficient improvements or not.

I'm fine with whatever you end up deciding for an internal avoid the mysterious Qt inverse bug implementation, but given the Qt version is so much faster I do think it makes sense to allow users to opt in for the native version if it won't cause them specific problems. However, not using it as the default is fine i guess.

I'm going to be very reluctant to let users opt-in on this in a way the library supports it. The failure mode here is not going to obvious, and hits method is used in almost every part of the library. If someone wants to overwrite this, they can do so via monkey-patching the method themselves (after all, this is python, we're all adults here).

import pyqtgraph as pg

def patchedInvertQTransform(transform: QTransform):
    return tr.inverted()

pg.functions.invertQTransform = patchedInvertQTransform

Generally matrix inversion is pretty "expensive", and to quote my numerical methods professor in undergrad @tbewley (I'm paraphrasing a bit, it's been some time)

If any of you ever invert a sparse matrix, you will not get any kind of letter of recommendation from me, I will likely fail you, and I will ask you do not use use me as any kind of reference, as I will pretend to have no idea who you are.

It may be more worthwhile to investigate even if we actually need to do these inversions, and if not, can we do other matrix operations instead.

Also, it's probably worth pushing on the bug either way as we already know we're not the only ones affected by it. Also, it's not like that hard to modify the C++ ourselves and submit a patch.

This has been reported on a few instances in the Qt bug tracker, but from their (Qt dev's) perspective, this isn't really a bug, but working as intended; it's just that their implementation is not sufficient enough for our purposes.

Here are some links to the relevant bug reports; you may get some luck chiming in on the first; the second link last update was almost 11 years ago.

If you can identify a way to track the resource consumption, that would be the next step here if we're going to evaluate another method.

@goodboy
Copy link
Copy Markdown
Author

goodboy commented Jan 4, 2021

So if it's not the latency, then we'll need to figure out another method of measuring the resource consumption that's the concern, and see if any of the tested methods here offer sufficient improvements or not.

The latency is still definitely a problem (one that is unacceptable for large data sets IMO) but not the original reason I noticed the issue in the first place; that was CPU overuse.

I'm going to be very reluctant to let users opt-in on this in a way the library supports it. The failure mode here is not going to obvious, and hits method is used in almost every part of the library. If someone wants to overwrite this, they can do so via monkey-patching the method themselves (after all, this is python, we're all adults here).

This is totally fine of course, but at some point, being someone extremely concerned with latency, I'll have overriden nearly everything in pyqtgraph that I'm using 😂.

It's essentially the same discoveries as in #1478; there's a lot of numpy stuff that doesn't make things any faster as long as you actually use Qt more effectively.

If there's tradeoffs that need to be made (like fp precision in this case) for certain edge cases then why are we tuning to the pathological case?

This is because QTransform::inverted uses qFuzzyIsNull and the numbers are too close to 0-1 so it ends up being incorrect for very precise numbers.

To do specialized computation, i would recommend to use a specialized library.

IMO if we need to handle the special cases (high prevision views in the range 0-1) then we load the specialized inverter. I have trouble believing many use cases involve this situation but 🤷🏼

I guess for me the main edge pyqtgraph is supposed to have is speed, and so far I just haven't really found that without heavy modifications on almost everything I've used.

Generally matrix inversion is pretty "expensive",

3x3 though?
We just showed pretty easily that it's not that expensive doing it by hand.

it's just that their implementation is not sufficient enough for our purposes.

Which purposes are those exactly?
Still not seeing from a41d330 what the problem was that was being addressed in terms of an example or test.
Do we not have at least some kind of situation to clarify the failure mode for our purposes in like image proc or whatever?

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Jan 4, 2021

@goodboy, are you using a numpy built with MKL? That could explain the multi-core usage.

Try adding the following before any other imports:

import mkl
mkl.set_num_threads(1)

@goodboy
Copy link
Copy Markdown
Author

goodboy commented Jan 4, 2021

That could explain the multi-core usage

Don't think so?
Can't import that name.

Either way probably just going to override this since I have no use cases that experience the underlying fp problem and the benefits are just too great in comparison.

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 5, 2021

With only examining where this method is called briefly; I would guess try to trigger the issue with either ScatterPlotItem w/ pxMode=True or a ROI (with some non-standard geometry, so not just a plain rectangle/square) with some very large image underneath...the key here is the pixel level accuracy that you're going after.

Another potential use case is a viewbox that's way zoomed in (or out?) and querying the pixel size, it's quite possible you'll get different results there as well.

In this branch, I would implement both methods, and before the function returns, compare the difference between them, given that matrix inversion of sparse matrices can be very sensitive to floating-point errors, you should expect to see some variance; question is is that difference enough to trigger different results.

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Jan 5, 2021

Why not something like the following?

def invertQTransform(tr):
    # try fast path first
    inv, ok = tr.inverted()
    if ok:
        return inv

    import numpy.linalg
    arr = np.array([[tr.m11(), tr.m12(), tr.m13()], [tr.m21(), tr.m22(), tr.m23()], [tr.m31(), tr.m32(), tr.m33()]])
    inv = numpy.linalg.inv(arr)
    return QtGui.QTransform(inv[0,0], inv[0,1], inv[0,2], inv[1,0], inv[1,1], inv[1,2], inv[2,0], inv[2,1])

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 5, 2021

Why not something like the following?

def invertQTransform(tr):
    # try fast path first
    inv, ok = tr.inverted()
    if ok:
        return inv

    import numpy.linalg
    arr = np.array([[tr.m11(), tr.m12(), tr.m13()], [tr.m21(), tr.m22(), tr.m23()], [tr.m31(), tr.m32(), tr.m33()]])
    inv = numpy.linalg.inv(arr)
    return QtGui.QTransform(inv[0,0], inv[0,1], inv[0,2], inv[1,0], inv[1,1], inv[1,2], inv[2,0], inv[2,1])

I don't think the problem is determining if it is investable and falling back to numpy if its not; but the issue is the inverse of the matrix will be off to a sufficient point that attempts at pixel perfect mappings will break. If you look at where the library uses invertQTransform it's all in areas attempting to do pixel based mapping, a feature that QGraphicsView intentionally tries to abstract away.

For example, if you take

numpyMatrix = np.array([[tr.m11(), tr.m12(), tr.m13()], [tr.m21(), tr.m22(), tr.m23()], [tr.m31(), tr.m32(), tr.m33()]])
itr = tr.inverted()
invertedTransformMatrix = np.array([[itr.m11(), itr.m12(), itr.m13()], [itr.m21(), itr.m22(), itr.m23()], [itr.m31(), itr.m32(), itr.m33()]])
invertedNumpyMatrix = np.linalg.inv(numpyMatrix)

assert np.testing.assert_all_close(invertedTransformMatrix, invertedNumpyMatrix)  # this will likely fail

This is why I would advise implementing both methods, and doing comparisons in the results of the functions that call invertQTransform within, some of those places include

  • GraphicsItem.mapRectFromDevice
  • GraphicsItem.pixelVectors
  • GraphicsItem.pixelWidth
  • GraphicsItem.pixelHeight

there are more locations, but you should get the idea.

Point being here is it must be shown that these functions would give the same results numpy.linalg.inv or QTransform.inverted.

@goodboy I would suggest you modify this PR to a draft, then implement both the numpy.linalg.inv and QTransform.inverted() you modify each function that calls inverertQTransform such that it gets results from both methods of matrix inversion, and then see what the output of those methods above are using each inversion method; basically you want tos ee, does pixelVectors consistently give the same result when using tr.inverted() vs. numpy.linalg.inv ? (same for pixelWidth, pixelHeight and so on).

I recognize this is a large burden to overcome; but given this has been an issue historically, and intentional changes were made to address it, and there is nothing in any Qt changelog suggesting the behavior has changed, I have little reason to believe whatever the issue was, wouldn't re-appear if we reverted to QTransform.inverted()

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Jan 6, 2021

I don't think the problem is determining if it is investable and falling back to numpy if its not; but the issue is the inverse of the matrix will be off to a sufficient point that attempts at pixel perfect mappings will break.

What QTransform.inverted() amounts to doing is:

  1. compute determinant
  2. ok = not qFuzzyIsNull(det)
  3. compute inverse using the manual method

What numpy.linalg.inv() seems to be doing is calling lapack GESV

What you are saying is that even if the use of qFuzzyIsNull were removed, it would not be sufficient?
i.e. numpy manual_calculation() would also not be sufficient.

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 6, 2021

I don't think the problem is determining if it is investable and falling back to numpy if its not; but the issue is the inverse of the matrix will be off to a sufficient point that attempts at pixel perfect mappings will break.

What QTransform.inverted() amounts to doing is:

  1. compute determinant
  2. ok = not qFuzzyIsNull(det)
  3. compute inverse using the manual method

My understanding was it was running qFuzzyIsNull on each of the components (m11, m12, ...), but now that I think about it, I don't have a good reason for thinking that, I sort of just assumed that.

If it is indeed running qFuzzyIsNull on just the determinant, then this issue gets a lot simpler.

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 6, 2021

@pijyoi you're right!

https://github.com/qt/qtbase/blob/e13173c112b729da8f53dd2e81e8116a1ed857cf/src/gui/painting/qtransform.cpp#L347

@goodboy based on what @pijyoi pointed out, I'm totally good with using tr.inverted() and falling back on a numpy inversion method if QTransform.inverted() determined the matrix was singular.

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Jan 6, 2021

Certain code-paths in QTransform::inverted() apply qFuzzyIsNull() to its elements, depending on the matrix type. The generic matrix type applies qFuzzyIsNull() only to the determinant. I don't know if pyqtgraph always falls into the generic matrix case.

We could avoid qFuzzyIsNull() totally perhaps by using:

def invertQTransform(tr):
    det = tr.determinant()
    # TODO: if needed, use some other criteria to determine invertable or not
    return tr.adjoint() / det

This takes twice the time of simply calling inverted() due to function call overhead.

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 6, 2021

Looks like the other methods that call qFuzzyIsNull on the elements are for scaling translation transforms;

We can easily query the type: http://doc.qt.io/qt-5/qtransform.html#TransformationType-enum and go from there?

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Jan 6, 2021

From your transforms.txt, I counted:
TxScale : 3333
TxRotate : 1630
TxNone : 6
So pyqtgraph does indeed hit uses of qFuzzyIsNull()

With your dataset, the following is slower than just unconditionally computing tr.adjoint() / tr.determinant().
For such small matrices, it's all function call overhead.

def invertTransforms3():
    transforms = originalTransforms.copy()
    TxScale = QtGui.QTransform.TransformationType.TxScale
    for tr in transforms:
        if tr.type() == TxScale:
            det = tr.determinant()
            inv = tr.adjoint() / det
        else:
            inv, ok = tr.inverted()
    return None

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 6, 2021

We can't quite unconditionally do that division, as we would have to catch the case when the determinant is 0; ...

should we then call a psedo-inverse function (they're a bit slower, but in theory they should be never called so who cares?) ...that said I can't remember ever getting a Transform is not invertable exception...

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Jan 6, 2021

Looks like I got confused myself in trying to avoid qFuzzyIsNull().
All uses of qFuzzyIsNull() in QTransform::inverted() are only for determining invertibility.

So the simplest would still be:

def invertQTransform(tr):
    # try fast path first
    inv, ok = tr.inverted()
    if ok:
        return inv

    import numpy.linalg
    arr = np.array([[tr.m11(), tr.m12(), tr.m13()], [tr.m21(), tr.m22(), tr.m23()], [tr.m31(), tr.m32(), tr.m33()]])
    inv = numpy.linalg.inv(arr)
    return QtGui.QTransform(inv[0,0], inv[0,1], inv[0,2], inv[1,0], inv[1,1], inv[1,2], inv[2,0], inv[2,1])

None of the ~5000 test matrices enter the slow path.

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 6, 2021

Now I don't really do much C coding, but the TXScale method seems to do some checks on the m11, and m22 values:

https://github.com/qt/qtbase/blob/e13173c112b729da8f53dd2e81e8116a1ed857cf/src/gui/painting/qtransform.cpp#L330-L333

oh wait...that just checks for invertability...

I would still advocate for using np.linalg.pinv instead of .inv

https://numpy.org/doc/stable/reference/generated/numpy.linalg.pinv.html

That should handle the case of singlar matrices better; which is what that method would expect to get...

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Jan 6, 2021

Hmm, maybe we jumped the gun a little.

To classify the kind of matrix, a call to the method type() is made.
https://github.com/qt/qtbase/blob/e13173c112b729da8f53dd2e81e8116a1ed857cf/src/gui/painting/qtransform.cpp#L323
https://github.com/qt/qtbase/blob/e13173c112b729da8f53dd2e81e8116a1ed857cf/src/gui/painting/qtransform.cpp#L2034
This method uses qFuzzyIsNull() to make its classification decision.

In the following, "9" is used to represent an arbitrary value.
"0" and "1" are checked fuzzily.

[[9 9 9]
 [9 9 9]  --> TxProject
 [9 9 9]]
[[9 9 0]
 [9 9 0]  --> TxShear / TxRotate
 [9 9 1]]
[[9 0 0]
 [0 9 0]  --> TxScale
 [9 9 1]]
[[1 0 0]
 [0 1 0]  --> TxTranslate
 [9 9 1]]
[[1 0 0]
 [0 1 0]  --> TxNone
 [0 0 1]]

So for instance, something that is not exactly a TxScale matrix could be classified as one and thus get inverted using the TxScale inversion way.
It also appears that previously, TxShear and TxRotate matrices got inverted specially, but now they go through the general way.
https://github.com/qt/qtbase/blob/e13173c112b729da8f53dd2e81e8116a1ed857cf/src/gui/painting/qtransform.cpp#L340

So to avoid this fuzzy matrix classification, we need to avoid calling inverted(), maybe something like the following?
It's slower than calling inverted() but I think it is better to be conservative and not invoke any fuzzy behavior.

def invertQTransform(tr):
    # try fast path first
    det = tr.determinant()
    bad = abs(det) <= 1e-12
    if not bad:
        return tr.adjoint() / det

    # anything else gets passed to the old way
    # TODO: use numpy.linalg.pinv
    import numpy.linalg
    arr = np.array([[tr.m11(), tr.m12(), tr.m13()], [tr.m21(), tr.m22(), tr.m23()], [tr.m31(), tr.m32(), tr.m33()]])
    inv = numpy.linalg.inv(arr)
    return QtGui.QTransform(inv[0,0], inv[0,1], inv[0,2], inv[1,0], inv[1,1], inv[1,2], inv[2,0], inv[2,1])

In my opinion, not changing np.linalg.inv() to np.linalg.pinv() has the advantage that the slow path code is just a cut-and-paste of the tested original code.

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Jan 6, 2021

A synthetic case to cause QTransform to use TxScale inversion method or general inversion method depending on whether small value meets qFuzzyIsNull() criteria.

import numpy as np
from PySide2 import QtGui

def nd_to_qtr(a):
    return QtGui.QTransform(*a.ravel())

def qtr_to_nd(tr):
    return np.array([[tr.m11(), tr.m12(), tr.m13()],
        [tr.m21(), tr.m22(), tr.m23()], [tr.m31(), tr.m32(), tr.m33()]])

def test(e):
    arr = np.array([[5,e,e], [e,5,0], [0,0,1]])
    tr = nd_to_qtr(arr)
    print(tr.type())
    qt_inv = qtr_to_nd(tr.inverted()[0])
    print(arr)
    print(qt_inv)
    print(arr @ qt_inv)

test(1e-11)     # classified as TxProject
test(1e-12)     # classified as TxScale

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 6, 2021

Nice find on how the transforms are classified!

Originally I was going to suggest trying to see how fast tr.determinant() was vs. np.linglag.det but I then realized to call the numpy determinant method, we would still have to construct the numpy array, but we could still likely leverage the adjoint method from the transform ...

def invertQTransform(tr):
    # try fast path first
    det = tr.determinant()
    bad = abs(det) <= 1e-12
    if not bad:
        return tr.adjoint() / det
    else:
        arr = np.array([[tr.m11(), tr.m12(), tr.m13()], [tr.m21(), tr.m22(), tr.m23()], [tr.m31(), tr.m32(), tr.m33()]])
        det = np.linalg.det(arr)
        if det != 0:  # due to floating poinit math, we probably want an (abs) <= some_really_small_value here
            return tr.adjoint() / det
        else:
            pinv = np.linalg.pinv(arr)
            return QtGui.QTransform(pinv[0,0], pinv[0,1], pinv[0,2], pinv[1,0], pinv[1,1], pinv[1,2], pinv[2,0], pinv[2,1])

This way we should always get an inverted transform, even when the matrix isn't invertable.

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Jan 8, 2021

In the original numpy implementation: inv[2,2] is assumed to be 1.0 and is forced to 1.0
If this behavior was intended, then it should be incorporated.

With the changes, I got the following timings on my machine.
The pure python versions avoid the Qt -> ndarray -> Qt conversion overhead and seem to run competitively.

One optimization is avoiding a general 3x3 inversion when the right-most column is exactly [0, 0, 1], which happens to be the case for all the transforms in transforms.txt.

Finished numpy_orig: 406.2722 ms
Finished scipy_linalg: 183.2955 ms
Finished python_general: 22.6760 ms
Finished python_affine: 17.6845 ms
Finished qt_noclassification: 22.2178 ms
Finished qt_noclassification_cheat: 14.2736 ms
Finished Qt inverted(): 4.5559 ms

import collections

import fileinput
import PySide2

from PySide2.QtGui import QTransform
from pyqtgraph.debug import Profiler
import numpy as np
import scipy.linalg


# note that the original forces inv[2,2] to 1.0,
# so all our implementations need to follow this
def numpy_orig(tr):
    arr = np.array([[tr.m11(), tr.m12(), tr.m13()],
                    [tr.m21(), tr.m22(), tr.m23()],
                    [tr.m31(), tr.m32(), tr.m33()]])
    inv = np.linalg.inv(arr)
    return QTransform(inv[0, 0], inv[0, 1], inv[0, 2],
                      inv[1, 0], inv[1, 1], inv[1, 2],
                      inv[2, 0], inv[2, 1])


def scipy_linalg(tr):
    # scipy with marshalling improvements incorporated
    arr = np.array([[tr.m11(), tr.m12(), tr.m13()],
                    [tr.m21(), tr.m22(), tr.m23()],
                    [tr.m31(), tr.m32(), tr.m33()]])
    arr.shape = (3, 3)
    inv = scipy.linalg.inv(arr)
    return QTransform(*inv.ravel().tolist()[:-1])


def marshalling_overhead_orig(tr):
    # measure cost of marshalling between Qt -> numpy -> Qt
    inv = np.array([[tr.m11(), tr.m12(), tr.m13()],
                    [tr.m21(), tr.m22(), tr.m23()],
                    [tr.m31(), tr.m32(), tr.m33()]])

    return QTransform(inv[0, 0], inv[0, 1], inv[0, 2],
                      inv[1, 0], inv[1, 1], inv[1, 2],
                      inv[2, 0], inv[2, 1])


def marshalling_overhead(tr):
    # measure cost of marshalling between Qt -> numpy -> Qt
    # faster to use a 1d-list than a 2d-list
    arr = np.array([tr.m11(), tr.m12(), tr.m13(),
                    tr.m21(), tr.m22(), tr.m23(),
                    tr.m31(), tr.m32(), tr.m33()])
    arr.shape = (3, 3)
    # faster to apply a list than an ndarray
    return QTransform(*arr.ravel().tolist()[:-1])


def python_general(tr):
    m11, m12, m13 = tr.m11(), tr.m12(), tr.m13()
    m21, m22, m23 = tr.m21(), tr.m22(), tr.m23()
    m31, m32, m33 = tr.m31(), tr.m32(), tr.m33()

    h11 = m22*m33 - m23*m32
    h21 = m23*m31 - m21*m33
    h31 = m21*m32 - m22*m31
    h12 = m13*m32 - m12*m33
    h22 = m11*m33 - m13*m31
    h32 = m12*m31 - m11*m32
    h13 = m12*m23 - m13*m22
    h23 = m13*m21 - m11*m23
    h33 = m11*m22 - m12*m21

    # m13, m23, m33 is likely to be 0, 0, 1
    det = m13*h31 + m23*h32 + m33*h33
    detr = 1.0 / det    # let singular matrices raise ZeroDivisionError

    inv = QTransform(h11 * detr, h12 * detr, h13 * detr,
                     h21 * detr, h22 * detr, h23 * detr,
                     h31 * detr, h32 * detr)
    return inv


def python_affine(tr):
    m11, m12, m13 = tr.m11(), tr.m12(), tr.m13()
    m21, m22, m23 = tr.m21(), tr.m22(), tr.m23()
    m31, m32, m33 = tr.m31(), tr.m32(), tr.m33()

    h31 = m21*m32 - m22*m31
    h32 = m12*m31 - m11*m32
    h33 = m11*m22 - m12*m21

    if m13 == 0.0 and m23 == 0.0 and m33 == 1.0:
        det = h33
        detr = 1.0 / det    # let singular matrices raise ZeroDivisionError

        inv = QTransform(m22 * detr, -m12 * detr,
                         -m21 * detr, m11 * detr,
                         h31 * detr,  h32 * detr)
        return inv
    else:
        det = m13*h31 + m23*h32 + m33*h33
        detr = 1.0 / det    # let singular matrices raise ZeroDivisionError

        h11 = m22*m33 - m23*m32
        h21 = m23*m31 - m21*m33
        # h31 = m21*m32 - m22*m31
        h12 = m13*m32 - m12*m33
        h22 = m11*m33 - m13*m31
        # h32 = m12*m31 - m11*m32
        h13 = m12*m23 - m13*m22     # zero if m13 == m23 == 0
        h23 = m13*m21 - m11*m23     # zero if m13 == m23 == 0
        # h33 = m11*m22 - m12*m21

        inv = QTransform(h11 * detr, h12 * detr, h13 * detr,
                         h21 * detr, h22 * detr, h23 * detr,
                         h31 * detr, h32 * detr)
        return inv


def qt_noclassification(tr):
    det = tr.det()
    detr = 1.0 / det    # let singular matrices raise ZeroDivisionError
    adj = tr.adjoint()

    inv = adj * detr
    inv = QTransform(inv.m11(), inv.m12(), inv.m13(),
                     inv.m21(), inv.m22(), inv.m23(),
                     inv.m31(), inv.m32())
    return inv


def qt_noclassification_cheat(tr):
    # we are cheating here because we didn't set inv[2,2] to 1.0
    det = tr.det()
    detr = 1.0 / det    # let singular matrices raise ZeroDivisionError
    adj = tr.adjoint()
    return adj * detr


def check_exact_affine(transforms):
    cnt = sum(tr.m13() == 0 and tr.m23() == 0 and tr.m33() == 1.0
              for tr in transforms)
    print('exact affine {} out of {}'.format(cnt, len(transforms)))


transforms = [eval(line.strip()) for line in fileinput.input("transforms.txt")]

transforms_copy = transforms.copy()
cnt = collections.Counter(tr.type() for tr in transforms_copy)
print(cnt)

check_exact_affine(transforms)

profiler = Profiler(disabled=False, delayed=False)

[marshalling_overhead_orig(tr) for tr in transforms]
profiler("Finished marshalling_overhead_orig")
[marshalling_overhead(tr) for tr in transforms]
profiler("Finished marshalling_overhead")

results = []

inv = [numpy_orig(tr) for tr in transforms]
results.append(inv)
profiler("Finished numpy_orig")
inv = [scipy_linalg(tr) for tr in transforms]
results.append(inv)
profiler("Finished scipy_linalg")
inv = [python_general(tr) for tr in transforms]
results.append(inv)
profiler("Finished python_general")
inv = [python_affine(tr) for tr in transforms]
results.append(inv)
profiler("Finished python_affine")
inv = [qt_noclassification(tr) for tr in transforms]
results.append(inv)
profiler("Finished qt_noclassification")
inv = inverted = [qt_noclassification_cheat(tr) for tr in transforms]
results.append(inv)
profiler("Finished qt_noclassification_cheat")
inv = [tr.inverted()[0] for tr in transforms]
results.append(inv)
profiler("Finished Qt inverted()")

for inverted in results:
    check_exact_affine(inverted)

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 8, 2021

In the original numpy implementation: inv[2,2] is assumed to be 1.0 and is forced to 1.0
If this behavior was intended, then it should be incorporated.

I'm fairly sure this is a byproduct of the matrix math involved with the Givens rotation matrix

One optimization is avoiding a general 3x3 inversion when the right-most column is exactly [0, 0, 1], which happens to be the case for all the transforms in transforms.txt.

It's been a while since I studied this linear algebra, and while we could depend on this for for 2D; we absolutely cannot depend on this in the case of 3D operations. That said, I'm not sure how these transforms are created in 3D space; my argument is strictly of an academic nature (I'm not even sure that this is method is called in 3D operations or not).

One thing that never dawned on me is to ignore numpy altogether; I keep forgetting how fast the python math module can be for singular values. Nice thought.

I'm of the opinion that the implementation should be like qt_noclassification_cheat but be able to handle the case when the determinant is 0 a little more gracefully.

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Jan 8, 2021

One optimization is avoiding a general 3x3 inversion when the right-most column is exactly [0, 0, 1], which happens to be the case for all the transforms in transforms.txt.

It's been a while since I studied this linear algebra, and while we could depend on this for for 2D; we absolutely cannot depend on this in the case of 3D operations. That said, I'm not sure how these transforms are created in 3D space; my argument is strictly of an academic nature (I'm not even sure that this is method is called in 3D operations or not).

The python_affine() implementation is in fact fully general for 3x3 matrices. It just avoids the redundant calculations when the right-most column is tested to be exactly [0, 0, 1].

QtGui.QTransform is purely for 2D transforms (using a 3x3 double precision matrix) [https://doc.qt.io/qt-5/qtransform.html]

Qt3DCore::QTransform is for 3D transforms (using a 4x4 single precision matrix) [https://doc.qt.io/qt-5/qt3dcore-qtransform.html]

I'm of the opinion that the implementation should be like qt_noclassification_cheat but be able to handle the case when the determinant is 0 a little more gracefully.

So something like this? It's slightly faster now by doing an in-place multiplication.

def pinv_fallback(tr):
    arr = np.array([tr.m11(), tr.m12(), tr.m13(),
                    tr.m21(), tr.m22(), tr.m23(),
                    tr.m31(), tr.m32(), tr.m33()])
    arr.shape = (3, 3)
    pinv = np.linalg.pinv(arr)
    return QtGui.QTransform(*pinv.ravel().tolist())

def qt_noclassification_cheat(tr):
    # we are cheating here because we didn't set inv[2,2] to 1.0
    try:
        det = tr.det()
        detr = 1.0 / det    # let singular matrices raise ZeroDivisionError
        inv = tr.adjoint()
        inv *= detr
        return inv
    except ZeroDivisionError:
        return pinv_fallback(tr)

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 8, 2021

Ahh makes sense 3d transformation matrix would be a 4x4.

So something like this? It's slightly faster now by doing an in-place multiplication.

Yeah, something like that; why are you defining the shape tho?

@pijyoi
Copy link
Copy Markdown
Contributor

pijyoi commented Jan 8, 2021

Yeah, something like that; why are you defining the shape tho?

Based on benchmarking marshalling_overhead(), that's the faster way of converting from Qt to ndarray. Not that it matters much here with pinv() dominating.

@j9ac9k
Copy link
Copy Markdown
Member

j9ac9k commented Jan 13, 2021

Superseded by #1493 Thanks for the PR @goodboy

@j9ac9k j9ac9k closed this Jan 13, 2021
@goodboy
Copy link
Copy Markdown
Author

goodboy commented Feb 6, 2021

@pijyoi @j9ac9k oh sweet glad y'all decided on a solution 🥳

I'll give that a shot on my stuff!

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.

4 participants