Skip to content

Commit bf399e7

Browse files
committedMar 31, 2022
Image processing as a service
1 parent a259a22 commit bf399e7

File tree

11 files changed

+995
-6
lines changed

11 files changed

+995
-6
lines changed
 

‎README.md

+10-6
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22

33
Cloud computing is a game changer for developers. What can you do in a couple hundred lines of code?
44

5-
## [🕵️ Face detection as a service 🐍](./gae_face_detection)
5+
## [🎨 Image processing as a service 🐍](./cr_image_processing/README.md)
66

7-
[![Face detection preview](https://github.com/PicardParis/cherry-on-py-pics/raw/main/gae_face_detection/pics/animated_1_american_gothic.png)](./gae_face_detection)
7+
[![preview animation](https://github.com/PicardParis/cherry-on-py-pics/raw/main/cr_image_processing/pics/preview.gif)](./cr_image_processing/README.md)
88

9-
## [🎞️ Video object tracking as a service 🐍](./gcf_object_tracking)
9+
## [🕵️ Face detection as a service 🐍](./gae_face_detection/README.md)
1010

11-
[![Video object tracking preview](https://github.com/PicardParis/cherry-on-py-pics/raw/main/gcf_object_tracking/pics/JaneGoodall.mp4.004_insect_pct71_fr23.gif)](./gcf_object_tracking)
11+
[![preview animation](https://github.com/PicardParis/cherry-on-py-pics/raw/main/gae_face_detection/pics/animated_1_american_gothic.png)](./gae_face_detection/README.md)
1212

13-
## [🎞️ Video summary as a service 🐍](./gcf_video_summary)
13+
## [🎞️ Video object tracking as a service 🐍](./gcf_object_tracking/README.md)
1414

15-
[![Video summary preview](https://github.com/PicardParis/cherry-on-py-pics/raw/main/gcf_video_summary/pics/JaneGoodall.mp4.summary035_anim.gif)](./gcf_video_summary)
15+
[![preview animation](https://github.com/PicardParis/cherry-on-py-pics/raw/main/gcf_object_tracking/pics/JaneGoodall.mp4.004_insect_pct71_fr23.gif)](./gcf_object_tracking/README.md)
16+
17+
## [🎞️ Video summary as a service 🐍](./gcf_video_summary/README.md)
18+
19+
[![preview animation](https://github.com/PicardParis/cherry-on-py-pics/raw/main/gcf_video_summary/pics/JaneGoodall.mp4.summary035_anim.gif)](./gcf_video_summary/README.md)

‎cr_image_processing/CREDITS.md

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Credits
2+
3+
| Libraries | |
4+
| ------------ | ------------------------------------- |
5+
| Gunicorn | <https://gunicorn.org> |
6+
| Flask | <https://palletsprojects.com/p/flask> |
7+
| scikit-image | <https://scikit-image.org> |
8+
| Shoelace | <https://shoelace.style> |
9+
10+
| Images | |
11+
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------- |
12+
| **La nascita di Venere** | |
13+
| Source | <https://commons.wikimedia.org/wiki/File:Sandro_Botticelli_-_La_nascita_di_Venere_-_Google_Art_ProjectFXD.jpg> |
14+
| Artist | Sandro Botticelli |
15+
| License | Public domain |
16+
| **The Great Wave off Kanagawa** | |
17+
| Source | <https://commons.wikimedia.org/wiki/File:Tsunami_by_hokusai_19th_century.jpg> |
18+
| Artist | Katsushika Hokusai |
19+
| License | Public domain |
20+
| **Doodle - Google's 23rd Birthday** | |
21+
| Source | <https://www.google.com/doodles/googles-23rd-birthday> |
22+
| Copyright | Google |

‎cr_image_processing/DEPLOY.md

+234
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
# 🎨 Image processing as a service 🐍 Deploying from scratch 🚀
2+
3+
With a Google Cloud account, you can set up the cloud architecture and deploy the app from scratch:
4+
5+
- directly from your browser ([Cloud Shell](https://console.cloud.google.com/?cloudshell=true)),
6+
- using 1 command-line tool (`gcloud`),
7+
- in less than 7 minutes.
8+
9+
## 🔧 Project setup
10+
11+
### Environment variables
12+
13+
```bash
14+
MY_UNIQUE_ID="MY_UNIQUE_ID" # e.g. you can use your GitHub username
15+
PROJECT_NAME="Coloring Page Generator"
16+
PROJECT_ID="cpg-$MY_UNIQUE_ID"
17+
18+
# Cloud Run region (see https://cloud.google.com/about/locations#region)
19+
CLOUD_RUN_REGION="europe-west6"
20+
21+
# Source on GitHub
22+
GIT_USER="PicardParis"
23+
GIT_REPO="cherry-on-py"
24+
GITHUB_SOURCE=https://github.com/$GIT_USER/$GIT_REPO.git
25+
PROJECT_SOURCE=~/$GIT_REPO/cr_image_processing/demo
26+
```
27+
28+
### Source code
29+
30+
```bash
31+
# Get the source code
32+
cd ~
33+
git clone $GITHUB_SOURCE
34+
35+
# Make sure you point to the right location
36+
ls $PROJECT_SOURCE
37+
38+
# You should get the following:
39+
# main.py Procfile requirements.txt static/
40+
```
41+
42+
### Project
43+
44+
```bash
45+
# Create a new project
46+
gcloud projects create $PROJECT_ID \
47+
--name "$PROJECT_NAME" \
48+
--set-as-default
49+
```
50+
51+
### Billing account
52+
53+
```bash
54+
# Link the project with a billing account
55+
BILLING_ACCOUNT=$(gcloud beta billing accounts list \
56+
--filter "displayName='My Billing Account'" \
57+
--format 'value(name)')
58+
59+
gcloud beta billing projects link $PROJECT_ID \
60+
--billing-account $BILLING_ACCOUNT
61+
```
62+
63+
### Cloud APIs
64+
65+
```bash
66+
# Enable the APIs
67+
# - Artifact Registry will store your build artifacts.
68+
# - Cloud Build will build your app.
69+
# - Cloud Run will deploy and serve your app.
70+
gcloud services enable \
71+
artifactregistry.googleapis.com \
72+
cloudbuild.googleapis.com \
73+
run.googleapis.com
74+
```
75+
76+
### Cloud Run
77+
78+
```bash
79+
# Set the default platform to "managed"
80+
# (Cloud Run can also be deployed to Kubernetes clusters)
81+
gcloud config set run/platform managed
82+
83+
# This demo uses a single region: define the default region
84+
# (To manage services in multiple regions, use the `--region` flag in `gcloud run` commands)
85+
gcloud config set run/region $CLOUD_RUN_REGION
86+
```
87+
88+
## 🚀 Deployment
89+
90+
Deploy your app from the source code with a single command:
91+
92+
```bash
93+
SERVICE="coloring-page-generator"
94+
CLOUD_RUN_MEMORY="2Gi"
95+
96+
gcloud run deploy $SERVICE \
97+
--source $PROJECT_SOURCE \
98+
--memory $CLOUD_RUN_MEMORY \
99+
--allow-unauthenticated \
100+
--quiet
101+
```
102+
103+
> Notes:
104+
> - For more details, see the `gcloud run deploy` [options](https://cloud.google.com/sdk/gcloud/reference/run/deploy)
105+
> - Deploying from source requires an Artifact Registry repository to store the build artifacts. By using the `--quiet` flag, you skip prompt confirmations and a default repository named `cloud-run-source-deploy` will automatically be created.
106+
> - The default memory allocated to a Cloud Run instance is 512 MiB. To handle higher resolution images, the service is configured with 2 GiB here. You can currently allocate up to 16 GiB of memory.
107+
108+
This gives the following output:
109+
110+
```text
111+
112+
Building using Buildpacks and deploying container to Cloud Run service [SERVICE] in project [PROJECT_ID] region [REGION]
113+
OK Building and deploying new service... Done.
114+
OK Creating Container Repository...
115+
OK Uploading sources...
116+
OK Building Container... Logs are available at […].
117+
OK Creating Revision...
118+
OK Routing traffic...
119+
OK Setting IAM Policy...
120+
Done.
121+
Service [SERVICE] revision [SERVICE-REVISION] has been deployed and is serving 100 percent of traffic.
122+
Service URL: https://SERVICE-PROJECTHASH-REGIONID.a.run.app
123+
```
124+
125+
Check the details of your service:
126+
127+
```bash
128+
gcloud run services describe $SERVICE
129+
```
130+
131+
> For more details, see the `gcloud run services` [options](https://cloud.google.com/sdk/gcloud/reference/run/services).
132+
133+
This lists the specific and default settings:
134+
135+
```text
136+
✔ Service SERVICE in region REGION
137+
138+
URL: https://SERVICE-PROJECTHASH-REGIONID.a.run.app
139+
Ingress: all
140+
Traffic:
141+
100% LATEST (currently SERVICE-REVISION)
142+
143+
Last updated…:
144+
Revision SERVICE-REVISION
145+
Image: REGION-docker.pkg.dev/PROJECT_ID/cloud-run-source-deploy/…
146+
Port: 8080
147+
Memory: 2Gi
148+
CPU: 1000m
149+
Service account: SERVICE_ACCOUNT@developer.gserviceaccount.com
150+
Concurrency: 80
151+
Max Instances: 100
152+
Timeout: 300s
153+
```
154+
155+
> To get all available info, request it in JSON: `gcloud run services describe $SERVICE --format json`
156+
157+
You can also manage your Cloud Run services from the [GUI (Cloud Console)](https://console.cloud.google.com/run).
158+
159+
List of services:
160+
161+
![screenshot](https://github.com/PicardParis/cherry-on-py-pics/raw/main/cr_image_processing/pics/a_cloud_run_services.png)
162+
163+
Service details:
164+
165+
![screenshot](https://github.com/PicardParis/cherry-on-py-pics/raw/main/cr_image_processing/pics/c_cloud_run_details.png)
166+
167+
## 🎉 Production test
168+
169+
The web app is ready. Retrieve your app URL:
170+
171+
```bash
172+
SERVICE_URL=$(gcloud run services describe $SERVICE --format "value(status.url)")
173+
echo $SERVICE_URL
174+
```
175+
176+
The service URL has the following format:
177+
178+
```text
179+
https://SERVICE-PROJECTHASH-REGIONID.a.run.app
180+
```
181+
182+
Send a GET request to your app:
183+
184+
```bash
185+
curl $SERVICE_URL
186+
```
187+
188+
This returns the static index page:
189+
190+
```html
191+
<!DOCTYPE html>
192+
<html lang="en">
193+
194+
<head>
195+
<title>Coloring Page Generator</title>
196+
...
197+
</head>
198+
<body>
199+
...
200+
</body>
201+
202+
</html>
203+
```
204+
205+
Open the URL in your browser. Your image processing app is live!
206+
207+
![Demo animation](https://github.com/PicardParis/cherry-on-py-pics/raw/main/cr_image_processing/pics/demo.gif)
208+
209+
## 🧹 Project deletion
210+
211+
```bash
212+
# To clean up everything, you can delete the project
213+
gcloud projects delete $PROJECT_ID
214+
```
215+
216+
## ➕ One more thing
217+
218+
How big is the code?
219+
220+
```bash
221+
first_line_after_licence=16
222+
223+
# Number of Python lines
224+
find $PROJECT_SOURCE -name "*.py" -exec tail -n +$first_line_after_licence {} \; | grep -c "\S"
225+
# 50
226+
227+
# Number of JavaScript lines
228+
find $PROJECT_SOURCE -name "*.js" -exec tail -n +$first_line_after_licence {} \; | grep -c "\S"
229+
# 130
230+
```
231+
232+
- This image processing app counts less than 200 lines of code: less lines, less bugs!
233+
- Deploying from scratch takes less than 7 minutes.
234+
- 🔥🐍 **Mission accomplished!** 🐍🔥

‎cr_image_processing/README.md

+365
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
# 🎨 Image processing as a service 🐍
2+
3+
![Coloring page generated from Hokusai's painting "The Great Wave off Kanagawa"](https://github.com/PicardParis/cherry-on-py-pics/raw/main/cr_image_processing/pics/preview.gif)
4+
5+
> _This is not an official Google product. This is an article aiming at giving you ideas…_
6+
7+
## 👋 Hello
8+
9+
Have you ever written a script to transform an image? Did you share the script with others or did you run it on multiple computers? How many times did you need to update the script or the setup instructions? Did you end up making it a service or an online app? If your script is useful, you’ll likely want to make it available to others. Deploying processing services is a recurring need – one that comes with its own set of challenges. Serverless technologies let you solve these challenges easily and efficiently.
10+
11+
In this post, you’ll see how to…
12+
13+
- Create an image processing service that generates coloring pages
14+
- Make it available online using minimal resources
15+
16+
…and do it all in less than 200 lines of Python and JavaScript!
17+
18+
## 🛠️ Tools
19+
20+
To build and deploy a coloring page generator, you’ll need a few tools:
21+
22+
- A library to process images
23+
- A web application framework
24+
- A web server
25+
- A serverless solution to make the demo available 24/7
26+
27+
## 🧱 Architecture
28+
29+
Here is one possible architecture for a coloring page generator using Cloud Run:
30+
31+
![Architecture serving a web app with Cloud Run](https://github.com/PicardParis/cherry-on-py-pics/raw/main/cr_image_processing/pics/1_architecture.png)
32+
33+
And here is the workflow:
34+
35+
- 1 - The user opens the web app: the browser requests the main page.
36+
- 2 - Cloud Run serves the app HTML code.
37+
- 3 - The browser requests the additional needed resources.
38+
- 4 - Cloud Run serves the CSS, JavaScript, and other resources.
39+
- A - The user selects an image and the frontend sends the image to the `/api/coloring-page` endpoint.
40+
- B - The backend processes the input image and returns an output image, which the user can then visualize, download, or print via the browser.
41+
42+
43+
## 🐍 Software stack
44+
45+
Of course, there are many different software stacks that you could use to implement such an architecture.
46+
47+
Here is a good one based on Python:
48+
49+
![schema](https://github.com/PicardParis/cherry-on-py-pics/raw/main/cr_image_processing/pics/2_software_stack.png)
50+
51+
It includes:
52+
53+
- [Gunicorn](https://pypi.org/project/gunicorn): A production-grade WSGI HTTP server
54+
- [Flask](https://pypi.org/project/Flask): A popular web app framework
55+
- [scikit-image](https://pypi.org/project/scikit-image): An extensive image processing library
56+
57+
Define these app dependencies in a file named `requirements.txt`:
58+
59+
```txt
60+
# https://pypi.org/project/gunicorn
61+
gunicorn==20.1.0
62+
63+
# https://pypi.org/project/flask
64+
Flask==2.1.1
65+
66+
# https://pypi.org/project/scikit-image
67+
# scikit-image dependencies include NumPy and Pillow
68+
scikit-image==0.19.2
69+
```
70+
71+
## 🎨 Image processing
72+
73+
How do you remove colors from an image? One way is by detecting the object edges and removing everything but the edges in the result image. This can be done with a [Sobel](https://wikipedia.org/wiki/Sobel_operator) filter, a convolution filter that detects the regions in which the image intensity changes the most.
74+
75+
Create a Python file named `main.py`, define an image processing function, and within it use the Sobel filter and other functions from scikit-image:
76+
77+
```py
78+
import numpy as np
79+
import skimage
80+
from PIL import Image
81+
from PIL.Image import Image as PilImage
82+
83+
84+
def generate_coloring_page(input: PilImage) -> PilImage:
85+
# Convert to grayscale if needed
86+
if input.mode != "L":
87+
input = input.convert("L")
88+
np_image = np.asarray(input)
89+
90+
# Detect the edges
91+
np_image = skimage.filters.sobel(np_image)
92+
# Convert to 8 bpp
93+
np_image = skimage.util.img_as_ubyte(np_image)
94+
# Invert to get dark edges on a light background
95+
np_image = 255 - np_image
96+
# Improve the contrast
97+
np_image = skimage.exposure.rescale_intensity(np_image)
98+
99+
return Image.fromarray(np_image)
100+
```
101+
102+
> Note: The NumPy and Pillow libraries are automatically installed as dependencies of scikit-image.
103+
104+
As an example, here is how the Cloud Run logo is processed at each step:
105+
106+
![Colored input transformed into edge-detected grayscale output](https://github.com/PicardParis/cherry-on-py-pics/raw/main/cr_image_processing/pics/3_edge_detection.png)
107+
108+
## ✨ Web app
109+
110+
### Backend
111+
112+
To expose both endpoints (`GET /` and `POST /api/coloring-page`), add Flask routes in `main.py`:
113+
114+
```py
115+
import io
116+
117+
import flask
118+
from PIL import Image
119+
120+
app = flask.Flask(__name__, static_url_path="")
121+
122+
123+
@app.get("/")
124+
def index():
125+
return app.send_static_file("index.html")
126+
127+
128+
@app.post("/api/coloring-page")
129+
def coloring_page():
130+
file = flask.request.files.get("input-image")
131+
if file is None:
132+
return "Missing input-image parameter", 400
133+
134+
input_image = Image.open(file.stream)
135+
output_image = generate_coloring_page(input_image)
136+
137+
image_io = io.BytesIO()
138+
output_format = "png"
139+
output_image.save(image_io, format=output_format)
140+
image_io.seek(0)
141+
142+
return flask.send_file(image_io, mimetype=f"image/{output_format}")
143+
```
144+
145+
### Frontend
146+
147+
On the browser side, write a JavaScript function that calls the `/api/coloring-page` endpoint and receives the processed image:
148+
149+
```js
150+
async function fetchColoringPage(inputFile) {
151+
const formData = new FormData()
152+
formData.append('input-image', inputFile)
153+
154+
const url = '/api/coloring-page'
155+
const init = { method: 'POST', body: formData }
156+
try {
157+
const response = await fetch(url, init)
158+
return response.ok ? response.blob() : null
159+
} catch (error) {
160+
console.error(error)
161+
return null
162+
}
163+
}
164+
```
165+
166+
The base of your app is there. Now you just need to add a mix of HTML + CSS + JS to complete the desired user experience.
167+
168+
### Local development
169+
170+
To develop and test the app on your computer, once your environment is set up, make sure you have the needed dependencies:
171+
172+
```sh
173+
pip install --upgrade -r requirements.txt
174+
```
175+
176+
Add the following block to `main.py`. It will only execute when you run your app manually:
177+
178+
```py
179+
import os
180+
181+
# ...
182+
183+
if __name__ == "__main__":
184+
os.environ["FLASK_ENV"] = "development"
185+
app.run(host="localhost", port=8080, debug=True)
186+
```
187+
188+
Run your app:
189+
190+
```sh
191+
python main.py
192+
```
193+
194+
Flask starts a local web server:
195+
196+
```txt
197+
* Serving Flask app 'main' (lazy loading)
198+
* Environment: development
199+
* Debug mode: on
200+
* Restarting with stat
201+
* Debugger is active!
202+
* Debugger PIN: 718-408-327
203+
* Running on http://localhost:8080/ (Press CTRL+C to quit)
204+
```
205+
206+
> Note: In this mode, you’re using a development web server (one that is not suited for production). You’ll next set up the deployment to serve your app with Gunicorn, a production-grade server.
207+
208+
You're all set. Open `localhost:8080` in your browser, test, refine, and iterate.
209+
210+
## 🚀 Deployment
211+
212+
Once your app is ready for prime time, you can define how it will be served with this single line in a file named `Procfile`:
213+
214+
```sh
215+
web: gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 0 main:app
216+
```
217+
218+
At this stage, here are the files found in a typical project:
219+
220+
```txt
221+
.
222+
├── main.py
223+
├── Procfile
224+
├── requirements.txt
225+
└── static
226+
├── favicon.ico
227+
├── index.html
228+
├── scripts.js
229+
└── styles.css
230+
```
231+
232+
That's it, you can now deploy your app from the source folder:
233+
234+
```sh
235+
SERVICE="coloring-page-generator"
236+
SOURCE="."
237+
238+
gcloud run deploy $SERVICE --source $SOURCE --allow-unauthenticated
239+
```
240+
241+
## ⚙️ Under the hood
242+
243+
The command line output details all the different steps:
244+
245+
```txt
246+
This command is equivalent to running `gcloud builds submit --pack image=[IMAGE] SOURCE` and `gcloud run deploy SERVICE --image [IMAGE]`
247+
248+
Building using Buildpacks and deploying container to Cloud Run service [SERVICE] in project [PROJECT_ID] region [REGION]
249+
OK Building and deploying... Done.
250+
OK Creating Container Repository...
251+
OK Uploading sources...
252+
OK Building Container... Logs are available at […].
253+
OK Creating Revision...
254+
OK Routing traffic...
255+
OK Setting IAM Policy...
256+
Done.
257+
Service [SERVICE] revision [SERVICE-REVISION] has been deployed and is serving 100 percent of traffic.
258+
Service URL: https://SERVICE-PROJECTHASH-REGIONID.a.run.app
259+
```
260+
261+
Cloud Build is indirectly called to containerize your app. One of its core components is Google Cloud [Buildpacks](https://github.com/GoogleCloudPlatform/buildpacks), which automatically builds a production-ready container image from your source code. Here are the main steps:
262+
263+
- Cloud Build fetches the source code.
264+
- Buildpacks autodetects the app language (Python, in this case) and uses the corresponding secure base image.
265+
- Buildpacks installs the app dependencies (defined in `requirements.txt` for Python).
266+
- Buildpacks configures the service entrypoint (defined in `Procfile` for Python).
267+
- Cloud Build pushes the container image to [Artifact Registry](https://cloud.google.com/artifact-registry).
268+
- Cloud Run creates a new revision of the service based on this container image.
269+
- Cloud Run routes production traffic to it.
270+
271+
Notes:
272+
273+
- Buildpacks currently supports the following runtimes: Go, Java, .NET, Node.js, and Python.
274+
- The base image is actively maintained by Google, scanned for security vulnerabilities, and patched against known issues. This means that, when you deploy an update, your service is based on an image that is as secure as possible.
275+
- If you need to build your own container image, for example with a custom runtime, you can add your own `Dockerfile` and Buildpacks will use it instead.
276+
277+
## 💫 Updates
278+
279+
More testing from real-life users shows some issues.
280+
281+
First, the app does not handle pictures taken with digital cameras in non-native orientations. You can fix this using the EXIF orientation data:
282+
283+
```diff
284+
-from PIL import Image
285+
+from PIL import Image, ImageOps
286+
...
287+
def generate_coloring_page(input: PilImage) -> PilImage:
288+
# Convert to grayscale if needed
289+
if input.mode != "L":
290+
input = input.convert("L")
291+
+ # Transpose if taken in non-native orientation (rotated digital camera)
292+
+ NATIVE_ORIENTATION = 1
293+
+ if input.getexif().get(0x0112, NATIVE_ORIENTATION) != NATIVE_ORIENTATION:
294+
+ input = ImageOps.exif_transpose(input)
295+
np_image = np.asarray(input)
296+
...
297+
```
298+
299+
In addition, the app is too sensitive to details in the input image. Textures in paintings, or noise in pictures, can generate many edges in the processed image. You can improve the processing algorithm by adding a denoising step upfront:
300+
301+
```diff
302+
...
303+
def generate_coloring_page(input: PilImage) -> PilImage:
304+
...
305+
np_image = np.asarray(input)
306+
307+
+ # Remove some noise to keep the most visible edges
308+
+ np_image = skimage.restoration.denoise_tv_chambolle(np_image, weight=0.05)
309+
# Detect the edges
310+
np_image = skimage.filters.sobel(np_image)
311+
...
312+
```
313+
314+
This additional step makes the coloring page cleaner and reduces the quantity of ink used if you print it:
315+
316+
![La nascita di Venere by Botticelli, with and without denoising](https://github.com/PicardParis/cherry-on-py-pics/raw/main/cr_image_processing/pics/4_denoising_botticelli.png)
317+
318+
Redeploy, and the app is automatically updated:
319+
320+
```sh
321+
gcloud run deploy $SERVICE --source $SOURCE
322+
```
323+
324+
## 🎉 It's alive
325+
326+
The app is visible as a service in Cloud Run:
327+
328+
![screenshot](https://github.com/PicardParis/cherry-on-py-pics/raw/main/cr_image_processing/pics/a_cloud_run_services.png)
329+
330+
The service dashboard gives you an overview of app usage:
331+
332+
![screenshot](https://github.com/PicardParis/cherry-on-py-pics/raw/main/cr_image_processing/pics/b_cloud_run_dashboard.png)
333+
334+
That's it; your image processing app is in production!
335+
336+
![Animated Demo](https://github.com/PicardParis/cherry-on-py-pics/raw/main/cr_image_processing/pics/demo.gif)
337+
338+
## 🤯 It's serverless
339+
340+
There are many benefits to using Cloud Run in this architecture:
341+
342+
- Your app is available 24/7.
343+
- The environment is fully managed: you can focus on your code and not worry about the infrastructure.
344+
- Your app is automatically available through HTTPS.
345+
- You can map your app to a custom domain.
346+
- Cloud Run scales the number of instances automatically and the billing includes only the resources used when your code runs.
347+
- If your app is not used, Cloud Run scales down to zero.
348+
- If your app gets more traffic (imagine it makes the news), Cloud Run scales up to the number of instances needed.
349+
- You can control performance and cost by fine-tuning many settings: CPU, memory, concurrency, minimum instances, maximum instances, and more.
350+
- Every month, the [free tier](https://cloud.google.com/run/pricing) offers the first 50 vCPU-hours, 100 GiB-hours, and 2 million requests for no cost.
351+
352+
## 💾 Source code
353+
354+
The project includes just seven files and less than 200 lines of Python + JavaScript code.
355+
356+
You can reuse this demo as a base to build your own image processing app:
357+
358+
- Check out the source code on [GitHub](https://github.com/PicardParis/cherry-on-py/tree/main/cr_image_processing).
359+
- For step-by-step instructions on deploying the app yourself in a few minutes, see [“Deploying from scratch”](https://github.com/PicardParis/cherry-on-py/blob/main/cr_image_processing/DEPLOY.md).
360+
361+
## 🖖 More
362+
363+
- [Try the demo](https://coloring-page.lolo.dev) and generate your own coloring pages.
364+
- [Learn more](https://cloud.google.com/run/docs) about Cloud Run.
365+
- For more cloud content, follow me on Twitter ([@PicardParis](https://twitter.com/PicardParis)) or LinkedIn ([in/PicardParis](https://linkedin.com/in/PicardParis)), and feel free to get in touch with any feedback or questions.

‎cr_image_processing/demo/Procfile

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
web: gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 0 main:app

‎cr_image_processing/demo/main.py

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""
2+
Copyright 2022 Google LLC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
"""
16+
import io
17+
import os
18+
19+
import flask
20+
import numpy as np
21+
import skimage
22+
from PIL import Image, ImageOps
23+
from PIL.Image import Image as PilImage
24+
25+
app = flask.Flask(__name__, static_url_path="")
26+
27+
28+
@app.get("/")
29+
def index():
30+
return app.send_static_file("index.html")
31+
32+
33+
@app.post("/api/coloring-page")
34+
def coloring_page():
35+
file = flask.request.files.get("input-image")
36+
if file is None:
37+
return "Missing input-image parameter", 400
38+
39+
input_image = Image.open(file.stream)
40+
output_image = generate_coloring_page(input_image)
41+
42+
image_io = io.BytesIO()
43+
output_format = "png"
44+
output_image.save(image_io, format=output_format)
45+
image_io.seek(0)
46+
47+
return flask.send_file(image_io, mimetype=f"image/{output_format}")
48+
49+
50+
def generate_coloring_page(input: PilImage) -> PilImage:
51+
# Convert to grayscale if needed
52+
if input.mode != "L":
53+
input = input.convert("L")
54+
# Transpose if taken in non-native orientation (rotated digital camera)
55+
NATIVE_ORIENTATION = 1
56+
if input.getexif().get(0x0112, NATIVE_ORIENTATION) != NATIVE_ORIENTATION:
57+
input = ImageOps.exif_transpose(input)
58+
np_image = np.asarray(input)
59+
60+
# Remove some noise to keep the most visible edges
61+
np_image = skimage.restoration.denoise_tv_chambolle(np_image, weight=0.05)
62+
# Detect the edges
63+
np_image = skimage.filters.sobel(np_image)
64+
# Convert to 8 bpp
65+
np_image = skimage.util.img_as_ubyte(np_image)
66+
# Invert to get dark edges on a light background
67+
np_image = 255 - np_image
68+
# Improve the contrast
69+
np_image = skimage.exposure.rescale_intensity(np_image)
70+
71+
return Image.fromarray(np_image)
72+
73+
74+
if __name__ == "__main__":
75+
# Dev only: run "python main.py" (3.9+) and open http://localhost:8080
76+
os.environ["FLASK_ENV"] = "development"
77+
app.run(host="localhost", port=8080, debug=True)
78+
else:
79+
# Prod only: cache static resources
80+
app.send_file_max_age_default = 3600 # 1 hour
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# https://pypi.org/project/gunicorn
2+
gunicorn==20.1.0
3+
4+
# https://pypi.org/project/flask
5+
Flask==2.1.0
6+
7+
# https://pypi.org/project/scikit-image
8+
# scikit-image dependencies include NumPy and Pillow, as well as other libraries
9+
scikit-image==0.19.2
5.3 KB
Binary file not shown.
+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<title>Coloring Page Generator</title>
6+
<meta name="description" content="Demo of a coloring page generator deployed with Cloud Run.">
7+
<meta name="viewport" content="width=device-width, initial-scale=1">
8+
<link rel="shortcut icon" href="favicon.ico">
9+
<link rel="stylesheet"
10+
href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.70/dist/themes/light.css">
11+
<script type="module"
12+
src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.70/dist/shoelace.js"></script>
13+
<link rel="stylesheet" href="styles.css">
14+
<script src="scripts.js" defer></script>
15+
</head>
16+
17+
<body>
18+
<h1>Coloring Page Generator</h1>
19+
<sl-card>
20+
<div id="input-block">
21+
<label for="input">Drop or select the picture to process</label>
22+
<br><br>
23+
<input type="file" id="input" name="input" accept="image/*">
24+
</div>
25+
<div id="spinner-block" hidden>
26+
<sl-spinner id="spinner" class="spinner-working"></sl-spinner>
27+
</div>
28+
<div id="output-block" hidden>
29+
<sl-image-comparer id="image-comparer">
30+
<img slot="before" id="before-image">
31+
<img slot="after" id="after-image">
32+
</sl-image-comparer>
33+
</div>
34+
</sl-card>
35+
<div id="command-block" hidden>
36+
<sl-button-group label="commands">
37+
<sl-tooltip content="Download">
38+
<sl-button size="large" id="download-button">
39+
<sl-icon name="file-arrow-down"></sl-icon>
40+
</sl-button>
41+
</sl-tooltip>
42+
<sl-tooltip content="Print">
43+
<sl-button size="large" id="print-button">
44+
<sl-icon name="printer"></sl-icon>
45+
</sl-button>
46+
</sl-tooltip>
47+
<sl-tooltip content="Source Code">
48+
<sl-button size="large" target="_blank"
49+
href="https://github.com/PicardParis/cherry-on-py/tree/main/cr_image_processing">
50+
<sl-icon name="file-earmark-text"></sl-icon>
51+
</sl-button>
52+
</sl-tooltip>
53+
</sl-button-group>
54+
</div>
55+
</body>
56+
57+
</html>
+165
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/*
2+
Copyright 2022 Google LLC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
const eInputBlock = document.getElementById('input-block')
17+
const eSpinnerBlock = document.getElementById('spinner-block')
18+
const eSpinner = document.getElementById('spinner')
19+
const eOutputBlock = document.getElementById('output-block')
20+
const eCommandBlock = document.getElementById('command-block')
21+
const eImageComparer = document.getElementById('image-comparer')
22+
const eBeforeImage = document.getElementById('before-image')
23+
const eAfterImage = document.getElementById('after-image')
24+
const eDownloadButton = document.getElementById('download-button')
25+
const ePrintButton = document.getElementById('print-button')
26+
var dropEffect = 'none'
27+
28+
async function fetchColoringPage(inputFile) {
29+
const formData = new FormData()
30+
formData.append('input-image', inputFile)
31+
32+
const url = '/api/coloring-page'
33+
const init = { method: 'POST', body: formData }
34+
try {
35+
const response = await fetch(url, init)
36+
return response.ok ? response.blob() : null
37+
} catch (error) {
38+
console.error(error)
39+
return null
40+
}
41+
}
42+
43+
async function onNewFile(event) {
44+
await processFile(this.files[0])
45+
}
46+
47+
async function processFile(inputFile) {
48+
onProcessStart(inputFile)
49+
const outputFile = await fetchColoringPage(inputFile)
50+
onProcessEnd(outputFile)
51+
}
52+
53+
async function onProcessStart(inputFile) {
54+
eInputBlock.hidden = true
55+
eOutputBlock.hidden = true
56+
eCommandBlock.hidden = true
57+
eSpinner.className = 'spinner-waiting'
58+
eSpinnerBlock.hidden = false
59+
60+
revokeElementSource(eAfterImage)
61+
revokeElementSource(eBeforeImage)
62+
eBeforeImage.download = inputFile.name
63+
eBeforeImage.src = URL.createObjectURL(inputFile)
64+
}
65+
66+
function revokeElementSource(element) {
67+
if (!element.src)
68+
return
69+
70+
URL.revokeObjectURL(element.src)
71+
element.src = ''
72+
}
73+
74+
async function onProcessEnd(coloringPage) {
75+
if (!coloringPage) {
76+
eSpinner.className = 'spinner-error'
77+
eSpinnerBlock.hidden = true
78+
eInputBlock.hidden = false
79+
return
80+
}
81+
eSpinner.className = 'spinner-finishing'
82+
83+
const fileStem = eBeforeImage.download.split('.')[0]
84+
const fileDate = new Date().toISOString().split('.')[0]
85+
const fileType = coloringPage.type.split('image/')[1]
86+
eAfterImage.download = `coloring-page_${fileStem}_${fileDate}.${fileType}`
87+
eAfterImage.src = URL.createObjectURL(coloringPage)
88+
await eBeforeImage.decode()
89+
await eAfterImage.decode()
90+
91+
eImageComparer.position = 50
92+
eDownloadButton.download = eAfterImage.download
93+
eDownloadButton.href = eAfterImage.src
94+
95+
eSpinnerBlock.hidden = true
96+
eOutputBlock.hidden = false
97+
eCommandBlock.hidden = false
98+
}
99+
100+
function onPrint(event) {
101+
const body = `
102+
<html><head><title>Coloring Page</title></head><body style="margin: 0">
103+
<img src="${eAfterImage.src}"
104+
style="width: 100vw; height: 100vh; object-fit: contain; overflow: hidden"
105+
onload="window.print(); window.close()">
106+
</body></html>`
107+
window.open('').document.write(body)
108+
}
109+
110+
function onDragEnterLeave(event) {
111+
event.preventDefault()
112+
switch (event.type) {
113+
case 'dragenter':
114+
if (draggedImageItem(event)) {
115+
document.body.className = 'drop-zone-active'
116+
dropEffect = 'copy'
117+
}
118+
return
119+
case 'dragleave':
120+
if (!event.relatedTarget) {
121+
document.body.className = ''
122+
dropEffect = 'none'
123+
}
124+
return
125+
}
126+
}
127+
128+
function onDragOver(event) {
129+
event.preventDefault()
130+
event.dataTransfer.dropEffect = dropEffect
131+
}
132+
133+
function onFileDrop(event) {
134+
event.preventDefault()
135+
document.body.className = ''
136+
const item = draggedImageItem(event)
137+
if (item)
138+
processFile(item.getAsFile())
139+
}
140+
141+
function draggedImageItem(event) {
142+
const dataTransfer = event.dataTransfer
143+
if (!dataTransfer || !dataTransfer.items) {
144+
console.log('DataTransfer is not supported')
145+
return null
146+
}
147+
if (dataTransfer.items.length != 1) {
148+
console.log('Only one input image is supported')
149+
return null
150+
}
151+
const item = dataTransfer.items[0]
152+
return (item.kind == 'file' && item.type.startsWith('image/')) ? item : null
153+
}
154+
155+
function init() {
156+
const eInputElement = document.getElementById('input')
157+
eInputElement.addEventListener('change', onNewFile, false)
158+
159+
document.ondragenter = document.ondragleave = onDragEnterLeave
160+
document.ondragover = onDragOver
161+
document.ondrop = onFileDrop
162+
ePrintButton.onclick = onPrint
163+
}
164+
165+
init()
+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
:root {
2+
--text-color: #3C4043;
3+
--frame-bg-color: #E8EAED;
4+
}
5+
6+
body {
7+
font-family: Roboto, Arial, Helvetica, sans-serif;
8+
color: var(--text-color);
9+
}
10+
11+
h1 {
12+
text-align: center;
13+
}
14+
15+
.drop-zone-active {
16+
background-color: #c4e2cc;
17+
}
18+
19+
sl-card, sl-button-group {
20+
display: flex;
21+
justify-content: center;
22+
align-items: center;
23+
}
24+
25+
sl-card::part(base) {
26+
background-color: var(--frame-bg-color);
27+
}
28+
29+
sl-spinner {
30+
font-size: 3rem;
31+
--track-width: 6px;
32+
}
33+
34+
sl-image-comparer::part(divider), sl-image-comparer::part(handle) {
35+
background-color: var(--frame-bg-color);
36+
}
37+
38+
#before-image, #after-image {
39+
max-height: 70vh;
40+
}
41+
42+
.spinner-working {
43+
--indicator-color: #4285F4;
44+
}
45+
46+
.spinner-finishing {
47+
--indicator-color: #34A853;
48+
}
49+
50+
.spinner-error {
51+
--indicator-color: red;
52+
}

0 commit comments

Comments
 (0)
Please sign in to comment.