【问题标题】:Parsing PPM images in Haskell在 Haskell 中解析 PPM 图像
【发布时间】:2014-01-15 06:14:46
【问题描述】:

我开始学习 Haskell 并希望为 execrsice 解析 PPM 图像。 PPM 格式的结构相当简单,但也很棘手。它被描述为here。首先,我为 PPM Image 定义了一个类型:

data Pixel = Pixel { red :: Int, green :: Int, blue :: Int} deriving(Show)
data BitmapFormat = TextualBitmap | BinaryBitmap deriving(Show)
data Header = Header { format :: BitmapFormat
                     , width :: Int
                     , height :: Int
                     , colorDepth :: Int} deriving(Show)
data PPM = PPM { header :: Header
               , bitmap :: [Pixel]
               }

bitmap 应该包含整个图像。这是第一个挑战出现的地方 - 在 PPM 中包含实际图像数据的部分可以是文本或二进制(在标题中描述)。 对于文本位图,我编写了以下函数:

parseTextualBitmap :: String -> [Pixel]
parseTextualBitmap = map textualPixel . chunksOf 3 . wordsBy isSpace
                     where textualPixel (r:g:b:[]) = Pixel (read r) (read g) (read b)

不过,我不确定如何处理二进制位图。使用read 将数字的字符串表示形式转换为数字。我想将 "\x01" 转换为 Int 类型的 1。

第二个挑战是解析标题。我写了以下函数:

parseHeader :: String -> Header
parseHeader = constructHeader . wordsBy isSpace . filterComments
              where
                filterComments = unlines . map (takeWhile (/= '#')) . lines
                formatFromText s
                  | s == "P6" = BinaryBitmap
                  | s == "P3" = TextualBitmap
                constructHeader (format:width:height:colorDepth:_) =
                  Header (formatFromText format) (read width) (read height) (read colorDepth)

效果很好。现在我应该编写模块导出函数(我们称之为parsePPM),它获取整个文件内容(String),然后返回PPM。该函数应该调用parseHeader,确定位图格式,调用适当的parse(Textual|Binary)Bitmap,然后用结果构造一个PPM。一旦 parseHeader 返回,我应该从 parseHeader 停止的点开始解码位图。但是,我不知道字符串 parseHeader 停止的点。我能想到的唯一解决方案是,当元组的第二个元素是constructHeader(当前命名为_)检索到的余数时,parseHeader 将返回(Header,String),而不是Header。但我不太确定这是做事的“Haskell 方式”。

总结一下我的问题: 1.如何将二进制格式解码成Pixel的列表 2.如何知道header在哪一点结束

由于我自己学习 Haskell,我没有人真正审查我的代码,所以除了回答我的问题外,我还会对我的编码方式(编码风格、错误、做事的替代方式、等等……)。

【问题讨论】:

  • 没有足够的时间给出完整的答案,但你应该看看Data.Binary.GetData.ByteString.Lazy
  • 返回未解析的余数绝对是“Haskell 方式”。看看Read 类型类是如何根据ReadS 类型类实现的。
  • @JonPurdy - 如果我理解正确,我不能将StringByteString 混合使用,所以如果我想使用unpack,我必须使用ByteString 的readFile 访问该文件。这不会影响我将标题视为文本的能力吗?
  • 我认为 PPM 规范中隐含的假设是 PPM 文件是 ASCII 字符/八位字节的文件 - 即。字节。我怀疑您是否会看到 UTF-16 编码的 PPM 文件。

标签: parsing haskell ppm


【解决方案1】:

让我们从问题 2 开始,因为它更容易回答。您的方法是正确的:在解析内容时,从输入字符串中删除这些字符,并返回一个包含解析结果和剩余字符串的元组。但是,没有理由从头开始编写所有这些内容(可能作为学术练习除外) - 有很多解析器会为您解决这个问题。我将使用的是Parsec。如果您不熟悉 monadic 解析,您应该首先阅读 the section on Parsec in RWH.

