จบชุดบทเรียนนี้เราจะต่อ "ท่อสั่งการ" (action pipeline) ที่แปลง verdict ของโมเดลให้กลายเป็น action จริง อย่างมีวินัย:
result()) ไปสั่งการจริง: ไฟ RGB บนจอ · เสียง · logdsp.EMA เพื่อกดสัญญาณกระตุกชั่ววูบedge_ai.on_result(cb) — รับ verdict แบบ event-driven ต่างจากการ poll ยังไง เลือกอันไหนs16_action_pipeline.py ให้ครบ 4 ด่านของท่อ แล้วจูนจนกัน false positive ได้ปลายทางของวันนี้: ทำเสียง/ท่าให้โมเดลจับคลาสเป้าหมายได้ ต่อเนื่องนานพอ จอถึงจะยิงไฟแดง + เสียง + log — ไอแวบเดียวไม่นับ
บทเรียน 6.1–6.2 เราทำแอปต่อโมเดลเดี่ยว ชุดบทเรียนนี้เราต่อ "หลังบ้าน" ของแอปนั้น: ชั้นตัดสินใจที่ทำให้มันเชื่อถือได้พอจะเอาไปใช้จริง
ชุดบทเรียนแรกเราเรียก 4 คำสั่งของ edge_ai แล้วเอาคลาสที่ชนะขึ้นจอ จบแค่นั้น — อ่านผลเป็น แต่ยัง ไม่สั่งการ
models → select → result → stop (อ่านผลออก แต่ผลนั้นไม่ได้ไปทำอะไรต่อ)ในโลกจริง แอป Edge AI ที่ขายได้ ต่างจาก demo ตรง "ชั้นตัดสินใจ" นี่แหละ demo ยิงทุก verdict ก็ดูเท่ในวิดีโอ แต่ของจริงต้องไม่ปลุกคนทั้งบ้านเพราะแมวเดินผ่าน
เราอยู่ที่ขั้นสุดท้ายของวงจรชีวิตข้อมูล — Apps (ขั้น 5) — และเป็นชุดบทเรียนกลางของ โมดูล 6 (Apps I·II·III)
action pipeline ที่เราต่อวันนี้ คือกระดูกสันหลังของทุกแอป Edge AI จริง — บทเรียน 6.5–6.6 แค่ต่อปลายท่อออกไปที่คลาวด์
หัวใจของชุดบทเรียนนี้คือมองการสั่งการเป็น ท่อ (pipeline) ที่ verdict ต้องผ่านทีละด่านก่อนจะได้ยิง action
อ่านท่อนี้ให้ขึ้นใจ เดี๋ยว 4 ช่องที่เราเติมในไฟล์ฝึก คือด่าน 1→4 นี้เป๊ะ ไล่จากซ้ายไปขวา
ถ้าเรายิง action ทุกครั้งที่ label == "cough" โดยไม่กรองอะไรเลย จะเกิดอะไรขึ้น? ลองดูสัญญาณจริง:
นี่คือปัญหาเดียวกับปุ่มกดจริงในวงจรดิจิทัลที่เรียกว่า switch bounce — หน้าสัมผัสสั่นเป็นสิบครั้งใน 1 การกด วิศวกรแก้ด้วยเทคนิคชื่อ debounce เราจะยืมมาใช้กับ verdict ของโมเดลตรงๆ
ด่านแรกของท่อ กันคำตอบที่โมเดล "เดามากกว่ารู้" ออกไปก่อน ด้วยเส้นความมั่นใจที่เฟิร์มแวร์แนะนำ
hit = (r['label'] == TARGET_CLASS and r['conf'] >= edge_ai.CONF_FLOOR)
# └ ตรงคลาสที่เราเฝ้าไหม └ มั่นใจถึงเกณฑ์ไหม (0.50)
edge_ai.CONF_FLOOR = 0.50 — ต่ำกว่านี้ถือว่า "ยังไม่ชัวร์" อย่าเพิ่งนับเป็นการเจอhit เป็นแค่ boolean ของ "เฟรมนี้" — ยังไม่ใช่การตัดสินใจยิง แค่บอกว่าเฟรมนี้นับเป็นการเจอเกณฑ์นี้จูนได้ ตั้งสูง (เช่น 0.70) = เตือนผิดน้อยลงแต่พลาดของจริงง่ายขึ้น · ตั้งต่ำ = ไวขึ้นแต่หลอกง่ายขึ้น เราจะได้เล่นกับ trade-off นี้ตอนจูน
ด่านที่ทำให้ "ไอแวบเดียว" ไม่ยิง: นับว่าเจอคลาสเป้าหมาย ติดกันกี่เฟรม ต้องครบ NEED_HITS ก่อนถึงจะยอม
if hit:
streak += 1 # เจอต่อเนื่อง เพิ่มตัวนับ
else:
streak = 0 # หลุดเมื่อไร รีเซ็ตทันที (ยอดแหลมเดี่ยวถูกล้างตรงนี้)
ready = streak >= NEED_HITS
streakทำงานเหมือน "ต้องกดค้าง" ไม่ใช่ "แตะแล้วปล่อย" — สัญญาณรบกวนชั่ววูบผ่านไม่ได้ เพราะมันไม่ค้าง
debounce กันยอดแหลมได้ แต่ยังมีอีกปัญหา: ถ้าคลาสเป้าหมายค้างสูง ยาว (เช่น ไอเป็นชุด) streak จะครบซ้ำๆ ทุกไม่กี่เฟรม → ยิงรัวเป็นสิบครั้ง เราจึงเพิ่ม ช่วงเว้น (refractory period)
now = time.ticks_ms()
cooled = time.ticks_diff(now, last_fire) >= COOLDOWN_MS
if ready and cooled: # ครบ debounce "และ" พ้นช่วงเว้น
fire_action(r['conf'])
last_fire = now # เริ่มนับ cooldown ใหม่
streak = 0
ใช้
time.ticks_ms()คู่time.ticks_diff()เสมอ อย่าลบเวลาตรงๆ เพราะตัวนับ ms มีวันวนกลับ (overflow)ticks_diffจัดการให้ถูกต้อง
ในไฟล์ฉบับเต็ม เราเพิ่มอีกชั้น: ก่อนตัดสินใจ เอาคะแนนคลาสเป้าหมายมา "ปรับให้เนียน" ด้วย EMA (ตัวเดียวกับที่เรียนในบทเรียน 4.1–4.2)
import dsp
smoother = dsp.EMA(alpha=0.35) # สร้างครั้งเดียวก่อนลูป
...
raw = r['scores'][target_idx] # คะแนนดิบของคลาสเป้าหมาย
sm = smoother.update(raw) # ค่าที่เนียนแล้ว
smooth_ok = sm >= edge_ai.CONF_FLOOR # ใช้ค่าเนียนเป็นด่านเสริม
y = alpha*x + (1-alpha)*y_prev — ยอดแหลมเดี่ยวถูกกดลงalpha ต่ำ = เนียนมากแต่ตอบช้า · สูง = ไวแต่กระตุก (0.35 คือจุดกลางๆ ที่ใช้ได้ดี)ไม่จำเป็นต้องมีทั้งสอง ไฟล์ฝึกใช้แค่ debounce ก็กัน false positive ได้แล้ว EMA คือของแถมในฉบับเต็มที่ทำให้เนียนขึ้นอีกขั้น
หัวใจของ smoothing เขียนเป็นสมการเดียว ค่าที่เนียนแล้วรอบนี้ = ผสมค่าดิบรอบนี้กับค่าที่เนียนแล้วรอบก่อน
อ่านทีละตัว (ภาษาคน):
r['scores'][target_idx])alpha=0.35)ลองแทนค่าให้เห็นภาพ: ถ้า และตอนนี้ แล้วมียอดแหลมเดี่ยว พุ่งขึ้นมา
CONF_FLOOR = 0.50 ด้วยซ้ำ นั่นคือเหตุผลที่ EMA กันแหลมปลอมได้ทำไม EMA ถึงเบา (เหมาะกับบอร์ด): เก็บสถานะแค่ตัวเดียว () ไม่ต้องเก็บ buffer ย้อนหลัง — คนละเรื่องกับ moving-average ที่ต้องจำ ค่า นี่คือเหตุผลที่มันวิ่งได้สบายบน Cortex-M55 ทุกเฟรม
debounce ที่เราต่อ ไม่ได้ยิงตอนคะแนน "สูง" เฉยๆ — มันยิงตอนคะแนน ข้ามเส้นจากล่างขึ้นบน (rising edge) แล้วค้างครบจำนวนเฟรม เขียนเป็นสองเงื่อนไข
ให้ = คะแนนที่เนียนแล้ว, = เกณฑ์ (CONF_FLOOR), = NEED_HITS
1) นับความต่อเนื่อง (streak):
2) ยิงเฉพาะ "ขอบขึ้น" ของความพร้อม — ครบเกณฑ์รอบนี้ แต่รอบก่อนยังไม่ครบ:
อ่านเป็นภาษาคน:
COOLDOWN_MS)ทำไมต้อง "ขอบขึ้น" ไม่ใช่ "อยู่เหนือเส้น": ถ้ายิงทุกเฟรมที่ พอคะแนนค้างสูงยาวๆ จะยิงรัวเป็นสิบครั้ง เงื่อนไข ทำให้ยิง ครั้งเดียวต่อหนึ่งเหตุการณ์ — นี่คือหลักการเดียวกับ edge-triggered interrupt ในโลก embedded
จนถึงตอนนี้เรา poll — เรียก result() เองทุกรอบลูป แต่ edge_ai มีอีกวิธี: ให้เฟิร์มแวร์ โทรกลับ หาเราเมื่อมีเหตุการณ์
def on_change(r): # เฟิร์มแวร์เรียกเมื่อคลาสเปลี่ยน (และทวนราววินาทีละครั้ง)
print("คลาสใหม่:", r['label'], "%.0f%%" % (r['conf']*100))
edge_ai.on_result(on_change) # ลงทะเบียน callback
...
edge_ai.on_result(None) # ถอนตอนจบ (คู่กับตอนลงทะเบียน)
cb(dict) รับ dict verdict แบบเดียวกับ result() — รันใน MicroPython scheduler context ปลอดภัยกับ print/UIseq เอง ไม่ต้องวนถาม — เหมือน "รอสายเข้า" แทน "โทรถามซ้ำๆ"จากซอร์สเฟิร์มแวร์ (ยังไม่เปิดเผย): callback ผูกกับ INTENT event ที่ส่งตอนคลาสเปลี่ยน และส่งซ้ำคลาสเดิมห่างกันอย่างน้อยราว 1 วินาที — ไม่ใช่ทุก result
สองวิธีนี้ไม่ได้มีอันถูกอันผิด มันเหมาะกับงานคนละแบบ ชุดบทเรียนนี้เราใช้ ทั้งคู่ ในฉบับเต็ม
result() (poll) |
on_result(cb) (event) |
|
|---|---|---|
| ใครเป็นคนเรียก | เราเรียกเองทุกรอบลูป | เฟิร์มแวร์เรียกให้ตอนคลาสเปลี่ยน (และทวนราววินาทีละครั้ง) |
| ได้ผลบ่อยแค่ไหน | ทุกเฟรม (นับ streak ได้) | ตอนเปลี่ยนคลาส + ทวนราววินาทีละครั้ง |
| เหมาะกับ | debounce / smoothing (ต้องนับต่อเฟรม) | log การเปลี่ยนคลาส · action ง่ายๆ |
ต้องเช็ก seq เอง |
ใช่ | ไม่ต้อง |
on_result แยกไปทำ log การเปลี่ยนคลาส — ต่างคนต่างอ่านสถานะ ไม่ชนกันจำหลักไว้: งานที่ต้องนับเวลา/จำนวนเฟรม → poll · งานที่แค่ตอบสนองต่อ "เหตุการณ์" → callback นี่คือ pattern ที่เจอทั่วงาน embedded
พอ verdict ผ่านครบ 4 ด่าน เราจะสั่งการออกไป 3 ช่องทางพร้อมกัน แต่ละช่องสื่อสารกับคนคนละแบบ
แยกฟังก์ชัน
fire_action()ออกมาต่างหาก ทำให้ "จะทำอะไรตอนยิง" แก้ที่เดียวจบ ไม่ปนกับตรรกะ debounce
บอร์ดไม่มี LED RGB แยกให้สั่งตรงๆ เราจึงทำ "ไฟ RGB" เป็น การ์ดสีบนจอ ที่เปลี่ยนสีตามสถานะของท่อ — เห็นชัดว่าตอนนี้ pipeline อยู่ด่านไหน
light = ui.Panel(x=380, y=48, w=120, h=120, color=LIGHT_OFF) # สร้างครั้งเดียว
...
light.color(CYAN) # กำลังเฝ้าดู (ยังไม่เจอเป้าหมาย)
light.color(AMBER) # เจอแล้วแต่ streak ยังไม่ครบ
light.color(RED) # ยิง! ผ่านครบทุกด่าน
| สี | สถานะ pipeline | หมายความว่า |
|---|---|---|
| เทา (off) | ยังไม่เริ่ม | รอผลแรก |
| ฟ้า (CYAN) | watching | เฝ้าดูอยู่ ไม่เจอคลาสเป้าหมาย |
| เหลือง (AMBER) | streak กำลังนับ | เจอแล้ว แต่ยังไม่ต่อเนื่องพอ |
| แดง (RED) | ALERT | ยิง action แล้ว |
ไล่สีจากฟ้า→เหลือง→แดง ทำให้ผู้ใช้ "เห็น" ว่าท่อกำลังกันหรือกำลังจะยิง — โปร่งใสกว่าไฟติด/ดับเฉยๆ และช่วยเราดีบักตอนจูนด้วย
เสียงเตือนคนที่ไม่ได้จ้องจอ BENTO มีสองทางให้เลือก ทั้งคู่เป็น API จริงบนบอร์ด
if hasattr(ui, "tone"):
ui.tone(76, ui.WAVE_SINE, 140, 160) # โน้ต MIDI 76 · รูปคลื่น · ความแรง (velocity) · ยาว 160 ms
elif hasattr(ui, "sfx"):
ui.sfx(ui.SFX_UI_DENY) # เอฟเฟกต์สำเร็จรูป
ui.tone(note, wave, ...) — โน้ตดนตรีสั้นๆ กำหนดความสูงต่ำได้ (โน้ต MIDI 60 = โดกลาง, 76 = มีสูง)ui.sfx(id) — เสียงเอฟเฟกต์สำเร็จรูป เช่น ui.SFX_UI_DENY, ui.SFX_UI_SELECThasattr(ui, "tone") เสมอ — บางบอร์ด/Emulator อาจไม่มีลำโพง โค้ดจะได้ไม่พังทั้งแอปเพราะเรื่องเสียงเลือกเสียงให้ตรงความหมาย: เตือนภัยควรเป็นเสียงสูง-สะดุด ไม่ใช่เสียงนุ่มๆ ที่คนมองข้าม — action ที่ดีต้อง "สื่อสาร" ไม่ใช่แค่ "ดัง"
action ที่มองไม่เห็นตอนเกิด แต่สำคัญที่สุดสำหรับงานจริง: log — เพราะเราต้องตอบได้ว่า "มันเตือนกี่ครั้ง ตอนไหน มั่นใจเท่าไร"
alerts += 1 # ตัวนับจำนวนครั้ง
lcd.console('<span class=error> ALERT #%d @%dms: %s (conf %.0f%%)</span>'
% (alerts, time.ticks_ms(), TARGET_CLASS, r['conf'] * 100))
lcd.console(...) พิมพ์บรรทัด log พร้อม timestamp + ความมั่นใจ ณ ตอนยิงalerts โชว์บนจอ — เห็นภาพรวมว่าเตือนไปกี่ครั้งแล้วblocked = จำนวนครั้งที่ pipeline กันการยิงซ้ำไว้ — ทำให้เห็นว่าเกราะทำงานจริงlog คือความต่างระหว่าง "ของเล่น" กับ "ของใช้งาน" ถ้าเตือนผิดกลางดึก คุณต้องเปิด log มาดูได้ว่าเกิดจากอะไร บทเรียน 6.5–6.6 เราจะส่ง log นี้ขึ้นคลาวด์