Steven
Steven4 นาทีที่อ่าน

จาก OCR สู่ pixel โดยไม่ทิ้ง fallback

GeekBye เลิกอ่าน screenshot ของคุณด้วย OCR แล้วเริ่มส่งภาพจริง ๆ ไปให้ vision model — ซึ่งเป็นส่วนที่ง่าย ส่วนที่ให้บทเรียนคือการ optimize ที่ข้าม OCR ไปทั้งหมดสำหรับผู้ใช้ vision ซึ่งทำให้ fallback ไปยัง model ที่ไม่รองรับ vision พังไปเงียบ ๆ และต้องถอยกลับไปยังเวอร์ชันที่ตาข่ายนิรภัยทำงานขนานกันและแทบไม่มีต้นทุนเลย

วิศวกรรม
AI
Desktop
GeekBye รีลีส
จาก OCR สู่ pixel โดยไม่ทิ้ง fallback

แอปไหนก็ตามที่ให้คุณ screenshot อะไรสักอย่างแล้วถาม AI เกี่ยวกับมัน ล้วนมีทางแยกของจริงอยู่ทางหนึ่ง คุณจะรัน OCR ก็ได้ — ดึงข้อความออกมาบนเครื่อง, ส่ง string จิ๋ว ๆ, แล้วทิ้งภาพไป หรือคุณจะ ส่ง pixel ก็ได้ — ส่งภาพจริง ๆ ออกไป แล้วปล่อยให้ model ที่รองรับ vision อ่านมัน OCR ถูกและเล็ก แต่ตาบอดต่อทุกอย่างที่ไม่ใช่ข้อความ: layout, diagram, การเยื้องของโค้ด, เส้นหยึกหยักสีแดงใต้ error pixel เก็บสิ่งเหล่านั้นไว้ทั้งหมด แต่กินไบต์มากกว่าและต้องการ model ที่มองเห็นได้ GeekBye เริ่มต้นอยู่ฝั่ง OCR ของทางแยกนั้น และข้าม v1.8.6 กับ v1.8.7 เดินข้ามมาฝั่ง pixel การสลับนั้นคือส่วนที่ง่ายของเรื่องนี้ ส่วนที่น่าสนใจคือการ optimize ที่มาพร้อมกับมัน, ทำ fallback พัง, และต้องถูกยกเลิกไป

ก่อนหน้านั้น: อ่านข้อความ, ทิ้งภาพ

ก่อนถึงเส้นเรื่องนี้ screenshot กลายเป็นคำตอบแบบนี้: คุณกดปุ่มลัดจับภาพ, client รัน OCR — บน macOS คือ Vision framework ของ Apple ผ่าน Swift binary ชื่อ VisionOCR, บน Windows คือ PowerShell script, ทั้งคู่อยู่หลัง abstraction เดียวคือ IOcrEngine — แล้วข้อความที่ดึงมาถูกห่อเป็น string ส่งไปยัง backend ในชื่อ screenshotText ตัวภาพเองไม่เคยออกจากเครื่องเลย นั่นมีประสิทธิภาพ และสำหรับกำแพงข้อความล้วน ๆ ก็ใช้ได้ แต่ screenshot น้อยครั้งจะเป็นแค่ข้อความ มันคือประกาศรับสมัครงานแบบ layout สองคอลัมน์, โจทย์ coding ที่มี diagram, error ที่มี stack trace ซึ่งการเยื้องของมันนั่นแหละคือเบาะแส OCR รีดทั้งหมดนั้นให้แบนลงเป็นการถอดความทีละบรรทัด และ model ก็ตอบคำถามที่บางกว่าคำถามที่คุณถามไป

การสลับ: ส่ง pixel (v1.8.6)

v1.8.6 เพิ่มอีกเส้นทางเข้ามา imageOptimizer.ts ตัวใหม่ ซึ่งสร้างบน sharp จึงข้ามแพลตฟอร์มได้ ทำสามอย่างพอดี ๆ กับ screenshot ดิบ:

sharp(pngFilePath).resize({ width: 1920, withoutEnlargement: true }).jpeg({ quality: 80 })

ย่อให้กว้างไม่เกิน 1920px (ไม่ขยาย capture ที่เล็กกว่าเด็ดขาด), แปลง PNG เป็น JPEG, quality 80 ผลลัพธ์คือ base64 ดิบ โดยมี media type แยกไปไว้อีก field หนึ่ง และมันเดินทางไปยัง backend ใน request field ใหม่สองตัวเคียงข้างตัวเดิม: screenshotImage และ screenshotImageMediaType ซึ่งเป็นอิสระจาก screenshotText ตอนนี้ backend รับได้ทั้งภาพ, ข้อความ, หรือทั้งสองอย่าง

