顯示具有 水井三寶 標籤的文章。 顯示所有文章
顯示具有 水井三寶 標籤的文章。 顯示所有文章

2026年9月4日 星期五

雲端網路的核心概念:從智慧養殖到 AI 互動導覽

雲端網路的核心概念:從智慧養殖到 AI 互動導覽

當我們使用手機看影片、上傳照片、網路購物或遠端控制家電時,背後都離不開「雲端網路」。 雲端網路不只是把資料放到遠端伺服器,更是透過網路把人、設備、資料、服務與人工智慧連結起來, 讓系統能夠即時互動、彈性擴充與集中管理。

一、什麼是雲端網路?

雲端網路是指透過網際網路,使用遠端資料中心提供的運算、儲存、資料庫、應用程式及網路服務。 使用者不必自己購買大量伺服器或網路設備,只要透過電腦、手機、感測器或機器人連上網路, 就能使用雲端上的資源。

一句話理解:
雲端網路就是把「運算、儲存、網路與智慧服務」放到雲端,讓各地的使用者與設備可以隨時連線使用。

二、雲端網路的核心概念

  1. 虛擬化(Virtualization)
    將一台實體伺服器、硬碟或網路設備,切分成多個可獨立使用的虛擬資源,例如虛擬機器、虛擬硬碟與虛擬網路。
  2. 軟體定義網路(SDN, Software Defined Networking)
    將網路設備的管理與決策集中到軟體控制器。管理者可以用軟體調整資料傳輸路徑、頻寬與安全規則, 讓網路管理更有彈性。
  3. 網路功能虛擬化(NFV, Network Function Virtualization)
    原本要使用專用硬體才能完成的路由器、防火牆、負載平衡器等功能,改以軟體方式在一般伺服器上執行, 可降低成本,也更容易擴充。
  4. 基礎設施即代碼(IaC, Infrastructure as Code)
    用程式或設定檔建立伺服器、資料庫、網路與防火牆規則。這樣可以重複部署、保留版本紀錄, 並減少人工設定造成的錯誤。
  5. 彈性擴充與負載平衡
    當使用者增加時,雲端可以自動增加伺服器資源;當流量下降時,再減少資源以節省成本。 負載平衡則把大量使用者的請求分配到多台伺服器。
  6. 資安、身分驗證與資料保護
    雲端系統會利用帳號權限、防火牆、加密、VPN、網路分區與備份機制, 保護資料與設備,避免未授權使用者存取。

三、雲端網路在生活與產業上的應用

領域 應用例子 雲端網路的功能
智慧家庭 遠端控制冷氣、攝影機、門鎖與掃地機器人 讓家電與手機透過雲端交換資料與控制指令。
串流影音 YouTube、Netflix、線上直播與雲端遊戲 利用雲端伺服器、CDN 與負載平衡,提供大量使用者穩定服務。
線上學習 Google Classroom、Moodle、視訊課程 集中提供教材、作業、測驗、影片與學習紀錄。
電子商務 網路商店、行動支付、物流追蹤 整合商品、訂單、庫存、金流與配送資訊。
智慧製造 機台監控、品質檢測、預測維護 收集設備資料,透過雲端 AI 分析異常與預估故障。
智慧農漁業 水質、土壤、氣象、影像與設備控制 讓感測器即時上傳資料,並由手機或電腦遠端監控。

四、三個智慧生活與地方創生應用案例

案例一:水井村智慧養殖

在養殖現場,可利用溶氧、水溫、pH 值等感測器蒐集魚塭資料,再透過 HUB8735、 Wi-Fi、4G/5G 或 LoRa 網路傳送到雲端平台。養殖戶只要打開手機,就能即時查看水質狀況。

當溶氧過低或水溫異常時,雲端平台可以自動發出告警通知,並進一步連動打氣機或抽水設備。 若累積足夠資料,還能運用 AI 分析水質變化,協助養殖戶及早處理問題。

核心價值:讓養殖管理從「靠經驗巡池」走向「即時監測、遠端管理與智慧決策」。

案例二:水井三寶智慧互動導覽平台

水井村可將烏龜、白馬與姻緣花等地方文化故事,建置成雲端智慧導覽平台。 遊客掃描 QR Code、使用手機網頁或操作互動螢幕後,即可取得故事、照片、語音、影片與地圖資訊。

平台也可結合 AI 導覽 Agent。當遊客詢問「姻緣樹有多久歷史?」、 「下一個景點怎麼走?」或「水井三寶代表什麼意義?」時, AI Agent 可根據地方知識庫即時回答,成為不受時間限制的數位導覽員。

核心價值:讓地方文化能被數位保存、持續更新,並以互動方式傳遞給更多遊客與下一代。

案例三:Q-Robot AI 結合 AI Agent

Q-Robot 可結合麥克風、喇叭、螢幕、按鈕、燈光與感測器,成為能與人互動的智慧機器人。 當孩童、長者或遊客向 Q-Robot 說話或按下按鈕時,Q-Robot 可透過雲端網路連接 AI Agent 與知識庫, 理解問題後,再以語音、文字、燈光或動作回應。

例如,Q-Robot 可以扮演下列角色:

  • 地方文化小導遊:介紹水井三寶與村庄故事。
  • 兒童學習夥伴:帶領孩子完成程式設計、環保、食農與 AI 體驗任務。
  • 長者陪伴助手:以較慢的語速聊天、播放懷舊故事,提醒社區活動。
  • 活動任務引導員:依照參與者的回答與活動進度,安排下一個互動關卡。
核心價值:讓機器人不只是播放指令,而是能連接地方知識、理解問題並提供個人化互動的 AI 夥伴。

五、三個案例的比較

案例 主要對象 雲端網路的角色 AI 的角色
水井村智慧養殖 養殖戶 傳送感測資料、遠端控制設備 分析水質與異常預警
水井三寶智慧互動導覽 遊客、社區居民 提供故事、地圖、影音與使用紀錄 回答地方文化與導覽問題
Q-Robot AI Agent 兒童、長者、學習者 連接機器人、知識庫與後台服務 理解提問、規劃任務、產生個人化回應

六、結語

雲端網路的價值,不只是把資料存放在遠端,而是把現場設備、使用者、資料平台與 AI 智慧服務連接起來。 從水井村智慧養殖的水質監測,到水井三寶的文化導覽,再到 Q-Robot 的 AI 互動陪伴, 都說明了雲端網路可以成為地方創生、智慧教育與永續生活的重要基礎。

善用雲端網路,讓地方的故事、產業與學習,連結成更智慧、更永續的未來。

2026年8月31日 星期一

Python一下:用jieba整理水井村地方故事與關鍵詞

《Python一下:從風土資料到智慧生活》第 32 篇

用jieba整理水井村地方故事與關鍵詞

讓「水井三寶、姻緣花、柴井車、智慧養殖」不再被切散,也不讓演算法取代社區自己的說法。

CH14 斷詞處理jieba地方故事
學習目標
完成本篇後,你能解釋中文斷詞的必要性,使用jieba精確模式處理繁體中文,建立水井村自訂詞典與停用詞,統計詞頻、搜尋上下文、擷取TF-IDF關鍵詞,建立可人工審查的結果檔,並說明口述故事的同意、去識別、台語轉寫及地方詮釋限制。

一、電腦為何不知道「姻緣花」是一個詞?

英文常以空白分隔單字,中文句子通常沒有天然空白。「水井三寶包含烏龜、白馬與姻緣花」若逐字處理,就無法直接知道「水井三寶」和「姻緣花」是地方概念。斷詞的工作,就是提出一種詞語邊界。

原文可能結果問題
水井三寶水井/三寶地方品牌詞被拆開
姻緣花姻緣/花地方文化名稱失去完整性
魚菜共生淨水魚菜/共生/淨水可能需要保留「魚菜共生」
HUB 8735 Ultra英數與空白混合需另外設計技術詞正規化
斷詞沒有唯一答案:適合搜尋的切法、適合關鍵詞統計的切法,以及社區認同的地方詞,可能不同。結果必須依任務評估,不能把工具輸出當成地方語言的標準答案。

二、安裝jieba並記錄維護風險

import subprocess
import sys


subprocess.run(
    [sys.executable, "-m", "pip", "install", "jieba"],
    check=True,
)

截至本篇核對時,PyPI顯示的最新版仍是2020年發布的0.42.1。它適合教學與既有專案評估,但正式採用前應重新檢查維護狀態、Python相容性、授權、安全與替代工具,不能因為安裝成功就直接長期部署。

三、第一個繁體中文斷詞

import jieba


text = "水井三寶包含烏龜、白馬與姻緣花。"
tokens = list(jieba.cut(text, cut_all=False, HMM=True))

print(tokens)
print("/".join(tokens))

cut_all=False是精確模式,較適合一般文字分析;HMM可嘗試辨識未登錄詞,但不保證地方詞一定正確。

四、三種模式用途不同

sentence = "水井村推動智慧養殖與魚菜共生淨水。"

precise = list(jieba.cut(sentence, cut_all=False))
full = list(jieba.cut(sentence, cut_all=True))
search = list(jieba.cut_for_search(sentence))

print("精確模式:", precise)
print("全模式:", full)
print("搜尋模式:", search)
模式適合限制
精確模式詞頻、一般分析仍可能切錯地方詞
全模式觀察可能詞語重疊多,不能直接計算一般詞頻
搜尋引擎模式建立搜尋索引長詞會再次切分,詞數不可和精確模式混比

五、使用獨立Tokenizer,避免全域設定互相干擾

import jieba


tokenizer = jieba.Tokenizer()
for word in [
    "水井三寶",
    "姻緣花",
    "柴井車",
    "魚菜共生",
    "智慧養殖",
    "工作蝦",
]:
    tokenizer.add_word(word, freq=100000)

text = "水井三寶與柴井車承載地方記憶。"
print(list(tokenizer.cut(text, cut_all=False)))

在多人專案或測試中,獨立Tokenizer比修改全域 jieba.dt更容易控制。詞頻100000只是確保教學示例優先成詞,不代表真實語料頻率。

六、繁體中文詞典與地方詞典分工

jieba專案提供較大的 extra_dict/dict.txt.big,可作為繁體中文候選詞典;實際專案應把經審核的詞典檔納入版本管理,記錄來源、授權與雜湊,不在程式執行時臨時從不明網址下載。

from pathlib import Path
import jieba


def build_tokenizer(base_dictionary=None, user_dictionary=None):
    tokenizer = jieba.Tokenizer(
        dictionary=str(base_dictionary) if base_dictionary else None
    )
    if user_dictionary:
        path = Path(user_dictionary)
        if not path.is_file():
            raise FileNotFoundError(path)
        tokenizer.load_userdict(str(path))
    return tokenizer


