Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
651 changes: 92 additions & 559 deletions doc/code/converters/0_converters.ipynb

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions doc/code/converters/0_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.17.3
# jupytext_version: 1.19.1
# kernelspec:
# display_name: pyrit-dev
# language: python
# name: python3
# ---

# %% [markdown]
Expand Down Expand Up @@ -70,7 +74,6 @@
# Converters can be used to perform these types of transformations. Here is a simple program that uses Rot13Converter converter, RandomCapitalLettersConverter, and AsciiArtConverter.

# %%

from pyrit.prompt_converter import (
AsciiArtConverter,
BinaryConverter,
Expand Down
179 changes: 160 additions & 19 deletions doc/code/converters/3_image_converters.ipynb

Large diffs are not rendered by default.

53 changes: 52 additions & 1 deletion doc/code/converters/3_image_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.17.3
# jupytext_version: 1.19.1
# ---

# %% [markdown]
Expand Down Expand Up @@ -116,6 +116,57 @@
compressed_img = Image.open(compressed_image.output_text)
display(compressed_img)

# %% [markdown]
# ### ImageColorSaturationConverter
#
# The `ImageColorSaturationConverter` adjusts the color saturation level of an image. A `level` of `0.0` (the default) converts to grayscale (black and white), `1.0` preserves original colors, and values greater than `1.0` oversaturate colors.

# %%
from pyrit.prompt_converter import ImageColorSaturationConverter

# Convert image to black and white (grayscale)
bw_converter = ImageColorSaturationConverter(level=0.0)
bw_result = await bw_converter.convert_async(prompt=image_location) # type: ignore

print(f"Black & white image saved to: {bw_result.output_text}")

bw_img = Image.open(bw_result.output_text)
display(bw_img)

# %% [markdown]
# ### ImageResizingConverter
#
# The `ImageResizingConverter` resizes an image by a given scale factor. The default is `0.5` (halve the size of the image).

# %%
from pyrit.prompt_converter import ImageResizingConverter

# Resize the image by a scale factor of 0.5
resize_converter = ImageResizingConverter(scale_factor=0.5)
resize_result = await resize_converter.convert_async(prompt=image_location) # type: ignore

print(f"Resized image saved to: {resize_result.output_text}")

resize_img = Image.open(resize_result.output_text)
display(resize_img)

# %% [markdown]
# ### ImageRotationConverter
#
# The `ImageRotationConverter` rotates an image by a given angle. The default is `90.0` (positive values rotate counter-clockwise).

# %%
from pyrit.prompt_converter import ImageRotationConverter

# Rotate the image by 90 degrees (counter-clockwise)
rotate_converter = ImageRotationConverter(angle=90.0)
rotate_result = await rotate_converter.convert_async(prompt=image_location) # type: ignore

print(f"Rotated image saved to: {rotate_result.output_text}")

rotate_img = Image.open(rotate_result.output_text)
display(rotate_img)

# %% [markdown]
# ### TransparencyAttackConverter
#
Expand Down
6 changes: 6 additions & 0 deletions pyrit/prompt_converter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@
from pyrit.prompt_converter.emoji_converter import EmojiConverter
from pyrit.prompt_converter.first_letter_converter import FirstLetterConverter
from pyrit.prompt_converter.flip_converter import FlipConverter
from pyrit.prompt_converter.image_color_saturation_converter import ImageColorSaturationConverter
from pyrit.prompt_converter.image_compression_converter import ImageCompressionConverter
from pyrit.prompt_converter.image_resizing_converter import ImageResizingConverter
from pyrit.prompt_converter.image_rotation_converter import ImageRotationConverter
from pyrit.prompt_converter.insert_punctuation_converter import InsertPunctuationConverter
from pyrit.prompt_converter.json_string_converter import JsonStringConverter
from pyrit.prompt_converter.leetspeak_converter import LeetspeakConverter
Expand Down Expand Up @@ -139,7 +142,10 @@
"EmojiConverter",
"FirstLetterConverter",
"FlipConverter",
"ImageColorSaturationConverter",
"ImageCompressionConverter",
"ImageResizingConverter",
"ImageRotationConverter",
"IndexSelectionStrategy",
"InsertPunctuationConverter",
"JsonStringConverter",
Expand Down
193 changes: 193 additions & 0 deletions pyrit/prompt_converter/image_color_saturation_converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

import base64
import logging
from io import BytesIO
from typing import Literal, Optional
from urllib.parse import urlparse

import aiohttp
from PIL import Image, ImageEnhance

from pyrit.identifiers import ComponentIdentifier
from pyrit.models import PromptDataType, data_serializer_factory
from pyrit.prompt_converter.prompt_converter import ConverterResult, PromptConverter

logger = logging.getLogger(__name__)


class ImageColorSaturationConverter(PromptConverter):
"""
Adjusts the color saturation level of an image.

This converter uses PIL's ImageEnhance.Color to adjust an image's color saturation.
A level of 0.0 produces a grayscale (black-and-white) image, 1.0 preserves the original
colors, and values greater than 1.0 oversaturate the colors.

When converting images with transparency (alpha channel) to JPEG format, the converter
automatically composites the transparent areas onto a solid background color.

Supported input types:
File paths to any image that PIL can open (or URLs pointing to such images):
https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#fully-supported-formats

Supported output formats:
JPEG, PNG, or WEBP. If not specified, defaults to JPEG.

References:
https://pillow.readthedocs.io/en/stable/handbook/concepts.html
https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg-saving
https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#png-saving
https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp-saving
"""

SUPPORTED_INPUT_TYPES = ("image_path", "url")
SUPPORTED_OUTPUT_TYPES = ("image_path",)

def __init__(
self,
*,
output_format: Optional[Literal["JPEG", "PNG", "WEBP"]] = None,
level: float = 0.0,
) -> None:
"""
Initialize the converter with the specified color saturation level and output format.

Args:
output_format (str, optional): Output image format.
Must be one of 'JPEG', 'PNG', or 'WEBP'.
If None, keeps original format (if supported).
level (float): The color saturation level.
0.0 produces a grayscale image (black and white).
1.0 preserves the original colors.
Values greater than 1.0 oversaturate the colors.
Defaults to 0.0 (grayscale image).

Raises:
ValueError: If unsupported output format is specified, or if level is negative.
"""
if level < 0:
raise ValueError(f"Level must be non-negative, got {level}")
self._level = level

if output_format and output_format not in ("JPEG", "PNG", "WEBP"):
raise ValueError("Output format must be one of 'JPEG', 'PNG', or 'WEBP'")
self._output_format = output_format

def _build_identifier(self) -> ComponentIdentifier:
"""
Build identifier with output format and color saturation level parameters.

Returns:
ComponentIdentifier: The identifier for this converter.
"""
return self._create_identifier(
params={
"output_format": self._output_format,
"level": self._level,
},
)

def _adjust_saturation(self, image: Image.Image, original_format: str) -> tuple[BytesIO, str]:
"""
Adjust the color saturation of the image. Returns the adjusted image bytes and output format.

Args:
image (PIL.Image.Image): The image to adjust.
original_format (str): The original format of the image.

Returns:
tuple[BytesIO, str]: A tuple containing the adjusted image bytes and the output format.
"""
original_format = original_format.upper()
output_format = self._output_format or (
original_format if original_format in ("JPEG", "PNG", "WEBP") else "JPEG"
)

logger.info(
f"Adjusting image color saturation level: original format={original_format}, output format={output_format}"
)

# Handle images with transparency when converting to JPEG
if output_format == "JPEG":
if image.has_transparency_data:
image = image.convert("RGBA")
background = Image.new("RGB", image.size, (255, 255, 255))
background.paste(image, mask=image.split()[-1])
image = background
else:
image = image.convert("RGB")

adjusted_image = ImageEnhance.Color(image).enhance(self._level)
adjusted_bytes = BytesIO() # in-memory buffer
adjusted_image.save(adjusted_bytes, output_format)
return adjusted_bytes, output_format

async def _read_image_from_url(self, url: str) -> bytes:
"""
Download data from a URL and return the content as bytes.

Args:
url (str): The URL to download the image from.

Returns:
bytes: The content of the image as bytes.

Raises:
RuntimeError: If there is an error during the download process.
"""
try:
async with aiohttp.ClientSession() as session, session.get(url) as response:
response.raise_for_status()
return await response.read()
except aiohttp.ClientError as e:
raise RuntimeError(f"Failed to download content from URL {url}: {str(e)}") from e

async def convert_async(self, *, prompt: str, input_type: PromptDataType = "image_path") -> ConverterResult:
"""
Convert the given prompt (image) by adjusting its color saturation level.

Args:
prompt (str): The image file path or URL pointing to the image to be adjusted.
input_type (PromptDataType): The type of input data.

Returns:
ConverterResult: The result containing the path to the adjusted image.

Raises:
ValueError: If the input type is not supported.
"""
if not self.input_supported(input_type):
raise ValueError(f"Input type '{input_type}' not supported")
if input_type == "url" and urlparse(prompt).scheme not in ("http", "https"):
raise ValueError(f"Invalid URL: {prompt}. Must start with 'http://' or 'https://'")

img_serializer = data_serializer_factory(category="prompt-memory-entries", value=prompt, data_type="image_path")

# Read the image data into memory as bytes for processing
original_img_bytes = (
await self._read_image_from_url(prompt) if input_type == "url" else await img_serializer.read_data()
)
original_img = Image.open(BytesIO(original_img_bytes))

original_format = original_img.format or "JPEG" # since PIL may not always provide a format

# Adjust the color saturation level of the image and get back a BytesIO buffer
# containing the adjusted data along with the actual output format used (which
# may differ from input format)
adjusted_bytes, output_format = self._adjust_saturation(original_img, original_format)
adjusted_bytes_value = adjusted_bytes.getvalue()

# This ensures the saved file has the correct extension for its actual format
# Only currently supported output formats are taken into account
format_extensions = {"JPEG": "jpeg", "PNG": "png", "WEBP": "webp"}
img_serializer.file_extension = format_extensions.get(output_format, "jpeg")

# Convert adjusted image to base64 for storage via the serializer
image_str = base64.b64encode(adjusted_bytes_value)
await img_serializer.save_b64_image(data=image_str.decode())

logger.info(f"Image color saturation level adjusted to {self._level}")

return ConverterResult(output_text=str(img_serializer.value), output_type="image_path")
Loading
Loading