├── .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 | ![](./demo.gif) 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 | Streamlit Component 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /streamlit_cropper/frontend/src/.env: -------------------------------------------------------------------------------- 1 | # Run the component's dev server on :3001 2 | # (The Streamlit dev server already runs on :3000) 3 | PORT=3001 4 | 5 | # Don't automatically open the web browser on `npm run start`. 6 | BROWSER=none 7 | -------------------------------------------------------------------------------- /streamlit_cropper/frontend/src/StreamlitCropper.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useRef, useState} from 'react'; 2 | import {ComponentProps, Streamlit, withStreamlitConnection} from "./streamlit"; 3 | import { Canvas, Rect, Image as FabricImage } from 'fabric'; 4 | 5 | interface PythonArgs { 6 | canvasWidth: number 7 | canvasHeight: number 8 | rectTop: number 9 | rectLeft: number 10 | rectWidth: number 11 | rectHeight: number 12 | realtimeUpdate: boolean 13 | boxColor: string 14 | strokeWidth: number 15 | imageData: string // base64 string 16 | lockAspect: boolean 17 | } 18 | 19 | 20 | 21 | const StreamlitCropper = (props: ComponentProps) => { 22 | const [canvas, setCanvas] = useState(null); 23 | const canvasRef = useRef(null); 24 | const rectRef = useRef(null); 25 | const {canvasWidth, canvasHeight, imageData}: PythonArgs = props.args; 26 | // imageData is now a base64 string (data URL) 27 | const dataUri = imageData || ""; 28 | 29 | /** 30 | * Initialize canvas on mount and add a rectangle 31 | */ 32 | useEffect(() => { 33 | // Only initialize Fabric once 34 | if (!canvasRef.current || canvas) return; 35 | const {rectTop, rectLeft, rectWidth, rectHeight, boxColor, strokeWidth, lockAspect}: PythonArgs = props.args; 36 | console.log(lockAspect, "lockAspect") 37 | const fabricCanvas = new Canvas(canvasRef.current, { 38 | enableRetinaScaling: false, 39 | uniformScaling: lockAspect 40 | }); 41 | 42 | if (dataUri) { 43 | FabricImage.fromURL(dataUri).then((img: FabricImage) => { 44 | fabricCanvas.backgroundImage = img; 45 | fabricCanvas.requestRenderAll(); 46 | }); 47 | } 48 | 49 | const rect = new Rect({ 50 | left: rectLeft, 51 | top: rectTop, 52 | fill: '', 53 | width: rectWidth, 54 | height: rectHeight, 55 | objectCaching: true, 56 | stroke: boxColor, 57 | strokeWidth: strokeWidth, 58 | // lockScalingFlip: true, 59 | }); 60 | rect.set({ 61 | hasRotatingControl: false, 62 | }); // Hide the rotation control and show/hide edge controls based on lockAspect 63 | rect.setControlsVisibility && rect.setControlsVisibility({ 64 | mt: !lockAspect, // middle top 65 | mb: !lockAspect, // middle bottom 66 | ml: !lockAspect, // middle left 67 | mr: !lockAspect, // middle right 68 | mtr: false, // rotation control 69 | tl: true, // always show corners for free resize 70 | tr: true, 71 | bl: true, 72 | br: true 73 | }); 74 | fabricCanvas.add(rect); 75 | rectRef.current = rect; 76 | 77 | setCanvas(fabricCanvas); 78 | Streamlit.setFrameHeight(); 79 | 80 | return () => { 81 | fabricCanvas.dispose(); 82 | }; 83 | // eslint-disable-next-line 84 | }, []); 85 | 86 | // Update rectangle properties when props.args change 87 | useEffect(() => { 88 | if (!canvas || !rectRef.current) return; 89 | const {rectTop, rectLeft, rectWidth, rectHeight, boxColor, strokeWidth, lockAspect}: PythonArgs = props.args; 90 | const rect = rectRef.current; 91 | rect.set({ 92 | left: rectLeft, 93 | top: rectTop, 94 | width: rectWidth, 95 | height: rectHeight, 96 | stroke: boxColor, 97 | strokeWidth: strokeWidth, 98 | hasRotatingControl: false, 99 | }); 100 | // Hide the rotation control and show/hide edge controls based on lockAspect 101 | rect.setControlsVisibility && rect.setControlsVisibility({ 102 | mt: !lockAspect, 103 | mb: !lockAspect, 104 | ml: !lockAspect, 105 | mr: !lockAspect, 106 | mtr: false, 107 | tl: true, 108 | tr: true, 109 | bl: true, 110 | br: true 111 | }); 112 | rect.setCoords(); 113 | canvas.requestRenderAll(); 114 | }, [props.args, canvas]); 115 | 116 | 117 | /** 118 | * On update (either realtime or doubleclick), send the coordinates of the rectangle 119 | * back to streamlit. 120 | */ 121 | useEffect(() => { 122 | const {realtimeUpdate}: PythonArgs = props.args 123 | if (!canvas) { 124 | return; 125 | } 126 | const handleEvent = () => { 127 | canvas.renderAll() 128 | const coords = canvas.getObjects()[0].getBoundingRect() 129 | Streamlit.setComponentValue({coords:coords}) 130 | } 131 | 132 | if (realtimeUpdate) { 133 | canvas.on("object:modified", handleEvent) 134 | return () => { 135 | canvas.off("object:modified"); 136 | } 137 | } 138 | else { 139 | canvas.on("mouse:dblclick", handleEvent) 140 | return () => { 141 | canvas.off("mouse:dblclick"); 142 | } 143 | } 144 | }) 145 | 146 | return ( 147 | <> 148 | 149 | 150 | ) 151 | }; 152 | 153 | export default withStreamlitConnection(StreamlitCropper); 154 | -------------------------------------------------------------------------------- /streamlit_cropper/frontend/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | box-sizing: border-box; 3 | } 4 | 5 | *, 6 | ::before, 7 | ::after { 8 | box-sizing: inherit; 9 | } 10 | 11 | body { 12 | margin: 0; 13 | background: transparent; 14 | } -------------------------------------------------------------------------------- /streamlit_cropper/frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { createRoot } from "react-dom/client" 3 | import StreamlitCropper from "./StreamlitCropper" 4 | 5 | import "./index.css" 6 | 7 | const root = createRoot(document.getElementById("root")!); 8 | root.render( 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /streamlit_cropper/frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /streamlit_cropper/frontend/src/streamlit/StreamlitReact.tsx: -------------------------------------------------------------------------------- 1 | import hoistNonReactStatics from "hoist-non-react-statics" 2 | import React, { ReactNode } from "react" 3 | import { RenderData, Streamlit } from "./streamlit" 4 | 5 | /** 6 | * Props passed to custom Streamlit components. 7 | */ 8 | export interface ComponentProps { 9 | /** Named dictionary of arguments passed from Python. */ 10 | args: any 11 | 12 | /** The component's width. */ 13 | width: number 14 | 15 | /** 16 | * True if the component should be disabled. 17 | * All components get disabled while the app is being re-run, 18 | * and become re-enabled when the re-run has finished. 19 | */ 20 | disabled: boolean 21 | } 22 | 23 | /** 24 | * Optional Streamlit React-based component base class. 25 | * 26 | * You are not required to extend this base class to create a Streamlit 27 | * component. If you decide not to extend it, you should implement the 28 | * `componentDidMount` and `componentDidUpdate` functions in your own class, 29 | * so that your plugin properly resizes. 30 | */ 31 | export class StreamlitComponentBase extends React.PureComponent< 32 | ComponentProps, 33 | S 34 | > { 35 | public componentDidMount(): void { 36 | // After we're rendered for the first time, tell Streamlit that our height 37 | // has changed. 38 | Streamlit.setFrameHeight() 39 | } 40 | 41 | public componentDidUpdate(): void { 42 | // After we're updated, tell Streamlit that our height may have changed. 43 | Streamlit.setFrameHeight() 44 | } 45 | } 46 | 47 | /** 48 | * Wrapper for React-based Streamlit components. 49 | * 50 | * Bootstraps the communication interface between Streamlit and the component. 51 | */ 52 | export function withStreamlitConnection( 53 | WrappedComponent: React.ComponentType 54 | ): React.ComponentType { 55 | interface WrapperProps {} 56 | 57 | interface WrapperState { 58 | renderData?: RenderData 59 | componentError?: Error 60 | } 61 | 62 | class ComponentWrapper extends React.PureComponent< 63 | WrapperProps, 64 | WrapperState 65 | > { 66 | public constructor(props: WrapperProps) { 67 | super(props) 68 | this.state = { 69 | renderData: undefined, 70 | componentError: undefined, 71 | } 72 | } 73 | 74 | /** 75 | * Error boundary function. This will be called if our wrapped 76 | * component throws an error. We store the caught error in our state, 77 | * and display it in the next render(). 78 | */ 79 | public static getDerivedStateFromError = ( 80 | error: Error 81 | ): Partial => { 82 | return { componentError: error } 83 | } 84 | 85 | public componentDidMount = (): void => { 86 | // Set up event listeners, and signal to Streamlit that we're ready. 87 | // We won't render the component until we receive the first RENDER_EVENT. 88 | Streamlit.events.addEventListener( 89 | Streamlit.RENDER_EVENT, 90 | this.onRenderEvent 91 | ) 92 | Streamlit.setComponentReady() 93 | } 94 | 95 | public componentDidUpdate = (): void => { 96 | // If our child threw an error, we display it in render(). In this 97 | // case, the child won't be mounted and therefore won't call 98 | // `setFrameHeight` on its own. We do it here so that the rendered 99 | // error will be visible. 100 | if (this.state.componentError != null) { 101 | Streamlit.setFrameHeight() 102 | } 103 | } 104 | 105 | public componentWillUnmount = (): void => { 106 | Streamlit.events.removeEventListener( 107 | Streamlit.RENDER_EVENT, 108 | this.onRenderEvent 109 | ) 110 | } 111 | 112 | /** 113 | * Streamlit is telling this component to redraw. 114 | * We save the render data in State, so that it can be passed to the 115 | * component in our own render() function. 116 | */ 117 | private onRenderEvent = (event: Event): void => { 118 | // Update our state with the newest render data 119 | const renderEvent = event as CustomEvent 120 | this.setState({ renderData: renderEvent.detail }) 121 | } 122 | 123 | public render = (): ReactNode => { 124 | // If our wrapped component threw an error, display it. 125 | if (this.state.componentError != null) { 126 | return ( 127 |
128 |

