สังเกตว่า s09 เดินตาม "โครงร่วม" เดียวกับทุกชุดบทเรียนที่ผ่านมา — จับโครงนี้ได้ อ่านโปรแกรมไหนบนบอร์ดก็ไม่หลง:
HALF แท่งกับป้าย Hz สร้างก่อนลูป ในลูปแค่ bars[k].value(...) กับ bars[k].color(...) — ถ้าสร้างแท่งใหม่ทุกเฟรม จอกระพริบและกินหน่วยความจำfinally อัปเดตข้อความปิด ไม่ทิ้งงานค้างโครงเดียวกันนี้ใช้ได้กับทุกแอปบนบอร์ด — พอจับโครงได้ คุณจะอ่านโค้ดชุดบทเรียนอื่นได้เร็วขึ้นมาก เพราะรู้ว่าจะไปหาส่วนไหนตรงไหน
ไฟล์ฝึกให้ fft() มาครบ (radix-2 Cooley-Tukey) เราไม่แก้ แต่มาดูว่ามันเป็นแค่การบวก-คูณเป็นระเบียบ:
def fft(re, im):
n = len(re)
# 1) bit-reversal: จัดลำดับ input ให้ butterfly ทำงานในที่ได้
...
# 2) butterflies: รวมผลทีละคู่ ไล่ขนาด 2,4,8,...,N
length = 2
while length <= n:
ang = -2.0 * math.pi / length
wr, wi = math.cos(ang), math.sin(ang) # หมุนเวกเตอร์ทีละสเต็ป
...
length <<= 1
return re, im
re/im เป็น list ยาว N คู่กัน (im เริ่มเป็น 0 เพราะสัญญาณจริงไม่มีส่วนจินตภาพ)length ทวีคูณ (2 → 4 → 8 → …) นี่คือ "แบ่งครึ่งซ้ำ ๆ" ที่ทำให้เร็ว N log Nอยากลองใน REPL ก็ได้: ป้อนคลื่นไซน์ที่เรารู้ความถี่เข้าไป แล้วดูว่า peak โผล่ที่ bin ที่คาดไว้ไหม — นี่คือวิธี "ตรวจสอบ FFT ด้วยตัวเอง" ที่ดีที่สุด
ก่อนเชื่อ FFT กับสัญญาณจริง มาลองกับไซน์ที่ เรารู้ความถี่อยู่แล้ว ถ้า peak โผล่ที่ bin ที่คำนวณได้ แปลว่า pipeline ถูก
# สร้างไซน์ 6.25 Hz ที่ FS=50, N=32 -> bin คาดหวัง = f*N/FS = 6.25*32/50 = 4
re = [math.sin(2*math.pi*6.25*i/FS) for i in range(N)]
im = [0.0]*N
fft(re, im)
mag = [math.sqrt(re[k]**2 + im[k]**2) for k in range(N//2)]
print(max(range(N//2), key=lambda k: mag[k])) # ควรได้ 4
f แปลงเป็น bin ด้วย k = f * N / FS (ผกผันกับ f = k * FS / N)เทคนิคนี้เรียก "sanity check ด้วย ground truth" — สร้าง input ที่รู้คำตอบ แล้วดูว่าเครื่องมือตอบตรงไหม เป็นนิสัยที่ดีก่อนเชื่ออะไรก็ตามกับข้อมูลจริง
ชุดบทเรียนนี้เราป้อน accel แกน Z ของ BMI270 เข้า FFT เพราะการเขย่าบอร์ดขึ้น-ลงตกที่แกน Z พอดี เห็นผลชัดและปลอดภัย (ไม่ต้องต่ออะไรเพิ่ม)
_, _, az, _, _, _ = sensors.bmi270.motion() # (ax,ay,az,gx,gy,gz)
buf.append(az)
time.sleep_ms(int(1000 / FS)) # เว้นให้ได้อัตราสุ่ม FS
sensors.bmi270.motion() คืน 6 ค่า (accel 3 + gyro 3) เราหยิบแค่ az1000/FS ms) — FFT อ่านความถี่ถูกต่อเมื่อระยะห่างระหว่างจุดเท่ากันเป๊ะทำไมไม่ใช้ไมค์เลยตั้งแต่ชุดบทเรียนนี้? เพราะ IMU ช้าและจับต้องได้ — เขย่าเองเห็น peak ขยับตามมือ เข้าใจ FFT ก่อน แล้วค่อยเอาไปใช้กับเสียง (16 kHz) ในบทเรียน 4.5–4.6
ส่วนต้นของลูป เก็บสัญญาณ N จุด (ให้มาแล้ว) แล้ว ช่องเติมที่ 1 คือหาค่าเฉลี่ยเพื่อตัด DC:
buf = []
for _ in range(N):
_, _, az, _, _, _ = sensors.bmi270.motion()
buf.append(az)
time.sleep_ms(int(1000 / FS))
# เติม: ตัด DC/แรงโน้มถ่วง -> mean = sum(buf) / N
mean = 0.0
mean = 0.0 ด้วย mean = sum(buf) / Nmean เป็น 0 แปลว่าไม่ตัด DC เลย → bin 0 พุ่งเด่น (แรงโน้มถ่วง) กลบสเปกตรัมจริงลองรันทั้งสองแบบเทียบกันจริง ๆ นะ วางบอร์ดนิ่ง ๆ แล้วดู bin 0 — เห็นความต่างชัดมาก นี่คือบทเรียน "ทำไมต้องตัด DC" ที่จำได้ไม่ลืม
ช่องเติมที่ 2: หลังตัด DC คูณ Hann window ก่อนส่งเข้า FFT:
# เติม: คูณ Hann window ลด spectral leakage
# w[i] = 0.5 - 0.5*math.cos(2*math.pi*i/(N-1))
re = [(buf[i] - mean) for i in range(N)] # <- ยังไม่คูณ window
im = [0.0] * N
แทนบรรทัด re = ... ด้วย:
re = [(buf[i] - mean) * (0.5 - 0.5 * math.cos(2 * math.pi * i / (N - 1)))
for i in range(N)]
im เป็น 0 ทั้งหมด เพราะสัญญาณจริง (accel) ไม่มีส่วนจินตภาพสังเกตว่าเราทำสองอย่างในบรรทัดเดียว:
(buf[i]-mean)คือตัด DC,*(0.5-...)คือ window — อ่านจากในออกนอก: ตัด DC ก่อน แล้วค่อยคูณผ้าคลุม
หลัง fft(re, im) (ให้มาแล้ว) ผลอยู่ใน re/im ช่องเติมที่ 3 คือแปลงเป็นขนาด:
fft(re, im) # ให้มาแล้ว: เวลา -> ความถี่
# เติม: magnitude = sqrt(re^2 + im^2) เอาเฉพาะครึ่งแรก HALF bin
mag = [0.0 for k in range(HALF)]
แทนด้วย:
mag = [math.sqrt(re[k] * re[k] + im[k] * im[k]) for k in range(HALF)]
range(HALF) = N//2 bin แรก (ความถี่บวก) ครึ่งหลังเป็นภาพสะท้อน ไม่ต้องใช้mag เป็น 0 ทั้งหมด → แท่งสเปกตรัมแบนราบ ไม่ขยับเลย
mag[k]คือคำตอบสุดท้ายของ "ความถี่ k แรงแค่ไหน" — จากตรงนี้เอาไปวาดแท่ง กับหา peak ได้เลย
ช่องเติมที่ 4: หา bin เด่น แล้วส่วนที่เหลือ (วาดแท่ง + แปลง Hz) ให้มาแล้ว:
# เติม: bin ที่พลังงานสูงสุด (ข้าม bin 0 = DC)
kmax = 1 # <- แก้เป็น max(...)
แทนด้วย kmax = max(range(1, HALF), key=lambda k: mag[k]) แล้วส่วนแสดงผล (ให้มาแล้ว):
top = max(mag[1:]) # ยอดสูงสุด ใช้ normalize
for k in range(HALF):
bars[k].value(min(100, int(mag[k] / top * 100)))
bars[k].color(GREEN if k == kmax else DIM) # bin เด่น = เขียว
peak_lbl.text("peak: %.1f Hz (bin %d)" % (kmax * FS / N, kmax))
top ให้แท่งสูงสุดเต็มจอเสมอ ไม่ว่าจะเขย่าแรงหรือเบาkmax: มันค้างที่ 1 → แท่งขยับแต่ "ความถี่เด่น" ชี้ผิดตลอดครบ 4 ช่องเมื่อไร กด Run แล้วเขย่า — แท่งสเปกตรัมขยับ และ
peakเลื่อนตามจังหวะที่คุณเขย่า นี่คือ MVP ของวันนี้
เปิด s09_fft_spectrum.py ในไฟล์มีค่าเริ่มต้นวางไว้ 4 จุด ตรงที่ต้องเติมขั้นของ pipeline:
| # | จุด | เติมด้วย | ถ้าลืม |
|---|---|---|---|
| 1 | ตัด DC | mean = sum(buf) / N |
bin 0 พุ่งกลบสเปกตรัม |
| 2 | window | re = [(buf[i]-mean)*(0.5-0.5*cos(...)) ...] |
แท่งข้างเคียงรั่ว สเปกตรัมเลอะ |
| 3 | magnitude | mag = [sqrt(re[k]**2+im[k]**2) ...] |
แท่งแบนราบ ไม่ขยับ |
| 4 | peak | kmax = max(range(1,HALF), key=lambda k: mag[k]) |
ความถี่เด่นชี้ผิด (ค้างที่ 1) |
ขั้นตอน:
# เติม: ทีละจุด แล้วแทนค่าเริ่มต้นด้วยคำสั่งตามคำใบ้สี่ช่องนี้คือ pipeline วิเคราะห์ความถี่เป๊ะ — ตัด DC → window → magnitude → peak เติมครบเมื่อไร คุณอ่านสเปกตรัมสดได้เอง
ไม่มีบอร์ดก็เริ่มได้ เปิดเบราว์เซอร์แล้วทำตามนี้:
s09_fft_spectrum.py (หลังเติมครบ 4 ช่อง)Emulator ใช้เซนเซอร์ จำลอง แต่ FFT คำนวณจริงทุกบรรทัด — เหมาะกับซ้อมที่บ้าน แล้วมายืนยันกับของจริงบนบอร์ด

จอ emulator ที่รันได้จริง — BENTO Edge AI Emulator บนเบราว์เซอร์ ไม่ต้องมีบอร์ดก็เขย่า (จำลอง) แล้วเห็นค่าเซนเซอร์ขยับสด ๆ
เห็นค่าขยับบนจอนี้เมื่อไร แปลว่า pipeline อ่านเซนเซอร์ได้ถูก เหลือแค่ต่อ 4 ขั้น FFT ก็ได้สเปกตรัมสด
บนบอร์ดจริงเราใช้ของจริง IMU จริง การสั่นสะเทือนจริง:
s09_fft_spectrum.py ใน BENTO IDE กด Program to Deviceจุดที่ควรสังเกต: peak Hz ที่ได้ควรใกล้กับ "จำนวนครั้งที่เขย่าต่อวินาที" — เขย่า 3 ครั้ง/วินาที ควรได้ peak ~3 Hz นี่คือ FFT ที่จับต้องได้จริง
อยากเห็น Fourier / FFT แบบเห็นภาพชัดขึ้น ลองตามลิงก์เหล่านี้:
วิดีโอ (ช่องการศึกษาที่น่าเชื่อถือ)
บทความ / ภาพอ้างอิง (สาธารณสมบัติ / CC)
วิดีโอ/ภาพภายนอกเป็นของเจ้าของต้นฉบับ ใช้เพื่อการศึกษา อ้างอิงลิงก์ต้นทาง
MVP ของบทเรียน 4.3–4.4 (เกณฑ์ผ่านของชุดบทเรียน): คุณรัน s09_fft_spectrum.py ได้ แล้ว อ่านสเปกตรัมสด ออก — แท่งความถี่ + peak (Hz) เปลี่ยนตามการเขย่าเร็ว/ช้าจริง
"อ่านสเปกตรัมออก" ไม่ใช่แค่ "เห็นแท่งขยับ" — คุณต้องบอกได้ว่า peak ที่ 3 Hz หมายความว่าอะไร และทำไมเขย่าเร็วขึ้น peak ถึงเลื่อนขวา
ถ้าติด ให้ไต่บันไดนี้ทีละขั้น อย่าเพิ่งกระโดดไปดูเฉลย เพราะของจะเข้าหัวตอนที่คุณพยายามเองก่อน:
# เติม: ทั้ง 4 จุดในไฟล์ฝึก + ตารางหน้าที่แล้ว บอกว่าแต่ละช่องเติมอะไรs09_fft_spectrum.py มีโครงครบทั้งไฟล์แล้ว (รวมทั้ง fft() และการวาดแท่ง) เหลือแค่ 4 ขั้นให้เติมs09_fft_spectrum.py เติมครบพร้อมคอมเมนต์อธิบายทุกขั้น (อ่านให้เข้าใจ ปิดไฟล์ แล้วพิมพ์เอง)s09_fft_spectrum_full.py ฉบับขัดเรียบร้อย เพิ่มการเฉลี่ยสเปกตรัม (EMA) ให้นิ่ง, ความถี่เด่นตัวใหญ่ (Seg7), peak-hold และวัดพลังงานรวม (RMS)ลองเขียนเองให้สุดก่อนนะ ถ้าติดจริง ๆ ค่อยเปิดเฉลยดูทีละขั้น แล้วกลับมาพิมพ์เอง — เดี๋ยวเราค่อย ๆ แกะไปด้วยกัน
การอ่านสเปกตรัมสดซ่อนแนวคิด DSP หลายชั้นที่จะใช้ต่อในชุดบทเรียน feature และ Training:
ฝั่ง DSP / โดเมนความถี่
N log N (radix-2)FS/N คุมความละเอียด, FS/2 คือเพดานฝั่ง MicroPython / โครงโปรแกรม
ui.poll (เหมือนทุกบทเรียน)ทั้งหมดนี้คือ front-end ครึ่งแรกของโมเดลเสียง บทเรียน 4.5–4.6 เราจะต่อ mel/log-mel ให้ครบ แล้วคุณจะเห็นว่า "ภาพที่โมเดลกิน" หน้าตาเป็นยังไง
สเปกตรัมที่เราทำวันนี้ ไม่ใช่ของเล่น มันคือแกนของงาน Edge AI และงานวิศวกรรมจริงหลายสาย:
เห็นไหมว่า FFT ที่เราเขย่าบอร์ดเล่นวันนี้ คือเครื่องมือเดียวกับที่ใช้ตรวจมอเตอร์โรงงาน วัดการหายใจด้วยเรดาร์ และเป็น front-end ของโมเดลเสียงทุกตัว เราแค่เริ่มจากสิ่งที่จับต้องได้ที่สุด
งานทำเอง (ท้ายบทเรียน):
s09_fft_spectrum.py ให้ครบทั้ง 4 ขั้น รันได้จริง (Emulator หรือบอร์ด)mean = 0.0) กับ ไม่คูณ window แล้วอธิบายว่าสเปกตรัมเปลี่ยนไปยังไง เพราะอะไรใบ้ข้อ 3 — ไม่ตัด DC จะเห็น bin 0 พุ่ง; ไม่คูณ window จะเห็นแท่งข้างเคียง peak "รั่ว" สูงกว่าที่ควร (spectral leakage)
วันนี้เราได้: เข้าใจโดเมนเวลา vs ความถี่ · รู้ว่า FFT ทำอะไรและทำไมไม่ใช่กล่องดำ · เดิน pipeline 4 ขั้น (ตัด DC → window → magnitude → peak) · อ่านสเปกตรัมสด + peak Hz จากการเขย่าจริง
ชุดบทเรียนถัดไป (บทเรียน 4.5–4.6) เราจะต่อจากสเปกตรัมนี้ไปเป็น windowing + mel-spectrogram — "ภาพความถี่ตามเวลา" ที่โมเดลเสียงกินเข้าไปจริง ๆ เจอกันครับ