ขั้นตอน optimize นั้นไม่ใช่แค่เรื่องผิวเผิน screenshot PNG ดิบมีขนาด 3–8 MB; JPEG ที่ย่อแล้วราว ๆ 200–500 KB นั่นคือหนึ่ง order of magnitude ที่หายไปจากการอัปโหลด และมันยังตัดบิลของ model ด้วย — vision model แบ่งภาพเป็น tile ตาม resolution ของมันและคิดเงินต่อ tile ดังนั้นการจำกัดความกว้างไว้ที่ 1920 แล้วบีบอัดใหม่จึงลดทั้งไบต์บนสายและ image token ที่คุณต้องจ่ายลงโดยตรง โดยความอ่านออกของข้อความบนจอไม่เสียหายอย่างมีนัยสำคัญ

แล้วแอปรู้ได้ยังไงว่า model มองเห็นได้หรือเปล่าตั้งแต่แรก? backend เป็นคนบอก response ของ /api/config ได้ block ai.vision ที่ key ตาม plan เพิ่มเข้ามา และ client cache ค่า boolean ตัวเดียว — aiSupportsVision — ทุกครั้งที่ดึง config เมื่อรองรับ vision ก็ optimize ภาพ; เมื่อไม่รองรับก็ fallback ไป OCR ดังนั้น ความสามารถ เป็นข้อเท็จจริงของ backend ที่ cache ไว้ฝั่ง client และ การตัดสินใจต่อ screenshot แต่ละใบ ว่าจะรันหรือข้าม OCR ก็ตัดสินบน client จาก flag นั้น ส่วนว่า model ตัวไหนตอบจริง ๆ ยังเป็นของ backend อยู่

การ optimize ที่ฉลาดเกินไป

ตรงนี้แหละที่เริ่มให้บทเรียน เวอร์ชันแรกของเส้นทาง vision รัน OCR กับการ optimize ภาพไปด้วยกันแบบขนาน จากนั้น commit "perf" ตัวหนึ่งบีบมันให้เป็นอย่างใดอย่างหนึ่ง: ถ้า plan รองรับ vision ก็ optimize ภาพและ ข้าม OCR ไปทั้งหมด; ถ้าไม่รองรับก็รัน OCR และข้ามงานภาพ บนกระดาษนี่คือชัยชนะที่เห็น ๆ — จะไปดึงข้อความที่ vision model ไม่ต้องการทำไม? changelog ยังโฆษณามันเลย: "OCR is skipped when the AI can read the image directly."

ปัญหาอยู่ที่คำว่า fallback backend ไม่ได้มี model แค่ตัวเดียว; มันมีตัวหลักและตัว fallback และตัว fallback อาจเป็น model ที่ ไม่รองรับ vision ก็ได้ ลองนึกภาพลำดับนี้: model หลักของ plan คุณรองรับ vision ดังนั้น client ก็ข้าม OCR อย่างภาคภูมิและส่งแค่ภาพ provider ตัวหลักสะดุด backend fallback ไปยัง model ที่รับแต่ข้อความ — ซึ่งตอนนี้ถูกยื่นภาพที่มันอ่านไม่ได้และ ไม่มีข้อความ มาให้ เพราะ client optimize ข้อความทิ้งไปแล้ว fast path ได้ลบตาข่ายนิรภัยทิ้งไปเงียบ ๆ คุณทิ้ง fast path ที่ซ้ำซ้อนได้อย่างปลอดภัย; แต่คุณทิ้งสิ่งที่ fast path เป็นทางลัด เลี่ยงผ่าน ไปไม่ได้

ทางแก้: ผูกกับ fallback ไม่ใช่ตัวหลัก

ทางแก้นี่แหละคือส่วนที่ควรขโมยไปใช้ มันไม่ใช่การกลับไปรัน OCR เสมอ — นั่นจะโยนชัยชนะของกรณีทั่วไปทิ้ง มันคือการทำให้การข้ามขึ้นอยู่กับ capability flag ตัวที่สอง: fallback รองรับ vision ไหม? config ของ backend ได้ block fallbackVision เพิ่มมา; client cache aiFallbackSupportsVision ไว้ข้าง flag ตัวแรก ตอนนี้การตัดสินใจมีสี่กิ่ง:

  • ทั้งตัวหลักและ fallback มองเห็น → optimize ภาพ, ข้าม OCR เส้นทางที่เร็วและพบบ่อย
  • ตัวหลักมองเห็นแต่ fallback ตาบอด → optimize ภาพ และ รัน OCR เป็นประกันให้ fallback
  • การ optimize ภาพ throw error → OCR เป็น fallback ขั้นสุดท้าย
  • plan ไม่มี vision เลย → OCR อย่างเดียว

