Administrator
发布于 2026-09-09 / 0 阅读
0
0

Python TP2:面向对象编程(Signal1D 类)

POO(面向对象编程)

本练习涉及面向对象编程、property属性和魔术方法的使用。目标是实现一个 Signal1D 类,用于表示一个离散时间信号,使其可以像对象一样进行各种操作。

信号由以下两个核心属性定义:一个numpy数据向量,存储在属性 .raw 中;一个采样频率,存储在属性 .fs 中。采样频率的物理意义是每秒采集的样本数,单位是赫兹(Hz)。

在开发过程中还需要注意以下实践要求:在git和uv仓库中对工作进行版本控制;在模块文件上执行 ruffblackmypy 进行代码质量检查;在项目根目录创建测试脚本来验证模块功能。

基础类的构建

创建类及其构造函数

首先定义 Signal1D 类的基本结构。在Python中,__init__ 方法是类的构造函数,在创建对象实例时自动调用。self 参数代表实例本身,通过它可以访问和设置实例的属性。

import numpy as np

class Signal1D:
    def __init__(self, data, fs: float):
        self.raw = np.asarray(data)
        self.fs = fs

np.asarray(data) 的作用是将输入数据转换为numpy数组。与 np.array() 不同的是,如果输入本身已经是ndarray,asarray 不会创建副本,而是直接返回原数组的引用,这样更高效。fs: float 是类型注解,表明参数 fs 应该是浮点数类型。

验证类的基本功能

在编写代码的过程中应该逐步验证其有效性,这是良好的开发习惯。

# 测试代码
s = Signal1D([1, 2, 3, 4, 5], fs=100.0)
print(s.raw)    # [1 2 3 4 5]
print(s.fs)     # 100.0

执行结果:

[1 2 3 4 5]
100.0

创建了一个包含5个采样点的信号,采样频率为100Hz。s.raw 返回存储的数据数组,s.fs 返回采样频率。

确保数据类型的一致性

构造函数应该能够接受 np.array 能接受的任何输入类型(列表、元组、numpy数组等),并统一转换为 np.ndarray 类型。这种设计提高了类的灵活性和易用性。

import numpy as np

class Signal1D:
    def __init__(self, data, fs: float):
        self.raw = np.asarray(data)  # 自动将任何输入转换为 np.ndarray
        self.fs = fs

测试不同输入类型的转换效果:

# 测试代码
s1 = Signal1D([1, 2, 3], fs=100.0)           # 列表
s2 = Signal1D((4, 5, 6), fs=100.0)           # 元组
s3 = Signal1D(np.array([7, 8, 9]), fs=100.0) # numpy数组

print(type(s1.raw))  # <class 'numpy.ndarray'>
print(type(s2.raw))  # <class 'numpy.ndarray'>
print(type(s3.raw))  # <class 'numpy.ndarray'>

执行结果:

<class 'numpy.ndarray'>
<class 'numpy.ndarray'>
<class 'numpy.ndarray'>

无论输入是列表、元组还是numpy数组,self.raw 最终都是 numpy.ndarray 类型,保证了后续处理的一致性。

参数有效性验证

采样频率 fs 从物理意义上必须严格为正数,因为负数或零的采样频率没有实际意义。需要在构造函数中添加验证逻辑。

import numpy as np

class Signal1D:
    def __init__(self, data, fs: float):
        if fs <= 0:
            raise ValueError("采样频率 fs 必须严格为正")
        self.raw = np.asarray(data)
        self.fs = fs

raise 语句用于主动抛出异常。ValueError 是Python内置的异常类型,适用于参数值不合法的情况。将验证逻辑放在赋值操作之前,可以确保对象在创建失败时不会处于不一致的状态。

测试正常情况:

# 测试代码
s1 = Signal1D([1, 2, 3], fs=100.0)  # 正常
print(s1.fs)

执行结果:

100.0
异常处理机制

如果传入的采样频率不满足条件(小于等于零),构造函数应该抛出异常,阻止创建无效的对象。

s2 = Signal1D([1, 2, 3], fs=-10.0)  # 如果 fs < 0, 抛出异常

执行结果:

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
...
----> 1 s2 = Signal1D([1, 2, 3], fs=-10.0)  # 抛出 ValueError

      4     def __init__(self, data, fs: float):
      5         if fs <= 0:
----> 6             raise ValueError("采样频率 fs 必须严格为正")
      7         self.raw = np.asarray(data)
      8         self.fs = fs

ValueError: 采样频率 fs 必须严格为正

异常信息清晰地指出了问题所在,便于调试和错误处理。在实际应用中,调用者可以使用 try-except 语句捕获这个异常并进行适当处理。

添加duration属性

使用 @property 装饰器可以将方法转换为只读属性。duration 表示信号的持续时间,以秒为单位,计算方式是采样点数除以采样频率。

import numpy as np

