这一章在干嘛?

把前面学过的「矩阵就是数据」这个直觉推到极致 —— 任何图像都是矩阵,矩阵里的数就是颜色;任何声音都是向量,向量里的数就是振幅。学完你就能用 MATLAB 读写图片、做滤镜、把语音合成出来,再用一个会响应点击的小程序把它们打包起来。整章分四块:图像(13.1~13.3)、GUI(13.4~13.7)、声音(13.8)。

13.1 图像就是矩阵

13.2 颜色的两种编码方式:索引图与 RGB

13.3 图像文件的读写与基本处理

13.4 GUI 基础:figure 与 uicontrol

13.5 文本框、按钮与滑块

13.6 按钮组:单选与互斥

13.7 GUIDE 与 App Designer:可视化搭界面

13.8 声音文件与音效合成

13.1 图像就是矩阵

「图像就是矩阵」是这一章最重要的认知 —— 一旦接受这个前提,后面所有 imageimshowimreadimwrite 都只是矩阵操作的一种新写法而已。这跟你在前面章节里学的「数据 = 矩阵」「信号 = 向量」是同一种世界观:把现实信号数字化成最基本的数组,让 MATLAB 把数组画出来 / 算出来。

像素(pixel,picture element)是图像的最小单元。把一张图按行按列拆开,每个像素就是一个数。整张图就是一个二维矩阵:

  • 一张 的灰度图 = 一个 的矩阵;
  • 一张 的彩色图(RGB,真彩色)= 一个 的三维矩阵;
  • 一张 的索引图(indexed image)= 一个 的整数矩阵 + 一个 的「调色板」。

下面这张示意图把索引图的两层结构画得很清楚 —— 左边图像矩阵里每个格子是个「指向调色板的指针」,右边的调色板每一行存储一种真实颜色:

图像矩阵中每个像素指向调色板的对应行,箭头展示 (x, x) → colormap 第 x 行

这张图想说明的是:图像矩阵和调色板矩阵是「两张表」,图像矩阵本身不存颜色,只存「去查表的下标」。这种设计在科学可视化里非常常见 —— 温度场、压力场、应力场只需要几十种「色阶」,用索引图比每个像素直接存 RGB 省 90% 内存。

为什么工程师喜欢用 MATLAB 处理图像?

因为它把像素处理降维成了矩阵运算 —— 你想给整张图加亮,只需 image = image + 30;想左右翻转,只需 image(:, end:-1:1, :);想做卷积滤波,直接调用 conv2。这是 OpenCV 或 C 写一行循环才能搞定的事。MATLAB 之所以能在图像/信号处理领域几十年长盛不衰,本质原因就是它把「像素」「采样点」「单元格」全都统一成了「矩阵元素」这一种数据结构。

常见坑:uint8 与 double 的取值范围

真彩色图像常常以 uint8 类型存储,每个通道取值 0~255。但很多图像处理函数(卷积、归一化、灰度变换)默认要求 double 取值 0~1。把它们混用就会出现「加出来全是白色」或「plot 出来一条直线」之类的诡异现象。解决套路:处理前先 im2double 转成 0~1 的 double,处理完要保存再 im2uint8 转回去。后面 13.3 节还会用阳台雪景图演示这条 pipeline。


13.2 颜色的两种编码方式:索引图与 RGB

颜色编码是这一节的硬核。理解清楚「索引图」和「RGB 图」的区别,等于掌握了 90% 的图像处理编程模型。

13.2.1 索引图(colormap 方式)

索引图的思路是「调色板复用」:颜色信息不存到每个像素里,而是放在一张叫 colormap 矩阵里( 是颜色总数)。图像矩阵的每个元素是一个整数下标(1~),指出「这个像素的颜色在调色板的第几行」。

MATLAB 自带多个命名调色板:默认 parula(64 色)、jet(蓝→青→绿→黄→红,64 色)、pinkhotgrayhsv 等等。colormap 不带参数时返回当前调色板,带参数时切换。

>> map = colormap;       % 把当前调色板存成 64x3 的 double 矩阵
>> [r, c] = size(map)
r = 64
c = 3
>> map(60:64, :)         % 看最后 5 行:黄到亮黄的渐变
ans =
    0.9626    0.8705    0.1309
    0.9589    0.8949    0.1132
    0.9598    0.9218    0.0948
    0.9661    0.9514    0.0755
    0.9763    0.9831    0.0538

注意 colormap 里的颜色分量都是 double,范围 01(不是 0255)。这是约定 —— 索引图和 RGB 矩阵类型约定不同:索引图 + colormap 走 double(01),而 RGB 矩阵走 uint8(0255)。

最直观的演示是把 1~64 排成一个 矩阵喂给 image,就能看到整个 parula 调色板:

>> imagemat = reshape(1:64, 8, 8);
>> image(imagemat)

8x8 矩阵展示 parula 调色板的 64 种颜色,按列序号映射颜色

切到 jet 调色板会看到完全不同的彩虹渐变(蓝→青→绿→黄→红):

>> colormap(jet)            % 把当前调色板换成 jet
>> image(imagemat)          % 同一矩阵,色彩立即改变

8x8 矩阵展示 jet 调色板的 64 种颜色,从深蓝到深红的彩虹条