# 專案備妥詞典後再啟用:
# tokenizer = build_tokenizer(
#     "data/dict.txt.big", "data/shuijing_words.txt"
# )

七、自訂詞典格式

自訂詞典每行為「詞語 詞頻 詞性」,詞頻與詞性可省略,檔案使用UTF-8。建議由社區與課程團隊共同審查:

水井三寶 100000 nz
姻緣花 100000 nz
柴井車 100000 nz
工作蝦 100000 nz
魚菜共生 100000 nz
智慧養殖 100000 nz
風頭水尾 100000 nz

nz只是常見自訂專名標記,詞性標註並非本篇主要目標。地方詞典還應另有說明表,記錄詞義、別稱、使用者、例句、審核者與版本日期。

八、先做最小文字清理

import re
import unicodedata


def normalize_text(text):
    text = unicodedata.normalize("NFKC", str(text))
    text = text.replace("\u3000", " ")
    text = re.sub(r"[ \t]+", " ", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    return text.strip()


sample = "水井村 推動  智慧養殖。"
print(normalize_text(sample))

NFKC會統一部分全形與相容字元,但也可能改變原始書寫形式。口述史原稿應另外保存;分析使用的是衍生副本,並記錄清理規則。

九、停用詞:刪掉常見詞也可能刪掉地方語氣

STOPWORDS = {
    "的", "了", "在", "和", "與", "是",
    "有", "也", "就", "我們", "一個",
}


def meaningful_tokens(text, tokenizer, stopwords=STOPWORDS):
    for token in tokenizer.cut(normalize_text(text)):
        token = token.strip()
        if not token:
            continue
        if token in stopwords:
            continue
        if not re.search(r"[\u3400-\u9fffA-Za-z0-9]", token):
            continue
        yield token


story = "我們在水井村用智慧養殖守護工作蝦。"
print(list(meaningful_tokens(story, tokenizer)))

停用詞必須依任務制定。長者口述中的「阮、咱、彼時、做伙」可能承載語氣、群體關係與台語特色,不應直接套用一般中文停用詞刪除。

十、用Counter統計地方詞頻

from collections import Counter


stories = [
    "水井村推動智慧養殖,也用魚菜共生淨水。",
    "水井三寶包含烏龜、白馬與姻緣花。",
    "姻緣花承載水井村的地方記憶與祝福。",
]

counter = Counter()
for story in stories:
    counter.update(meaningful_tokens(story, tokenizer))

for word, count in counter.most_common(10):
    print(word, count)

詞頻高表示在這批語料出現多,不等於對社區最重要。一次只被長者說出的罕見詞,可能反而是珍貴文化線索。

十一、同一詞在幾篇故事出現?

document_frequency = Counter()

for story in stories:
    unique_words = set(
        meaningful_tokens(story, tokenizer)
    )
    document_frequency.update(unique_words)

for word, documents in document_frequency.most_common(10):
    print(word, "出現在", documents, "篇")

詞頻計算總出現次數;文件頻率計算出現於幾篇文件。兩者回答不同問題,不能混稱「熱門程度」。

十二、顯示關鍵詞所在原句

def find_contexts(stories, keyword):
    results = []
    for index, story in enumerate(stories, start=1):
        sentences = re.split(r"(?<=[。!?])", story)
        for sentence in sentences:
            sentence = sentence.strip()
            if keyword in sentence:
                results.append({
                    "story": index,
                    "sentence": sentence,
                })
    return results


for item in find_contexts(stories, "姻緣花"):
    print(item)

詞頻表應能回到原句,才能讓社區夥伴確認用法與意義。公開結果若含可識別人物或私密經驗,仍需授權與去識別。

十三、TF-IDF擷取關鍵詞

import jieba.analyse


corpus_text = "\n".join(stories)
keywords = jieba.analyse.extract_tags(
    corpus_text,
    topK=8,
    withWeight=True,
)

for word, weight in keywords:
    print(word, round(weight, 4))

TF-IDF權重不是機率,也不是社區重要性分數。jieba預設IDF來自其內建語料,不一定代表臺灣農漁村或水井村;地方分析可建立經審查的領域語料與IDF,但需要足夠文件及清楚方法。

十四、讓TF-IDF使用自訂Tokenizer的地方詞

jieba.analyse預設使用全域斷詞器,可能和前面的獨立Tokenizer設定不同。簡單教材可在分析前把地方詞加入全域詞典,但要在單一初始化函式集中管理:

DOMAIN_WORDS = [
    "水井三寶", "姻緣花", "柴井車",
    "魚菜共生", "智慧養殖", "工作蝦",
]


def configure_global_jieba():
    for word in DOMAIN_WORDS:
        jieba.add_word(word, freq=100000, tag="nz")


configure_global_jieba()
keywords = jieba.analyse.extract_tags(
    "\n".join(stories), topK=8, withWeight=True
)
print(keywords)

測試要先重新初始化或固定執行順序,避免全域狀態讓測試互相污染。大型系統可評估更易注入Tokenizer的分析設計。

十五、比較詞頻與TF-IDF,不急著選「唯一答案」

frequency_top = [
    word for word, count in counter.most_common(8)
]
tfidf_top = [word for word, weight in keywords]

comparison = {
    "both": sorted(set(frequency_top) & set(tfidf_top)),
    "frequency_only": [
        word for word in frequency_top if word not in tfidf_top
    ],
    "tfidf_only": [
        word for word in tfidf_top if word not in frequency_top
    ],
}

print(comparison)

共同出現的詞可優先檢視;只在一邊出現的詞也不能直接丟掉。最終主題命名應回到原句、訪談脈絡與社區共同討論。

十六、為地方詞典建立回歸測試

CASES = {
    "水井三寶包含姻緣花": {"水井三寶", "姻緣花"},
    "魚菜共生協助淨水": {"魚菜共生"},
    "智慧養殖記錄工作蝦": {"智慧養殖", "工作蝦"},
}


def test_domain_dictionary(tokenizer):
    failures = []
    for sentence, expected in CASES.items():
        actual = set(tokenizer.cut(sentence, cut_all=False))
        missing = expected - actual
        if missing:
            failures.append({
                "sentence": sentence,
                "missing": sorted(missing),
                "actual": sorted(actual),
            })
    return failures


failures = test_domain_dictionary(tokenizer)
assert not failures, failures
print("地方詞典測試通過")

每次更新jieba、基礎詞典或地方詞典都執行測試,避免原本正確的地方詞突然被拆開。

十七、輸出可審查的JSON,而非只有詞雲

import json
from datetime import datetime, timezone


report = {
    "created_at": datetime.now(timezone.utc).isoformat(),
    "method": "jieba精確模式+自訂地方詞+詞頻與TF-IDF",
    "documents": len(stories),
    "top_frequency": counter.most_common(10),
    "top_tfidf": [
        {"word": word, "weight": weight}
        for word, weight in keywords
    ],
    "review_status": "待社區人工審查",
}

print(json.dumps(report, ensure_ascii=False, indent=2))

詞雲視覺吸引人,但會省略方法、分母與上下文。研究或USR成果應保留版本、語料範圍、清理規則、詞典、停用詞、程式與人工審查紀錄。

十八、台語口述故事要多一層語言尊重

階段應注意
語音轉文字台語辨識可能誤轉人名、地名、農產與料理
轉寫選擇漢字、台羅或混合書寫需保留原則與原始錄音
jieba斷詞主要針對中文文字,不等於台語語言分析工具
修訂由說話者或熟悉地方語言的人確認,不擅自「改成標準中文」
公開確認用途、範圍、署名/匿名、撤回及保存期限

十九、個資去識別不能只靠正規表示式

import re


def flag_possible_sensitive_text(text):
    patterns = {
        "phone": r"(?<!\d)09\d{8}(?!\d)",
        "email": r"[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}",
        "address_hint": r"(?:路|街|巷|弄|號)",
    }
    return {
        name: bool(re.search(pattern, text))
        for name, pattern in patterns.items()
    }


print(flag_possible_sensitive_text(
    "這是一段不含聯絡方式的示範故事。"
))

這只能標記部分可能風險,無法辨識暱稱、親屬關係、罕見事件或組合資訊。去識別必須人工審查;即使移除姓名,故事仍可能讓社區成員辨識當事人。

二十、課堂挑戰

挑戰A|基礎:以5段匿名地方故事建立詞頻表,列出有效詞數、文件頻率及每個前10詞的原句。
挑戰B|進階:建立20個水井村地方詞的詞典與至少10個回歸測試案例,記錄新增前後斷詞差異。
挑戰C|USR場域:請長者或社區夥伴檢視演算法選出的關鍵詞,標記「認同、需改名、不宜公開、演算法漏掉」,比較機器結果與地方觀點。

二十一、用AI協助整理,但不能代替受訪者確認

請擔任地方故事斷詞與關鍵詞分析助教。
語料來自已同意教學分析並完成去識別的社區故事。
請先檢查繁體中文、台語轉寫、地方專名、同義詞、停用詞與個資風險,
再協助比較詞頻、文件頻率與TF-IDF結果。
不得把演算法關鍵詞稱為「社區最重要的文化」,
不得擅自改寫長者原意,也不要要求提供真實姓名或未授權逐字稿。
請輸出待人工確認清單、原句索引與地方詞典修訂建議。

二十二、延伸閱讀

Python一下:用Pillow打造水井三寶地方圖卡處理工具

《Python一下:從風土資料到智慧生活》第 30 篇

用Pillow打造水井三寶地方圖卡處理工具

讓烏龜、白馬與姻緣花的照片,變成尺寸一致、文字清楚、可追溯來源的地方圖卡。

CH14 圖片處理Pillow水井三寶
學習目標
完成本篇後,你能安全開啟與驗證圖片、依EXIF修正方向、比較thumbnail與fit、加入繁體中文標題及半透明浮水印、正確輸出JPEG/PNG、批次產生圖卡與處理紀錄,並說明肖像權、著作權、位置資訊及AI生成標示。

一、地方圖卡不是「把字貼上去」而已

同一張水井三寶照片可能來自手機、相機、社區長者或AI創作,具有不同方向、尺寸、色彩模式、檔案格式及使用權。圖卡工具至少要回答四件事:

問題程式處理USR責任
圖片能否安全讀取?限制大小、辨識格式、捕捉錯誤不接受不明來源大檔直接進系統
版型是否一致?轉向、縮放、裁切與色彩模式不扭曲人物、工藝或地方物件
文字是否清楚?字型、文字框、對比與安全邊界名稱與故事先由社區確認
能否合法公開?輸出時移除不必要metadata確認授權、肖像同意及AI標示

二、安裝Pillow:安裝名稱與匯入名稱不同

import subprocess
import sys


subprocess.run(
    [sys.executable, "-m", "pip", "install", "Pillow"],
    check=True,
)

pip安裝名稱是Pillow,程式匯入名稱仍是PIL。請在專案虛擬環境安裝,並把實際驗證版本記錄在依賴檔中。

三、讀取圖片基本資訊

from pathlib import Path
from PIL import Image


source = Path("images/turtle.jpg")

with Image.open(source) as image:
    print("格式:", image.format)
    print("尺寸:", image.size)
    print("色彩模式:", image.mode)
    print("影格數:", getattr(image, "n_frames", 1))

Image.open()會延遲讀取像素,因此要使用with確保檔案關閉。不要只依副檔名判定內容;Pillow會由檔案內容辨識格式。

四、不可信圖片先驗證,再重新開啟

import warnings
from pathlib import Path
from PIL import Image, UnidentifiedImageError


MAX_FILE_BYTES = 15 * 1024 * 1024
ALLOWED_FORMATS = {"JPEG", "PNG", "WEBP"}


def validate_image(path):
    path = Path(path)
    if not path.is_file():
        raise FileNotFoundError(path)
    if path.stat().st_size > MAX_FILE_BYTES:
        raise ValueError("檔案超過15 MB限制")

    with warnings.catch_warnings():
        warnings.simplefilter("error", Image.DecompressionBombWarning)
        try:
            with Image.open(path) as image:
                detected_format = image.format
                if detected_format not in ALLOWED_FORMATS:
                    raise ValueError("不支援的圖片格式")
                image.verify()
        except UnidentifiedImageError as error:
            raise ValueError("無法辨識圖片") from error

    return detected_format

verify()檢查後必須重新開啟圖片,不能繼續用同一物件處理像素。檔案大小與像素量是兩種不同風險;Pillow會對異常巨大像素數提出decompression bomb警告或錯誤,不應為方便直接關閉保護。

五、手機照片方向錯誤:套用EXIF Orientation

from PIL import Image, ImageOps


def open_oriented_rgb(path):
    validate_image(path)
    with Image.open(path) as image:
        oriented = ImageOps.exif_transpose(image)
        return oriented.convert("RGB")


image = open_oriented_rgb("images/turtle.jpg")
print(image.size, image.mode)

ImageOps.exif_transpose()依EXIF方向旋轉或鏡射像素,並處理方向資訊。若略過這一步,手機上直立的照片在輸出後可能橫躺。

六、thumbnail:完整保留、放進指定範圍

from pathlib import Path
from PIL import Image


image = open_oriented_rgb("images/turtle.jpg")
thumbnail = image.copy()
thumbnail.thumbnail(
    (800, 800),
    resample=Image.Resampling.LANCZOS,
)
Path("output").mkdir(parents=True, exist_ok=True)
thumbnail.save("output/turtle_thumbnail.jpg", quality=88)

print(thumbnail.size)

thumbnail()會原地修改圖片,保持長寬比並限制在指定框內,通常不會放大小圖。它適合完整保留照片,但輸出寬高不一定完全一致。

七、ImageOps.fit:裁成一致的社群圖卡

from PIL import Image, ImageOps


image = open_oriented_rgb("images/white_horse.jpg")
card_photo = ImageOps.fit(
    image,
    (1200, 800),
    method=Image.Resampling.LANCZOS,
    centering=(0.5, 0.45),
)
card_photo.save("output/white_horse_1200x800.jpg", quality=90)

print(card_photo.size)

fit()會等比例縮放後裁切,得到固定尺寸。centering可以調整保留重心;人物、樹木或工藝品不能一律置中,最好提供裁切預覽讓社區夥伴確認。

八、不要用resize硬拉成固定尺寸

from PIL import ImageOps, Image


def make_card_background(image, size=(1200, 1200), mode="crop"):
    if mode == "crop":
        return ImageOps.fit(
            image, size, method=Image.Resampling.LANCZOS
        )
    if mode == "contain":
        return ImageOps.pad(
            image,
            size,
            method=Image.Resampling.LANCZOS,
            color=(245, 240, 224),
        )
    raise ValueError("mode必須是crop或contain")

crop填滿版面但會裁掉邊緣;contain完整保留但可能出現留白。直接 resize((1200,1200))會改變比例,使姻緣花、烏龜或人物變形。

九、繁體中文字型要由專案明確提供

不同電腦的中文字型位置不同。最穩定做法是在取得合法再散布授權後,將指定字型作為專案資產,或由部署環境透過設定提供路徑。

import os
from pathlib import Path
from PIL import ImageFont


def load_font(size):
    font_path = Path(os.environ["SHUIJING_FONT_PATH"])
    if not font_path.is_file():
        raise FileNotFoundError("找不到指定中文字型")
    return ImageFont.truetype(str(font_path), size=size)


title_font = load_font(64)

不要假設Arial、微軟正黑體或Noto一定存在;也不要未確認授權就把系統字型複製到公開專案。

十、使用textbbox量字,再畫文字底板

from PIL import ImageDraw


def draw_title(image, title, font, margin=48):
    draw = ImageDraw.Draw(image, "RGBA")
    box = draw.textbbox((0, 0), title, font=font)
    text_width = box[2] - box[0]
    text_height = box[3] - box[1]

    x = margin
    y = image.height - text_height - margin * 2
    panel = (
        x - 20,
        y - 16,
        min(x + text_width + 20, image.width - margin),
        y + text_height + 20,
    )
    draw.rounded_rectangle(
        panel, radius=18, fill=(13, 66, 55, 205)
    )
    draw.text(
        (x, y), title, font=font,
        fill=(255, 255, 255, 255),
        stroke_width=1,
        stroke_fill=(0, 0, 0, 180),
    )


card = make_card_background(
    open_oriented_rgb("images/marriage_flower.jpg")
)
draw_title(card, "水井三寶|姻緣花", load_font(64))

textbbox()可先取得文字範圍,再決定底板大小。長標題仍可能超出畫面,下一節加入自動縮小。

十一、讓長標題自動縮小

from PIL import ImageDraw


def fit_font(draw, text, max_width, start=72, minimum=28):
    for size in range(start, minimum - 1, -2):
        font = load_font(size)
        left, top, right, bottom = draw.textbbox(
            (0, 0), text, font=font
        )
        if right - left <= max_width:
            return font
    raise ValueError("標題太長,請編輯文字或改用多行版型")

自動縮小仍應設最低可讀字級。若再縮就看不清楚,應改寫標題或設計多行文字,而不是讓字擠成一條。

十二、加入半透明來源標示

from PIL import Image, ImageDraw


def add_credit(image, text, font):
    base = image.convert("RGBA")
    layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(layer)
    box = draw.textbbox((0, 0), text, font=font)
    width = box[2] - box[0]
    height = box[3] - box[1]
    position = (
        base.width - width - 30,
        24,
    )
    draw.text(
        position, text, font=font,
        fill=(255, 255, 255, 190),
        stroke_width=2,
        stroke_fill=(0, 0, 0, 150),
    )
    return Image.alpha_composite(base, layer)


credited = add_credit(
    card, "影像來源:經社區授權", load_font(28)
)

浮水印不能取代授權。文字要依真實來源填寫;AI生成或經AI大幅修改的圖片,也應依使用情境清楚標示。

十三、輸出JPEG或PNG前先處理色彩模式

from pathlib import Path


def save_for_web(image, destination):
    destination = Path(destination)
    destination.parent.mkdir(parents=True, exist_ok=True)
    suffix = destination.suffix.lower()

    if suffix in {".jpg", ".jpeg"}:
        image.convert("RGB").save(
            destination,
            format="JPEG",
            quality=88,
            optimize=True,
        )
    elif suffix == ".png":
        image.convert("RGBA").save(
            destination,
            format="PNG",
            optimize=True,
        )
    else:
        raise ValueError("輸出格式只允許JPEG或PNG")


save_for_web(credited, "output/marriage_flower_card.jpg")

JPEG不支援透明度,RGBA直接存JPEG會出錯;PNG適合透明圖示,但照片可能較大。重新建立輸出檔可避免原照片中的GPS等EXIF跟著公開,但若你主動傳入exif或其他metadata則另當別論。

十四、完成單張水井三寶圖卡函式

from PIL import ImageDraw


def create_local_card(
    source, destination, title, credit,
    size=(1200, 1200), crop_mode="crop"
):
    image = open_oriented_rgb(source)
    card = make_card_background(image, size, crop_mode)

    draw = ImageDraw.Draw(card, "RGBA")
    title_font = fit_font(
        draw, title, max_width=size[0] - 120
    )
    draw_title(card, title, title_font, margin=48)
    card = add_credit(card, credit, load_font(28))
    save_for_web(card, destination)

    return {
        "source": str(source),
        "destination": str(destination),
        "title": title,
        "size": size,
    }


result = create_local_card(
    "images/turtle.jpg",
    "output/turtle_card.jpg",
    "水井三寶|烏龜",
    "影像來源:經社區授權",
)
print(result)

十五、批次處理三張圖卡

JOBS = [
    {
        "source": "images/turtle.jpg",
        "destination": "output/turtle_card.jpg",
        "title": "水井三寶|烏龜",
    },
    {
        "source": "images/white_horse.jpg",
        "destination": "output/white_horse_card.jpg",
        "title": "水井三寶|白馬",
    },
    {
        "source": "images/marriage_flower.jpg",
        "destination": "output/marriage_flower_card.jpg",
        "title": "水井三寶|姻緣花",
    },
]

records = []
for job in JOBS:
    try:
        record = create_local_card(
            **job,
            credit="影像來源:經社區授權",
        )
        record["status"] = "success"
    except Exception as error:
        record = {
            "source": job["source"],
            "status": "failed",
            "error": type(error).__name__,
        }
    records.append(record)

for record in records:
    print(record)

批次工具不應因一張壞圖讓所有工作中止,但錯誤紀錄不要包含個資、秘密路徑或完整內部例外內容。

十六、用雜湊與尺寸驗證輸出

import hashlib
from pathlib import Path
from PIL import Image


def inspect_output(path, expected_size=(1200, 1200)):
    path = Path(path)
    digest = hashlib.sha256(path.read_bytes()).hexdigest()
    with Image.open(path) as image:
        image.load()
        if image.size != expected_size:
            raise ValueError("輸出尺寸不正確")
        return {
            "path": str(path),
            "format": image.format,
            "mode": image.mode,
            "size": image.size,
            "bytes": path.stat().st_size,
            "sha256": digest,
        }


print(inspect_output("output/turtle_card.jpg"))

雜湊可協助確認檔案是否變更,不能證明圖片內容正確。仍要人工檢查裁切、文字、對比、地方名稱與來源標示。

十七、發布前的人文與倫理檢查

檢查發布前要確認
著作權攝影者、插畫者或權利人是否同意使用及改作?
肖像與隱私可辨識人物是否同意?是否含門牌、車牌或位置資訊?
地方詮釋「水井三寶」名稱與故事是否經社區夥伴確認?
AI透明AI生成、補圖或大幅修改是否清楚標示?
可近用性網站是否提供替代文字,而非只把文字畫進圖片?
原圖要另外保管:批次程式輸出到獨立資料夾,不覆蓋社區提供的原始照片。若未取得公開同意,處理完成也不代表可以上網發布。

十八、課堂挑戰

挑戰A|基礎:用同一張照片分別產生thumbnail、crop與contain三種版本,說明各自適合的情境。
挑戰B|進階:讓長標題自動換成兩行,確保不低於32px,並為文字底板保留左右安全距離。
挑戰C|USR場域:和社區夥伴共同建立圖卡metadata表,記錄作品名、來源、授權範圍、人物同意、AI使用、審核者及下架日期。

十九、用AI協助檢查,但不要交出未授權照片

請擔任Python Pillow程式碼審查助教。
我要把社區授權照片製作成1200×1200地方圖卡。
請檢查:不可信圖片驗證、解壓縮炸彈、EXIF方向、
等比例縮放、裁切重心、中文字型授權、文字可讀性、
JPEG/PNG模式、metadata移除、錯誤處理與不覆蓋原圖。
不要要求我上傳未取得同意的人像或含GPS的原始照片。
請提供最小測試案例與人工審查清單。

二十、延伸閱讀

Python一下:從XML讀懂智慧設備設定與地方導覽資料

《Python一下:從風土資料到智慧生活》第 23 篇

從XML讀懂智慧設備設定與地方導覽資料

從樹狀標記中找出水井三寶多語內容與設備參數,同時守住格式、來源及操作安全。

CH12 XMLElementTree設定驗證
學習目標
完成本篇後,你能辨認XML元素、屬性、文字與階層,使用 ElementTree 解析、搜尋、建立及輸出XML,處理命名空間與解析錯誤,並把設備設定轉成「待人工確認」的安全資料。

一、XML與JSON有何不同?

兩者都能表示結構化資料。JSON常見於網頁API;XML在設備設定、標準文件、RSS及舊系統交換格式中仍很常見。

特性JSONXML
主要結構物件、陣列、基本型別元素樹、屬性、文字
資料型別數字、布林、null等讀出時多半先是字串
重複節點通常使用陣列重複同名元素
命名空間沒有內建機制可避免不同詞彙名稱衝突
設備安全原則:XML格式正確不代表參數安全。教學程式只讀取、驗證並產生預覽;不可把外部XML直接送往水泵、曝氣機或其他設備。實際套用須確認來源、版本、簽章或完整性、場域規則及人工授權。

二、看懂XML結構

<guide version="1.0">
  <item id="white-horse" language="zh-Hant">
    <title>白馬</title>
    <status>待社區確認</status>
  </item>
  <item id="turtle" language="zh-Hant">
    <title>烏龜</title>
    <status>待社區確認</status>
  </item>
</guide>

guide 是根元素;item 是子元素;idlanguage 是屬性;title 元素內的「白馬」是文字內容。XML必須只有一個根元素,開始與結束標籤要正確巢狀。

三、從字串解析XML

import xml.etree.ElementTree as ET

xml_text = """
<guide version="1.0">
  <item id="marriage-flower" language="zh-Hant">
    <title>姻緣花</title>
    <status>待社區確認</status>
  </item>
</guide>
"""

root = ET.fromstring(xml_text)
print("根元素:", root.tag)
print("版本:", root.get("version"))

fromstring() 回傳根元素。XML屬性可用 get() 取得,找不到時回傳 None

四、搜尋元素與讀取文字

for item in root.findall("item"):
    item_id = item.get("id")
    language = item.get("language")
    title = item.findtext("title", default="").strip()
    status = item.findtext("status", default="").strip()

    print(item_id, language, title, status)
方法用途
find()尋找第一個符合的子元素
findall()取得所有符合元素
findtext()直接取得第一個符合元素的文字
iter()走訪目前元素下所有指定節點

五、從檔案安全解析

import xml.etree.ElementTree as ET
from pathlib import Path


def load_xml(path, max_bytes=2_000_000):
    path = Path(path)
    if not path.is_file():
        raise FileNotFoundError(f"找不到XML:{path}")
    if path.stat().st_size > max_bytes:
        raise ValueError("XML超過教學程式允許大小")

    try:
        tree = ET.parse(path)
    except ET.ParseError as error:
        raise ValueError(f"XML格式錯誤:{error}") from error
    return tree

檔案大小限制可降低意外資源消耗,但不是完整的XML安全防護。若要解析來源不可信的XML,應採用專為防禦XML攻擊設計的函式庫(如defusedxml)及環境資源限制。

六、把導覽元素轉成字典

def parse_guide_item(item):
    item_id = (item.get("id") or "").strip()
    language = (item.get("language") or "").strip()
    title = item.findtext("title", default="").strip()
    status = item.findtext("status", default="").strip()

    missing = [
        name
        for name, value in {
            "id": item_id,
            "language": language,
            "title": title,
            "status": status,
        }.items()
        if not value
    ]
    if missing:
        raise ValueError("缺少內容:" + "、".join(missing))

    return {
        "id": item_id,
        "language": language,
        "title": title,
        "status": status,
    }

XML沒有自動幫你驗證業務規則。讀取後仍須確認必要屬性、空白文字、重複ID及可接受的語言代碼。

七、批次處理並隔離錯誤

def parse_guide(root):
    records = []
    errors = []
    used_ids = set()

    for index, item in enumerate(root.findall("item"), start=1):
        try:
            record = parse_guide_item(item)
            if record["id"] in used_ids:
                raise ValueError(f"重複id:{record['id']}")
            used_ids.add(record["id"])
            records.append(record)
        except ValueError as error:
            errors.append({"序號": index, "原因": str(error)})

    return records, errors

八、解析智慧設備設定

device_xml = """
<device-config version="1.0" apply="preview-only">
  <device id="TEMP-01" type="temperature">
    <enabled>true</enabled>
    <sample-seconds>60</sample-seconds>
  </device>
</device-config>
"""

config_root = ET.fromstring(device_xml)
device = config_root.find("device")

device_id = device.get("id")
device_type = device.get("type")
enabled_text = device.findtext("enabled", default="").strip().lower()
sample_seconds = int(
    device.findtext("sample-seconds", default="")
)

print(device_id, device_type, enabled_text, sample_seconds)

XML文字須自行轉成 int 或布林值。不要使用 bool("false"),因為非空字串會得到 True

九、嚴格轉換布林與整數

def parse_boolean(text, field_name):
    normalized = str(text).strip().lower()
    if normalized == "true":
        return True
    if normalized == "false":
        return False
    raise ValueError(f"{field_name}只能是true或false")


def parse_positive_int(text, field_name, maximum):
    value = int(str(text).strip())
    if not 1 <= value <= maximum:
        raise ValueError(
            f"{field_name}必須介於1到{maximum}"
        )
    return value


enabled = parse_boolean(enabled_text, "enabled")
interval = parse_positive_int(
    sample_seconds, "sample-seconds", 86400
)
print(enabled, interval)
上限只是教學防線:86400秒並非真實設備管理建議。實際採樣週期與控制參數要由設備規格、場域人員與專業需求共同決定。

十、命名空間不是多餘的括號

XML命名空間避免不同標準使用相同標籤時衝突。ElementTree搜尋時要提供前綴對照。

import xml.etree.ElementTree as ET

xml_text = """
<g:guide xmlns:g="https://example.edu/guide">
  <g:item id="white-horse">
    <g:title>白馬</g:title>
  </g:item>
</g:guide>
"""

root = ET.fromstring(xml_text)
ns = {"g": "https://example.edu/guide"}

for item in root.findall("g:item", ns):
    title = item.findtext("g:title", default="", namespaces=ns)
    print(item.get("id"), title)

範例網址只是命名空間識別字,不代表瀏覽器一定能開啟該網址。

十一、建立XML導覽檔

import xml.etree.ElementTree as ET
from pathlib import Path


def build_guide_xml(records):
    root = ET.Element("guide", {"version": "1.0"})
    for record in records:
        item = ET.SubElement(
            root,
            "item",
            {
                "id": record["id"],
                "language": record["language"],
            },
        )
        ET.SubElement(item, "title").text = record["title"]
        ET.SubElement(item, "status").text = record["status"]

    tree = ET.ElementTree(root)
    ET.indent(tree, space="  ")
    return tree


records = [
    {"id": "white-horse", "language": "zh-Hant",
     "title": "白馬", "status": "待社區確認"},
]
tree = build_guide_xml(records)

由ElementTree建立元素時,特殊字元會被正確跳脫;不要用字串拼接XML,否則文字中的 &< 等符號容易破壞格式。

十二、不覆蓋地輸出XML

from pathlib import Path


def write_xml_exclusive(tree, path):
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)

    xml_bytes = ET.tostring(
        tree.getroot(),
        encoding="utf-8",
        xml_declaration=True,
    )
    with path.open("xb") as file:
        file.write(xml_bytes)
    return path


