Dinh Hieu Nguyen commited on
Commit
8b35e63
·
verified ·
1 Parent(s): 26deac0

first commit

Browse files
Files changed (1) hide show
  1. miner.py +520 -0
miner.py ADDED
@@ -0,0 +1,520 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from ultralytics import YOLO
3
+ from numpy import ndarray
4
+ from pydantic import BaseModel
5
+ from typing import List, Tuple, Optional
6
+ import numpy as np
7
+ import cv2
8
+ from sklearn.cluster import KMeans
9
+ import base64
10
+ import boto3
11
+ import json
12
+ import uuid
13
+ import torch
14
+ from torchvision.models import resnet50, ResNet50_Weights
15
+ import torchvision.transforms as transforms
16
+
17
+
18
+ ########################################
19
+ # Helper utilities for R2 storage
20
+ ########################################
21
+
22
+ def init_r2_client():
23
+ """
24
+ Khởi tạo S3 client cho Cloudflare R2.
25
+ Returns:
26
+ tuple: (s3_client, bucket_name, can_upload)
27
+ """
28
+ try:
29
+ r2_account_id = "f5ac691bc782b80f90edb38eba5534ad"
30
+ r2_access_key_id = "54f3343f68621c563d7ca29d3b356122"
31
+ r2_secret_access_key = "41484baa8a10838e197f528b7eefbb824e1f38ffe13abc4e6b5fa7b68ad6d82d"
32
+ bucket_name = "my-miner-sn44"
33
+
34
+ can_upload = all([r2_account_id, r2_access_key_id, r2_secret_access_key, bucket_name])
35
+
36
+ if can_upload:
37
+ s3_client = boto3.client(
38
+ 's3',
39
+ endpoint_url=f"https://{r2_account_id}.r2.cloudflarestorage.com",
40
+ aws_access_key_id=r2_access_key_id,
41
+ aws_secret_access_key=r2_secret_access_key,
42
+ region_name='auto'
43
+ )
44
+ print(f"✅ R2 client initialized for bucket: {bucket_name}")
45
+ return s3_client, bucket_name, True
46
+ else:
47
+ print("⚠️ Thiếu một hoặc nhiều secret của R2, sẽ không lưu frames.")
48
+ return None, None, False
49
+
50
+ except Exception as e:
51
+ print(f"⚠️ Không thể khởi tạo S3 client: {e}")
52
+ return None, None, False
53
+
54
+
55
+ def image_to_base64(image: np.ndarray, quality: int = 85) -> str:
56
+ """
57
+ Convert numpy image array to base64 string.
58
+ Args:
59
+ image: numpy array (BGR format from OpenCV)
60
+ quality: JPEG quality (1-100, default 85)
61
+ Returns:
62
+ str: base64 encoded string
63
+ """
64
+ # Encode image as JPEG
65
+ encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), quality]
66
+ _, buffer = cv2.imencode('.jpg', image, encode_param)
67
+ # Convert to base64
68
+ base64_str = base64.b64encode(buffer).decode('utf-8')
69
+ return base64_str
70
+
71
+
72
+ def upload_frames_to_r2(
73
+ s3_client,
74
+ bucket_name: str,
75
+ frames_base64: List[dict],
76
+ challenge_id: str
77
+ ) -> bool:
78
+ """
79
+ Upload danh sách frames (base64) lên Cloudflare R2 dưới dạng JSON.
80
+ Args:
81
+ s3_client: boto3 S3 client
82
+ bucket_name: Tên bucket R2
83
+ frames_base64: List of dicts with frame_id and base64 data
84
+ challenge_id: ID của challenge (dùng làm tên file)
85
+ Returns:
86
+ bool: True nếu upload thành công
87
+ """
88
+ try:
89
+ json_filename = f"{challenge_id}_frames.json"
90
+ json_data = json.dumps(frames_base64)
91
+
92
+ s3_client.put_object(
93
+ Bucket=bucket_name,
94
+ Key=json_filename,
95
+ Body=json_data.encode('utf-8'),
96
+ ContentType='application/json'
97
+ )
98
+ print(f"✅ {len(frames_base64)} frames đã được lưu vào R2: {json_filename}")
99
+ return True
100
+ except Exception as e:
101
+ print(f"⚠️ Lỗi khi tải frames lên R2: {e}")
102
+ return False
103
+
104
+
105
+ ########################################
106
+ # Helper utilities for grass & color clustering
107
+ ########################################
108
+
109
+ def get_grass_color(img: np.ndarray) -> Tuple[int, int, int]:
110
+ """Estimate dominant green (grass) color from the image in BGR."""
111
+ hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
112
+ lower_green = np.array([30, 40, 40])
113
+ upper_green = np.array([80, 255, 255])
114
+ mask = cv2.inRange(hsv, lower_green, upper_green)
115
+ grass_color = cv2.mean(img, mask=mask)
116
+ return grass_color[:3]
117
+
118
+
119
+ def get_players_boxes(result):
120
+ """Extract player crops and boxes from YOLO result.
121
+
122
+ Model class mapping:
123
+ 0: 'Player', 1: 'GoalKeeper', 2: 'Ball', 3: 'Main Referee',
124
+ 4: 'Side Referee', 5: 'Staff Member', 6: 'left team', 7: 'right team'
125
+ """
126
+ players_imgs, players_boxes = [], []
127
+ for box in result.boxes:
128
+ label = int(box.cls.cpu().numpy()[0])
129
+ if label == 0: # Player class (cls_id=0 is Player)
130
+ x1, y1, x2, y2 = map(int, box.xyxy[0].cpu().numpy())
131
+ crop = result.orig_img[y1:y2, x1:x2]
132
+ if crop.size > 0:
133
+ players_imgs.append(crop)
134
+ players_boxes.append((x1, y1, x2, y2))
135
+ return players_imgs, players_boxes
136
+
137
+
138
+ def get_kits_colors(players, grass_hsv=None, frame=None):
139
+ """Extract average kit colors from player crops."""
140
+ kits_colors = []
141
+ if grass_hsv is None:
142
+ grass_color = get_grass_color(frame)
143
+ grass_hsv = cv2.cvtColor(np.uint8([[list(grass_color)]]), cv2.COLOR_BGR2HSV)
144
+ for player_img in players:
145
+ hsv = cv2.cvtColor(player_img, cv2.COLOR_BGR2HSV)
146
+ lower_green = np.array([grass_hsv[0, 0, 0] - 10, 40, 40])
147
+ upper_green = np.array([grass_hsv[0, 0, 0] + 10, 255, 255])
148
+ mask = cv2.inRange(hsv, lower_green, upper_green)
149
+ mask = cv2.bitwise_not(mask)
150
+ upper_mask = np.zeros(player_img.shape[:2], np.uint8)
151
+ upper_mask[0:player_img.shape[0] // 2, :] = 255
152
+ mask = cv2.bitwise_and(mask, upper_mask)
153
+ kit_color = np.array(cv2.mean(player_img, mask=mask)[:3])
154
+ kits_colors.append(kit_color)
155
+ return kits_colors
156
+
157
+
158
+ # ============================================================================
159
+ # Team Classification using ResNet50 Features
160
+ # ============================================================================
161
+ class TeamClassifierResNet:
162
+ def __init__(self, device="cuda"):
163
+ self.device = device
164
+ self.model = resnet50(weights=ResNet50_Weights.IMAGENET1K_V1).to(device).eval()
165
+ self.preprocess = transforms.Compose([
166
+ transforms.ToPILImage(),
167
+ transforms.Resize((224, 224)),
168
+ transforms.ToTensor(),
169
+ transforms.Normalize(
170
+ mean=[0.485, 0.456, 0.406],
171
+ std=[0.229, 0.224, 0.225],
172
+ ),
173
+ ])
174
+ self.kmeans = None
175
+ self.left_team = None
176
+
177
+ def get_feature(self, img):
178
+ t = self.preprocess(img).unsqueeze(0).to(self.device)
179
+ with torch.no_grad():
180
+ f = self.model(t)
181
+ # ✅ convert to float64 here to be safe for sklearn
182
+ return f.squeeze(0).cpu().numpy().astype(np.float64)
183
+
184
+ def fit(self, player_crops, player_centers):
185
+ feats = []
186
+ for crop in player_crops:
187
+ feats.append(self.get_feature(crop))
188
+ feats = np.array(feats, dtype=np.float64)
189
+
190
+ # KMeans feature clustering
191
+ self.kmeans = KMeans(n_clusters=2, random_state=0)
192
+ labels = self.kmeans.fit_predict(feats)
193
+
194
+ # Determine which team is on the left side
195
+ mean_x = {0: [], 1: []}
196
+ for lab, (x, y) in zip(labels, player_centers):
197
+ mean_x[lab].append(x)
198
+
199
+ left = 0 if np.mean(mean_x[0]) < np.mean(mean_x[1]) else 1
200
+ self.left_team = left
201
+ return labels
202
+
203
+ ########################################
204
+ # Data models
205
+ ########################################
206
+
207
+ class BoundingBox(BaseModel):
208
+ x1: int
209
+ y1: int
210
+ x2: int
211
+ y2: int
212
+ cls_id: int
213
+ conf: float
214
+
215
+
216
+ class TVFrameResult(BaseModel):
217
+ frame_id: int
218
+ boxes: list[BoundingBox]
219
+ keypoints: list[Tuple[int, int]]
220
+
221
+
222
+ ########################################
223
+ # Main Miner class
224
+ ########################################
225
+
226
+ class Miner:
227
+ """
228
+ Main class for sn44-compatible inference pipeline.
229
+ Integrates YOLO + team color classification (HSV-based).
230
+ """
231
+ CORNER_INDICES = {0, 5, 24, 29}
232
+
233
+ def __init__(
234
+ self,
235
+ path_hf_repo: Path,
236
+ ) -> None:
237
+ """Load models from the repository.
238
+
239
+ Model class mapping:
240
+ 0: 'Player', 1: 'GoalKeeper', 2: 'Ball', 3: 'Main Referee',
241
+ 4: 'Side Referee', 5: 'Staff Member', 6: 'left team', 7: 'right team'
242
+
243
+ Args:
244
+ path_hf_repo: Path to HuggingFace repo with models
245
+ enable_frame_storage: If True, collect frames as base64 for R2 upload
246
+ storage_quality: JPEG quality for stored frames (1-100)
247
+ challenge_id: Challenge ID for R2 upload (required if enable_frame_storage=True)
248
+ """
249
+ enable_frame_storage = True
250
+ storage_quality = 85
251
+
252
+ challenge_id = f"challenge_{uuid.uuid4().hex[:12]}"
253
+
254
+ # Option 2: Timestamp-based (unique theo thời gian)
255
+ # challenge_id = f"challenge_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
256
+
257
+ print(f"✅ Auto-generated challenge_id: {challenge_id}")
258
+
259
+
260
+ self.bbox_model = YOLO(path_hf_repo / "251110-football-detection.pt")
261
+ print("✅ BBox Model Loaded")
262
+ self.keypoints_model = YOLO(path_hf_repo / "17112025_keypoint.pt")
263
+ print("✅ Keypoints Model (Pose) Loaded")
264
+
265
+ self.team_kmeans = None
266
+ self.left_team_label = 0
267
+ self.grass_hsv = None
268
+ self.team_classifier_fitted = False
269
+
270
+ # Frame storage setup
271
+ self.enable_frame_storage = enable_frame_storage
272
+ self.storage_quality = storage_quality
273
+ self.stored_frames: List[dict] = [] # Store frames as base64
274
+ self.challenge_id = challenge_id
275
+
276
+ # R2 client setup
277
+ if enable_frame_storage:
278
+ self.s3_client, self.r2_bucket, self.can_upload = init_r2_client()
279
+ if not challenge_id:
280
+ print("⚠️ WARNING: enable_frame_storage=True nhưng chưa set challenge_id")
281
+ else:
282
+ self.s3_client = None
283
+ self.r2_bucket = None
284
+ self.can_upload = False
285
+
286
+ def __repr__(self) -> str:
287
+ return (
288
+ f"BBox Model: {type(self.bbox_model).__name__}\n"
289
+ f"Keypoints Model: {type(self.keypoints_model).__name__}\n"
290
+ f"Team Clustering: HSV + KMeans"
291
+ )
292
+
293
+ def fit_team_classifier(self, frame):
294
+ print("[INFO] Extracting players from first frame for team classifier...")
295
+
296
+ result = self.bbox_model(frame, conf=0.2, verbose=False)[0]
297
+
298
+ players_imgs = []
299
+ player_centers = []
300
+
301
+ if result and result.boxes is not None:
302
+ for box in result.boxes:
303
+ cls_id = int(box.cls.cpu().numpy()[0])
304
+ if cls_id == 0: # player
305
+ x1, y1, x2, y2 = map(int, box.xyxy[0].cpu().numpy())
306
+ crop = frame[y1:y2, x1:x2]
307
+ players_imgs.append(crop)
308
+ player_centers.append(((x1 + x2) / 2, (y1 + y2) / 2))
309
+
310
+ if len(players_imgs) < 2:
311
+ print("[WARN] Not enough players to fit KMeans. Skip.")
312
+ self.team_classifier_fitted = True
313
+ return None
314
+
315
+ # Init classifier
316
+ self.team_classifier = TeamClassifierResNet()
317
+
318
+ # Extract features
319
+ feats = []
320
+ for crop in players_imgs:
321
+ try:
322
+ f = self.team_classifier.get_feature(crop)
323
+ feats.append(f)
324
+ except:
325
+ feats.append(np.zeros(512, dtype=np.float64))
326
+
327
+ feats = np.array(feats, dtype=np.float64) # ✅ convert to float64
328
+
329
+ # Fit KMeans
330
+ print("[INFO] Fitting KMeans on ResNet player features...")
331
+ self.team_kmeans = KMeans(n_clusters=2, random_state=0)
332
+ teams = self.team_kmeans.fit_predict(feats)
333
+
334
+ # Determine left team
335
+ left_cluster = np.argmin([
336
+ np.mean([c for c, t in zip([x for x, y in player_centers], teams) if t == cluster])
337
+ for cluster in [0, 1]
338
+ ])
339
+ self.left_team_label = left_cluster
340
+ self.team_classifier_fitted = True
341
+ print("[INFO] Team classifier fitted using ResNet50.")
342
+
343
+
344
+ def _auto_upload_frames(self) -> None:
345
+ """Internal method to auto-upload frames after last batch."""
346
+ if not self.challenge_id:
347
+ print("❌ Không thể upload: challenge_id chưa được set!")
348
+ return
349
+
350
+ total_frames = len(self.stored_frames)
351
+ size_mb = self.get_stored_frames_size_mb()
352
+
353
+ print(f"📊 Tổng frames đã lưu: {total_frames}")
354
+ print(f"💾 Size trong memory: {size_mb:.2f} MB")
355
+ print(f"📤 Đang upload lên R2...")
356
+
357
+ success = upload_frames_to_r2(
358
+ self.s3_client,
359
+ self.r2_bucket,
360
+ self.stored_frames,
361
+ self.challenge_id
362
+ )
363
+
364
+ if success:
365
+ print(f"✅ Upload thành công {total_frames} frames!")
366
+ print(f"📁 File trên R2: {self.challenge_id}_frames.json")
367
+ # Clear frames after successful upload
368
+ self.clear_stored_frames()
369
+ else:
370
+ print(f"❌ Upload thất bại!")
371
+ print(f"💡 Frames vẫn còn trong memory. Có thể retry bằng: miner.upload_stored_frames('{self.challenge_id}')")
372
+
373
+ def upload_stored_frames(self, challenge_id: str) -> bool:
374
+ """
375
+ Upload all stored frames to R2.
376
+ Args:
377
+ challenge_id: ID của challenge để đặt tên file
378
+ Returns:
379
+ bool: True nếu upload thành công
380
+ """
381
+ if not self.can_upload:
382
+ print("⚠️ R2 client chưa được khởi tạo, không thể upload frames.")
383
+ return False
384
+
385
+ if len(self.stored_frames) == 0:
386
+ print("⚠️ Không có frames nào để upload.")
387
+ return False
388
+
389
+ print(f"📤 Đang upload {len(self.stored_frames)} frames lên R2...")
390
+ success = upload_frames_to_r2(
391
+ self.s3_client,
392
+ self.r2_bucket,
393
+ self.stored_frames,
394
+ challenge_id
395
+ )
396
+
397
+ if success:
398
+ print(f"✅ Đã upload thành công {len(self.stored_frames)} frames")
399
+ return True
400
+ else:
401
+ print("Chưa upload được.")
402
+ return False
403
+
404
+ def clear_stored_frames(self) -> None:
405
+ """Clear all stored frames from memory."""
406
+ self.stored_frames = []
407
+ print("🗑️ Đã xóa stored frames khỏi memory")
408
+
409
+ def get_stored_frames_count(self) -> int:
410
+ """Get number of stored frames."""
411
+ return len(self.stored_frames)
412
+
413
+ def get_stored_frames_size_mb(self) -> float:
414
+ """Get approximate size of stored frames in MB."""
415
+ if len(self.stored_frames) == 0:
416
+ return 0.0
417
+ total_size = sum(len(frame["data"]) for frame in self.stored_frames)
418
+ # Base64 encoding adds ~33% overhead, but we calculate as-is
419
+ return total_size / (1024 * 1024)
420
+
421
+ def predict_batch(self, batch_images: list[ndarray], offset: int, n_keypoints: int) -> list[TVFrameResult]:
422
+ results: list[TVFrameResult] = []
423
+
424
+ for i, frame in enumerate(batch_images):
425
+ frame_id = offset + i
426
+
427
+ if not self.team_classifier_fitted:
428
+ self.fit_team_classifier(frame)
429
+
430
+ bbox_result = self.bbox_model(frame, conf=0.2, verbose=False)[0]
431
+ boxes = []
432
+
433
+ if bbox_result and bbox_result.boxes is not None:
434
+ players_imgs, players_boxes = get_players_boxes(bbox_result)
435
+
436
+ # Extract features
437
+ player_features = []
438
+ for crop in players_imgs:
439
+ try:
440
+ feat = self.team_classifier.get_feature(crop)
441
+ player_features.append(feat)
442
+ except:
443
+ player_features.append(np.zeros(512, dtype=np.float64))
444
+
445
+ # Predict teams
446
+ teams = []
447
+ if len(player_features) > 0 and self.team_kmeans is not None:
448
+ player_features = np.array(player_features, dtype=np.float64) # ✅ convert to float64
449
+ teams = self.team_kmeans.predict(player_features)
450
+
451
+ # Map teams to boxes
452
+ player_indices = [idx for idx, box in enumerate(bbox_result.boxes) if int(box.cls.cpu().numpy()[0]) == 0]
453
+ team_predictions = {}
454
+ if len(player_indices) > 0 and len(teams) > 0:
455
+ for player_idx, team_id in zip(player_indices, teams):
456
+ team_predictions[player_idx] = 6 if team_id == self.left_team_label else 7
457
+
458
+ # Create BoundingBox list
459
+ for idx, box in enumerate(bbox_result.boxes):
460
+ x1, y1, x2, y2 = map(int, box.xyxy[0].cpu().numpy())
461
+ conf = float(box.conf.cpu().numpy()[0])
462
+ cls_id = int(box.cls.cpu().numpy()[0])
463
+
464
+ if idx in team_predictions:
465
+ cls_id = team_predictions[idx]
466
+ elif cls_id == 0:
467
+ cls_id = 2
468
+ elif cls_id == 1:
469
+ cls_id = 1
470
+ elif cls_id == 2:
471
+ cls_id = 0
472
+ elif cls_id in [3, 4]:
473
+ cls_id = 3
474
+ else:
475
+ continue
476
+
477
+ boxes.append(BoundingBox(x1=x1, y1=y1, x2=x2, y2=y2, cls_id=cls_id, conf=conf))
478
+
479
+ # -----------------------------------------
480
+ # Keypoint detection using YOLO pose model
481
+ # -----------------------------------------
482
+ keypoints_result = self.keypoints_model(frame, verbose=False)[0]
483
+ frame_keypoints: List[Tuple[int, int]] = [(0, 0)] * n_keypoints
484
+ if keypoints_result and hasattr(keypoints_result, "keypoints") and keypoints_result.keypoints is not None:
485
+ frame_keypoints_with_conf = []
486
+ for i, part_points in enumerate(keypoints_result.keypoints.data):
487
+ for k_id, (x, y, _) in enumerate(part_points):
488
+ confidence = float(keypoints_result.keypoints.conf[i][k_id])
489
+ frame_keypoints_with_conf.append((int(x), int(y), confidence))
490
+
491
+ if len(frame_keypoints_with_conf) < n_keypoints:
492
+ frame_keypoints_with_conf.extend([(0, 0, 0.0)] * (n_keypoints - len(frame_keypoints_with_conf)))
493
+ else:
494
+ frame_keypoints_with_conf = frame_keypoints_with_conf[:n_keypoints]
495
+
496
+ filtered_keypoints = []
497
+ for idx, (x, y, confidence) in enumerate(frame_keypoints_with_conf):
498
+ if idx in self.CORNER_INDICES:
499
+ filtered_keypoints.append((int(x), int(y)) if confidence >= 0.3 else (0, 0))
500
+ else:
501
+ filtered_keypoints.append((int(x), int(y)) if confidence >= 0.5 else (0, 0))
502
+ frame_keypoints = filtered_keypoints
503
+
504
+ results.append(TVFrameResult(frame_id=frame_id, boxes=boxes, keypoints=frame_keypoints))
505
+
506
+ # Auto-upload when reaching frame 750
507
+ if frame_id == 749 and self.enable_frame_storage and self.can_upload:
508
+ try:
509
+ if len(self.stored_frames) > 0:
510
+ print(f"\n{'='*60}")
511
+ print(f"🏁 FRAME 750 REACHED - Tự động upload {len(self.stored_frames)} frames lên R2")
512
+ print(f"{'='*60}")
513
+ self._auto_upload_frames()
514
+ else:
515
+ print("⚠️ Frame 750 reached nhưng không có frames nào để upload.")
516
+ except Exception as e:
517
+ print(f"⚠️ Lỗi khi upload R2: {e}")
518
+ print(f"💡 Tiếp tục trả về results. Frames vẫn còn trong memory.")
519
+
520
+ return results