单元三

第9课 模糊与去噪

模糊柔化
任务场景:模糊柔化
模糊
原理图:高斯模糊用邻域平均抹平细节

一、任务目标

给图加一点「糊」,噪声被抹平:像眯着眼看,毛刺少了,轮廓更干净。

二、核心概念讲清楚

GaussianBlur 用周围像素加权平均。核越大越糊。常作为边缘检测前的预处理。

  • 输入图像与核大小
  • 核一般为奇数
  • 过大核会丢重要边缘
1读图可先转灰
2选核大小如 (5,5)
3GaussianBlur得到模糊图
4对比原图看细节变化

三、跟着想一遍(小例子)

对噪声点较多的图先模糊,再做阈值,结果更稳。

易错:核大到把目标糊没。

正确:从小核试起。

想一想:为什么边缘检测前常先模糊?

四、算法思路

任务:给图加一点「糊」,噪声被抹平:像眯着眼看,毛刺少了,轮廓更干净。 先想清楚:输入是什么、处理后得到什么、屏幕上应出现什么。

本课主要用到:图像输入、图像预览/对照区。

  1. 1明确任务「模糊与去噪」:运行后你希望看到什么结果(文字、图形或计算结果)。
  2. 2确认输入:演示图/上传照片/摄像头抓一帧,得到一张可用的 BGR 图。
  3. 3用 imread 读图;若失败(None),改用演示图或提示上传,保证课能继续。
  4. 4先做颜色转换(如转灰或转 HSV),再进入后续处理。
  5. 5需要去噪时先模糊,再阈值或边缘,减少噪点干扰。
  6. 6加载检测器 → 在图上检测 → 用矩形/文字把结果标给人看。
  7. 7处理完成后 imwrite 保存,形成「读→处理→保存」闭环。

流程示意(可对照左侧文字)

任务:模糊与去噪确认输入:演示图/上传照片…用imread读图;若失败…先做颜色转换(如转灰或转H…需要去噪时先模糊,再阈值或…

五、语句功能讲解

下面只列出本课首次出现的语句/函数。已在前面课讲过的不再重复;先认新语句,再对照参考代码。

语句作用本课用法
randint()随机整数randint(a, b) 含两端。
GaussianBlur()高斯模糊抹平噪声,核大小多为奇数。

六、知识点讲解

输入—处理—输出:先弄清本课输入是什么、按什么规则判断、输出应看到什么变化;再用仿真核对现象。

先弄清本课输入与输出,再进编程练习对照现象。

七、编程练习

先理解概念,再在下方 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

img = load_bgr()
blur = cv2.GaussianBlur(img, (9, 9), 0)
print("原均值:", round(float(img.mean()), 1))
print("模糊后均值:", round(float(blur.mean()), 1))
print("模糊 shape:", blur.shape)
show_bgr(blur, "模糊预览")

八、课堂练习

选出你认为正确的一项,再点「检查」。

  1. 高斯模糊核越大,通常效果?

九、本节小结

去噪常是边缘检测的前一步。