至于问题1,如果你使用ByteString而不是String,那么解析单个字节很容易,因为单个字节是ByteStrings的原子元素!

还有Char/ByteString接口的问题。对于Parsec,这不是问题,因为您可以将ByteString 视为ByteChar 的序列 - 我们稍后会看到。

我决定只编写完整的解析器 - 这是一种非常简单的语言,因此在 Parsec 库中为您定义了所有原语,它非常简单且非常简洁。

文件头:

import Text.Parsec.Combinator
import Text.Parsec.Char
import Text.Parsec.ByteString
import Text.Parsec 
import Text.Parsec.Pos

import Data.ByteString (ByteString, pack)
import qualified Data.ByteString.Char8 as C8

import Control.Monad (replicateM)
import Data.Monoid

首先,我们编写“原始”解析器 - 即解析字节、解析文本数字和解析空格(PPM 格式用作分隔符):

parseIntegral :: (Read a, Integral a) => Parser a
parseIntegral = fmap read (many1 digit)

digit 解析单个数字 - 您会注意到许多函数名称解释了解析器的作用 - 而many1 将应用给定的解析器 1 次或更多次。然后我们读取结果字符串以返回一个实际数字(而不是字符串)。在这种情况下,输入 ByteString 被视为文本。

parseByte :: Integral a => Parser a
parseByte = fmap (fromIntegral . fromEnum) $ tokenPrim show (\pos tok _ -> updatePosChar pos tok) Just

对于这个解析器,我们解析一个Char——它实际上只是一个字节。它只是作为Char 返回。我们可以安全地设置返回类型Parser Word8,因为可以返回的值的范围是[0..255]

whitespace1 :: Parser ()
whitespace1 = many1 (oneOf "\n ") >> return ()

oneOf 获取Char 列表并按给定顺序解析任何一个字符 - 同样,ByteString 被视为Text

现在我们可以编写头部的解析器了。

parseHeader :: Parser Header 
parseHeader = do
  f <- choice $ map try $ 
         [string "P3" >> return TextualBitmap
         ,string "P6" >> return BinaryBitmap]
  w <- whitespace1 >> parseIntegral
  h <- whitespace1 >> parseIntegral
  d <- whitespace1 >> parseIntegral
  return $ Header f w h d

一些笔记。 choice 获取解析器列表并按顺序尝试它们。 try p 采用解析器 p,并“记住”p 开始解析之前的状态。如果 p 成功,则try p == p。如果 p 失败,则恢复 p 开始之前的状态,并且您假装您从未尝试过p。由于choice 的行为方式,这是必要的。

对于像素,我们目前有两种选择:

parseTextual :: Header -> Parser [Pixel]
parseTextual h = do
  xs <- replicateM (3 * width h * height h) (whitespace1 >> parseIntegral)
  return $ map (\[a,b,c] -> Pixel a b c) $ chunksOf 3 xs

我们可以使用many1 (whitespace 1 &gt;&gt; parseIntegral) - 但这不会强制执行我们知道长度应该是多少的事实。然后,将数字列表转换为像素列表很简单。

对于二进制数据:

parseBinary :: Header -> Parser [Pixel]
parseBinary h = do
  whitespace1
  xs <- replicateM (3 * width h * height h) parseByte
  return $ map (\[a,b,c] -> Pixel a b c) $ chunksOf 3 xs

请注意两者几乎相同。您可能可以概括此函数(如果您决定解析其他类型的像素数据 - 单色和灰度,这将特别有用)。

现在把它们放在一起:

parsePPM :: Parser PPM
parsePPM = do
  h <- parseHeader
  fmap (PPM h) $ 
       case format h of
         TextualBitmap -> parseTextual h
         BinaryBitmap  -> parseBinary  h

这应该是不言自明的。解析头部,然后根据格式解析正文。这里有一些例子可以试一试。它们是规范页面中的那些。