对比上面两张图:调色板不一样,同一个整数矩阵显示出来天差地别。这说明图像矩阵本身不含颜色,颜色是由矩阵 + colormap 共同决定的。你要保存为图片发给同事,要么发带 colormap 的 .fig 文件,要么把它先转成 RGB 矩阵再 imwrite

随手生成一张「伪图像」也很容易 —— 把 1~颜色数之间的随机整数塞进矩阵即可:

>> mat = randi(64, 5);      % 5x5 矩阵,元素是 1~64 的随机整数
>> image(mat)

5x5 随机方块图,每个方块从 jet 调色板随机抽色

把网格放大到 看上去才像「真的图」:

>> mat = randi(64, 500);
>> image(mat)

500x500 随机彩色像素,看起来就像电子噪点或抽象画

你也可以自己造调色板。下面只用三种颜色(黑、白、红)做一张 40×40 的随机图:

>> mycolormap = [0 0 0; 1 1 1; 1 0 0];   % 黑、白、红
>> colormap(mycolormap)
>> mat = randi(3, 40);                    % 元素只能是 1/2/3
>> image(mat)

自定义 3 色调色板(黑/白/红)下的 40x40 随机图,是像素艺术的雏形

调色板的颜色分量不必是整数。下面用 6 级深红展示 colormap 是怎么「渐变」的:

>> colors = [0 0 0;
            0.2 0 0;
            0.4 0 0;
            0.6 0 0;
            0.8 0 0;
            1 0 0];
>> colormap(colors)
>> vec = 1:length(colors);
>> image(vec)

6 级红色渐变条,每级用 0~1 之间的实数表示红色分量强度

调色板不只服务于 image,任何把数值映射到颜色的 plot 函数(surfmeshimagesc)都能用 colormap 切换:

>> [x, y, z] = sphere(20);   % 单位球坐标
>> colormap(pink)
>> surf(x, y, z)
>> title('Pink sphere')
>> colorbar

球面用 pink 调色板着色,附带右侧颜色条显示数值与颜色对应关系

R2014b 之后还可以让一个 Figure 里不同子图用不同调色板 —— 把 axes 句柄传给 colormap

[x, y, z] = sphere(20);
ax1 = subplot(1, 2, 1);
colormap(ax1, pink);    % 左子图:粉色调
surf(x, y, z); title('Pink sphere');
 
ax2 = subplot(1, 2, 2);
colormap(ax2, jet);     % 右子图:jet 彩虹
surf(x, y, z); title('Jet sphere');

同一个 figure 中左球用 pink、右球用 jet,对比鲜明

同一个 figure 中左球用 pink 调色板、右球用 jet 调色板,互不干扰

13.2.2 真彩色(RGB / truecolor)

真彩色绕开了调色板,直接把每个像素的 R/G/B 三个分量都存在矩阵里:

  • 三维矩阵的尺寸是
  • (:,:,1) 是红色层,(:,:,2) 是绿色层,(:,:,3) 是蓝色层;
  • 一个 8-bit RGB 图的元素是 uint8,每个通道取值 0~255(0 = 该通道无光,255 = 该通道最亮)。

下面创建一个 的「四色像素图」:(1,1) 红、(1,2) 蓝、(2,1) 绿、(2,2) 黑:

>> mat = zeros(2, 2, 3);     % 预分配黑色
>> mat(1, 1, 1) = 255;        % 左上:R=255 → 纯红
>> mat(1, 2, 3) = 255;        % 右上:B=255 → 纯蓝
>> mat(2, 1, 2) = 255;        % 左下:G=255 → 纯绿
>> mat = uint8(mat);
>> image(mat)

2x2 RGB 矩阵生成的图像:左上红、右上蓝、左下绿、右下黑

想单独看某条通道?把对应层抽出来做一张「只剩红/绿/蓝」的图:

matred   = uint8(zeros(2,2,3));  matred(:,:,1) = mat(:,:,1);
matgreen = uint8(zeros(2,2,3));  matgreen(:,:,2) = mat(:,:,2);
matblue  = uint8(zeros(2,2,3));  matblue(:,:,3)  = mat(:,:,3);
 
subplot(2,2,1); image(mat);        title('原图')
subplot(2,2,2); image(matred);     title('R 通道')
subplot(2,2,3); image(matgreen);   title('G 通道')
subplot(2,2,4); image(matblue);    title('B 通道')

四个子图:原图(左上)、仅保留 R 的红色块(右上)、仅保留 G 的绿色块(左下)、仅保留 B 的蓝色块(右下)

把三张通道子图相加(重叠的部分会变白)就回到原图。这说明 RGB 表示法的本质就是「三层灰度图叠加」。理解这点后,对图像处理的算子就有直觉了 —— 比如「加红滤镜」就是把 R 通道整体加大,「去蓝」就是把 B 通道置零。

下面是一个练习题:构造一个 的 RGB 矩阵(绿色背景 + 红色方块):

>> pic = zeros(3,3,3,'uint8');
>> pic(:,:,1) = 200; pic(:,:,2) = 220; pic(:,:,3) = 80;   % 整张铺成黄绿色
>> pic(2,3,:) = [255 0 0];                                 % 中行右列塞个红块
>> image(pic)

3x3 真彩色矩阵:满屏黄绿色,中央右下角一个亮红方块