# write_xml_exclusive(tree, "導覽資料/水井三寶_v1.xml")

十三、設定變更比較

def compare_device_config(old_config, new_config):
    changes = {}
    keys = sorted(old_config.keys() | new_config.keys())

    for key in keys:
        old_value = old_config.get(key)
        new_value = new_config.get(key)
        if old_value != new_value:
            changes[key] = {
                "舊值": old_value,
                "新值": new_value,
            }
    return changes

變更報告應先交由設備負責人確認,再經測試環境驗證;不可由XML解析程式直接套用到正式場域。

十四、常見錯誤檢查表

現象原因修正
ParseError標籤未關閉或巢狀錯誤檢查錯誤位置與原始來源
find找不到元素路徑錯誤或有命名空間確認階層並提供ns對照
false被轉成True使用bool("false")明確比對true/false
中文輸出成亂碼編碼不一致輸出UTF-8並寫入XML宣告
設定檔直接控制設備缺少驗證與核准層只產生預覽與變更報告

十五、USR實作挑戰

挑戰A|水井三寶多語XML
為白馬、烏龜與姻緣花建立華語、台語文字節點,加入確認狀態與授權欄位,找出缺漏及重複ID。
挑戰B|設備設定預覽
解析三個匿名設備設定,比較新舊版本,輸出變更報告,但不連接或操作任何真實設備。
挑戰C|錯誤XML測試
準備缺標籤、缺屬性、錯誤布林、過大整數與命名空間版本,確認程式能提供清楚錯誤。

