FrancisGOS commited on
Commit
f180ac2
·
1 Parent(s): db45ae3

Fix tourview: Add video and image as way to create tourview

Browse files
app/domains/supabase/service.py CHANGED
@@ -70,19 +70,15 @@ class SupabaseService:
70
  raise InternalServerException(
71
  f"Failed to delete old image from storage: {e}"
72
  )
73
- # Prepare the new file for upload.
74
  new_image_content = await file.read()
75
  file_extension = file.filename.split(".")[-1] if "." in file.filename else "jpg"
76
- # Reuse the old filename if available; otherwise, generate a new one.
77
  filename = old_filename if old_filename else f"{uuid.uuid4()}.{file_extension}"
78
- # Write file content to a temporary file.
79
  fd, tmp_path = tempfile.mkstemp()
80
  try:
81
  with open(tmp_path, "wb") as tmp_file:
82
  tmp_file.write(new_image_content)
83
  finally:
84
  os.close(fd)
85
- # Upload the new file to Supabase Storage.
86
  try:
87
  self.supabase_client.storage.from_(bucket_name).upload(
88
  filename,
@@ -100,6 +96,5 @@ class SupabaseService:
100
  )
101
  return public_url
102
 
103
-
104
  def provide_supabase_service(bucket_name: str) -> SupabaseService:
105
  return SupabaseService(bucket_name=bucket_name)
 
70
  raise InternalServerException(
71
  f"Failed to delete old image from storage: {e}"
72
  )
 
73
  new_image_content = await file.read()
74
  file_extension = file.filename.split(".")[-1] if "." in file.filename else "jpg"
 
75
  filename = old_filename if old_filename else f"{uuid.uuid4()}.{file_extension}"
 
76
  fd, tmp_path = tempfile.mkstemp()
77
  try:
78
  with open(tmp_path, "wb") as tmp_file:
79
  tmp_file.write(new_image_content)
80
  finally:
81
  os.close(fd)
 
82
  try:
83
  self.supabase_client.storage.from_(bucket_name).upload(
84
  filename,
 
96
  )
97
  return public_url
98
 
 
99
  def provide_supabase_service(bucket_name: str) -> SupabaseService:
100
  return SupabaseService(bucket_name=bucket_name)
app/domains/tourview/controller.py CHANGED
@@ -1,10 +1,12 @@
1
  from __future__ import annotations
2
- from typing import List, Union
3
  import uuid
4
- from litestar import Controller, Response, get, post, status_codes
5
  from litestar.di import Provide
6
  from litestar.params import Body
7
  from litestar.datastructures import UploadFile
 
 
8
  from domains.tourview.service import TourviewService, provide_tourview_service
