src/main.lisp @ 8a6db152fb11

Add utility functions for reading textures for OpenGL
author Steve Losh <steve@stevelosh.com>
date Sat, 02 Feb 2019 14:30:59 -0500
parents 1e155f658715
children 70f64dff49b5
(in-package :netpbm)

;;;; Peekable Streams ---------------------------------------------------------
(defstruct (peekable-stream (:conc-name nil)
                            (:constructor make-peekable-stream (s)))
  (p nil :type (or null (unsigned-byte 8)))
  (s (error "Required") :type stream))


(defun read-byte (stream &optional (eof-error-p t))
  (if (p stream)
    (prog1 (p stream)
      (setf (p stream) nil))
    (cl:read-byte (s stream) eof-error-p nil)))

(defun peek-byte (stream)
  (when (null (p stream))
    (setf (p stream) (cl:read-byte (s stream))))
  (p stream))

(defun unread-byte (stream byte)
  (assert (null (p stream)))
  (setf (p stream) byte)
  (values))


;;;; Implementation -----------------------------------------------------------
;;; TODO: We're explicit about ASCII values here, but other places in the code
;;; rely on char-code and friends returning ASCII.  Eventually we should
;;; probably fix that.

(defconstant +space+ 32)
(defconstant +tab+ 9)
(defconstant +line-feed+ 10)
(defconstant +vertical-tab+ 11)
(defconstant +form-feed+ 12)
(defconstant +carriage-return+ 13)
(defconstant +comment-char+ 35)


(defun white-space-p (byte)
  (if (member byte (list +space+ +form-feed+
                         +tab+ +vertical-tab+
                         +line-feed+ +carriage-return+))
    t
    nil))

(defun line-terminator-p (byte)
  (if (member byte (list +line-feed+ +carriage-return+))
    t
    nil))


(defun skip-comment-body (stream)
  (loop :until (line-terminator-p (read-byte stream))))

(defun skip-whitespace (stream)
  (loop :for byte = (read-byte stream nil)
        :while (white-space-p byte)
        :finally (unread-byte stream byte)))


(defun error-junk (section byte)
  (error "Junk byte in ~A data: ~D (~S)" section byte (code-char byte)))