และกิ่งที่สอง — กรณีประกัน — คือที่ที่หมากสุดท้ายสำคัญ การรัน OCR หลัง การ optimize ภาพจะเอา latency ของมันมาบวกทับข้างบน ดังนั้นทั้งสองจึงรันไปด้วยกันภายใต้ Promise.all เดียว: การ optimize ภาพกับ OCR เหลื่อมกัน และ OCR ที่มีอยู่เผื่อว่า backend ต้องใช้เท่านั้น ก็ทำเสร็จภายในเวลาที่ภาพกำลังถูก optimize อยู่แล้วอยู่ดี ตาข่ายนิรภัยฟรีในแง่ wall-clock เมื่อไหร่ก็ตามที่ไม่ได้ถูกใช้ ทั้งกลเม็ดสรุปได้ในประโยคเดียว: เก็บ fallback ราคาถูกไว้, ผูกมันกับว่า provider ตัว fallback ของคุณต้องใช้มันจริงไหม, แล้วทำให้มันขนานกัน เพื่อว่ามันจะไม่มีต้นทุนอะไรเลยจนถึงวันที่มันช่วยชีวิตคุณไว้

กับดักเล็กกว่าอีกสองอันที่การข้ามทำให้ลั่น

การข้ามงานที่เลื่อนไว้อย่างดุดัน มักมีวิธีทำให้ทุกที่ที่เคยแอบสมมติว่างานนั้นจะเกิดขึ้น โผล่ขึ้นมาให้เห็น สองอันโผล่ออกมาใน PR review สัปดาห์เดียวกัน

queue deadlock queue ของ screenshot ถือว่ารายการหนึ่ง "พร้อม" เมื่อมันมีข้อความ OCR ตอนนี้ vision screenshot ไม่มีวันได้ข้อความ OCR — โดยการออกแบบ — ดังนั้นการตรวจความครบถ้วน hasIncompleteOCR จึงรอไปชั่วนิรันดร์และ queue ก็ค้าง ทางแก้นิยาม "พร้อม" ใหม่เป็น มีภาพที่ optimize แล้ว หรือ มีข้อความ OCR ซึ่งคือสิ่งที่ pipeline รับประกันจริง ๆ ในตอนนี้

capability flag ที่ค้างเก่า aiSupportsVision ถูก cache ไว้ ซึ่งแปลว่ามันเก่าค้างไปในทิศทางที่ผิดพอดีได้: user logout, หรือการดึง config ล้มเหลว, แล้ว client ก็ถูกทิ้งไว้กับความเชื่อว่ามันยังส่งภาพไปให้ model ที่ไม่รองรับภาพอีกต่อไปได้ ตอนนี้ flag reset เมื่อ logout และเมื่อดึง config ล้มเหลว — capability ที่ถูก cache ไว้ต้องมีเรื่องราวรองรับไว้สำหรับตอนที่สิ่งที่มันบรรยายถึงหายไป

ขอพูดสักคำเรื่องตัวเลข

เพราะความซื่อสัตย์คือหัวใจของซีรีส์นี้: commit message พก figure มาด้วย — การ optimize ภาพ "~30ms" เทียบกับ OCR "~100–150ms", payload "~200–500 KB vs ~3–8 MB PNG" — และมันก็ฟังดูเป็นไปได้ แต่มันเป็นการประมาณแบบ inline ของผู้เขียนเอง ไม่ใช่ผลจาก benchmark ไม่มี latency แบบ end-to-end ก่อน/หลังที่วัดจริงอยู่ที่ไหนในประวัติเลย จงถือว่าข้อโต้แย้งเรื่องความเร็วเป็นเจตนาการออกแบบที่ดี ไม่ใช่ผลลัพธ์ที่พิสูจน์แล้ว การลดขนาด payload เป็นตัวเลขเดียวที่คุณเชื่อได้ เพราะมันหล่นออกมาตรง ๆ จากการย่อและบีบอัดใหม่

