-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
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
Allow passing callable for high and low threshold of Canny #3070
Conversation
Hello @jmetz! Thanks for updating the PR.
Comment last updated on May 21, 2018 at 12:52 Hours UTC |
skimage/feature/_canny.py
Outdated
Lower bound for hysteresis thresholding (linking edges). | ||
If callable is given, it is applied to the gradient image | ||
to generate the low_threshold. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe you could extend the Examples section to illustrate this possibility, maybe with percentiles?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jmetz, this docstring statement isn't bad but it's not great either. Specifically, I want it to state the type of the callable: takes in image
as its only input and returns either a float or an array of the same shape as image
as output.
Hi @jmetz thank you for your PR. What kind of functions would typically be used to compute thresholds in your experience, apart from percentiles? If it's only percentiles, we can also think of a keyword argument |
Hi @emmanuelle - I've used skimage's own Because of this I'd suggest at least having the option to pass in a
Thoughts? |
Hi @emmanuelle and @jmetz I am -1 for doing separate functions. We already support int/float thresholds and quantiles. From the users' perspective, this simplicity never goes away. Advanced users would then have the complex use at their disposal if they so desire, without the need to clutter the API. Another example where the advanced API could be used: using |
I tend to agree @jni - are you happy with the PR as is then? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jmetz I've got a couple more comments. Also, add the new feature to doc/releases/release_dev
. Also, as @emmanuelle says, ideally you could extend the current Canny example to show this feature in action. You could, for example, fiddle with the example image so that the brightness/contrast decreases over the rows axis and then a global threshold doesn't work anymore, but a local threshold does.
Or if you have your own example data, that also works, but probably that's more of a hassle because we don't want to keep adding data to the repo.
skimage/feature/_canny.py
Outdated
Lower bound for hysteresis thresholding (linking edges). | ||
If callable is given, it is applied to the gradient image | ||
to generate the low_threshold. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jmetz, this docstring statement isn't bad but it's not great either. Specifically, I want it to state the type of the callable: takes in image
as its only input and returns either a float or an array of the same shape as image
as output.
skimage/feature/_canny.py
Outdated
# | ||
# Else if high_threshold and/or low_threshold are callables | ||
# call them to determine the threshold value / image | ||
# |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mind removing the whitespace comments? I would also change call them
to apply them to the input image
.
skimage/feature/_canny.py
Outdated
if callable(high_threshold): | ||
high_threshold = high_threshold(magnitude) | ||
if callable(low_threshold): | ||
low_threshold = low_threshold(magnitude) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not going to hold up the PR for this, but ideally we would do some sanity checking here: use try/except so that if there's an error, we raise a TypeError with the appropriate error message (the input type of the callable passed to Canny is wrong; and reproduce the traceback that came with the call), and if the type of high_threshold and low_threshold are wrong, then we raise a TypeError saying that the output type of the callable is wrong.
If you can add this we would be eternally grateful! =)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just working on the example quickly over lunch ;)
@jmetz I won't hold the PR for the example though if you don't have time right now. Let us know! And thanks again! =) |
When passing in image to low_threshold callable, only pass in values that are below high_thresh. This is similar to how low_threshold is usually set to a fraction of high threshold for scalar threshold values
Also added low SNR image to show strength of callables
@jni - Added callable usage to example. Even for simple data (in this case just making a lower SNR square image) shows the strength of using a callable (here I used For your convenience: the image in the canny example now looks like this: |
(Also apologies for that last commit - can consolidate if needed) |
doc/examples/edges/plot_canny.py
Outdated
ax1.set_title('noisy image', fontsize=20) | ||
axes[0][0].imshow(im, cmap=plt.cm.gray) | ||
axes[0][0].axis('off') | ||
axes[0][0].set_title('Low noise image') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you change this to NumPy indexing? [0, 0], etc. Much neater imho.
I also feel like some of this repetition can be removed: instead of individual calls to axis('off')
, remove them all and at the end say, for ax in axes.ravel(): ax.axis('off')
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a spectacular example! Thank you for this!
I've made one more minor comment, and also this comment was not addressed, but I'm happy to approve as-is. Neither change should hold up a merge, but is something that you might be willing to push.
@jni - thanks, I should be able to add those last changes in later today - though your link to the comment that I didn't address doesn't seem to take me to any specific comments, would you be able to let me know which one it is? |
Ah it’s the thing about producing a meaningful error message when the callables don’t produce the right type of output or fail with the given input |
@jni - just taking a look at this now... so cleaning up the example to use a loop is straight-forward. names_and_ims = (
('Input Image', im),
('Default', edges_1),
...
)
for ax, (name, im) in zip(axes.ravel(), names_and_ims):
ax.imshow(im)
ax.set_title(name) In terms of analysing the callables, it's a little less straight-forward how I should check that they have the correct call signature and return the correct things. a) Put the callable-related code in a try-except and then raise an error on an except I'd suggest we go with a), as that's more Pythonic, and cleaner to code up - what do you guys thing? |
@jmetz there are two types of errors to catch: (1) the callable fails with a typeerror, which probably means that the callable passed has the wrong input type. We should put this in a try/except clause and raise the appropriate error in the except (a TypeError with an explanation of how to use callables here, and the full traceback to help diagnose things). Regarding the example, I would only go for removing the |
@jni - I've implemented the changes outlined, but just before I commit, final detail: I check that the output of the thresholds is either a 2d array or a |
@jmetz when you say "2D array" I hope you mean "array of the same shape as the input image"? =) Yes |
if not( isinstance(high_threshold, numbers.Number) or ( | ||
isinstance(high_threshold, np.ndarray) and | ||
(high_threshold.shape == magnitude.shape))): | ||
raise ValueError("Callable `high_threshold` must take one input (image array) and return a scalar value or image array of the same shape as input image") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @jmetz! These are great, except they should be TypeError
s. (the same error as when a function is called with the wrong arguments.) And incidentally the except
above should not be bare — it should only catch TypeError
s!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jni Ah... So I checked about TypeError Vs ValueError, and because the issue is with the "value" of the callables, I thought ValueError would be more appropriate?
I.e. to quote the docs for ValueError:
"Raised when a built-in operation or function receives an argument that has the right type but an inappropriate value, and the situation is not described by a more precise exception such as IndexError."
Also as for not having a bare except (I know that's generally quite bad), if there is any issue with a called function, I thought it might still be useful to have the info from canny also, not just have it raise the internal exception... Happy to only catch ValueError though if that's preferred.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(put another way, as we're in the case where eg high_threshold is a callable, it's type is technically already correct... Just that it's specific value is wrong... Right?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I once thought the same as you, but I've learned a bit more about type theory since then. =) Specifically, the type of the callable is not just "callable", but Union[(array -> float), (array -> array)]
. That is: the inputs and outputs of the callable are part of the type. (Whereas a ValueError would stem from, e.g. returning a float that is outside some allowed range, for example.)
And, as I mentioned, you should catch TypeError, because that is the error we are detecting: when the function is unhappy with the inputs as provided.
The errors will propagate through if they are of a different type, and this is what we want: our error message that "you have provided the wrong type of function" will not necessarily be accurate if we catch all errors. If someone, for example, provides a threshold function that sends the data to some Google Compute Engine to process on their TPUs (to get back a threshold 😂), and there is a NetworkError, we don't want to say that it's the wrong kind of function: it is the right kind, but it failed for extraneous reasons.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jni - many thanks for the explanation, glad I know more about type theory wrt functions now too :) - I'll amend the last commit in the morning.
Codecov Report
@@ Coverage Diff @@
## master #3070 +/- ##
========================================
Coverage ? 85.9%
========================================
Files ? 336
Lines ? 27329
Branches ? 0
========================================
Hits ? 23477
Misses ? 3852
Partials ? 0
Continue to review full report at Codecov.
|
@stefanv - I can't see how this can work... setting, for example As you point out, the whole logic of the low and high threshold does, to some extent, imply a dependency, i.e. My vote would be to leave it as is and accept that this dependency is a current limitation, but I'm happy to switch to having one or several hard-coded explicit implementations (such as what I used for the example). |
|
||
# Correct output produced manually with quantiles | ||
# of 0.8 and 0.6 for high and low respectively | ||
correct_output = np.array([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use a new line...
low_threshold = low_threshold(magnitude[mask_threshold]) | ||
except: | ||
traceback.print_exc() | ||
raise ValueError("Callable `low_threshold` raised above exception.\nIt must take one input (image array) and return a scalar value or image array of the same shape as input image") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is too long line...
@jmetz Do you think the robust thresholding method can be implemented (perhaps not with ideal efficiently, but still) by calling the existing threshold method multiple times? If so, the complexity this PR generates may be worth avoiding, and we can either add a new method that does it the circuitous way (for now) or document that a robust method exists and give appropriate pointers. |
I agree. This comment comes from a discussion in the group meeting about the PR. Unless I'm mistaken, these things could be achieved outside of the function of interest, so perhaps this PR is best replaced by a gallery example, which I think would actually be a very valuable addition to our gallery! |
Hi @stefanv & @jni - tbh I'm a little lost now... So to recap, my original PR was to add in extended functionality in canny by allowing the user to pass in callables as I can see how this would be deemed quite an advanced functionality and therefore there's reluctance to accept this PR, but I'm confused what's being proposed now instead? |
You linked to a part of the wikipedia article that mentions how to find a robust threshold: set the high threshold to be something principled (e.g. Otsu but I guess anything will do), then, set the low threshold to be the same principled method limited to the pixels below the original threshold. I think this would make a very nice example in the gallery. In fact, I might be inclined to make this be the default going forward. The old defaults seem pretty random! |
There is a problem with that idea - using Otsu to calculate the threshold values can only be done either
This was the reason I first suggested passing callables for the threshold values. Either way, IMO it makes most sense to close this PR and create one or more issues to cover these proposals. |
On a related but separate note, I would also suggest that the
such that |
Sorry for coming late to the discussion. I see two other options concerning the discussion above:
|
Thanks for the updates to this question @rfezzani, and I agree - switching to a more generic |
@jmetz Thank you for this PR. It was a great thought experiment in how we can improve the canny implementation in scikit-image, and several concrete ideas have been proposed that we should follow-up on. This specific PR, then, is probably not the right fit, but I hope to see some follow-up PRs that take on the rest. Thanks again for going through the whole review cycle and engaging in such constructive conversation; I know it's not ideal to have a PR closed after all that work, but I think we all learned a lot. |
Description
Adds option of passing callables for
high_threshold
andlow_threshold
, whichtake the magnitude image as inputs. This allows more general threshold strategies.
Checklist
[It's fine to submit PRs which are a work in progress! But before they are merged, all PRs should provide:]
./doc/examples
(new features only)References
Closes #3054, a feature request to add this option.
For reviewers
(Don't remove the checklist below.)
later.
__init__.py
.doc/release/release_dev.rst
.