HotPDF ถอดรหัส QR ที่ถูกหมุนในหน้า PDF ที่โหลดไว้ โดยการ normalize เมทริกซ์โมดูลที่สุ่มตัวมาผ่านทิศทาง D4 ทั้งแปดแบบภายในตัวถอดรหัสเอง การ retry แบบหมุนภาพรอบนอกที่ใช้ได้กับ symbology แบบเส้น ไม่มีทางใช้ได้กับ QR และการเข้าใจเหตุผลช่วยประหยัดเวลาหนึ่งวันจากการไล่จับตัวถอดรหัสที่ดูเหมือนพังแต่จริง ๆ ไม่ได้พัง
สถานการณ์นี้ธรรมดาเกินกว่าจะเชื่อ ใบกำกับการส่งของที่สแกนเดินทางมาเป็น PDF ทุกหน้าพกฉลาก QR และคนให้อาหารเครื่องสแกนป้อนกระดาษกองนั้นตามทิศทางที่ถาดยอมรับ ฉลากบางใบตั้งตรง บางใบเอียงไปสี่หนึ่งวง บางใบกลับหัวลอย คุณเรียกตัวถอดรหัส barcode ครึ่งหนึ่งของหน้า resolve ผ่าน อีกครึ่งกลับมาว่างเปล่าโดยไม่มี error แม้แต่ข้อเดียว
ทำไมการหมุน scan mask จึงไม่มีวันแก้ QR ที่ถูกหมุนได้
เพราะเลย์เอาต์ finder pattern ของ QR ถูกออกแบบให้ไม่สมมาตรโดยตั้งใจ และการหมุนภาพทั้งใบเป็นการเก็บความไม่สมมาตรนั้นไว้ แทนที่จะเอามันออก QR Code วางสามเหลี่ยมค้นหาไว้ที่มุมซ้ายบน ขวาบน และซ้ายล่าง โดยทิ้งมุมขวาล่างไว้ว่าง (ISO/IEC 18004:2015 §6.3.3) มุมที่หายไปนั่นแหละคือตัวบอกทิศทาง หมุน bitmap ของหน้าไปเก้าสิบองศา ช่องว่างก็แค่ย้ายไปมุมอื่น ไม่มีการหมุนแบบ trivial หนึ่งครั้งของระนาบที่จะพาเลย์เอาต์สามมุมกลับมาทาบตัวเองได้ ตัวถอดรหัสที่ยอมรับเฉพาะการจัดวาง canonical จะปฏิเสธทุกความพยายามไล่กันมาเป็นรอบ ๆ
เรื่องนี้สำคัญเพราะทางแก้ตามสัญชาตญาณคือทางที่ผิด ความรู้สึกธรรมชาติคือเอา retry ไปแขวนไว้ด้านนอก: เรนเดอร์หน้า ส่ง mask ให้ตัวถอดรหัส ถ้าพังก็หมุน mask แล้วลองใหม่ที่ 90, 180 และ 270 องศา กับ Code 39 นโยบายนี้ถูกต้องเป๊ะ เพราะ symbology แบบเส้นมี start กับ stop pattern ที่เครื่องอ่านหาเจอเมื่อแท่งวิ่งในแนวนอน กับ QR มันคือความล้มเหลวที่รับประกันไว้สี่รอบ ตามด้วยรายงานว่าไม่พบอะไรเลย
กลุ่ม D4 ถูกนำไปใช้บนเมทริกซ์โมดูล
ตำแหน่งที่ถูกต้องของการ normalize คือหลังการสุ่มตัวอย่าง บนกริดโมดูลแบบบูลีน ไม่ใช่บน mask ระดับพิกเซล เมื่อตัวถอดรหัส resolve symbol เป็นเมทริกซ์ n คูณ n ของโมดูลเข้มกับสว่างแล้ว มันไล่เรียกกลุ่มไดฮีดรัลของสี่เหลี่ยมจัตุรัสได้ สี่การหมุนคูณสองการสะท้อน เป็นแปดทิศทางเข้าชิง สำหรับแต่ละเจ้าบ้านมันเช็กสามเหลี่ยม finder และเจ้าแรกที่สาม finder ตกลงในตำแหน่งซ้ายบน ขวาบน ซ้ายล่างคือทิศทางจริง จากจุดนั้น pipeline เดิมวิ่งต่อโดยไม่เปลี่ยนอะไร เพราะ format information bits, การวางข้อมูลแบบ zigzag และการแก้ Reed-Solomon ล้วนสมมติเมทริกซ์ canonical อยู่แล้ว ตอนนี้มันก็ได้อย่างที่ต้องการ
มีสองคุณสมบัติที่ทำให้วิธีนี้ถูกจุดตลอด เมทริกซ์เล็กกว่า bitmap ที่เรนเดอร์มามาก แปดการ transpose จึงถูกกว่าแปดการเรนเดอร์หน้าอยู่หลายเท่า และเมทริกซ์เป็นอาร์เรย์บูลีนสะอาด ๆ ที่ตัวสุ่มตัวสร้างขึ้น การ transform ระหว่างทางจึงไม่มีทางใส่ค่าที่ไม่เคยถูกสุ่มเข้ามาได้
การหาเวอร์ชันคือการค้นหาความหารลงตัว ไม่ใช่การหาร
จำนวนโมดูลหาได้จากการเอาความกว้างที่สุ่มมาหารด้วยขนาดโมดูลที่สมมติขึ้นไม่ได้ และการเข้าใจผิดตรงนี้เป็นแหล่งความล้มเหลวในการถอดรหัสแบบละเอียดอ่อนบนผลเรนเดอร์ความละเอียดสูง symbol QR เวอร์ชัน v กว้าง 4v + 17 โมดูล เวอร์ชัน 1 คือ 21 โมดูล และเวอร์ชัน 40 คือ 177 mask ที่วัดได้ 126 พิกเซลเข้ากับเวอร์ชัน 1 ที่หกพิกเซลต่อโมดูลได้เท่าเทียมกับเวอร์ชันสูงกว่าหลายตัวที่โมดูลเล็กกว่า การหารเส้นตรงเลือกหนึ่งตัวจากพวกมัน และมักเลือกผิด
สิ่งที่ได้ผลคือการค้นหาแบบหารลงตัวไล่ผ่านเวอร์ชันเข้าชิง เดินจากเวอร์ชัน 40 ลงไปถึง 1 เก็บเจ้าที่จำนวนโมดูลหารความกว้างที่สุ่มมาลงตัวเป๊ะและเหลือโมดูลละอย่างน้อยสามพิกเซล แล้วเลือกเวอร์ชันที่รอดที่เล็กที่สุด พื้นสามพิกเซลคือสิ่งที่กันไม่ให้การค้นหายอมรับการอ่านแบบแน่นโปร่งเกินมนุษย์ของ symbol หยาบ ๆ และกฎเวอร์ชันที่เล็กสุดช่วยตัดสินความกำกวมที่เหลือให้ลงรอยเดียวกับที่เครื่องอ่านจะผลิตจริง
var
Pdf: THotPDF;
Options: THPDFBarcodeDecodeOptions;
Codes: THPDFDecodedBarcodes;
Info: THPDFBarcodeDecodeInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('delivery-notes.pdf');
Options := THPDFBarcodeDecodeOptions.Default;
Options.DPI := 300;
Options.RotationPolicy := bdrpFallback;
Options.MinimumConfidence := 0.5;
Options.MaxResults := 16;
if Pdf.DecodeLoadedPageBarcodes(0, Options, Codes, Info) then
for I := 0 to High(Codes) do
if Codes[I].Symbology = bsyQRCode then
Writeln(Codes[I].Text, ' at ',
Format('%.0f', [Codes[I].OrientationDegrees]), ' degrees');
finally
Pdf.Free;
end;
end;
THPDFBarcodeDecodeOptions.Default คืน record ที่ถูกอุดค่ามาแล้ว ไม่ใช่อันที่ถูกเคาะเป็นศูนย์ ซึ่งสำคัญเพราะ DPI เป็นศูนย์หรือเพดานผลลัพธ์เป็นศูนย์เป็นทางที่ดูถูกต้องแต่ให้ผลว่างเปล่าได้ RotationPolicy คุมเฉพาะ retry วงนอก: bdrpNone เรนเดอร์รอบเดียว bdrpFallback ลองทิศทางอื่นต่อหลัง pass แรกล้ม และ bdrpAll เรนเดอร์ทุกทิศทางโดยไม่มีเงื่อนไข เพราะการ normalize QR เกิดขึ้นข้างในตัวถอดรหัส หน้า QR จึง resolve ผ่านตั้งแต่ครั้งแรกใต้นโยบายทั้งสาม นโยบายนี้มีไว้เพื่อ symbology แบบเส้นที่ต้องการมันจริง ๆ
พิสูจน์อย่างไรว่า transform ของ bitmap ไม่ได้ปลอมพิกเซลขึ้นมา
นับหมึกทั้งสองฝั่งแล้วบังคับให้ผลรวมตรงกัน การหมุนคือการเรียงสลับพิกเซล แค่นั้น จำนวนช่องที่ไม่ใช่ศูนย์ใน output ต้องเท่ากับใน input เมื่อการหมุน mask ในเส้นทาง retry วงนอกรายงานช่องที่ถูกตั้งค่าไป 4800 ช่อง แล้วกลับออกมา 7439 ช่อง การเทียบครั้งเดียวก็พอต้องการข้อหา transform ตัวนี้ได้โดยไม่ต้องอ่านโค้ด geometry แม้แต่บรรทัดเดียว
สาเหตุธรรมดาจนควรเก็บไว้เป็นกฎ dynamic array ที่จองด้วย SetLength ไม่มีการรับประกันว่าจะมาถึงแบบเป็นศูนย์เมื่อมันเป็นผลลัพธ์ของฟังก์ชันที่เดินทางบนเส้นทางที่ runtime ไม่เคยเคลียร์ และช่องที่การหมุนไม่เคยเขียนก็จะพกไบต์เดิมที่นั่งอยู่ก่อน บางไบต์ค้างเวลาพวกนั้นไม่ใช่ศูนย์ และไม่ใช่ศูนย์แปลว่าเป็นหมึก ทางแก้ใช้บรรทัดเดียว FillChar(Result[0], N, 0) ก่อนลูปเรียงสลับวิ่ง และวินัยที่มันบอกใยกว้างกว่านั้น: ฟังก์ชันใดก็ตามที่คืน mask หรือ buffer ของ bitmap ควรเคลียร์ output ให้ชัดเจน แทนที่จะฝากความหวังไว้กับ semantic ของการจองหน่วยความจำ
สิ่งที่น่าสนใจกว่าบั๊กคือทำไมมันรอดมาได้สามเวอร์ชัน release พอ QR ย้ายการจัดการทิศทางเข้าไปในตัวถอดรหัส QR ก็เลิกแตะการหมุน mask วงนอกทั้งหมด ผู้ใช้เส้นทางโค้ดนั้นที่เหลืออยู่จึงมีแค่ Code 39 infrastructure ที่ถูกใช้ร่วมกันซ่อนบั๊กแบบนี้ได้ตลอดเวลา coverage จากฟีเจอร์หนึ่งทำให้เส้นทางหนึ่งดูเหมือนถูกเทสต์ ขณะที่ฟีเจอร์ที่พึ่งพามันจริง ๆ กลับไม่มีของตัวเองเลย ทุกเส้นทางที่ฟีเจอร์ใหม่เลิกใช้ต้องมีเทสต์ที่ยังใช้มันอยู่
อ่านผลลัพธ์กลับมาในพิกัดของหน้ากระดาษ
ค่าเชิงเรขาคณิตทุกค่าที่ตัวถอดรหัสผลิตออกมาถูกแสดงในกรอบพิกัดของ bitmap ของรอบลอง ขณะที่ผู้เรียกต้องการมันใน user space ของ PDF การแปลงนี้วิ่งสองช่วง: เอาการหมุนสี่หนึ่งวงที่ retry ทำไว้ออกก่อน แล้วค่อยเอา render transform ที่พา user space มาทาบบน bitmap ออก สิ่งที่มาถึงใน THPDFDecodedBarcode คือ bounding box ที่ขนานกับแกนใน user space พร้อม Left, Bottom, Right และ Top ตามธรรมเนียม PDF ที่แกน Y โตขึ้นด้านบน บวกกับ OrientationDegrees ที่นับทวนเข็ม
ทำทิศทางของการแปลงช่วงที่สองผิดแล้วอาการจะน่าขยะแขยง: ข้อความถอดรหัสได้เนียนสมบูรณ์ แต่กล่องที่คุณวาดทับเพื่อให้คนทบทวนไปนั่งกอดภาพกระจกของตำแหน่งที่ถูก ใครก็ตามที่กำลังจะสร้าง interface ทบทวนงานทับตัวถอดรหัสควร assert กับ fixture ที่รู้คำตอบ โดยวาง symbol ไว้ใกล้มุมหนึ่งของหน้าโดยเจตนา เพื่อให้แกน Y ที่กลับหัวเห็นได้ในแวบแรก เหตุผลแบบเดียวกันใช้กับพิกัดใด ๆ ที่ข้ามขอบเขตการเรนเดอร์ ซึ่งเป็นเหตุผลที่ การเรนเดอร์หน้า PDF เป็น bitmap ใน Delphi ควรถูกเข้าใจก่อนคุณสร้างของทับตัวถอดรหัส
ตัวถอดรหัสในตัวทำอะไรได้และทำไม่ได้
ตัวถอดรหัสในตัวเป็น implementation ที่มีขอบเขตและไม่พึ่ง dependency และมันซื่อตรงกับขีดจำกัดของตัวเอง แทนที่จะเสื่อมสภาพไปเงียบ ๆ มันรู้จัก Code 39 กับ QR ตรวจสอบ format bits ที่มี BCH ปกป้องกับ mask pattern ก่อนประกาศข้อมูลใด ๆ และไม่พยายามกู้ symbol ที่เสียหาย ถ้า input ของคุณเป็นรูปถ่ายฉลากโค้งใต้แสงไม่สม่ำเสมอ นั่นคือปัญหาคนละชั้นและควรได้เครื่องยนต์เฉพาะทาง
// เปลี่ยนเครื่องยนต์เป็นของคุณเอง: implement IHPDFBarcodeDecoder แล้วส่ง
// ให้ overload ที่รับ decoder HotPDF ยังเป็นเจ้าของการเรนเดอร์หน้า
// งบประมาณ, การแมปพิกัด และการตัดซ้ำเหมือนเดิม
if not Pdf.DecodeLoadedPageBarcodes(PageIndex, MyDecoder, Options,
Codes, Info) then
case Info.Status of
bdsBudgetExceeded:
Log('raise MaxPixels or lower DPI: ' + string(Info.Diagnostic));
bdsRenderError:
Log('page did not render: ' + string(Info.Diagnostic));
bdsDecoderError:
Log(string(Info.DecoderName) + ' failed: ' + string(Info.Diagnostic));
end;
THPDFBarcodeDecodeInfo คือจุดที่ pipeline ระดับการผลิตเก็บกินมันมา RotationAttemptCount กับ DecoderCallCount บอกคุณว่า retry วงนอกวิ่งเลยหรือไม่ การเทียบ ReceivedResultCount กับ AcceptedResultCount แยกตัวถอดรหัสที่หาอะไรไม่เจอออกจาก threshold ความมั่นใจที่ปฏิเสธทุกสิ่งที่หาเจอ ส่วน RenderedPixels คู่กับ PeakWorkingBytes คือสิ่งที่คุณจะวาดกราฟเมื่อ batch job เริ่มหอบ ผลลัพธ์ว่างเปล่าบวก bdsSucceeded แปลว่าหน้านั้นไม่มี symbol ที่อ่านได้จริง ๆ ซึ่งเป็นข้อเท็จจริงเชิง operation ที่ต่างจาก bdsBudgetExceeded คนละเรื่อง
ฟิลด์งบประมาณสมควรได้การตัดสินใจอย่างตั้งใจ ไม่ใช่ค่าเริ่มต้นตามสบาย MaxPixels กับ MaxWorkingBytes มีอยู่เพราะ DPI คูณแบบกำลังสอง: ขยับจาก 300 เป็น 600 DPI บนหน้า A4 เพิ่มต้นทุนการเรนเดอร์และการจองสูงสุดสี่เท่า และ input ที่ไม่น่าเชื่อถือที่ประกาศ page box ยักษ์สามารถเปลี่ยนงานสแกนเป็นเหตุการณ์หน่วยความจำเต็มได้ ตั้งเพดานตามที่เอกสารจริงที่แย่ที่สุดของคุณต้องการ แล้วให้ bdsBudgetExceeded ส่งตัวนอกค่าเฉลี่ยไปเส้นทางที่ช้ากว่าและแยกกัน
ถ้าเอกสารของคุณผสมฉลากที่เครื่องอ่านได้กับข้อความพิมพ์ที่วางแผนจะจัดทำดัชนี ตัวถอดรหัส barcode จับคู่กับเครื่องรู้จำที่เล่าไว้ใน OCR template matching ภายใน HotPDF ได้อย่างเป็นธรรมชาติ ฝั่งผลิตของเรื่องเดียวกันอยู่ใน การวาด barcode ลง PDF ด้วย HotPDF ทั้งคู่วิ่งบน infrastructure เรนเดอร์และงบประมาณชุดเดียวกัน pipeline ที่ตั้งเพดานสมเหตุสมผลไว้กับตัวหนึ่งจึงได้อีกตัวมาแทบฟรี
ความทนทานต่อการหมุนเป็นฟีเจอร์ประเภทที่มองไม่เห็นเมื่อมันทำงาน และน่าหมั่นไส้เวลามันไม่ทำงาน บทเรียนวิศวกรรมขยายได้เกิน QR: normalize ให้ใกล้การแทนค่าเชิง semantic เท่าที่จะไปถึงได้ ไม่ใช่ที่ชั้นพิกเซลซึ่งข้อมูลยังพกอุบัติเหตุทุกอย่างจากตอนมันถูกจับมา HotPDF ส่งสิ่งนี้มาเป็นส่วนหนึ่งของ HotPDF Delphi PDF component ควบคู่กับชิ้นเรนเดอร์, OCR และวิเคราะห์หน้าที่ pipeline รับงานแบบเดียวกันมักต้องใช้