Skip to content

ximgproc: EdgeDrawing must not modify the caller's input when Sigma < 1.0 - #4206

Open
purehol wants to merge 2 commits into
opencv:4.xfrom
purehol:fix-edgedrawing-input-aliasing
Open

purehol wants to merge 2 commits into
opencv:4.xfrom
purehol:fix-edgedrawing-input-aliasing

Conversation

@purehol

@purehol purehol commented Sep 2, 2026 •

Copy link
Copy Markdown

Root cause

On 4.x, with Params::Sigma < 1.0, detectEdges() sets smoothImage = srcImage (edge_drawing.cpp:411), and srcImage = src.getMat() (edge_drawing.cpp:396) is a header on the caller's buffer. smoothImage therefore shares the caller's image until it is reallocated, and every later GaussianBlur() into smoothImage blurs that image in place. This happens in four cases:

case in-place write since
1. detectEllipses() GaussianBlur(srcImage, smoothImage, Size(), 0.50) — edge_drawing.cpp:2718 4.5.2
2. detectEdges() with PFmode = true GaussianBlur(srcImage, smoothImage, Size(), 0.4) — edge_drawing.cpp:430 4.12.0
3. the next detectEdges() with Sigma >= 1.0 on the same detector and a same-size image — writes into the previous input GaussianBlur(srcImage, smoothImage, ...) — edge_drawing.cpp:413-415 4.5.2
4. as 1, with Sigma raised to >= 1.0 before detectEllipses() — the sharing was created by detectEdges() same write — edge_drawing.cpp:2718 4.5.2

Fix

smoothImage = srcImage.clone() at edge_drawing.cpp:411 gives smoothImage its own buffer where the sharing is created, so all four cases are covered by one line and no later stage has to know about it. Cost is one width*height copy per detectEdges() call when Sigma < 1.0.

Verification

TEST_F(ximgproc_ED, inputNotModifiedWithSigmaBelowOne) runs the four cases, one detector and one EXPECT per case. On 4.x it fails in all four cases (1916 / 1356 / 3618 / 1916 modified pixels); with the fix it passes. The other ximgproc_ED and ximgproc_FLD tests, including detectLinesAndEllipses, pass with and without the fix.

Effect on results

Compared with 4.x, results are identical for Sigma >= 1.0, and for Sigma < 1.0 with PFmode = false (checked on the test image and on 8 images of opencv/samples/data). With Sigma < 1.0 and PFmode = true, detectLines() / detectEllipses() results change (e.g. fruits.jpg: 351 → 347 lines, 2 → 1 ellipses), because validation now reads the unmodified input instead of the image that edge_drawing.cpp:430 had blurred in place.

Changes since the approval

  • edge_drawing.cpp: unchanged.
  • test_fld.cpp: detectEllipsesDoesNotModifyInput (case 1 only) is replaced with inputNotModifiedWithSigmaBelowOne (all four cases).

Fixes #4203

  • I agree to contribute to the project under Apache 2 License.
  • To the best of my knowledge, the proposed patch is not based on a code under GPL or another license that is incompatible with OpenCV
  • The PR is proposed to the proper branch
  • There is a reference to the original bug report and related work
  • There is accuracy test, performance test and test data in the repository, if applicable
  • The feature is well documented and sample code can be built with the project CMake

… 1.0

When Params::Sigma < 1.0, detectEdges() set `smoothImage = srcImage`, aliasing
the caller's input Mat. detectEllipses() then runs an in-place GaussianBlur into
smoothImage (the PFmode path in detectEdges() does the same), silently
overwriting the user's input image and any later reads of it. Clone the image so
the input stays read-only, matching the reference implementation ED_Lib.

Adds a regression test that fails before this change (1916 pixels of the input
were modified) and passes after it.
@purehol
purehol marked this pull request as ready for review September 2, 2026 15:36
@asmorkalov asmorkalov self-assigned this Sep 18, 2026
@sturkmen72

Copy link
Copy Markdown
Contributor
        if (params.Sigma < 1.0)
            smoothImage = srcImage.clone(); // must not alias the caller's input: the PFmode path and detectEllipses() blur into smoothImage in place
        else if (params.Sigma == 1.0)
            GaussianBlur(srcImage, smoothImage, Size(5, 5), params.Sigma);
        else
            GaussianBlur(srcImage, smoothImage, Size(), params.Sigma); // calculate kernel from sigma

        smoothImg = smoothImage.data;

