├── .github └── workflows │ └── ruff.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── app.py ├── demo.gif ├── pyproject.toml └── streamlit_cropper ├── __init__.py └── frontend ├── .prettierrc ├── package-lock.json ├── package.json ├── public ├── bootstrap.min.css └── index.html ├── src ├── .env ├── StreamlitCropper.tsx ├── index.css ├── index.tsx ├── react-app-env.d.ts └── streamlit │ ├── StreamlitReact.tsx │ ├── index.tsx │ └── streamlit.ts └── tsconfig.json /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff 2 | on: [ pull_request ] 3 | jobs: 4 | ruff: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: astral-sh/ruff-action@v3 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | node_modules/ 29 | build/ 30 | 31 | # Ruff 32 | .ruff_cache/ 33 | 34 | # Environments 35 | venv/ 36 | .venv/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 The Python Packaging Authority 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include streamlit_cropper/frontend/build * 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Streamlit - Cropper 2 | 3 | A streamlit custom component for easy image cropping 4 | 5 |  6 | 7 | ## Installation 8 | 9 | ```shell script 10 | pip install streamlit-cropper 11 | ``` 12 | 13 | ## Example Usage 14 | 15 | ```python 16 | import streamlit as st 17 | from streamlit_cropper import st_cropper 18 | from PIL import Image 19 | st.set_option('deprecation.showfileUploaderEncoding', False) 20 | 21 | # Upload an image and set some options for demo purposes 22 | st.header("Cropper Demo") 23 | img_file = st.sidebar.file_uploader(label='Upload a file', type=['png', 'jpg']) 24 | realtime_update = st.sidebar.checkbox(label="Update in Real Time", value=True) 25 | box_color = st.sidebar.color_picker(label="Box Color", value='#0000FF') 26 | aspect_choice = st.sidebar.radio(label="Aspect Ratio", options=["1:1", "16:9", "4:3", "2:3", "Free"]) 27 | aspect_dict = { 28 | "1:1": (1, 1), 29 | "16:9": (16, 9), 30 | "4:3": (4, 3), 31 | "2:3": (2, 3), 32 | "Free": None 33 | } 34 | aspect_ratio = aspect_dict[aspect_choice] 35 | 36 | if img_file: 37 | img = Image.open(img_file) 38 | if not realtime_update: 39 | st.write("Double click to save crop") 40 | # Get a cropped image from the frontend 41 | cropped_img = st_cropper(img, realtime_update=realtime_update, box_color=box_color, 42 | aspect_ratio=aspect_ratio) 43 | 44 | # Manipulate cropped image at will 45 | st.write("Preview") 46 | _ = cropped_img.thumbnail((150,150)) 47 | st.image(cropped_img) 48 | ``` 49 | 50 | ## References 51 | 52 | - [streamlit-drawable-canvas](https://github.com/andfanilo/streamlit-drawable-canvas) 53 | 54 | ## Acknowledgments 55 | 56 | Big thanks to zoncrd and yanirs for their contributions -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import streamlit as st 2 | import numpy as np 3 | from streamlit_cropper import st_cropper 4 | from PIL import Image 5 | from io import BytesIO 6 | 7 | # Upload an image and set some options for demo purposes 8 | st.header("Cropper Demo") 9 | img_file = st.sidebar.file_uploader(label='Upload a file', type=['png', 'jpg']) 10 | realtime_update = st.sidebar.checkbox(label="Update in Real Time", value=True) 11 | box_color = st.sidebar.color_picker(label="Box Color", value='#0000FF') 12 | stroke_width = st.sidebar.number_input(label="Box Thickness", value=3, step=1) 13 | 14 | aspect_choice = st.sidebar.radio(label="Aspect Ratio", options=["1:1", "16:9", "4:3", "2:3", "Free"]) 15 | aspect_dict = { 16 | "1:1": (1, 1), 17 | "16:9": (16, 9), 18 | "4:3": (4, 3), 19 | "2:3": (2, 3), 20 | "Free": None 21 | } 22 | aspect_ratio = aspect_dict[aspect_choice] 23 | 24 | return_type_choice = st.sidebar.radio(label="Return type", options=["Cropped image", "Rect coords"]) 25 | return_type_dict = { 26 | "Cropped image": "image", 27 | "Rect coords": "box" 28 | } 29 | return_type = return_type_dict[return_type_choice] 30 | 31 | if img_file: 32 | img = Image.open(img_file) 33 | if not realtime_update: 34 | st.write("Double click to save crop") 35 | if return_type == 'box': 36 | rect = st_cropper( 37 | img, 38 | realtime_update=realtime_update, 39 | box_color=box_color, 40 | aspect_ratio=aspect_ratio, 41 | return_type=return_type, 42 | stroke_width=stroke_width 43 | ) 44 | raw_image = np.asarray(img).astype('uint8') 45 | left, top, width, height = tuple(map(int, rect.values())) 46 | st.write(rect) 47 | masked_image = np.zeros(raw_image.shape, dtype='uint8') 48 | masked_image[top:top + height, left:left + width] = raw_image[top:top + height, left:left + width] 49 | st.image(Image.fromarray(masked_image), caption='masked image') 50 | else: 51 | # Get a cropped image from the frontend 52 | cropped_img = st_cropper( 53 | img, 54 | realtime_update=realtime_update, 55 | box_color=box_color, 56 | aspect_ratio=aspect_ratio, 57 | return_type=return_type, 58 | stroke_width=stroke_width 59 | ) 60 | 61 | # Manipulate cropped image at will 62 | st.write("Preview") 63 | _ = cropped_img.thumbnail((150, 150)) 64 | st.image(cropped_img) 65 | 66 | # Save the cropped image to a BytesIO buffer in PNG format 67 | buf = BytesIO() 68 | cropped_img.save(buf, format="PNG") 69 | buf.seek(0) 70 | st.download_button( 71 | label="Download Cropped Image", 72 | data=buf, 73 | file_name="cropped_image.png", 74 | mime="image/png" 75 | ) -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turner-anderson/streamlit-cropper/41b7d0381da678b43b5da2ad4a633fc05c7bee82/demo.gif -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name="streamlit-cropper" 3 | version="0.3.1" 4 | authors=[{name="Turner Anderson", email="andersontur11@gmail.com"}] 5 | description="A simple image cropper for Streamlit" 6 | readme="README.md" 7 | requires-python=">=3.10" 8 | classifiers = [ 9 | "Programming Language :: Python :: 3", 10 | "License :: OSI Approved :: MIT License", 11 | "Operating System :: OS Independent", 12 | ] 13 | dependencies = [ 14 | "streamlit", 15 | "Pillow", 16 | "numpy" 17 | ] 18 | 19 | [project.urls] 20 | "Homepage" = "https://github.com/turner-anderson/streamlit-cropper" 21 | "Bug Tracker" = "https://github.com/turner-anderson/streamlit-cropper/issues" 22 | 23 | [build-system] 24 | requires = ["setuptools>=61.0"] 25 | build-backend = "setuptools.build_meta" 26 | 27 | [tool.ruff] 28 | exclude = [ 29 | ".bzr", 30 | ".direnv", 31 | ".eggs", 32 | ".git", 33 | ".git-rewrite", 34 | ".hg", 35 | ".ipynb_checkpoints", 36 | ".mypy_cache", 37 | ".nox", 38 | ".pants.d", 39 | ".pyenv", 40 | ".pytest_cache", 41 | ".pytype", 42 | ".ruff_cache", 43 | ".svn", 44 | ".tox", 45 | ".venv", 46 | ".vscode", 47 | "__pypackages__", 48 | "_build", 49 | "buck-out", 50 | "build", 51 | "dist", 52 | "node_modules", 53 | "site-packages", 54 | "venv", 55 | ] 56 | line-length = 120 57 | indent-width = 4 -------------------------------------------------------------------------------- /streamlit_cropper/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import streamlit.components.v1 as components 3 | from PIL import Image 4 | from typing import Optional 5 | import numpy as np 6 | import io 7 | import base64 8 | 9 | _RELEASE = True 10 | 11 | if not _RELEASE: 12 | _component_func = components.declare_component( 13 | "st_cropper", 14 | url="http://localhost:3000", 15 | ) 16 | else: 17 | parent_dir = os.path.dirname(os.path.abspath(__file__)) 18 | build_dir = os.path.join(parent_dir, "frontend/build") 19 | _component_func = components.declare_component("st_cropper", path=build_dir) 20 | 21 | 22 | def _resize_img(img: Image, max_height: int = 700, max_width: int = 700) -> Image: 23 | # Resize the image to be a max of 700x700 by default, or whatever the user 24 | # provides. If streamlit has an attribute to expose the default width of a widget, 25 | # we should use that instead. 26 | if img.height > max_height: 27 | ratio = max_height / img.height 28 | img = img.resize((int(img.width * ratio), int(img.height * ratio))) 29 | if img.width > max_width: 30 | ratio = max_width / img.width 31 | img = img.resize((int(img.width * ratio), int(img.height * ratio))) 32 | return img 33 | 34 | 35 | def _recommended_box(img: Image, aspect_ratio: tuple = None) -> dict: 36 | # Find a recommended box for the image (could be replaced with image detection) 37 | box = (img.width * 0.2, img.height * 0.2, img.width * 0.8, img.height * 0.8) 38 | box = [int(i) for i in box] 39 | height = box[3] - box[1] 40 | width = box[2] - box[0] 41 | 42 | # If an aspect_ratio is provided, then fix the aspect 43 | if aspect_ratio: 44 | ideal_aspect = aspect_ratio[0] / aspect_ratio[1] 45 | height = (box[3] - box[1]) 46 | current_aspect = width / height 47 | if current_aspect > ideal_aspect: 48 | new_width = int(ideal_aspect * height) 49 | offset = (width - new_width) // 2 50 | resize = (offset, 0, -offset, 0) 51 | else: 52 | new_height = int(width / ideal_aspect) 53 | offset = (height - new_height) // 2 54 | resize = (0, offset, 0, -offset) 55 | box = [box[i] + resize[i] for i in range(4)] 56 | left = box[0] 57 | top = box[1] 58 | width = 0 59 | iters = 0 60 | while width < box[2] - left: 61 | width += aspect_ratio[0] 62 | iters += 1 63 | height = iters * aspect_ratio[1] 64 | else: 65 | left = box[0] 66 | top = box[1] 67 | width = box[2] - box[0] 68 | height = box[3] - box[1] 69 | return {'left': int(left), 'top': int(top), 'width': int(width), 'height': int(height)} 70 | 71 | def _get_cropped_image(img_file:Image, should_resize_image:bool, orig_file: Image, rect: dict): 72 | # Return a cropped image. 73 | if not should_resize_image: 74 | cropped_img = img_file.crop( 75 | (rect['left'], rect['top'], rect['width'] + rect['left'], rect['height'] + rect['top'])) 76 | else: 77 | cropped_img = orig_file.crop( 78 | (rect['left'], rect['top'], rect['width'] + rect['left'], rect['height'] + rect['top'])) 79 | return cropped_img 80 | 81 | def st_cropper(img_file: Image, realtime_update: bool = True, default_coords: Optional[tuple] = None, box_color: str = 'blue', aspect_ratio: tuple = None, 82 | return_type: str = 'image', box_algorithm=None, key=None, should_resize_image: bool = True, stroke_width = 3) -> Image.Image | dict | tuple[Image.Image, dict]: 83 | """Create a new instance of "st_cropper". 84 | 85 | Parameters 86 | ---------- 87 | img_file: PIL.Image 88 | The image to be cropped 89 | realtime_update: bool 90 | A boolean value to determine whether the cropper will update in realtime. 91 | If set to False, a double click is required to crop the image. 92 | default_coords: Optional[tuple] 93 | The (xl, xr, yt, yb) coords to use by default 94 | box_color: string 95 | The color of the cropper's bounding box. Defaults to blue, can accept 96 | other string colors recognized by fabric.js or hex colors in a format like 97 | '#ff003c' 98 | aspect_ratio: tuple 99 | Tuple representing the ideal aspect ratio: e.g. 1:1 aspect is (1,1) and 4:3 is (4,3) 100 | box_algorithm: function 101 | A function that can return a bounding box, the function should accept a PIL image 102 | and return a dictionary with keys: 'left', 'top', 'width', 'height'. Note that 103 | if you use a box_algorithm with an aspect_ratio, you will need to decide how to 104 | handle the aspect_ratio yourself 105 | return_type: str 106 | The return type that you would like. The default, 'image', returns the cropped 107 | image, while 'box' returns a dictionary identifying the box by its 108 | left and top coordinates as well as its width and height. Alternatively 'both' 109 | will return both the cropped image and box coordinates 110 | key: str or None 111 | An optional key that uniquely identifies this component. If this is 112 | None, and the component's arguments are changed, the component will 113 | be re-mounted in the Streamlit frontend and lose its current state. 114 | should_resize_image: bool 115 | A boolean to select whether the input image should be resized. As default the image 116 | will be resized to 700x700 pixel for streamlit display. Set to false when using 117 | custom box_algorithm. 118 | stroke_width: int 119 | The width of the bounding box 120 | 121 | Returns 122 | ------- 123 | PIL.Image 124 | The cropped image in PIL.Image format 125 | or 126 | Dict of box with coordinates 127 | or 128 | Tuple of PIL.Image and box coordinates 129 | """ 130 | 131 | # Ensure that the return type is in the list of supported return types 132 | supported_types = ('image', 'box', 'both') 133 | if return_type.lower() not in supported_types: 134 | raise ValueError(f"{return_type} is not a supported value for return_type, try one of {supported_types}") 135 | 136 | resized_ratio_w = 1 137 | resized_ratio_h = 1 138 | orig_file = img_file.copy() 139 | 140 | # Load the image and resize to be no wider than the streamlit widget size 141 | if should_resize_image: 142 | img_file = _resize_img(img_file) 143 | resized_ratio_w = orig_file.width / img_file.width 144 | resized_ratio_h = orig_file.height / img_file.height 145 | 146 | if default_coords is not None: 147 | box = {'left': default_coords[0] // resized_ratio_w, 148 | 'top': default_coords[2] // resized_ratio_h, 149 | 'width': (default_coords[1] - default_coords[0]) // resized_ratio_w, 150 | 'height': (default_coords[3] - default_coords[2]) // resized_ratio_h 151 | } 152 | else: 153 | # Find a default box 154 | if not box_algorithm: 155 | box = _recommended_box(img_file, aspect_ratio=aspect_ratio) 156 | else: 157 | box = box_algorithm(img_file, aspect_ratio=aspect_ratio) 158 | 159 | rect_left = box['left'] 160 | rect_top = box['top'] 161 | rect_width = box['width'] 162 | rect_height = box['height'] 163 | 164 | # Get arguments to send to frontend 165 | canvas_width = img_file.width 166 | canvas_height = img_file.height 167 | lock_aspect = False 168 | if aspect_ratio: 169 | lock_aspect = True 170 | 171 | 172 | # Convert image to base64 string for passing to Javascript 173 | buffered = io.BytesIO() 174 | img_file.convert("RGBA").save(buffered, format="PNG") 175 | image_data = "data:image/png;base64," + base64.b64encode(buffered.getvalue()).decode() 176 | 177 | # Call through to our private component function. Arguments we pass here 178 | # will be sent to the frontend, where they'll be available in an "args" 179 | # dictionary. 180 | # 181 | # Defaults to a box whose vertices are at 20% and 80% of height and width. 182 | # The _recommended_box function could be replaced with some kind of image 183 | # detection algorith if it suits your needs. 184 | component_value = _component_func(canvasWidth=canvas_width, canvasHeight=canvas_height, 185 | realtimeUpdate=realtime_update, strokeWidth=stroke_width, 186 | rectHeight=rect_height, rectWidth=rect_width, rectLeft=rect_left, rectTop=rect_top, 187 | boxColor=box_color, imageData=image_data, lockAspect=lock_aspect, key=key) 188 | 189 | # Return a cropped image using the box from the frontend 190 | if component_value: 191 | rect = component_value['coords'] 192 | else: 193 | rect = box 194 | 195 | # Scale box according to the resize ratio, but make sure new box does not exceed original bounds 196 | if should_resize_image: 197 | rect['left'] = max(0, int(rect['left'] * resized_ratio_w)) 198 | rect['top'] = max(0, int(rect['top'] * resized_ratio_h)) 199 | rect['width'] = min(orig_file.size[0] - rect['left'], int(rect['width'] * resized_ratio_w)) 200 | rect['height'] = min(orig_file.size[1] - rect['top'], int(rect['height'] * resized_ratio_h)) 201 | 202 | # Return the value desired by the return_type 203 | if return_type.lower() == 'image': 204 | return _get_cropped_image(img_file, should_resize_image, orig_file, rect) 205 | elif return_type.lower() == 'box': 206 | return rect 207 | elif return_type.lower() == 'both': 208 | return _get_cropped_image(img_file, should_resize_image, orig_file, rect), rect 209 | 210 | 211 | # Add some test code to play with the component while it's in development. 212 | # During development, we can run this just as we would any other Streamlit 213 | # app: `$ streamlit run my_component/__init__.py` 214 | if not _RELEASE: 215 | import streamlit as st 216 | 217 | # Upload an image and set some options for demo purposes 218 | st.header("Cropper Testing") 219 | img_file = st.sidebar.file_uploader(label='Upload a file', type=['png', 'jpg']) 220 | realtime_update = st.sidebar.checkbox(label="Update in Real Time", value=True) 221 | box_color = st.sidebar.color_picker(label="Box Color", value='#0000FF') 222 | 223 | aspect_choice = st.sidebar.radio(label="Aspect Ratio", options=["1:1", "16:9", "4:3", "2:3", "Free"]) 224 | aspect_dict = { 225 | "1:1": (1, 1), 226 | "16:9": (16, 9), 227 | "4:3": (4, 3), 228 | "2:3": (2, 3), 229 | "Free": None 230 | } 231 | aspect_ratio = aspect_dict[aspect_choice] 232 | 233 | return_type_choice = st.sidebar.radio(label="Return type", options=["Cropped image", "Rect coords"]) 234 | return_type_dict = { 235 | "Cropped image": "image", 236 | "Rect coords": "box" 237 | } 238 | return_type = return_type_dict[return_type_choice] 239 | 240 | if img_file: 241 | img = Image.open(img_file) 242 | 243 | if return_type == 'box': 244 | rect = st_cropper( 245 | img_file=img, 246 | realtime_update=True, 247 | box_color=box_color, 248 | aspect_ratio=aspect_ratio, 249 | return_type=return_type 250 | ) 251 | raw_image = np.asarray(img).astype('uint8') 252 | left, top, width, height = tuple(map(int, rect.values())) 253 | st.write(rect) 254 | masked_image = np.zeros(raw_image.shape, dtype='uint8') 255 | masked_image[top:top + height, left:left + width] = raw_image[top:top + height, left:left + width] 256 | st.image(Image.fromarray(masked_image), caption='masked image') 257 | else: 258 | if not realtime_update: 259 | st.write("Double click to save crop") 260 | # Get a cropped image from the frontend 261 | cropped_img = st_cropper( 262 | img_file=img, 263 | realtime_update=realtime_update, 264 | box_color=box_color, 265 | aspect_ratio=aspect_ratio, 266 | return_type=return_type 267 | ) 268 | 269 | # Manipulate cropped image at will 270 | st.write("Preview") 271 | _ = cropped_img.thumbnail((150, 150)) 272 | st.image(cropped_img) 273 | -------------------------------------------------------------------------------- /streamlit_cropper/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": false, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /streamlit_cropper/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "streamlit_cropper", 3 | "version": "0.3.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^6.6.3", 7 | "@testing-library/react": "^16.3.0", 8 | "@testing-library/user-event": "^14.6.1", 9 | "@types/fabric": "^5.3.10", 10 | "@types/hoist-non-react-statics": "^3.3.1", 11 | "@types/jest": "^29.5.14", 12 | "@types/node": "^22.15.29", 13 | "@types/react": "^19.1.6", 14 | "@types/react-cropper": "^2.0.0", 15 | "@types/react-dom": "^19.1.5", 16 | "apache-arrow": "^20.0.0", 17 | "bootstrap": "^5.3.6", 18 | "cropper": "^4.1.0", 19 | "event-target-shim": "^6.0.2", 20 | "fabric": "^6.6.7", 21 | "hoist-non-react-statics": "^3.3.2", 22 | "react": "^19.1.0", 23 | "react-cropper": "^2.3.3", 24 | "react-dom": "^19.1.0", 25 | "react-scripts": "^5.0.1", 26 | "typescript": "^4.9.5" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "react-scripts test", 32 | "eject": "react-scripts eject" 33 | }, 34 | "eslintConfig": { 35 | "extends": "react-app" 36 | }, 37 | "browserslist": { 38 | "production": [ 39 | ">0.2%", 40 | "not dead", 41 | "not op_mini all" 42 | ], 43 | "development": [ 44 | "last 1 chrome version", 45 | "last 1 firefox version", 46 | "last 1 safari version" 47 | ] 48 | }, 49 | "homepage": "." 50 | } 51 | -------------------------------------------------------------------------------- /streamlit_cropper/frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |