fvid/fvid/fvid.py

261 lines
6.4 KiB
Python

from bitstring import Bits, BitArray
from magic import Magic
import mimetypes
from PIL import Image
import glob
import numpy as np
from tqdm import tqdm
import ffmpeg
import binascii
import argparse
DELIMITER = bin(int.from_bytes("HELLO MY NAME IS ALFREDO".encode(), "big"))
FRAMES_DIR = "./fvid_frames/"
def get_bits_from_file(filepath):
bitarray = BitArray(filename=filepath)
# adding a delimiter to know when the file ends to avoid corrupted files
# when retrieving
bitarray.append(DELIMITER)
return bitarray.bin
def less(val1, val2):
return val1 < val2
def get_bits_from_image(image):
width, height = image.size
done = False
px = image.load()
bits = ""
delimiter_str = DELIMITER.replace("0b", "")
delimiter_length = len(delimiter_str)
pbar = tqdm(range(height), desc="Getting bits from frame")
white = (255, 255, 255)
black = (0, 0, 0)
for y in pbar:
for x in range(width):
# check if we have hit the delimiter
if bits[-delimiter_length:] == delimiter_str:
# remove delimiter from bit data to have an exact one to one
# copy when decoding
bits = bits[: len(bits) - delimiter_length]
pbar.close()
return (bits, True)
pixel = px[x, y]
pixel_bin_rep = "0"
# for exact matches
if pixel == white:
pixel_bin_rep = "1"
elif pixel == black:
pixel_bin_rep = "0"
else:
white_diff = tuple(map(abs, map(sub, white, pixel)))
# min_diff = white_diff
black_diff = tuple(map(abs, map(sub, black, pixel)))
# if the white difference is smaller, that means the pixel is closer
# to white, otherwise, the pixel must be black
if all(map(less, white_diff, black_diff)):
pixel_bin_rep = "1"
else:
pixel_bin_rep = "0"
# adding bits
bits += pixel_bin_rep
return (bits, done)
def get_bits_from_video(video_filepath):
# get image sequence from video
image_sequence = []
ffmpeg.input(video_filepath).output(
f"{FRAMES_DIR}decoded_frames%03d.png"
).run(quiet=True)
for filename in glob.glob(f"{FRAMES_DIR}decoded_frames*.png"):
image_sequence.append(Image.open(filename))
bits = ""
sequence_length = len(image_sequence)
for index in range(sequence_length):
b, done = get_bits_from_image(image_sequence[index])
bits += b
if done:
break
return bits
def save_bits_to_file(file_path, bits):
# get file extension
bitstring = Bits(bin=bits)
mime = Magic(mime=True)
mime_type = mime.from_buffer(bitstring.tobytes())
# If filepath not passed in use defualt
# otherwise used passed in filepath
if file_path == None:
filepath = f"file{mimetypes.guess_extension(type=mime_type)}"
else:
filepath = file_path
with open(
filepath, "wb"
) as f:
bitstring.tofile(f)
def make_image(bit_set, resolution=(1920, 1080)):
width, height = resolution
image = Image.new("1", (width, height))
image.putdata(bit_set)
return image
def split_list_by_n(lst, n):
for i in range(0, len(lst), n):
yield lst[i : i + n]
def make_image_sequence(bitstring, resolution=(1920, 1080)):
width, height = resolution
# split bits into sets of width*height to make (1) image
set_size = width * height
# bit_sequence = []
bit_sequence = split_list_by_n(list(map(int, bitstring)), width * height)
image_bits = []
# using bit_sequence to make image sequence
image_sequence = []
for bit_set in bit_sequence:
image_sequence.append(make_image(bit_set))
return image_sequence
def make_video(output_filepath, image_sequence, framerate="1/5"):
if output_filepath == None:
outputfile = "file.mp4"
else:
outputfile = output_filepath
frames = glob.glob(f"{FRAMES_DIR}encoded_frames*.png")
# for one frame
if len(frames) == 1:
ffmpeg.input(frames[0], loop=1, t=1).output(
outputfile, vcodec="libx264rgb"
).run(quiet=True)
else:
ffmpeg.input(
f"{FRAMES_DIR}encoded_frames*.png",
pattern_type="glob",
framerate=framerate,
).output(outputfile, vcodec="libx264rgb").run(quiet=True)
def cleanup():
# remove frames
import shutil
shutil.rmtree(FRAMES_DIR)
def setup():
import os
if not os.path.exists(FRAMES_DIR):
os.makedirs(FRAMES_DIR)
def main():
parser = argparse.ArgumentParser(description="save files as videos")
parser.add_argument(
"-e", "--encode", help="encode file as video", action="store_true"
)
parser.add_argument(
"-d", "--decode", help="decode file from video", action="store_true"
)
parser.add_argument("-i", "--input", help="input file", required=True)
parser.add_argument("-o", "--output", help="output path")
parser.add_argument("-f", "--framerate", help="set framerate for encoding (as a fraction)", default="1/5", type=str)
args = parser.parse_args()
setup()
if args.decode:
bits = get_bits_from_video(args.input)
file_path = None
if args.output:
file_path = args.output
save_bits_to_file(file_path, bits)
elif args.encode:
# isdigit has the benefit of being True and raising an error if the user passes a negative string
# all() lets us check if both the negative sign and forward slash are in the string, to prevent negative fractions
if (not args.framerate.isdigit() and "/" not in args.framerate) or all(x in args.framerate for x in ("-", "/")):
raise NotImplementedError("The framerate must be a positive fraction or an integer for now, like 3, '1/3', or '1/5'!")
# get bits from file
bits = get_bits_from_file(args.input)
# create image sequence
image_sequence = make_image_sequence(bits)
# save images
for index in range(len(image_sequence)):
image_sequence[index].save(
f"{FRAMES_DIR}encoded_frames_{index}.png"
)
video_file_path = None
if args.output:
video_file_path = args.output
make_video(video_file_path, image_sequence, args.framerate)
cleanup()