十六、與生成式AI協作

請擔任Python XML與智慧設備設定安全助教。
請檢查我的ElementTree程式:
1. 驗證根元素、版本、必要屬性與文字;
2. 數字與布林必須嚴格轉型;
3. 支援XML命名空間;
4. 不以字串拼接XML;
5. 輸出UTF-8且不得覆蓋舊檔;
6. 不可信XML須限制大小並說明安全解析方案;
7. 設定只能產生預覽和變更報告,不可直接控制設備。
請提供正常與故障測試資料。
本篇小結
XML是一棵帶有元素、屬性與文字的樹。解析只是第一步;真正可靠的流程還要驗證結構、型別、命名空間、來源與版本,設備設定更必須經人工核准。

Python一下:照片與語音不是文字——認識二進位檔與雜湊驗證

《Python一下:從風土資料到智慧生活》第 19 篇

照片與語音不是文字——認識二進位檔與雜湊驗證

安全保存水井三寶影像、長者口述錄音及設備原始檔,並確認備份前後一個位元組都沒有改變。

CH10 二進位檔bytesSHA-256
學習目標
完成本篇後,你能分辨文字與二進位資料,使用 rbwbxb 讀寫位元組,以分塊方式處理大型媒體檔,使用Base64進行文字化傳輸,並用SHA-256驗證檔案完整性。

