16

Coding4Fun - 點陣中文字型之快速圖形化

 3 years ago
source link: https://blog.darkthread.net/blog/dotmatrix-font-to-bitmap/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
Coding4Fun - 點陣中文字型之快速圖形化-黑暗執行緒

上週聊到中文點陣字型,年輕同學們可能沒啥感覺,但經歷過 DOS 時代的老人隔了幾十年後再摸到老東西,滿滿的回憶呀,感受格外強烈,本週就繼續在其中找樂子。

上回說到我沒找到明確授權且不是 GPL 的中文點陣字型(Open Source 沒問題,但真心不喜歡被 GPL 掐住脖子的感覺),我打算用思源黑體轉換成點陣字型以避開版權爭議,為準備後續大量批次作業,我先寫好一個將國喬、倚天字型 byte[] 資料轉中文字圖檔的小函式。

以國喬中文系統為例,普通中文字的尺寸是 16x14 點,在 16x16 字型檔 KCCHIN16.F00 會佔 28 個 Byte,例如「黑」這個字的字型資料是 00-04-1F-FE-10-84-16-B4-10-84-1F-FC-00-80-3F-FE-00-80-7F-FF-00-00-14-48-12-26-22-22,每一列為 2 Byte 共 16 Bit 記錄 16 個點是否塗色,共 14 列。若將這串數字稍加排列並轉成二進位,點陣資料的儲存原理就不難理解了。

註:以上的示意圖是用 JavaScript 寫的,這陣子較少碰前端,我刻意寫成網頁維持手感:線上版

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>中文 16x16 點陣字範例</title>
  <style>
    .z { margin-left: 16px; font-size: 9pt; }
    .z > span { 
      display: inline-block; width: 14px; 
      border: 1px solid gray;
      text-align: center; margin: 1px;
    }
    .z .v1 {
      color: white; background-color: #444;
    }
  </style>
</head>
<body>
  <div id=display></div>
  <script>
    var hex = '00-04-1F-FE-10-84-16-B4-10-84-1F-FC-00-80-3F-FE-00-80-7F-FF-00-00-14-48-12-26-22-22';
    var a = hex.split('-');
    var h = [];
    function toBin(v) {
      v = parseInt(v, 16);
      for (var j = 0; j < 8; j++) {
        var d = ((v << j) & 0x80) > 0 ? '1' : '0';
        h.push('<span class=v' + d + '>' + d + '</span>');
      }
    }
    for (var i = 0; i < a.length / 2; i++) {
      var o = i * 2;
      h.push('<div>' + a[o] + ' ' + a[o + 1] + '<span class=z>');
      toBin(a[o]);
      toBin(a[o + 1]);
      h.push('</span></div>');
    }
    document.getElementById('display').innerHTML = h.join('');
  </script>
</body>
</html>

按照這個原理,要將 byte[28] 轉成圖形,最直覺的做法是建一個 16x16 的 Bitmap,x 由 0 到 16,y 由 0 到 14,依據各位元是 1 是 0 將對映像素設成黑色或白色,例如:

public byte[] GetCharPng(char ch)
{
    var cd = new CharData(ch);
    var fontData = ReadFont(ch);
    var bmp = new Bitmap(cd.Category == CharCategories.Ascii ? 8 : 16, 16);
    var h = cd.Category == CharCategories.Symbol ? 16 : (cd.Category == CharCategories.Chinese ? 14 : 15);
    var w = cd.Category == CharCategories.Ascii ? 8 : 16;
    var offset = 0;
    for (var y = 0; y < h; y++)
    {
        var b = fontData[offset];
        for (var x = 0; x < w; x++)
        {
            if ((b & 0x80) != 0)
                bmp.SetPixel(x, y, Color.Black);
            if (w > 8 && x == 7)
                b = fontData[++offset];
            else
                b = (byte)(b << 1);
        }
        offset++;
    }
    using (var ms = new MemoryStream())
    {
        bmp.Save(ms, ImageFormat.Png);
        return ms.ToArray();
    }
}

以「黑」為例,這個 16x16 的 PNG 檔,大小為 192 Bytes,若 13195 個中文 + 765 個全形英數字符號,總大小應不超過 3MB(每個字的圖檔會因壓縮大小不一)。

但想想這個方法不算太有效率,.NET 的 Bitmap 圖檔型別支援 PixelFormat.Format1bppIndexed 格式,當顏色深度 為 1,系統會用用一個位元記錄黑白,儲存格式跟中文字型檔的原生格式相同,代表我們可以將其直接複製到 Bitmap 內部,以 Byte 為單位,取代一個一個像素設定。針對這種低階資料作業,Bitmap 提供了 LockBits()BitmapData.Scan0 等 API,允許開發人員藉由低階資料存取提升圖形運算速度。之前我用過它展示 Unsafe 的威力(延伸閱讀:Unsafe, But Fast!),這次的情境也非常適合靠它加速,除了將字型資料圖形化,未來向量字點陣字時,會先顯示成圖形再轉成相容的 byte[] 格式,亦可用相同技巧取代 GetPixel() 一點一點掃瞄,速度可望大幅提升。

廢話不多說,直接上 Code:

public byte[] GetCharBmp(char ch)
{
    var cd = new CharData(ch);
    var fontData = ReadFont(ch);
    var w = cd.Category == CharCategories.Ascii ? 8 : 16;
    var fontDataStride = w / 8;
    var h = 16;
    var bmp = new Bitmap(w, h, PixelFormat.Format1bppIndexed);
    var bmd = bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.Read, PixelFormat.Format1bppIndexed);
    var idx = 0;
    unsafe
    {
        byte* p = (byte*)bmd.Scan0;
        for (var y = 0; y<h; y++)
        {
            for (var s = 0; s < bmd.Stride; s++)
            {
                if (s < fontDataStride && idx < fontData.Length)
                {
                    p[0] = fontData[idx++];
                }
                p++;
            }
        }
    }
    bmp.UnlockBits(bmd);
    using (var ms = new MemoryStream())
    {
        bmp.Save(ms, ImageFormat.Bmp);
        return ms.ToArray();
    }
}

實際動手寫才會發現眉角,例如:BitmapData 有個 Stride 屬性,代表一列像素要用多少個 Byte 儲存,依直覺 16 點用兩個 Byte 就夠了。但實際上它會以 4 為單位遞增(可能要湊 32 Bits 與 CPU、暫存器的運算單位一致吧),不足 4 時以 4 計,官方說明有提到這點(如下),所以在複製資料時,每兩個 Byte 之後再跳過兩個 Byte,不是連續寫入。

The stride is the width of a single row of pixels (a scan line), rounded up to a four-byte boundary.

而存成 .bmp 檔時,每一列像素也是用 4 Bytes,加上 .bmp 檔的標頭,16x16 的黑白兩色圖檔大小為 126 Bytes,比 .png 小。

接下來跑個 Benchmark,用一小段程式對範例字串逐字元呼叫 GetCharPng() 及 GetCharBmp() 1000 次:

public IActionResult OnGet()
{
    var sw = new Stopwatch();
    var s = "我達達的馬蹄是美麗的錯誤";
    var ary = s.ToCharArray();
    int times = 1000;
    for (int j = 0; j < 3; j++)
    {
        sw.Restart();
        for (var i = 0; i < times; i++)
            s.ToCharArray().Select(ch => kcfa.GetCharPng(ch)).ToList();
        sw.Stop();
        Debug.WriteLine($"PNG: {sw.ElapsedTicks:n0}");
        sw.Restart();
        for (var i = 0; i < times; i++)
            s.ToCharArray().Select(ch => kcfa.GetCharBmp(ch)).ToList();
        sw.Stop();
        Debug.WriteLine($"BMP: {sw.ElapsedTicks:n0}");
    }
    return Content("OK");
}

數據顯示,GetCharBmp() 比 GetCharPng() 快了三倍多,但領先幅度不如預期大。

PNG: 25,683,409
BMP: 7,291,316
PNG: 25,021,673
BMP: 7,334,556
PNG: 24,495,296
BMP: 7,004,444

Visual Studio 2019 Diagnostic Tools 的效能圖表,SetPixel() 不意外榜上有名,但發現 Bitmap 建構式與 Save() 也挺耗 CPU,Save() 無法避免,但採用低階指令複製 byte[],Bitmap 物件應可以共用。於是我再改寫一版,預先建好半形及全形用兩個 Bitmap 靜態變數,之後直接共用不要每次重建(考慮可能多緒執行,宜加 lock 保護):

static Bitmap Bmp4Ascii = new Bitmap(8, 16, PixelFormat.Format1bppIndexed);
static Bitmap Bmp4Chinese = new Bitmap(16, 16, PixelFormat.Format1bppIndexed);

public byte[] GetCharBmp(char ch)
{
    var cd = new CharData(ch);
    var fontData = ReadFont(ch);
    var w = cd.Category == CharCategories.Ascii ? 8 : 16;
    var fontDataStride = w / 8;
    var h = 16;
    var bmp = cd.Category == CharCategories.Ascii ? Bmp4Ascii : Bmp4Chinese;
    lock (bmp)
    {
        var bmd = bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadWrite, PixelFormat.Format1bppIndexed);
        var idx = 0;
        unsafe
        {
            byte* p = (byte*)bmd.Scan0;
            for (var y = 0; y < h; y++)
            {
                for (var s = 0; s < bmd.Stride; s++)
                {
                    if (s < fontDataStride && idx < fontData.Length)
                    {
                        p[0] = fontData[idx++];
                    }
                    p++;
                }
            }
        }
        bmp.UnlockBits(bmd);
        using (var ms = new MemoryStream())
        {
            bmp.Save(ms, ImageFormat.Bmp);
            return ms.ToArray();
        }
    }
}

由實測結果,這項調整有抓到重點,Bitmap 產生時間再縮短一半:

PNG: 25,782,349
BMP: 3,352,245
PNG: 24,865,341
BMP: 3,334,074
The thread 0xaa4 has exited with code 0 (0x0).
PNG: 24,673,937
BMP: 3,479,733

若還想再加速,還有一些調整手法,例如:省下迴圈直接寫死 p[0] = fontData[0]; p[1] = fontData[1]; p[4] = fontData[2]; p[5] = fontData[3]...,但這類技巧要犠牲可讀性及可維護性,代價偏高。這次處理量不過一萬多筆,直覺不必搞到太極端,未來等真有需要再改也不遲。

今天的 Coding4Fun 練習了 JavaScript、Bitmap 低階操作、unsafe 及簡單效能調校,收獲滿滿,Coding 樂無窮~


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK