Skip to content

Commit

Permalink
Leverage PyAV in mosaic_py for decoding
Browse files Browse the repository at this point in the history
With PyAV's bindings to FFMPEG it's trivial to offload decoding to
FFMPEG.

While fun, it wasn't feasible to write a decoder for every format used
in QTVR files. The self writter decoders also had slight color
differences with respect to FFMPEG.

Note:
    PyAV needs to be installed by hand, see the README for more
information.

Long story short, there are two issues:

1. Regular PyAV from PyPI doesn't expose `bits_per_coded_sample` on codecs.
    PR 1162 resolves the issue:
    PyAV-Org/PyAV#1162

2. PyAV can't be build easily.
    Missing is Cython 3 support from the source.
    Apply this PR PyAV-Org/PyAV#1145
  • Loading branch information
rvanlaar committed Sep 13, 2023
1 parent 61d68f0 commit 9dfc912
Show file tree
Hide file tree
Showing 3 changed files with 107 additions and 63 deletions.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,27 @@ Example files can be downloaded from:
https://www.dr-lex.be/info-stuff/qtvr.html

PRs welcome

## Build

A special build for PyAV is needed with 2 PRs combined.
https://github.com/PyAV-Org/PyAV/pull/1145
https://github.com/PyAV-Org/PyAV/pull/1163


To build PyAV:
```
git clone https://github.com/rvanlaar/PyAV
cd PyAV
git switch cython3
source scripts/activate.sh
scripts/build-deps
python3 setup.pt bdist_wheel
```

This will create a file in dist/. In my case `av-10.0.0-cp310-cp310-linux_x86_64.whl`

Add PyAV with poetry:
```
poetry add ../PyAV/dist/av-10.0.0-cp310-cp310-linux_x86_64.whl
```
85 changes: 54 additions & 31 deletions mosaic_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,27 @@
from mrcrowbar.utils import to_uint32_be as FourCCB
from PIL import Image

from mr_quicktime import (NAVGAtom, QuickTime, ctypAtom, get_atom, get_atoms,
import av

from mr_quicktime import (NAVGAtom, QuickTime, get_atom, get_atoms,
stblAtom, stcoAtom, stscAtom, stsdAtom, stszAtom,
tkhdAtom, trakAtom)
from rpza import create_image_rpza
from rle import create_image_rle
tkhdAtom, trakAtom, is_qtvr, QTVRType)

formats = {"rpza": "rpza",
"rle ": "qtrle",
"cine": "cinepak"}

formats = {"rpza": create_image_rpza,
"rle ": create_image_rle}
def create_image(codec: av.codec.Codec, data: bytes) -> Image:
p = av.Packet(data)
frame = codec.decode(p)[0]
return frame.to_image()

def parse_file(filename: Path):
f = open(filename, "rb").read()
return QuickTime(f)
with open(filename, "rb") as f:
qt = QuickTime(f.read())
return qt

def handle_object_movies(filename, qt):
print("detected: object movie")
navg_list = get_atoms(qt, NAVGAtom)
if len(navg_list) != 1:
print("is object movie, but wrong number of NAVGatoms")
Expand All @@ -40,6 +46,7 @@ def handle_object_movies(filename, qt):
width = int(trak_header.obj.track_width)
height = int(trak_header.obj.track_height)

print(f"width: {width}\nheight: {height}")
sample_table = get_atom(trak_atom, stblAtom)
# Note: handle samples per chunk in stsc atom
sample_size_table = get_atom(sample_table, stszAtom).obj.sample_size_table
Expand All @@ -50,12 +57,16 @@ def handle_object_movies(filename, qt):

data_format = FourCCB(sample_description_table.data_format).decode("ASCII")
depth = sample_description_table.depth
create_image = formats.get(data_format, None)
if create_image is None:
ffmpeg_codec = formats.get(data_format, None)
if ffmpeg_codec is None:
print(f"Unknown file format: {data_format}")
print(f"Can only handle RPZA and RLE 24 bits movies.")
print(f"Can only handle RPZA, cinepak and RLE movies.")
exit(1)

codec = av.Codec(ffmpeg_codec, "r").create()
codec.width = width
codec.height = height
codec.bits_per_coded_sample = depth

stsc = get_atom(sample_table, stscAtom).obj
samples_per_chunk = stsc.sample_to_chunk_table[0].samples_per_chunk
Expand Down Expand Up @@ -88,35 +99,47 @@ def handle_object_movies(filename, qt):
chunk_id += 1
sample_id += 1

for sample_id, sample_size in enumerate(sample_sizes):
chunk_id, first_in_chunk = sample_to_chunk[sample_id]
if first_in_chunk is True:
sample_offset = 0
chunk_offset = chunk_offsets[chunk_id - 1]
total_offset = chunk_offset + sample_offset
frame = create_image(filename, sample_size, total_offset, width, height, depth)
sample_offset += sample_size

# write frame out to the destination mosaic
column = sample_id % columns
row = sample_id // columns
pos = (column * width, row * height)
dst.paste(frame.create_img(), pos)
with open(filename, "rb") as movie:
for sample_id, sample_size in enumerate(sample_sizes):
chunk_id, first_in_chunk = sample_to_chunk[sample_id]
if first_in_chunk is True:
sample_offset = 0
chunk_offset = chunk_offsets[chunk_id - 1]
absolute_offset = chunk_offset + sample_offset

movie.seek(absolute_offset)
data = movie.read(sample_size)

image = create_image(codec, data)
sample_offset += sample_size

# write frame out to the destination mosaic
column = sample_id % columns
row = sample_id // columns
pos = (column * width, row * height)
dst.paste(image, pos)

name = filename.name
dst.save(f"mosaic-{name}.png")

def handle_panorama_movies(filename: Path, qt: QuickTime):
pass

def main():
parser = argparse.ArgumentParser()
parser.add_argument("filename", metavar="FILE", type=Path, help="QTVR v1 movie")
args = parser.parse_args()

qt = parse_file(args.filename)
ctype = get_atom(qt, ctypAtom)
controller_id = FourCCB(ctype.obj.id)
if controller_id == b"stna":
handle_object_movies(args.filename, qt)

match is_qtvr(qt):
case QTVRType.OBJECT:
print("detected: object movie")
handle_object_movies(args.filename, qt)
case QTVRType.PANORAMA:
print("detected: panorama movie")
handle_panorama_movies(args.filename, qt)
case _:
print("Not a QTVR 1 movie")

if __name__ == "__main__":
main()
61 changes: 29 additions & 32 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 9dfc912

Please sign in to comment.