Initial commit

This commit is contained in:
Alex Eidt 2021-11-22 19:14:37 -08:00
commit 059f46f9ed
10 changed files with 511 additions and 0 deletions

0
.gitignore vendored Normal file
View file

90
README.md Normal file
View file

@ -0,0 +1,90 @@
# ImageIO
A simple Image and Video I/O library written in Go. This library relies on [FFMPEG](https://www.ffmpeg.org/), which must be downloaded before usage.
One of the key features of this library is it's simplicity: The FFMPEG commands used to read and write video are readily available in `video.go` and `videowriter.go` for you to modify as you need.
For portability, all the library code can be found in the `videoio.go` file.
## Documentation
ImageIO features `Video` and `VideoWriter` structs which can read and write videos.
## `Video`
The `Video` struct stores data about a video file you give it. The code below shows an example of sequentially reading the frames of the given video.
```go
video := NewVideo("input.mp4")
for video.NextFrame() {
frame := video.framebuffer // "frame" stores the video frame as a flattened RGB image.
}
```
Notice that once the `video` is initialized, you will have access to certain metadata of the video such as the
* width (pixels)
* height (pixels)
* bitrate (kB/s)
* duration (seconds)
* frames per second
* video codec
* pixel format
Once the frame is read by calling the `NextFrame()` function, the resulting frame is stored in the `framebuffer` as shown above. The frame buffer is an array of bytes representing the most recently read frame as an RGB image. The framebuffer is flattened and contains image data in the form: `RGBRGBRGBRGB...`.
## `VideoWriter`
The `VideoWriter` is used to write frames to a video file. You first need to create a `Video` struct with all the desired properties of the new video you want to create such as width, height and framerate.
```go
video := Video{
// width and height are required, defaults available for all other parameters.
width: 1920
height: 1080
... // Initialize other desired properties of the video you want to create.
}
writer := NewVideoWriter("output.mp4", video)
defer writer.Close() // Make sure to close writer.
w, h, c := 1920, 1080, 3
frame = make([]byte, w*h*c) // Create Frame as RGB Image and modify.
writer.Write(frame) // Write Frame to video.
...
```
Alternatively, you could manually create a `VideoWriter` struct and fill it in yourself.
```go
writer := VideoWriter{
filename: "output.mp4"
width: 1920
height: 1080
...
}
defer writer.Close() // Make sure to close writer.
w, h, c := 1920, 1080, 3
frame = make([]byte, w*h*c) // Create Frame as RGB Image and modify.
writer.Write(frame) // Write Frame to video.
...
```
## Examples
Copy `input` to `output`.
```go
video := NewVideo(input)
writer := NewVideoWriter(output, video)
defer writer.Close()
for video.NextFrame() {
writer.Write(video.framebuffer)
}
```
# Acknowledgements
* Special thanks to [Zulko](http://zulko.github.io/) and his [blog post](http://zulko.github.io/blog/2013/09/27/read-and-write-video-frames-in-python-using-ffmpeg/) about using FFMPEG to process video.

24
demo.go Normal file
View file

@ -0,0 +1,24 @@
package main
import "fmt"
func main() {
// Try it yourself!
// Update "filename" to a video file on your system and
// create and output file you'd like to copy this video to.
filename := "input.mp4"
output := "output.mp4"
video := NewVideo(filename)
fmt.Println(video.bitrate)
writer := NewVideoWriter(output, video)
defer writer.Close()
count := 0
for video.NextFrame() {
writer.Write(video.framebuffer)
count += 1
fmt.Println(count)
}
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module github.com/AlexEidt/test
go 1.16
require gocv.io/x/gocv v0.28.0

4
go.sum Normal file
View file

@ -0,0 +1,4 @@
github.com/hybridgroup/mjpeg v0.0.0-20140228234708-4680f319790e/go.mod h1:eagM805MRKrioHYuU7iKLUyFPVKqVV6um5DAvCkUtXs=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
gocv.io/x/gocv v0.28.0 h1:hweRS9Js60YEZPZzjhU5I+0E2ngazquLlO78zwnrFvY=
gocv.io/x/gocv v0.28.0/go.mod h1:oc6FvfYqfBp99p+yOEzs9tbYF9gOrAQSeL/dyIPefJU=

61
imageio.go Normal file
View file

@ -0,0 +1,61 @@
package main
import (
"fmt"
"image"
"image/jpeg"
"image/png"
"os"
"strings"
)
type Image struct {
width int
height int
channels int
data []byte
}
func ReadImage(filename string) *Image {
// Read image from "filename".
file, err := os.Open(filename)
if err != nil {
fmt.Printf("%s not found.", filename)
return nil
}
defer file.Close()
var im image.Image
if strings.HasSuffix(filename, "jpg") {
im, err = jpeg.Decode(file)
} else if strings.HasSuffix(filename, "png") {
im, err = png.Decode(file)
} else {
im, _, err = image.Decode(file)
}
if err != nil {
fmt.Printf("%s is an invalid image format. Could not parse.\n", filename)
return nil
}
bounds := im.Bounds().Max
data := make([]byte, bounds.Y*bounds.X*4)
// Fill in "data" with colors of the image.
index := 0
for y := 0; y < bounds.Y; y++ {
for x := 0; x < bounds.X; x++ {
r, g, b, a := im.At(x, y).RGBA()
data[index] = byte(r)
data[index+1] = byte(g)
data[index+2] = byte(b)
data[index+3] = byte(a)
index += 4
}
}
return &Image{width: bounds.X, height: bounds.Y, channels: 4, data: data}
}
func WriteImage(filename string, img *Image) {
}

72
parsing.go Normal file
View file

@ -0,0 +1,72 @@
package main
import (
"regexp"
"strconv"
"strings"
)
// Parses the duration of the video from the ffmpeg header.
func parseDurationBitrate(video *Video, data []string) {
videoData := ""
for _, line := range data {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Duration: ") {
videoData = line
break
}
}
if videoData == "" {
panic("Could not find duration in ffmpeg header.")
}
// Duration
duration := strings.Split(strings.SplitN(strings.SplitN(videoData, ",", 2)[0], "Duration:", 2)[1], ":")
seconds, _ := strconv.ParseFloat(duration[len(duration)-1], 64)
minutes, _ := strconv.ParseFloat(duration[len(duration)-2], 64)
hours, _ := strconv.ParseFloat(duration[len(duration)-3], 64)
video.duration = seconds + minutes*60 + hours*3600
// Bitrate
bitrate := strings.SplitN(strings.TrimSpace(strings.SplitN(videoData, "bitrate:", 2)[1]), " ", 2)[0]
video.bitrate, _ = strconv.Atoi(bitrate)
}
func parseVideoData(video *Video, data []string) {
videoData := ""
// Get string containing video data.
for _, line := range data {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Stream") && strings.Contains(line, "Video:") {
videoData = strings.TrimSpace(strings.SplitN(line, "Video:", 2)[1])
break
}
}
if videoData == "" {
panic("No video data found in ffmpeg header.")
}
// Video Codec
video.codec = strings.TrimSpace(strings.SplitN(videoData, " ", 2)[0])
// FPS
fpsstr := strings.SplitN(videoData, "fps", 2)[0]
fps, _ := strconv.Atoi(strings.TrimSpace(fpsstr[strings.LastIndex(fpsstr, ",")+1:]))
video.fps = float64(fps)
// Pixel Format
video.pix_fmt = strings.TrimSpace(strings.Split(videoData, ",")[1])
// Width and Height
r, _ := regexp.Compile("\\d+x\\d+")
wh := r.FindAllString(videoData, -1)
dims := strings.SplitN(wh[len(wh)-1], "x", 2)
width, _ := strconv.Atoi(dims[0])
height, _ := strconv.Atoi(dims[1])
video.width = width
video.height = height
}
// Parses the ffmpeg header.
// Code inspired by the imageio-ffmpeg project.
// GitHub: https://github.com/imageio/imageio-ffmpeg/blob/master/imageio_ffmpeg/_parsing.py#L113
func parseFFMPEGHeader(video *Video, header string) {
data := strings.Split(strings.ReplaceAll(header, "\r\n", "\n"), "\n")
parseDurationBitrate(video, data)
parseVideoData(video, data)
}

19
utils.go Normal file
View file

@ -0,0 +1,19 @@
package main
import (
"errors"
"os"
)
// Returns true if file exists, false otherwise.
// https://stackoverflow.com/questions/12518876/how-to-check-if-a-file-exists-in-go
func Exists(filename string) bool {
_, err := os.Stat(filename)
if err == nil {
return true
}
if errors.Is(err, os.ErrNotExist) {
return false
}
return false
}

114
video.go Normal file
View file

@ -0,0 +1,114 @@
package main
import (
"io"
"os"
"os/exec"
"os/signal"
"syscall"
)
type Video struct {
filename string
width int
height int
channels int
bitrate int
duration float64
fps float64
codec string
pix_fmt string
framebuffer []byte
pipe *io.ReadCloser
cmd *exec.Cmd
}
func NewVideo(filename string) *Video {
if !Exists(filename) {
panic("File: " + filename + " does not exist")
}
// Execute ffmpeg -i command to get video information.
cmd := exec.Command("ffmpeg", "-i", filename, "-")
// FFMPEG output piped to Stderr.
pipe, err := cmd.StderrPipe()
if err != nil {
panic(err)
}
if err := cmd.Start(); err != nil {
panic(err)
}
buffer := make([]byte, 2<<12)
total := 0
for {
n, err := pipe.Read(buffer[total:])
total += n
if err == io.EOF {
break
}
}
cmd.Wait()
video := &Video{filename: filename, channels: 3, pipe: nil, framebuffer: nil}
parseFFMPEGHeader(video, string(buffer))
return video
}
func (video *Video) initVideoStream() {
// If user exits with Ctrl+C, stop ffmpeg process.
video.cleanup()
cmd := exec.Command(
"ffmpeg",
"-loglevel", "quiet",
"-i", video.filename,
"-f", "image2pipe",
"-pix_fmt", "rgb24",
"-vcodec", "rawvideo", "-",
)
video.cmd = cmd
pipe, err := cmd.StdoutPipe()
if err != nil {
panic(err)
}
video.pipe = &pipe
if err := cmd.Start(); err != nil {
panic(err)
}
video.framebuffer = make([]byte, video.width*video.height*video.channels)
}
func (video *Video) NextFrame() bool {
// If cmd is nil, video reading has not been initialized.
if video.cmd == nil {
video.initVideoStream()
}
total := 0
for total < video.width*video.height*video.channels {
n, err := (*video.pipe).Read(video.framebuffer[total:])
if err == io.EOF {
(*video.pipe).Close()
if err := video.cmd.Wait(); err != nil {
panic(err)
}
return false
}
total += n
}
return true
}
// Stops the "cmd" process running when the user presses Ctrl+C.
// https://stackoverflow.com/questions/11268943/is-it-possible-to-capture-a-ctrlc-signal-and-run-a-cleanup-function-in-a-defe
func (video *Video) cleanup() {
c := make(chan os.Signal)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
if video.pipe != nil {
(*video.pipe).Close()
}
if video.cmd != nil {
video.cmd.Process.Kill()
}
os.Exit(1)
}()
}