class Signal1D:
    def __init__(self, data, fs: float):
        if fs <= 0:
            raise ValueError("采样频率 fs 必须严格为正")
        self.raw = np.asarray(data)
        self.fs = fs
    
    @property
    def duration(self) -> float:
        """返回信号持续时间 (秒)"""
        return len(self.raw) / self.fs

@property 装饰器的作用是让方法可以像属性一样被访问,即使用 s.duration 而不是 s.duration()。这种设计使得接口更加简洁直观。-> float 是返回值类型注解,表明该方法返回浮点数。

持续时间的计算公式为:

\text{duration} = \frac{\text{len(raw)}}{\text{fs}} = \frac{N}{f_s}

其中 N 是采样点数,f_s 是采样频率。例如,100个采样点、采样频率100Hz的信号,持续时间为1秒。由于这是一个计算属性而非存储属性,每次访问时都会根据当前的 rawfs 值重新计算,确保结果始终正确。将其设置为只读(只有getter没有setter)是因为持续时间是由数据长度和采样频率决定的派生量,不应该被直接修改。

将raw改为property并添加数据验证

之前 raw 是一个普通的实例属性,可以被任意赋值。为了增加数据验证功能(例如拒绝空数组),需要将其改为property。实际的数据存储在私有属性 ._data 中(Python约定以单下划线开头的属性为内部使用),而 raw 作为访问接口。

@property
def raw(self) -> np.ndarray:
    """返回信号数据"""
    return self._data

@raw.setter
def raw(self, value):
    """设置信号数据,拒绝空数组"""
    arr = np.asarray(value)
    if arr.size == 0:
        raise ValueError("数据不能为空")
    self._data = arr

@property 装饰器定义了getter方法,当访问 s.raw 时被调用。@raw.setter 装饰器定义了setter方法,当执行 s.raw = value 时被调用。在setter中,先将输入转换为numpy数组,然后检查数组大小,如果为空则抛出异常,否则才赋值给私有属性 _data

构造函数也需要相应修改,使用私有属性并添加空数组检查:

def __init__(self, data, fs: float):
    if fs <= 0:
        raise ValueError("采样频率 fs 必须严格为正")
    self._data = np.asarray(data)
    if self._data.size == 0:
        raise ValueError("数据不能为空")
    self.fs = fs

同时,duration 属性的实现也要改为使用 self._data

@property
def duration(self) -> float:
    """返回信号持续时间 (秒)"""
    return len(self._data) / self.fs

测试property的读写功能和验证逻辑:

# 测试代码
s = Signal1D([1, 2, 3], fs=100.0)
print(s.raw)  # [1 2 3]

s.raw = [4, 5, 6, 7]  # 正常赋值
print(s.raw)  # [4 5 6 7]

try:
    s.raw = []  # 空数组,抛出异常
except ValueError as e:
    print(f"捕获异常: {e}")

执行结果:

[1 2 3]
[4 5 6 7]
捕获异常: 数据不能为空
添加norm属性

norm 是一个只读属性,用于计算向量的范数(均方根值,RMS)。计算公式为:

\text{norm} = \sqrt{\frac{1}{N} \sum_{i} x_i^2}

其中 N 是采样点数,x_i 是第 i 个采样值。这个值表示信号的平均能量水平。

@property
def norm(self) -> float:
    """返回向量的范数: sqrt(1/N * sum(x_i^2))"""
    return np.sqrt(np.mean(self._data ** 2))

self._data ** 2 对数组中每个元素求平方,np.mean() 计算平均值,np.sqrt() 开平方根。这是一个只读属性,没有定义setter。

测试范数计算:

# 测试代码
s = Signal1D([1, 2, 3, 4, 5], fs=100.0)
print(s.norm)  # sqrt((1+4+9+16+25)/5) = sqrt(11) = 3.3166

执行结果:

3.3166247903554

手动验证:(1^2 + 2^2 + 3^2 + 4^2 + 5^2) / 5 = (1+4+9+16+25)/5 = 55/5 = 11\sqrt{11} \approx 3.3166

魔术方法

魔术方法(Magic Methods)是Python中以双下划线开头和结尾的特殊方法,它们定义了对象在特定操作下的行为。通过实现这些方法,可以让自定义类的对象像内置类型一样自然地使用。

实现__repr__方法

__repr__ 方法定义了对象的字符串表示形式,当使用 print() 或在交互式环境中直接输入对象名时被调用。目标是显示信号的有用信息,包括点数、采样频率、持续时间等。

def __repr__(self) -> str:
    return f"Signal1D(points={len(self._data)}, fs={self.fs} Hz, duration={self.duration} s)"

f-string 格式化字符串中,{len(self._data)} 插入采样点数,{self.fs} 插入采样频率,{self.duration} 插入持续时间。返回的字符串清晰地展示了信号的关键参数。

测试:

# 测试代码
s = Signal1D([1, 2, 3, 4, 5], fs=100.0)
print(s)  # Signal1D(points=5, fs=100.0 Hz, duration=0.05 s)

执行结果:

Signal1D(points=5, fs=100.0 Hz, duration=0.05 s)

5个采样点、100Hz采样频率,持续时间为 5/100 = 0.05 秒。

实现__len__方法

__len__ 方法使对象可以被 len() 函数调用,返回信号的长度(采样点数量)。

def __len__(self) -> int:
    return len(self._data)

实现后,可以直接对 Signal1D 对象使用 len() 函数,而不必访问 .raw 属性再取长度。

测试:

# 测试代码
s = Signal1D([1, 2, 3, 4, 5], fs=100.0)
print(len(s))  # 5

执行结果:

5
实现__getitem__方法

如果想用类似numpy数组的方式 s[i] 访问元素,必须先访问 .raw 属性,即 s.raw[i],这不太方便。实现 __getitem__ 方法可以让对象直接支持索引和切片操作。

def __getitem__(self, index):
    return self._data[index]

当使用 s[index] 语法时,Python会自动调用 s.__getitem__(index)。这里直接将索引操作委托给内部的numpy数组 _data,因此支持numpy的所有索引方式,包括正索引、负索引和切片。

测试各种索引方式:

# 测试代码
s = Signal1D([10, 20, 30, 40, 50], fs=100.0)
print(s[0])    # 10
print(s[2])    # 30
print(s[-1])   # 50
print(s[1:4])  # [20 30 40] 切片也支持

执行结果:

10
30
50
[20 30 40]

s[0] 访问第一个元素,s[-1] 访问最后一个元素(Python支持负索引从末尾计数),s[1:4] 返回索引1到3的切片(不包含索引4)。由于底层是numpy数组,所有numpy的高级索引功能都可以使用。

实现__add__方法

__add__ 方法定义了加法运算符 + 的行为,使两个 Signal1D 对象可以直接相加。基本用法如下:

s1 = Signal1D(...)
s2 = Signal1D(...)
s3 = s1 + s2
print(s3)

初始版本的实现直接将两个信号的数据相加,并返回一个新的 Signal1D 对象:

def __add__(self, other: "Signal1D") -> "Signal1D":
    return Signal1D(self._data + other._data, self.fs)

类型注解 other: "Signal1D" 使用字符串形式是因为在类定义内部引用自身类型时,类尚未完全定义,需要用字符串延迟求值。self._data + other._data 利用numpy数组的逐元素加法,结果仍是numpy数组。返回值是一个新的 Signal1D 实例,采样频率继承自 self

测试基本加法:

# 测试代码
s1 = Signal1D([1, 2, 3], fs=100.0)
s2 = Signal1D([4, 5, 6], fs=100.0)
s3 = s1 + s2
print(s3)      # Signal1D(points=3, fs=100.0 Hz, duration=0.03 s)
print(s3.raw)  # [5 7 9]

执行结果:

Signal1D(points=3, fs=100.0 Hz, duration=0.03 s)
[5 7 9]

逐元素相加:1+4=52+5=73+6=9

添加加法运算的验证逻辑

两个信号相加在物理上需要满足条件:采样频率必须相同(否则时间尺度不一致),信号长度必须相同(否则无法逐点对应)。需要在 __add__ 方法中添加验证,不满足条件时抛出 ValueError

def __add__(self, other: "Signal1D") -> "Signal1D":
    if self.fs != other.fs:
        raise ValueError("采样频率必须相同")
    if len(self) != len(other):
        raise ValueError("信号长度必须相同")
    return Signal1D(self._data + other._data, self.fs)

测试正常情况和异常情况:

# 测试代码
s1 = Signal1D([1, 2, 3], fs=100.0)
s2 = Signal1D([4, 5, 6], fs=100.0)
s3 = s1 + s2
print(s3.raw)  # [5 7 9]

# 测试不同采样频率
try:
    s4 = Signal1D([1, 2, 3], fs=200.0)
    s5 = s1 + s4
except ValueError as e:
    print(f"捕获异常: {e}")  # 采样频率必须相同

# 测试不同长度
try:
    s6 = Signal1D([1, 2, 3, 4], fs=100.0)
    s7 = s1 + s6
except ValueError as e:
    print(f"捕获异常: {e}")  # 信号长度必须相同

执行结果:

[5 7 9]
捕获异常: 采样频率必须相同
捕获异常: 信号长度必须相同
确保返回值类型正确

加法运算的结果必须是一个 Signal1D 对象,而不是普通的numpy数组。可以通过 type()isinstance() 来验证。

# 测试代码
s1 = Signal1D([1, 2, 3], fs=100.0)
s2 = Signal1D([4, 5, 6], fs=100.0)
s3 = s1 + s2

print(type(s3))                  # <class '__main__.Signal1D'>
print(isinstance(s3, Signal1D))  # True
print(s3)                        # Signal1D(points=3, fs=100.0 Hz, duration=0.03 s)

执行结果:

<class '__main__.Signal1D'>
True
Signal1D(points=3, fs=100.0 Hz, duration=0.03 s)

返回的 s3 是完整的 Signal1D 对象,拥有所有的属性和方法。

实现__mul__方法

__mul__ 方法定义了乘法运算符 * 的行为。这里实现的是信号与标量(数字)的乘法,即将信号的每个采样值乘以一个系数,常用于信号的放大或衰减。

def __mul__(self, scalar: float) -> "Signal1D":
    if not isinstance(scalar, (int, float)):
        raise TypeError("必须与标量相乘")
    if scalar <= 0:
        raise ValueError("标量必须为正")
    return Signal1D(self._data * scalar, self.fs)

首先用 isinstance() 检查 scalar 是否为数字类型(整数或浮点数),如果不是则抛出 TypeError。然后检查标量是否为正数,这里的设计选择是只允许正数乘法(根据具体需求,也可以允许负数或零)。最后返回新的 Signal1D 对象,数据是原数据乘以标量。

测试标量乘法:

# 测试代码
s1 = Signal1D([1, 2, 3], fs=100.0)
s2 = s1 * 2.5
print(s2.raw)  # [2.5 5.  7.5]
print(s2)      # Signal1D(points=3, fs=100.0 Hz, duration=0.03 s)

# 测试负标量
try:
    s3 = s1 * (-1)
except ValueError as e:
    print(f"捕获异常: {e}")  # 标量必须为正

执行结果:

[2.5 5.  7.5]
Signal1D(points=3, fs=100.0 Hz, duration=0.03 s)
捕获异常: 标量必须为正

信号 [1, 2, 3] 乘以 2.5 得到 [2.5, 5.0, 7.5]。采样频率和持续时间保持不变,因为标量乘法只改变幅度,不改变时间特性。

方法

normalize方法

normalize 方法用于对信号进行归一化处理,使信号的幅值最大值变为1。这在信号处理中是常见操作,可以消除不同信号之间的幅度差异,便于比较和后续处理。

def normalize(self) -> "Signal1D":
    """归一化信号,使得幅值最大为1"""
    max_val = np.max(np.abs(self._data))
    return Signal1D(self._data / max_val, self.fs)

归一化的计算过程分为两步:首先用 np.abs(self._data) 取所有数据的绝对值(因为信号可能有正有负),然后用 np.max() 找到绝对值的最大值;接着将整个数据数组除以这个最大值。这样处理后,所有数据都会落在 [-1, 1] 范围内,且绝对值最大的点变为 \pm 1。方法返回一个新的 Signal1D 对象,原信号不被修改(不可变设计)。

测试归一化:

# 测试代码
s = Signal1D([2, 4, -8, 6], fs=100.0)
s_norm = s.normalize()
print(s_norm.raw)                    # [ 0.25  0.5  -1.    0.75]
print(np.max(np.abs(s_norm.raw)))    # 1.0

执行结果:

[ 0.25  0.5  -1.    0.75]
1.0

原信号中绝对值最大的是 -8,因此所有值除以 82/8=0.254/8=0.5-8/8=-16/8=0.75。归一化后最大绝对值确实为 1

concatenate方法

concatenate 方法用于将两个信号首尾相连,生成一个更长的信号。这在拼接音频片段、组合不同信号段时非常有用。

def concatenate(self, other: "Signal1D") -> "Signal1D":
    """连接两个信号,采样频率必须相同"""
    if self.fs != other.fs:
        raise ValueError("采样频率必须相同")
    return Signal1D(np.concatenate([self._data, other._data]), self.fs)

连接操作在物理上要求两个信号具有相同的采样频率,否则时间尺度不一致,连接后的信号没有意义。np.concatenate() 函数接受一个数组列表,将它们沿指定轴(默认为第一个轴)连接起来。返回的新信号长度等于两个原信号长度之和,持续时间也相应增加。

测试信号连接:

# 测试代码
s1 = Signal1D([1, 2, 3], fs=100.0)
s2 = Signal1D([4, 5, 6, 7], fs=100.0)
s3 = s1.concatenate(s2)
print(s3.raw)  # [1 2 3 4 5 6 7]
print(s3)      # Signal1D(points=7, fs=100.0 Hz, duration=0.07 s)

# 测试不同采样频率
try:
    s4 = Signal1D([8, 9], fs=200.0)
    s5 = s1.concatenate(s4)
except ValueError as e:
    print(f"捕获异常: {e}")  # 采样频率必须相同

执行结果:

[1 2 3 4 5 6 7]
Signal1D(points=7, fs=100.0 Hz, duration=0.07 s)
捕获异常: 采样频率必须相同

s1 有3个点,s2 有4个点,连接后 s3 有7个点。持续时间为 7/100 = 0.07 秒。尝试连接采样频率不同的信号会触发异常。

play方法

play 方法将信号作为音频播放。在Jupyter/Colab环境中,可以使用 IPython.display.Audio 类来实现音频播放功能。首先需要安装 sounddevice 库:

!pip install sounddevice

实现代码:

from IPython.display import Audio

def play(self):
    """播放信号(返回 Audio 对象)"""
    return Audio(self._data, rate=int(self.fs))

Audio 类接受两个主要参数:self._data 是音频数据数组,rate 是采样率(必须是整数)。返回的 Audio 对象在Jupyter环境中会自动渲染为一个可交互的音频播放器。

测试播放功能,生成一个标准A4音符(440Hz)的正弦波:

# 测试代码 - 生成一个 440Hz 的正弦波(A4音符)并播放
fs = 44100  # 采样频率
duration = 1.0  # 持续1秒
t = np.linspace(0, duration, int(fs * duration), endpoint=False)
data = 0.5 * np.sin(2 * np.pi * 440 * t)  # 440Hz 正弦波

s = Signal1D(data, fs=fs)
print(s)
s.play()  # 会显示一个播放器,点击播放

采样频率 44100 Hz 是CD音质的标准采样率。np.linspace(0, duration, int(fs * duration), endpoint=False) 生成从0到1秒的时间轴,共44100个采样点。正弦波公式 0.5 \times \sin(2\pi \times 440 \times t) 生成频率为440Hz、幅度为0.5的正弦信号。440Hz对应音乐中的标准A4音符。执行 s.play() 后,在Notebook环境中会显示一个音频播放器控件,点击即可听到这个音符。

迭代器和闭包

本节将编写一种迷你处理流水线,涉及的数值计算任务包括:将信号分割成块、应用滤波器、计算时间计时、参数管理。

数据类ParametresFiltre

dataclass 是Python 3.7引入的装饰器,用于简化数据容器类的定义。它自动生成 __init____repr____eq__ 等方法,减少样板代码。创建一个 ParametresFiltre 数据类来管理滤波器参数,包含增益(浮点数)、块大小(整数)和可选的名称。

from dataclasses import dataclass
from typing import Optional

@dataclass
class ParametresFiltre:
    gain: float
    block_size: int
    name: Optional[str] = None

@dataclass 装饰器自动根据类属性生成构造函数。gain: floatblock_size: int 是类型注解,同时也定义了必需的参数。name: Optional[str] = None 表示这是一个可选参数,默认值为 NoneOptional[str] 类型注解表示该值可以是字符串或 None

测试数据类:

# 测试代码
p1 = ParametresFiltre(gain=0.8, block_size=512)
print(p1)  # ParametresFiltre(gain=0.8, block_size=512, name=None)

p2 = ParametresFiltre(gain=1.2, block_size=256, name="低通滤波器")
print(p2)  # ParametresFiltre(gain=1.2, block_size=256, name='低通滤波器')

执行结果:

ParametresFiltre(gain=0.8, block_size=512, name=None)
ParametresFiltre(gain=1.2, block_size=256, name='低通滤波器')

@dataclass 自动生成的 __repr__ 方法提供了清晰的字符串表示。

迭代器的基本结构

迭代器(iterator)是Python中实现迭代协议的对象。一个类要成为迭代器,必须实现两个方法:__iter__(self) 返回迭代器对象本身,__next__(self) 返回下一个元素或在没有更多元素时抛出 StopIteration 异常。迭代器使得对象可以在 for 循环中使用。

定义 BlockIterator 类的基本结构:

import numpy as np

class BlockIterator:
    def __init__(self, signal: Signal1D, block_size: int):
        self.signal = signal
        self.block_size = block_size
        self.index = 0

    def __iter__(self):
        return self
    
    def __next__(self) -> np.ndarray:
        raise StopIteration

__init__ 方法接收两个参数:要切割的信号(Signal1D 对象)和块大小(整数)。self.index 用于跟踪当前迭代位置,初始为0。

__iter__方法

__iter__ 方法是迭代协议的一部分,当对象被用于 for 循环或 iter() 函数时调用。最常见的实现是返回 self,表示对象本身就是迭代器。这是 for 循环的初始化步骤。

def __iter__(self):
    """返回 self,对象本身就是迭代器"""
    return self

验证 __iter__ 返回自身:

# 测试代码
s = Signal1D([1, 2, 3, 4, 5], fs=100.0)
it = BlockIterator(s, 2)
print(iter(it) is it)  # True

执行结果:

True

iter(it) 调用 it.__iter__(),返回 self,因此 iter(it) is itTrue

__next__方法

__next__ 方法负责返回序列中的下一个元素。在信号分块的场景中,它返回信号的下一个块。当所有块都已返回后,抛出 StopIteration 异常来通知 for 循环结束。

def __next__(self) -> np.ndarray:
    """返回信号的下一个块"""
    if self.index >= len(self.signal):
        raise StopIteration
    block = self.signal.raw[self.index:self.index + self.block_size]
    self.index += self.block_size
    return block