一、為什麼照片不能用UTF-8讀?

文字檔需要編碼把位元組解讀成字元;JPEG、PNG、MP3、WAV等檔案則有自己的二進位格式。若以 encoding="utf-8" 開啟照片,Python會嘗試把影像位元組解碼為文字,通常產生 UnicodeDecodeError

資料Python型別常用模式
逐字稿、CSV、JSONstrrw,指定編碼
照片、錄音、PDF、韌體bytesrbwb,不指定編碼
資料權利提醒:照片與錄音可能包含肖像、聲音、住家環境、位置及個人故事。能讀取或複製檔案不等於取得公開、訓練AI或再利用權利。應保存同意範圍、撤回方式及公開版本,原始媒體需限制存取。

二、認識bytes

text = "水井三寶"
data = text.encode("utf-8")

print(type(text), len(text))
print(type(data), len(data))
print(data)
print(data.decode("utf-8"))

bytes 是0到255整數組成的不可變序列。文字可經編碼變成位元組,但照片位元組本身不應任意 decode() 成文字。

三、讀取二進位檔的前幾個位元組

from pathlib import Path


def read_header(path, size=16):
    path = Path(path)
    with path.open("rb") as file:
        return file.read(size)


# 實際使用時換成資料副本
# header = read_header("照片/水井三寶.jpg")
# print(header)
# print(header.hex())

許多檔案格式有特徵位元組,但只檢查檔頭不能證明整份檔案安全或內容正確。副檔名與檔頭都只能作為初步判斷。

四、建立教學用二進位檔

from pathlib import Path

path = Path("教學資料.bin")
sample = bytes([0, 1, 2, 127, 128, 254, 255])

with path.open("xb") as file:
    file.write(sample)

with path.open("rb") as file:
    restored = file.read()

print(restored)
print(list(restored))

xb 表示排他建立二進位檔;目標已存在時會拒絕覆蓋,適合保護重要資料。

五、分塊讀取大型照片或錄音

使用 read() 一次讀完整段錄音可能占用大量記憶體。分塊處理能固定每次使用量。

from pathlib import Path


def count_bytes(path, chunk_size=64 * 1024):
    total = 0
    with Path(path).open("rb") as file:
        while chunk := file.read(chunk_size):
            total += len(chunk)
    return total


print(count_bytes("教學資料.bin"))

六、安全複製:目標存在就停止

from pathlib import Path


def copy_binary(source, target, chunk_size=1024 * 1024):
    source = Path(source)
    target = Path(target)
    if not source.is_file():
        raise FileNotFoundError(f"來源不存在:{source}")

    target.parent.mkdir(parents=True, exist_ok=True)
    with source.open("rb") as input_file:
        with target.open("xb") as output_file:
            while chunk := input_file.read(chunk_size):
                output_file.write(chunk)
    return target


# copy_binary("原始媒體/訪談.wav", "備份/訪談.wav")
避免半成品:若複製途中失敗,目標可能只寫入一部分。正式流程可先寫入暫存檔,驗證雜湊後再更名;失敗時保留錯誤紀錄並人工處理。

七、用SHA-256建立檔案指紋

import hashlib
from pathlib import Path


def sha256_file(path, chunk_size=1024 * 1024):
    digest = hashlib.sha256()
    with Path(path).open("rb") as file:
        while chunk := file.read(chunk_size):
            digest.update(chunk)
    return digest.hexdigest()


print(sha256_file("教學資料.bin"))

相同位元組會產生相同摘要;只要內容改變,摘要通常就不同。雜湊適合驗證完整性,但不能從摘要還原檔案。

八、複製後驗證是否一致

def verify_same_file(source, target):
    source_hash = sha256_file(source)
    target_hash = sha256_file(target)
    return {
        "來源SHA256": source_hash,
        "目標SHA256": target_hash,
        "內容一致": source_hash == target_hash,
    }


# result = verify_same_file("原始媒體/訪談.wav", "備份/訪談.wav")
# print(result)
雜湊不是備份:摘要只能幫助發現差異,不能救回遺失檔案。真正備份至少要有獨立副本、權限管理、定期驗證與還原演練。

九、Base64:把位元組轉成文字

import base64

original = b"USR binary demo"
encoded = base64.b64encode(original)
decoded = base64.b64decode(encoded, validate=True)

print(encoded.decode("ascii"))
print(decoded)
print(original == decoded)

Base64常用於JSON、電子郵件或文字通道傳輸二進位內容,但體積會增加,而且不是加密。任何拿到內容的人都能解碼,不能用來保護訪談錄音或設備憑證。

十、建立二進位檔清冊

from pathlib import Path


def binary_inventory(folder):
    allowed = {".jpg", ".jpeg", ".png", ".wav", ".mp3", ".pdf"}
    records = []

    for path in sorted(Path(folder).rglob("*")):
        if path.is_file() and path.suffix.lower() in allowed:
            records.append({
                "相對路徑": str(path.relative_to(folder)),
                "大小": path.stat().st_size,
                "SHA256": sha256_file(path),
            })
    return records


# for record in binary_inventory("教學媒體"):
#     print(record)

清冊公開前要檢查檔名是否含姓名、地址或活動參與者資訊。SHA-256摘要本身通常不透露內容,但可用於比對已知檔案,仍應依資料政策管理。

十一、驗證備份清冊

def compare_inventories(source_records, backup_records):
    source_map = {
        record["相對路徑"]: record["SHA256"]
        for record in source_records
    }
    backup_map = {
        record["相對路徑"]: record["SHA256"]
        for record in backup_records
    }

    all_paths = sorted(source_map.keys() | backup_map.keys())
    return [
        {
            "相對路徑": path,
            "狀態": (
                "一致"
                if source_map.get(path) == backup_map.get(path)
                else "缺少或內容不同"
            ),
        }
        for path in all_paths
    ]

十二、不要用雜湊保存密碼

本篇SHA-256用於檔案完整性,不適合直接保存使用者密碼。密碼需要專用、帶鹽且刻意緩慢的演算法與成熟框架;也不要自己發明加密方式。

十三、常見錯誤檢查表

現象原因修正
照片出現UnicodeDecodeError以文字模式讀取改用rb,不指定encoding
大型錄音占滿記憶體一次read全部內容使用固定大小分塊
備份覆蓋原檔使用wb且未檢查使用xb並分開來源與目標
檔案大小相同就判斷一致大小不能代表內容比較SHA-256
把Base64當加密誤解其用途視為編碼,不存敏感資料

十四、USR實作挑戰

挑戰A|媒體清冊
對已授權的教學副本建立相對路徑、大小與SHA-256清冊,檢查檔名是否洩露個資。
挑戰B|備份驗證
複製三個小型教學檔案到獨立資料夾,比對雜湊;再修改副本一個位元組,觀察結果。
挑戰C|授權分級
為原始錄音、內部研究版、講述者確認版與公開剪輯版設計不同資料夾與存取規則。

十五、與生成式AI協作

請擔任Python二進位檔與USR媒體保存助教。
請檢查我的照片與錄音備份程式:
1. 使用rb與xb,不得覆蓋原始媒體;
2. 大檔案必須分塊處理;
3. 複製後以SHA-256驗證;
4. Base64只能視為編碼,不是加密;
5. 清冊不得洩露不必要個資;
6. 能存取檔案不代表取得公開或AI訓練授權。
請先列出風險,再提供可測試函式。
本篇小結
二進位檔要用位元組模式處理,大檔案要分塊,重要副本要拒絕覆蓋並用雜湊驗證。技術確保內容完整,授權與權限制度則確保使用正當。

Python一下:從水井三寶到多語導覽——ASCII、Unicode與中文字串

《Python一下:從風土資料到智慧生活》第 14 篇

從水井三寶到多語導覽——ASCII、Unicode與中文字串

讓白馬、烏龜、姻緣花與台語地方故事,跨過裝置、檔案和網頁仍能正確顯示。

CH9 進階字串Unicode多語導覽
學習目標
完成本篇後,你能分辨字元、碼位、字串與位元組;理解 ASCII、Unicode 與 UTF-8 的關係;使用 ord()chr()encode()decode();處理中文字串、正規化與常見亂碼。

一、電腦怎麼認識「姻緣花」?

人看到的是文字,電腦儲存的是數字與位元組。為了讓不同裝置對同一個字有共同理解,需要三個層次:

層次意義例子
字元 character人理解的文字單位「花」
Unicode 碼位字元的通用編號U+82B1
編碼 encoding把碼位轉成實際位元組的規則UTF-8
地方語言提醒:台語文字可能使用漢字、臺羅、聲調符號或混合形式。程式能保存字形,卻不能替社區決定哪種寫法最能表達語意;公開導覽前仍須由講述者與地方語言使用者確認。