122
videowriter.go Normal file
View file

@ -0,0 +1,122 @@
package main
import (
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"syscall"
)
type VideoWriter struct {
filename string
width int
height int
channels int
bitrate int
fps float64
codec string
pix_fmt string
pipe *io.WriteCloser
cmd *exec.Cmd
}
func NewVideoWriter(filename string, video *Video) *VideoWriter {
if video.width == 0 || video.height == 0 {
panic("Video width and height must be set.")
}
if video.channels == 0 {
video.channels = 3 // Default to RGB frames.
}
if video.fps == 0 {
video.fps = 25 // Default to 25 FPS.
}
if video.codec == "" {
video.codec = "mpeg4" // Default to MPEG4.
}
if video.pix_fmt == "" {
video.pix_fmt = "rgb24" // Default to RGB24.
}
return &VideoWriter{
filename: filename,
width: video.width,
height: video.height,
channels: video.channels,
bitrate: video.bitrate,
fps: video.fps,
codec: video.codec,
pix_fmt: video.pix_fmt,
}
}
func (writer *VideoWriter) initVideoWriter() {
// If user exits with Ctrl+C, stop ffmpeg process.
writer.cleanup()
cmd := exec.Command(
"ffmpeg",
"-y", // overwrite output file if it exists
"-f", "rawvideo",
"-vcodec", "rawvideo",
"-s", fmt.Sprintf("%dx%d", writer.width, writer.height), // frame w x h
"-pix_fmt", writer.pix_fmt,
"-r", fmt.Sprintf("%f", writer.fps), // frames per second
"-i", "-", // The imput comes from stdin
"-an", // Tells FFMPEG not to expect any audio
"-vcodec", writer.codec,
writer.filename,
)
writer.cmd = cmd
pipe, err := cmd.StdinPipe()
if err != nil {
panic(err)
}
writer.pipe = &pipe
if err := cmd.Start(); err != nil {
panic(err)
}
}
func (writer *VideoWriter) Write(frame []byte) {
// If cmd is nil, video writing has not been set up.
if writer.cmd == nil {
writer.initVideoWriter()
}
total := 0
for total < len(frame) {
n, err := (*writer.pipe).Write(frame[total:])
if err != nil {
fmt.Println("Likely cause is invalid parameters to FFMPEG.")
panic(err)
}
total += n
}
}
func (writer *VideoWriter) Close() {
if writer.pipe != nil {
(*writer.pipe).Close()
}
if writer.cmd != nil {
writer.cmd.Wait()
}
}
// Stops the "cmd" process running when the user presses Ctrl+C.
// https://stackoverflow.com/questions/11268943/is-it-possible-to-capture-a-ctrlc-signal-and-run-a-cleanup-function-in-a-defe
func (writer *VideoWriter) cleanup() {
c := make(chan os.Signal)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
if writer.pipe != nil {
(*writer.pipe).Close()
}
if writer.cmd != nil {
writer.cmd.Process.Kill()
}
os.Exit(1)
}()
}