首先检查当前索引是否已超过信号长度,如果是则抛出 StopIteration。否则,使用切片 self.signal.raw[self.index:self.index + self.block_size] 提取当前块,然后将索引前移一个块大小,最后返回提取的块。

测试迭代功能:

# 测试代码
s = Signal1D([1, 2, 3, 4, 5, 6], fs=100.0)

for bloc in BlockIterator(s, 2):
    print(bloc)

执行结果:

[1 2]
[3 4]
[5 6]

信号被分成3个大小为2的块,for 循环依次获取每个块直到 StopIteration 被抛出。

处理不完整的最后一个块

当信号长度不能被块大小整除时,最后一个块的大小会小于指定的块大小。Python的切片操作自动处理这种情况:如果切片的结束索引超过数组长度,切片会返回到数组末尾为止的所有元素,不会报错。

def __next__(self) -> np.ndarray:
    """返回下一个块,最后一个块可能小于 block_size"""
    if self.index >= len(self.signal):
        raise StopIteration
    # 切片自动处理最后一个块不足的情况
    block = self.signal.raw[self.index:self.index + self.block_size]
    self.index += self.block_size
    return block

测试信号长度为7、块大小为3的情况:

# 测试代码 - 信号长度为7,块大小为3
s = Signal1D([1, 2, 3, 4, 5, 6, 7], fs=100.0)

for bloc in BlockIterator(s, 3):
    print(f"块: {bloc}, 大小: {len(bloc)}")

执行结果:

块: [1 2 3], 大小: 3
块: [4 5 6], 大小: 3
块: [7], 大小: 1

前两个块大小为3,最后一个块只有1个元素,因为 7 = 3 \times 2 + 1

迭代器的实际使用

完整的 BlockIterator 类可以直接在 for 循环中使用,遍历信号的所有块:

for bloc in BlockIterator(signal, 512):
    print(bloc.shape)

测试更大规模的信号:

# 测试代码 - 按题目要求的方式使用
s = Signal1D(np.random.randn(2000), fs=44100.0)

for bloc in BlockIterator(s, 512):
    print(bloc.shape)

执行结果:

(512,)
(512,)
(512,)
(464,)

信号共2000个采样点,块大小512。2000 = 512 \times 3 + 464,因此产生3个完整块和1个大小为464的最后块。bloc.shape 返回numpy数组的形状,一维数组显示为 (n,) 形式。

滤波器工厂函数

make_filtre 是一个工厂函数(factory function),它返回另一个函数。这种模式称为闭包(closure):内部函数 filtre 可以访问外部函数 make_filtre 的参数 gain,即使外部函数已经返回。闭包使得我们可以创建具有预设参数的函数。

def make_filtre(gain: float):
    """返回一个对信号应用增益的函数"""
    def filtre(signal):
        return signal * gain
    return filtre

make_filtre(gain) 不直接处理信号,而是返回一个新函数 filtre。这个返回的函数接受信号作为参数,将其乘以预设的增益值。gain 变量被"捕获"在闭包中,即使 make_filtre 执行完毕,filtre 仍然可以访问它。

测试工厂函数:

# 测试代码
filtre = make_filtre(0.5)
data = np.array([2, 4, 6, 8, 10])
result = filtre(data)
print(result)  # [1. 2. 3. 4. 5.]

执行结果:

[1. 2. 3. 4. 5.]

所有值乘以0.5:2 \times 0.5 = 14 \times 0.5 = 2,以此类推。

滤波器作为单参数函数

通过 make_filtre 创建的 filtre 是一个单参数函数,只需要传入信号即可,增益已经预设好了。这种设计使得滤波器可以方便地与其他组件(如迭代器)配合使用。

# 测试代码
filtre = make_filtre(0.8)

# filtre 是一个单参数函数
print(callable(filtre))  # True

# 使用 filtre
data = np.array([10, 20, 30])
result = filtre(data)
print(result)  # [ 8. 16. 24.]

执行结果:

True
[ 8. 16. 24.]

callable(filtre) 检查对象是否可调用,函数当然是可调用的。

将滤波器与 BlockIterator 配合使用,对每个块应用滤波:

# 也可以与 BlockIterator 配合使用
s = Signal1D([1, 2, 3, 4, 5, 6], fs=100.0)
for bloc in BlockIterator(s, 2):
    filtered_bloc = filtre(bloc)
    print(f"原始: {bloc}, 滤波后: {filtered_bloc}")

执行结果:

原始: [1 2], 滤波后: [0.8 1.6]
原始: [3 4], 滤波后: [2.4 3.2]
原始: [5 6], 滤波后: [4.  4.8]

每个块被迭代器提取后,立即通过滤波器处理。

扩展为低通滤波器

可以扩展 make_filtre 函数,增加低通滤波功能。低通滤波器允许低频信号通过,抑制高频成分(如噪声)。

from scipy import signal as sp_signal