二、ASCII:早期的基本字元表

ASCII 主要涵蓋英文字母、數字、標點與控制字元,無法直接表示「水井村」等中文字。

print(ord("A"))
print(chr(65))
print(ord("1"))

text = "USR"
for character in text:
    print(character, ord(character))

ord() 取得單一字元的 Unicode 碼位整數;chr() 則把整數轉回字元。ASCII 字元同時也是 Unicode 的一部分。

三、Unicode 不等於 UTF-8

Unicode 是字元及碼位的共同標準;UTF-8、UTF-16 則是把碼位轉成位元組的編碼方式。Python 3 的 str 表示 Unicode 文字,bytes 表示已編碼的位元組。

name = "姻緣花"

print(type(name))
for character in name:
    print(character, f"U+{ord(character):04X}")

四、encode 與 decode:文字和位元組之間的橋

story = "水井三寶:白馬、烏龜、姻緣花"

utf8_data = story.encode("utf-8")
restored_story = utf8_data.decode("utf-8")

print(utf8_data)
print(restored_story)
print(story == restored_story)

記住方向:文字 encode 成 bytes;bytes decode 回文字。兩端必須對同一批位元組使用相符編碼。

五、字數與位元組數為何不同?

texts = ["ABC", "水井村", "水井村🐢"]

for text in texts:
    encoded = text.encode("utf-8")
    print(
        text,
        "字串長度:", len(text),
        "UTF-8 位元組數:", len(encoded),
    )

len(str) 計算 Python 字串中的碼位數量;len(bytes) 計算位元組。資料庫欄位、網路封包或設備緩衝區若限制位元組,不能只用中文字數估算。

六、中文字串也能切片與搜尋

guide = "白馬、烏龜、姻緣花"

items = guide.split("、")
print(items)
print("烏龜" in guide)
print(guide.replace("、", "|"))

for number, item in enumerate(items, start=1):
    print(f"{number}. {item}")

Python 的字串操作對中文同樣適用。但使用者看到的一個圖形,可能由多個 Unicode 碼位組成,因此「畫面上的字數」不一定永遠等於 len()

七、看起來一樣,電腦卻判斷不同

帶重音字母可以是一個預組字元,也可以由基本字母加組合符號構成。兩者畫面相似,但直接比較可能不同。

import unicodedata

text_a = "Tâi-gí"
text_b = "Ta\u0302i-gi\u0301"

print(text_a, text_b)
print(text_a == text_b)

normalized_a = unicodedata.normalize("NFC", text_a)
normalized_b = unicodedata.normalize("NFC", text_b)
print(normalized_a == normalized_b)

搜尋、比對與去除重複資料前,可統一使用 NFC。正規化改變的是字元表示方式,不會自動修正拼字或翻譯。

八、清理訪談轉文字資料

語音轉文字結果常混入全形空白、換行與前後空白。先做最小幅度清理,並保留原始訪談版本。

import unicodedata


def clean_transcript(text):
    text = unicodedata.normalize("NFC", text)
    text = text.replace("\u3000", " ")
    lines = [line.strip() for line in text.splitlines()]
    return "\n".join(line for line in lines if line)


raw_text = " 阿嬤講:姻緣花開矣。\n\n  這是庄頭的記持。 "
print(clean_transcript(raw_text))
口述歷史倫理:不要因為「清理文字」而改寫長輩原意。建議同時保存原始錄音、逐字稿、修訂稿、講述者確認狀態及公開授權範圍。

九、讀寫 UTF-8 文字檔

開啟文字檔時明確指定 encoding="utf-8",可降低不同作業系統間的差異。

from pathlib import Path

path = Path("shuijing_guide.txt")
story = "水井三寶:白馬、烏龜、姻緣花\n"

path.write_text(story, encoding="utf-8")
loaded_story = path.read_text(encoding="utf-8")

print(loaded_story)

若來源是 Windows 軟體匯出的 UTF-8 BOM 檔案,可視情況以 utf-8-sig 讀取;但應先確認來源格式,不要輪流猜編碼直到「看似正常」。

十、亂碼是怎麼發生的?

亂碼通常不是文字「壞掉」,而是寫入與讀取使用不同編碼。以下故意用錯誤方式示範:

original = "水井村"
utf8_bytes = original.encode("utf-8")

wrong_text = utf8_bytes.decode("latin-1")
print(wrong_text)

recovered = wrong_text.encode("latin-1").decode("utf-8")
print(recovered)

這個還原方法只適用於特定「UTF-8 位元組被誤當 Latin-1」的情況。真實資料若已被多次轉碼或遺失位元組,可能無法完全恢復,應優先找回原始檔。

十一、不要隨便忽略解碼錯誤

data = b"guide:\xff\xfe"

try:
    text = data.decode("utf-8")
except UnicodeDecodeError as error:
    print("無法以 UTF-8 解碼:", error)
    text = None

print(text)

errors="ignore" 會直接丟掉無法解碼的內容,可能讓地方名稱或訪談語句悄悄缺字。資料保存工作應記錄錯誤並回查來源;只有明確了解後果時才使用替代策略。

十二、完成多語導覽資料清理工具

import unicodedata


def normalize_text(value):
    if not isinstance(value, str):
        raise TypeError("導覽文字必須是字串")
    value = unicodedata.normalize("NFC", value)
    return " ".join(value.split())


def clean_guide_record(record):
    required = ("主題", "語言", "內容")
    missing = [key for key in required if key not in record]
    if missing:
        raise KeyError("、".join(missing))

    return {
        "主題": normalize_text(record["主題"]),
        "語言": normalize_text(record["語言"]),
        "內容": normalize_text(record["內容"]),
    }


records = [
    {"主題": "白馬", "語言": "華語", "內容": " 水井村地方故事 "},
    {"主題": "姻緣花", "語言": "台語", "內容": "庄頭的記持"},
]

for record in records:
    print(clean_guide_record(record))

十三、常見問題檢查表

現象可能原因建議
中文字變成問號或亂碼編碼不一致確認原始編碼及讀寫設定
相同臺羅詞搜尋不到組合字元表示不同比對前統一 NFC
上傳設備後文字被截斷把字數誤當位元組數以指定編碼後的 bytes 計算
檔案開頭多出奇怪符號UTF-8 BOM確認後使用 utf-8-sig
忽略錯誤後地方名稱缺字使用 errors="ignore"保留錯誤並回查原始資料

十四、USR 實作挑戰

挑戰 A|水井三寶碼位卡
列出「白馬、烏龜、姻緣花」每個字元的 Unicode 碼位與 UTF-8 位元組數,觀察標點與中文字的差異。
挑戰 B|台語逐字稿正規化
對已獲同意的教學逐字稿進行 NFC 與空白清理,逐行比較原始版和清理版,請講述者確認沒有改變原意。
挑戰 C|跨裝置測試
將 UTF-8 導覽檔分別在 Thonny、Colab、手機與網頁開啟,記錄顯示差異與解決方式。

十五、與生成式 AI 協作

請擔任 Python Unicode 與地方語言資料助教。
請檢查我的多語導覽文字處理程式:
1. 分辨 str 與 bytes 的使用是否正確;
2. 檔案讀寫必須明確指定 UTF-8;
3. 搜尋比對前使用 NFC 正規化;
4. 不可用 errors="ignore" 默默刪字;
5. 保留原始逐字稿,不可擅自改寫地方語意;
6. 說明如何測試中文、臺羅與表情符號。
請先指出風險,再提供修正版。
本篇小結
Unicode 為字元建立共同編號,UTF-8 則把這些編號轉成位元組。只要清楚區分 strbytes、統一編碼並保留原始資料,多語地方故事就能更可靠地跨平台流動。

2026年8月30日 星期日

Python一下:讓水井三寶開口說話——input、print與型別轉換

Python一下|第2篇

讓水井三寶開口說話——input、print與型別轉換

《從風土資料到智慧生活的程式設計》——讓程式從單向展示,變成會聽、會回答的地方導覽員。

本篇學習目標
  • 使用print()輸出文字、變數及多筆資料。
  • 使用input()接收使用者輸入。
  • 理解input()取得的資料一律是字串。
  • 使用int()float()str()進行型別轉換。
  • 完成一個「水井三寶互動導覽」小程式。

一、從會顯示文字,到會和人互動

上一篇,我們在Python、Thonny或Google Colab中執行第一支程式。當時程式只會把預先寫好的內容顯示出來。這一次,我們要讓程式先詢問使用者,再依照回答呈現結果。

這種「輸入—處理—輸出」的流程,是智慧導覽、問卷、資料登錄、感測資料處理及AI系統的共同基礎。

輸入 Input

接收使用者輸入的姓名、答案或數值。

處理 Process

儲存、轉換、計算或判斷取得的資料。

輸出 Output

把處理結果顯示給使用者。

二、使用print()讓Python說話

print()是Python最常用的輸出函式,可以顯示文字、數字及變數內容。

範例1:輸出文字

print("歡迎來到雲林縣口湖鄉水井村!")
print("今天要介紹水井三寶。")
歡迎來到雲林縣口湖鄉水井村! 今天要介紹水井三寶。

範例2:同時輸出文字與變數

place = "水井村"
treasure = "姻緣花"

print("地方:", place)
print("地方特色:", treasure)

逗號可以讓print()一次輸出多個項目;Python通常會自動在項目之間加入空白。

範例3:使用f-string排出自然句子

place = "水井村"
treasure = "白馬"

print(f"歡迎來到{place},今天要認識的是{treasure}。")
f-string:在字串前加上f,就能把大括號中的變數放進句子。這種寫法清楚、好讀,後續教材會經常使用。

print()常用參數

參數作用範例
sep指定多個輸出項目之間的分隔符號print("白馬", "烏龜", "姻緣花", sep="、")
end指定輸出結束後使用的字元,預設為換行print("水井村", end="→")
print("白馬", "烏龜", "姻緣花", sep="、")
print("地方故事", end=" → ")
print("數位導覽")

三、使用input()讓Python聽見回答

input()會顯示提示文字,暫停程式並等待使用者輸入;按下Enter後,輸入內容才會交給程式。

name = input("請問你叫什麼名字?")
print(f"{name},歡迎你來認識水井村!")
操作提醒:在Thonny執行時,請到下方Shell輸入答案;在Google Colab執行時,輸入框會出現在程式碼儲存格下方。

把輸入內容存入變數

等號左邊的name是變數,負責記住使用者輸入的內容:

favorite = input("水井三寶中,你最想認識哪一個?")
print(f"你選擇了:{favorite}")

四、input()取得的內容都是字串