常见坑:colorbar 上 0 1 与 0255 的混淆

colormap 矩阵用 double(01);RGB 图像用 uint8(0255)。colorbar 显示的是 colormap 的值(01),而 image 显示的是矩阵元素。要保持一致,要么把所有数据归一化到 double(01),要么在画 RGB 图前 caxis([0 255])

13.2.3 灰度图与强度图

工程上第三种常见图像是灰度图 / 强度图(intensity image)。它的矩阵元素是 0~1 的 double,表示灰度(0 = 黑,1 = 白)。rgb2gray 把 RGB 转灰度,im2double 把 uint8 转 double。强度图常常搭配 imagesc(image scale,自动缩放到当前 colormap)来画,例如画一个灰度热图:

>> A = magic(10);             % 一个 10x10 的「魔方矩阵」
>> imagesc(A);                % 自动按当前 colormap 着色
>> colorbar;

几个常见函数的混淆

  • image:纯粹按矩阵元素画,不缩放
  • imagesc:自动缩放到 [min max] 再映射到当前 colormap,画「按比例填色」
  • imshow:图像处理工具箱的图像显示函数,自动设置坐标轴比例(保持原图长宽比)

13.3 图像文件的读写与基本处理

真实工程里图片都是存成文件的(JPG、PNG、TIFF、BMP)。MATLAB 用 imread 读成矩阵、用 imwrite 把矩阵写成文件,imshow 是个比 image 更「图像友好」的显示函数(自动处理坐标轴、长宽比)。

>> porchimage = imread('snowyporch.JPG');    % 读入一张 JPG
>> size(porchimage)                          % 注意是 3 维的
ans =
        2848        4272           3        % 高 2848 × 宽 4272 × 3 通道
>> class(porchimage)
ans =
uint8

这张阳台雪景 4272 × 2848 ≈ 1200 万像素,是手机拍的照片。直接 image(porchimage) 显示:

>> image(porchimage)

读入 MATLAB 的 4272x2848 阳台雪景原图,色彩鲜艳

想「整体压暗」,最直接的方式就是乘 0.5(uint8 矩阵乘以 0.5 会自动截断到 0~255):

>> dimmer = 0.5 * porchimage;
>> imwrite(dimmer, 'dimporch.JPG');           % 把处理后的图写回文件

整张图乘以 0.5 后的暗化效果:色彩只剩一半亮度,阴影区域更暗

工程实战:从代码到图像处理的 3 个动作

  1. I = imread('pic.jpg');
  2. :所有你想得到的 numpy 矩阵操作都能用 —— I(end:-1:1, :, :) 上下翻转、I(:, 100:300, :) 横向裁剪、rgb2gray(I) 转灰度。
  3. imwrite(I, 'out.png');imshow(I); 只看不存。

没有「图像 API」这回事,就是矩阵。

下面这几道教材练习题涵盖了图像处理入门的高频操作,可以一一对照原图:

Practice 13.7(蓝绿渐变调色板)

「10 蓝 + 10 aqua + 10 绿」三段渐变,铺成 3×10 图像:

cmap = zeros(30, 3);
% 蓝色段:R=0,G=0,B 从 0.1 增到 1
cmap(1:10, 3) = linspace(0.1, 1, 10);
% aqua 段:R=0,G 与 B 都从 0.1 增到 1
cmap(11:20, 2:3) = repmat(linspace(0.1, 1, 10)', 1, 2);
% 绿色段:R=0,G 从 0.1 增到 1,B=0
cmap(21:30, 2) = linspace(0.1, 1, 10);
 
colormap(cmap);
img = [1:10; 11:20; 21:30];   % 3x10 矩阵,索引对应 cmap 各行
image(img);

三行三色渐变:上面一行是从黑到蓝的 10 级蓝,中间一行是从黑到青(aqua)的 10 级 aqua,下面一行是从黑到绿的 10 级绿。每一行的「浓度」逐级增加

Practice 13.6(黑白噪声 + 颜色反转)

bw = [0 1; 1 0];             % 黑白颜色:第 1 行黑,第 2 行白
colormap(bw);
mat = randi(2, 50, 50);      % 50x50 随机选 0/1
subplot(1,2,1); image(mat);  title('Yin');
subplot(1,2,2); image(1-mat); title('Yang');  % 颜色反转

左图标题 Yin、右图标题 Yang:两张 50x50 黑白噪声图,黑白像素的位置完全相反,像镜面映射的随机噪点

Practice 13.4(RGB 色块)

50x50 的 RGB 图,绿/黄/红三色方块互相重叠:

rgb = zeros(50, 50, 3, 'uint8');
% 绿块在左上
rgb(10:33, 10:33, 2) = 255; rgb(10:33, 10:33, 1) = 0; rgb(10:33, 10:33, 3) = 0;
% 红块在右下
rgb(20:42, 20:42, 1) = 255; rgb(20:42, 20:42, 2) = 0; rgb(20:42, 20:42, 3) = 0;
% 黄块 = 红 + 绿,在重叠处
rgb(20:33, 20:33, 1) = 255; rgb(20:33, 20:33, 2) = 255;
image(rgb);

50x50 RGB 图:绿色方块与红色方块部分重叠,重叠区域因为红+绿混合显示为黄色

Practice 13.9(亮度感知错觉)

「同一灰度在不同背景下显得不一样亮」。构造两个 3×3 RGB 图,中块同色、外圈不同色:

% 左图:外圈深蓝,中块稍亮
left  = zeros(3,3,3,'uint8'); left(:,:,3)  = 200; left(2,2,:) = [120 120 200];
% 右图:外圈草绿,中块同色稍亮
right = zeros(3,3,3,'uint8'); right(:,:,2) = 180; right(2,2,:) = [120 200 120];
subplot(1,2,1); image(left);
subplot(1,2,2); image(right);

左图:3x3 RGB 图,外圈深蓝、中块较亮的蓝色(看起来中块「更亮」)

右图:3x3 RGB 图,外圈草绿、中块较亮的蓝色(看起来中块「更暗」)

这两张图的中央像素颜色完全相同,但因为周围环境不同,肉眼会感觉左边中央更亮、右边更暗。这就是著名的「亮度恒常错觉」(brightness illusion),在图像处理做对比度增强时是绕不开的现象。

Exercise 26(随机游走纹理)

教材最后给了一个 ranwalk(n) 函数,模拟从中心出发的随机游走并把路径存成矩阵。运行两次()对比,能看到「确定性 + 随机性」是怎样长成两种完全不同的视觉形态的:

walk_small = ranwalk(8);     % 8x8 矩阵,色块稀疏
walk_big   = ranwalk(100);   % 100x100 矩阵,纹理丰富
 
figure;
subplot(1,2,1); image(walk_small); axis square; title('ranwalk(8)');
subplot(1,2,2); image(walk_big);   axis square; title('ranwalk(100)');

左图:8x8 随机游走纹理,深蓝/蓝色块呈锯齿状蔓延,色块大而稀疏

右图:100x100 随机游走纹理,整个画面长成彩色灌木丛,模拟森林火灾扩散或枝晶生长

随机游走在物理上模拟的是森林火灾蔓延枝晶生长布朗运动等扩散现象。把模拟结果当「图」画出来,本质上还是个矩阵赋值 + image


13.4 GUI 基础:figure 与 uicontrol

GUI(Graphical User Interface,图形用户界面)让程序不再只是「跑完结束」,而是「等待用户点击、滑块拖动、文字输入」之后再跑。MATLAB 里一切 GUI 都是 11 章讲过的「句柄对象」的延伸 —— 一个 Figure 是父,下面挂着若干 uicontrol 子对象。

13.4.1 第一个 GUI

下面这个例子只做一件事:在窗口中央显示一行静态文字。

% simpleGui.m
function simpleGui
% 创建一个只有静态文本框的极简 GUI
% Format: simpleGui 或 simpleGui()
 
% 1) 先建一个隐形窗口
f = figure('Visible', 'off', ...
           'Color',    'white', ...
           'Position', [300 400 450 250]);   % [左 下 宽 高],单位像素
 
% 2) 在窗口里放一个静态文本框
htext = uicontrol('Style',     'text', ...
                  'Position',  [200 50 100 25], ...
                  'String',    'My First GUI string');
 
% 3) 给窗口起名字,挪到屏幕中央,最后才显示
f.Name = 'Simple GUI';
movegui(f, 'center');
f.Visible = 'on';     % 关键:在所有对象都建好之后才 Visible
end

第一个 GUI:一个居中的窗口里写着一行 "My First GUI string"

几个细节值得点出:

  • 'Visible', 'off' 让窗口在搭建阶段不可见,避免出现「GUI 一半一半」的尴尬,最后才 'on'
  • Position 的四个数字是 [left bottom width height],单位默认像素。
  • movegui(f, 'center') 把窗口挪到屏幕正中。
  • 'Normalized' 单位:把坐标改成 0~1 的相对值,让窗口在不同屏幕分辨率下看起来一致(推荐用)。

13.4.2 事件驱动与回调(callback)

GUI 程序和普通程序最大的不同是事件驱动编程:用户的每一次点击、拖动、按键都会触发一个事件,事件会调用你写好的「回调函数」(callback function)。MATLAB 的回调约定是:用户写一个子函数,系统自动传两个参数进去:

  • hObject:触发事件的那个控件的句柄
  • eventdata:事件相关数据(一般不用,可以用 ~ 跳过)
% simpleGuiNormalized.m — 使用 Normalized 单位的版本
function simpleGuiNormalized
f = figure('Visible', 'off', 'Color', 'white', ...
           'Units', 'Normalized', ...
           'Position', [.25 .5 .35 .3]);   % 占屏幕左中 35%×30%
 
htext = uicontrol('Style', 'text', ...
                  'Units', 'Normalized', ...
                  'Position', [.45 .2 .2 .1], ...
                  'String', 'My First GUI string');
 
f.Name = 'Simple GUI Normalized';
movegui(f, 'center');
f.Visible = 'on';
end

Normalized 单位下,Position = [.25 .5 .35 .3] 表示「窗口左下角在屏幕的 25% 横向、50% 纵向处,宽 35%、高 30%」。窗口放大缩小,里面的控件会按比例缩放。

为什么推荐 Normalized?

你的 GUI 在实验室 1080p 显示器上调好,放在 4K 投影仪上用像素坐标会挤成左上角一小块。Normalized 单位能让你「写一次,到处能看」。