def make_filtre(gain: float, lowpass: bool = False, cutoff: float = 0.5):
    """
    返回一个滤波器函数
    :param gain: 增益
    :param lowpass: 是否启用低通滤波
    :param cutoff: 截止频率(归一化,0到1之间,1表示奈奎斯特频率)
    """
    def filtre(signal_data):
        result = signal_data * gain
        if lowpass:
            # 设计低通滤波器 (Butterworth, 4阶)
            b, a = sp_signal.butter(4, cutoff, btype='low')
            result = sp_signal.filtfilt(b, a, result)
        return result
    return filtre

sp_signal.butter(4, cutoff, btype='low') 设计一个4阶Butterworth低通滤波器,返回滤波器系数 bacutoff 是归一化截止频率,范围0到1,其中1对应奈奎斯特频率(采样频率的一半)。sp_signal.filtfilt 进行零相位滤波,即正向和反向各滤波一次,消除相位延迟。

测试低通滤波效果:

# 低通滤波器
filtre_lowpass = make_filtre(gain=1.0, lowpass=True, cutoff=0.3)

# 生成带噪声的信号
fs = 1000
t = np.linspace(0, 1, fs, endpoint=False)
clean_signal = np.sin(2 * np.pi * 5 * t)  # 5Hz 正弦波
noise = 0.5 * np.sin(2 * np.pi * 100 * t)  # 100Hz 噪声
noisy_signal = clean_signal + noise

# 应用低通滤波
filtered_signal = filtre_lowpass(noisy_signal)
print(f"原始信号方差: {np.var(noisy_signal):.4f}")
print(f"滤波后信号方差: {np.var(filtered_signal):.4f}")

原始信号包含5Hz的有用信号和100Hz的噪声。低通滤波器截止频率设为0.3(对应150Hz),可以让5Hz信号通过,同时抑制100Hz噪声。滤波后方差降低表明高频噪声被有效去除。

装饰器

计时装饰器

装饰器(decorator)是一种修改函数行为的技术。创建一个装饰器来计算函数执行时间,并将时间存储在列表中,便于后续分析。

import time

def timing_decorator(func):
    """装饰器:计算函数执行时间并存储在列表中"""
    def wrapper(*args, **kwargs):
        start = time.process_time()
        result = func(*args, **kwargs)
        end = time.process_time()
        wrapper.times.append(end - start)
        return result
    wrapper.times = []  # 存储时间的列表
    return wrapper

timing_decorator 接受一个函数 func 作为参数,返回一个新函数 wrapperwrapper 在调用原函数前后记录时间,计算执行时长并存入 wrapper.times 列表。*args, **kwargs 使得 wrapper 可以接受任意参数并传递给原函数。

测试计时装饰器:

# 测试代码
@timing_decorator
def my_filtre(signal):
    return signal * 0.8

data = np.random.randn(10000)
for _ in range(5):
    my_filtre(data)

print(f"执行时间列表: {my_filtre.times}")

@timing_decorator 语法等价于 my_filtre = timing_decorator(my_filtre)。每次调用 my_filtre 时,实际执行的是 wrapper,执行时间被记录。

函数作为对象

Python中函数是一等对象(first-class object),可以像其他对象一样被赋值、传递、添加属性。wrapper.times = [] 给函数对象添加了一个 times 属性,用于存储时间数据。

# 测试代码 - 验证函数属性
@timing_decorator
def my_filtre(signal):
    return signal * 0.8

# 函数是对象,可以有属性
print(type(my_filtre))             # <class 'function'>
print(hasattr(my_filtre, 'times')) # True
print(my_filtre.times)             # []

# 执行几次
my_filtre(np.array([1, 2, 3]))
my_filtre(np.array([4, 5, 6]))

# 查看时间列表
print(my_filtre.times)  # [时间1, 时间2]

执行结果:

<class 'function'>
True
[]
[时间1, 时间2]
使用process_time

time.process_time() 返回当前进程的CPU时间,不包括睡眠时间,比 time.time() 更适合测量计算密集型任务的执行时间。time.time() 返回的是墙钟时间(wall-clock time),会受到系统负载、I/O等待等因素影响。

import time

def timing_decorator(func):
    """
    装饰器:计算函数执行时间并存储在列表中
    使用 time.process_time() 测量CPU时间(更精确)
    """
    def wrapper(*args, **kwargs):
        start = time.process_time()  # CPU时间,比time.time()更精确
        result = func(*args, **kwargs)
        end = time.process_time()
        elapsed = end - start
        wrapper.times.append(elapsed)
        return result
    wrapper.times = []
    return wrapper

测试并统计执行时间:

# 测试代码
@timing_decorator
def my_filtre(signal):
    # 模拟一些计算
    return signal * 0.8 + np.sin(signal)

# 执行多次
data = np.random.randn(100000)
for i in range(10):
    my_filtre(data)

print(f"执行次数: {len(my_filtre.times)}")
print(f"时间列表: {my_filtre.times}")
print(f"平均时间: {np.mean(my_filtre.times):.6f} 秒")
print(f"总时间: {np.sum(my_filtre.times):.6f} 秒")
完整的信号处理流水线

