-
Notifications
You must be signed in to change notification settings - Fork 321
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
[py-tx] embeded tx hash for unletterboxing #1684
base: main
Are you sure you want to change the base?
[py-tx] embeded tx hash for unletterboxing #1684
Conversation
5944b03
to
926801e
Compare
Before I look at the code, thank you for the very comprehensive summary and test writeup. You could probably even skimp a little bit in future PRs! I also like that you looked for other potential improvements that made sense in the area. I was considering suggesting a --save option during @haianhng31 --rotation diffs as well. |
Of course, thank you! I can also update the save output option and file output for the rotation if you would like. |
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.
As mentioned, thanks for the strong summary writeup.
Toplevel things:
- I think there's a /data/ directory in python-threatexchange. We've also previously loaded test images from the pdq/data directory - there's a trick you can use to find relative imports from the current file path you can see in a couple of tests. What do you think about synthesizing a new letterboxed bridge photo from the bridge-mods directory and use the meta logo only for the manual testing for this diff? Why - I don't want folks to be concerned about image rights, and the bridge-mods are only for the purpose of this repo.
- I think we should simplify the version of the interface we use on PhotoContent for now, and move the specific letterboxing into its own directory under content_type, or at least its own file. I forsee us adding more preprocessing tricks like this in the future.
- Can you explain more how you came to choose the thresholds, .e.g 40?
I found this random library that does something similar - you might want to give their code a read for ideas: https://github.com/Animenosekai/bordercrop/blob/main/bordercrop/bordercrop.py, though as mentioned we are trying to limit the number of external dependencies, otherwise we could even just use the library as is. We don't need all the features they have.
Overall strong work, thanks for the effort put in!
ap.add_argument( | ||
"--rotations", |
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.
(no changes needed): Do you think we should combine rotations into your generalization of preprocessing?
Alternatively, what do you think about making rotation and unletterboxing mutually exclusive?
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.
Yes, I thought that it made the most sense to combine but didn't want to jump ahead and do it beforehand but I can add it to the list of actions.
Also, would there ever be a workflow where someone may want to both check for rotation and process the image for letterboxing?
type=bool, | ||
default=False, |
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.
nit: If you make the action store_true
then the default is false IIRC.
@@ -118,7 +144,17 @@ def execute(self, settings: CLISettings) -> None: | |||
if not self.rotations: | |||
for file in self.files: | |||
for hasher in hashers: | |||
hash_str = hasher.hash_from_file(file) | |||
if isinstance(hasher, PdqSignal) and ( |
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.
Why only PdqSignal? Wouldn't other image perceptual hashing algorithms benefit from this?
We also generally want to avoid places where we do isinstance(<interface_implementation>)
in preference of it being handled by the interface itself.
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.
Okay, I thought it was specific to PdqSignal and as for why I bypassed the interface it is because they do not all have the method hash from bytes and I did not always want to create the new file with updated images bytes. Is there a way I can go around this or would it be better to always create the new file even if temporarily, and then save the output if the user passes the flag to save it?
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.
It's not specific to PdqSignal, and the hash_from_bytes method is itself part of a wider interface.
I think I eventually decided it was simpler to write everything to tmpfiles, which is how we ended up with the current implementation.
However, similar to the feedback I gave for --rotations, we can make our life a lot easier by having the preprocessing happen in between the file input and the for file in self.files
.
I like your idea of providing a way to pass flag to save it, but let's save that for a followup.
@@ -118,7 +144,17 @@ def execute(self, settings: CLISettings) -> None: | |||
if not self.rotations: | |||
for file in self.files: | |||
for hasher in hashers: | |||
hash_str = hasher.hash_from_file(file) | |||
if isinstance(hasher, PdqSignal) and ( | |||
self.content_type.get_name() == "photo" |
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.
Hmm, more specialization.
|
||
@classmethod | ||
def detect_top_border( | ||
cls, grayscale_img: Image.Image, black_threshold: int = 10 | ||
) -> int: | ||
""" | ||
Detect the top black border by counting rows with only black pixels. | ||
Uses a defualt black threshold of 10 so that only rows with pixel brightness | ||
of 10 or lower will be removed. | ||
|
||
Returns the first row that is not all blacked out from the top. | ||
""" | ||
width, height = grayscale_img.size | ||
for y in range(height): | ||
row_pixels = list(grayscale_img.crop((0, y, width, y + 1)).getdata()) | ||
if all(pixel < black_threshold for pixel in row_pixels): | ||
continue | ||
return y | ||
return height | ||
|
||
@classmethod | ||
def detect_bottom_border( | ||
cls, grayscale_img: Image.Image, black_threshold: int = 10 | ||
) -> int: | ||
""" | ||
Detect the bottom black border by counting rows with only black pixels from the bottom up. | ||
Uses a defualt black threshold of 10 so that only rows with pixel brightness | ||
of 10 or lower will be removed. | ||
|
||
Returns the first row that is not all blacked out from the bottom. | ||
""" | ||
width, height = grayscale_img.size | ||
for y in range(height - 1, -1, -1): | ||
row_pixels = list(grayscale_img.crop((0, y, width, y + 1)).getdata()) | ||
if all(pixel < black_threshold for pixel in row_pixels): | ||
continue | ||
return height - y - 1 | ||
return height | ||
|
||
@classmethod | ||
def detect_left_border( | ||
cls, grayscale_img: Image.Image, black_threshold: int = 10 | ||
) -> int: | ||
""" | ||
Detect the left black border by counting columns with only black pixels. | ||
Uses a defualt black threshold of 10 so that only colums with pixel brightness | ||
of 10 or lower will be removed. | ||
|
||
Returns the first column from the left that is not all blacked out in the column. | ||
""" | ||
width, height = grayscale_img.size | ||
for x in range(width): | ||
col_pixels = list(grayscale_img.crop((x, 0, x + 1, height)).getdata()) | ||
if all(pixel < black_threshold for pixel in col_pixels): | ||
continue | ||
return x | ||
return width | ||
|
||
@classmethod | ||
def detect_right_border( | ||
cls, grayscale_img: Image.Image, black_threshold: int = 10 | ||
) -> int: | ||
""" | ||
Detect the right black border by counting columns with only black pixels from the right. | ||
Uses a defualt black threshold of 10 so that only colums with pixel brightness | ||
of 10 or lower will be removed. | ||
|
||
Returns the first column from the right that is not all blacked out in the column. | ||
""" | ||
width, height = grayscale_img.size | ||
for x in range(width - 1, -1, -1): | ||
col_pixels = list(grayscale_img.crop((x, 0, x + 1, height)).getdata()) | ||
if all(pixel < black_threshold for pixel in col_pixels): | ||
continue | ||
return width - x - 1 | ||
return width |
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.
By putting these all at the top level, we are signaling that they are part of the "official" interface for photos.
Instead, let's move this functionality into its own file in a new /preprocessing
directory. We can add unletterbox.py
as its own module, with these 4 methods then as standalone.
|
||
@classmethod | ||
def unletterbox( | ||
cls, file_path: Path, save_output: bool = False, black_threshold: int = 40 |
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.
blocking: Instead of making save_output an argument here, it might be better to compose it from the outside by taking the bytes, which will give the caller more control over the file directory.
blocking q: Can you tell more about how you picked 40? We may want to be very conservative by default (even to only 100% black).
|
||
Then removing the edges to give back a cleaned image bytes. | ||
|
||
Return the new hash of the cleaned image with an option to create a new output file as well |
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 don't think this returns the hash, no?
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.
Ahh no I had it returning the hash at first but then it created a circular dependency so I removed it but did not update the comment. I will update.
|
||
# Convert the cropped image to bytes for hashing | ||
with io.BytesIO() as buffer: | ||
cropped_img.save(buffer, format=img.format) |
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.
Why .img? Should we use the same format that was passed in?
""" | ||
# Open the original image | ||
with Image.open(file_path) as img: | ||
grayscale_img = img.convert("L") |
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.
blocking q: Hmm, why convert to grayscale first? Won't think convert some full colors to black?
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 revised this and updated to check each individual value of the r g b pixel
I will update the images. |
This was super helpful I can go ahead and use the pillow library and implement it in a similar format for simplicity of dependencies and cleaner code. It should work the same way. However, if you would like me to go ahead and use this library I can do that as well. |
Also, I looked more into it and this focuses on also being able to address image URL types and more than the basic image format that we are using. If that is by design I can add the pass-through to do that. |
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.
Some response to comments - use "request review" in the upper right hand of the summary page to send it back to me for review (little refresh logo).
@@ -118,7 +144,17 @@ def execute(self, settings: CLISettings) -> None: | |||
if not self.rotations: | |||
for file in self.files: | |||
for hasher in hashers: | |||
hash_str = hasher.hash_from_file(file) | |||
if isinstance(hasher, PdqSignal) and ( |
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.
It's not specific to PdqSignal, and the hash_from_bytes method is itself part of a wider interface.
I think I eventually decided it was simpler to write everything to tmpfiles, which is how we ended up with the current implementation.
However, similar to the feedback I gave for --rotations, we can make our life a lot easier by having the preprocessing happen in between the file input and the for file in self.files
.
I like your idea of providing a way to pass flag to save it, but let's save that for a followup.
b50a0f9
to
01c2bf9
Compare
…ved source files for unboxing and pytest
01c2bf9
to
9dba274
Compare
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.
Getting closer!
blocking: Instead of adding the test file to /threatexchange, can you add it to https://github.com/facebook/ThreatExchange/tree/main/pdq/data/bridge-mods instead?
Because threatexchange is in the same repo as PDQ, you can read the pdq directory from ThreatExchange tests using the trick I noted inline with __file__
.
Optional / For your consideration: At the rate we are going, this feels like it will take a few more passes. You can simplify the PR by breaking out the changes to unletterboxing.py and photo to its own PR (the bottom/simplest part of the stack), though splitting a PR in git is pretty daunting for the first time. Here's a random article describing a method using cherry-pick, but there are others.
default=10, | ||
help=( | ||
"Set the black threshold for unletterboxing (default: 5)." |
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.
blocking: documentation for default seems off. There also might be a default argparse option that will display the default for you.
blocking q: Can you tell me how you chose 10 for this?
"--save-output", | ||
action="store_true", | ||
help="for photos, generate all 8 simple rotations", | ||
help="If true, saves the processed image as a new file.", |
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.
blocking: To help a user understand what this option does, suggest naming it --save-preprocess
Since store_true doesn't take an argument, suggest this as an alternative help:
save the preprocessed image data as new files
rotation_type = [] | ||
if self.photo_preprocess == "unletterbox": | ||
updated_bytes.append( | ||
PhotoContent.unletterbox(str(file), self.black_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.
fine as a followup: whoops, we should make unletterbox take a Path
object for consistency with the rest of the library
with tempfile.NamedTemporaryFile() as temp_file: | ||
temp_file.write(bytes_data) | ||
temp_file_path = pathlib.Path(temp_file.name) | ||
for hasher in hashers: | ||
hash_str = hasher.hash_from_file(temp_file_path) | ||
if hash_str: | ||
print(rotation_type.name, hasher.get_name(), hash_str) | ||
print( | ||
f"{rotation_type[idx].name if rotation_type else ''} {hasher.get_name()} {hash_str}" | ||
) | ||
if self.save_output: |
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.
ignorable: We can simplify this logic by using the delete=
keyword of NamedTemporaryfile: https://docs.python.org/3/library/tempfile.html#tempfile.NamedTemporaryFile
delete=not self.save_output
output_path = file.with_stem(f"{file.stem}{suffix}") | ||
with open(output_path, "wb") as output_file: | ||
output_file.write(bytes_data) | ||
print(f"Processed image saved to: {output_path}") |
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.
This might get a bit messy - you can include files from many locations. Additionally, do we know the format of the resulting image? Without the extension the file might not be usable.
cropped_img = image.crop((left, top, width - right, height - bottom)) | ||
|
||
with io.BytesIO() as buffer: | ||
cropped_img.save(buffer, format=image.format) |
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.
ignorable: Ah I see, we keep the original format, I like this choice.
@@ -0,0 +1,69 @@ | |||
from PIL import 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.
blocking: You need to add the Meta copyright header or I get a nagger add it myself - you can copy it from the other files
Check if each color channel in the pixel is below the threshold | ||
""" | ||
r, g, b = pixel | ||
return r < threshold and g < threshold and b < 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.
blocking q: Shouldn't this be <=
? Your default threshold is 0. Can it be negative?
from PIL import Image | ||
|
||
|
||
def is_pixel_black(pixel, 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.
blocking: missing typing
""" | ||
width, height = image.size | ||
for y in range(height): | ||
row_pixels = list(image.crop((0, y, width, y + 1)).getdata()) |
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 couldn't tell from reading the pillow docs, but can you use the returned core.image object as an iterator without wrapping it in a list?
Preprocessing and Hashing Enhancements in ThreatExchange CLI
This update introduces new preprocessing functionality to remove black letterbox borders from images before hashing. This pr resolves issue #1666. Key changes include an
unletterbox
method inphoto.py
, command-line enhancements inhash_cmd.py
for preprocessing control, and added tests intest_pdq_letterboxing.py
. Integrated with rotation to allow for preprocessing in brute force look-up as well.Summary of Changes
New
unletterbox
Method inphoto.py
The
unletterbox
method preprocesses images by removing black letterbox borders, with options for customization:Parameters:
black_threshold
: Sets the brightness threshold to detect black borders (default: 40).save_output
: IfTrue
, saves the unletterboxed image as a new file with_unletterboxed
appended to the filename.Returns: Cropped image bytes, enabling flexibility for in-memory processing or file-based hashing.
Updated
HashCommand
inhash_cmd.py
The
HashCommand
class now supports additional command-line arguments to control preprocessing:--preprocess
: Specifies preprocessing type, currentlyunletterbox
for black border removal androtations
see pr [pytx] Add --rotations to hash_cmd #1678 for more details.--black-threshold
: Sets brightness threshold for border detection, allowing sensitivity adjustments.--save-output
: saves the processed image as a new file, which is used for hashing. IfFalse
, hashing is done directly on the processed image bytes.These parameters enable better control over image processing for different workflows.
Testing in
test_pdq_letterboxing.py
Added a new unit test to python-threatexchange/threatexchange/tests/hashing/test_pdq_letterboxing.py to validate
unletterbox
functionality:test_unletterbox_image
: Confirms that unletterboxed image bytes match the expected PDQ hash.test_unletterboxfile_creates_output_file
: Verifies file creation whensave_output=True
and checks for file existence.These tests cover scenarios for both in-memory processing and output file creation workflows.
Usage Examples with Expected Hash Outputs
1. Basic Hashing without Preprocessing
2. Hashing with sensitivity threshold
Preprocessing
letterboxed.png
with Customblack-threshold
3. Hashing with In-Memory Preprocessing
Preprocessing
letterboxed.png
with Defaultblack-threshold=40
4. Hashing with Saved Output File
Saving Preprocessed Output with Default
black-threshold=40
Summary of Usage
--black-threshold
: Controls border detection sensitivity. Lower values increase sensitivity, while higher values decrease sensitivity.--save-output
: Saves the processed image as<original_file>_unletterboxed.png