13.5 文本框、按钮与滑块

13.5.1 可编辑文本框(edit)—— 第一次交互

下面这个 GUI 让用户在文本框输入字符串,回车后大红色字体显示出来。回调函数用嵌套函数写在外层 function 内部,这样可以共享外层的句柄变量。

% guiWithEditbox.m
function guiWithEditbox
% 一个可编辑文本框 + 回调函数把用户输入显示成红色大字
 
f = figure('Visible', 'off', 'Color', 'white', ...
           'Units', 'Normalized', 'Position', [.25 .5 .4 .2]);
f.Name = 'GUI with editable text';
movegui(f, 'center');
 
% 提示文字
hsttext = uicontrol('Style', 'text', ...
                    'BackgroundColor', 'white', ...
                    'Units', 'Normalized', 'Position', [.2 .6 .6 .2], ...
                    'String', 'Enter your string here');
 
% 可编辑文本框 + 回调
huitext = uicontrol('Style', 'edit', ...
                    'Units', 'Normalized', 'Position', [.3 .3 .4 .2], ...
                    'Callback', @callbackfn);   % 关键:'Callback' 指向回调句柄
 
f.Visible = 'on';
 
% ——— 嵌套回调 ———
    function callbackfn(~, ~)
        % 隐藏原控件
        set([hsttext huitext], 'Visible', 'off');
        % 取输入内容(也可以用 huitext.String)
        printstr = huitext.String;
        % 大红色字体再显示
        hstr = uicontrol('Style', 'text', ...
            'BackgroundColor', 'white', ...
            'Units', 'Normalized', ...
            'Position', [.1 .3 .8 .4], ...
            'String', printstr, ...
            'ForegroundColor', 'Red', ...
            'FontSize', 30);
        hstr.Visible = 'on';
    end
end

用户输入 "Hello and how are you?" 的可编辑文本框,还没按回车时的样子

按回车后回调函数触发,原控件消失,文字以 30 号红字出现:

同一 GUI 按回车后的效果:原控件消失,红字 "Hello and how are you?" 居中显示

注意几个工程要点:

  • 回调函数声明是 callbackfn(hObject, eventdata),两个输入参数由 GUI 系统自动传。如果不用,可以写 callbackfn(~, ~)
  • 嵌套函数可以直接闭包外层的 hsttexthuitext 句柄,不必通过 guidata 传来传去。
  • getset 是句柄对象的标准读写接口:huitext.String 是 R2014b 后的点语法糖,等价于 get(huitext, 'String')

13.5.2 按钮(pushbutton)—— 不再「回车即触发」

把上面那个例子改成「必须点按钮才触发」:

% guiWithPushbutton.m(节选关键差异)
hbutton = uicontrol('Style', 'pushbutton', ...
                    'String', 'Push me!!', ...
                    'Units', 'Normalized', ...
                    'Position', [.6 .1 .3 .2], ...
                    'Callback', @callbackfn);   % 回调挂在按钮上

含输入框、按钮的 GUI:用户先输入字符串,再按 "Push me!!" 触发显示

按钮回调里别忘了处理「空字符串」的情况,否则用户没输入就点会出现空白:

function callbackfn(~, ~)
    set([hsttext huitext hbutton], 'Visible', 'off');
    printstr = huitext.String;
    if isempty(printstr)
        printstr = 'Enter something next time!';
    end
    hstr = uicontrol('Style', 'text', ...
        'BackgroundColor', 'white', ...
        'Units', 'Normalized', 'Position', [.1 .3 .8 .4], ...
        'String', printstr, ...
        'ForegroundColor', 'Red', 'FontSize', 30);
    hstr.Visible = 'on';
end

13.5.3 滑块(slider)—— 连续输入

滑块适合做亮度、音量、阈值这类「在范围内连续调」的参数。下面做一个英寸↔厘米换算器:滑块拖到 1.0,按钮按下后显示「1.0 in = 2.54 cm」。

% slider 关键代码(按教材练习 13.3 改写)
slhan = uicontrol('Style', 'slider', ...
                  'Units', 'Normalized', 'Position', [.3 .3 .4 .1], ...
                  'Min', 0, 'Max', 10, 'Value', 1, ...
                  'Callback', @callbackfn);
 
% 回调:把滑块当前值换算成厘米
function callbackfn(~, ~)
    inches = slhan.Value;
    cm = inches * 2.54;
    set(resulttext, 'String', sprintf('%.2f in = %.2f cm', inches, cm));
end

下面三个截图演示了完整的滑块应用 —— 滑块在小范围内移动,下方实时显示数值:

英寸→厘米换算器初始状态(默认 1 inch),"Convert me!" 按钮在下方

用户输入 5.2 后点击 "Convert me!":结果变为 "5.2 in = 13.21 cm"

滑块停在中间,下方文字 "2.5" 实时显示

常见坑:滑块的 SliderStep 不设就用默认值

SliderStep 是个 [小步 大步] 二元组,默认是 [0.01 0.10]。想做「一次加 1」的整数滑块,要么手动改 SliderStep = [1/(Max-Min) 1/(Max-Min)],要么在回调里把 Value 取整 round(slhan.Value)

13.5.4 多个按钮 vs 一个回调服务多个按钮

