ตําเตือน: บทความนี้โคตรยาว
สวัสดีท่านผู้อ่าน ยินดีต้อนรับสู่บทความชุด “ข้าวเปล่า” อ่านแค่ชื่ออาจจะมองไม่ออกว่า เอ้ บทความนี้มันจะเขียนไปทําไร จึงต้องขอเกริ่นก่อนว่า ไม่ว่าจะอีสานบ้านเฮา ฮือ คนเมียง จะสะแตกข้าวเป็นอาหารหลัก แต่ในการใช้ชีวิตในเมียงใหญ่โพ้น จะเหลือเพียงกี่ท่านที่ทําข้าวกินเองที่บ้านกัน ?
บทความชุด “ข้าวเปล่า” ก็เหมือนแบบนั้น เสมือนว่าเรากําลังหุงข้าว แต่ก่อนหุงข้าวเราต้องทําไรบ้างล่ะ? ซาวข้าว? เกียกข้าว? จักมัน เอาเป็นว่า “ข้าวเปล่า” เปรียบเสมือนการพาคนอ่าน ดิ่งลงไปในหลุมของสถาปัตยกรรม ว่ากว่าจะเป็นสิ่งที่เราใช้นั้น ได้ผ่านกระบวนการไรอะไรมาบ้าง นี่แหละ “ข้าวเปล่า”
ในบทความข้าวเปล่าวันนี้ เชื่อว่าทุกคนน่าจะใช้ หรือ เคยใช้พวกแอพเขียนโน้ตกัน โดยเฉพาะถ้ามีพวก Tablet/iPad นี่น่าจะเป็น Must have เลย เพราะแอพโน้ตที่มากับตัว OS เองมันโคตรกาก เลยต้องพึ่งใบบุญกับแอพโน้ต Third-Party จำพวก Goodnotes, Freenotes, Element Note, Notability,… กัน
และเชื่อว่าหลายท่านก็น่าจะจ่าย Subscription ของแอพเหล่านั้น แลกกับความสะดวกสบายในการเรียนหรือทำงานกัน แต่ภายใต้ความสะดวกสบายเหล่านั้น เราเคยสงสัยมั้ยว่า Stack ข้างใน มันทำงานกันยังไงวะ ถึงลื่นไหลเป็นปลา (ไม่นับที่มากับ iOS) วันนี้เราจะมาแกะกัน โดยเป้าหมายหลักของการเจาะเราวันนี้คือ แอพ Freenotes
แล้วเราจะเจาะอะไรก่อน? เชื่อว่าถ้าหากเคย Export โน้ตจากแอพเหล่านั้นมา นอกจากไฟล์ standard พวก .pdf, .png ก็น่าจะเคยเห็นไฟล์ที่เป็น proprietary format ของแอพเหล่านั้นเอง เช่น .freenotes หรือ .goodnotes ซึ่งจะเปิดได้เฉพาะในแอพๆ นั้น และที่ต่างจาก format อื่นๆ format เหล่านั้นล้วนแล้วจะเก็บข้อมูลแบบ lossless นั่นก็คือเป็นข้อมูลที่ผู้ที่โหลดไฟล์นั้นมา หรือกู้โน้ตนั้นมา สามารถเปิดและแก้ไขต่อได้เลย
ก่อนจะเริ่ม
เราต้องเข้าใจก่อนว่าถึงแม้ไฟล์จะมีนามสกุล .freenotes แต่ไส้ในมันจริงๆก็คือ Zip ซึ่งถ้าหากเราลองดาวโหลดไฟล์มาแล้วมาส่องดู ก็จะพบว่ามันก็คือไฟล์ Zip นั่นแหละ
<xxxx>.freenotes: Zip archive data, made by v3.0 UNIX, extract using at least v4.5, last modified YYYY, method=deflateในส่วนต่อไปเราจะเริ่มเข้าสู่โครงสร้างของไฟล์โดยรายละเอียด ซึ่งในบทความนี้เราจะสนใจโฟกัสไปที่ Format ของ .freenotes เป็นหลัก
โครงสร้างของไฟล์
ก่อนที่เราจะดําดิ่ง เรามาแตกไฟล์กันก่อนเพื่อดูโครงสร้างและวางแผนแนวทางต่อไปของเรา
เมื่อเราแตกไฟล์มาแล้วเราจะพบโครงสร้างไฟล์ดังนี้
[BOX] .frnote File Structure [ CLICK TO UNBOX ] [ CLICK TO CLOSE ]
915CB187-E62E-439E-AD9B-DC50DA3AAC13.frnote/
├── AssetState
│ ├── imageObjectState.dat
│ ├── imageState.dat
│ └── pdfState.dat
├── document.dat
├── FNPages
│ └── 3C892967-BC80-4C5F-B384-FB2045DEC9CA.dat
├── FNPagesObject
├── FNPagesSummary
│ └── 3C892967-BC80-4C5F-B384-FB2045DEC9CA.dat
├── Images
├── index.db
├── layerInfo.dat
├── layout.dat
├── metadata.dat
├── Objects
│ └── Images
├── OCRRequests
│ └── requests.dat
├── OCR_v3
│ └── en-US
│ └── D0E31896-FAE8-460B-A9F8-69909A199688
│ ├── content.dat
│ └── summary.dat
├── PDFs
│ ├── 2F8DA175-4F19-4ADA-9FD2-B2369CE08F77.pdf
│ └── idMap.dat
├── Records
│ └── record.dat
├── Thumbnail
│ ├── Dark
│ │ └── Cover
│ │ ├── 775251DC-90E3-4C40-8B98-912ED498456F.png
│ │ └── Data.dat
│ └── Light
│ └── Cover
│ ├── 20696BD2-BF7E-417B-A75D-70A079CB82DE.png
│ └── Data.dat
├── versionList.dat
└── WebVideos
└── webVideo.datProtobuf
เมื่อเราแตกไฟล์มาแล้ว สิ่งที่เราได้รับมาคือไฟล์จํานวนมหาศาลที่ไม่รู้จะอ่านอย่างไร (ถ้าหากเอาไปให้ AI มันก็รู้แหละว่ามันคือ Protobuf 😂 แต่เราจะไม่เล่นง่ายขนาดนั้น) เราก็เริ่มจากการสํารวจ file byte by byte ก่อน โดยจะยกตัวอย่างมาจาก metadata.dat
00000000 0a 24 39 31 35 43 42 31 38 37 2d 45 36 32 45 2d |.$915CB187-E62E-|
00000010 34 33 39 45 2d 41 44 39 42 2d 44 43 35 30 44 41 |439E-AD9B-DC50DA|
00000020 33 41 41 43 31 33 1a 08 61 72 74 69 63 6c 65 77 |3AAC13..articlew|Tag Length Value
ก่อนอื่นต้องเข้าใจก่อนว่า Protobuf payload จะทํางานแตกต่างจาก protocol อื่นอยู่ระดับนึงคือ จะไม่มี Magic Code จึงทําให้แยกค่อนข้างยากกว่า Protocol อื่นหน่อย แต่จะยังคงมีลักษณะที่ชัดเจน ก็คือ 1 Payload จะประกอบด้วย 3 ส่วนคือ Tag, Length, Value
Tag
Tag ทําหน้าที่เหมือนป้ายกํากับของแต่ละ Payload ซึ่งประกอบด้วย 2 ส่วนคือ
- Field Number (เลขของ field ข้อมูล)
- Wire Type (ประเภทของข้อมูล)
สมมติว่าข้อมูลเราคือ 0x0A ซึ่งแปลงจาก HEX -> Binary ได้ 0b00001010 และเราสามารถแปลงได้ดังนี้
00001 | 010
^^^^^ ^^^
Field Wire Typeสามารถแปลงข้อมูลต่อไปว่า
- Field
00001->1- Field Number มีค่าเท่ากับ 1
- Wire Type
010->2- Wire Type = 2 มีค่าเท่ากับ ‘Length-delimited’ (หรือ Payload ที่ไม่ได้จํากัด Length) ซึ่งหมายความว่า byte ต่อไปต้องมีการระบุความยาวของ Payload หรือ
LEN
- Wire Type = 2 มีค่าเท่ากับ ‘Length-delimited’ (หรือ Payload ที่ไม่ได้จํากัด Length) ซึ่งหมายความว่า byte ต่อไปต้องมีการระบุความยาวของ Payload หรือ
Length (Optional)
Length หรือ LEN เป็นส่วนประกอบที่มี หรือ ไม่มีก็ได้ แต่จะใช้สําหรับ Payload ที่ไม่ได้จํากัดความยาว
ซึ่งในที่นี้ 0x24 = 36 หมายความว่าเราต้องอ่านต่ออีก 36 Bytes
Value
ส่วนนี้ ถ้าหากอยู่ใน Payload ที่เป็นแบบไม่จํากัดความยาว ในส่วนนี้จะเป็นส่วนที่แท้จริงของข้อมูล ซึ่งในที่นี้ก็จะอ่านยาวทั้งหมด 36 Bytes จนจบ
ก่อนจะไปต่อ
เมื่อเรารู้แล้วว่าข้อมูลส่วนนี้(*.dat) คือ Protobuf จริงๆ ส่วนต่อไปคือการไขข้อมูล Protobuf ซึ่งโดยปกติการจะอ่าน Protobuf ได้ จะต้องมี .proto หรือคู่มือเสียก่อน ซึ่งในเมื่อเราไม่มีข้อมูลนั้น เราก็เลยไขข้อมูลแบบดิบๆ โดยที่ไม่มีคู่มือ ซึ่งการจะอ่านข้อมูลนั้นได้ จึงต้องใช้ protoc แบบนี้
protoc --decode_raw < metadata.dat[BOX] metadata.dat raw decoded [ CLICK TO UNBOX ] [ CLICK TO CLOSE ]
1: "915CB187-E62E-439E-AD9B-DC50DA3AAC13"
3: "articlew"
4: "articlew"
6 {
1: 1785397999
2: 484794140
}
8 {
1: 1785398005
2: 949923992
}
11: 0หมายเหตุ:
articlewคือชื่อ Notebook ที่นํามาแงะ ซึ่งตั้งชื่อเอง มิใช่ชื่อที่มาจาก Freenotes แต่อย่างใด
แงะไฟล์ดิบ
จากในหัวข้อที่ผ่านมา เราได้พิสูจน์ทราบแล้วว่าไฟล์ *.dat นั้น คือไฟล์ Protobuf ทั้งหมด แต่เราไม่รู้ว่า Entity ไหนคือ Entity ไหน เราจะแก้ยังไง?
ง่ายมาก
เดาเอา
ไม่ได้พูดเล่น แต่ในเมื่อเราไม่มีคู่มือให้ใช้ วิธีการที่ง่ายที่สุดคือการหาความสัมพันธ์ระหว่างแต่ละไฟล์ ซึ่งจากการสังเกตพบว่า
ข้อสังเกต
- แต่ละ object มีค่า id unique ของตัวเอง ซึ่งเป็นค่า UUID
- แต่ละ UUID มี Relation ต่อกันไปมาตามชื่อไฟล์
.datเช่น Layout, Metadata, Document และ อื่นๆ - Index.db ดูเหมือนว่าจะเก็บข้อมูลเกี่ยวกับ OCR และ Object ประเภท Text ซึ่งไม่ใช่จุดสนใจของเรา
อาจจะยังไม่เห็นภาพ อันนี้คือภาพกราฟความสัมพันธ์เบื้องต้นโดยเป็นการแสดงความสัมพันธ์ของค่า UUID ต่างๆ
document.dat
จากกราฟ เราพบว่าแต่ละไฟล์มีความสัมพันธ์ต่อกันโดยมี document.dat เป็นเสมือนศูนย์กลางของ metadata ทั้งหมด เราจึงได้เริ่มจากการไขไฟล์ดังกล่าว โดยจะมีการ highlight เพื่อให้เห็นภาพของการเชื่อมโยงความสัมพันธ์ไปแต่ละไฟล์ของเอกสารชุดนี้
1: 2
2: "915CB187-E62E-439E-AD9B-DC50DA3AAC13" # Document UUID
4 {
# Page Entry 1: ตัวอย่างเชื่อมโยง PDF และ Cover Page
1 {
1: "AA553773-6919-4ECA-B97F-DAA2ADE15E06" # Page UUID
...
3 {
2: "2F8DA175-4F19-4ADA-9FD2-B2369CE08F77" # REF: PDFs/<uuid>.pdf
}
}
# Page Entry 2: ตัวอย่างเชื่อมโยง Stroke (.dat) และ OCR (.json)
1 {
1: "1AF3A213-07F5-45B4-BA87-E63E7B9AE6C9" # Page UUID
6: "3C892967-BC80-4C5F-B384-FB2045DEC9CA" # REF: FNPages/<uuid>.dat
8: "D0E31896-FAE8-460B-A9F8-69909A199688" # REF: OCR_v3/en-US/<uuid>/content.json
}
}(หมายเหตุ: ดู dump ฉบับเต็มได้ที่ ภาคผนวก)
Document_uuid Page_uuid Pdf_uuid FNPages_uuid OCR_uuid
เข้าใจว่าถ้าแค่อ่านโครงสร้างไฟล์ดิบของ document.dat อาจจะไม่สามารถทําความเข้าใจกับโครงสร้างของไฟล์ Freenotes ได้
เรามาดูภาพรวมของ document.dat กันก่อนว่าแต่ละองค์ประกอบสัมพันธ์กันอย่างไร:
Document (เอกสารโน้ตหลัก / document.dat)
│
├── Metadata (ข้อมูลทั่วไป เช่น ชื่อโน้ต, วันที่สร้าง / metadata.dat)
├── Layout (ขนาดกระดาษแต่ละหน้า เช่น A4 / layout.dat)
├── Pages (รายการหน้าทั้งหมดในเอกสาร)
│ │
│ └── Stroke (พิกัดลายเส้นเวกเตอร์แต่ละหน้า / FNPages/<uuid>.dat)
│
└── PDF (ไฟล์ PDF พื้นหลังที่ Import เข้ามา / PDFs/<uuid>.pdf)FNPages/<uuid>.dat
ไฟล์นี้เป็นไฟล์ที่น่าสนใจที่สุดจากทั้งหมด ถึงแม้ดูเผินๆอาจจะดูเหมือนไม่มีอะไร แต่ถ้าหากลองส่องดีๆจะพบว่า
0x3ff0000000000000 เป็น Hex Representation ตามมาตรฐาน IEEE-754 ของ Double Precision double point หรือ เลขทศนิยมลอยสองเท่า หรือ Double ที่มีค่าเท่ากับ 1.0
และไม่มีมีเพียงแค่ค่าสําหรับ 1.0 เท่านั้น แต่ยังมีทศนิยมอยู่ในอีกหลายจุดภายในไฟล์ ที่สามารถบ่งบอกถึงพิกัด X,Y ทําให้เราเชื่อมั่นได้เลยว่าไฟล์นี้คือไฟล์ที่เก็บ ลายเส้น(Strokes) อย่างแน่นอน
1: "3C892967-BC80-4C5F-B384-FB2045DEC9CA" # FNPages UUID
2: "1AF3A213-07F5-45B4-BA87-E63E7B9AE6C9" # Page UUID
3 {
1: "54C99B69-BB82-4936-A855-9354BAE8C45B"
2 {
1 {
1: "54C99B69-BB82-4936-A855-9354BAE8C45B"
2 {
1: 1 # Tool ID (1 = Ballpoint Pen)
2: 2377619967 # #8DB799FF Color Hex
}
3 {
1: 0x3ff0000000000000 # Scale X (double: 1.0)
4: 0x3ff0000000000000 # Scale Y (double: 1.0)
}
4 {
1: "2A59792B-ED5F-447D-9680-E0D230B07858" # Stroke Segment UUID
# Touch Point 1
2 {
1 {
1: 0x4064a0b4eb000000 # X (double: 165.022086)
2: 0x4064a842f20b1378 # Y (double: 165.258172)
}
3 {
1: 0x4002917f20000000 # Start Weight (double: 2.321045)
2: 0x4002917f20000000 # End Weight (double: 2.321045)
}
4: 0xbf90647fe0000000 # Delta Time ($\Delta t = -0.016008$s)
}
# Touch Point 2
2 {
1 {
1: 0x4064f20362e80000 # X (double: 167.562892)
2: 0x4064b466bc170e50 # Y (double: 165.637540)
}
...
}
}
}
}
}(หมายเหตุ: ดู dump ฉบับเต็มได้ที่ ภาคผนวก)
Document_uuid Page_uuid Pdf_uuid FNPages_uuid OCR_uuid
PDFs/Layout.dat
ในส่วนนี้อาจจะดูไม่ค่อยมีอะไร แต่หากเราลองแปลง 0x4082980000000000 และ 0x408a500000000000 ตามมาตรฐาน IEEE-754 จะได้ค่าเท่ากับ 595 x 842 ซึ่งในหน่วย pts จะเป็นขนาดโดยมาตรฐานของ Layout ขนาด A4 จึงได้ถือซะว่า 3->1->[1-2] เป็น width, height ไปโดยปริยาย
1: "915CB187-E62E-439E-AD9B-DC50DA3AAC13" # Document UUID
2: 0x4082980000000000 # Document Width (double: 595.0 pt)
# Page Layout (ตัวอย่าง 1 หน้า):
3 {
1 {
1: 0x4082980000000000 # Width (double: 595.0 pt)
2: 0x408a4cf81852b2c6 # Height (double: 841.62 pt - ขนาดมาตรฐาน A4)
}
5: "AA553773-6919-4ECA-B97F-DAA2ADE15E06" # Page UUID
}(หมายเหตุ: ดู dump ฉบับเต็มได้ที่ ภาคผนวก)
Document_uuid Page_uuid Pdf_uuid FNPages_uuid OCR_uuid
อื่นๆ
เนื่องจาก จุดประสงค์หลักของ Blog นี้คือการเจาะลึกเข้าไปในส่วนของการทํางานของ การเก็บ vector เส้นวาด
ถ้าหากท่านสนใจจะดูข้อมูลที่ถูกแกะแบบเต็ม สามารถตามเนื้อหาส่วนนี้ต่อได้ที่ ภาคผนวก ด้านล่างสุด
หลักการเชิง Computer Graphics
ในโลกของ Computer Graphics มีกราฟฟิกอยู่ 2 ประเภทคือ Raster Graphics และ Vector Graphics โดยที่
-
Raster Graphics
- เป็นการสร้างภาพด้วยการทาสีช่องสีเหลี่ยมเล็กๆ หรือที่เรียกว่า Pixel มาประกอบเข้าด้วยกันจบกลายเป็นภาพที่เรามองเห็น
- มี Loss ซึ่งหมายความว่าทุกการ scale อาจทําให้เกิดการสูญเสีย pixel บางส่วน
- ถูกใช้เป็นหลักในปัจจุบัน อย่างเช่นจอมอนิเตอร์ เป็นต้น
-
Vector Graphics
- เป็นการสร้างภาพที่เกิดจากการกําหนดจุดและพิกัดบนระบบพิกัดคาร์ทีเซียน ซึ่งประกอบด้วย จุด, เส้นโค้ง หรือรูปทรงเลขาคณิต
- เป็นแบบ Lossless หมายความว่าสามารถ scale ขนาดได้โดยไม่มีการเสียหาย
- ไม่สามารถสร้างภาพที่จรืง 100% ได้
ถ้าหากเคยใช้โปรแกรมวาดรูปทั่วไปอย่างเช่น Krita หรือ IbisPaint หรือ อื่นๆ ส่วนใหญ่ล้วนแต่จะใช้แบบ Raster Graphics เป็นค่าเริ่มต้นหรือไฟล์จําพวก .png .jpeg .gif ล้วนแล้วแต่จะเป็นประเภท Rector แต่บางโปรแกรมสามารถเปลี่ยน Layer เป็นประเภท Vector ได้ แต่ก็ไม่ได้หมายความว่าทุกอย่างจะเป็น Rector เลย ยังมีพวก .pdf ที่เป็นประเภท Vector
ซึ่งถ้าหากเรามองในมุมของ SA หากต้องการออกแบบแอพจดโน้ตด้วยลายมือ ที่โดยส่วนใหญ่จะเป็นการเขียนบน หน้า Template เปล่าๆ(สีขาว A4 etc.) หรือบนไฟล์ PDF ที่ import เข้ามา ซึ่งต้องรองรับการ
- Lasso เพื่อเคลื่อนย้าย
- การขยายขนาดโดยที่ไม่เสียข้อมูลใดๆ และดูต่อเนื่อง
- สามารถรองรับ Undo/Redo ได้
วิธีการที่ง่ายและตอบโจทย์ผู้ใช้มากที่สุดคือการออกแบบโครงสร้างการเก็บข้อมูลให้เป็นแบบ Vector กล่าวคือ แทนที่จะเก็บ Pixel by Pixel ซึ่งต้องเก็บทุกเสี้ยวอณู เราจะเก็บแค่พิกัดที่ลากผ่านต่อเนื่องก่อนจะยกปากกาขึ้น และจะเก็บทุกๆ thresholds (อย่าง Freenotes จะเก็บทุกราวๆ ~16ms) แล้วนํามาประกอบเป็นกราฟเส้นหลายๆ เส้นมากองรวมกันบนหน้ากระดาษตามขนาด pts ที่กําหนดตอนสร้าง และ $(0,0)$ มักจะเริ่มที่ฝั่งบนซ้าย บนระบบพิกัดคาร์ทีเซียน
ประกอบลายเส้นขึ้นมาจาก 0
หลังจากที่เราได้ทำการถอดรหัสพิกัดไบนารีจากไฟล์ FNPages/3C892967-BC80-4C5F-B384-FB2045DEC9CA.dat ในส่วนก่อนหน้าแล้ว เราสามารถนำค่าพิกัดดิบ $(x, y)$, สี Hex (#8DB799FF), ความหนาเส้น (2.321045 pt), และเวลาสัมพัทธ์ ($\Delta t$) ของทั้ง 6 จุดมาเรียนรู้ขั้นตอนการเรนเดอร์ลายเส้นตามลำดับขั้นได้ดังนี้:
จากผืนผ้าใบ จะเห็นรายละเอียดพิกัดของจุด Touch Events ทั้ง 6 จุดดังนี้:
- $P_0$ (Start Point): $(165.022, 165.258)$ | $\Delta t = -0.016008\text{ s}$
- $P_1$: $(167.563, 165.638)$ | $\Delta t = +0.004992\text{ s}$
- $P_2$: $(173.915, 166.528)$ | $\Delta t = +0.021992\text{ s}$
- $P_3$: $(182.461, 167.434)$ | $\Delta t = +0.037992\text{ s}$
- $P_4$: $(191.536, 167.749)$ | $\Delta t = +0.051000\text{ s}$
- $P_5$ (End Point): $(211.846, 166.677)$ | $\Delta t = +0.072000\text{ s}$
สร้างโค้งให้เส้นกราฟ
จากที่เราเริ่มเห็นโครงสร้างของ Vector ที่เราได้ประกอบขึ้นมาแล้วนั้น จะพบว่าเส้นระหว่างจุด $P_i$ และ $P_{i+1}$ เป็นเส้นตรงทั้งหมด ซึ่งทื่อ และไม่ตรงกับ ของจริงของเรา สิ่งที่เราขาด นั้นคือการสร้างเส้นโค้งระหว่างจุด $P_i$ และ $P_{i+1}$
สูตรพื้นฐานสําหรับ Computer Graphics สําหรับการแก้ปัญหานี้คือ Bézier curve โดยพิจารณาจากสูตร Linear Bézier
$${B} (t)=(1-t) {p} _{0}+t {p} _{1}, t\in [0,1].$$จากสูตรนี้ เส้นระหว่าง $P_i$ และ $P_{i+1}$ เป็นเพียงเส้นตรงทื่อๆ ไม่โค้งมนสมจริงตามลายมือธรรมชาติ
ทำหน้าที่สร้างได้เพียงเส้นตรงระหว่างจุดสองจุดเท่านั้น
การแก้ปัญหาด้วย Cubic Bézier (ดีกรี 3)
เพื่อสร้างเส้นโค้งที่ Smooth เราใช้ Cubic Bézier (ดีกรี 3) ซึ่งเพิ่มจุดควบคุม(ความชัน) (Control Points) P₁ และ P₂:
$$ B(t) = (1-t)^3 P_0 + 3(1-t)^2 t P_1 + 3(1-t) t^2 P_2 + t^3 P_3 $$- \(P_0\) = จุดเริ่มต้นของเส้นโค้ง (Start Point)
- \(P_1\) = จุดควบคุมที่ 1 (Control Point 1)
- \(P_2\) = จุดควบคุมที่ 2 (Control Point 2)
- \(P_3\) = จุดสิ้นสุดของเส้นโค้ง (End Point)
- \(t\) = ค่าพารามิเตอร์ โดย \(0 \le t \le 1\)
- \(B(t)\) = ตำแหน่งของจุดบนเส้นโค้งเมื่อกำหนดค่า \(t\)
การทำให้เส้นโค้งเรียบเนียน ($C^1$ Continuity)
เพื่อไม่ให้เกิดรอยหักตรงจุดเชื่อมต่อ จะต้องคํานวณ จุดกึ่งกลาง (Midpoint) เพื่อประกอบโค้งเส้งโค้งไม่มีความ smooth ที่สุด
$$ M_i = \frac{{P_i + P_{i+1}}}{2}$$ตัวอย่าง การหาจุดกลาง
- $P_0 = (0,0)$ (จุดเริ่มต้น)
- $P_1 = (4,8)$ (จุดพิกัดจริง)
- $P_2 = (12,4)$ (จุดพิกัดถัดไป)
คำนวณจุดกึ่งกลาง $M_1$ ระหว่าง $P_1(4,8)$ และ $P_2(12,4)$:
$$M_1 = \left( \frac{4+12}{2}, \frac{8+4}{2}\right) = (8,6)$$จะได้จุดควบคุม 4 จุดของเส้นโค้งนี้คือ:
- $C_0 = (0,0)$ [จุดเริ่มต้น]
- $C_1 = (4,8)$ [จุดควบคุมที่ 1: x1, y1]
- $C_2 = (8,6)$ [จุดควบคุมที่ 2: mid_x, mid_y]
- $C_3 = (8,6)$ [จุดสิ้นสุด: mid_x, mid_y]
นําไปแทนค่าในสมการ
$$ B(t) = (1 - t)^{3}C_0 + 3(1 - t)^2tC_1 + 3(1 - t)t^2C_2 + t^3C_3 $$โดยแทนค่า $C_i$ แยก $X,Y$ และแทน $t$ ด้วยค่าพารามิเตอร์ หรือ
# วาดเส้นโค้งดีกรี 3 ไปยังจุดกึ่งกลาง Mi
ctx.curve_to(x1, y1, mid_x, mid_y, mid_x, mid_y)คำสั่งนี้จะถูกแปลงเป็น PDF Operator y / c (Curveto) ในไฟล์ PDF โดยอัตโนมัติ ทำให้ได้เส้นลายมือเวกเตอร์ที่คมชัด $C^1$ Continuity และไม่แตกเป็นพิกเซลเมื่อซูมขยาย
ยังไม่จบแค่นี้ ถ้าหากเราไม่ได้มีเพียงแค่ 3 จุดแบบในชุดตัวอย่าง เราจะได้ $M_1 = (8,6)$ และไปหาจุดต่อไปได้เลย เช่น
- $P_1 = (4,8)$ (จุดพิกัดเดิม)
- $P_2 = (12,4)$ (จุดพิกัดปัจจุบัน)
- $P_3 = (20,10)$ (จุดพิกัดถัดไป)
เนื้อหาในส่วนนี้อาจจะค่อนข้างงงนิด…คนเขียนเองก็งง 5555555
สามารถเรียนรู้เพิ่มเติมได้ใน 6.837: Introduction to Computer Graphics (fall 2020) by Justin Solomon
คน Tools ที่ใช่ ถูกใจเสมอ
ในหัวข้อที่ผ่านมาเราได้ดําดิ่งระดับหนึ่งในการชุบชีวิต Vector เราขึ้นมาอีกครั้งและติดปีกโค้งให้กับมัน ซึ่งเมื่อเราเริ่มแกร่งวิชาแล้ว สิ่งที่เราจะทําต่อไปคือการนํามาใช้กับ Vector ที่มีความซับซ้อนขึ้น
ดังรูป
ถ้าหากมองแว็บแรก เราอาจจะมองว่ากราฟเราสวยและสมบูรณ์แบบแล้ว….แต่ช้าาาาาก่อน สิ่งที่เห็นอาจจะไม่ใช่สิ่งที่เป็น จะเชื่อมั้ยว่าเรายังไม่ได้ใกล้กับของจริงเลย :)
แต่เพื่อให้ให้ผู้อ่านไม่รู้สึกเหมือนกําลังตีกับความว่างเปล่า นี่คือภาพเฉลยตัวจริงเสียงจริง พึ่งแคปมาเลย
ถ้าหากเราสังเกตภาพเฉลย กับกราฟข้างบน เราอาจจะเห็นความคล้ายบางอย่าง นั่นก็คือ เสี้ยวตัว S กลับด้าน ถ้าหากบอกว่าเส้นสีดํา (ในกราฟ) คือเส้นเต็มของเรา แล้วที่เหลือคืออะไรล่ะ? นั่นคือสิ่งที่เราจะหาคําตอบต่อไปในเนื้อหาส่วนต่อไป
Ballpoint Pen
ถ้าหากมองเรากลับไปที่โครงสร้างของไฟล์ต้นฉบับของเรา เราจะพบจุดที่น่าสนใจอยู่จุดหนึ่ง นั่นคือ block นี้
2 {
1: 1
2: 2377619967 # #8DB799FF Color Hex
}(หมายเหตุ: ดู dump ฉบับเต็มได้ที่ ภาคผนวก)
เราจะพบว่า 2377619967 ซึ่งสามารถแปลงเป็น Hex ได้ #8DB799FF ซึ่งเป็นรหัสสีที่ตรงกับสีปากกาเราพอดี
นี่คือข้อสำคัญที่ยืนยันว่า บล็อกข้อมูลนี้คือตัวกำหนดคุณลักษณะของเส้นปากกา เช่น สี และประเภทหัวปากกา
ถัดมาจากคุณลักษณะของสีและประเภทหัวปากกา เมื่อเราขุดลึกลงไปต่อ ในส่วนที่เก็บ ข้อมูลจุดพิกัดและน้ำหนักเส้น ของ Ballpoint Pen เราจะพบกับโครงสร้างดังนี้:
4 {
1: "CBE801A9-59C1-4698-B9EC-61B12D3E0422"
2 {
1 {
1: 0x4070da82b3c37a63 # (double: 269.6568865804606)
2: 0x406886d5b4a4c882 # (double: 196.21308480620384)
}
3 {
1: 0x4015d72cc0000000 # (double: 5.459999999999999)
2: 0x4015d72cc0000000 # (double: 5.459999999999999)
}
4: 0xbf8c87e0c0000000 # (double: -0.013912320137023926)
}(หมายเหตุ: ดู dump ฉบับเต็มได้ที่ ภาคผนวก)
ซึ่งส่วนนี้จะเป็นสิ่งเดียวกันกับใน หัวข้อนี้
Eraser
หนึ่งใน Function ที่สําคัญที่สุดในแอพโน้ต/วาดรูป ก็คือยางลบ หรือความสามารถในการลบสิ่งที่ไม่ต้องการออกจากลายเส้น
ถ้ามองจากมุมมองของผู้ใช้งาน ยางลบเป็นเรื่องที่ดูเรียบง่าย แค่ลากผ่านตรงไหน ตรงนั้นก็หายไป แต่ในทางเทตนิค ยางลบถือเป็นหนึ่งในระบบที่ซับซ้อนที่สุด เพราะเบื้องหลังการลบที่ดูธรรมดานั้น ได้ซ่อนกลไกทางการจัดข้อมูลและเทคนิคเอาไว้
โดยในส่วนของ FreeNotes จะมีการจัดการการลบกับลายเส้นอยู่ 2 แบบหลักๆ คือ:
- ทุกส่วนของลายเส้นโดนลบหมด
- บางส่วนของลายเส้นโดนลบ
โดยโครงสร้างของข้อมูลที่มีข้อมูลเส้นยางลบจะเป็นดังนี้:
3 {
1: "8EB54CB6-5BBD-4F94-B628-8E9C9E3481CD"
2 {
1 {
1: "8EB54CB6-5BBD-4F94-B628-8E9C9E3481CD"
2 {
1: 1
2: 377420543
}
3 {
1: 0x3ff0000000000000
4: 0x3ff0000000000000
}
4 {
1: "15BA1FE2-22AC-4370-81ED-D79B6602183B"
2 {
1 {
1: 0x40705c0338360178
2: 0x403e59082a5b1a00
}
3 {
1: 0x4002487100000000
2: 0x4002487100000000
}
4: 0xbf98beaa80000000
}
...
5 {
1: "57363165-BD86-49F6-9A04-3EA2C44B5786"
2 {
1: 101
}
3 {
1: 0x3ff0000000000000
4: 0x3ff0000000000000
}
4 {
1: "BF9BCA51-959B-4123-AC9E-6128FA6FAC0F"
2 {
1 {
1: 0x406fa3251751e1f4
2: 0x40493b125eff6980
}
3 {
1: 0x401469e640000000
2: 0x401469e640000000
}
4: 0xbf8f712ce0000000
}โดยจากโครงสร้าง Protobuf ข้างต้น สามารถอธิบายองค์ประกอบสำคัญได้ดังนี้:
-
field_4: คือพิกัดลายเส้นปากกาหลักที่ผู้ใช้เขียนขึ้นมาตั้งแต่แรก (เช่น สีปากกา, ขนาดเส้น, และแรงกด Stylus) ซึ่งข้อมูลในfield_4นี้จะถูกเก็บรักษาไว้แบบสมบูรณ์ 100% ไม่ถูกตัดทอนพิกัดทิ้ง ตราบใดที่ทุกพิกัดในfield_4ไม่ได้ถูกทับโดย ลายเส้นของยางลบ(field_5) -
field_5: คือพิกัดทางเดินของหัวยางลบ ซึ่งมีคุณลักษณะพิเศษคือจะระบุ Tool ID เป็น101(Eraser Brush) อยู่ภายในเสมอ โดย Tool ID 101 นี้จะปรากฏอยู่เฉพาะในfield_5เท่านั้น ไม่เคยปรากฏในfield_2ของลายเส้นหลัก
โดยแต่ละ Object จะมี field_4 field_5 ของตัวเองโดยเป็นอิสระจากกันและถ้าหาก ลายเส้นยางลบปาดผ่านเส้นไหน พิกัดของเส้นยางลบนั้นจะถูกก็อปไปอยู่ field_5 ของเส้นนั้นด้วย (ผ่าน 3 Strokes ก็ field_5 3 Strokes)
Lasso
ในการเขียนโน้ต Function หนึ่งที่ขาดแคลนไม่ได้คือ Lasso แล้ว Lasso คืออะไร? Lasso หรือ Freeform Selection หรือเอาง่ายๆ คือการเลือกนั้นแหละ ใน Context ของแอพโน้ตคือการเลือกเพื่อที่จะทําอะไรบางอย่าง เช่น เลือกเพื่อขยายขนาด เลือกเพื่อเคลื่อนย้าย โดยไม่สน Layer ซึ่งถ้าหากเราได้อ่านในส่วนหัวข้อ Eraser อาจจะเริ่มเข้าใจเหตุผลมากขึ้นว่าทําไมต้องมีการให้ Object ของยางลบ อยู่แยกกันไปตาม Object ต่างๆที่ลายมือไปโดน ไม่ใช่การใช้รวมกัน
เพราะ เมื่อเราต้องการ ขยายหรือย้าย สิ่งใดออกไป ลายยางลบจะถูกเคลื่อนย้ายไปด้วย ทําให้ลายเส้นไม่เปลี่ยนนั่นเอง โดยก่อนจะเข้าสู่เนื้อหาส่วนนี้ในเชิงลึกภายหลัง เรามาส่องโครงสร้างกันก่อน
ก่อน :
1: "0A7ACF32-9122-4274-A932-EE34D88740C2"
2: "EF7159AE-7834-41FC-B92A-AAD4F6B0DCF1"
3 {
1: "5F7A6342-14E4-4005-8A7D-1EAA221DD7BF"
2 {
1 {
1: "5F7A6342-14E4-4005-8A7D-1EAA221DD7BF"
2 {
1: 1
2: 377420543
}
3 {
1: 0x3ff0000000000000 # (double: 1.0)
4: 0x3ff0000000000000 # (double: 1.0)
}
4 {
1: "CBE801A9-59C1-4698-B9EC-61B12D3E0422"
2 {
1 {
1: 0x4070da82b3c37a63 # (double: 269.6568865804606)
2: 0x406886d5b4a4c882 # (double: 196.21308480620384)
}
3 {
1: 0x4015d72cc0000000 # (double: 5.459999999999999)
2: 0x4015d72cc0000000 # (double: 5.459999999999999)
}
4: 0xbf8c87e0c0000000 # (double: -0.013912320137023926)
}
...
3 {
1: 0x4015d72cc0000000 # (double: 5.459999999999999)
2: 0x4015d72cc0000000 # (double: 5.459999999999999)
}
4: 0x3fea4487c0000000 # (double: 0.8208653926849365)
}
3 {
1: 1785729853
2: 191121101
}
6: 4
}
}
2: "EF7159AE-7834-41FC-B92A-AAD4F6B0DCF1"
3: "EF7159AE-7834-41FC-B92A-AAD4F6B0DCF1"
}
}หลัง:
1: "0A7ACF32-9122-4274-A932-EE34D88740C2"
2: "EF7159AE-7834-41FC-B92A-AAD4F6B0DCF1"
3 {
1: "5F7A6342-14E4-4005-8A7D-1EAA221DD7BF"
2 {
1 {
1: "5F7A6342-14E4-4005-8A7D-1EAA221DD7BF"
2 {
1: 2
2: 377420416
}
3 {
1: 0x3ff0000000000000 # (double: 1.0)
4: 0x3ff0000000000000 # (double: 1.0)
5: 0xc055e6da97516fe8
6: 0x406008b4c3505ac0
}
4 {
1: "32CD8D60-9A6D-4B91-AA07-3626A60C777B"
2 {
1 {
1: 0x4074b0fb38a35ae8 # (double: 331.06142247294666)
2: 0x406c994a7d67f8e0 # (double: 228.7888842988145)
}
3 {
1: 0x402e16a260000000 # (double: 15.044208)
2: 0x402e16a260000000 # (double: 15.044208)
}
4: 0xbf843c0d80000000 # (double: -0.0098801851272583)
}
...
3 {
1: 0x402e16a260000000 # (double: 15.044208)
2: 0x402e16a260000000 # (double: 15.044208)
}
4: 0x3fea4487c0000000 # (double: 0.8208653926849365)
}
3 {
1: 1785729853
2: 191121101
}
5: 0x400e28aba8559ead # (double: 3.7698668981162904)
6: 4
}
}
2: "64E033B5-37F4-4315-A4E2-D532689960F6"
3: "64E033B5-37F4-4315-A4E2-D532689960F6"
}
}ใน Freenotes ถ้าหากตําแหน่งถูกเคลื่อนย้าย พิกัดหลักจะไม่ถูกเปลี่ยน แต่จะเป็นการ offset $X,\text{ } Y$ ไปตามทิศที่ถูกเคลื่อนย้ายแทน แต่ในทางกลับกัน ถ้าหากเป็นการเปลี่ยนขนาด พิกัดจะถูกเปลี่ยน พร้อมทั้งขนาดรัศมีของ $X, \text{ } Y$ ด้วย (ความหนาของเส้น) โดยจะยังมีการเก็บเลข Scale ล่าสุดเอาไว้
Highlighter
เมื่อเราได้สมมติฐานจากการสังเกตบล็อกข้อมูลของปากกาหัวกลม (Ballpoint Pen) ว่าตัวเลขใน field_2
คือรหัสสี Hexadecimal และ field_1
คือประเภทหัวปากกา
เราจึงทดลองเปิดดูข้อมูลของ ปากกาไฮไลต์ (Highlighter) ที่วาดลงไปในอีกหน้า:
3 {
1: "1494B805-9F85-4F16-B997-61C6C3D1A3A6"
2 {
1 {
1: "1494B805-9F85-4F16-B997-61C6C3D1A3A6"
2 {
1: 2
2: 1440180352 # #55D76990 Color Hex
}
...
3 {
1: "E53E9C9D-110F-433B-A86F-05863135D8AA"
2 {
1 {
1: "E53E9C9D-110F-433B-A86F-05863135D8AA"
2 {
1: 2
2: 4291768192 # #FFCF2F80 Color Hex
}ถอดรหัสออกมาจะได้ค่าสี สีเหลืองไฮไลต์แบบโปร่งแสง:
0xFF CF 2F 80
- R (Red): 0xFF = 255
- G (Green): 0xCF = 207
- B (Blue): 0x2F = 47
- A (Alpha): 0x80 = 128 (โปร่งแสงประมาณ 50%)
อ่านต่อในหัวข้อแยกต่อไป
[Highlight] การเรนเดอร์ทับซ้อนของสีไฮไลต์
เมื่อสกัดรหัสสีโปร่งแสง (Alpha 50%) ของปากกาไฮไลต์ได้แล้ว ความท้าทายถัดไปคือการจำลองการลากเส้นไฮไลต์ทับลงบนข้อความให้เหมือนกับการใช้ปากกาไฮไลต์จริงบนแอพ รวมทั้งการจัดการกับ Alpha ใน RGBA ให้เหลือแต่ RGB เพราะ Pixel ของหน้าจอแสดงผลได้เพียงค่าสี RGB ทึบแสงค่าเดียวต่อจุด ไม่มีกลไกแสดงความโปร่งแสง (Alpha) ได้จริง ค่า Alpha จึงต้องถูกคำนวณผสมรวมเข้ากับพื้นหลังจนเหลือแค่ RGB ล้วนๆ ก่อนส่งขึ้นจอ และ ท้ายที่สุดคือการแปลง RGB ให้ตรงกับมาตรฐานของ Monitor หรือ sRGB
ในเอนจินกราฟิก หากลากสีไฮไลต์ทับกัน จะทําให้เกิดสีไฮไลต์วางทับระหว่าง Source และ Background โดยการทับกับทําให้เกิดการผสม หรือที่เรียกว่า Blending
โดยสองทฤษฎี Compositing ที่เราจะ Cover ในบทความนี้คือ การผสม แบบ Multiply และแบบ Source Over ซึ่งเหล่านี้เป็นเพียงหนึ่งในหลายทฤษฎีผสมสีทาง Computer Graphics ที่เราหยิบมาใช้จากการสังเกตเทียบกับภาพจริงจากแอพ Freenotes (สามารถอ่านต่อได้ที่นี่) ซึ่งเนื้อหาในส่วนการผสมจะถูกเจาะลึกต่อไปในส่วนต่อไปของบทความ
ในบทความนี้จะใช้สัญลักษณ์ตลอดทุกสมการดังนี้:
- $C_s, c_s, \alpha_s$ : ค่าสีจริง, สีแบบ Premultiplied ($c_s = C_s \cdot \alpha_s$), และค่า Alpha ของ สีบน (Source Layer)
- $C_b, c_b, \alpha_b$ : ค่าสีจริง, สีแบบ Premultiplied ($c_b = C_b \cdot \alpha_b$), และค่า Alpha ของ สีล่าง (Backdrop Layer)
- $c_o, \alpha_o$ : ค่าสีแบบ Premultiplied ผลลัพธ์ และค่า Alpha รวมผลลัพธ์
- $C_{\text{final}}$ : ค่าสีจริงผลลัพธ์หลังถอด Alpha ($C_{\text{final}} = \frac{c_o}{\alpha_o}$)
[ไฮไลต์ทับกัน] 1. สมการการผสมสีแบบ Multiply (Porter-Duff Multiply Blend Mode)
ระบบจะไม่ใช้วิธีการวางสีทับ แต่จะใช้การคูณค่าสีพิกเซลระหว่างสีไฮไลต์และสีน้ำหมึกดั้งเดิม ตามทฤษฎีการผสมสีสากลในคอมพิวเตอร์กราฟิก (Blend modes)
ซึ่งถ้าหากเทียบในมุมมองที่อาจจะเห็นภาพขึ้น อาจจะเทียบได้กับ CAIRO_OPERATOR_MULTIPLY 12
สมการการคูณค่าสีโดยตรง (กรณีไม่คิดค่า Alpha):
$$c_o = c_s \cdot c_b$$ทำให้ภาพผลลัพธ์ในส่วนที่ Overlay กันจะเป็นสีที่มืดกว่า Input ทั้งสอง Input ที่ Overlay กัน
ตัวอย่าง
การผสมสี #55D76990 (สีบน $c_s$) กับ #FFCF2F80 (สีล่าง $c_b$) ด้วย Blend mode Multiply (กรณีคิดเฉพาะค่าสี RGB โดยไม่นำ Alpha มาคิด):
| ช่องสี | สีบน ($c_s$) | สีล่าง ($c_b$) | Multiply ($c_o = c_s \cdot c_b$) | ผลลัพธ์ Hex |
|---|---|---|---|---|
| R (Red) | 0x55 (0.333) | 0xFF (1.000) | 0.333 × 1.000 = 0.333 | $(0.333/1)*255=85$ ปัดขึ้น = 0x55 |
| G (Green) | 0xD7 (0.843) | 0xCF (0.812) | 0.843 × 0.812 = 0.684 | $(0.684/1)*255=174$ ปัดขึ้น = 0xAE |
| B (Blue) | 0x69 (0.412) | 0x2F (0.184) | 0.412 × 0.184 = 0.076 | $(0.076/1)*255=19$ ปัดขึ้น = 0x13 |
ซึ่งสีผสมที่ได้จะมีค่า #55AE13
แต่ในสูตรนี้เราไม่ได้นํา ค่า alpha มาคิดด้วย ซึ่งในความเป็นจริงจะต้องมีการนําค่า alpha (0.0-1.0) มาคิดด้วย ซึ่งจะต้องแปลงสูตรดังนี้
โดยในที่นี้ สีพิกเซลจะถูกประมวลผลในรูปแบบ Premultiplied Alpha (คือการนำค่าสี $C$ มาคูณกับค่าความโปร่งแสง $\alpha$ ล่วงหน้า: $c = C \cdot \alpha$)
ตามสูตร Porter-Duff แบบ Source Over เพื่อคํานวณหา Alpha สะสมรวม ($\alpha_o$) ของทั้งสองเลเยอร์ สามารถมองเป็นภาพ Venn Diagram
การรวมกันของเซตความทึบแสงได้ดังนี้:
เมื่อแทนค่า Alpha ($\alpha$) ลงในสมการเซต:
$$ \alpha_o = \alpha_s + \alpha_b - (\alpha_s \cdot \alpha_b) $$$$ \alpha_o = \alpha_s + \alpha_b (1 - \alpha_s) $$โดยที่
- $\alpha_o$ [ค่าความทึบแสงรวมผลลัพธ์: Alpha Output ที่ปรากฏตรงจุดซ้อนทับ]
- $\alpha_s$ [ค่าความทึบแสงเลเยอร์บน: Alpha Source ของเส้นชั้นบนสุด]
- $\alpha_b$ [ค่าความทึบแสงเลเยอร์ล่าง: Alpha Backdrop ของเส้นหรือพื้นหลังชั้นล่าง]
- $(1 - \alpha_s)$ [สัดส่วนความโปร่งแสงที่เหลือ: ยอมให้เลเยอร์ล่างส่องสว่างลอดผ่านขึ้นมา]
จากนั้น นำค่าสี Premultiplied ($c = C \cdot \alpha$) เข้าสู่สมการการผสมสีแบบ Multiply ซึ่งพจน์ตรงกลางที่ซ้อนทับกันจะเกิดการตัดกันของตัวแปร $\alpha$ เหลือเพียง:
$$ c_s \cdot c_b $$มารวมกับแสงที่ส่องผ่านจากขอบทั้งสองฝั่ง:
$$ c_o = (c_s \cdot c_b) + c_s \cdot (1 - \alpha_b) + c_b \cdot (1 - \alpha_s) $$เมื่อคำนวณเสร็จสิ้น จะถอดค่าสีกราฟิกแบบ Premultiplied ($c_o$) กลับเป็นค่าสีปกติสำหรับแสดงผลบนหน้าจอ โดยนำมาหารด้วยค่า Alpha รวม ($\alpha_o$):
$$ C_{\text{final}} = \frac{c_o}{\alpha_o} $$เมื่อแทนค่าสีบน #55D76990 ($\alpha_s = 0.565$) กับสีล่าง #FFCF2F80 ($\alpha_b = 0.502$):
| ช่องสี | สีบน ($c_s$) | สีล่าง ($c_b$) | Premultiplied Multiply ($c_o$) | ค่าสีจริงหลังถอด Alpha ($C_{\text{final}} = \frac{c_o}{\alpha_o}$) |
|---|---|---|---|---|
| R (Red) | $0.188$ | $0.502$ | $(0.188 \cdot 0.502) + 0.188(1-0.502) + 0.502(1-0.565) = \mathbf{0.406}$ | $\frac{0.406}{0.783} = 0.519 \rightarrow$ 0x84 |
| G (Green) | $0.476$ | $0.408$ | $(0.476 \cdot 0.408) + 0.476(1-0.502) + 0.408(1-0.565) = \mathbf{0.609}$ | $\frac{0.609}{0.783} = 0.777 \rightarrow$ 0xC6 |
| B (Blue) | $0.233$ | $0.092$ | $(0.233 \cdot 0.092) + 0.233(1-0.502) + 0.092(1-0.565) = \mathbf{0.178}$ | $\frac{0.178}{0.783} = 0.227 \rightarrow$ 0x3A |
| A (Alpha) | $0.565$ | $0.502$ | $\alpha_o = 0.565 + 0.502 \cdot (1 - 0.565) = \mathbf{0.783}$ | $0.783 \times 255 = 199 \rightarrow$ 0xC7 |
ซึ่งรหัสสีผลลัพธ์สุดท้ายที่เรนเดอร์ปรากฏบนหน้าจอคือ #84C63AC7
แต่ถ้าหากเทียบกับภาพเฉลยของเรานั้น สีจะไม่ตรงกันหรือใกล้เคียงกันเลย เพราะใน Freenotes หรือ Goodnotes หรือแอป note โดยทั่วไปจะไม่ใช้การผสมสีแบบ Multiply เพราะอาจทําให้สีโน้ตเพี้ยนได้ เราจึงต้องใช้สูตรผสมสีในรูปแบบต่อไป
[ไฮไลต์ทับกัน] 2. สมการการผสมสีแบบ Source Over (Porter-Duff Normal Blend Mode)
หลักการของ Normal Blending คือการนำ “สี Source” ($c_s$) วางทับ “สี Background” ($c_b$) โดยคิดค่าน้ำหนักตามความโปร่งแสง ($\alpha_s$) ของ Source เป็นหลักนี่คือภาพที่สร้างขึ้นใหม่โดยใช้ Normal Blending (Source Over)13 กับค่าสีเดิมและเพิ่มความโปร่งแสง
| ช่องสี | สีบน ($c_s$) | สีล่าง ($c_b$) | Premultiplied Source Over ($c_o = c_s + c_b(1-\alpha_s)$) | ค่าสีจริงหลังถอด Alpha ($C_{\text{final}} = \frac{c_o}{\alpha_o}$) |
|---|---|---|---|---|
| R (Red) | $0.188$ | $0.502$ | $0.188 + 0.502 \cdot (1 - 0.565) = \mathbf{0.406}$ | $\frac{0.406}{0.783} = 0.519 \rightarrow$ 0x84 |
| G (Green) | $0.476$ | $0.408$ | $0.476 + 0.408 \cdot (1 - 0.565) = \mathbf{0.653}$ | $\frac{0.653}{0.783} = 0.834 \rightarrow$ 0xD5 |
| B (Blue) | $0.233$ | $0.092$ | $0.233 + 0.092 \cdot (1 - 0.565) = \mathbf{0.273}$ | $\frac{0.273}{0.783} = 0.349 \rightarrow$ 0x59 |
| A (Alpha) | $0.565$ | $0.502$ | $\alpha_o = 0.565 + 0.502 \cdot (1 - 0.565) = \mathbf{0.783}$ | $0.783 \times 255 = 199 \rightarrow$ 0xC7 |
สรุปรหัสสีผลลัพธ์สุดท้าย: #84D559C7 (สีเขียวตองอ่อนพาสเทล)
[ไฮไลต์ทับกัน] ตกผลึก: ทําไมเราใช้ Source Over แทนที่จะเป็น Multiply?
จากที่เราได้ทดลองคํานวณสูตรการแก้ปัญหาเรื่องที่ ไฮไลต์ทับกัน ทั้งสองสูตร แล้วสูตรไหนดีกว่ากัน?
เราว่าไม่มีสูตรไหนดีกว่ากัน เพราะเอาจริงๆ มันก็ใช้ได้ทั้งสองนั้นแหละในช่วง Engineering/Designing Decisions เพราะมันตอบโจทย์ผู้ใช้ที่ต่างกัน เรามองว่า ทั้งสองสูตรสามารถนําไปใช้กับแอพโน้ตได้ แต่แล้วแต่ Developer ว่าจะทําแบบไหน ซึ่งแต่ละทางเลือกมี Trace-off ของตัวเอง:
-
Multiply: สีจะเข้มขึ้นทุกครั้งที่มีการซ้อนทับ เหมาะกับงานที่อยากให้เห็นความหนาแน่นของการ layer ชัดเจน (เช่นโปรแกรมวาดรูปทั่วไป)
-
Source Over: สีของเส้นน้ำหมึกเดิมจะไม่เปลี่ยน ต่อให้มีไฮไลต์ซ้อนกันกี่ชั้นก็ตาม เพราะ Source Over คิดน้ำหนักจาก alpha ของ highlighter (ชั้นบนสุด) เป็นหลัก ไม่ใช่สะสมความเข้มจากทุกชั้น
สำหรับแอพจดโน้ตอย่าง Freenote จะเลือกใช้แบบ Source-Over เพราะเหมาะสมกว่า และ จุดประสงค์หลักของการไฮไลต์คือเน้นข้อความ ไม่ใช่เปลี่ยนสีลายมือของผู้ใช้ ถ้าใช้ Multiply ยิ่งไฮไลต์ซ้อนกันมาก สีน้ำหมึกเดิมจะยิ่งมืดลงเรื่อยๆ
กําจัด Alpha ออกจากรหัสสี
ถึงแม้เราจะบอกว่าสีนี้เป็นสีที่ถูก แต่ถ้าหากลองทํา Color Pickup ใน Browser จะยังพบว่ารหัสสียังไม่ตรงอยู่
สาเหตุเกิดจากว่า การคํานวณของเรายังไม่ได้นับ Layer ล่างสุด นั่นคือหน้ากระดาษขาว (#FFFFFF) ซึ่งใน Browser จะประมวลผลให้เรียบร้อย
ซึ่งถ้าหากต้องการค่าสุดท้ายจริงๆ จะต้องคํานวณ สีสําหรับหน้าจอ ($C_{\text{screen}}$) เสียก่อน เพราะตัวหลอดภาพ RGB บนหน้าจอต้องส่งพลังงานแสงออกมาเต็ม $100\%$ โดยจากสูตร Source Over เมื่อวางสีผลลัพธ์ ($c_o, \alpha_o$) ลงบนพื้นหลังกระดาษขาว ($C_{\text{paper}} = 1.0$):
$$C_{\text{screen}} = c_o + C_{\text{paper}} \cdot (1 - \alpha_o)$$ซึ่งเทียบกับกระดาษเปล่า (สีขาว #FFFFFF) ที่มีค่าสี $C_{\text{paper}} = 1.0$:
แทน $c_o = C_{\text{final}} \cdot \alpha_o$ เข้าไปในสมการ:
$$C_{\text{screen}} = C_{\text{final}} \cdot \alpha_o + (1 - \alpha_o)$$เมื่อจัดรูปสมการ จะได้สูตรคำนวณค่าสีจริงบนหน้าจอ ($C_{\text{screen}}$):
$${C_{\text{screen}} = c_o + (1 - \alpha_o)}$$การแปลงสเกลเป็นพิกเซล 8-บิต ($0 - 255$)
เนื่องจากพิกเซลสีบนจอภาพและ Color Picker ในเบราว์เซอร์จัดเก็บค่าสี RGB แต่ละช่องด้วยระบบเลข 8 บิต (8 bits per channel) ที่มีค่าระหว่าง $0$ ถึง $255$ (0xFF) เราจึงต้องนำค่า $C_{\text{screen}}$ ในสเกล $0.0 - 1.0$ มาคูณด้วย $255$:
เมื่อแทนค่า $c_o$ และ $\alpha_o = 0.783$ (แสงจากกระดาษขาวทะลุผ่านได้ $1 - 0.783 = 0.217$):
| ช่องสี | สี Premultiplied ($c_o \times 255$) | แสงกระดาษขาวส่องผ่าน ($(1 - \alpha_o) \times 255$) | ค่าพิกเซลรวมบนหน้าจอ ($C_{\text{screen}} \times 255$) | Hex |
|---|---|---|---|---|
| R (Red) | $0.406 \times 255 = 103.5$ | $0.217 \times 255 = 55.3$ | $103.5 + 55.3 = \mathbf{158.8} \approx 159$ | 0x9F |
| G (Green) | $0.653 \times 255 = 166.5$ | $0.217 \times 255 = 55.3$ | $166.5 + 55.3 = \mathbf{221.8} \approx 222$ | 0xDE |
| B (Blue) | $0.273 \times 255 = 69.6$ | $0.217 \times 255 = 55.3$ | $69.6 + 55.3 = \mathbf{124.9} \approx 125$ | 0x7D |
สี Output สำหรับบนหน้าจอจะมีรหัสคือ #9FDE7D ซึ่งอาจจะไม่ตรงกับ Color Picker ใน Browser ต่างๆ (เช่น #A8DF7E) เกิดจากการที่ มาตรฐานของ display ในปัจจุบันจะใช้ color space sRGB (Standard RGB) เป็นมาตรฐานหลัก เพราะสายตามนุษย์นั้นไวต่อแสงและเงาดํา sRGB จึงเป็นการบีบอัด Linear RGB เพื่อเก็บแสงและเงาดําให้มากที่สุด โดยตามมาตรฐาน Gamma จะมีค่าเท่ากับ $2.2$
คํานวณหาสีสําหรับหน้าจอ
ตามที่กล่าวมาข้างต้น เนื่องจากปัจจุบันค่าสีของเรา ถึงแม้จะผสมสีแบบมี alpha ผสมแล้ว และ กำจัด alphaแล้วนั้น ยังคงไม่เพียงพอสําหรับ display ที่ใช้ sRGB ไม่ใช่ RGB เชิงเส้น ($C_{\text{screen}}$) ที่เราได้ค่าออกมา
เราจึงต้องทำการแปลงค่าสีจาก Linear RGB ($C_{\text{screen}}$) ไปเป็น sRGB ($C_{\text{sRGB}}$) ผ่านกระบวนการ Gamma Correction ($\gamma = 2.2$) 6
สมการแปลง Linear RGB ไปเป็น sRGB
ตามมาตรฐาน IEC 61966-2-17 การแปลงค่าสีเชิงเส้น $C_{\text{screen}}$ (สเกล $0.0 - 1.0$) เป็น $C_{\text{sRGB}}$ คำนวณได้จาก:
$$C_{\text{sRGB}} = \begin{cases} 12.92 \cdot C_{\text{screen}}, & C_{\text{screen}} \le 0.0031308 \\ 1.055 \cdot C_{\text{screen}}^{1/2.2} - 0.055, & C_{\text{screen}} > 0.0031308 \end{cases}$$หรือประมาณการอย่างง่ายด้วยสมการ Gamma Power Law:
$$C_{\text{sRGB}} \approx \left( C_{\text{screen}} \right)^{\frac{1}{2.2}}$$| ช่องสี | ค่า Hex | ค่า Linear ($C_{\text{screen}} = \text{Hex}/255$) | ผ่าน Gamma $2.2$ ($C_{\text{sRGB}} = C_{\text{screen}}^{1/2.2}$) | ค่าพิกเซลรวมบนหน้าจอ ($C_{\text{sRGB}} \cdot 255$) | Hex |
|---|---|---|---|---|---|
| R (Red) | 0x9F (159) | 159 / 255 = 0.623529 | (0.623529)^0.4545 = 0.658764 | $0.658764 * 255 = \mathbf{167.98} \approx 168 $ | 0xA8 |
| G (Green) | 0xDE (222) | 222 / 255 = 0.870588 | (0.870588)^0.4545 = 0.938927 | $0.938927 * 255 = \mathbf{223.42} \approx 223$ | 0xDF |
| B (Blue) | 0x7D (125) | 125 / 255 = 0.490196 | (0.490196)^0.4545 = 0.494164 | $0.494164 * 255 = \mathbf{126.01} \approx 126 $ | 0x7E |
ท้ายที่สุด รหัสสีจะมีค่าเท่ากับ #A8DF7E ซึ่งเกิดจากการผสมแบบ Source Over สองครั้ง
- ครั้งแรกเทียบกับสีที่ถูกทับ
- ครั้งที่สองเทียบกับ กระดาษขาว (Background ของเรา)
และกําจัด Alpha ด้วยการบีบอัด Alpha ให้กระจายเข้าไปโดยไม่เสียคุณภาพเพื่อให้จอสามารถแสดง RGB ได้ทั้งยังคงความเหมือนเดิมของสีอยู่ และท้ายที่สุดคือการแปลงจาก Linear RGB ไปสู่ sRGB เพื่อให้สีตรงกันกับภาพที่ผู้ใช้เห็นจริง
และ ถ้าหาก Color Pick สีที่ overlap จาก browser แล้วนั้น รหัสสีจะมีค่าตรงกัน
เปลี่ยนจากปลายพู่กันที่ใช้สร้างสีสัน
ย้อนกลับมานิดนึง…..เนื่องจากเราลืมพูดถึงเรื่อง การคํานวณขนาดของเส้นปากกาใน Freenotes โดยใน Freenotes มีเพียงตัวแปรเดียวที่สามารถปรับความหนาบางของเส้นได้ คือ Thickness ซึ่งมี range ระหว่าง $1.0-15.0$
ซึ่งใน Protobuf ที่ decoded ออกมานั้น จะมีค่าดังนี้สําหรับ $1.0$ และ $15.0$
4 {
1: "C170DC0C-03D6-4826-A674-030C1F37E9A0"
2 {
1 {
1: 0x4077c9d0ab6541b0 # (double: 380.613445)
2: 0x405c7d60c9f44148 # (double: 113.959033)
}
3 {
1: 0x4002da8d40000000 # (double: 2.356715)
2: 0x4002da8d40000000 # (double: 2.356715)
}
4: 0xbf8c87e0c0000000 # (double: -0.013912320137023926)
}4 {
1: "031183C5-74C2-4061-B2E7-012C73A3AE81"
2 {
1 {
1: 0x40777122f0175b79 # (double: 375.07103)
2: 0x405a52287e36fab0 # (double: 105.283721)
}
3 {
1: 0x401d672300000000 # (double: 7.350719)
2: 0x401d672300000000 # (double: 7.350719)
}
4: 0xbf8c87e0c0000000 # (double: -0.013912320137023926)
}แปลงค่า Weight ของ Freenotes -> ขนาด Point (pt)
เมื่อเราลองปรับขนาดความหนาปากกาใน UI ของ Freenotes แล้วถอดรหัสค่า Protobuf ออกมาส่อง เราจะพบว่าค่าความหนาที่เก็บในไฟล์ (Weight) ไม่ได้บันทึกเป็นขนาด Point ($pt$) ตรงๆ แต่มีกระบวนการบางอย่างอยู่
จากการสังเกตข้อมูลทดสอบ 2 จุดอ้างอิงหลักที่ขนาด Thickness ขั้นต่ำ ($T = 1.0$) และขั้นสูง ($T = 15.0$):
- Thickness $1.0$ pt $\rightarrow$ ค่าใน Protobuf ได้
2.356715 - Thickness $15.0$ pt $\rightarrow$ ค่าใน Protobuf ได้
7.350719
สมมติฐานเบื้องต้นคือ น่าจะใช้ความสัมพันธ์เชิงเส้นง่ายๆ ในการ Mapping ค่าความหนา เราจึงลองตั้งสมการเส้นตรงพื้นฐาน:
$$ f(T) = \alpha \cdot T + \beta = V $$เมื่อ $T$ คือขนาด Thickness ในหน่วย pt และ $V$ คือค่า Weight ที่ถูกบันทึกในไฟล์ Protobuf
แทนค่าจุดทดสอบทั้งสองลงในสมการ:
$$ f(1.0) = 1.0 \alpha + \beta = 2.356715 \quad \text{--- (1)} $$$$ f(15.0) = 15.0 \alpha + \beta = 7.350719 \quad \text{--- (2)} $$
นำสมการ $(2) - (1)$ เพื่อกำจัดตัวแปร $\beta$:
$$ 14\alpha = 7.350719 - 2.356715 $$$$ 14\alpha = 4.994004 $$
$$ \mathbf{\alpha = \frac{4.994004}{14} = 0.356714571 \approx 0.356715} $$
นำค่า $\alpha \approx 0.356715$ ไปแทนกลับในสมการ $(1)$:
$$ (0.356715 \times 1.0) + \beta = 2.356715 $$$$ \mathbf{\beta = 2.356715 - 0.356715 = 2.000000} $$
ทำให้เราได้สมการแปลงค่าความหนาในหน่วย $pt$ สู่ค่าบันทึกในไฟล์ที่สมบูรณ์:
$$ V = f(T) = 0.356715 \cdot T + 2.00 $$และเมื่อต้องการ ถอดรหัสกลับ (Decoding) จากค่าในไฟล์ Protobuf กลับมาเป็นขนาดจุด ($pt$) เราแค่ย้ายข้างสมการ:
$$ T = \frac{V - 2.00}{0.356715} \quad \text{(pt)} $$การทดสอบและยืนยันสมการ
ลองนำสูตรนี้ไปทดสอบกับค่าขนาดอื่นๆ ที่ไม่ได้อยู่ใน 2 จุดอ้างอิงเดิม เพื่อยืนยัน ว่าสมการเชิงเส้นนี้ครอบคลุมการใช้งานจริงในช่วงที่สํารวจ:
1. ปากกา Pen ขนาด $7.3$ pt
- คำนวณค่าที่ถูกบันทึก ($V$): $$V = 2.00 + (0.356715 \times 7.3) = 4.6040195$$
- ค่าจริงที่พบในไฟล์ Protobuf:
4.604017(คลาดเคลื่อน $0.0000025$) - ลองถอดรหัสกลับทาง: $$T = \frac{4.604017 - 2.00}{0.356715} = \frac{2.604017}{0.356715} \approx 7.300004 \text{ pt}$$
2. ปากกาเน้นข้อความ Highlight ขนาด $30.0$ pt
- คำนวณค่าที่ถูกบันทึก ($V$): $$V = 2.00 + (0.356715 \times 30.0) = 12.70145$$
- ค่าจริงที่พบในไฟล์ Protobuf:
12.701439 - ลองถอดรหัสกลับทาง: $$T = \frac{12.701439 - 2.00}{0.356715} = \frac{10.701439}{0.356715} \approx 29.999969 \approx 30.0 \text{ pt}$$
จะเห็นว่าสูตรเชิงเส้น $V = 0.356715 \cdot T + 2.00$ สามารถถอดรหัสและแปลงค่ากลับไปมาได้อย่างเกือบแม่นยำ $100\%$ โดยค่า $\beta = 2.00$ ตาดว่าคือ Base Offset Radius ของทางฝั่ง Freenotes เอง
การจัดลำดับชั้นการวาดแยก 2 รอบ (Multi-Pass Z-Layering)
ในการใช้งานจริง ผู้ใช้มักจะเขียนข้อความสีดำเสร็จก่อน แล้วจึงลากปากกาไฮไลต์ทับทีหลัง หากเรนเดอร์ตามลำดับเวลาที่เขียนจริง (Time-series) สีไฮไลต์อาจไปอยู่ด้านบนสุดของเส้นข้อความ แล้วทําไมเราไม่ใช้ Layer ไปเสียเลยแบบพวก Krita?
เพราะ นี่ไม่ใช่ Krita Krita ไม่ได้ถูกออกแบบมาให้เป็นแอพจดโน้ต เพราะถึงแม้ Freenotes จะมี Layer อยู่ก็จริง แต่ผู้ใช้โดยทั่วไปไม่ได้มานั่งสลับ Layer ไปมาหรอก
ดังนั้น ในฐานะที่เป็นแอพจดโน้ตที่ดี วิธีการแก้ปัญหาที่ดีที่สุดคือการจัด Z-Order ใหม่ ที่แยกด้วยประเภทของหัวปากกา (Highlighter = 2)
ระบบเรนเดอร์ (ที่เราทำย้อนกลับ) จึงทำงานโดยการแบ่งการวาดออกเป็น 2 รอบ (2-Pass Render Loop) เพื่อให้ภาพผลลัพธ์ออกมาสมบูรณ์และถูกต้องที่สุด:
1. Loop ที่ 1 (Lower Z-Pass - Highlighter):
สแกนอ่านอาร์เรย์ Stroke ทั้งหมดในไฟล์ FNPages เพื่อดึงเฉพาะเส้นที่มี tool_type == 2 (ปากกาไฮไลต์) ออกมาเรนเดอร์ลงบนผืนผ้าใบเป็นเลเยอร์ล่างสุดก่อนเพื่อน เพื่อสร้างชั้นสีโปร่งแสงรองรับไว้ก่อนใต้ข้อความ
2. Loop ที่ 2 (Upper Z-Pass - Ink Stroke):
สแกนดึงเส้นที่มี tool_type != 2 (ปากกาหัวกลม ปากกาลูกลื่น ปากกาหัวแร้ง ดินสอ) ออกมาเรนเดอร์ทับลงมาในเลเยอร์ด้านบนสุด ทำให้ข้อความลายมือสีดำคมชัดอยู่บนสุดเสมอ ไม่ถูกสีไฮไลต์บดบังขอบเส้น
# Reverse-Engineered Renderer
def render_page_strokes_with_z_order(ctx, page_strokes):
# Pass 1: วาดเส้นไฮไลต์ทั้งหมดลงในเลเยอร์ล่างก่อน
for stroke in page_strokes:
if stroke.tool_type == 2: # Highlighter
draw_bezier_path(ctx, stroke.points, stroke.color, stroke.width)
# Pass 2: วาดเส้นน้ำหมึกธรรมดาทับลงในเลเยอร์บนสุด
for stroke in page_strokes:
if stroke.tool_type != 2: # Regular Pens
draw_bezier_path(ctx, stroke.points, stroke.color, stroke.width)การทำงานร่วมกับกลไกยางลบ (Eraser Path Integration)
ในทุกๆ Pass ที่ทำการวาด เอนจินจะทำการตรวจสอบรอยลากยางลบ (field_5
/ Eraser Tool ID 101) ควบคู่ไปด้วย โดยใช้ Mode Operator Clear เสมือนการเจาะรู (cairo.OPERATOR_CLEAR) เพื่อตัดสไลซ์เส้นในเลเยอร์นั้นๆ ก่อนนำผืนผ้าใบหลักไปวางแปะบนกระดาษโน้ตสีขาว (#FFFFFF)
[Eraser] สมมติว่าเราอยากสร้างยางลบ
หมายเหตุ: สิ่งที่เราทำตอนนี้คือการนำ จุดไปเทียบกับจุด ซึ่งในความเป็นจริงแล้ว ควรจะเป็นการเทียบการทับซ้อนของ Vector เส้นมากกว่า เพราะ ลายเส้นไม่ได้ประกอบด้วยจุดพิกัดโดดๆ แต่สร้างขึ้นจากส่วนของเส้นตรงที่มีความหนา หากตรวจจับเฉพาะจุดต่อจุดจะส่งผลให้เส้นขาดตอนเกินความจริง
ก่อนอื่นเรากลับมาดูโครงสร้างของ Object แบบยางลบกันก่อน
5 {
1: "E76432F0-7870-4F87-9C95-1C7A826E097C"
2 {
1: 101
}
3 {
1: 0x3ff0000000000000 # 4607182418800017408 (double: 1.0)
4: 0x3ff0000000000000 # 4607182418800017408 (double: 1.0)
}
4 {
1: "6D1279C6-1FDC-467F-8141-2B43C495F640"
2 {
1 {
1: 0x4067026487fe2766 # 4640680571830151014 (double: 184.07674296048283)
2: 0x4077b98267580d10 # 4645204090968083728 (double: 379.5975656105)
}
3 {
1: 0x401469e640000000 # 4617332999552237568 (double: 5.103416442871094)
2: 0x401469e640000000 # 4617332999552237568 (double: 5.103416442871094)
}
4: 0xbf857f6de0000000 # 13800520563544645632 (double: -0.010496958158910275)
}จะพบว่าโครงสร้าง Protobuf ของหัวยางลบ แทบไม่ต่างจาก Object ของปากกาเลย ดังนั้นเราจึงใช้หลักการถอดรหัสพิกัดและขนาดเส้นแบบเดียวกันได้ทั้งหมด
จุดที่น่าสนใจคือในบล็อกน้ำหนักเส้น:
3 {
1: 0x401469e640000000 # 4617332999552237568 (double: 5.103416442871094)
2: 0x401469e640000000 # 4617332999552237568 (double: 5.103416442871094)
}สังเกตได้ว่าค่า 5.103416442871094 ใน field 1 และ 2 คือ Start Weight ($V_{start}$) และ End Weight ($V_{end}$) ของเส้นยางลบ ณ จุดนั้นๆ
1. ถอดรหัสขนาดความหนาหัวยางลบกลับเป็น UI Thickness (pt)
เมื่อนำค่า Weight $V = 5.10341644$ มาเข้าสูตรแปลงกลับที่เราถอดรหัสไว้ก่อนหน้านี้:
$$T = \frac{V - 2.00}{0.356715}$$$$T = \frac{5.103416442871094 - 2.00}{0.356715} = \frac{3.103416442871094}{0.356715} \approx \mathbf{8.70\text{ pt}}$$นั่นแสดงว่าหัวยางลบจุดนี้ ถูกตั้งค่าขนาดใน Freenotes ไว้ที่ $8.70\text{ pt}$ พอดี
2. การคำนวณการตรวจจับการทับซ้อน (Geometric Overlap Detection)
เมื่อผู้ใช้ลากยางลบผ่านพิกัด $E = (x_e, y_e)$ ด้วยขนาดความหนา $T_e = 8.70\text{ pt}$ (หรือมีรัศมีขอบเขตการลบ $R_e \approx 4.35\text{ pt}$) ระบบจะคำนวณหาระยะทางยูคลิดแบบ 2 มิติ (Euclidean Distance in 2D) ไปยังจุดหมึกปากกาเดิม $P = (x_p, y_p)$ ที่มีรัศมีขอบเขตหมึก $r_p$:
$$d(P, E) = \sqrt{(x_p - x_e)^2 + (y_p - y_e)^2}$$เงื่อนไขการตัด/ลบเส้น:
$$d(P, E) \le R_e + r_p$$- ถ้าเป็นจริง ($d \le R_e + r_p$): ขอบเขตยางลบสัมผัสทับซ้อนโดนเส้นหมึกเดิม ระบบจะทำการแยก Stroke หรือลบ segment นั้นออกทันที
ตัวอย่างการคำนวณการชนกันของยางลบ
กำหนดให้:
- $P = (10, 15)$ [จุดพิกัดเส้นหมึกเดิม]
- $r_p = 2.0\text{ pt}$ [รัศมีขอบเขตเส้นหมึกเดิม]
- $E = (14, 18)$ [จุดพิกัดหัวยางลบ]
- $R_e = 4.35\text{ pt}$ [รัศมีขอบเขตหัวยางลบ $8.70 / 2$]
- คำนวณระยะห่างยูคลิด ($d$) ระหว่างจุดหมึก $P(10,15)$ และจุดยางลบ $E(14,18)$:
- คำนวณผลรวมรัศมีขอบเขตทั้งสอง ($R_e + r_p$):
- ตรวจสอบเงื่อนไขการชนกัน ($d \le R_e + r_p$):
เนื่องจากระยะห่าง $5.0\text{ pt}$ น้อยกว่าผลรวมรัศมีขอบเขต $6.35\text{ pt}$ แสดงว่าขอบเขตยางลบปาดทับซ้อนโดนเส้นหมึกเดิม
[Lasso] Transformation แบบใด๋
ก่อนที่เราจะไปเข้าถึงหัวข้อภายในเฉพาะเจาะจง เราจะต้องเริ่มก่อนว่า สูตรทุกสูตรในทางการแปรรูป(Transformation) มาจาก สมการเชิงเส้นคือ
$$x' = A x + b$$เมื่อ:
- $x$ คือเวกเตอร์พิกัดดั้งเดิม
- $x'$ คือเวกเตอร์พิกัดหลังการแปลง
- $A$ คือเมทริกซ์การแปลงเชิงเส้น (Linear Transformation Matrix) เช่น การหมุน ย่อ/ขยาย เฉียง สะท้อน
- $b$ คือเวกเตอร์การเลื่อนขนาน
ซึ่งสามารถเราจะแปลงสมการข้างต้นให้อยู่ในรูป เมทริกซ์ทั่วไปสำหรับ $N$ มิติ ได้ดังนี้:
$$ {\begin{bmatrix}\mathbf {x'} \\1\end{bmatrix}}=\left[{\begin{array}{ccc|c}&A&&\mathbf {b} \\0&\cdots &0&1\end{array}}\right]{\begin{bmatrix}\mathbf {x} \\1\end{bmatrix}} $$เมื่อแทนค่าในระบบ 2 มิติ ($x = \begin{bmatrix} x \\ y \end{bmatrix}$, $A = \begin{bmatrix} a & b \\ c & d \end{bmatrix}$, $b = \begin{bmatrix} e \\ f \end{bmatrix}$) จะได้เมทริกซ์ขนาด $3 \times 3$:
$$ \begin{bmatrix} x' \\ y' \\ 1 \end{bmatrix} = \begin{bmatrix} a & b & e \\ c & d & f \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} $$การเลื่อนขนาน (Translation)
การเลื่อนขนาน (Translation) คือกระบวนการย้ายตำแหน่งของ objects ลายเส้นจากจุดหนึ่งไปยังอีกจุดหนึ่งบนหน้ากระดาษเมื่อเราใช้ Lasso Tool ลากย้ายวัตถุ
ในการจัดเก็บข้อมูลระดับเวกเตอร์ Freenotes ไม่ได้นำพิกัดลายเส้นดั้งเดิมมาคำนวณบวกขยับพิกัดใหม่ แต่ใช้สิ่งที่เรียกว่า Matrix Offset ซึ่งอาศัยสูตรการ การเลื่อนขนาน Transformation ดังนี้:
$$ T(t_x, t_y) = \begin{bmatrix} 1 & 0 & t_x \\ 0 & 1 & t_y \\ 0 & 0 & 1 \end{bmatrix} $$เมื่อนํามาประกอบกับสมการขั้นต้น สมการนี้จะแปลงรูปเป็น
$$ \begin{bmatrix} x' \\ y' \\ 1 \end{bmatrix} = \begin{bmatrix} 1 & 0 & t_x \\ 0 & 1 & t_y \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} = \begin{bmatrix} x + t_x \\ y + t_y \\ 1 \end{bmatrix} $$ตัวอย่าง
กําหนดให้
- $x$ แทนแกน $x$ มีค่าเท่ากับ $269.65$
- $x$ แทนแกน $y$ มีค่าเท่ากับ $196.21$
- $t_x$ แทนการเลื่อนแกน $x$ มีค่าเท่ากับ $-87.60$
- $t_y$ แทนการเลื่อนแกน $y$ มีค่าเท่ากับ $128.27$
แทนค่าลงได้เป็น
$$ \begin{bmatrix} x' \\ y' \\ 1 \end{bmatrix} = \begin{bmatrix} 1 & 0 & -87.60 \\ 0 & 1 & 128.27 \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} 269.65 \\ 196.21 \\ 1 \end{bmatrix} $$$$ \begin{bmatrix} x' \\ y' \\ 1 \end{bmatrix} = \begin{bmatrix} 269.65 + (-87.60) \\ 196.21 + 128.27 \\ 1 \end{bmatrix} = \begin{bmatrix} \mathbf{182.05} \\ \mathbf{324.48} \\ 1 \end{bmatrix} $$ทําให้พิกัดที่แสดงบนหน้าจอมีค่าเท่ากับ $(182.05, 324.48)$ โดยที่ภายในโครงสร้างข้อมูลจะยังเป็นค่าเดิมอยู่ ก็คือ $(269.65, 196.21)$
การขยายย่อลด (Scale)
การขยายขนาด หรือ ย่อลด หรือเรียกได้อีกชื่อว่า Scale ในแนวแกน X ด้วยตัวคูณ $s_x$ และแกน Y ด้วยตัวคูณ $s_y$:
ในการเก็บข้อมูลของ Freenotes เมื่อขยายขนาดแล้ว พิกัดจะถูกเปลี่ยนไปตามขนาดที่ Scale ใหม่ ซึ่งต่างจากการเลื่อนขนานที่จะคงเลขเดิมไว้
$$ S(s_x, s_y) = \begin{bmatrix} s_x & 0 & 0 \\ 0 & s_y & 0 \\ 0 & 0 & 1 \end{bmatrix} $$เมื่อนำมาประกอบเข้ากับสมการการแปลงพิกัดทั่วไป จะได้สมการในรูป Matrix ดังนี้:
$$ \begin{bmatrix} x' \\ y' \\ 1 \end{bmatrix} = \begin{bmatrix} s_x & 0 & 0 \\ 0 & s_y & 0 \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} = \begin{bmatrix} s_x \cdot x \\ s_y \cdot y \\ 1 \end{bmatrix} $$การย่อ/ขยายขนาด (Scaling) ในแอปพลิเคชันโน้ต จะกระทำโดยมี จุดศูนย์กลางของวัตถุ (Bounding Box Center: $C_x, C_y$) เป็นจุดหมุนและขยาย ไม่ใช้จุดเริ่มต้นที่ $(0,0)$ เพื่อรักษาตำแหน่งตั้งต้นของวัตถุไม่ให้คลาดเคลื่อน
$$ \begin{bmatrix} x' \\ y' \\ 1 \end{bmatrix} = \begin{bmatrix} 1 & 0 & C_x \\ 0 & 1 & C_y \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} s_x & 0 & 0 \\ 0 & s_y & 0 \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} 1 & 0 & -C_x \\ 0 & 1 & -C_y \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} $$$$ \begin{bmatrix} x' \\ y' \\ 1 \end{bmatrix} = \begin{bmatrix} s_x & 0 & C_x(1 - s_x) \\ 0 & s_y & C_y(1 - s_y) \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} $$$$ x' = C_x + s_x \cdot (x - C_x) $$$$ y' = C_y + s_y \cdot (y - C_y) $$ตัวอย่าง
กําหนดให้
- $x$ แทนแกน $x$ มีค่าเท่ากับ $269.65$
- $y$ แทนแกน $y$ มีค่าเท่ากับ $196.21$
- $s_x$ แทนตัวคูณการขยาย/ย่อแกน $x$ (Scale Factor) มีค่าเท่ากับ $3.76$
- $s_y$ แทนตัวคูณการขยาย/ย่อแกน $y$ (Scale Factor) มีค่าเท่ากับ $3.76$
แทนค่าลงได้เป็น
$$ x' = 247.1983 + 3.769859 \times (269.656910 - 247.1983) = \mathbf{331.870204\text{ px}} $$$$ y' = 250.3054 + 3.769859 \times (196.213587 - 250.3054) = \mathbf{46.387123\text{ px}} $$ผลลัพธ์จะได้ $(331.870204, 46.387123)$ ซึ่เป็นผลจากการขยายขนาด 3.76 เท่าโดยเอาตัวเองเป็นศูนย์กลาง
การหมุน (Rotation)
การหมุน ใช้อีกชื่อว่า Rotation เป็นการใช้ Transformation เพื่อการหมุน object ไปตามองศาโดยที่เช่นเดียวกับ Scale จะไม่เก็บค่าไว้ และจะแทนที่ค่าเดิม
สมการเมทริกซ์การหมุนรอบจุดศูนย์กลางเขียนได้ดังนี้:
$$ {\begin{bmatrix}\cos(\theta )&-\sin(\theta )&0\\\sin(\theta )&\cos(\theta )&0\\0&0&1\end{bmatrix}} $$โดยจะมี จุดศูนย์กลางของวัตถุ (Bounding Box Center: $C_x, C_y$) เป็นจุดหมุน (Pivot Point) เช่นเดียวกับ Scale :
$$ \begin{bmatrix} x' \\ y' \\ 1 \end{bmatrix} = \begin{bmatrix} 1 & 0 & C_x \\ 0 & 1 & C_y \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} \cos\theta & -\sin\theta & 0 \\ \sin\theta & \cos\theta & 0 \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} 1 & 0 & -C_x \\ 0 & 1 & -C_y \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} $$$$ \begin{bmatrix} x' \\ y' \\ 1 \end{bmatrix} = \begin{bmatrix} \cos\theta & -\sin\theta & C_x(1 - \cos\theta) + C_y\sin\theta \\ \sin\theta & \cos\theta & C_y(1 - \cos\theta) - C_x\sin\theta \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} $$$$ x' = C_x + (x - C_x)\cos\theta - (y - C_y)\sin\theta $$$$ y' = C_y + (x - C_x)\sin\theta + (y - C_y)\cos\theta $$
ตัวอย่าง
กำหนดให้
- $x$ แทนแกน $x$ มีค่าเท่ากับ $268.31$
- $y$ แทนแกน $y$ มีค่าเท่ากับ $144.09$
- $C_x$ แทนจุดศูนย์กลางแนวแกน $x$ มีค่าเท่ากับ $282.25$
- $C_y$ แทนจุดศูนย์กลางแนวแกน $y$ มีค่าเท่ากับ $137.64$
- $\theta$ แทนมุมในการหมุน (ทวนเข็มนาฬิกา) มีค่าเท่ากับ $90^\circ$ ($\cos 90^\circ = 0, \sin 90^\circ = 1$)
แทนค่าลงได้เป็น
$$ x' = 282.2467 + (268.3091 - 282.2467)\cos 90^\circ - (144.0901 - 137.6390)\sin 90^\circ $$$$ = \mathbf{275.7956\text{ px}} $$$$ y' = 137.6390 + (268.3091 - 282.2467)\sin 90^\circ + (144.0901 - 137.6390)\cos 90^\circ $$$$ = \mathbf{123.7014\text{ px}} $$
ผลลัพธ์จะได้ $(275.7956, 123.7014)$ ซึ่งเป็นผลจากการหมุน $90^\circ$ โดยเอาตัวเองเป็นศูนย์กลาง
สรุป
หลังจากที่เขียนมาอย่างยาวเหยียด บทความนี้สรุปได้ยังไง? แอพโน้ตมันค้ดยากเลยคับ 555555
แต่ส่วนอื่นส่วนใด นี่คือเสน่ห์ของ Vector Graphics เพราะจาก operation ที่เราทํามาทั้งหมด ไม่มีส่วนใดเลย ที่ทําให้รายละเอียดของเส้นลดทอนลงไปเลย ไม่ว่าจะลงสีทับ หมุน ขยาย …. ไรก็ตาม ผลออกมาจะยังเป็นลายเส้นที่คมชัดเหมือนเดิม
ในส่วนท้ายสุดนี้ไม่มีไรมากเพราะเขียนไปเยอะแล้ว
เลยใช้ลูกน้องให้เอาทั้งบทความนี้ไปเป็น Prompt เพื่อสร้าง Webgl Note taking ง่ายๆให้ลองเล่นดู ลองเล่นดู xoxo
บทความนี้เป็นบทความที่ยาวที่สุดในชีวิตที่ผู้เขียนเคยเขียน (รอบหน้าจะแยกเป็นพาร์ทๆ ละ)
ถ้าหากมีข้อผิดพลาดประการใด หรือต้องการให้เพิ่มอะไร สามารถคอมเมนต์ติชมได้เต็มที่ 🥰
ภาคผนวก: Raw Protobuf Decoded Dumps
[BOX] A. document.dat (Full Raw Decoded Dump) [ CLICK TO UNBOX ] [ CLICK TO CLOSE ]
1: 2
2: "915CB187-E62E-439E-AD9B-DC50DA3AAC13"
4 {
1 {
1: "AA553773-6919-4ECA-B97F-DAA2ADE15E06"
5 {
1: "2018E179-78B1-4CF2-A8D5-1E002442D853"
2: 1
4 {
1: 1
3 {
1: "A998661F-669C-4F73-8D21-21E16BF1496C"
2: "2F8DA175-4F19-4ADA-9FD2-B2369CE08F77" # REF: PDFs/<uuid>.pdf
4: "common1_A3_5_vertical_color5"
6: 1
7: 3068264191
9: 1
10: "common_cover_identifier_38"
}
}
}
11 {
1: 1785397999
2: 501024961
}
}
1 {
1: "1AF3A213-07F5-45B4-BA87-E63E7B9AE6C9"
3: 1
5 {
1: "A9BB4458-0053-4953-8CE4-AD21FFE7A7DB"
3 {
2: 5
3: 4294967295
4: 3166485759
5: 1
}
}
6: "3C892967-BC80-4C5F-B384-FB2045DEC9CA" # REF: FNPages/<uuid>.dat
8: "D0E31896-FAE8-460B-A9F8-69909A199688" # REF: OCR_v3/en-US/<uuid>/content.json
11 {
1: 1785397999
2: 484833956
}
}
1 {
1: "D7341019-D335-43EB-BBE8-B7B59D31303E"
3: 2
5 {
1: "A9BB4458-0053-4953-8CE4-AD21FFE7A7DB"
3 {
2: 5
3: 4294967295
4: 3166485759
5: 1
}
}
11 {
1: 1785397999
2: 484909058
}
}
2 {
1: "2F8DA175-4F19-4ADA-9FD2-B2369CE08F77" # REF: PDFs/<uuid>.pdf
2: "AA553773-6919-4ECA-B97F-DAA2ADE15E06"
3 {
1: 0
2 {
2: "AA553773-6919-4ECA-B97F-DAA2ADE15E06"
}
}
}
5: 1
}
6 {
1: 1785397999
2: 484794140
}
7: 2Document_uuid Page_uuid Pdf_uuid FNPages_uuid OCR_uuid
[BOX] B. FNPages/3C892967-BC80-4C5F-B384-FB2045DEC9CA.dat (Full Raw Decoded Dump) [ CLICK TO UNBOX ] [ CLICK TO CLOSE ]
1: "3C892967-BC80-4C5F-B384-FB2045DEC9CA"
2: "1AF3A213-07F5-45B4-BA87-E63E7B9AE6C9"
3 {
1: "54C99B69-BB82-4936-A855-9354BAE8C45B"
2 {
1 {
1: "54C99B69-BB82-4936-A855-9354BAE8C45B"
2 {
1: 1
2: 2377619967 # #8DB799FF Color Hex
}
3 {
1: 0x3ff0000000000000 # 4607182418800017408 (double: 1.0)
4: 0x3ff0000000000000 # 4607182418800017408 (double: 1.0)
}
4 {
1: "2A59792B-ED5F-447D-9680-E0D230B07858"
2 {
1 {
1: 0x4064a0b4eb000000 # 4640028243290628096 (double: 165.02208614349365)
2: 0x4064a842f20b1378 # 4640036553818313592 (double: 165.2581723947078)
}
3 {
1: 0x4002917f20000000 # 4612349070775910400 (double: 2.321044921875)
2: 0x4002917f20000000 # 4612349070775910400 (double: 2.321044921875)
}
4: 0xbf90647fe0000000 # 13803606670868217856 (double: -0.016008377075195312)
}
2 {
1 {
1: 0x4064f20362e80000 # 4640117703550238720 (double: 167.56289291381836)
2: 0x4064b466bc170e50 # 4640049909204094544 (double: 165.6375402379781)
}
3 {
1: 0x4002917f20000000 # 4612349070775910400 (double: 2.321044921875)
2: 0x4002917f20000000 # 4612349070775910400 (double: 2.321044921875)
}
4: 0x3f74721920000000 # 4572412853765111808 (double: 0.004991650581359863)
}
2 {
1 {
1: 0x4065bd4797f80000 # 4640343336494628864 (double: 173.91500091552734)
2: 0x4064d0e753d760a0 # 4640081297780039840 (double: 166.5282381283321)
}
3 {
1: 0x4002917f20000000 # 4612349070775910400 (double: 2.321044921875)
2: 0x4002917f20000000 # 4612349070775910400 (double: 2.321044921875)
}
4: 0x3f9684f900000000 # 4581987541655027712 (double: 0.021991610527038574)
}
2 {
1 {
1: 0x4066cec3f6600000 # 4640644342516350976 (double: 182.46142482757568)
2: 0x4064ece0c4a43de8 # 4640112048504028648 (double: 167.43369062638842)
}
3 {
1: 0x4002917f20000000 # 4612349070775910400 (double: 2.321044921875)
2: 0x4002917f20000000 # 4612349070775910400 (double: 2.321044921875)
}
4: 0x3fa373a200000000 # 4585623049581920256 (double: 0.037991583347320557)
}
2 {
1 {
1: 0x4067f1257c000000 # 4640963500353159168 (double: 191.53582382202148)
2: 0x4064f7f640c94ea4 # 4640124239853760164 (double: 167.74880983636504)
}
3 {
1: 0x4002917f20000000 # 4612349070775910400 (double: 2.321044921875)
2: 0x4002917f20000000 # 4612349070775910400 (double: 2.321044921875)
}
4: 0x3faa1b9300000000 # 4587494541332611072 (double: 0.05100008845329285)
}
2 {
1 {
1: 0x406a7b1248100000 # 4641678170286358528 (double: 211.84600067138672)
2: 0x4064d5a764798c64 # 4640086522616122468 (double: 166.6766835154381)
}
3 {
1: 0x4002917f20000000 # 4612349070775910400 (double: 2.321044921875)
2: 0x4002917f20000000 # 4612349070775910400 (double: 2.321044921875)
}
4: 0x3fb26e0b00000000 # 4589836932400906240 (double: 0.07200008630752563)
}
3 {
1: 1785398005
2: 835236073
}
6: 4
}
}
2: "1AF3A213-07F5-45B4-BA87-E63E7B9AE6C9"
3: "1AF3A213-07F5-45B4-BA87-E63E7B9AE6C9"
}
}Document_uuid Page_uuid Pdf_uuid FNPages_uuid OCR_uuid
[BOX] C. PDFs/Layout.dat (Full Raw Decoded Dump) [ CLICK TO UNBOX ] [ CLICK TO CLOSE ]
1: "915CB187-E62E-439E-AD9B-DC50DA3AAC13"
2: 0x4082980000000000 # 4648439126305964032 (double: 595.0)
3 {
1 {
1: 0x4082980000000000 # 4648439126305964032 (double: 595.0)
2: 0x408a4cf81852b2c6 # 4650644485586170566 (double: 841.6211242675781)
}
2: 0x3fe69ce2344b66c4 # 4604539121650330308 (double: 0.7066507339477539)
3: ""
5: "AA553773-6919-4ECA-B97F-DAA2ADE15E06"
}
3 {
1 {
1: 0x4082980000000000 # 4648439126305964032 (double: 595.0)
2: 0x408a500000000000 # 4650647820000000000 (double: 842.0)
}
2: 0x3ff0000000000000 # 4607182418800017408 (double: 1.0)
3 {
2: 0x408a4cf81852b2c6 # 4650644485586170566 (double: 841.6211242675781)
}
4: 1
5: "1AF3A213-07F5-45B4-BA87-E63E7B9AE6C9"
}
3 {
1 {
1: 0x4082980000000000 # 4648439126305964032 (double: 595.0)
2: 0x408a500000000000 # 4650647820000000000 (double: 842.0)
}
2: 0x3ff0000000000000 # 4607182418800017408 (double: 1.0)
3 {
2: 0x409a4e7c0c295963 # 4655022137682221411 (double: 1683.6211242675781)
}
4: 2
5: "D7341019-D335-43EB-BBE8-B7B59D31303E"
}
4 {
2: "AA553773-6919-4ECA-B97F-DAA2ADE15E06"
2: "1AF3A213-07F5-45B4-BA87-E63E7B9AE6C9"
2: "D7341019-D335-43EB-BBE8-B7B59D31303E"
}Document_uuid Page_uuid Pdf_uuid FNPages_uuid OCR_uuid
-
https://docs.kanzi.com/3.9.1/en/working-with/2d-content/blending-2d-nodes.html#alpha-premultiplied ↩︎
-
https://www.w3.org/TR/compositing-1/#porterduffcompositingoperators_srcover ↩︎ ↩︎
-
https://webgl.brown37.net/12_advanced_rendering/05_color_blending.html ↩︎
-
https://cdn.standards.iteh.ai/samples/10795/ae461684569b40bbbb2d9a22b1047f05/IEC-61966-2-1-1999-AMD1-2003.pdf The full document are behind a huge sum paywall, legally obtain it at your very owned risk ↩︎
-
https://medium.com/@tomforsyth/the-srgb-learning-curve-773b7f68cf7a ↩︎
-
https://en.wikipedia.org/wiki/Affine_transformation#Representation ↩︎
-
https://en.wikipedia.org/wiki/Affine_transformation#Augmented_matrix ↩︎
-
https://en.wikipedia.org/wiki/Affine_transformation#Image_transformation ↩︎ ↩︎ ↩︎
ความคิดเห็น