imho no need to clone srcImage there is no code to change srcImage

@purehol

purehol commented Oct 13, 2026 •

Copy link
Copy Markdown
Author

Thanks for taking a look, @sturkmen72. You're right that nothing writes to srcImage by name. The problem is buffer sharing: srcImage = src.getMat() (L396) is a header on the caller's buffer, and for 8-bit single-channel input with Sigma < 1.0, smoothImage = srcImage; (L411) makes smoothImage a second header on that same buffer. Any later write into smoothImage therefore lands in the caller's image.

The caller's image is modified when all of the following hold:

  • the input is 8-bit single-channel (grayscale, CV_8UC1);
  • Sigma < 1.0. Any such value (0, 0.5, ...) behaves the same, because the value itself is not used on this path;
  • one of the two writes below is executed.
Code (current 4.x) Function Since Executed when
GaussianBlur(srcImage, smoothImage, Size(), 0.50) (L2718) detectEllipses() 4.5.2 (first 4.x release with EdgeDrawing) detectEllipses() is called. Each call blurs the input again.
GaussianBlur(srcImage, smoothImage, Size(), 0.4) (L430) detectEdges() 4.12.0 PFmode = true. detectEdges() alone already modifies the input.

Sigma >= 1.0 (including the default 1.0) and color input (CV_8UC3/CV_8UC4) are not affected, since smoothImage is then allocated by GaussianBlur and owned by the detector.

The problem can be reproduced with the C++ code in #4203, or with the Python code below (tested with the official opencv-contrib-python wheels 4.8.1, 4.10, 4.13 and 4.14). Both only simulate part of the scenarios above: grayscale input with Sigma = 0 and PFmode = false, and a single detectEllipses() call.

import numpy as np, cv2

img = np.full((200, 200), 255, np.uint8)
cv2.circle(img, (100, 100), 60, 0, 2)
backup = img.copy()

ed = cv2.ximgproc.createEdgeDrawing()
p = cv2.ximgproc.EdgeDrawing.Params()
p.Sigma = 0.0
ed.setParams(p)
ed.detectEdges(img)
ed.detectEllipses()

print(np.count_nonzero(img != backup))                              # 1916
print(np.array_equal(img, cv2.GaussianBlur(backup, (0, 0), 0.5)))   # True

It also affects the detector's own results. ValidateLineSegments() and ValidateLineSegmentRect() compute gradients from srcImg, which no longer holds the input after the blur. On samples/data/fruits.jpg with Sigma = 0, detectLines() finds 498 lines when called first and 526 when called after detectEllipses(). With this patch, or with Sigma >= 1.0, the call order makes no difference.

About the fix: srcImg (L397) and smoothImg (L417) are cached raw pointers that later stages read, so cloning at L411 keeps both valid with a one-line change. It costs one image copy, and only when Sigma < 1.0. One side effect: with PFmode = true and Sigma < 1.0, results change slightly (on fruits.jpg: 351 → 347 lines, 2 → 1 ellipses), because validation now sees the same data as in the Sigma >= 1.0 path.

@sturkmen72

Copy link
Copy Markdown
Contributor

https://github.com/purehol/opencv_contrib/blob/6b5b73a05c01753f6870d4f06b080de647019a6f/modules/ximgproc/src/edge_drawing.cpp#L2718
This line of code probably modifies the source image.
let me take a deep look if there is a better solution without cloning the source image

@sturkmen72

Copy link
Copy Markdown
Contributor

With Sigma < 1.0 smoothImage shared the caller's buffer. opencv#4203 named two
writes into that buffer, the GaussianBlur() in detectEllipses() and the one
in the PFmode branch of detectEdges(), but only the first had a test. Two
more cases turned up while checking the remaining uses of smoothImage: the
next detectEdges() with Sigma >= 1.0 on the same detector blurred into the
still shared buffer, i.e. into the previous input image, and raising Sigma
before detectEllipses() did not prevent the write, since the sharing was
created by detectEdges(). Replace the single-case test by one that runs the
four cases of the opencv#4203 reproducer, so that a fix has to cover all of them.
@purehol

purehol commented Oct 16, 2026

Copy link
Copy Markdown
Author