Component Error

129 | {this.state.componentError.message} 130 |
131 | ) 132 | } 133 | 134 | // Don't render until we've gotten our first RENDER_EVENT from Streamlit. 135 | if (this.state.renderData == null) { 136 | return null 137 | } 138 | 139 | return ( 140 | 145 | ) 146 | } 147 | } 148 | 149 | return hoistNonReactStatics(ComponentWrapper, WrappedComponent) 150 | } 151 | -------------------------------------------------------------------------------- /streamlit_cropper/frontend/src/streamlit/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018-2020 Streamlit Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | // Workaround for type-only exports: 19 | // https://stackoverflow.com/questions/53728230/cannot-re-export-a-type-when-using-the-isolatedmodules-with-ts-3-2-2 20 | 21 | import { ComponentProps as ComponentProps_ } from "./StreamlitReact" 22 | import { RenderData as RenderData_ } from "./streamlit" 23 | 24 | export { 25 | StreamlitComponentBase, 26 | withStreamlitConnection, 27 | } from "./StreamlitReact" 28 | export { Streamlit } from "./streamlit" 29 | export type ComponentProps = ComponentProps_ 30 | export type RenderData = RenderData_ 31 | -------------------------------------------------------------------------------- /streamlit_cropper/frontend/src/streamlit/streamlit.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018-2020 Streamlit Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | // Safari doesn't support the EventTarget class, so we use a shim. 19 | // Simple EventTarget polyfill for browser compatibility 20 | class SimpleEventTarget { 21 | private listeners: { [type: string]: Function[] } = {} 22 | addEventListener(type: string, callback: Function) { 23 | if (!this.listeners[type]) this.listeners[type] = [] 24 | this.listeners[type].push(callback) 25 | } 26 | removeEventListener(type: string, callback: Function) { 27 | if (!this.listeners[type]) return 28 | this.listeners[type] = this.listeners[type].filter(fn => fn !== callback) 29 | } 30 | dispatchEvent(event: any) { 31 | if (!this.listeners[event.type]) return 32 | for (const fn of this.listeners[event.type]) fn(event) 33 | } 34 | } 35 | 36 | /** Data sent in the custom Streamlit render event. */ 37 | export interface RenderData { 38 | args: any 39 | disabled: boolean 40 | } 41 | 42 | /** Messages from Component -> Streamlit */ 43 | enum ComponentMessageType { 44 | // A component sends this message when it's ready to receive messages 45 | // from Streamlit. Streamlit won't send any messages until it gets this. 46 | // Data: { apiVersion: number } 47 | COMPONENT_READY = "streamlit:componentReady", 48 | 49 | // The component has a new widget value. Send it back to Streamlit, which 50 | // will then re-run the app. 51 | // Data: { value: any } 52 | SET_COMPONENT_VALUE = "streamlit:setComponentValue", 53 | 54 | // The component has a new height for its iframe. 55 | // Data: { height: number } 56 | SET_FRAME_HEIGHT = "streamlit:setFrameHeight", 57 | } 58 | 59 | /** 60 | * Streamlit communication API. 61 | * 62 | * Components can send data to Streamlit via the functions defined here, 63 | * and receive data from Streamlit via the `events` property. 64 | */ 65 | export class Streamlit { 66 | /** 67 | * The Streamlit component API version we're targetting. 68 | * There's currently only 1! 69 | */ 70 | public static readonly API_VERSION = 1 71 | 72 | public static readonly RENDER_EVENT = "streamlit:render" 73 | 74 | /** Dispatches events received from Streamlit. */ 75 | public static readonly events = new SimpleEventTarget() 76 | 77 | private static registeredMessageListener = false 78 | private static lastFrameHeight?: number 79 | 80 | /** 81 | * Tell Streamlit that the component is ready to start receiving data. 82 | * Streamlit will defer emitting RENDER events until it receives the 83 | * COMPONENT_READY message. 84 | */ 85 | public static setComponentReady = (): void => { 86 | if (!Streamlit.registeredMessageListener) { 87 | // Register for message events if we haven't already 88 | window.addEventListener("message", Streamlit.onMessageEvent) 89 | Streamlit.registeredMessageListener = true 90 | } 91 | 92 | Streamlit.sendBackMsg(ComponentMessageType.COMPONENT_READY, { 93 | apiVersion: Streamlit.API_VERSION, 94 | }) 95 | } 96 | 97 | /** 98 | * Report the component's height to Streamlit. 99 | * This should be called every time the component changes its DOM - that is, 100 | * when it's first loaded, and any time it updates. 101 | */ 102 | public static setFrameHeight = (height?: number): void => { 103 | if (height === undefined) { 104 | // `height` is optional. If undefined, it defaults to scrollHeight, 105 | // which is the entire height of the element minus its border, 106 | // scrollbar, and margin. 107 | height = document.body.scrollHeight 108 | } 109 | 110 | if (height === Streamlit.lastFrameHeight) { 111 | // Don't bother updating if our height hasn't changed. 112 | return 113 | } 114 | 115 | Streamlit.lastFrameHeight = height 116 | Streamlit.sendBackMsg(ComponentMessageType.SET_FRAME_HEIGHT, { height }) 117 | } 118 | 119 | /** 120 | * Set the component's value. This value will be returned to the Python 121 | * script, and the script will be re-run. 122 | * 123 | * For example: 124 | * 125 | * JavaScript: 126 | * Streamlit.setComponentValue("ahoy!") 127 | * 128 | * Python: 129 | * value = st.my_component(...) 130 | * st.write(value) # -> "ahoy!" 131 | * 132 | * The value must be serializable into JSON. 133 | */ 134 | public static setComponentValue = (value: any): void => { 135 | Streamlit.sendBackMsg(ComponentMessageType.SET_COMPONENT_VALUE, { value }) 136 | } 137 | 138 | /** Receive a ForwardMsg from the Streamlit app */ 139 | private static onMessageEvent = (event: MessageEvent): void => { 140 | const type = event.data["type"] 141 | switch (type) { 142 | case Streamlit.RENDER_EVENT: 143 | Streamlit.onRenderMessage(event.data) 144 | break 145 | } 146 | } 147 | 148 | /** 149 | * Handle an untyped Streamlit render event and redispatch it as a 150 | * StreamlitRenderEvent. 151 | */ 152 | private static onRenderMessage = (data: any): void => { 153 | let args = data["args"] 154 | if (args == null) { 155 | console.error( 156 | `Got null args in onRenderMessage. This should never happen` 157 | ) 158 | args = {} 159 | } 160 | 161 | // No ArrowTable/dataframe parsing needed. args should contain imageData as base64 string. 162 | const disabled = Boolean(data["disabled"]) 163 | 164 | // Dispatch a render event! 165 | const eventData = { disabled, args } 166 | const event = new CustomEvent(Streamlit.RENDER_EVENT, { 167 | detail: eventData, 168 | }) 169 | Streamlit.events.dispatchEvent(event) 170 | } 171 | 172 | 173 | 174 | /** Post a message to the Streamlit app. */ 175 | private static sendBackMsg = (type: string, data?: any): void => { 176 | window.parent.postMessage( 177 | { 178 | isStreamlitMessage: true, 179 | type: type, 180 | ...data, 181 | }, 182 | "*" 183 | ) 184 | } 185 | } 186 | 187 | 188 | -------------------------------------------------------------------------------- /streamlit_cropper/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react" 17 | }, 18 | "include": ["src"] 19 | } 20 | --------------------------------------------------------------------------------