即使使用者輸入28input()取得的仍是文字"28",而不是可以直接計算的整數。

temperature = input("請輸入目前水溫:")

print(temperature)
print(type(temperature))

type()可用來查看資料型別,執行結果會顯示<class 'str'>

為什麼沒有轉換就不能正確計算?

temperature = input("請輸入目前水溫:")
tomorrow = temperature + 1

上例會產生錯誤,因為字串不能直接和整數相加。必須先把輸入內容轉成數值。

五、int()、float()與str()型別轉換

函式轉換結果適合資料範例
int()整數人數、數量、次數int("25")變成25
float()浮點數水溫、重量、價格、溶氧float("28.5")變成28.5
str()字串把數值轉成可串接的文字str(28.5)變成"28.5"

範例:計算參訪總人數

students = int(input("請輸入學生人數:"))
teachers = int(input("請輸入教師人數:"))
total = students + teachers

print(f"本次USR參訪共有{total}人。")

範例:輸入智慧養殖水溫

water_temperature = float(input("請輸入養殖池水溫:"))
adjusted_temperature = water_temperature + 0.5

print(f"目前水溫為{water_temperature:.1f}°C。")
print(f"模擬升高0.5°C後為{adjusted_temperature:.1f}°C。")

:.1f表示以小數點後一位顯示浮點數。

六、完成「水井三寶互動導覽」

現在把輸入、變數、型別轉換與輸出組合成一支完整程式:

print("=== 水井三寶互動導覽 ===")

name = input("請輸入你的名字:")
age = int(input("請輸入你的年齡:"))
favorite = input("白馬、烏龜、姻緣花,你最想認識哪一個?")

print()
print(f"{name},歡迎來到水井村!")
print(f"你今年{age}歲,最想認識的是{favorite}。")
print("地方故事因為有人聆聽,才能繼續流傳。")
=== 水井三寶互動導覽 === 請輸入你的名字:小敏 請輸入你的年齡:20 白馬、烏龜、姻緣花,你最想認識哪一個?姻緣花 小敏,歡迎來到水井村! 你今年20歲,最想認識的是姻緣花。 地方故事因為有人聆聽,才能繼續流傳。

七、常見錯誤與除錯

錯誤情況原因修正方式
SyntaxError引號、括號或冒號未成對檢查中英文標點及左右括號
NameError變數拼字不同,或尚未建立確認大小寫及變數名稱完全一致
TypeError字串與數字直接相加先使用int()float()str()轉換
ValueError把「二十八」或空白轉為數值數值欄位輸入阿拉伯數字;進階時加入例外處理
程式一直等待input()正在等待使用者輸入在Shell或Colab輸入框輸入資料並按Enter
資料倫理提醒:課堂練習不要要求學生輸入身分證號、電話、住址等不必要個資。USR場域蒐集長者或居民資料前,應先說明用途、取得同意並妥善保存。

八、漸進式練習

基礎練習:地方名片

輸入社區名稱、代表農產與地方特色,再輸出一張文字名片。

community = input("社區名稱:")
product = input("代表農產或漁產:")
feature = input("地方特色:")

print("------ 地方名片 ------")
print(f"社區:{community}")
print(f"農漁產:{product}")
print(f"特色:{feature}")

進階練習:養殖用電估算

輸入設備功率與每天運轉時數,估算一天用電量:

power = float(input("設備功率(kW):"))
hours = float(input("每天運轉時數:"))
energy = power * hours

print(f"每日估計用電量:{energy:.2f} kWh")

USR挑戰:長者故事基本資料

設計一支程式,依序詢問:

  1. 故事主題。
  2. 發生地點。
  3. 最懷念的味道、聲音或情景。
  4. 是否同意將整理後的故事公開展示。

最後以清楚的格式輸出。請不要增加受訪者沒有說過的內容,也不要蒐集與作品無關的個人資料。

九、學習檢核

  1. print()input()分別負責什麼工作?
  2. 為什麼input()輸入的28不能直接加上1
  3. 人數、水溫與姓名分別適合轉成哪一種資料型別?
  4. print(a, b, sep="、")中的sep有什麼作用?
  5. USR場域進行互動輸入時,為什麼要注意個資與知情同意?

十、AI協作提示詞

請擔任Python初學者助教。
我要設計一支「地方文化互動介紹」程式,
目前只學過print、input、變數、int、float、str與f-string。

請先問我想介紹的社區、農漁產或地方故事,
再依我提供的內容產生程式。
不要使用if、for、函式或尚未學過的語法,
也不要增加我沒有提供的人物與地方資料。
最後逐行說明程式。

十一、本篇小結

print()讓程式把資訊傳達給人,input()讓程式聽見人的回答,型別轉換則讓輸入的文字能正確參與計算。三者組合起來,Python就不再只是顯示固定內容,而能成為簡單的地方導覽與資料登錄工具。

程式會說話,也要先學會聆聽;科技進入USR場域,也應從理解地方與尊重居民開始。

2026年8月26日 星期三

[水井村USR] 從遠端控制走向自主維運:異常告警、雲端診斷與安全維護模式實測

水井三寶故事機|V1.0-14

從遠端控制走向自主維運:異常告警、雲端診斷與安全維護模式實測

HUB 8735 Ultra+JQ6500+Django,不只會播放地方故事,更能自行回報健康、接受安全維護命令,並在展場端保留必要的實體停止能力。

100設備健康分數
GOOD健康等級
-27 dBmWi-Fi RSSI
0離線待送事件

前一版 V1.0-13 已完成安全遠端命令、執行結果回報與逾時保護;V1.0-14 再向真正的展場自主維運前進。這次測試不是只確認「雲端按鈕能不能動」,而是驗證設備能否回答三個更重要的問題:設備現在健康嗎?需要維護時能否安全鎖定?維護結束後能否立即恢復服務?

一、V1.0-14增加了什麼?

功能用途展場價值
設備健康分數依網路、播放器、事件佇列、復原次數與告警計算健康狀態管理者不必到現場逐台檢查
異常告警將故障或維護狀態同步到Django儀表板快速辨認需要處理的設備
RUN_DIAGNOSTIC由雲端要求設備產生完整診斷快照遠端取得韌體、RSSI、按鈕、BUSY及JQ6500狀態
MAINTENANCE_ON進入維護模式並阻擋一般播放與音量命令避免維修時被觀眾誤觸啟動
MAINTENANCE_OFF解除維護鎖定,恢復故事播放服務完成維護後不必重新燒錄程式
復原頻率限制限制單位時間內的播放器自動復原次數防止故障設備陷入無限重置

二、雲端儀表板已成為展場維運中心

Django儀表板同時呈現展覽互動成果與設備健康資訊。管理者可以看見故事啟動、完整播放率、設備在線狀態、健康分數、健康等級、告警數、復原層級、最新診斷,以及最近一筆遠端命令結果。


圖:SHUIJING-002在線,健康分數100、等級GOOD、告警0;下方可直接建立V1.0-14自主維運命令。
發表圖片提醒:ChatGPT工作區圖片不是公開網址。請在Blogger編輯器上傳本次儀表板截圖,取得Blogger圖片網址後,把本文唯一的 BLOGGER_IMAGE_URL 換掉,圖片就能穩定顯示。

三、第一階段:遠端完整診斷成功

我們先在Django後台建立 RUN_DIAGNOSTIC 命令。設備透過Heartbeat領取命令後,立即產生診斷快照,再把執行結果送回雲端。

[REMOTE] Heartbeat received RUN_DIAGNOSTIC
[DIAGNOSTIC] fw=V1.0-14,health=100/GOOD,state=IDLE,
track=0,vol=16,rssi=-27,queue=0,jq=0,busy=0,
btn=111111,alert=NONE
[REMOTE] Executed RUN_DIAGNOSTIC | result queued SUCCESS
[REMOTE] Result ACK SUCCESS

診斷資料如何解讀?

health=100/GOOD設備健康,沒有需要立即處理的異常。
state=IDLE故事機處於待機狀態。
vol=16JQ6500目前音量為16。
rssi=-27Wi-Fi訊號非常良好。
queue=0沒有尚未同步的離線事件。
jq=0本次運作尚未發生JQ6500復原。
busy=0播放器沒有正在播放。
btn=111111六個按鈕皆為釋放狀態,沒有腳位異常拉低。
alert=NONE目前沒有作用中的設備告警。

真正關鍵的不是看到 Executed,而是最後收到 Result ACK SUCCESS。這代表「雲端建立命令、設備領取、設備執行、結果回送、伺服器確認」五個環節全部完成。

四、第二階段:從雲端開啟維護模式

Django 建立命令
Heartbeat 領取命令
設備啟用鎖定
結果 ACK 確認
[REMOTE] Heartbeat received MAINTENANCE_ON
[MAINTENANCE] Enabled by cloud
[REMOTE] Executed MAINTENANCE_ON | result queued SUCCESS
[REMOTE] Result ACK SUCCESS

維護模式不是關閉整台設備,而是選擇性鎖定具有風險的操作。故事播放及音量調整會被拒絕,但停止功能仍保留,讓現場人員遇到播放器異常時仍能立即處理。

維護模式中的操作處理結果設計理由
故事按鈕拒絕防止維修時意外啟動音訊
Web故事播放拒絕避免遠端使用者干擾現場維護
音量+/-拒絕維持檢修時的固定條件
停止按鈕保留確保現場仍可立即停止播放器
遠端診斷保留維護期間仍需觀察設備狀態
解除維護保留讓雲端可以安全恢復服務

五、實體按鈕真的被鎖住了嗎?

進入維護模式後,我們分別按下GPIO11烏龜故事與GPIO10白馬故事,系統確實讀到按鍵,但沒有啟動JQ6500:

[BUTTON] Pressed GPIO11
[MAINTENANCE] Story command rejected
[BUTTON] Pressed GPIO10
[MAINTENANCE] Story command rejected

接著按下GPIO20停止鍵,系統仍然接受停止操作;因為當時播放器原本就在待機,所以正確回報:

[BUTTON] Pressed GPIO20
[STOP] Ignored: already idle

這個結果十分重要:維護模式沒有讓整個按鍵掃描停止,而是由命令層判斷哪些動作可以執行。設備仍持續掃描實體輸入,因此不會重演早期版本「網路工作時按鈕失去反應」的問題。

六、解除維護後立即恢復播放

[REMOTE] Heartbeat received MAINTENANCE_OFF
[MAINTENANCE] Disabled by cloud
[REMOTE] Executed MAINTENANCE_OFF | result queued SUCCESS
[REMOTE] Result ACK SUCCESS