สามสิ่งที่รีลีสนี้สอนเรา

  1. การส่ง pixel ชนะการอ่านข้อความ — เมื่อ model มองเห็นได้ OCR ทิ้งทุกอย่างที่ screenshot สื่อสารนอกเหนือจากตัวอักษร vision model อ่าน layout, diagram, และการเยื้อง และ JPEG ที่ optimize แล้วทำให้มันจ่ายไหว แต่มันคือการเดิมพันกับ capability ซึ่งเป็นเหตุผลว่าทำไมมันถึงต้องมี fallback
  2. คุณ optimize ทิ้ง fast path ได้ แต่ตาข่ายนิรภัยไม่ได้เด็ดขาด การข้าม OCR สำหรับผู้ใช้ vision ถูกสำหรับตัวหลักและผิดสำหรับ fallback bug ไม่ได้อยู่ที่การข้าม; มันอยู่ที่การข้ามโดยอิงกับ capability ของตัวหลัก ทั้งที่ capability ที่สำคัญคือของ fallback
  3. ตาข่ายนิรภัยที่ฟรีคือตาข่ายที่ถูกทำให้ขนาน ผูกงานประกันไว้กับว่ามันจำเป็นจริงไหม แล้วรันมันเคียงข้าง fast path ให้มันเหลื่อมกัน ถ้าทำถูก graceful degradation จะไม่มีต้นทุนอะไรเลยจนถึงวันที่มันเป็นเหตุผลเดียวที่ฟีเจอร์ของคุณยังทำงานอยู่

สำหรับบทก่อนหน้าในเรื่องยุค v1 ดูสองโหมดความล้มเหลวของ overlay แบบคลิกทะลุ (v1.8.5); และสำหรับภาพรวมทั้งเส้นเรื่อง ดูกายวิภาคของการส่งมอบซอฟต์แวร์ให้สมบูรณ์แบบ

บทความที่เกี่ยวข้อง

สองโหมดความล้มเหลวของ overlay แบบคลิกทะลุ
Steven
Steven4 นาทีที่อ่าน

สองโหมดความล้มเหลวของ overlay แบบคลิกทะลุ

หน้าต่างของ GeekBye ลอยอยู่เหนือทุกอย่าง และปล่อยให้คลิกของคุณผ่านทะลุมันไป — ยกเว้นตรงที่มันมีปุ่ม นั่นคือสัญญาสองด้าน และ v1.8.5 กับ v1.8.14 คือหน้าตาของตอนที่แต่ละด้านพัง: รีลีสหนึ่ง overlay กลืน dialog ของระบบไป, อีกรีลีสหนึ่งมันขโมยการกดปุ่มของคุณ ทางแก้ที่ชนะสำหรับอันที่สองคือการลบโค้ดทิ้ง

วิศวกรรม
Electron
Desktop
เอาการรีลีสใส่ไว้ใน CI, สองครั้ง
Steven
Steven4 นาทีที่อ่าน

เอาการรีลีสใส่ไว้ใน CI, สองครั้ง

GeekBye v1.8.4 ย้าย release build ทั้ง macOS และ Windows เข้าไปใน CI สิ่งที่ changelog ไม่ได้บอกคือ เราเคยลองแบบนี้มาก่อนหนึ่งครั้ง, ลบทิ้งหนึ่งเดือนต่อมาเพราะค่า runner, และเพิ่งทำให้มันอยู่ตัวได้ในครั้งที่สอง — เพราะพอถึงตอนนั้น release script ก็ทำงานด้วยมือได้แล้ว

วิศวกรรม
CI/CD
Desktop
ส่งมอบสามสิบภาษาโดยไม่มีตาข่ายรองรับ
Steven
Steven4 นาทีที่อ่าน

ส่งมอบสามสิบภาษาโดยไม่มีตาข่ายรองรับ

GeekBye v1.8.3 ต่อ react-i18next เข้าไปและแปลทั้งแอปเป็น 30 ภาษา ส่วนที่เป็น library นั้นเป็นงานปกติ ส่วนที่น่าสนใจคือสิ่งที่ขาดหายไป: ไม่มีการเช็ก key parity และไม่มีตัวดึงสตริง — ภาษาอังกฤษที่ hardcode ไว้จึงหลุดออกมา, typo ภาษาเดนมาร์กหลุดขึ้น production, และ changelog เองก็ตกลงกันไม่ได้ว่าแอปพูดได้ 28, 29 หรือ 30 ภาษากันแน่

วิศวกรรม
i18n
Desktop