将所有组件整合在一起:Signal1D 类用于表示信号,ParametresFiltre 数据类管理参数,BlockIterator 迭代器分块处理,timing_decorator 装饰器计时,make_filtre 工厂函数创建滤波器。

import numpy as np
import time
from dataclasses import dataclass
from typing import Optional
from IPython.display import Audio

# ============== Signal1D 类 ==============
class Signal1D:
    # ... 完整实现如前所述 ...

# ============== 数据类 ==============
@dataclass
class ParametresFiltre:
    gain: float
    block_size: int
    name: Optional[str] = None

# ============== 迭代器 ==============
class BlockIterator:
    def __init__(self, signal: Signal1D, block_size: int):
        self.signal = signal
        self.block_size = block_size
        self.index = 0

    def __iter__(self):
        return self
    
    def __next__(self) -> np.ndarray:
        if self.index >= len(self.signal):
            raise StopIteration
        block = self.signal.raw[self.index:self.index + self.block_size]
        self.index += self.block_size
        return block

# ============== 装饰器 ==============
def timing_decorator(func):
    def wrapper(*args, **kwargs):
        start = time.process_time()
        result = func(*args, **kwargs)
        end = time.process_time()
        wrapper.times.append(end - start)
        return result
    wrapper.times = []
    return wrapper

# ============== 滤波器工厂 ==============
def make_filtre(gain: float):
    @timing_decorator
    def filtre(signal):
        return signal * gain
    return filtre

使用这套流水线处理信号时,首先用参数类配置滤波器参数,然后用工厂函数创建滤波器,接着用迭代器将信号分块,对每个块应用滤波器,最后将滤波后的块重新组装。装饰器自动记录每个块的处理时间,便于性能分析。

完整流水线测试

将所有组件组装起来,实现一个完整的信号处理流水线:创建参数、生成信号、创建滤波器、分块处理、合并结果。

# 测试代码 - 完整流水线

# 1. 创建参数
params = ParametresFiltre(gain=0.8, block_size=512, name="增益滤波器")
print(f"参数: {params}")

# 2. 创建信号
fs = 44100
duration = 1.0
t = np.linspace(0, duration, int(fs * duration), endpoint=False)
data = np.sin(2 * np.pi * 440 * t)  # 440Hz 正弦波
signal = Signal1D(data, fs=fs)
print(f"信号: {signal}")

# 3. 创建滤波器
filtre = make_filtre(params.gain)

# 4. 处理流水线:遍历块并应用滤波器
filtered_blocks = []
for bloc in BlockIterator(signal, params.block_size):
    filtered_bloc = filtre(bloc)
    filtered_blocks.append(filtered_bloc)

# 5. 合并结果
filtered_data = np.concatenate(filtered_blocks)
filtered_signal = Signal1D(filtered_data, fs=fs)

# 6. 显示结果
print(f"滤波后信号: {filtered_signal}")
print(f"处理块数: {len(filtre.times)}")
print(f"总处理时间: {sum(filtre.times):.6f} 秒")
print(f"平均每块时间: {np.mean(filtre.times):.6f} 秒")

# 7. 播放滤波后的信号
filtered_signal.play()

流水线的执行流程如下:

第一步使用 ParametresFiltre 数据类创建参数对象,包含增益0.8、块大小512和滤波器名称。数据类自动提供了清晰的字符串表示。

第二步生成测试信号。采样频率44100Hz是CD音质标准,持续时间1秒,共44100个采样点。使用 np.linspace 生成时间轴,然后生成440Hz的正弦波(标准A4音符)。

第三步通过工厂函数 make_filtre 创建滤波器。由于在 make_filtre 内部使用了 @timing_decorator 装饰器,返回的滤波器函数会自动记录每次调用的执行时间。

第四步是核心处理循环。BlockIterator 将信号分成512个采样点的块,for 循环遍历每个块,对每个块应用滤波器,将滤波后的块添加到列表中。每次调用 filtre(bloc) 时,装饰器自动记录执行时间到 filtre.times 列表。

第五步使用 np.concatenate 将所有滤波后的块重新合并成完整的数组,然后创建新的 Signal1D 对象。

第六步显示处理结果。len(filtre.times) 给出处理的块数,44100 / 512 \approx 86 个块(最后一个块可能不完整)。sum(filtre.times) 给出总处理时间,np.mean(filtre.times) 给出平均每块的处理时间。这些性能数据对于优化流水线和评估实时处理能力非常有用。

第七步调用 play() 方法播放滤波后的信号。由于增益为0.8,滤波后的信号幅度是原信号的80%,听起来会稍微安静一些。

这个流水线展示了面向对象编程、迭代器模式、闭包、装饰器等Python高级特性的综合应用,构成了一个模块化、可扩展、可测量的信号处理框架。


评论