解除維護後再次按下GPIO10,白馬故事成功播放,雲端事件也正常寫入本機佇列:

[BUTTON] Pressed GPIO10
[CLOUD QUEUE] + SHUIJING-002-G68F044D7-0000000033
| STORY_START | pending=1
[JQ] Play track 1
[STATE] STORY_PLAYING | 白馬故事
[BUSY] 0 -> 1 | idle=0

這表示解除維護不需要重新開機,也不需要重新燒錄程式;實體按鈕、JQ6500播放、BUSY狀態與雲端事件佇列一起恢復正常。

七、為什麼一直看到NUL filtered=1?

[HEARTBEAT HTTP] NUL filtered=1
[HEARTBEAT] Accepted HTTP 200

這不是錯誤。實測發現Ameba SSL接收的HTTP封包偶爾會夾帶一個 0x00 NUL位元組。早期版本會因此無法解析HTTP狀態列或JSON;現在程式會先過濾NUL,再解析完整回應。

判讀原則:只要NUL過濾後緊接著出現 Accepted HTTP 200,就代表封包已修正並成功處理。這一行是防護機制的工作紀錄,不是通訊失敗。

八、這次完整測試結果

  • STA網路連線與HTTPS Heartbeat正常。
  • Django成功送出RUN_DIAGNOSTIC命令。
  • 設備回傳完整健康診斷快照。
  • 命令執行結果收到雲端ACK。
  • MAINTENANCE_ON成功啟用。
  • 維護期間實體故事按鈕受到阻擋。
  • 維護期間停止按鈕仍可使用。
  • MAINTENANCE_OFF成功解除鎖定。
  • 解除維護後白馬故事成功播放。
  • STORY_START事件成功進入離線保護佇列。
  • JQ6500 BUSY腳位正確由0切換至1。
  • 設備健康分數100、等級GOOD、告警NONE。
實測結論:V1.0-14核心功能全部通過。

水井三寶故事機已從「可以被遠端操作的播放器」,進一步成為「可以自我回報、接受安全維護、保留現場控制並完成雲端稽核」的展場智慧設備。

九、這次最寶貴的工程經驗

展場設備的可靠性,不只取決於功能多不多,而是發生異常時能不能被看見、被限制、被診斷、被恢復。V1.0-14建立的健康分數、異常告警、維護鎖定、遠端診斷與結果ACK,形成一條完整的維運證據鏈。

感知設備健康
雲端派送命令
邊緣安全執行
回報並完成稽核

這也讓地方故事、樹藝作品與智慧展覽不再只是一次性的展示,而是可以長期運作、跨場域部署並由遠端團隊共同維護的數位文化服務。

測試平台:HUB 8735 Ultra、JQ6500、實體按鈕、LED、STA Wi-Fi、HTTPS、Django/PythonAnywhere。版本:V1.0-14「展場自主維運+異常告警+遠端診斷版」。

2026年8月25日 星期二

[水井村USR] 看得見538 Bytes,卻讀不到HTTP:一次珍貴的Ameba SSL封包除錯紀錄

水井三寶故事機|V1.0-13 R3.3

看得見538 Bytes,卻讀不到HTTP:一次珍貴的Ameba SSL封包除錯紀錄

本次測試完成了HUB 8735 Ultra故事機的安全遠端命令、執行結果回報與重新啟動,也找到一個非常隱密的封包問題:SSL回應中只要混入一個NUL(0x00),Arduino的字串解析就可能看似收到資料,實際上卻找不到HTTP狀態列。

最終結果:六種遠端命令全部成功,Django後台均顯示「成功」,每筆命令只領取一次;設備重新啟動後,開機語音也正常播放。

一、這一版要完成什麼?

水井三寶故事機先前已具備實體按鈕、手機Web控制、JQ6500播放、STA主要連線、AP故障備援、NTP校時、Flash離線事件佇列與設備心跳。V1.0-13再向前一步:讓管理者可以從Django雲端安全地下達維護命令,設備執行後還必須回報結果。

Django建立命令
心跳領取命令
8735執行命令
結果ACK後結案
安全領取命令包含Device ID、API Key、UUID與逾期時間。
只執行一次透過命令UUID避免同一指令被重複執行。
結果可追蹤Django必須收到SUCCESS或FAILED,才能將命令結案。

二、R3.1已經成功,為何後面又失敗?

R3.1第一次證明「心跳夾帶命令」的架構可行。設備成功收到並執行狀態回報:

[HEARTBEAT] Accepted HTTP 200
[REMOTE] Heartbeat received REPORT_STATUS | bba846e1-...
[REMOTE] Executed REPORT_STATUS | result queued SUCCESS

但是下一次心跳要把執行結果送回Django時,卻出現:

[HEARTBEAT] POST | state=IDLE | queue=0
[HEARTBEAT] Failed code=0

這不是命令沒有執行,也不是Django拒絕請求,而是RTL8735B端沒有正確解析伺服器回傳的HTTP狀態。

三、最關鍵的線索:538 Bytes與空白Preview

R3.2先改成收完整HTTP Header,並加入原始資料長度診斷。結果出現非常矛盾的訊息:

[HEARTBEAT HTTP] Unparsed bytes=538 | preview=
[HEARTBEAT] Failed code=0
問題焦點:如果完全沒有收到資料,長度應該是0;現在明明收到538 bytes,為何預覽是空白,而且找不到HTTP/1.1 200 OK

答案是回應內容最前方混入了NUL,也就是數值為0的位元組:

第1 byte00 NUL控制字元
後續文字HTTP/1.1 200 OK
自訂HeaderX-Ameba-Result-Ack: 1
JSON Body{"ok":true,...}

Arduino的String仍可能把這個0x00算入長度,所以看到538 bytes;但許多字串函式會把NUL視為C字串結尾。於是:

  • raw.length()仍顯示收到資料。
  • raw.indexOf("HTTP/")可能找不到後面的HTTP狀態列。
  • Serial.println(raw)從第一個NUL就停止,所以Preview看起來完全空白。
  • HTTP狀態碼最後被判定為0。
這次最重要的除錯觀念:「收到幾個bytes」不等於「收到可直接當成文字處理的bytes」。網路除錯不能只看字串,也要保留位元組、控制字元與十六進位的觀點。

四、R3.3如何修正?

R3.3不再把讀到的0x00直接加入HTTP文字緩衝區,而是在SSL讀取階段排除NUL,同時計算排除數量:

int value = client.read();
if (value == 0) {
    nulCount++;
} else if (value > 0) {
    raw += (char)value;
}

若解析仍失敗,程式還會輸出前32 bytes的HEX資料。這使除錯從「猜測HTTP為何不見」變成「直接觀察真正收到的位元組」。

[HEARTBEAT HTTP] Unparsed bytes=... | NUL filtered=... | preview=...
[HEARTBEAT HTTP] HEX: 48 54 54 50 2F 31 2E 31 ...

其中48 54 54 50就是ASCII的HTTP。這種診斷方式未來也適合用於UART、MQTT、Modbus、WebSocket及其他嵌入式通訊問題。

五、R3.3實機測試結果

更新R3.3後,日誌第一次直接證實問題來源:

[HEARTBEAT HTTP] NUL filtered=1
[HEARTBEAT] Accepted HTTP 200
[REMOTE] Result ACK SUCCESS | d7d05b25-...

只是一個NUL,就足以讓前一版完全讀不到HTTP;排除後,命令領取、執行與ACK全部恢復正常。

六種遠端命令測試

遠端命令設備動作結果
REPORT_STATUS立即回報設備狀態成功
SYNC_EVENTS立即補送Flash離線事件成功
SYNC_TIMENTP校時;失敗時切換HTTP時間成功
SET_VOLUME音量20調整為16並延遲保存成功
PLAYER_RESET重新初始化JQ6500播放器成功
DEVICE_RESTART回報成功並保存資料後重新啟動成功

六、安全重新啟動不是「收到就重開」

DEVICE_RESTART尤其值得記錄。設備不是一收到命令便立刻重新啟動,而是依序完成:

  1. 領取具有UUID的重新啟動命令。
  2. 將執行結果排入下一次心跳。
  3. 等待Django回傳X-Ameba-Result-Ack: 1
  4. 保存Flash中的統計、音量與佇列資料。
  5. 最後才執行系統重新啟動。
[REMOTE] Executed DEVICE_RESTART | result queued SUCCESS
[HEARTBEAT] Accepted HTTP 200
[REMOTE] Result ACK SUCCESS | 0a815d53-...
[FLASH] Batch saved: before remote restart
[SYSTEM] Restart scheduled: heartbeat command restart
[SYSTEM] Restarting: heartbeat command restart

重新開機後,音量16成功保留,開機提示語音正常播放,STA重新連上Wi-Fi,證明「命令回報、Flash保存、重啟復原」三個環節都通過。

七、Django後台驗證

後台六筆命令全部顯示「成功」,Delivery Attempts均為1,Completed At也有完成時間。這代表:

  • 命令沒有因輪詢或網路延遲而重複執行。
  • 每筆命令都由相同UUID貫穿領取、執行與結果確認。
  • 設備重新啟動前,雲端已收到成功結果。
  • 心跳既是健康監控,也是低負擔的安全命令通道。
命令類型狀態領取次數完成確認
重新啟動設備成功1已記錄
重設播放器成功1已記錄
設定音量成功1已記錄
立即網路校時成功1已記錄
立即補送事件成功1已記錄
立即回報狀態成功1已記錄

八、這次經驗教會我們什麼?

1. HTTP仍是Bytes即使最終看到的是文字協定,底層傳輸仍可能包含控制字元。
2. 長度不能代表內容538 bytes可能以NUL開頭,字串函式看到的卻是空字串。
3. 要有HEX診斷文字預覽失效時,十六進位輸出是最可靠的證據。
4. ACK後才能破壞性操作重啟前先回報與保存,才能避免雲端永遠停在「已領取」。
5. UUID保證冪等同一命令不因重送而執行兩次,對展場設備非常重要。
6. 現場功能優先網路同步與心跳都不能阻塞實體按鈕及故事播放。

九、版本結論

V1.0-13 R3.3已完成從「會播放故事的單機」到「可被雲端安全維護的展場設備」的重要跨越。它不只會執行遠端命令,還能辨識命令、避免重複、回報結果、等待ACK、保存狀態,並在必要時安全重啟。

本次最珍貴的發現:真正讓系統卡住的,不是538 bytes的大問題,而是藏在最前面、肉眼看不見的1 byte——NUL(0x00)。物聯網系統的可靠性,常常就建立在願不願意把「看似空白」繼續追查到底。