单元六
第21课 AI 视觉检测入门

一、任务目标
分清「检测」和「识别」:本课先找位置画框,不急着认「是谁」;并知道网页里能做什么。
二、核心概念讲清楚
检测:有没有?在哪里?(画框) 识别:这是谁?(需要额外训练,本课不支持)
- Haar 级联:人脸、眼睛、微笑
- HOG:行人检测
- QRCodeDetector:二维码检测并读出文字
- 本课不做:深度人脸识别、认人身份
1取图文件或摄像头一帧
2选模型级联/HOG/扫码
3检测得到框或文字
4标注画框/打印结果
三、跟着想一遍(小例子)
自拍一张:人脸检测会画绿框;但程序不知道你叫什么——那是识别。
易错:把「画了人脸框」说成「认出是某某同学」。
正确:说「检测到一张脸,位置在矩形框内」。
想一想:为什么网页版更适合「检测」而不是「认人」?
四、算法思路
任务:分清「检测」和「识别」:本课先找位置画框,不急着认「是谁」;并知道网页里能做什么。 先想清楚:输入是什么、处理后得到什么、屏幕上应出现什么。
本课主要用到:图像输入、图像预览/对照区。
- 1明确任务「AI 视觉检测入门」:运行后你希望看到什么结果(文字、图形或计算结果)。
- 2确认输入:演示图/上传照片/摄像头抓一帧,得到一张可用的 BGR 图。
- 3用 imread 读图;若失败(None),改用演示图或提示上传,保证课能继续。
- 4先做颜色转换(如转灰或转 HSV),再进入后续处理。
- 5加载检测器 → 在图上检测 → 用矩形/文字把结果标给人看。
- 6处理完成后 imwrite 保存,形成「读→处理→保存」闭环。
- 7用 print 核对 shape/检测数量等关键信息,并对照图像页预览。
流程示意(可对照左侧文字)
五、语句功能讲解
本课用到的 Mixly 图形块均已在前面的课讲过。请直接对照编程练习搭积木;若有遗忘,回到该积木首次出现的课页复习。
六、知识点讲解
传感器把现实中的光、温、距离等变成程序可读的数值。先弄清它测的是什么、读数大概什么范围,再设阈值或做显示;读数会抖,必要时可多次读取或做简单平均。
先弄清本课输入与输出,再进编程练习对照现象。
七、编程练习
先确认模型能否加载;没有人脸的演示图检测到 0 个也正常。请先读本页「四、算法思路」与「五、知识讲解」,再点载入。
确认 CascadeClassifier 可用。
参考代码(对照抄写到下方 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
face = load_cascade("haarcascade_frontalface_default.xml")
print("人脸模型就绪:", face is not None and not face.empty())
print("检测 ≠ 识别:本课只找位置")
img, source = get_frame(prefer_camera=True, path="photo.jpg")
print("取图来源:", source)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
boxes = detect_boxes(gray, face)
print("检测到人脸数:", len(boxes))
show_bgr(draw_boxes(img, boxes, (0, 255, 0), "face"), "预览")
正在载入编程环境
正在载入 Mixly 文本编程环境…
八、课堂练习
选出你认为正确的一项,再点「检查」。
本单元的「人脸检测」主要回答?
九、本节小结
单元六做检测:人脸/眼睛/行人/二维码;不做「认出是谁」。