diff --git a/examples/opencv-python/README.md b/examples/opencv-python/README.md
new file mode 100644
index 0000000..6a749a0
--- /dev/null
+++ b/examples/opencv-python/README.md
@@ -0,0 +1,18 @@
+# opencv-python Examples
+
+Each sub-directory contains a self-contained example. The order in
+which the examples are to appear is specified in `order.json` (an
+array of directory names in the expected order).
+
+In each example directory you'll find:
+
+* `config.toml` - must conform to the specification outlined here:
+ https://docs.pyscript.net/latest/user-guide/configuration/ This is
+ parsed and ultimately turned into a JSON representation as part of
+ the package's API object.
+* `setup.py` - Python code for contextual and environmental setup,
+ NOT SEEN BY THE END USER, but is run before the `code.py` code is
+ evaluated. Allows us to create useful (IPython) shims, avoid
+ repeating boilerplate and whatnot.
+* `code.py` - the actual code added to the editor which forms the
+ practical example of using the package.
diff --git a/examples/opencv-python/filters_and_edges/code.py b/examples/opencv-python/filters_and_edges/code.py
new file mode 100644
index 0000000..0d0b7f5
--- /dev/null
+++ b/examples/opencv-python/filters_and_edges/code.py
@@ -0,0 +1,58 @@
+# ---------------------------------------------------------------------
+# Smoothing noisy images and finding edges with Canny.
+# ---------------------------------------------------------------------
+
+heading("Build a noisy test image")
+note(
+ "We make a synthetic grayscale scene of overlapping shapes, "
+ "then add Gaussian noise. This gives us something to denoise "
+ "and find edges in."
+)
+
+scene = np.full((200, 300), 40, dtype=np.uint8)
+cv2.rectangle(scene, (30, 40), (140, 160), 200, thickness=-1)
+cv2.circle(scene, (210, 100), 55, 140, thickness=-1)
+cv2.rectangle(scene, (170, 30), (260, 80), 90, thickness=-1)
+
+# Add Gaussian noise and clip back to valid 8-bit range.
+noise = rng.normal(0, 25, size=scene.shape)
+noisy = np.clip(scene.astype(np.int16) + noise, 0, 255).astype(np.uint8)
+
+note(
+ f"Clean image dtype: {scene.dtype}, range "
+ f"[{scene.min()}, {scene.max()}]. Noisy range "
+ f"[{noisy.min()}, {noisy.max()}]."
+)
+
+# ---------------------------------------------------------------------
+# Smooth with a Gaussian blur, then run Canny edge detection.
+# ---------------------------------------------------------------------
+
+# Gaussian blur: kernel size must be odd. Sigma 0 lets cv2 derive it.
+smoothed = cv2.GaussianBlur(noisy, ksize=(5, 5), sigmaX=0)
+
+# Canny works best on a smoothed image. The two thresholds control
+# which gradient magnitudes are kept (low) and which definitely start
+# an edge (high).
+edges_noisy = cv2.Canny(noisy, threshold1=80, threshold2=160)
+edges_smooth = cv2.Canny(smoothed, threshold1=80, threshold2=160)
+
+fig, axes = plt.subplots(2, 2, figsize=(9, 6))
+axes[0, 0].imshow(noisy, cmap="gray")
+axes[0, 0].set_title("Noisy input")
+axes[0, 1].imshow(smoothed, cmap="gray")
+axes[0, 1].set_title("Gaussian-blurred")
+axes[1, 0].imshow(edges_noisy, cmap="gray")
+axes[1, 0].set_title("Canny on noisy (lots of false edges)")
+axes[1, 1].imshow(edges_smooth, cmap="gray")
+axes[1, 1].set_title("Canny on blurred (cleaner edges)")
+for ax in axes.flat:
+ ax.axis("off")
+fig.tight_layout()
+display(fig, append=True)
+
+note(
+ "Smoothing first removes high-frequency noise so the gradient-"
+ "based Canny detector finds the true object boundaries instead "
+ "of grain."
+)
diff --git a/examples/opencv-python/filters_and_edges/config.toml b/examples/opencv-python/filters_and_edges/config.toml
new file mode 100644
index 0000000..25da125
--- /dev/null
+++ b/examples/opencv-python/filters_and_edges/config.toml
@@ -0,0 +1 @@
+packages = ["opencv-python", "numpy", "matplotlib"]
diff --git a/examples/opencv-python/filters_and_edges/setup.py b/examples/opencv-python/filters_and_edges/setup.py
new file mode 100644
index 0000000..64394f5
--- /dev/null
+++ b/examples/opencv-python/filters_and_edges/setup.py
@@ -0,0 +1,24 @@
+"""Setup for the filtering example."""
+import js
+from pyscript import window, HTML, display as _display
+
+js.alert = window.alert
+
+
+def display(*args, **kwargs):
+ return _display(*args, **kwargs, target=__pyscript_display_target__)
+
+
+import numpy as np
+import cv2
+import matplotlib.pyplot as plt
+
+rng = np.random.default_rng(7)
+
+
+def heading(text, level=2):
+ display(HTML(f"
{text}
"), append=True) diff --git a/examples/opencv-python/image_basics/code.py b/examples/opencv-python/image_basics/code.py new file mode 100644 index 0000000..9d0fd8b --- /dev/null +++ b/examples/opencv-python/image_basics/code.py @@ -0,0 +1,83 @@ +""" +A first look at OpenCV (cv2): build a synthetic image, draw on it, +and convert between color spaces. + +OpenCV stores images as NumPy arrays. Color images are typically in +BGR order (blue, green, red), which differs from matplotlib's RGB. +We'll use cv2.cvtColor to translate between the two when displaying. + +Docs: https://docs.opencv.org/4.x/ +""" +from IPython.core.display import display, HTML + +import numpy as np +import cv2 +import matplotlib.pyplot as plt + +rng = np.random.default_rng(7) + + +heading("Drawing on a blank canvas") +note( + "We start with a black 200x300 BGR image (a NumPy array of " + "zeros) and draw a few primitives on it: a rectangle, a " + "filled circle, a line, and some text." +) + +# Create a blank BGR image: height=200, width=300, 3 channels. +canvas = np.zeros((200, 300, 3), dtype=np.uint8) + +# Colors are (B, G, R) tuples in OpenCV. +cv2.rectangle(canvas, (20, 20), (140, 120), (0, 200, 255), thickness=3) +cv2.circle(canvas, (220, 70), 40, (0, 255, 0), thickness=-1) # filled +cv2.line(canvas, (20, 160), (280, 160), (255, 100, 100), thickness=2) +cv2.putText( + canvas, "hello, cv2", + org=(40, 190), + fontFace=cv2.FONT_HERSHEY_SIMPLEX, + fontScale=0.7, + color=(255, 255, 255), + thickness=2, + lineType=cv2.LINE_AA, +) + +note(f"Canvas shape: {canvas.shape}, dtype: {canvas.dtype}") + +# Convert BGR -> RGB so matplotlib displays the colors correctly. +canvas_rgb = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB) + +fig, ax = plt.subplots(figsize=(6, 4)) +ax.imshow(canvas_rgb) +ax.set_title("Primitives drawn with cv2") +ax.axis("off") +fig.tight_layout() +display(fig, append=True) + + +heading("Color spaces: BGR, RGB, and grayscale") +note( + "Real images come in many color spaces. Here we build a small " + "BGR test image with three colored stripes, then view it as " + "RGB (wrong) and after converting BGR->RGB (correct), and as " + "grayscale." +) + +stripes = np.zeros((120, 300, 3), dtype=np.uint8) +stripes[:, :100] = (255, 0, 0) # blue stripe in BGR +stripes[:, 100:200] = (0, 255, 0) # green stripe +stripes[:, 200:] = (0, 0, 255) # red stripe + +stripes_rgb = cv2.cvtColor(stripes, cv2.COLOR_BGR2RGB) +stripes_gray = cv2.cvtColor(stripes, cv2.COLOR_BGR2GRAY) + +fig, axes = plt.subplots(1, 3, figsize=(10, 3)) +axes[0].imshow(stripes) # matplotlib assumes RGB, so colors look swapped +axes[0].set_title("BGR shown as RGB (wrong)") +axes[1].imshow(stripes_rgb) +axes[1].set_title("After BGR->RGB (correct)") +axes[2].imshow(stripes_gray, cmap="gray") +axes[2].set_title("Grayscale") +for ax in axes: + ax.axis("off") +fig.tight_layout() +display(fig, append=True) diff --git a/examples/opencv-python/image_basics/config.toml b/examples/opencv-python/image_basics/config.toml new file mode 100644 index 0000000..25da125 --- /dev/null +++ b/examples/opencv-python/image_basics/config.toml @@ -0,0 +1 @@ +packages = ["opencv-python", "numpy", "matplotlib"] diff --git a/examples/opencv-python/image_basics/setup.py b/examples/opencv-python/image_basics/setup.py new file mode 100644 index 0000000..84faac4 --- /dev/null +++ b/examples/opencv-python/image_basics/setup.py @@ -0,0 +1,40 @@ +""" +Shim IPython's display API onto PyScript so example code written in a +Jupyter/IPython idiom runs unmodified in the browser. +""" + +import sys +import types +import js +from pyscript import window, HTML, display as _display + +js.alert = window.alert + + +def display(*args, **kwargs): + return _display( + *args, **kwargs, target=__pyscript_display_target__, + ) + + +ipython = types.ModuleType("IPython") +core = types.ModuleType("IPython.core") +core_display = types.ModuleType("IPython.core.display") +core_display.display = display +core_display.HTML = HTML +ipython.core = core +core.display = core_display +ipython.get_ipython = lambda: None +ipython.display = core_display +sys.modules["IPython"] = ipython +sys.modules["IPython.core"] = core +sys.modules["IPython.core.display"] = core_display +sys.modules["IPython.display"] = core_display + + +def heading(text, level=2): + display(HTML(f"{text}
"), append=True) diff --git a/examples/opencv-python/order.json b/examples/opencv-python/order.json new file mode 100644 index 0000000..4cb8d5a --- /dev/null +++ b/examples/opencv-python/order.json @@ -0,0 +1,5 @@ +[ + "image_basics", + "filters_and_edges", + "thresholding_and_contours" +] diff --git a/examples/opencv-python/thresholding_and_contours/code.py b/examples/opencv-python/thresholding_and_contours/code.py new file mode 100644 index 0000000..2c78f9f --- /dev/null +++ b/examples/opencv-python/thresholding_and_contours/code.py @@ -0,0 +1,86 @@ +# --------------------------------------------------------------------- +# Segment shapes from the background and measure them with contours. +# --------------------------------------------------------------------- + +heading("A scattering of coins (well, disks)") +note( + "We sprinkle a handful of bright disks of varying size onto a " + "dark background, then use thresholding and findContours to " + "count them and measure each one's area and centroid." +) + +# Synthetic scene: dark background with several light circles. +image = np.full((260, 360, 3), 30, dtype=np.uint8) + +disks = [ + (60, 70, 25), + (140, 90, 35), + (240, 60, 20), + (310, 130, 30), + (90, 180, 40), + (200, 200, 28), + (290, 210, 22), +] +for cx, cy, r in disks: + color = tuple(int(v) for v in rng.integers(180, 255, size=3)) + cv2.circle(image, (cx, cy), r, color, thickness=-1) + +# Threshold on grayscale to separate foreground from background. +gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) +_, binary = cv2.threshold(gray, 100, 255, cv2.THRESH_BINARY) + +# Find external contours (one per disk). +contours, _ = cv2.findContours( + binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE, +) + +note(f"Found {len(contours)} contours.") + +# Annotate a copy of the original image with each contour's bounding +# box, centroid, and area. +annotated = image.copy() +rows = [] +for index, contour in enumerate(contours, start=1): + area = cv2.contourArea(contour) + x, y, w, h = cv2.boundingRect(contour) + + # Image moments give us the centroid (cx, cy) of the contour. + moments = cv2.moments(contour) + if moments["m00"] > 0: + cx = int(moments["m10"] / moments["m00"]) + cy = int(moments["m01"] / moments["m00"]) + else: + cx, cy = x + w // 2, y + h // 2 + + cv2.rectangle(annotated, (x, y), (x + w, y + h), (0, 255, 255), 2) + cv2.circle(annotated, (cx, cy), 3, (0, 0, 255), -1) + cv2.putText( + annotated, str(index), (cx + 6, cy - 6), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1, cv2.LINE_AA, + ) + rows.append((index, area, (cx, cy), (w, h))) + +# Display the pipeline: original, binary mask, annotated result. +fig, axes = plt.subplots(1, 3, figsize=(12, 4)) +axes[0].imshow(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) +axes[0].set_title("Original") +axes[1].imshow(binary, cmap="gray") +axes[1].set_title("Binary mask (threshold)") +axes[2].imshow(cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB)) +axes[2].set_title("Contours, bounding boxes, centroids") +for ax in axes: + ax.axis("off") +fig.tight_layout() +display(fig, append=True) + +# Print a small report of the per-disk measurements. +report_rows = "".join( + f"| # | Area (px) | " + "Centroid | Bounding box |
|---|
{text}
"), append=True)