@sturkmen72 thanks for the review. #4203 and the description name two places that blur into the shared buffer, detectEllipses() (edge_drawing.cpp:2718) and the PFmode branch of detectEdges() (edge_drawing.cpp:430), but the test covered only the first. Your question whether the clone is needed made me check the remaining writes into smoothImage and the alternatives to the clone. Two things changed as a result, neither of which sends a notification, so I list them here:

  • the description of EdgeDrawing modifies the input image in place when Params::Sigma < 1.0 #4203 was edited (2026-10-16): the reproducer now runs four cases and prints 1916 / 1356 / 3618 / 1916 modified pixels on the unpatched code. Case 3 is the next detectEdges() with Sigma >= 1.0 writing into the previous input (edge_drawing.cpp:413-415); case 4 is case 1 with Sigma raised to 1.0 before detectEllipses();
  • the test of this PR has been extended to the same four cases (inputNotModifiedWithSigmaBelowOne, purehol/opencv_contrib@ba5b7f5, on top of this PR's commit). I will push it here once the approach is settled, so that the PR does not change twice; the fix itself is unchanged.

Starting from your guard at edge_drawing.cpp:2718, I built the variants below against the same tree (each branch is 4.x plus edge_drawing.cpp and the four-case test) and ran the reproducer and the test on each:

Sigma guard at :2718 (sturkmen72/opencv_contrib@61623c1) Sigma guards at :2718 and :430, smoothImage.release() at :396 (purehol/opencv_contrib@4e5c612) same, but the guards test smoothImage.data != srcImage.data (purehol/opencv_contrib@e5b29af) smoothImage.release() at :396 and before the blurs at :430 and :2718, smoothImg refreshed (purehol/opencv_contrib@9c3b515) clone at :411 (this PR)
reproducer cases 1–4, modified px 0 / 1356 / 3618 / 1916 0 / 0 / 0 / 1916 0 / 0 / 0 / 0 0 / 0 / 0 / 0 0 / 0 / 0 / 0
test inputNotModifiedWithSigmaBelowOne fails, cases 2–4 fails, case 4 passes passes passes
changes 1 place 3 places 3 places 3 places + 2 pointer refreshes 1 place
extra copy with Sigma < 1.0 none none none only when a write happens (PFmode or detectEllipses()) one per detectEdges()

A guard keyed on params.Sigma writes in case 4 because the sharing is created by the first detectEdges() while the guard only sees the current value; in 4.x, edge_drawing.cpp:2718 does not depend on Sigma at all, so this dependency is new. The guards also skip the blur: 4.x has always run ValidateCircles() on a 0.50-blurred copy of the input (edge_drawing.cpp:2718, since 4.5.2) and the PF validation on a 0.4-blurred one (edge_drawing.cpp:430, since 4.12.0), and with the guards they run on the unsmoothed image whenever Sigma < 1.0, so the detection result changes along with the fix. The release variant keeps the blur and gives the later stages the same data as the clone. I kept the clone because it is one line at the place where the sharing is created, needs no knowledge of the later writes, and has no raw pointer to keep in sync.

One correction to my comment of 13 October: I wrote that Sigma >= 1.0 is not affected. That holds for the image passed in that call, but a detector that was used once with Sigma < 1.0 still has smoothImage sharing that earlier image, so the next detectEdges() with Sigma >= 1.0 on a same-size image blurs the earlier image in place (case 3 of the reproducer).

You know this code much better than I do, so before I push the test I would like to ask for your advice: of the variants above, which one would you prefer to see in this PR? Each branch builds and runs the four-case test as it is, so any of them can be checked directly.

@sturkmen72

Copy link
Copy Markdown
Contributor

@purehol thank you for detailed analysis. i missed blurring when PFmode = true
i am OK for

        if (params.Sigma < 1.0)
            smoothImage = srcImage.clone();

it seems solves all complexity

@purehol

purehol commented Oct 19, 2026

Copy link
Copy Markdown
Author

@sturkmen72 thank you for the review.

I pushed the test as a second commit (purehol/opencv_contrib@ba5b7f5): inputNotModifiedWithSigmaBelowOne covers all four cases and replaces detectEllipsesDoesNotModifyInput; edge_drawing.cpp is unchanged. The description is updated accordingly.

@asmorkalov the new commit needs workflow approval before CI can run. Could you approve it?

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants