Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Broken buffer detection - contrast method #94

Open
wants to merge 6 commits into
base: feat-preprocess
Choose a base branch
from

Conversation

MarcelMB
Copy link
Contributor

@MarcelMB MarcelMB commented Jan 16, 2025

Takuyas approach was:

  • Detect broken buffers by comparing buffers with the same position buffer in the previous frame (I'm only making mean error now).
  • Remove frames that have broken buffers. These broken frames are individually stacked and tracked to examine which frame got removed.

This method works fine most of the time. But has an issue with the data we recorded in December. Because the previous frame is often also broken.

I added another method: block_contrast

  • Broken buffers typically have higher contrast. Applying local contrast detection to identify regions with unusually bright or dark pixels could be helpful

  • a broken buffer looks like this (black&white pixels), and therefore has a high contrast, buffers with 'real' neural images don't have this very high contrast:
    12222

  • detect regions with high contrast on a block-by-block basis (not for the entire frame, that didn't work so well when I tried this), each block represents the size of a buffer

  • The frame is divided into non-overlapping blocks/buffers.
    • Each block is analyzed independently for contrast.

  • For each block, the standard deviation of pixel intensities is calculated.
    • If the standard deviation (contrast) exceeds the threshold, the block is flagged as noisy.

worked well with test data and data from December

Its a relatively small PR and I tried to stick strictly to how the code is organized at the moment. And only added one other method for filtering broken buffers. So could be merged easily.


📚 Documentation preview 📚: https://miniscope-io--94.org.readthedocs.build/en/94/

@MarcelMB MarcelMB self-assigned this Jan 16, 2025
Copy link
Collaborator

@t-sasatani t-sasatani left a comment

Choose a reason for hiding this comment

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

Nice! We can examine the dropped frames with the root denoise branch update. It'll be interesting to compare what this and mean_error drops.

One processing-wise concern is that the comparison unit doesn't match the buffer shapes in data transfer and is redefining an original block shape. I commented more about this inline. My guess is that with these blocks there should be more false-positive/false-negatives depending on the threshold, but I might be wrong.

Another request is to add some tests and do linting if possible, but as this isn't headed to the main branch, we can also take care of that later.

Comment on lines -158 to -160
self.diff_frames.append(
cv2.absdiff(input_frame, self.previous_frame)
* self.noise_patch_config.diff_multiply
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could you leave these so it doesn't break the mean version functions? It's my bad if the primitive tests don't detect this, so we need to update that too.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this has somewhat changed with the unified mode now that I have implemented

)
else:
raise ValueError(f"Unsupported noise detection method: {self.noise_patch_config.method}")
Copy link
Collaborator

Choose a reason for hiding this comment

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

It may be better to validate in the denoise config model. If you can switch, that'll be great, but if not, it's not a big deal so we can leave it.

Comment on lines 142 to 144
# Use buffer_size to calculate the height of each block
block_height = noise_patch_config.buffer_size // width # Block spans entire width
block_height = max(1, block_height) # Ensure at least one row per block
Copy link
Collaborator

Choose a reason for hiding this comment

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

My intention in using the buffer size and chunking it up in the mean_error method was to match the comparison block with the shape of the buffers because errors are likely to occur within a buffer unit.

I guess you're using buffer_size for the same reason, but I think you need to serialize the frame so we can chunk it in the way it's done in data transfer. This is done in the mean_error method so I think it's worth looking into it.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Or might it be better to unify it with the mean_error method as a single noise detection method and make it run different detection functions based on the input options? That way, it's already chunked in buffers (communication packets), and we can also visualize the areas within the frame that the detector determined are noisy, which the mean stuff is doing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good.
I will change it to have a noise detection logic so that there’s one entry point (detect_frame_with_noisy_buffer).
Inside this method:
• Serialize the frame into chunks (buffers).
• Based on the configuration (e.g., method: mean_error or method: block_contrast), run the appropriate detection function on those chunks.

@@ -121,6 +121,53 @@ def detect_frame_with_noisy_buffer(
noise_output = np.concatenate(noisy_parts)[: self.height * self.width]
noise_patch = noise_output.reshape(self.width, self.height)
return any_buffer_has_noise, np.uint8(noise_patch)

def detect_frame_with_block_contrast(
Copy link
Collaborator

@t-sasatani t-sasatani Jan 16, 2025

Choose a reason for hiding this comment

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

Maybe standard deviation or SD to be specific?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

changed to: def _detect_with_block_contrast_SD

@MarcelMB
Copy link
Contributor Author

main new commit change:
I unified the
• Serialize the frame into chunks (buffers).
• Based on the configuration (e.g., method: mean_error or method: block_contrast), run the appropriate detection function on those chunks
as Takuya suggested

needed to change mean_error buffer_split: 10 to 8 because it didn't work that it cut the buffer into 10 smaller pieces but only up to 8, 8 is the amount of splits/chunks/buffers for the 200x200

included some logging for debug as well

@t-sasatani
Copy link
Collaborator

What do you mean when you say buffer_split 10 doesn't work? Does it just not detect errors correctly or does it get an error? (if It's an error what kind?)

Comment on lines +185 to +186
# Slide through the frame vertically in block_height steps
for y in range(0, height, block_height):
Copy link
Collaborator

Choose a reason for hiding this comment

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

not sure why we are using a different splitting method here? we already have split_current passed to us (but unused).

logger.debug("Previous frame is None.")

buffer_size = noise_patch_config.buffer_size
split_current = self.split_by_length(serialized_current, buffer_size)
Copy link
Collaborator

Choose a reason for hiding this comment

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

this seems to be a different value for the second argument buffer_size compared to before buffer_size // buffer_split + 1 - does this affect the other method?

if noise_patch_config.method == "mean_error" and previous_frame is not None:
return self._detect_with_mean_error(split_current, split_previous, noise_patch_config)
elif noise_patch_config.method == "block_contrast":
return self._detect_with_block_contrast_SD(split_current, current_frame, buffer_size, noise_patch_config)
Copy link
Collaborator

@sneakers-the-rat sneakers-the-rat Jan 17, 2025

Choose a reason for hiding this comment

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

not sure why buffer_size is split out as a separate param when we are passing the config object anyway, seems like the signature here should be just (current_frame, noise_patch_config) (or (split_current, noise_patch_config) if there isn't a reason to have a different splitting method)

continue

mean_intensity = np.mean(block)
std_intensity = np.std(block)
Copy link
Collaborator

@sneakers-the-rat sneakers-the-rat Jan 17, 2025

Choose a reason for hiding this comment

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

I think what we want here is not the standard deviation of the whole block, but of neighboring pixels. otherwise it seems like this would be tripped by an uncorrupted buffer that just has a very bright region and a very dark region.

For example this image:
Screenshot 2025-01-16 at 4 28 04 PM

has a standard deviation of 112.7

and this image

Screenshot 2025-01-16 at 4 28 43 PM

has a standard deviation of 127.5

and i can get the donut image to have the same standard deviation by increasing the size of the donut until half the pixels are 1 and half the pixels are 0.

If we however use the second derivative (in this case over just the -1th axis, but you could also average the diffs over x and y) they are easily distinguishable.

>>> # the random image
>>> np.mean(np.diff(np.diff(speckle)))
np.float64(95.5089898989899)
>>> # the donut image
>>> np.mean(np.diff(np.diff(donut)))
np.float64(2.87030303030303)

Screenshot 2025-01-16 at 4 52 18 PM

Screenshot 2025-01-16 at 4 52 44 PM

Copy link
Collaborator

Choose a reason for hiding this comment

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

Sort of related note. We just chatted that we'll probably need to combine detection methods because there are two modes of broken buffers now: (a) sandstorm and (b) all black (not showing up here, but this happens if the preamble or header is missed). SD won't be good for detecting the latter, and the mean error comparison needs two almost valid frames, so we'll need a fusion of these methods.

Doesn't have to be this PR, but we'll eventually have to combine these or think of a better detection method to reduce false positives/negatives.

Copy link
Collaborator

Choose a reason for hiding this comment

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

agreed on having several, separable methods rather than one huge complicated one

block_height = buffer_size // width # Block spans the entire width
block_height = max(1, block_height) # Ensure at least one row per block

noisy_mask = np.zeros_like(current_frame, dtype=np.uint8)
Copy link
Collaborator

Choose a reason for hiding this comment

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

seems like it could be dtype=bool for memory efficiency

Copy link
Collaborator

@sneakers-the-rat sneakers-the-rat left a comment

Choose a reason for hiding this comment

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

Since we're merging this into the preprocessing branch, and i figure we'll need further work there on refactoring these into separable classes, not commenting on the need for that here, but we do need tests for this - two kinds would be ideal:

  • naturalistic, with a short video segment where we have "ground truth" labels for buffers/ known to be corrupted - confirm that we label those and only those labels as corrupted
  • unitlike, where we generate a frame with a normal image in it (like that donut image) and then randomly corrupt some buffer-shaped segment within it

I also think we need to not just use plain stdev as i said in a comment bc it's not very specific to the corruption we're filtering for, proposed an example alternative in comments

@sneakers-the-rat
Copy link
Collaborator

sneakers-the-rat commented Jan 17, 2025

ok I linted so the tests would run. @MarcelMB check out https://miniscope-io.readthedocs.io/en/latest/meta/contributing.html#linting - your IDE should be checking this for you (it's way less annoying that way to have the IDE warn you about it as you're writing and do the autofixes), but otherwise just run pdm run format or install pre-commit like pip install pre-commit and then do pre-commit install while in the mio directory to automatically run it before committing

edit: ope i was thinking of another repo, we don't have tests dependent on code quality checks here, it's the PR not being to main. i'll fix that one sec

@sneakers-the-rat
Copy link
Collaborator

added tests with a sample video (very small, just 60 frame segment) with a lot of the speckle noise error of varying sizes. Currently the tests fail because we miss 5 of the frames with smaller patches. I think the more sensitive method described above would let us set a much lower threshold so we could catch those.

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.

3 participants