Skip to content

Commit

Permalink
Svs fixes (#22)
Browse files Browse the repository at this point in the history
* Remove jp2 part

* Support for overview and label pages

* Use ceil_div() instead of __truediv__

* Do not add jpeg_tables if None

* Make use of add_jpeg_tables more clear

* Docstrings on Jpeg-functions

* More descriptive error for crop error

* Stricter tag check

* pixel_spacing optional

* Change order on return of tiled_size-property.

* Add correct jpegtables in test

Co-authored-by: Erik O Gabrielsson <[email protected]>
  • Loading branch information
erikogabrielsson and Erik O Gabrielsson authored Feb 14, 2022
1 parent 9fa0e6c commit ed8cdf6
Show file tree
Hide file tree
Showing 9 changed files with 390 additions and 77 deletions.
41 changes: 7 additions & 34 deletions opentile/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,9 @@ def tile_size(self) -> Size:
@property
def tiled_size(self) -> Size:
"""The size of the image when tiled."""
if self.tile_size != Size(0, 0):
return self.image_size / self.tile_size
else:
if self.tile_size == Size(0, 0):
return Size(1, 1)
return self.image_size.ceil_div(self.tile_size)

@property
def pyramid_index(self) -> int:
Expand All @@ -178,7 +177,7 @@ def pyramid_index(self) -> int:

@property
@abstractmethod
def pixel_spacing(self) -> SizeMm:
def pixel_spacing(self) -> Optional[SizeMm]:
"""Should return the pixel size in mm/pixel of the page."""
raise NotImplementedError

Expand Down Expand Up @@ -346,10 +345,10 @@ def get_tile(
"""
tile_point = Point.from_tuple(tile_position)
frame_index = self._tile_point_to_frame_index(tile_point)
frame = self._read_frame(frame_index)
if self.page.jpegtables is None:
return frame
return Jpeg.add_jpeg_tables(frame, self.page.jpegtables)
tile = self._read_frame(frame_index)
if self.page.jpegtables is not None:
tile = Jpeg.add_jpeg_tables(tile, self.page.jpegtables)
return tile

def get_decoded_tile(self, tile_position: Tuple[int, int]) -> np.ndarray:
"""Return decoded tile for tile position. Returns a white tile if tile
Expand Down Expand Up @@ -389,32 +388,6 @@ def _tile_point_to_frame_index(
frame_index = tile_point.y * self.tiled_size.width + tile_point.x
return frame_index

def _add_jpeg_tables(self, frame: bytes) -> bytes:
"""Add jpeg tables to frame. Tables are insterted before 'start of
scan'-tag, and leading 'start of image' and ending 'end of image' tags
are removed from the header prior to insertion.
Parameters
----------
frame: bytes
'Abbreviated' jpeg frame lacking jpeg tables.
Returns
----------
bytes:
'Interchange' jpeg frame containg jpeg tables.
"""
if self.page.jpegtables is None:
return frame

start_of_scan = frame.find(Jpeg.start_of_scan())
with io.BytesIO() as buffer:
buffer.write(frame[0:start_of_scan])
buffer.write(self.page.jpegtables[2:-2]) # No start and end tags
buffer.write(frame[start_of_scan:])
return buffer.getvalue()


class Tiler(metaclass=ABCMeta):
"""Abstract class for reading pages from TiffFile."""
Expand Down
12 changes: 9 additions & 3 deletions opentile/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ class PointMm:
x: float
y: float

def __str__(self) -> str:
return f'{self.x},{self.y}'

def __floordiv__(self, divider: SizeMm) -> 'Point':
"""Return divided PointMm rounded down to closest integer x and y.
"""
Expand Down Expand Up @@ -164,7 +167,7 @@ def __floordiv__(
)
return NotImplemented

def __truediv__(
def ceil_div(
self,
divider: Union[int, float, 'Size', SizeMm]
) -> 'Size':
Expand Down Expand Up @@ -258,11 +261,11 @@ def __floordiv__(
return Point(int(self.x/divider.width), int(self.y/divider.height))
return NotImplemented

def __truediv__(
def ceil_div(
self,
divider: Union[int, float, 'Point', Size, SizeMm]
) -> 'Point':
"""Return divided Point rounded down up closest integer x and y."""
"""Return divided Point rounded up closest integer x and y."""
if isinstance(divider, (int, float)):
return Point(
math.ceil(self.x/divider),
Expand Down Expand Up @@ -414,6 +417,9 @@ class RegionMm:
position: PointMm
size: SizeMm

def __str__(self) -> str:
return f'from {self.start} to {self.end}'

@property
def start(self) -> PointMm:
return self.position
Expand Down
3 changes: 2 additions & 1 deletion opentile/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ def open(

if file_format == 'svs':
return SvsTiler(
filepath
filepath,
find_turbojpeg_path()
)

if file_format == 'philips_tiff':
Expand Down
144 changes: 140 additions & 4 deletions opentile/jpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@
from opentile.turbojpeg_patch import tjMCUHeight, tjMCUWidth


class JpegTagNotFound(Exception):
"""Raised when expected Jpeg tag was not found."""
pass


class JpegCropError(Exception):
"""Raised when crop operation fails."""
pass


class Jpeg:
TAGS = {
'tag marker': 0xFF,
Expand All @@ -48,8 +58,29 @@ def concatenate_fragments(
fragments: Iterator[bytes],
header: bytes
) -> bytes:
"""Return frame created by vertically concatenating fragments.
Parameters
----------
fragments: Iterator[bytes]
Iterator providing fragments to concatenate.
header: bytes
Heaeder for the frame.
Returns
----------
bytes:
Concatenated frame in bytes.
"""
frame = header
for fragment_index, fragment in enumerate(fragments):
if not (
fragment[-2] == Jpeg.TAGS['tag marker']
and fragment[-1] != b'0'
):
raise JpegTagNotFound(
"Tag for end of scan or restart marker not found in scan"
)
frame += fragment[:-1] # Do not include restart mark index
frame += self.restart_mark(fragment_index)
frame += self.end_of_image()
Expand Down Expand Up @@ -97,7 +128,9 @@ def concatenate_scans(
self.start_of_scan()
)
if start_of_scan is None or length is None:
raise ValueError()
raise JpegTagNotFound(
'Start of scan not found in header'
)
scan_start = start_of_scan + length + 2

frame += scan[scan_start:-2]
Expand All @@ -123,23 +156,76 @@ def concatenate_scans(
return bytes(frame)

def fill_frame(self, frame: bytes, luminance: float) -> bytes:
"""Return frame filled with color from luminance.
Parameters
----------
frame: bytes
Frame to fill.
luminance: float
Luminance to fill (0: black - 1: white).
Returns
----------
bytes:
Frame with constant color from luminance.
"""
return self._turbo_jpeg.fill_image(frame, luminance)

def decode(self, frame: bytes) -> np.ndarray:
"""Decode frame to np array.
Parameters
----------
frame: bytes
Frame to decode.
Returns
----------
np.ndarray:
Decoded frame as np array.
"""
return self._turbo_jpeg.decode(frame)

def encode(self, data: np.ndarray) -> bytes:
"""Encode np array to bytes.
Parameters
----------
data: np.ndarray
Numpy array to encode.
Returns
----------
bytes:
Encoded frame of data.
"""
return self._turbo_jpeg.encode(data)

def crop_multiple(
self,
frame: bytes,
crop_parameters: Sequence[Tuple[int, int, int, int]]
) -> List[bytes]:
"""Crop multipe frames out of frame.
Parameters
----------
frame: bytes
Frame to crop from.
crop_parameters: Sequence[Tuple[int, int, int, int]]
Parameters for each crop, specified as left position, top position,
widht, height.
Returns
----------
List[bytes]:
Croped frames.
"""
try:
return self._turbo_jpeg.crop_multiple(frame, crop_parameters)
except OSError:
raise ValueError(
raise JpegCropError(
f"Crop of frame failed "
f"with parameters {crop_parameters}"
)
Expand All @@ -150,13 +236,43 @@ def add_jpeg_tables(
frame: bytes,
jpeg_tables: bytes
) -> bytes:
"""Add jpeg tables to frame. Tables are insterted before 'start of
scan'-tag, and leading 'start of image' and ending 'end of image' tags
are removed from the header prior to insertion.
Parameters
----------
frame: bytes
'Abbreviated' jpeg frame lacking jpeg tables.
jpeg_tables: bytes
Jpeg tables to add
Returns
----------
bytes:
'Interchange' jpeg frame containg jpeg tables.
"""
return bytes(cls._add_jpeg_tables(bytearray(frame), jpeg_tables))

@classmethod
def add_color_space_fix(
cls,
frame: bytes
) -> bytes:
"""Add color space fix to frame (for svs).
Parameters
----------
frame: bytes
Frame to add color space fix to.
Returns
----------
bytes:
Frame with color fix.
"""
return bytes(cls._add_color_space_fix(bytearray(frame)))

@classmethod
Expand All @@ -166,6 +282,24 @@ def manipulate_header(
image_size: Optional[Size] = None,
restart_interval: Optional[int] = None
) -> bytes:
"""Return frame with changed header to reflect changed image size
or restart interval.
Parameters
----------
frame: bytes
Frame with header to update.
image_size: Optional[Size] = None
Image size to update header with.
restart_interval: Optional[int] = None
Restart interval to update header with.
Returns
----------
bytes:
Frame with updated header.
"""
return bytes(cls._manipulate_header(
bytearray(frame),
image_size,
Expand Down Expand Up @@ -262,7 +396,7 @@ def _manipulate_header(
frame, cls.start_of_frame()
)
if start_of_frame_index is None:
raise ValueError("Start of frame tag not found in header")
raise JpegTagNotFound("Start of frame tag not found in header")
size_index = start_of_frame_index+5
frame[size_index:size_index+2] = cls.code_short(size.height)
frame[size_index+2:size_index+4] = cls.code_short(size.width)
Expand All @@ -280,7 +414,9 @@ def _manipulate_header(
frame, cls.start_of_scan()
)
if start_of_scan_index is None:
raise ValueError("Start of scan tag not found in header")
raise JpegTagNotFound(
"Start of scan tag not found in header"
)
frame[start_of_scan_index:start_of_scan_index] = (
cls.restart_interval()
+ cls.code_short(4)
Expand Down
20 changes: 13 additions & 7 deletions opentile/ndpi_tiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

from opentile.common import OpenTilePage, Tiler
from opentile.geometry import Point, Region, Size, SizeMm
from opentile.jpeg import Jpeg
from opentile.jpeg import Jpeg, JpegCropError


def get_value_from_ndpi_comments(
Expand Down Expand Up @@ -371,7 +371,7 @@ def get_tile(self, tile_position: Tuple[int, int]) -> bytes:
Produced tile at position.
"""
if tile_position != (0, 0):
raise ValueError
raise ValueError("Non-tiled page, expected tile_position (0, 0)")
return self._read_frame(0)

def get_decoded_tile(self, tile_position: Tuple[int, int]) -> np.ndarray:
Expand Down Expand Up @@ -615,10 +615,16 @@ def _crop_to_tiles(
Dict[Point, bytes]:
Created tiles ordered by tile coordinate.
"""
tiles: List[bytes] = self._jpeg.crop_multiple(
frame,
frame_job.crop_parameters
)
try:
tiles: List[bytes] = self._jpeg.crop_multiple(
frame,
frame_job.crop_parameters
)
except JpegCropError:
raise ValueError(
f'Failed to crop at position {frame_job.position} with '
f'parameters {frame_job.crop_parameters}.'
)
return {
tile.position: tiles[i]
for i, tile in enumerate(frame_job.tiles)
Expand Down Expand Up @@ -707,7 +713,7 @@ def _read_extended_frame(
Frame
"""
if position != Point(0, 0):
raise ValueError("Frame osition not (0, 0) for one frame level.")
raise ValueError("Frame position not (0, 0) for one frame level.")
frame = self._read_frame(0)
# Use crop_multiple as it allows extending frame
tile: bytes = self._jpeg.crop_multiple(
Expand Down
Loading

0 comments on commit ed8cdf6

Please sign in to comment.