可以让两个按钮各自挂自己的回调,也可以两个按钮挂同一个回调,靠判断 hObject 是哪个:

% 一个回调处理两个按钮
function callbackfn(hObject, ~)
    set([hbutton1 hbutton2], 'Visible', 'off');
    hstr = uicontrol('Style', 'text', ...
        'BackgroundColor', 'white', ...
        'Units', 'Normalized', 'Position', [.4 .5 .2 .2], ...
        'FontSize', 30);
    if hObject == hbutton1
        hstr.String     = '!!!!!';
        hstr.ForeColor  = 'red';
    else
        hstr.String     = '*****';
        hstr.ForeColor  = 'blue';
    end
    hstr.Visible = 'on';
end

两个按钮并排显示:「Push me!!」和「No, Push me!!」等待用户选择

按上面按钮后:窗口中央出现红色 "!!!!!" 字样

13.5.5 把 plot 和 image 塞进 GUI

在 GUI 里画图只要先用 axes 在 Figure 上定一个子区域当画布,回调里照常用 plotimage

% guiSliderPlot.m 节选:滑块控制 sin(x) 终点
axhan = axes('Position', [.2 .1 .6 .5]);   % 在窗口下方留一块画布
 
function callbackfn(~, ~)
    num = slhan.Value;
    set(hsttext, 'Visible', 'on', 'String', num2str(num));
    x = 0:num/50:num;
    y = sin(x);
    plot(x, y); xlabel('x'); ylabel('sin(x)');
end

滑块停在 4π 附近,下方 sin(x) 曲线一直画到滑块当前位置,整整画了约两个周期

plot 换成 image 就能做「拖滑块调亮度」的小工具(13.3 节的 dimmer):

function callbackfn(~, ~)
    num = slhan.Value;                  % 滑块 0~1
    set(hsttext, 'Visible', 'on', 'String', num2str(num));
    myimage1 = imread('snowyporch.JPG');
    dimmer = num * myimage1;            % 0~1 的亮度因子
    image(dimmer);                      % 直接显示
end

滑块拖到 0.77778 时下方阳台雪景按比例压暗的效果:滑块控制整体明暗

把科研 Demo 包装成 GUI

这是把算法「产品化」的最快路径 —— 把算法核心放在 callback 里,前端用 uicontrol 暴露两三个旋钮、一个 plot 区域。30 行代码就能把命令行脚本变成可交互的小工具。


13.6 按钮组:单选与互斥

单选按钮(radio button)的特点是「一组里只能选一个」。MATLAB 用 uibuttongroup 把单选按钮圈起来实现互斥。

% guiWithButtongroup.m 节选
f = figure('Visible', 'off', 'Color', 'white', ...
           'Units', 'Normalized', 'Position', [.2 .5 .4 .3]);
 
grouph = uibuttongroup('Parent', f, ...
    'Units', 'Normalized', 'Position', [.2 .5 .4 .4], ...
    'Title', 'Choose Color', ...
    'SelectionChangedFcn', @whattodo);   % 选谁触发谁
 
toph = uicontrol(grouph, 'Style', 'radiobutton', ...
                 'String', 'Blue', ...
                 'Units', 'Normalized', 'Position', [.2 .7 .4 .2]);
 
both = uicontrol(grouph, 'Style', 'radiobutton', ...
                 'String', 'Green', ...
                 'Units', 'Normalized', 'Position', [.2 .4 .4 .2]);
 
texth = uicontrol('Style', 'text', 'Units', 'Normalized', ...
                  'Position', [.6 .5 .3 .3], 'String', 'Hello', ...
                  'Visible', 'off', 'BackgroundColor', 'white');
 
grouph.SelectedObject = [];   % 一开始谁都不选
f.Name = 'GUI with button group';
movegui(f, 'center');
f.Visible = 'on';
 
    function whattodo(~, ~)
        which = get(grouph, 'SelectedObject');
        if which == toph
            texth.ForeColor = 'blue';
        else
            texth.ForeColor = 'green';
        end
        texth.Visible = 'on';
    end
end

按钮组初始状态:蓝色和绿色两个单选按钮谁都没选中,右边没有文字

用户选了蓝色按钮后:右边出现蓝色 "Hello" 字样

注意 SelectionChangedFcn 是按钮组级别的回调 —— 不管选哪个按钮,都会触发它,函数内部用 grouph.SelectedObject 判断到底选了谁。教材练习 13.19 让你写风寒指数计算器、风速滑块等场景,思路都是一样的。


13.7 GUIDE 与 App Designer:可视化搭界面

手写 uicontrol 适合学习,工程上更常见的做法是用 MATLAB 自带的「所见即所得」GUI 构建工具。教材介绍了两个:

  • GUIDE(R2016a 之前的标配):命令窗口敲 guide,进入拖拽式编辑器,自动生成两份文件(.m + .fig)。
  • App Designer(R2016a 引入的现代替代):命令窗口敲 appdesigner,生成的代码全部是面向对象的,控件对应 uifigure / uilabel / uislider / uibutton 等专门函数而不是 uicontrol('Style', 'slider', ...)

GUIDE 布局视图:左侧控件面板,中间网格画布,已经拖入了一个 Static Text 和一个 Edit Text