9
  from domains.tourview.dtos import (
10
  StartTransferSessionDTO,
@@ -12,6 +14,8 @@ from domains.tourview.dtos import (
12
  )
13
  from database.models.tourview import Tourview
14
  from litestar.background_tasks import BackgroundTask
 
 
15
 
16
  class TourviewController(Controller):
17
  path = "/properties/{property_id:uuid}/tourview"
@@ -58,7 +62,7 @@ class TourviewController(Controller):
58
  @post(
59
  "/transfer/{session_id:uuid}/chunk",
60
  path_override="/tourview/transfer/{session_id:uuid}/chunk", # Override class path
61
- no_auth=True,
62
  )
63
  async def upload_chunk(
64
  self,
@@ -76,24 +80,60 @@ class TourviewController(Controller):
76
  @post(
77
  "/transfer/{session_id:uuid}/finalize",
78
  # path_override="/tourview/transfer/{session_id:uuid}/finalize", # Override class path
79
- no_auth=True,
80
  )
81
  async def finalize_transfer(
82
  self,
83
  property_id: uuid.UUID,
 
84
  service: TourviewService,
85
  session_id: uuid.UUID,
86
-
87
  ) -> str:
88
  """
89
  Extra Endpoint: Finalizes a chunked transfer after all chunks are uploaded.
90
  This is necessary to trigger the processing of video/panorama files.
91
  """
92
  result, name = await service.finalize_transfer(session_id)
93
- background_task = BackgroundTask(self.create_tourview, result, property_id, name, service)
 
 
94
  return Response("Transfer completed successfully.", background=background_task)
95
- async def create_tourview(self, path: Union[str, list[str]], property_id: uuid.UUID, name: str, service: TourviewService):
96
- if path is str:
97
- service.stitch_video(path, property_id, name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  else:
99
- service.stitch_images(path, property_id, name)
 
 
1
  from __future__ import annotations
2
+ from typing import Any, List, Union
3
  import uuid
4
+ from litestar import Controller, Request, Response, get, post
5
  from litestar.di import Provide
6
  from litestar.params import Body
7
  from litestar.datastructures import UploadFile
8
+ from database.models.user import User
9
+ from domains.notification.service import NotificationService
10
  from domains.tourview.service import TourviewService, provide_tourview_service
11
  from domains.tourview.dtos import (
12
  StartTransferSessionDTO,
 
14
  )
15
  from database.models.tourview import Tourview
16
  from litestar.background_tasks import BackgroundTask
17
+ from litestar.security.jwt import Token
18
+
19
 
20
  class TourviewController(Controller):
21
  path = "/properties/{property_id:uuid}/tourview"
 
62
  @post(
63
  "/transfer/{session_id:uuid}/chunk",
64
  path_override="/tourview/transfer/{session_id:uuid}/chunk", # Override class path
65
+ # no_auth=True,
66
  )
67
  async def upload_chunk(
68
  self,
 
80
  @post(
81
  "/transfer/{session_id:uuid}/finalize",
82
  # path_override="/tourview/transfer/{session_id:uuid}/finalize", # Override class path
83
+ # no_auth=True,
84
  )
85
  async def finalize_transfer(
86
  self,
87
  property_id: uuid.UUID,
88
+ request: Request[User, Token, Any],
89
  service: TourviewService,
90
  session_id: uuid.UUID,
 
91
  ) -> str:
92
  """
93
  Extra Endpoint: Finalizes a chunked transfer after all chunks are uploaded.
94
  This is necessary to trigger the processing of video/panorama files.
95
  """
96
  result, name = await service.finalize_transfer(session_id)
97
+ background_task = BackgroundTask(
98
+ self.create_tourview, request.user, result, property_id, name, service
99
+ )
100
  return Response("Transfer completed successfully.", background=background_task)
101
+
102
+ async def notify_partner(self, user: User, tourview: Union[Tourview, None]):
103
+ if not user.device_token:
104
+ return
105
+ notify_service = NotificationService()
106
+ title = "Tourview Fail" if tourview is None else "Tourview Complete"
107
+ body = (
108
+ f"Fail when trying to create your tourview."
109
+ if tourview is None
110
+ else f"Your tourview for {tourview.name} is complete. Try it right away!!!"
111
+ )
112
+ notify_service.send_to_token(
113
+ token=user.device_token,
114
+ title=title,
115
+ body=body,
116
+ data=(
117
+ {
118
+ "type": "tourview",
119
+ "id": str(tourview.id),
120
+ "url": str(tourview.image.url),
121
+ }
122
+ if tourview
123
+ else None
124
+ ),
125
+ )
126
+
127
+ async def create_tourview(
128
+ self,
129
+ user: User,
130
+ path: Union[str, list[str]],
131
+ property_id: uuid.UUID,
132
+ name: str,
133
+ service: TourviewService,
134
+ ):
135
+ if isinstance(path, str):
136
+ tourview = await service.stitch_video(path, property_id, name)
137
  else:
138
+ tourview = await service.stitch_images(path, property_id, name)
139
+ await self.notify_partner(user, tourview)
app/domains/tourview/service.py CHANGED
@@ -1,4 +1,7 @@
1
  from collections.abc import AsyncGenerator
 
 
 
2
  from pathlib import Path
3
  import shutil
4
  import cv2
@@ -6,13 +9,17 @@ from typing import List, Optional, Tuple, Union
6
  import uuid
7
  from advanced_alchemy.repository import SQLAlchemyAsyncRepository
8
  from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
9
- import numpy as np
 
 
 
 
 
10
  from domains.tourview.dtos import (
11
  StartTransferSessionDTO,
12
  )
13
  from database.models.tourview import Tourview
14
  from sqlalchemy.ext.asyncio import AsyncSession
15
- from PIL import Image
16
  from domains.supabase.service import SupabaseService
17
  import aiofiles
18
 
@@ -81,7 +88,7 @@ class TourviewService(SQLAlchemyAsyncRepositoryService[Tourview]):
81
 
82
  # 3. Create database records
83
  # NOTE: final_image_path should be a public URL after moving it to a public storage (e.g., S3)
84
- new_image = Image(
85
  url=str(final_image_path), # This should be a URL, not a local path in prod
86
  model_id=prop.id,
87
  model_type="property_tourview",
@@ -157,7 +164,7 @@ class TourviewService(SQLAlchemyAsyncRepositoryService[Tourview]):
157
  final_processed_path = await self.reassemble_and_process_chunks(
158
  file_paths, session["type"]
159
  )
160
- return final_processed_path, session["name"]
161
  elif session["type"] == "image":
162
  return file_paths, session["name"]
163
  else:
@@ -192,54 +199,101 @@ class TourviewService(SQLAlchemyAsyncRepositoryService[Tourview]):
192
 
193
  print(f"Reassembly complete. Final file at {final_file}")
194
  return final_file
195
-
196
- def extract_and_select_frames(
197
- self, video_path, min_movement=5, max_movement=50, step=5
198
- ):
199
- cap = cv2.VideoCapture(video_path)
200
- if not cap.isOpened():
201
- raise RuntimeError("Could not open video.")
202
-
203
- selected_frames = []
204
- prev_gray = None
205
- frame_idx = 0
206
-
207
- while True:
208
- ret, frame = cap.read()
209
- if not ret:
210
- break
211
-
212
- if frame_idx % step != 0:
213
- frame_idx += 1
214
- continue
215
-
216
- gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
217
- if prev_gray is not None:
218
- # Calculate optical flow or feature movement
219
- flow = cv2.calcOpticalFlowFarneback(
220
- prev_gray, gray, None, 0.5, 3, 15, 3, 5, 1.2, 0
221
  )
222
- movement = np.linalg.norm(flow, axis=2).mean()
223
- print(f"Frame {frame_idx}: avg movement={movement:.2f}")
224
-
225
- if min_movement < movement < max_movement:
226
- selected_frames.append(frame)
227
- else:
228
- # Always select the first frame
229
- selected_frames.append(frame)
230
-
231
- prev_gray = gray
232
- frame_idx += 1
233
-
234
- cap.release()
235
- return selected_frames
236
-
237
- def stitch_video(self, path: str, property_id: uuid.UUID, name: str) -> Tourview:
238
- pass
239
-
240
- def stitch_images(self, paths: list[str], property_id: uuid.UUID, name: str) -> Tourview:
241
- pass
 
 
 
 
 
 
 
 
 
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
  async def provide_tourview_service(
245
  db_session: AsyncSession,
 
1
  from collections.abc import AsyncGenerator
2
+ from io import BytesIO
3
+ import os
4
+ from PIL import Image as PILImage
5
  from pathlib import Path
6
  import shutil
7
  import cv2
 
9
  import uuid
10
  from advanced_alchemy.repository import SQLAlchemyAsyncRepository
11
  from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
12
+ from database.models.image import Image
13
+ from domains.image.service import ImageService
14
+ from domains.tourview.utils import (
15
+ generate_panorama_image_from_path,
16
+ generate_panorama_image_from_video,
17
+ )
18
  from domains.tourview.dtos import (
19
  StartTransferSessionDTO,
20
  )
21
  from database.models.tourview import Tourview
22
  from sqlalchemy.ext.asyncio import AsyncSession
 
23
  from domains.supabase.service import SupabaseService
24
  import aiofiles
25
 
 
88
 
89
  # 3. Create database records
90
  # NOTE: final_image_path should be a public URL after moving it to a public storage (e.g., S3)
91
+ new_image = PILImage(
92
  url=str(final_image_path), # This should be a URL, not a local path in prod
93
  model_id=prop.id,
94
  model_type="property_tourview",
 
164
  final_processed_path = await self.reassemble_and_process_chunks(
165
  file_paths, session["type"]
166
  )
167
+ return str(final_processed_path), session["name"]
168
  elif session["type"] == "image":
169
  return file_paths, session["name"]
170
  else:
 
199
 
200
  print(f"Reassembly complete. Final file at {final_file}")
201
  return final_file
202
+
203
+ async def stitch_video(
204
+ self, path: str, property_id: uuid.UUID, name: str
205
+ ) -> Tourview:
206
+ try:
207
+ panorama = generate_panorama_image_from_video(path)
208
+ image_rgb = cv2.cvtColor(panorama, cv2.COLOR_BGR2RGB)
209
+ pil_image = PILImage.fromarray(image_rgb)
210
+ buffer = BytesIO()
211
+ pil_image.save(buffer, format="PNG")
212
+ buffer.seek(0)
213
+ bucket = self.supabase_service.supabase_client.storage.from_("tourview")
214
+ try:
215
+ result = bucket.upload(
216
+ f"tourview_{property_id}_{name}.png",
217
+ buffer.read(),
218
+ {"content-type": "image/png"},
 
 
 
 
 
 
 
 
 
219
  )
220
+ except Exception as e:
221
+ raise InternalServerException("Unable to connect to Storage Service")
222
+ try:
223
+ public_url = bucket.get_public_url(f"tourview_{property_id}_{name}.png")
224
+ except:
225
+ raise InternalServerException(
226
+ f"Unable to find the public url for tourview_{property_id}_{name}.png"
227
+ )
228
+ image_service = ImageService(session=self.repository.session)
229
+ image = await image_service.create(
230
+ Image(
231
+ **{
232
+ "url": public_url,
233
+ "model_id": None,
234
+ "model_type": None,
235
+ }
236
+ )
237
+ )
238
+ tourview = await self.create(
239
+ Tourview(image_id=image.id, property_id=property_id, name=name)
240
+ )
241
+ return tourview
242
+ except Exception as e:
243
+ print(e)
244
+ await self.repository.session.rollback()
245
+ raise InternalServerException("Error when trying to create your tourview")
246
+ finally:
247
+ await self.repository.session.commit()
248
+ os.remove(path)
249
 
250
+ async def stitch_images(
251
+ self, paths: list[str], property_id: uuid.UUID, name: str
252
+ ) -> Tourview:
253
+ try:
254
+ panorama = generate_panorama_image_from_path(paths)
255
+ image_rgb = cv2.cvtColor(panorama, cv2.COLOR_BGR2RGB)
256
+ pil_image = PILImage.fromarray(image_rgb)
257
+ buffer = BytesIO()
258
+ pil_image.save(buffer, format="PNG")
259
+ buffer.seek(0)
260
+ bucket = self.supabase_service.supabase_client.storage.from_("tourview")
261
+ try:
262
+ result = await bucket.upload(
263
+ f"tourview_{property_id}_{name}.png",
264
+ buffer.read(),
265
+ {"content-type": "image/png"},
266
+ )
267
+ except Exception as e:
268
+ raise InternalServerException("Unable to connect to Storage Service")
269
+ try:
270
+ public_url = bucket.get_public_url(f"tourview_{property_id}_{name}.png")
271
+ except:
272
+ raise InternalServerException(
273
+ f"Unable to find the public url for tourview_{property_id}_{name}.png"
274
+ )
275
+ image_service = ImageService(session=self.repository.session)
276
+ image = await image_service.create(
277
+ Image(
278
+ **{
279
+ "url": public_url,
280
+ "model_id": None,
281
+ "model_type": None,
282
+ }
283
+ )
284
+ )
285
+ tourview = await self.create(
286
+ Tourview(image_id=image.id, property_id=property_id, name=name)
287
+ )
288
+ return tourview
289
+ except Exception as e:
290
+ print(e)
291
+ await self.repository.session.rollback()
292
+ raise InternalServerException("Error when trying to create your tourview")
293
+ finally:
294
+ await self.repository.session.commit()
295
+ for path in paths:
296
+ os.remove(path)
297
 
298
  async def provide_tourview_service(
299
  db_session: AsyncSession,
app/domains/tourview/utils.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+ import torch
3
+ from lightglue.utils import rbd
4
+ from lightglue import LightGlue, SuperPoint
5
+ import networkx as nx
6
+ from tqdm import tqdm
7
+ import gc
8
+ device = "cuda" if torch.cuda.is_available() else "cpu"
9
+ from PIL import Image
10
+ from torchvision import transforms as t
11
+
12
+ tf = t.Compose(
13
+ [
14
+ t.ToTensor(), # converts to (C, H, W), [0,1]
15
+ ]
16
+ )
17
+ import cv2
18
+ import numpy as np
19
+
20
+ import cv2
21
+ from tqdm import tqdm
22
+
23
+
24
+ def pad_to_equirectangular(image):
25
+ h, w = image.shape[:2]
26
+ target_height = w // 2
27
+
28
+ if h == target_height:
29
+ print("Image is already 2:1 equirectangular.")
30
+ return image
31
+ elif h > target_height:
32
+ print(f"Resizing image from ({w}x{h}) to ({w}x{target_height}) to match 2:1 ratio.")
33
+ resized_image = cv2.resize(image, (w, target_height), interpolation=cv2.INTER_AREA)
34
+ return resized_image
35
+ pad_total = target_height - h
36
+ print(f"Padding: {pad_total} px to reach {target_height}px height.")
37
+ padded_image = cv2.copyMakeBorder(
38
+ image,
39
+ pad_total,
40
+ 0,
41
+ 0,
42
+ 0,
43
+ borderType=cv2.BORDER_CONSTANT,
44
+ value=(0, 0, 0), # black padding
45
+ )
46
+
47
+ return padded_image
48
+
49
+
50
+ def read_images(image_paths):
51
+ images = []
52
+ for path in image_paths:
53
+ img = cv2.imread(path)
54
+ if img is None:
55
+ raise ValueError(f"Could not read {path}")
56
+ images.append(img)
57
+ return images
58
+
59
+
60
+ def compute_matches(images, match_conf=0.6):
61
+ extractor = SuperPoint(max_num_keypoints=512).eval().to(device)
62
+ matcher = LightGlue(features="superpoint").eval().to(device)
63
+ features, feats_np = [], []
64
+ for img in images:
65
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
66
+ gray = Image.fromarray(gray, mode="L")
67
+ gray = tf(gray).unsqueeze(0).to(device)
68
+ feat = extractor.extract(gray, device=device)
69
+ features.append(feat)
70
+ feats_np.append(gray)
71
+ G = nx.Graph()
72
+ for i in range(len(images)):
73
+ G.add_node(i) # no need for image_paths
74
+ print("Computing pairwise matches using LightGlue with progress bar...")
75
+ pairs = [(i, j) for i in range(len(images)) for j in range(i + 1, len(images))]
76
+ for i, j in tqdm(pairs, desc="Matching pairs", unit="pair"):
77
+ f0, f1 = features[i], features[j]
78
+ match_dict = matcher({"image0": f0, "image1": f1})
79
+ f0, f1, match_dict = [rbd(x) for x in [f0, f1, match_dict]]
80
+ matches = match_dict["matches"].cpu().numpy() # (K,2)
81
+ if matches.shape[0] < 5:
82
+ continue
83
+ kpts0 = f0["keypoints"][matches[:, 0]].cpu().numpy()
84
+ kpts1 = f1["keypoints"][matches[:, 1]].cpu().numpy()
85
+ if len(kpts1) < 8:
86
+ continue
87
+ H, inliers = cv2.findHomography(kpts0, kpts1, cv2.RANSAC, 4.0)
88
+ inlier_ratio = np.sum(inliers) / len(inliers) if inliers is not None else 0
89
+ if H is not None and inlier_ratio > match_conf:
90
+ conf = inlier_ratio
91
+ G.add_edge(i, j, weight=-conf)
92
+ tqdm.write(f"Match {i}-{j}: {len(kpts1)} matches, inlier ratio={conf:.2f}")
93
+ del match_dict, f0, f1, matches
94
+ del features, feats_np, matcher, extractor
95
+ return G
96
+
97
+ def compute_mst_order(G, start):
98
+ mst = nx.minimum_spanning_tree(G, weight="weight")
99
+ order = list(nx.dfs_preorder_nodes(mst, source=start))
100
+ print("MST-based order:", order)
101
+ return order
102
+
103
+
104
+ def generate_panorama_images(images):
105
+ if len(images) < 2:
106
+ print("Need at least two images to stitch a panorama.")
107
+ return None
108
+ stitcher = cv2.Stitcher_create(cv2.Stitcher_PANORAMA)
109
+ try:
110
+ cv2.ocl.setUseOpenCL(False)
111
+ stitcher.setWaveCorrection(True)
112
+ stitcher.setRegistrationResol(0.6)
113
+ stitcher.setSeamEstimationResol(0.6)
114
+ stitcher.setInterpolationFlags(cv2.INTER_CUBIC)
115
+ except Exception as e:
116
+ print(f"Warning: could not set advanced options: {e}")
117
+
118
+ status, pano = stitcher.stitch(images)
119
+ if status != cv2.Stitcher_OK:
120
+ print(f"Error during stitching: {status}")
121
+ return None
122
+ return pano
123
+
124
+ def fill_black_with_inpainting(image):
125
+ mask = np.all(image == 0, axis=2).astype(np.uint8) * 255
126
+ impainted = cv2.inpaint(image, mask, inpaintRadius=5, flags=cv2.INPAINT_TELEA)
127
+ return impainted
128
+
129
+ def generate_panorama_image_from_path(image_paths: List[str]):
130
+ images = read_images(image_paths)
131
+ graph = compute_matches(images, match_conf=0.6)
132
+ degrees = dict(graph.degree())
133
+ start = max(degrees, key=degrees.get)
134
+ mst_order = compute_mst_order(graph, start)
135
+ panorama = generate_panorama_images([images[i] for i in mst_order])
136
+ if panorama is None:
137
+ return None
138
+ panorama = fill_black_with_inpainting(panorama)
139
+ panorama = pad_to_equirectangular(panorama)
140
+ return fill_black_with_inpainting(panorama)
141
+
142
+ def extract_and_select_frames(video_path, min_movement=3, max_movement=50, step=3):
143
+ cap = cv2.VideoCapture(video_path)
144
+ if not cap.isOpened():
145
+ raise RuntimeError("Could not open video.")
146
+
147
+ selected_frames = []
148
+ prev_gray = None
149
+ frame_idx = 0
150
+
151
+ while True:
152
+ ret, frame = cap.read()
153
+ if not ret:
154
+ break
155
+
156
+ if frame_idx % step != 0:
157
+ frame_idx += 1
158
+ continue
159
+
160
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
161
+ if prev_gray is not None:
162
+ flow = cv2.calcOpticalFlowFarneback(prev_gray, gray, None, 0.3, 3, 15, 3, 8, 1.5, 0)
163
+ movement = np.linalg.norm(flow, axis=2).mean()
164
+ print(f"Frame {frame_idx}: avg movement={movement:.2f}")
165
+ if min_movement < movement < max_movement:
166
+ selected_frames.append(frame)
167
+ else:
168
+ selected_frames.append(frame)
169
+
170
+ prev_gray = gray
171
+ frame_idx += 1
172
+ cap.release()
173
+ return selected_frames
174
+ def generate_panorama_image_from_video(video_path: str):
175
+ images = extract_and_select_frames(video_path)
176
+ gc.collect()
177
+ graph = compute_matches(images, match_conf=0.6)
178
+ degrees = dict(graph.degree())
179
+ start = max(degrees, key=degrees.get)
180
+ mst_order = compute_mst_order(graph, start)
181
+ panorama = generate_panorama_images([images[i] for i in mst_order])
182
+ if panorama is None:
183
+ return None
184
+ panorama = fill_black_with_inpainting(panorama)
185
+ panorama = pad_to_equirectangular(panorama)
186
+ return fill_black_with_inpainting(panorama)
app/migrations/versions/2025-07-07_tourvie2w_b4291c447067.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # type: ignore
2
+ """tourvie2w
3
+
4
+ Revision ID: b4291c447067
5
+ Revises: 97836c3c7d1b
6
+ Create Date: 2025-07-07 12:22:14.516779
7
+
8
+ """
9
+
10
+ import warnings
11
+ from typing import TYPE_CHECKING
12
+
13
+ import sqlalchemy as sa
14
+ from alembic import op
15
+ from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC
16
+ from sqlalchemy import Text # noqa: F401
17
+ from sqlalchemy.dialects import postgresql
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Sequence
20
+
21
+ __all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"]
22
+
23
+ sa.GUID = GUID
24
+ sa.DateTimeUTC = DateTimeUTC
25
+ sa.ORA_JSONB = ORA_JSONB
26
+ sa.EncryptedString = EncryptedString
27
+ sa.EncryptedText = EncryptedText
28
+
29
+ # revision identifiers, used by Alembic.
30
+ revision = 'b4291c447067'
31
+ down_revision = '97836c3c7d1b'
32
+ branch_labels = None
33
+ depends_on = None
34
+
35
+
36
+ def upgrade() -> None:
37
+ with warnings.catch_warnings():
38
+ warnings.filterwarnings("ignore", category=UserWarning)
39
+ with op.get_context().autocommit_block():
40
+ schema_upgrades()
41
+ data_upgrades()
42
+
43
+ def downgrade() -> None:
44
+ with warnings.catch_warnings():
45
+ warnings.filterwarnings("ignore", category=UserWarning)
46
+ with op.get_context().autocommit_block():
47
+ data_downgrades()
48
+ schema_downgrades()
49
+
50
+ def schema_upgrades() -> None:
51
+ """schema upgrade migrations go here."""
52
+ # ### commands auto generated by Alembic - please adjust! ###
53
+ op.create_table('tourviews',
54
+ sa.Column('id', sa.UUID(), nullable=False),
55
+ sa.Column('name', sa.String(length=255), nullable=False),
56
+ sa.Column('image_id', sa.UUID(), nullable=False),
57
+ sa.Column('property_id', sa.UUID(), nullable=False),
58
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
59
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
60
+ sa.ForeignKeyConstraint(['image_id'], ['images.id'], name=op.f('fk_tourviews_image_id_images'), ondelete='SET NULL'),
61
+ sa.ForeignKeyConstraint(['property_id'], ['properties.id'], name=op.f('fk_tourviews_property_id_properties'), ondelete='SET NULL'),
62
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_tourviews')),
63
+ sa.UniqueConstraint('id'),
64
+ sa.UniqueConstraint('id', name=op.f('uq_tourviews_id')),
65
+ sa.UniqueConstraint('image_id'),
66
+ sa.UniqueConstraint('image_id', name=op.f('uq_tourviews_image_id')),
67
+ sa.UniqueConstraint('property_id'),
68
+ sa.UniqueConstraint('property_id', name=op.f('uq_tourviews_property_id'))
69
+ )
70
+ # ### end Alembic commands ###
71
+
72
+ def schema_downgrades() -> None:
73
+ """schema downgrade migrations go here."""
74
+ # ### commands auto generated by Alembic - please adjust! ###
75
+ op.drop_table('tourviews')
76
+ # ### end Alembic commands ###
77
+
78
+ def data_upgrades() -> None:
79
+ """Add any optional data upgrade migrations here!"""
80
+
81
+ def data_downgrades() -> None:
82
+ """Add any optional data downgrade migrations here!"""