(defun byte-to-digit (byte)
  (when (and byte (<= (char-code #\0) byte (char-code #\9)))
    (- byte (char-code #\0))))


(defun read-raster-number (stream)
  "Read the next ASCII-encoded number from `stream` (does not allow comments)."
  (skip-whitespace stream)
  (loop :with i = nil
        :for byte = (read-byte stream nil)
        :for digit = (byte-to-digit byte)
        :unless (or (null byte) digit (white-space-p byte))
        :do (error-junk "raster" byte)
        :while digit
        :do (setf i (+ (* (or i 0) 10) digit))
        :finally (return i)))

(defun read-header-number (stream)
  "Read the next ASCII-encoded number from `stream` (allows comments)."
  (skip-whitespace stream)
  (loop :with i = nil
        :for byte = (read-byte stream nil)
        :for digit = (byte-to-digit byte)
        :while byte
        :while (cond ((= byte +comment-char+) (skip-comment-body stream) t)
                     (digit (setf i (+ (* (or i 0) 10) digit)) t)
                     ((white-space-p byte) nil)
                     (t (error-junk "header" byte)))
        :finally (return i)))

(defun read-magic-byte (stream)
  "Read the initial `P#` from `stream`, returning the magic `#` character."
  (assert (eql (cl:read-byte stream) (char-code #\P)) (stream)
          "Stream ~S does not appear to be in P*M file."
          stream)
  (code-char (cl:read-byte stream)))


(defun write-string-as-bytes (string stream)
  (loop :for ch :across string
        :do (write-byte (char-code ch) stream)))

(defun format-to-stream (stream &rest format-args)
  (write-string-as-bytes (apply #'format nil format-args) stream))

(defmacro check-number (place maximum-value)
  `(assert (typep ,place `(integer 0 ,maximum-value)) (,place)
     "Cannot write sample value ~D to Netpbm file with maximum value of ~D"
     ,place
     ,maximum-value))

(defun write-number-ascii (value stream maximum-value)
  "Write `value` to stream as an ASCII-encoded number, with sanity check."
  (check-number value maximum-value)
  (format-to-stream stream "~D " value))

(defun write-number-binary (value stream maximum-value)
  "Write `value` to `stream` as a binary value, with sanity check."
  (check-number value maximum-value)
  (write-byte value stream))

(defun write-line-feed (stream)
  (write-byte +line-feed+ stream))


(defun file-format (magic-byte)
  "Return `(values format binary?)` for the given magic byte character."
  (ecase magic-byte
    (#\1 (values :pbm nil))
    (#\2 (values :pgm nil))
    (#\3 (values :ppm nil))
    (#\4 (values :pbm t))
    (#\5 (values :pgm t))
    (#\6 (values :ppm t))))

(defun magic-byte (file-format binary?)
  "Return the magic byte character to use for the given format/encoding combination."
  (if binary?
    (ecase file-format
      (:pbm #\4)
      (:pgm #\5)
      (:ppm #\6))
    (ecase file-format
      (:pbm #\1)
      (:pgm #\2)
      (:ppm #\3))))


(defun pixel-type (format bit-depth)
  "Return the type specifier for a pixel of an image with the given `format` and `bit-depth`."
  (ecase format
    (:pbm 'bit)
    (:pgm `(integer 0 ,bit-depth))
    (:ppm `(simple-array (integer 0 ,bit-depth) (3)))))


(defun bits (byte)
  (loop :for i :from 7 :downto 0
        :collect (ldb (byte 1 i) byte)))

(defun make-color (r g b)
  (make-array 3
    :initial-contents (list r g b)
    :element-type 'fixnum))


;;;; Reading ------------------------------------------------------------------
(defun read-bitmap-binary (stream &aux (buffer nil))
  (flet ((read-bit (stream)
           (when (null buffer)
             (setf buffer (bits (read-byte stream))))
           (pop buffer))
         (flush-buffer ()
           (setf buffer nil)))
    (let* ((width (read-header-number stream))
           (height (read-header-number stream))
           (data (make-array (list width height) :element-type 'bit)))
      (dotimes (y height)
        (dotimes (x width)
          (setf (aref data x y) (- 1 (read-bit stream))))
        (flush-buffer))
      (values data :pbm 1))))

(defun read-bitmap-ascii (stream)
  (flet ((read-bit (stream)
           (skip-whitespace stream)
           (byte-to-digit (read-byte stream))))
    (let* ((width (read-header-number stream))
           (height (read-header-number stream))
           (data (make-array (list width height) :element-type 'bit)))
      (dotimes (y height)
        (dotimes (x width)
          (setf (aref data x y) (- 1 (read-bit stream)))))
      (values data :pbm 1))))

(defun read-graymap (stream binary?)
  (let* ((width (read-header-number stream))
         (height (read-header-number stream))
         (bit-depth (read-header-number stream))
         (data (make-array (list width height)
                 :element-type `(integer 0 ,bit-depth)))
         (reader (if binary? #'read-byte #'read-raster-number)))
    (dotimes (y height)
      (dotimes (x width)
        (setf (aref data x y) (funcall reader stream))))
    (values data :pgm bit-depth)))

(defun read-pixmap (stream binary?)
  (let* ((width (read-header-number stream))
         (height (read-header-number stream))
         (bit-depth (read-header-number stream))
         (data (make-array (list width height)
                 :element-type `(simple-array (integer 0 ,bit-depth) (3))))
         (reader (if binary? #'read-byte #'read-raster-number)))
    (dotimes (y height)
      (dotimes (x width)
        (setf (aref data x y) (make-color (funcall reader stream)
                                          (funcall reader stream)
                                          (funcall reader stream)))))
    (values data :ppm bit-depth)))

(defun read-texture (stream binary?)
  (let* ((width (read-header-number stream))
         (height (read-header-number stream))
         (bit-depth (float (read-header-number stream) 1.0f0))
         (data (make-array (* width height 3)
                 :element-type '(single-float 0.0 1.0)))
         (reader (if binary? #'read-byte #'read-raster-number)))
    (loop :for y :from (1- height) :downto 0 :do
          (dotimes (x width)
            (let ((i (+ (* y width 3) (* 3 x))))
              (setf (aref data (+ i 0)) (/ (funcall reader stream) bit-depth)
                    (aref data (+ i 1)) (/ (funcall reader stream) bit-depth)
                    (aref data (+ i 2)) (/ (funcall reader stream) bit-depth)))))
    (values data width height)))


(defun read-netpbm (stream format binary? texture?)
  (if texture?
    (ecase format
      (:ppm (read-texture stream binary?)))
    (ecase format
      (:pbm (if binary?
              (read-bitmap-binary stream)
              (read-bitmap-ascii stream)))
      (:pgm (read-graymap stream binary?))
      (:ppm (read-pixmap stream binary?)))))


;;;; Writing ------------------------------------------------------------------
(defun write-bitmap-binary (data stream &aux (buffer 0) (buffer-length 0))
  (labels ((write-buffer (stream)
             (write-byte buffer stream)
             (setf buffer 0 buffer-length 0))
           (write-bit (bit stream)
             (setf buffer (+ (ash buffer 1) bit))
             (incf buffer-length)
             (when (= buffer-length 8)
               (write-buffer stream)))
           (flush-buffer (stream)
             (when (plusp buffer-length)
               (setf buffer (ash buffer (- 8 buffer-length)))
               (write-buffer stream))))
    (destructuring-bind (width height) (array-dimensions data)
      (format-to-stream stream "P~D~%~D ~D~%" (magic-byte :pbm t) width height)
      (dotimes (y height)
        (dotimes (x width)
          (let ((pixel (aref data x y)))
            (write-bit (- 1 pixel) stream)))
        (flush-buffer stream)))))

(defun write-bitmap-ascii (data stream)
  (destructuring-bind (width height) (array-dimensions data)
    (format-to-stream stream "P~D~%~D ~D~%" (magic-byte :pbm nil) width height)
    (dotimes (y height)
      (dotimes (x width)
        (write-number-ascii (- 1 (aref data x y)) stream 1))
      (write-line-feed stream))))

(defun write-graymap (data stream binary? maximum-value)
  (let ((writer (if binary?
                  #'write-number-binary
                  #'write-number-ascii)))
    (destructuring-bind (width height) (array-dimensions data)
      (format-to-stream stream "P~D~%~D ~D~%~D~%"
                        (magic-byte :pgm binary?) width height maximum-value)
      (dotimes (y height)
        (dotimes (x width)
          (funcall writer (aref data x y) stream maximum-value))
        (unless binary? (write-line-feed stream))))))

(defun write-pixmap (data stream binary? maximum-value)
  (let ((writer (if binary?
                  #'write-number-binary
                  #'write-number-ascii)))
    (destructuring-bind (width height) (array-dimensions data)
      (format-to-stream stream "P~D~%~D ~D~%~D~%"
                        (magic-byte :ppm binary?) width height maximum-value)
      (dotimes (y height)
        (dotimes (x width)
          (let ((pixel (aref data x y)))
            (funcall writer (aref pixel 0) stream maximum-value)
            (funcall writer (aref pixel 1) stream maximum-value)
            (funcall writer (aref pixel 2) stream maximum-value)))
        (unless binary? (write-line-feed stream))))))


(defun write-netpbm (data stream format binary? maximum-value)
  (ecase format
    (:pbm (if binary?
            (write-bitmap-binary data stream)
            (write-bitmap-ascii data stream)))
    (:pgm (write-graymap data stream binary? maximum-value))
    (:ppm (write-pixmap data stream binary? maximum-value))))


;;;; API ----------------------------------------------------------------------
;;; TODO: The stream type checking here is kind of a mess.  Basically what we
;;; care about is the following:
;;;
;;;   * For input streams we need to be able to call (read-byte …) and get
;;;     back numbers in the range 0-255.
;;;   * For output streams we need to be able to call (write-byte …) with
;;;     numbers in the range 0-255.
;;;
;;; As far as I can tell, there's no way to verify this in advance.  Or, indeed,
;;; *at all*, because the spec for `write-byte` says:
;;;
;;; > Might signal an error of type type-error if byte is not an integer of the
;;; > stream element type of stream.
;;;
;;; "Might"?!

(defun read-from-stream (stream)
  "Read a PPM image file from `stream`, returning an array of pixels and more.

  `stream` must be a binary input stream, specifically of `(unsigned-byte 8)`s
  unless you *really* know what you're doing.

  The primary return value will be a 2D array with dimensions `(width height)`.
  Each element of the array will be a single pixel whose type depends on the
  image file format:

  * PBM: `bit`
  * PGM: `(integer 0 maximum-value)`
  * PPM: `(simple-array (integer 0 maximum-value) (3))`

  Two other values are returned:

  * The format of the image that was read (one of `:pbm`, `:pgm`, `:ppm`).
  * The bit depth of the image.

  "
  (check-type stream stream)
  (assert (input-stream-p stream) (stream)
    "Stream ~S is not an input stream." stream)
  (multiple-value-bind (format binary?)
      (file-format (read-magic-byte stream))
    (read-netpbm (make-peekable-stream stream) format binary? nil)))

(defun read-texture-from-stream (stream)
  "Read a PPM image file from `stream`, returning an OpenGL-style array and more.

  `stream` must be a binary input stream, specifically of `(unsigned-byte 8)`s
  unless you *really* know what you're doing.  The stream must contain a PPM
  formatted image — PBM and PGM images are not supported.
  
  The primary return value will be an OpenGL-style array of type:

    (simple-array (single-float 0.0 1.0) (* width height 3))

  The vertical axis of the image will be flipped, which is what OpenGL expects.

  Three values are returned: the array, the width, and the height.

  "
  (check-type stream stream)
  (assert (input-stream-p stream) (stream)
    "Stream ~S is not an input stream." stream)
  (multiple-value-bind (format binary?)
      (file-format (read-magic-byte stream))
    (read-netpbm (make-peekable-stream stream) format binary? t)))


(defun write-to-stream (stream data &key
                        (format :ppm)
                        (encoding :binary)
                        (maximum-value (ecase format (:pbm 1) ((:pgm :ppm) 255))))
  "Write a PPM image array `data` to `stream`.

  Nothing is returned.

  `stream` must be a binary output stream, specifically of `(unsigned-byte 8)`s
  unless you *really* know what you're doing.

  `format` must be one of `:pbm`, `:pgm`, `:ppm`.

  `encoding` must be one of `:binary`, `:ascii`.

  `maximum-value` must be the desired bit depth of the image (the maximum value
  any particular pixel can have).  For PBM images it must be `1`.

  For PBM and PGM images, `data` must be a two dimensional array of integers
  between `0` and `maximum-value` inclusive.

  For PPM images, `data` must be a two dimensional array of pixels, each of
  which must be a 3 element vector of integers between `0` and `maximum-value`
  inclusive.

  "
  (check-type stream stream)
  (assert (output-stream-p stream) (stream)
    "Stream ~S is not an output stream." stream)
  (check-type format (member :ppm :pgm :pbm))
  (check-type encoding (member :binary :ascii))
  (if (eql format :pbm)
    (check-type maximum-value (eql 1))
    (check-type maximum-value (integer 1 *)))
  (write-netpbm data stream format (eql :binary encoding) maximum-value)
  (values))


(defun read-from-file (path)
  "Read a PPM image file from `path`, returning an array of pixels and more.

  The primary return value will be a 2D array with dimensions `(width height)`.
  Each element of the array will be a single pixel whose type depends on the
  image file format:

  * PBM: `bit`
  * PGM: `(integer 0 maximum-value)`
  * PPM: `(simple-array (integer 0 maximum-value) (3))`

  Two other values are returned:

  * The format of the image that was read (one of `:pbm`, `:pgm`, `:ppm`).
  * The bit depth of the image.

  "
  (with-open-file (s path :direction :input :element-type '(unsigned-byte 8))
    (read-from-stream s)))

(defun read-texture-from-file (path)
  "Read a PPM image file from `path`, returning an OpenGL-style array and more.

  The primary return value will be an OpenGL-style array of type:

    (simple-array (single-float 0.0 1.0) (* width height 3))

  The vertical axis of the image will be flipped, which is what OpenGL expects.

  Three values are returned: the array, the width, and the height.

  "
  (with-open-file (s path :direction :input :element-type '(unsigned-byte 8))
    (read-texture-from-stream s)))

(defun write-to-file (path data &key
                      (if-exists nil if-exists-given)
                      (format :ppm)
                      (encoding :binary)
                      (maximum-value (ecase format (:pbm 1) ((:pgm :ppm) 255))))
  "Write a PPM image array `data` to a file at `path`.

  Nothing is returned.

  `format` must be one of `:pbm`, `:pgm`, `:ppm`.

  `encoding` must be one of `:binary`, `:ascii`.

  `maximum-value` must be the desired bit depth of the image (the maximum value
  any particular pixel can have).  For PBM images it must be `1`.

  For PBM and PGM images, `data` must be a two dimensional array of integers
  between `0` and `maximum-value` inclusive.

  For PPM images, `data` must be a two dimensional array of pixels, each of
  which must be a 3 element vector of integers between `0` and `maximum-value`
  inclusive.

  "
  (check-type format (member :ppm :pgm :pbm))
  (check-type encoding (member :binary :ascii))
  (if (eql format :pbm)
    (check-type maximum-value (eql 1))
    (check-type maximum-value (integer 1 *)))
  (flet ((write-it (stream)
           (write-to-stream stream data
                            :format format
                            :encoding encoding
                            :maximum-value maximum-value)))
    (if if-exists-given
      (with-open-file (s path :direction :output :if-exists if-exists :element-type '(unsigned-byte 8))
        (write-it s))
      (with-open-file (s path :direction :output :element-type '(unsigned-byte 8))
        (write-it s))))
  (values))