单元四
第16课 综合:简易滤镜相机

一、任务目标
做一台简易滤镜相机:取图后可选灰度/模糊/边缘,再保存——像手机滤镜,但步骤你看得见。
二、核心概念讲清楚
用分支选择不同 cv2 处理函数,最后统一保存/展示。这是小项目的常见结构。
- 先读图
- 按选项调用不同处理
- 输出文件名可带滤镜名
1读入统一入口
2选择滤镜分支
3处理灰/模糊/边缘
4保存可检查结果
三、跟着想一遍(小例子)
选项=2 走模糊分支,生成 blur_out.png。
易错:每个分支重复读图代码,难维护。
正确:读图一次,分支只写处理差异。
想一想:如何扩展第四种滤镜最省事?
四、算法思路
任务:做一台简易滤镜相机:取图后可选灰度/模糊/边缘,再保存——像手机滤镜,但步骤你看得见。 先想清楚:输入是什么、处理后得到什么、屏幕上应出现什么。
本课主要用到:图像输入、图像预览/对照区。
- 1明确任务「综合:简易滤镜相机」:运行后你希望看到什么结果(文字、图形或计算结果)。
- 2确认输入:演示图/上传照片/摄像头抓一帧,得到一张可用的 BGR 图。
- 3用 imread 读图;若失败(None),改用演示图或提示上传,保证课能继续。
- 4先做颜色转换(如转灰或转 HSV),再进入后续处理。
- 5需要去噪时先模糊,再阈值或边缘,减少噪点干扰。
- 6用 Canny 找出边缘线,观察高低阈值对结果的影响。
- 7加载检测器 → 在图上检测 → 用矩形/文字把结果标给人看。
流程示意(可对照左侧文字)
五、语句功能讲解
本课用到的 Mixly 图形块均已在前面的课讲过。请直接对照编程练习搭积木;若有遗忘,回到该积木首次出现的课页复习。
六、知识点讲解
图像输入与显示:预览/采集 → 处理或保存 → 显示或回放。缺一环后面都做不了。
先弄清本课输入与输出,再进编程练习对照现象。
七、编程练习
先理解概念,再在下方 Mixly 运行(opencv-python);没有摄像头可用演示图或上传照片。请先读本页「四、算法思路」与「五、知识讲解」,再点载入。
摄像头/文件取帧 → 三选一滤镜 → 保存。
参考代码(对照抄写到下方 Mixly)
import cv2
import numpy as np
def make_demo(h=120, w=160):
img = np.zeros((h, w, 3), dtype=np.uint8)
img[:, :] = (40, 90, 200)
cv2.rectangle(img, (15, 20), (70, 90), (0, 220, 0), -1)
cv2.circle(img, (115, 60), 28, (220, 60, 60), -1)
return img
def show_bgr(img, title="预览"):
"""显示到 Mixly「图像」页签右侧对照区(灰度会先转成 BGR)"""
if img is None:
return None
vis = img
if len(getattr(img, "shape", ())) == 2:
vis = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
# 课页对照布局:右栏「处理结果」(不依赖 imshow 是否进图像页)
try:
from js import window
push = getattr(window, "__ailessonsShowCvResultB64", None)
if push:
ok, buf = cv2.imencode(".jpg", vis, [int(cv2.IMWRITE_JPEG_QUALITY), 88])
if ok:
import base64
push(base64.b64encode(buf.tobytes()).decode("ascii"), str(title or "处理结果"))
except Exception as e:
print("对照区显示跳过:", e)
# Mixly 积木常见:imshow(图, 标题);标准 OpenCV:imshow(标题, 图)
try:
cv2.imshow(vis, title)
except Exception:
try:
cv2.imshow(title, vis)
except Exception as e:
print("图像页显示跳过:", e)
return None
def show_text(text, title="处理结果"):
"""把文字结果显示到「图像」页右侧「处理结果」(可与图片同屏:图下说明)"""
msg = "" if text is None else str(text)
try:
from js import window
push = getattr(window, "__ailessonsShowCvResultText", None)
if push:
push(msg, str(title or "处理结果"))
except Exception as e:
print("对照区文字跳过:", e)
if msg:
print(msg)
return None
def _scan_fs_mounts():
"""不依赖 JS:扫描 Pyodide 根目录,找出 Mixly 挂载的本地文件夹。"""
import os
mounts = []
skip = {
"home", "tmp", "dev", "proc", "lib", "usr", "bin", "etc",
"media", "mnt", "opt", "root", "run", "sbin", "srv", "sys", "var",
"lib64", "boot", "data",
}
try:
for name in os.listdir("/"):
if not name or name in skip or name.startswith("."):
continue
p = "/" + name
try:
if not os.path.isdir(p):
continue
sub = os.listdir(p)
except Exception:
continue
hit = False
for s in sub:
sl = str(s).lower()
if sl == "zhengjian" or sl.endswith((".jpg", ".jpeg", ".png", ".bmp", ".webp")):
hit = True
break
try:
sp = p + "/" + s
if os.path.isdir(sp):
for s2 in os.listdir(sp):
if str(s2).lower().endswith((".jpg", ".jpeg", ".png", ".bmp", ".webp")):
hit = True
break
except Exception:
pass
if hit:
break
if hit:
mounts.append(p)
except Exception:
pass
return mounts
def _native_mounts():
"""Mixly「本地文件系统」挂载点,如 ['/某文件夹']。"""
mounts = []
try:
from js import window
fn = getattr(window, "__ailessonsListNativeMounts", None)
if fn:
arr = fn()
for i in range(len(arr)):
mounts.append(str(arr[i]))
except Exception:
pass
for m in _scan_fs_mounts():
if m not in mounts:
mounts.append(m)
return mounts
def _preferred_save_dir():
"""photo.jpg 所在子目录(如 zhengjian),保存结果写到这里文件树才看得到。"""
pref = ""
try:
from js import window
fn = getattr(window, "__ailessonsGetPreferredSaveDir", None)
if fn:
pref = str(fn() or "").strip().strip("/")
if not pref:
pref = str(getattr(window, "__ailessonsPreferredSaveDir", "") or "").strip().strip("/")
except Exception:
pref = ""
if pref:
return pref
# 自动探测 zhengjian
import os
for m in _native_mounts():
z = m.rstrip("/") + "/zhengjian"
try:
if os.path.isdir(z):
return "zhengjian"
except Exception:
pass
return ""
def _has_local_fs():
if len(_native_mounts()) > 0:
return True
try:
from js import window
fn = getattr(window, "__ailessonsHasLocalFs", None)
if fn and bool(fn()):
return True
# 直接问 Mixly 文件页是否已挂载
try:
mgr = window.Mixly.Workspace.getMain().getStatusBarsManager()
page = mgr.get("file-system")
if page and callable(getattr(page, "getNativeFS", None)):
if page.getNativeFS():
return True
except Exception:
pass
except Exception:
pass
return False
def _prefer_local_image():
"""课页弹窗点了「改用本地图」后为 True,不再抢摄像头帧。"""
try:
from js import window
return bool(getattr(window, "__ailessonsPreferLocalImage", False))
except Exception:
return False
def _await_js(promise):
"""尽量等 JS Promise 完成。失败时返回 None(不要把 Promise 对象当成成功)。"""
if promise is None:
return None
# 若仍是 thenable,说明没真正 await 到
try:
if hasattr(promise, "then") and not isinstance(promise, (bool, int, float, str)):
try:
from pyodide.ffi import run_sync
return run_sync(promise)
except Exception:
return None
except Exception:
pass
try:
from pyodide.ffi import run_sync
return run_sync(promise)
except Exception:
pass
# 已是具体值
if promise is True or promise is False:
return promise
try:
if hasattr(promise, "then"):
return None
except Exception:
pass
return promise
def _write_local_fs(name):
"""把已写入内存盘的图片同步到「本地文件系统」页并刷新文件树。"""
try:
from js import window
fn = getattr(window, "__ailessonsWriteLocalFile", None)
if not fn:
return False
result = _await_js(fn(name))
if result is True:
return True
if result is False or result is None:
# 异步未完成时交给 eval.finished 钩子;此处不谎报成功
return False
return bool(result)
except Exception as e:
print("同步本地文件系统失败:", e)
return False
def save_bgr(path, img):
"""保存图片到「本地文件系统」(与 photo.jpg 同目录优先),并在「图像」页预览。"""
if img is None:
print("保存失败: 图像为空")
return False
vis = img
if len(getattr(img, "shape", ())) == 2:
vis = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
import os
name = os.path.basename(str(path)) or str(path)
mounts = _native_mounts()
pref = _preferred_save_dir()
ok = False
saved = None
targets = []
for m in mounts:
root = str(m).rstrip("/")
if pref:
targets.append(root + "/" + pref + "/" + name)
targets.append(root + "/" + name)
targets.append(name)
if path not in targets:
targets.append(path)
for t in targets:
try:
parent = os.path.dirname(t)
if parent:
try:
os.makedirs(parent, exist_ok=True)
except Exception:
pass
if cv2.imwrite(t, vis):
ok = True
saved = t
break
except Exception:
continue
show_bgr(vis, name)
if not ok:
print("保存失败", path)
return False
synced = _write_local_fs(name)
rel = (pref + "/" + name) if pref else name
if synced:
print("已保存", name, "→ 本地文件系统", rel)
elif _has_local_fs():
print("已保存", name, "→ 图像页可预览;正在同步到本地文件夹", rel, "(完成后看文件树 / 输出区提示)")
else:
print("已保存", name, "→ 图像页可预览;请先在「本地文件系统」载入文件夹后再运行")
return True
def load_bgr(path="photo.jpg"):
import os
name = os.path.basename(str(path)) or str(path)
# 若本次数运行前已抓拍摄像头帧,优先用它(避免本地 photo.jpg 盖住实时抓拍)
# 「改用本地图」时不要再抢 camera_frame
if (not _prefer_local_image()) and str(name).lower() in ("photo.jpg", "photo.jpeg", "photo.png", "camera_frame.jpg"):
for cam in ("camera_frame.jpg", "/home/pyodide/camera_frame.jpg"):
if not os.path.exists(cam):
continue
try:
img = cv2.imread(cam)
except Exception:
img = None
if img is not None and getattr(img, "size", 0) > 0:
print("已读入", cam, "(摄像头帧) shape=", img.shape)
return img
pref = _preferred_save_dir()
# 重要:优先读「本地文件系统」挂载目录,避免 cwd 里旧的演示图抢先命中
candidates = []
for m in _native_mounts():
root = m.rstrip("/")
if pref:
candidates.append(root + "/" + pref + "/" + name)
candidates.append(root + "/" + name)
try:
for sub in os.listdir(root):
sub_path = root + "/" + sub
if os.path.isdir(sub_path):
candidates.append(sub_path + "/" + name)
elif str(sub).lower() == name.lower():
candidates.append(sub_path)
except Exception:
pass
candidates.extend([path, name, "/home/pyodide/" + name])
seen = set()
for c in candidates:
if not c or c in seen:
continue
seen.add(c)
if not os.path.exists(c):
continue
try:
img = cv2.imread(c)
except Exception:
img = None
if img is not None and getattr(img, "size", 0) > 0:
# 根据读到的路径回写 preferred,保证 filter_out 落到同一目录
try:
from js import window
parent = os.path.dirname(c).replace(chr(92), "/").strip("/")
mounts = _native_mounts()
for m in mounts:
root = str(m).rstrip("/").lstrip("/")
if parent == root:
window.__ailessonsPreferredSaveDir = ""
break
prefix = root + "/"
if parent.startswith(prefix):
window.__ailessonsPreferredSaveDir = parent[len(prefix):]
break
else:
# 无挂载前缀时,若路径含 zhengjian 则记下
if "/zhengjian" in ("/" + parent) or parent.endswith("zhengjian"):
window.__ailessonsPreferredSaveDir = "zhengjian"
except Exception:
pass
print("已读入", c, "shape=", img.shape)
return img
print("未找到", name, "→ 生成默认图")
try:
import os
print("调试: mounts=", _native_mounts(), "pref=", _preferred_save_dir(), "cwd文件=", [x for x in os.listdir(".") if str(x).lower().endswith((".jpg", ".png", ".jpeg"))][:8])
except Exception:
pass
img = make_demo()
save_bgr(name, img)
return img
def grab_camera_frame(cam_index=0, save_path="camera_frame.jpg"):
"""取摄像头一帧为 BGR 图。
网页 Mixly(Pyodide)里 cv2.VideoCapture(0) 打不开设备——即使浏览器已授权也不行。
课页会在点「运行」前用浏览器摄像头抓拍,写入 camera_frame.jpg / photo.jpg。
本函数优先读这些文件;没有时再请求浏览器抓拍;桌面真机再尝试 VideoCapture。
"""
import os
if _prefer_local_image():
print("已改用本地图,跳过摄像头")
return None
def _try_read(paths):
for path in paths:
if not path:
continue
for cand in (path, os.path.basename(path), "/home/pyodide/" + os.path.basename(str(path))):
if not cand or not os.path.exists(cand):
continue
try:
img = cv2.imread(cand)
except Exception:
img = None
if img is not None and getattr(img, "size", 0) > 0:
print("摄像头取帧成功 shape=", img.shape, "←", cand)
if save_path and cand != save_path:
try:
cv2.imwrite(save_path, img)
except Exception:
pass
return img
return None
# 1) 浏览器课页预抓拍(只认 camera_frame,勿把本地 photo 当成摄像头)
img = _try_read((save_path, "camera_frame.jpg"))
if img is not None:
return img
# 2) 运行中再请浏览器抓一帧(权限已授予时可不依赖点击手势)
try:
from js import window
if getattr(window, "__ailessonsPreferLocalImage", False):
return None
fn = getattr(window, "__ailessonsCaptureCamera", None)
if fn:
ok = _await_js(fn())
if ok:
img = _try_read((save_path, "camera_frame.jpg"))
if img is not None:
return img
except Exception as e:
print("浏览器抓拍跳过:", e)
img = _try_read((save_path, "camera_frame.jpg"))
if img is not None:
return img
# 3) 桌面/真机 OpenCV
try:
cap = cv2.VideoCapture(cam_index)
except Exception as e:
print("打开摄像头异常:", e)
return None
if not cap or not cap.isOpened():
print("摄像头未打开:请重新点「运行」,在弹窗中允许摄像头;或点右下角「打开摄像头」。也可改用本地 photo.jpg")
try:
if cap:
cap.release()
except Exception:
pass
return None
ok, frame = cap.read()
try:
cap.release()
except Exception:
pass
if (not ok) or (frame is None):
print("摄像头读帧失败")
return None
print("摄像头取帧成功 shape=", frame.shape, "← VideoCapture")
if save_path:
save_bgr(save_path, frame)
return frame
def get_frame(prefer_camera=True, path="photo.jpg"):
"""优先摄像头取帧并处理;失败则读本地文件/演示图。返回 (img, source)。"""
if _prefer_local_image():
prefer_camera = False
if prefer_camera:
cam = grab_camera_frame(0, "camera_frame.jpg")
if cam is not None:
return cam, "camera"
img = load_bgr(path)
return img, ("local" if _prefer_local_image() else "file_or_demo")
def load_cascade(xml_name="haarcascade_frontalface_default.xml"):
"""加载 OpenCV 自带 Haar/LBP 级联模型。成功返回 CascadeClassifier,失败返回 None。"""
import os
cands = []
try:
base = getattr(cv2, "data", None)
if base is not None:
cands.append(os.path.join(base.haarcascades, xml_name))
except Exception:
pass
cands.extend([xml_name, "/home/pyodide/" + xml_name])
for p in cands:
if not p or not os.path.exists(p):
continue
try:
cascade = cv2.CascadeClassifier(p)
if cascade is not None and not cascade.empty():
print("已加载模型:", xml_name)
return cascade
except Exception:
pass
print("模型加载失败:", xml_name)
return None
def detect_boxes(gray, cascade, scaleFactor=1.1, minNeighbors=5, minSize=(40, 40)):
"""对灰度图做 detectMultiScale,返回 [(x,y,w,h), ...](整数坐标)。"""
if cascade is None or gray is None:
return []
boxes = cascade.detectMultiScale(
gray, scaleFactor=scaleFactor, minNeighbors=minNeighbors, minSize=minSize
)
if boxes is None or len(boxes) == 0:
return []
out = []
for b in boxes:
x, y, w, h = [int(v) for v in b]
out.append((x, y, w, h))
return out
def draw_boxes(img, boxes, color=(0, 255, 0), label="obj", thickness=2):
"""在图上画矩形框。"""
vis = img.copy()
for (x, y, w, h) in boxes:
x, y, w, h = int(x), int(y), int(w), int(h)
cv2.rectangle(vis, (x, y), (x + w, y + h), color, thickness)
cv2.putText(vis, label, (x, max(12, y - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
return vis
def detect_people_hog(img, winStride=(8, 8), padding=(8, 8), scale=1.05):
"""HOG 默认行人检测。返回 [(x,y,w,h), ...]。"""
hog = cv2.HOGDescriptor()
hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
rects, _ = hog.detectMultiScale(img, winStride=winStride, padding=padding, scale=scale)
return list(rects) if rects is not None else []
def decode_qr(img):
"""二维码检测并解码。返回 (text, points);失败 text 为空字符串。"""
det = cv2.QRCodeDetector()
try:
text, points, _ = det.detectAndDecode(img)
except Exception as e:
print("二维码解码异常:", e)
return "", None
return (text or ""), points
frame, source = get_frame(prefer_camera=True)
print("输入来源:", source)
mode = "edges" # 可改成 "gray" / "blur" / "edges"
if mode == "gray":
out = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
elif mode == "blur":
out = cv2.GaussianBlur(frame, (11, 11), 0)
else:
g = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
out = cv2.Canny(g, 60, 120)
show_bgr(out, "滤镜结果")
save_bgr("filter_out.png", out)
print("滤镜模式:", mode)
print("输出 shape:", out.shape)
正在载入编程环境
正在载入 Mixly 文本编程环境…
八、课堂练习
选出你认为正确的一项,再点「检查」。
滤镜相机里最关键的一步是?
九、本节小结
滤镜 = 取图 + 一种(或多种)处理 + 输出。