├── README.md ├── TODO.txt ├── imagemaker.py ├── pgsreader.py └── tests ├── MissionImpossible ├── MI-image52386.sup └── Manual.Trunch.MI-image52386.ods └── SwapCrCb └── extractedred.sup /README.md: -------------------------------------------------------------------------------- 1 | # pgsreader 2 | Read Presentation Graphic Stream (.SUP) files and provide python objects for parsing through the data 3 | 4 | 5 | Example: 6 | 7 | from pgsreader import PGSReader 8 | from imagemaker import make_image 9 | 10 | # Load a Blu-Ray PGS/SUP file. 11 | pgs = PGSReader('mysubtitles.sup') 12 | 13 | # Get the first DisplaySet that contains a bitmap image 14 | display_set = next(ds for ds in pgs.iter_displaysets() if ds.has_image) 15 | 16 | # Get Palette Display Segment 17 | pds = display_set.pds[0] 18 | # Get Object Display Segment 19 | ods = display_set.ods[0] 20 | 21 | # Create and show the bitmap image 22 | img = make_image(ods, pds) 23 | img.save('my_subtitle_image.png') 24 | img.show() 25 | 26 | # Create and show the bitmap image with swapped YCbCr channels 27 | img = make_image(ods, pds, swap=True) 28 | img.save('my_subtitle_image.png') 29 | img.show() 30 | 31 | # Retrieve time when image would have been displayed on screen in milliseconds: 32 | timestamp_ms = ods.presentation_timestamp 33 | 34 | Extremely alpha, issues and suggestions welcome! 35 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | - Detectection scheme for Japanese Subs (or others langcodes) that may mix both vertical and horizontal subs 2 | - Fix Subtitle that do not have Image attribute but lack PDS info (particularly appears to be transitioning or Epoch ODS updates ) 3 | - Transform separate alpha to black straight away for OCR. Also apply brightness+contrast transformation to foreground text before merge 4 | -------------------------------------------------------------------------------- /imagemaker.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from PIL import Image 3 | 4 | def read_rle_bytes(ods_bytes): 5 | 6 | pixels = [] 7 | line_builder = [] 8 | 9 | i = 0 10 | while i < len(ods_bytes): 11 | if ods_bytes[i]: 12 | incr = 1 13 | color = ods_bytes[i] 14 | length = 1 15 | else: 16 | check = ods_bytes[i+1] 17 | if check == 0: 18 | incr = 2 19 | color = 0 20 | length = 0 21 | pixels.append(line_builder) 22 | line_builder = [] 23 | elif check < 64: 24 | incr = 2 25 | color = 0 26 | length = check 27 | elif check < 128: 28 | incr = 3 29 | color = 0 30 | length = ((check - 64) << 8) + ods_bytes[i + 2] 31 | elif check < 192: 32 | incr = 3 33 | color = ods_bytes[i+2] 34 | length = check - 128 35 | else: 36 | incr = 4 37 | color = ods_bytes[i+3] 38 | length = ((check - 192) << 8) + ods_bytes[i + 2] 39 | line_builder.extend([color]*length) 40 | i += incr 41 | 42 | if line_builder: 43 | print(f'Probably an error; hanging pixels: {line_builder}') 44 | 45 | return pixels 46 | 47 | def ycbcr2rgb(ar): 48 | xform = np.array([[1, 0, 1.402], [1, -0.34414, -.71414], [1, 1.772, 0]]) 49 | rgb = ar.astype(float) 50 | # Subtracting by 128 the R and G channels 51 | rgb[:,[1,2]] -= 128 52 | #.dot is multiplication of the matrices and xform.T is a transpose of the array axes 53 | rgb = rgb.dot(xform.T) 54 | # Makes any pixel value greater than 255 just be 255 (Max for RGB colorspace) 55 | np.putmask(rgb, rgb > 255, 255) 56 | # Sets any pixel value less than 0 to 0 (Min for RGB colorspace) 57 | np.putmask(rgb, rgb < 0, 0) 58 | return np.uint8(rgb) 59 | 60 | def px_rgb_a(ods, pds, swap): 61 | px = read_rle_bytes(ods.img_data) 62 | px = np.array([[255]*(ods.width - len(l)) + l for l in px], dtype=np.uint8) 63 | 64 | # Extract the YCbCrA palette data, swapping channels if requested. 65 | if swap: 66 | ycbcr = np.array([(entry.Y, entry.Cb, entry.Cr) for entry in pds.palette]) 67 | else: 68 | ycbcr = np.array([(entry.Y, entry.Cr, entry.Cb) for entry in pds.palette]) 69 | 70 | rgb = ycbcr2rgb(ycbcr) 71 | 72 | # Separate the Alpha channel from the YCbCr palette data 73 | a = [entry.Alpha for entry in pds.palette] 74 | a = np.array([[a[x] for x in l] for l in px], dtype=np.uint8) 75 | 76 | return px, rgb, a 77 | 78 | def make_image(ods, pds, swap=False): 79 | px, rgb, a = px_rgb_a(ods, pds, swap) 80 | alpha = Image.fromarray(a, mode='L') 81 | img = Image.fromarray(px, mode='P') 82 | img.putpalette(rgb) 83 | img.putalpha(alpha) 84 | return img 85 | -------------------------------------------------------------------------------- /pgsreader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from os.path import split as pathsplit 4 | from collections import namedtuple 5 | 6 | # Constants for Segments 7 | PDS = int('0x14', 16) 8 | ODS = int('0x15', 16) 9 | PCS = int('0x16', 16) 10 | WDS = int('0x17', 16) 11 | END = int('0x80', 16) 12 | 13 | # Named tuple access for static PDS palettes 14 | Palette = namedtuple('Palette', "Y Cr Cb Alpha") 15 | 16 | class InvalidSegmentError(Exception): 17 | '''Raised when a segment does not match PGS specification''' 18 | 19 | 20 | class PGSReader: 21 | 22 | def __init__(self, filepath): 23 | self.filedir, self.file = pathsplit(filepath) 24 | with open(filepath, 'rb') as f: 25 | self.bytes = f.read() 26 | 27 | 28 | def make_segment(self, bytes_): 29 | cls = SEGMENT_TYPE[bytes_[10]] 30 | return cls(bytes_) 31 | 32 | def iter_segments(self): 33 | bytes_ = self.bytes[:] 34 | while bytes_: 35 | size = 13 + int(bytes_[11:13].hex(), 16) 36 | yield self.make_segment(bytes_[:size]) 37 | bytes_ = bytes_[size:] 38 | 39 | def iter_displaysets(self): 40 | ds = [] 41 | for s in self.iter_segments(): 42 | ds.append(s) 43 | if s.type == 'END': 44 | yield DisplaySet(ds) 45 | ds = [] 46 | 47 | @property 48 | def segments(self): 49 | if not hasattr(self, '_segments'): 50 | self._segments = list(self.iter_segments()) 51 | return self._segments 52 | 53 | @property 54 | def displaysets(self): 55 | if not hasattr(self, '_displaysets'): 56 | self._displaysets = list(self.iter_displaysets()) 57 | return self._displaysets 58 | 59 | class BaseSegment: 60 | 61 | SEGMENT = { 62 | PDS: 'PDS', 63 | ODS: 'ODS', 64 | PCS: 'PCS', 65 | WDS: 'WDS', 66 | END: 'END' 67 | } 68 | 69 | def __init__(self, bytes_): 70 | self.bytes = bytes_ 71 | if bytes_[:2] != b'PG': 72 | raise InvalidSegmentError 73 | self.pts = int(bytes_[2:6].hex(), base=16)/90 74 | self.dts = int(bytes_[6:10].hex(), base=16)/90 75 | self.type = self.SEGMENT[bytes_[10]] 76 | self.size = int(bytes_[11:13].hex(), base=16) 77 | self.data = bytes_[13:] 78 | 79 | def __len__(self): 80 | return self.size 81 | 82 | @property 83 | def presentation_timestamp(self): return self.pts 84 | 85 | @property 86 | def decoding_timestamp(self): return self.dts 87 | 88 | @property 89 | def segment_type(self): return self.type 90 | 91 | class PresentationCompositionSegment(BaseSegment): 92 | 93 | class CompositionObject: 94 | 95 | def __init__(self, bytes_): 96 | self.bytes = bytes_ 97 | self.object_id = int(bytes_[0:2].hex(), base=16) 98 | self.window_id = bytes_[2] 99 | self.cropped = bool(bytes_[3]) 100 | self.x_offset = int(bytes_[4:6].hex(), base=16) 101 | self.y_offset = int(bytes_[6:8].hex(), base=16) 102 | if self.cropped: 103 | self.crop_x_offset = int(bytes_[8:10].hex(), base=16) 104 | self.crop_y_offset = int(bytes_[10:12].hex(), base=16) 105 | self.crop_width = int(bytes_[12:14].hex(), base=16) 106 | self.crop_height = int(bytes_[14:16].hex(), base=16) 107 | 108 | STATE = { 109 | int('0x00', base=16): 'Normal', 110 | int('0x40', base=16): 'Acquisition Point', 111 | int('0x80', base=16): 'Epoch Start' 112 | } 113 | 114 | def __init__(self, bytes_): 115 | BaseSegment.__init__(self, bytes_) 116 | self.width = int(self.data[0:2].hex(), base=16) 117 | self.height = int(self.data[2:4].hex(), base=16) 118 | self.frame_rate = self.data[4] 119 | self._num = int(self.data[5:7].hex(), base=16) 120 | self._state = self.STATE[self.data[7]] 121 | self.palette_update = bool(self.data[8]) 122 | self.palette_id = self.data[9] 123 | self._num_comps = self.data[10] 124 | 125 | @property 126 | def composition_number(self): return self._num 127 | 128 | @property 129 | def composition_state(self): return self._state 130 | 131 | @property 132 | def composition_objects(self): 133 | if not hasattr(self, '_composition_objects'): 134 | self._composition_objects = self.get_composition_objects() 135 | if len(self._composition_objects) != self._num_comps: 136 | print('Warning: Number of composition objects asserted ' 137 | 'does not match the amount found.') 138 | return self._composition_objects 139 | 140 | def get_composition_objects(self): 141 | bytes_ = self.data[11:] 142 | comps = [] 143 | while bytes_: 144 | length = 8*(1 + bool(bytes_[3])) 145 | comps.append(self.CompositionObject(bytes_[:length])) 146 | bytes_ = bytes_[length:] 147 | return comps 148 | 149 | class WindowDefinitionSegment(BaseSegment): 150 | 151 | def __init__(self, bytes_): 152 | BaseSegment.__init__(self, bytes_) 153 | self.num_windows = self.data[0] 154 | self.window_id = self.data[1] 155 | self.x_offset = int(self.data[2:4].hex(), base=16) 156 | self.y_offset = int(self.data[4:6].hex(), base=16) 157 | self.width = int(self.data[6:8].hex(), base=16) 158 | self.height = int(self.data[8:10].hex(), base=16) 159 | 160 | class PaletteDefinitionSegment(BaseSegment): 161 | 162 | def __init__(self, bytes_): 163 | BaseSegment.__init__(self, bytes_) 164 | self.palette_id = self.data[0] 165 | self.version = self.data[1] 166 | self.palette = [Palette(0, 0, 0, 0)]*256 167 | # Slice from byte 2 til end of segment. Divide by 5 to determine number of palette entries 168 | # Iterate entries. Explode the 5 bytes into namedtuple Palette. Must be exploded 169 | for entry in range(len(self.data[2:])//5): 170 | i = 2 + entry*5 171 | self.palette[self.data[i]] = Palette(*self.data[i+1:i+5]) 172 | 173 | class ObjectDefinitionSegment(BaseSegment): 174 | 175 | SEQUENCE = { 176 | int('0x40', base=16): 'Last', 177 | int('0x80', base=16): 'First', 178 | int('0xc0', base=16): 'First and last' 179 | } 180 | 181 | def __init__(self, bytes_): 182 | BaseSegment.__init__(self, bytes_) 183 | self.id = int(self.data[0:2].hex(), base=16) 184 | self.version = self.data[2] 185 | self.in_sequence = self.SEQUENCE[self.data[3]] 186 | self.data_len = int(self.data[4:7].hex(), base=16) 187 | self.width = int(self.data[7:9].hex(), base=16) 188 | self.height = int(self.data[9:11].hex(), base=16) 189 | self.img_data = self.data[11:] 190 | if len(self.img_data) != self.data_len - 4: 191 | print('Warning: Image data length asserted does not match the ' 192 | 'length found.') 193 | 194 | class EndSegment(BaseSegment): 195 | 196 | @property 197 | def is_end(self): return True 198 | 199 | 200 | SEGMENT_TYPE = { 201 | PDS: PaletteDefinitionSegment, 202 | ODS: ObjectDefinitionSegment, 203 | PCS: PresentationCompositionSegment, 204 | WDS: WindowDefinitionSegment, 205 | END: EndSegment 206 | } 207 | 208 | class DisplaySet: 209 | 210 | def __init__(self, segments): 211 | self.segments = segments 212 | self.segment_types = [s.type for s in segments] 213 | self.has_image = 'ODS' in self.segment_types 214 | 215 | def segment_by_type_getter(type_): 216 | def f(self): 217 | return [s for s in self.segments if s.type == type_] 218 | return f 219 | 220 | for type_ in BaseSegment.SEGMENT.values(): 221 | setattr(DisplaySet, type_.lower(), property(segment_by_type_getter(type_))) 222 | -------------------------------------------------------------------------------- /tests/MissionImpossible/MI-image52386.sup: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EzraBC/pgsreader/b4be94e972aa7fb4070e536ed1bfd8bb30862d81/tests/MissionImpossible/MI-image52386.sup -------------------------------------------------------------------------------- /tests/MissionImpossible/Manual.Trunch.MI-image52386.ods: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EzraBC/pgsreader/b4be94e972aa7fb4070e536ed1bfd8bb30862d81/tests/MissionImpossible/Manual.Trunch.MI-image52386.ods -------------------------------------------------------------------------------- /tests/SwapCrCb/extractedred.sup: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EzraBC/pgsreader/b4be94e972aa7fb4070e536ed1bfd8bb30862d81/tests/SwapCrCb/extractedred.sup --------------------------------------------------------------------------------