Skip to content

Commit 99f8d87

Browse files
committed
modified the video handler
1 parent b45d5cb commit 99f8d87

3 files changed

Lines changed: 309 additions & 59 deletions

File tree

exact/exact/images/models.py

Lines changed: 73 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
from openslide import OpenSlide, open_slide
2828
from czifile import czi2tif
2929
from util.cellvizio import ReadableCellVizioMKTDataset # just until data access is pip installable
30-
30+
# for video handling
31+
from util.video_handler import ReadableMP4Dataset
3132
from PIL import Image as PIL_Image
3233

3334
from datetime import datetime
@@ -192,69 +193,83 @@ def save_file(self, path:Path):
192193
os.remove(str(path_temp))
193194
self.filename = path.name
194195
# Videos
195-
elif Path(path).suffix.lower() in [".avi", ".mp4"]:
196-
dtype_to_format = {
197-
'uint8': 'uchar',
198-
'int8': 'char',
199-
'uint16': 'ushort',
200-
'int16': 'short',
201-
'uint32': 'uint',
202-
'int32': 'int',
203-
'float32': 'float',
204-
'float64': 'double',
205-
'complex64': 'complex',
206-
'complex128': 'dpcomplex',
207-
}
208-
209-
folder_path = Path(self.image_set.root_path()) / path.stem
210-
os.makedirs(str(folder_path), exist_ok =True)
211-
os.chmod(str(folder_path), 0o777)
212-
self.save() # initially save
213-
214-
cap = cv2.VideoCapture(str(Path(path)))
215-
frame_id = 0
216-
while cap.isOpened():
217-
ret, frame = cap.read()
218-
if not ret:
219-
# if video has just one frame copy file to top layer
220-
if frame_id == 1:
221-
copy_path = Path(path).with_suffix('.tiff')
222-
shutil.copyfile(str(target_file), str(copy_path))
223-
self.filename = copy_path.name
224-
break
225-
226-
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
227-
height, width, bands = frame.shape
228-
linear = frame.reshape(width * height * bands)
229-
230-
vi = pyvips.Image.new_from_memory(np.ascontiguousarray(linear.data), width, height, bands,
231-
dtype_to_format[str(frame.dtype)])
232-
if dtype_to_format[str(frame.dtype)] not in ["uchar"]:
233-
vi = vi.scaleimage()
234-
235-
height, width, channels = vi.height, vi.width, vi.bands
236-
self.channels = channels
237-
238-
target_file = folder_path / "{}_{}_{}".format(1, frame_id + 1, path.name) #z-axis frame image
239-
vi.tiffsave(str(target_file), tile=True, compression='lzw', bigtiff=True, pyramid=True, tile_width=256, tile_height=256)
240-
241-
# save first frame as default file for thumbnail etc.
242-
if frame_id == 0:
243-
self.filename = target_file.name
244-
245-
# save FrameDescription object for each frame
196+
elif Path(path).suffix.lower().endswith(".mp4"):
197+
reader = ReadableMP4Dataset(str(path))
198+
self.frames = reader.nFrames
199+
self.width, self.height = reader.dimensions
200+
self.channels = 3
201+
self.filename = path.name
202+
self.save()
203+
for frame_id in range(reader.nFrames):
246204
FrameDescription.objects.create(
247205
Image=self,
248206
frame_id=frame_id,
249-
file_path=target_file,
207+
file_path=self.filename,
250208
frame_type=FrameType.TIMESERIES,
251-
description='%.2f s (%d)' % ((float(frame_id-1)/cap.get(cv2.CAP_PROP_FPS)), frame_id)
209+
description=reader.frame_descriptors[frame_id]
252210
)
253-
254-
255-
frame_id += 1
211+
# dtype_to_format = {
212+
# 'uint8': 'uchar',
213+
# 'int8': 'char',
214+
# 'uint16': 'ushort',
215+
# 'int16': 'short',
216+
# 'uint32': 'uint',
217+
# 'int32': 'int',
218+
# 'float32': 'float',
219+
# 'float64': 'double',
220+
# 'complex64': 'complex',
221+
# 'complex128': 'dpcomplex',
222+
# }
223+
224+
# folder_path = Path(self.image_set.root_path()) / path.stem
225+
# os.makedirs(str(folder_path), exist_ok =True)
226+
# os.chmod(str(folder_path), 0o777)
227+
# self.save() # initially save
228+
229+
# cap = cv2.VideoCapture(str(Path(path)))
230+
# frame_id = 0
231+
# while cap.isOpened():
232+
# ret, frame = cap.read()
233+
# if not ret:
234+
# # if video has just one frame copy file to top layer
235+
# if frame_id == 1:
236+
# copy_path = Path(path).with_suffix('.tiff')
237+
# shutil.copyfile(str(target_file), str(copy_path))
238+
# self.filename = copy_path.name
239+
# break
240+
241+
# frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
242+
# height, width, bands = frame.shape
243+
# linear = frame.reshape(width * height * bands)
244+
245+
# vi = pyvips.Image.new_from_memory(np.ascontiguousarray(linear.data), width, height, bands,
246+
# dtype_to_format[str(frame.dtype)])
247+
# if dtype_to_format[str(frame.dtype)] not in ["uchar"]:
248+
# vi = vi.scaleimage()
249+
250+
# height, width, channels = vi.height, vi.width, vi.bands
251+
# self.channels = channels
252+
253+
# target_file = folder_path / "{}_{}_{}".format(1, frame_id + 1, path.name) #z-axis frame image
254+
# vi.tiffsave(str(target_file), tile=True, compression='lzw', bigtiff=True, pyramid=True, tile_width=256, tile_height=256)
255+
256+
# # save first frame as default file for thumbnail etc.
257+
# if frame_id == 0:
258+
# self.filename = target_file.name
259+
260+
# # save FrameDescription object for each frame
261+
# FrameDescription.objects.create(
262+
# Image=self,
263+
# frame_id=frame_id,
264+
# file_path=target_file,
265+
# frame_type=FrameType.TIMESERIES,
266+
# description='%.2f s (%d)' % ((float(frame_id-1)/cap.get(cv2.CAP_PROP_FPS)), frame_id)
267+
# )
268+
269+
270+
# frame_id += 1
256271