App Designer 主界面:顶部 DESIGNER/CANVAS 切换按钮,左侧 COMPONENT LIBRARY,中间空白画布,右侧 COMPONENT BROWSER 与 COMPONENT PROPERTIES 面板

13.7.1 App Designer 里的核心概念

每个 App Designer 工程是一个classdef),继承自 matlab.apps.AppBase。每个拖到画布上的控件对应类的 properties

classdef App1 < matlab.apps.AppBase
    properties (Access = public)
        UIFigure matlab.ui.Figure         % 顶层窗口
        Label    matlab.ui.control.Label  % 拖入的 Label 自动变成属性
    end
 
    methods (Access = private)
        function createComponents(app)
            app.UIFigure = uifigure;
            app.UIFigure.Position = [100 100 640 480];
            app.UIFigure.Name     = 'UI Figure';
            % ... 各种 ui* 控件的创建
        end
    end
 
    methods (Access = public)
        function app = App1()
            createComponents(app);
            registerApp(app, app.UIFigure);
            runStartupFcn(app, @startupFcn);
        end
    end
end

跑起来后这个 App 就会显示一个 UI Figure,里面有一个写着「Hello」的 Label:

App Designer 跑出来的 UI Figure 窗口:白色背景中央显示一行 "Hello" 文字(默认 Label.Text 被改成了 Hello)

13.7.2 UI Figure 函数族

App Designer 抛弃了 uicontrol('Style', ...) 这种「一个函数所有控件」的老套路,每种控件都有自己的构造函数:

控件App Designer 函数uicontrol 风格
顶层窗口uifigurefigure
静态文字uilabeluicontrol('Style','text')
可编辑文本框uitextarea / uieditfielduicontrol('Style','edit')
按钮uibuttonuicontrol('Style','pushbutton')
滑块uislideruicontrol('Style','slider')
单选 / 复选uiradiobutton / uicheckboxuicontrol('Style','radio'/'checkbox')
绘图区uiaxesaxes

举个例子,不用 App Designer,光在命令窗口就能现搭一个简单滑块窗口:

>> uif = uifigure;
>> uif.Name = 'Simple UI Fig Slider';
>> uislid = uislider(uif);   % 默认 0~100 的整数刻度滑块

App Designer 风格的 UI Figure 窗口:白色背景下方居中有一个 0~100 的整数刻度滑块

要给滑块加个标签显示数值,App Designer 的回调叫 ValueChangedFcn(旧版是 Callback):

% uislideruilabel.m
function uislideruilabel
    uif = uifigure('Name', 'UI Fig Slider with Callback');
    uislid = uislider(uif);
    uilab  = uilabel(uif, 'Position', [150 200 30 15], 'Text', '0');
 
    uislid.ValueChangedFcn = @whatslid;
    function whatslid(~, ~)
        uilab.Text = num2str(round(uislid.Value));
    end
end

App Designer 风格的滑块 GUI:滑块拖到 43,旁边的 Label 实时显示 "43"

App Designer 项目里把滑块和它的标签同时拖进去,会自动生成两个属性(SliderLabelSlider)。在 Code View 右键 → Callbacks → 选 ValueChangedFcn,MATLAB 会自动生成回调骨架:

function SliderValueChanged(app)
    app.LabelSlider.Text = num2str(round(app.Slider.Value));
end

App Designer 项目运行起来的效果:滑块停在中段,左侧 Label 实时显示当前值 "43"

下面两个图展示两个 App Designer 小项目跑起来的样子 —— 一个文本编辑器,一个红绿灯:

App Designer 制作的简易文本编辑器:大文本区显示 Lorem ipsum 占位文字,下方是 8~32 的 Font Size 滑块和 Bold / Italic 两个状态按钮

App Designer 制作的红绿灯:左边 Stop / Go 按钮,右边三盏红黄绿圆灯(红灯亮起)

13.7.3 几个常用 App Designer 控件

  • Drop Down:下拉菜单,用 Items 列表预设选项,回调里读 app.DropDown.Value
  • Spinner:数值微调按钮,回调里读 app.Spinner.Value,可以快速实现「点一下 +1」之类的输入。
  • Lamp:状态指示灯,常用 app.Lamp.Color = 'red'/'green' 表示系统状态。
  • Gauge / Knob:仪表盘 / 旋钮控件,回调里读 Value,非常适合做工业控制 Demo。
  • Date Picker:日期选择器,返回 datetime 类型。

工程建议:什么时候用 GUIDE,什么时候用 App Designer?

现在新项目永远从 App Designer 起手。GUIDE 只在维护老 .fig 文件时才需要回来看一眼。教材后续章节如果做 GUI 练习,先在 App Designer 里拖出界面,再到 Code View 里写 app.Slider.ValueChangedFcn 这类回调。


13.8 声音文件与音效合成

声音信号在 MATLAB 里就是个一维向量:长度 = 采样点数,元素值 = 该时刻的振幅。配套的 Fs 是采样率(sampling rate,单位 Hz),常见值 8000(电话)、22050、44100(CD 音质)、48000(专业音频)。

13.8.1 采样与重建的基本直觉