example0 :: ByteString
example0 = C8.pack $ unlines 
  ["P3"
  , "4 4"
  , "15"
  , " 0  0  0    0  0  0    0  0  0   15  0 15"
  , " 0  0  0    0 15  7    0  0  0    0  0  0"
  , " 0  0  0    0  0  0    0 15  7    0  0  0"
  , "15  0 15    0  0  0    0  0  0    0  0  0" ]

example1 :: ByteString
example1 = C8.pack ("P6 4 4 15 ") <> 
  pack [0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0, 15, 0, 0, 0, 0, 15, 7, 
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 7, 0, 0, 0, 15,
        0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]

几个注意事项:这不处理作为规范的一部分的 cmets。错误消息不是很有用;您可以使用&lt;?&gt; 函数来创建您自己的错误消息。规范还指出“行不应超过 70 个字符。” - 这也没有强制执行。

编辑:

仅仅因为您看到 do-notation,并不一定意味着您正在使用不纯的代码。一些单子(比如这个解析器)仍然是纯粹的——它们只是为了方便而使用。例如,您可以使用parser :: String -&gt; (a, String) 类型编写解析器,或者,我们在这里所做的是,我们使用新类型:data Parser a = Parser (String -&gt; (a, String)) 并拥有parser :: Parser a;然后我们为Parser 编写一个monad 实例来获得有用的do-notation。需要明确的是,Parsec 支持单子解析,但我们的解析器不是单子 - 或者更确切地说,使用 Identity monad,它只是 newtype Identity a = Identity { runIdentity :: a },并且只是必要的,因为如果我们使用 type Identity a = a,我们将拥有 '到处重叠实例的错误,这不好。

>:i Parser
type Parser = Parsec ByteString ()
        -- Defined in `Text.Parsec.ByteString'
>:i Parsec
type Parsec s u = ParsecT s u Data.Functor.Identity.Identity
        -- Defined in `Text.Parsec.Prim'

那么,Parser 的类型实际上是 ParsecT ByteString () Identity。也就是说,解析器输入是ByteString,用户状态是()——这只是意味着我们没有使用用户状态,我们正在解析的monad是IdentityParsecT 本身只是一种新型:

forall b.
    State s u
    -> (a -> State s u -> ParseError -> m b)
    -> (ParseError -> m b)
    -> (a -> State s u -> ParseError -> m b)
    -> (ParseError -> m b)
    -> m b

中间的所有这些函数都只是用来漂亮地打印错误。如果您正在解析 10 的数千个字符并发生错误,您将无法仅查看它并查看发生的位置 - 但Parsec 会告诉您行和列。如果我们将所有类型特化为Parser,并假设Identity 只是type Identity a = a,那么所有的monad 都消失了,你可以看到解析器不是不纯的。如您所见,Parsec 比解决此问题所需的功能强大得多 - 由于熟悉,我只是使用它,但是如果您愿意编写自己的原始函数,例如 manydigit,那么您可以使用newtype Parser a = Parser (ByteString -&gt; (a, ByteString))

【讨论】:

  • 感谢您提供详细描述的答案。我缺少一些完全理解它所需的基本知识,所以我会在进一步阅读后回到它。不过,我有一个问题 - 我注意到解析是使用 do-notations 完成的,这(据我目前的理解)使这些函数不纯。为非 IO 事物编写不纯函数不是一个糟糕的 Haskell 实践吗?
  • 我编辑了我的答案以解决这个问题 - tl;dr 是我编写的解析器实际上是纯的,并不是所有的单子都是不纯的。
  • 其实所有的符号都是纯粹的。运行时在main执行 IO 操作是杂质的来源,而不是IO a 类型的实际值或其通过do 表示法构造的值。
猜你喜欢
  • 2011-10-07
  • 2010-11-22
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2023-03-19
  • 1970-01-01
  • 2022-08-22
  • 1970-01-01
相关资源
最近更新 更多