257-
self.frames = frame_id
272+
# self.frames = frame_id
258273

259274

260275
# check if file is philips iSyntax

exact/exact/images/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ def upload_image(request, imageset_id):
303303
image_set=imageset).first()
304304
print('Image:',image)
305305
if image is None:
306-
306+
os.makedirs(os.path.dirname(filename), exist_ok=True)
307307
with open(filename, 'wb') as out:
308308
for chunk in f.chunks():
309309
out.write(chunk)

exact/util/video_handler.py

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
"""
2+
Scripts for MP4 files's support
3+
4+
"""
5+
import threading
6+
from collections import OrderedDict
7+
8+
import openslide
9+
from openslide import OpenSlideError
10+
import numpy as np
11+
import cv2
12+
from PIL import Image
13+
try :
14+
from util.enums import FrameType
15+
except ImportError:
16+
from enums import FrameType
17+
18+
19+
class ReadableMP4Dataset(openslide.ImageSlide):
20+
def __init__(self, filename, cache_size=32, max_cache_bytes=None):
21+
self.slide_path = filename
22+
23+
self._cap = None
24+
self._cap_lock = threading.RLock()
25+
self._frame_cache = OrderedDict()
26+
self._cache_size = cache_size
27+
28+
self._max_cache_bytes = max_cache_bytes # optional based on memory
29+
self._cache_bytes = 0
30+
31+
cap = cv2.VideoCapture(filename)
32+
if not cap.isOpened():
33+
raise OpenSlideError(f"Could not open video file: {filename}")
34+
# Get video properties
35+
self._width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
36+
self._height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
37+
self.numberOfLayers = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) # total number of frames
38+
self.fps = cap.get(cv2.CAP_PROP_FPS)
39+
40+
cap.release()
41+
42+
self._dimensions = (self._width, self._height)
43+
44+
def __reduce__(self):
45+
return (self.__class__, (self.slide_path,))
46+
47+
def close(self):
48+
with self._cap_lock:
49+
if self._cap is not None:
50+
self._cap.release()
51+
self._cap = None
52+
53+
self._frame_cache.clear()
54+
self._cache_bytes = 0
55+
56+
def __del__(self):
57+
self.close()
58+
59+
def __enter__(self):
60+
return self
61+
62+
def __exit__(self, *args):
63+
self.close()
64+
65+
@property
66+
def properties(self):
67+
return {
68+
openslide.PROPERTY_NAME_BACKGROUND_COLOR: '000000',
69+
openslide.PROPERTY_NAME_MPP_X: 0,
70+
openslide.PROPERTY_NAME_MPP_Y: 0,
71+
openslide.PROPERTY_NAME_OBJECTIVE_POWER: 1,
72+
openslide.PROPERTY_NAME_VENDOR: 'MP4'
73+
}
74+
75+
76+
@property
77+
def dimensions(self):
78+
return self._dimensions
79+
80+
@property
81+
def frame_descriptors(self) -> list[str]:
82+
""" returns a list of strings, used as descriptor for each frame
83+
"""
84+
return ['%.2f' % (x/self.fps) for x in range(self.nFrames)]
85+
86+
@property
87+
def frame_type(self):
88+
return FrameType.TIMESERIES
89+
90+
@property
91+
def default_frame(self) -> list[str]:
92+
return 0
93+
94+
@property
95+
def nFrames(self):
96+
return self.numberOfLayers
97+
98+
@property
99+
def level_dimensions(self):
100+
return (self.dimensions,)
101+
102+
@property
103+
def level_count(self):
104+
return 1
105+
106+
def _get_capture(self):
107+
if self._cap is None or not self._cap.isOpened():
108+
self._cap = cv2.VideoCapture(self.slide_path)
109+
return self._cap
110+
111+
def _frame_num_bytes(self, frame_arr: np.ndarray) -> int:
112+
"""
113+
114+
Calculate the number of bytes used by a frame array.
115+
:param frame_arr: Description
116+
"""
117+
try:
118+
return int(frame_arr.nbytes)
119+
except Exception:
120+
return 0
121+
122+
def _evict_if_needed(self):
123+
"""
124+
Evict frames by LRU
125+
"""
126+
127+
# Evict frames if exceeding max frame count
128+
while self._cache_size is not None and len(self._frame_cache) > self._cache_size:
129+
old_idx, old_frame = self._frame_cache.popitem(last=False)
130+
self._cache_bytes -= self._frame_num_bytes(old_frame)
131+
# Evict based on byte size
132+
if self._max_cache_bytes is not None:
133+
while self._cache_bytes > self._max_cache_bytes and len(self._frame_cache) > 0:
134+
old_idx, old_frame = self._frame_cache.popitem(last=False)
135+
self._cache_bytes -= self._frame_num_bytes(old_frame)
136+
137+
def _read_frame(self, frame_idx: int):
138+
"""
139+
Before reading the frame, check the cache first with thread safety.
140+
Followed by LRU cache eviction policy.
141+
142+
:param frame_idx:
143+
"""
144+
with self._cap_lock:
145+
cached = self._frame_cache.get(frame_idx)
146+
if cached is not None:
147+
self._frame_cache.move_to_end(frame_idx)
148+
return cached
149+
150+
cap = self._get_capture()
151+
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
152+
success, img = cap.read()
153+
if not success:
154+
return None
155+
# Convert BGR to RGBA
156+
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGBA)
157+
self._frame_cache[frame_idx] = img_rgb
158+
self._frame_cache.move_to_end(frame_idx, last=True)
159+
self._cache_bytes += self._frame_num_bytes(img_rgb)
160+
161+
self._evict_if_needed()
162+
return img_rgb
163+
164+
165+
def get_thumbnail(self, size):
166+
return self.read_region((0,0),0, self.dimensions).resize(size)
167+
168+
def read_region(self, location, level, size, frame=0):
169+
170+
"""
171+
Reads a region from a specific video frame.
172+
Return a PIL.Image containing the contents of the region.
173+
Reference: https://github.com/DeepMicroscopy/Exact/commit/4d52b614fa41328bf08367d99e088c1e838fb05a
174+
175+
176+
location: (x, y) tuple giving the top left pixel in the level 0
177+
reference frame.
178+
level: the level number.
179+
size: (width, height) tuple giving the region size.
180+
frame: the frame index to read from the video.
181+
182+
"""
183+
if level != 0:
184+
raise OpenSlideError("Only level 0 is supported for video files.")
185+
186+
if any(s < 0 for s in size):
187+
raise OpenSlideError(f"Size {size} must be non-negative")
188+
189+
# Clamp frame index
190+
frame = max(0, min(frame, self.numberOfLayers - 1))
191+
img_rgb = self._read_frame(frame)
192+
if img_rgb is None:
193+
# Return a transparent tile if frame read fails
194+
return Image.new("RGBA", size, (0, 0, 0, 0))
195+
196+
x, y = location
197+
w, h = size
198+
199+
# Create the transparent canvas
200+
tile = Image.new("RGBA", size, (0, 0, 0, 0))
201+
202+
# Calculate crop boundaries within the source image
203+
img_h, img_w = img_rgb.shape[:2]
204+
205+
# Source coordinates
206+
src_x1 = max(0, min(x, img_w))
207+
src_y1 = max(0, min(y, img_h))
208+
src_x2 = max(0, min(x + w, img_w))
209+
src_y2 = max(0, min(y + h, img_h))
210+
211+
# Destination coordinates (where to paste on the tile)
212+
dst_x1 = max(0, -x) if x < 0 else 0
213+
dst_y1 = max(0, -y) if y < 0 else 0
214+
215+
# Extract the crop using numpy slicing
216+
crop_data = img_rgb[src_y1:src_y2, src_x1:src_x2]
217+
218+
if crop_data.size > 0:
219+
crop_img = Image.fromarray(crop_data)
220+
tile.paste(crop_img, (dst_x1, dst_y1))
221+
222+
return tile
223+
224+
def get_duration(self):
225+
"""Get the length of the video in seconds."""
226+
return self.numberOfLayers / self.fps if self.fps > 0 else 0
227+
228+
def time_to_frame(self, time_seconds: float) -> int:
229+
"""Convert time in seconds to frame index."""
230+
frame_idx = int(time_seconds * self.fps)
231+
return max(0, min(frame_idx, self.numberOfFrames - 1))
232+
233+
def frame_to_time(self, frame_idx: int) -> float:
234+
"""Convert frame index to time in seconds."""
235+
return frame_idx / self.fps if self.fps > 0 else 0

0 commit comments

Comments
 (0)