采样定理(Nyquist–Shannon sampling theorem)告诉我们:要从离散采样无损重建一段最高频率为 的连续信号,采样率必须 。这意味着:

  • 人耳最高能听到约 20 kHz,所以 CD 用 44.1 kHz 采样(略高于 40 kHz,留 10% 余量);
  • 打电话只传 0~3.4 kHz,所以 8 kHz 采样就够;
  • 超声检测要用 MHz 级采样才能还原几十 kHz 的回波信号。

13.8.2 内置声音与 sound 函数

MATLAB 自带几个示范用的 MAT 文件:chirp(频率递增的啁啾)、gonglaughtersplattrainhandel。每个文件里都有两个变量:

  • y:声音信号的离散采样向量
  • Fs:采样率
>> load gong          % 把 y 和 Fs 装进工作区
>> sound(y, Fs)       % 按 Fs 速度播放

想听到声音不一样?改播放速度:

>> sound(y, Fs*2)     % 2 倍速播放:音调升高 8 度
>> sound(y, Fs*0.5)   % 半速播放:音调降低 8 度

注意 sound 的第二参数只是播放速度,不会改变数据本身 —— 数据向量 y 不变,只是「读得更快」或「读得更慢」。这就是为什么同一段声音在不同采样率下听起来像变调的 CarolBells。

下面是 chirp 和 train 两段内置声音的波形可视化,能看出 chirp 的「频率递增」与 train 的「火车经过的包络」特征:

% chirptrain.m
subplot(2,1,1)
load chirp
plot(y); ylabel('Amplitude'); title('Chirp')
 
subplot(2,1,2)
load train
plot(y); ylabel('Amplitude'); title('Train')

chirp 信号的波形:若干段高频振荡紧密挤在一起,振幅忽大忽小,符合「频率越来越高的啁啾」特征

train 信号的波形:开头密集噪声,中间变稀疏像火车鸣笛过去,结尾又衰减,符合火车由远及近再到远的包络

如果你想拼接两段声音,先把它们的 y 向量竖向堆叠:

>> load gong; y_gong = y;
>> load chirp; y_chirp = y;
>> sound([y_gong; y_chirp], Fs);   % 先 gong 后 chirp,串起来播放

这就是教材练习 29 留的空:sound([y1; y2], Fs)

13.8.3 现代音频 I/O:audioread / audiowrite / audioinfo

sound 是老接口(1990s),R2015b 起更推荐用 audioread / audiowrite / audioinfo 处理 .wav.mp3.flac 等文件:

>> [y, Fs] = audioread('myaudio.wav');   % 读:返回 y 和采样率
>> info = audioinfo('myaudio.wav')        % 看:返回结构体,含时长、位深、通道数
>> audiowrite('copy.wav', y, Fs);          % 写:保存为 wav

立体声文件里 y 矩阵(左/右两个通道),sound(y, Fs) 会自动两声道播放。

13.8.4 自己合成音效

工程师经常会用 MATLAB 生成测试音(例如测扬声器频率响应、测耳机失真)。最基本的就是正弦波:

% 生成 2 秒、440 Hz 的标准 A 音(A4)
Fs = 44100;                          % CD 采样率
t  = 0:1/Fs:2;                       % 2 秒时长
y  = 0.5 * sin(2*pi*440*t);          % 振幅 0.5 的正弦波
sound(y, Fs);

实测中为了避免”咔哒”声(信号从 0 突然跳到正弦值),通常会加一个淡入淡出包络(fade in / fade out):

% 加 50ms 线性淡入淡出
fade = round(0.05 * Fs);             % 50 ms 内的样本数
env  = ones(size(t));
env(1:fade)          = linspace(0, 1, fade);
env(end-fade+1:end)  = linspace(1, 0, fade);
y_env = y .* env;
sound(y_env, Fs);

进一步合成更复杂的「和弦 / 旋律」:

% C 大调和弦:C4 (262 Hz) + E4 (330 Hz) + G4 (392 Hz)
freqs = [262, 330, 392];
y = zeros(size(t));
for k = 1:length(freqs)
    y = y + (1/length(freqs)) * sin(2*pi*freqs(k)*t);  % 平均能量
end
sound(y, Fs);

常见坑:振幅超过 ±1 会削顶

sound / audiowrite 期望数据在 -1~+1 之间。如果你把多个声源简单相加(例如 sin+cos),结果可能超过 1.0,导致输出出现”噼啪”的硬削顶失真。解决:先用 max(abs(y)) 看峰值,超过 1 就 y = y / max(abs(y)) 归一化。

13.8.5 真实工程:心电图 / 振动信号的播放

心电信号本质是低频(0.5~40 Hz)的电信号。如果你测到一段 ECG,可以这样播放:

>> [y, Fs] = audioread('ecg.wav');    % 真实 ECG 数据
>> y = y / max(abs(y));               % 归一化到 -1~1
>> sound(y, Fs);                      % 听「心跳」

测振动信号则要注意采样率 —— 轴承振动往往在 1 kHz 以上,必须用 ≥ 10 kHz 的采样率才能在频谱里看到峰值。


本章通关标准:

  1. 能在脑子里把「一张图像」直接翻译成「一个 的矩阵」;
  2. 能用 imread 读入图像并通过矩阵运算修改像素(如压暗、翻转、灰度化)后 imwrite 写回;
  3. 能写一个带按钮和滑块的 GUI,让用户点击触发一段处理逻辑。