1102 lines
43 KiB
Python
1102 lines
43 KiB
Python
|
|
import json
|
|||
|
|
import io
|
|||
|
|
import logging
|
|||
|
|
import os
|
|||
|
|
import tarfile
|
|||
|
|
import threading
|
|||
|
|
import time
|
|||
|
|
import uuid
|
|||
|
|
import zipfile
|
|||
|
|
from datetime import datetime
|
|||
|
|
import xml.etree.ElementTree as ET
|
|||
|
|
from typing import List
|
|||
|
|
|
|||
|
|
from django.conf import settings
|
|||
|
|
from django.db import close_old_connections, connection, transaction
|
|||
|
|
from django.db.models import Case, Count, IntegerField, Max, Q, Sum, When
|
|||
|
|
from django.shortcuts import get_object_or_404
|
|||
|
|
from rest_framework import status
|
|||
|
|
from rest_framework.decorators import api_view, parser_classes, permission_classes
|
|||
|
|
from rest_framework.pagination import PageNumberPagination
|
|||
|
|
from rest_framework.parsers import MultiPartParser
|
|||
|
|
from rest_framework.permissions import IsAuthenticated
|
|||
|
|
from rest_framework.response import Response
|
|||
|
|
|
|||
|
|
from apps.common.serializers import AiDatasetSerializer, AiDatasetSampleSerializer
|
|||
|
|
from apps.core.models import AiDataset
|
|||
|
|
from apps.core.models import (
|
|||
|
|
AiAlgorithmTrainRecords,
|
|||
|
|
AiDatasetSample,
|
|||
|
|
AiAnnotateTask,
|
|||
|
|
AiTempFile,
|
|||
|
|
)
|
|||
|
|
from apps.common.minio_client import minio_storage
|
|||
|
|
from apps.common.utils.annotation_store import split_dataset_sample_annotation_content
|
|||
|
|
from apps.datasets.dataset_stats import refresh_dataset_sample_counters
|
|||
|
|
from apps.datasets.storage_service import (
|
|||
|
|
VIDEO_EXTENSIONS,
|
|||
|
|
copy_minio_object_to_dataset_result,
|
|||
|
|
copy_minio_object_to_dataset,
|
|||
|
|
dataset_type_accepts_extension,
|
|||
|
|
extract_video_frames_to_dataset_result,
|
|||
|
|
extract_video_frames_to_dataset,
|
|||
|
|
import_zip_bytes_to_dataset_result,
|
|||
|
|
import_zip_bytes_to_dataset,
|
|||
|
|
save_bytes_to_dataset,
|
|||
|
|
upload_sidecar_bytes,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
IMAGE_EXTENSIONS = ("jpg", "jpeg", "png", "bmp", "gif")
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_filespace_url(rel_path: str) -> str:
|
|||
|
|
base = str(getattr(settings, "IMAGE_BASE_URL", "") or "").rstrip("/")
|
|||
|
|
rel = str(rel_path or "").lstrip("/").replace("\\", "/")
|
|||
|
|
return f"{base}/{rel}" if base else rel
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _strip_nginx_image_prefix(raw: str) -> str:
|
|||
|
|
s = str(raw or "").strip().replace("\\", "/")
|
|||
|
|
if not s:
|
|||
|
|
return ""
|
|||
|
|
if "://" not in s:
|
|||
|
|
return s.lstrip("/")
|
|||
|
|
try:
|
|||
|
|
from urllib.parse import urlsplit
|
|||
|
|
|
|||
|
|
parts = urlsplit(s)
|
|||
|
|
path = (parts.path or "").lstrip("/")
|
|||
|
|
except Exception:
|
|||
|
|
idx = s.find("://")
|
|||
|
|
path = s[idx + 3 :]
|
|||
|
|
slash = path.find("/")
|
|||
|
|
path = path[slash + 1 :] if slash >= 0 else ""
|
|||
|
|
path = path.lstrip("/")
|
|||
|
|
if path.startswith("image/"):
|
|||
|
|
path = path[len("image/") :]
|
|||
|
|
return path.lstrip("/")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _videos_base_rel(videos_dir_rel: str) -> str:
|
|||
|
|
s = str(videos_dir_rel or "").strip().strip("/").replace("\\", "/")
|
|||
|
|
return f"{s}/" if s else ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _safe_load_json_list(raw: str) -> List[dict]:
|
|||
|
|
if not raw:
|
|||
|
|
return []
|
|||
|
|
s = str(raw).strip()
|
|||
|
|
if not s.startswith("["):
|
|||
|
|
return []
|
|||
|
|
try:
|
|||
|
|
obj = json.loads(s)
|
|||
|
|
except Exception:
|
|||
|
|
return []
|
|||
|
|
if isinstance(obj, list):
|
|||
|
|
return [x for x in obj if isinstance(x, dict)]
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _safe_load_json_value(raw, default):
|
|||
|
|
if raw in (None, ""):
|
|||
|
|
return default
|
|||
|
|
if isinstance(raw, (dict, list)):
|
|||
|
|
return raw
|
|||
|
|
try:
|
|||
|
|
return json.loads(raw)
|
|||
|
|
except Exception:
|
|||
|
|
return default
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _find_dataset_sample_for_xml(dataset_id: str, image_name: str, xml_name: str):
|
|||
|
|
queryset = AiDatasetSample.objects.filter(dataset_id=dataset_id).order_by("-create_time")
|
|||
|
|
candidates = []
|
|||
|
|
for name in (image_name, os.path.basename(image_name or ""), xml_name):
|
|||
|
|
s = str(name or "").strip()
|
|||
|
|
if s:
|
|||
|
|
candidates.append(os.path.basename(s))
|
|||
|
|
|
|||
|
|
for candidate in candidates:
|
|||
|
|
exact = queryset.filter(Q(original_filename=candidate) | Q(saved_filename=candidate)).first()
|
|||
|
|
if exact:
|
|||
|
|
return exact
|
|||
|
|
|
|||
|
|
stems = {os.path.splitext(name)[0].lower() for name in candidates if name}
|
|||
|
|
if not stems:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
for sample in queryset:
|
|||
|
|
original_stem = os.path.splitext(str(sample.original_filename or ""))[0].lower()
|
|||
|
|
saved_stem = os.path.splitext(str(sample.saved_filename or ""))[0].lower()
|
|||
|
|
if original_stem in stems or saved_stem in stems:
|
|||
|
|
return sample
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_voc_xml_annotations(file_content: bytes, dataset_labels) -> tuple[str, list[dict]]:
|
|||
|
|
root = ET.fromstring(file_content)
|
|||
|
|
image_name = root.findtext("filename") or ""
|
|||
|
|
label_config = _safe_load_json_value(dataset_labels, [])
|
|||
|
|
label_map = {}
|
|||
|
|
for label in label_config if isinstance(label_config, list) else []:
|
|||
|
|
if not isinstance(label, dict):
|
|||
|
|
continue
|
|||
|
|
code = str(label.get("label") or "").strip()
|
|||
|
|
name = str(label.get("name") or code).strip()
|
|||
|
|
if code:
|
|||
|
|
label_map[code] = {
|
|||
|
|
"label": code,
|
|||
|
|
"name": name or code,
|
|||
|
|
"color": label.get("color") or "#409eff",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
result_list = []
|
|||
|
|
for obj in root.findall("object"):
|
|||
|
|
obj_name = str(obj.findtext("name") or "").strip()
|
|||
|
|
bndbox = obj.find("bndbox")
|
|||
|
|
if not obj_name or bndbox is None:
|
|||
|
|
continue
|
|||
|
|
xmin = bndbox.findtext("xmin")
|
|||
|
|
ymin = bndbox.findtext("ymin")
|
|||
|
|
xmax = bndbox.findtext("xmax")
|
|||
|
|
ymax = bndbox.findtext("ymax")
|
|||
|
|
if None in {xmin, ymin, xmax, ymax}:
|
|||
|
|
continue
|
|||
|
|
matched_label = label_map.get(
|
|||
|
|
obj_name,
|
|||
|
|
{"label": obj_name, "name": obj_name, "color": "#409eff"},
|
|||
|
|
)
|
|||
|
|
result_list.append(
|
|||
|
|
{
|
|||
|
|
"label": matched_label["label"],
|
|||
|
|
"name": matched_label["name"],
|
|||
|
|
"color": matched_label["color"],
|
|||
|
|
"region": [xmin, ymin, xmax, ymax],
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
return image_name, result_list
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _load_temp_file_imported_dataset_ids(raw: str) -> list[str]:
|
|||
|
|
return [item for item in str(raw or "").split(",") if item]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _dump_temp_file_imported_dataset_ids(dataset_ids: list[str]) -> str:
|
|||
|
|
return ",".join(dict.fromkeys([item for item in dataset_ids if item]))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _compact_temp_import_summary(payload: dict, max_length: int = 1000) -> str:
|
|||
|
|
candidate = dict(payload or {})
|
|||
|
|
for limit in (5, 3, 1, 0):
|
|||
|
|
shrunk = dict(candidate)
|
|||
|
|
if isinstance(shrunk.get("failures"), list):
|
|||
|
|
failures = shrunk["failures"][:limit]
|
|||
|
|
shrunk["failures"] = failures
|
|||
|
|
shrunk["failures_truncated"] = len(candidate.get("failures", [])) > len(failures)
|
|||
|
|
if isinstance(shrunk.get("duplicates"), list):
|
|||
|
|
duplicates = shrunk["duplicates"][:limit]
|
|||
|
|
shrunk["duplicates"] = duplicates
|
|||
|
|
shrunk["duplicates_truncated"] = len(candidate.get("duplicates", [])) > len(duplicates)
|
|||
|
|
text = json.dumps(shrunk, ensure_ascii=False, separators=(",", ":"))
|
|||
|
|
if len(text) <= max_length:
|
|||
|
|
return text
|
|||
|
|
return json.dumps({"status": payload.get("status"), "message": payload.get("message")}, ensure_ascii=False)[:max_length]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _normalize_temp_file_ids(data) -> list[str]:
|
|||
|
|
raw_values = []
|
|||
|
|
if hasattr(data, "getlist"):
|
|||
|
|
raw_values.extend(data.getlist("temp_file_ids"))
|
|||
|
|
temp_file_ids = data.get("temp_file_ids")
|
|||
|
|
if isinstance(temp_file_ids, (list, tuple)):
|
|||
|
|
raw_values.extend(temp_file_ids)
|
|||
|
|
elif temp_file_ids not in (None, ""):
|
|||
|
|
raw_values.extend(str(temp_file_ids).split(","))
|
|||
|
|
temp_file_id = data.get("temp_file_id")
|
|||
|
|
if temp_file_id not in (None, ""):
|
|||
|
|
raw_values.append(temp_file_id)
|
|||
|
|
|
|||
|
|
normalized = []
|
|||
|
|
seen = set()
|
|||
|
|
for item in raw_values:
|
|||
|
|
value = str(item or "").strip()
|
|||
|
|
if not value or value in seen:
|
|||
|
|
continue
|
|||
|
|
normalized.append(value)
|
|||
|
|
seen.add(value)
|
|||
|
|
return normalized
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _import_single_temp_file_to_dataset(dataset: AiDataset, temp_file: AiTempFile, interval_seconds: int, max_frames: int) -> tuple[int, dict]:
|
|||
|
|
dataset_id = str(dataset.id)
|
|||
|
|
with transaction.atomic():
|
|||
|
|
bucket_name = temp_file.bucket_name or "tempfile"
|
|||
|
|
object_name = temp_file.object_name
|
|||
|
|
if not object_name:
|
|||
|
|
return status.HTTP_400_BAD_REQUEST, {"error": "临时文件对象不存在"}
|
|||
|
|
|
|||
|
|
info = minio_storage.get_object_info(bucket_name, object_name)
|
|||
|
|
filename = temp_file.original_name or os.path.basename(object_name)
|
|||
|
|
original_type = str(temp_file.original_type or "")
|
|||
|
|
imported_dataset_ids = _load_temp_file_imported_dataset_ids(temp_file.custom3)
|
|||
|
|
if dataset_id in imported_dataset_ids:
|
|||
|
|
latest_summary = _safe_load_json_value(temp_file.custom2, {})
|
|||
|
|
return status.HTTP_409_CONFLICT, {
|
|||
|
|
"error": "该临时文件已导入过当前数据集,无需重复导入",
|
|||
|
|
"latest_import": latest_summary,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
source_marker_prefix = f"temp:{temp_file.id}"
|
|||
|
|
result = {"imported": [], "skipped": [], "failed": []}
|
|||
|
|
|
|||
|
|
if original_type == "04":
|
|||
|
|
zip_bytes = minio_storage.download_bytes(bucket_name, object_name)
|
|||
|
|
result = import_zip_bytes_to_dataset_result(
|
|||
|
|
dataset=dataset,
|
|||
|
|
zip_bytes=zip_bytes,
|
|||
|
|
source_marker_prefix=source_marker_prefix,
|
|||
|
|
skip_existing=True,
|
|||
|
|
)
|
|||
|
|
elif dataset.dataset_type == "02" and original_type == "02":
|
|||
|
|
video_bytes = minio_storage.download_bytes(bucket_name, object_name)
|
|||
|
|
result = extract_video_frames_to_dataset_result(
|
|||
|
|
dataset=dataset,
|
|||
|
|
video_bytes=video_bytes,
|
|||
|
|
original_filename=filename,
|
|||
|
|
interval_seconds=interval_seconds,
|
|||
|
|
max_frames=max_frames,
|
|||
|
|
source_marker_prefix=source_marker_prefix,
|
|||
|
|
skip_existing=True,
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
ext = os.path.splitext(filename)[1].lower()
|
|||
|
|
if not dataset_type_accepts_extension(dataset.dataset_type, ext):
|
|||
|
|
return status.HTTP_400_BAD_REQUEST, {"error": "临时文件类型与数据集类型不匹配"}
|
|||
|
|
result = copy_minio_object_to_dataset_result(
|
|||
|
|
dataset=dataset,
|
|||
|
|
source_bucket=bucket_name,
|
|||
|
|
source_object=object_name,
|
|||
|
|
original_filename=filename,
|
|||
|
|
size_bytes=info.get("size", 0),
|
|||
|
|
source_marker=f"{source_marker_prefix}:object",
|
|||
|
|
skip_existing=True,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
imported_samples = result["imported"]
|
|||
|
|
duplicate_items = result["skipped"]
|
|||
|
|
failed_items = result["failed"]
|
|||
|
|
imported_count = len(imported_samples)
|
|||
|
|
duplicate_count = len(duplicate_items)
|
|||
|
|
failed_count = len(failed_items)
|
|||
|
|
|
|||
|
|
if imported_count > 0:
|
|||
|
|
refresh_dataset_sample_counters(dataset_id)
|
|||
|
|
|
|||
|
|
summary_status = "success"
|
|||
|
|
if imported_count > 0 and failed_count > 0:
|
|||
|
|
summary_status = "partial"
|
|||
|
|
elif imported_count == 0 and duplicate_count > 0 and failed_count == 0:
|
|||
|
|
summary_status = "duplicate"
|
|||
|
|
elif imported_count == 0 and failed_count > 0:
|
|||
|
|
summary_status = "failed"
|
|||
|
|
|
|||
|
|
summary_payload = {
|
|||
|
|
"status": summary_status,
|
|||
|
|
"dataset_id": dataset_id,
|
|||
|
|
"temp_file_id": temp_file.id,
|
|||
|
|
"bucket_name": bucket_name,
|
|||
|
|
"object_name": object_name,
|
|||
|
|
"filename": filename,
|
|||
|
|
"original_type": original_type,
|
|||
|
|
"etag": info.get("etag"),
|
|||
|
|
"imported_count": imported_count,
|
|||
|
|
"duplicate_count": duplicate_count,
|
|||
|
|
"failed_count": failed_count,
|
|||
|
|
"duplicates": duplicate_items,
|
|||
|
|
"failures": failed_items,
|
|||
|
|
}
|
|||
|
|
temp_file.custom1 = summary_status
|
|||
|
|
temp_file.custom2 = _compact_temp_import_summary(summary_payload)
|
|||
|
|
if imported_count > 0 and failed_count == 0:
|
|||
|
|
imported_dataset_ids.append(dataset_id)
|
|||
|
|
temp_file.custom3 = _dump_temp_file_imported_dataset_ids(imported_dataset_ids)
|
|||
|
|
if imported_count > 0:
|
|||
|
|
temp_file.status = "02"
|
|||
|
|
temp_file.save(update_fields=["status", "custom1", "custom2", "custom3"])
|
|||
|
|
|
|||
|
|
if failed_items:
|
|||
|
|
logger.warning("temp file import partial/failed: %s", summary_payload)
|
|||
|
|
else:
|
|||
|
|
logger.info("temp file import success: %s", summary_payload)
|
|||
|
|
|
|||
|
|
if imported_count == 0 and duplicate_count > 0 and failed_count == 0:
|
|||
|
|
return status.HTTP_409_CONFLICT, {
|
|||
|
|
"message": "临时文件已导入过当前数据集,本次未新增样本",
|
|||
|
|
"imported_count": 0,
|
|||
|
|
"duplicate_count": duplicate_count,
|
|||
|
|
"failed_count": 0,
|
|||
|
|
"duplicates": duplicate_items,
|
|||
|
|
"failures": [],
|
|||
|
|
"sample_ids": [],
|
|||
|
|
}
|
|||
|
|
if imported_count == 0 and failed_count > 0:
|
|||
|
|
return status.HTTP_400_BAD_REQUEST, {
|
|||
|
|
"error": "未导入任何样本,请检查文件类型与数据集类型是否匹配",
|
|||
|
|
"imported_count": 0,
|
|||
|
|
"duplicate_count": duplicate_count,
|
|||
|
|
"failed_count": failed_count,
|
|||
|
|
"duplicates": duplicate_items,
|
|||
|
|
"failures": failed_items,
|
|||
|
|
"sample_ids": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
message = "临时文件导入数据集成功"
|
|||
|
|
if failed_count > 0:
|
|||
|
|
message = f"临时文件导入部分成功,成功{imported_count}条,失败{failed_count}条"
|
|||
|
|
elif duplicate_count > 0:
|
|||
|
|
message = f"临时文件导入完成,新增{imported_count}条,跳过重复{duplicate_count}条"
|
|||
|
|
return status.HTTP_200_OK, {
|
|||
|
|
"message": message,
|
|||
|
|
"imported_count": imported_count,
|
|||
|
|
"duplicate_count": duplicate_count,
|
|||
|
|
"failed_count": failed_count,
|
|||
|
|
"duplicates": duplicate_items,
|
|||
|
|
"failures": failed_items,
|
|||
|
|
"sample_ids": [sample.id for sample in imported_samples],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _load_video_manifest(raw: str) -> tuple[str, List[dict]]:
|
|||
|
|
if not raw:
|
|||
|
|
return "", []
|
|||
|
|
s = str(raw).strip()
|
|||
|
|
if not s:
|
|||
|
|
return "", []
|
|||
|
|
if s.startswith("{"):
|
|||
|
|
try:
|
|||
|
|
obj = json.loads(s)
|
|||
|
|
except Exception:
|
|||
|
|
return "", []
|
|||
|
|
if not isinstance(obj, dict):
|
|||
|
|
return "", []
|
|||
|
|
base = str(obj.get("b") or obj.get("base") or "")
|
|||
|
|
items = obj.get("v") or obj.get("videos") or []
|
|||
|
|
if isinstance(items, list):
|
|||
|
|
vids = [x for x in items if isinstance(x, dict)]
|
|||
|
|
else:
|
|||
|
|
vids = []
|
|||
|
|
return base, vids
|
|||
|
|
if s.startswith("["):
|
|||
|
|
try:
|
|||
|
|
obj = json.loads(s)
|
|||
|
|
except Exception:
|
|||
|
|
return "", []
|
|||
|
|
if not isinstance(obj, list):
|
|||
|
|
return "", []
|
|||
|
|
vids = [x for x in obj if isinstance(x, dict)]
|
|||
|
|
return "", vids
|
|||
|
|
return "", []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _dump_video_manifest(base_url: str, videos: List[dict], max_len: int = 1000) -> str:
|
|||
|
|
b = str(base_url or "")
|
|||
|
|
keep = list(videos or [])
|
|||
|
|
while True:
|
|||
|
|
payload = {"b": b, "v": keep}
|
|||
|
|
s = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|||
|
|
if len(s) <= max_len:
|
|||
|
|
return s
|
|||
|
|
if len(keep) <= 1:
|
|||
|
|
break
|
|||
|
|
keep = keep[-max(1, len(keep) // 2) :]
|
|||
|
|
payload = {"b": b, "v": keep}
|
|||
|
|
s = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|||
|
|
if len(s) <= max_len:
|
|||
|
|
return s
|
|||
|
|
overhead = len(json.dumps({"b": "", "v": keep}, ensure_ascii=False, separators=(",", ":")))
|
|||
|
|
allow_b = max(0, max_len - overhead)
|
|||
|
|
b2 = b[:allow_b]
|
|||
|
|
return json.dumps({"b": b2, "v": keep}, ensure_ascii=False, separators=(",", ":"))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_videos_dir_rel(dataset: AiDataset) -> str:
|
|||
|
|
if dataset.custom3:
|
|||
|
|
return dataset.custom3
|
|||
|
|
base_dir = os.path.dirname((dataset.original_file_path or "").rstrip("/\\"))
|
|||
|
|
return os.path.join(base_dir, "video").replace("\\", "/") if base_dir else ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_dataset_root(dataset_code: str) -> str:
|
|||
|
|
return str(dataset_code or "").strip().replace("\\", "/").strip("/")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_dataset_paths(dataset_code: str) -> dict:
|
|||
|
|
root = _build_dataset_root(dataset_code)
|
|||
|
|
return {
|
|||
|
|
"original_file_path": os.path.join(root, "file").replace("\\", "/"),
|
|||
|
|
"dataset_path": os.path.join(root, "image").replace("\\", "/"),
|
|||
|
|
"custom1": os.path.join(root, "image_mask").replace("\\", "/"),
|
|||
|
|
"custom3": os.path.join(root, "video").replace("\\", "/"),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _generate_next_dataset_code() -> str:
|
|||
|
|
# Lock the dataset table so concurrent creates cannot generate the same code.
|
|||
|
|
with connection.cursor() as cursor:
|
|||
|
|
cursor.execute("LOCK TABLE ai_dataset IN EXCLUSIVE MODE")
|
|||
|
|
cursor.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT COALESCE(MAX(CAST(dataset_code AS INTEGER)), 0)
|
|||
|
|
FROM ai_dataset
|
|||
|
|
WHERE dataset_code ~ '^[0-9]+$'
|
|||
|
|
"""
|
|||
|
|
)
|
|||
|
|
next_index = int((cursor.fetchone() or [0])[0] or 0) + 1
|
|||
|
|
return f"{next_index:06d}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _ensure_video_storage(dataset: AiDataset) -> str:
|
|||
|
|
videos_dir_rel = _get_videos_dir_rel(dataset)
|
|||
|
|
if not videos_dir_rel:
|
|||
|
|
return ""
|
|||
|
|
if dataset.custom3 != videos_dir_rel:
|
|||
|
|
dataset.custom3 = videos_dir_rel
|
|||
|
|
dataset.save(update_fields=["custom3"])
|
|||
|
|
if not (dataset.custom2 or "").strip():
|
|||
|
|
dataset.custom2 = _dump_video_manifest(_videos_base_rel(videos_dir_rel), [], max_len=1000)
|
|||
|
|
dataset.save(update_fields=["custom2"])
|
|||
|
|
return videos_dir_rel
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _extract_sample_index(saved_filename: str) -> int | None:
|
|||
|
|
stem = os.path.splitext(os.path.basename(str(saved_filename or "")))[0]
|
|||
|
|
tail = stem.rsplit("-", 1)[-1]
|
|||
|
|
if tail.isdigit():
|
|||
|
|
return int(tail)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _next_sample_index(dataset_id: str) -> int:
|
|||
|
|
max_saved_filename = AiDatasetSample.objects.filter(dataset_id=dataset_id).aggregate(
|
|||
|
|
max_saved_filename=Max("saved_filename")
|
|||
|
|
)["max_saved_filename"]
|
|||
|
|
current_index = _extract_sample_index(max_saved_filename)
|
|||
|
|
return (current_index or 0) + 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _generate_images_from_videos(dataset_id: str, frame_interval: int) -> None:
|
|||
|
|
close_old_connections()
|
|||
|
|
try:
|
|||
|
|
import cv2
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[video->images] cv2 not available: {e}")
|
|||
|
|
close_old_connections()
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
dataset = AiDataset.objects.filter(id=dataset_id).first()
|
|||
|
|
if not dataset:
|
|||
|
|
close_old_connections()
|
|||
|
|
return
|
|||
|
|
if dataset.custom2 and not str(dataset.custom2).strip().startswith(("[", "{")) and not dataset.custom3:
|
|||
|
|
dataset.custom3 = str(dataset.custom2).strip()
|
|||
|
|
dataset.custom2 = ""
|
|||
|
|
dataset.save(update_fields=["custom2", "custom3"])
|
|||
|
|
|
|||
|
|
videos_dir_rel = _get_videos_dir_rel(dataset)
|
|||
|
|
if not videos_dir_rel:
|
|||
|
|
print(f"[video->images] dataset_id={dataset_id} no videos dir")
|
|||
|
|
close_old_connections()
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
interval = int(frame_interval) if int(frame_interval) > 0 else 1
|
|||
|
|
base_url = _videos_base_rel(videos_dir_rel)
|
|||
|
|
_, raw_items = _load_video_manifest(dataset.custom2 or "")
|
|||
|
|
videos: List[dict] = []
|
|||
|
|
for it in raw_items:
|
|||
|
|
if not isinstance(it, dict):
|
|||
|
|
continue
|
|||
|
|
filename = it.get("f") or it.get("filename")
|
|||
|
|
if not filename and it.get("url"):
|
|||
|
|
filename = os.path.basename(str(it.get("url") or ""))
|
|||
|
|
if not filename and it.get("rel_path"):
|
|||
|
|
filename = os.path.basename(str(it.get("rel_path") or ""))
|
|||
|
|
filename = str(filename or "").strip()
|
|||
|
|
if not filename:
|
|||
|
|
continue
|
|||
|
|
g = it.get("g")
|
|||
|
|
if g is None:
|
|||
|
|
g = it.get("generated")
|
|||
|
|
videos.append(
|
|||
|
|
{
|
|||
|
|
"f": filename,
|
|||
|
|
"g": 1 if bool(g) else 0,
|
|||
|
|
"t": it.get("t") or it.get("generated_at"),
|
|||
|
|
"i": it.get("i") or it.get("frame_interval"),
|
|||
|
|
"n": it.get("n") or it.get("generated_images"),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
pending = [v for v in videos if int(v.get("g") or 0) == 0]
|
|||
|
|
if not pending:
|
|||
|
|
print(f"[video->images] dataset_id={dataset_id} no pending videos")
|
|||
|
|
close_old_connections()
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
total_written = 0
|
|||
|
|
start = time.time()
|
|||
|
|
|
|||
|
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|||
|
|
for v in pending:
|
|||
|
|
filename = str(v.get("f") or "").strip()
|
|||
|
|
if not filename:
|
|||
|
|
continue
|
|||
|
|
video_object_name = f"{str(videos_dir_rel).strip('/').replace('\\', '/')}/{filename}".strip("/")
|
|||
|
|
if not minio_storage.object_exists("dataset", video_object_name):
|
|||
|
|
print(f"[video->images] missing object: {video_object_name}")
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
video_bytes = minio_storage.download_bytes("dataset", video_object_name)
|
|||
|
|
result = extract_video_frames_to_dataset_result(
|
|||
|
|
dataset=dataset,
|
|||
|
|
video_bytes=video_bytes,
|
|||
|
|
original_filename=filename,
|
|||
|
|
interval_seconds=interval,
|
|||
|
|
source_marker_prefix=f"video:{dataset_id}:{filename}",
|
|||
|
|
skip_existing=True,
|
|||
|
|
)
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"[video->images] process failed: {video_object_name}, error={exc}")
|
|||
|
|
continue
|
|||
|
|
written_for_video = len(result.get("imported") or [])
|
|||
|
|
total_written += written_for_video
|
|||
|
|
v["g"] = 1
|
|||
|
|
v["t"] = now_str
|
|||
|
|
v["i"] = interval
|
|||
|
|
v["n"] = int(written_for_video)
|
|||
|
|
|
|||
|
|
total_count, annotated_count = refresh_dataset_sample_counters(dataset_id)
|
|||
|
|
dataset.dataset_count = total_count
|
|||
|
|
dataset.annotated_count = annotated_count
|
|||
|
|
dataset.custom2 = _dump_video_manifest(base_url, videos, max_len=1000)
|
|||
|
|
dataset.save(update_fields=["dataset_count", "annotated_count", "custom2"])
|
|||
|
|
print(f"[video->images] dataset_id={dataset_id} frames={total_written} elapsed={round(time.time()-start,3)}s")
|
|||
|
|
close_old_connections()
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["POST"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def generate_dataset_images(request):
|
|||
|
|
data = request.data
|
|||
|
|
dataset_id = data.get("id") or data.get("dataset_id")
|
|||
|
|
if not dataset_id:
|
|||
|
|
return Response({"error": "缺少数据集 id"}, status=status.HTTP_400_BAD_REQUEST)
|
|||
|
|
dataset = get_object_or_404(AiDataset, id=dataset_id)
|
|||
|
|
if dataset.dataset_type != "03":
|
|||
|
|
return Response({"error": "当前数据集不是视频类型"}, status=status.HTTP_400_BAD_REQUEST)
|
|||
|
|
_, videos = _load_video_manifest(dataset.custom2 or "")
|
|||
|
|
pending = [v for v in videos if not bool(v.get("g") or v.get("generated"))]
|
|||
|
|
if not pending:
|
|||
|
|
return Response({"message": "没有需要生成图片的视频", "dataset_id": dataset_id, "pending": 0})
|
|||
|
|
frame_interval = int(getattr(settings, "VIDEO_FRAME_INTERVAL", 30))
|
|||
|
|
t = threading.Thread(
|
|||
|
|
target=_generate_images_from_videos,
|
|||
|
|
kwargs={"dataset_id": str(dataset_id), "frame_interval": int(frame_interval)},
|
|||
|
|
daemon=True,
|
|||
|
|
)
|
|||
|
|
t.start()
|
|||
|
|
return Response(
|
|||
|
|
{
|
|||
|
|
"message": "生成图片任务已后台启动",
|
|||
|
|
"dataset_id": dataset_id,
|
|||
|
|
"frame_interval": frame_interval,
|
|||
|
|
"pending": len(pending),
|
|||
|
|
"thread_name": t.name,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["POST"])
|
|||
|
|
@parser_classes([MultiPartParser])
|
|||
|
|
@transaction.atomic
|
|||
|
|
def upload_dataset_file(request):
|
|||
|
|
data = request.data
|
|||
|
|
dataset = AiDataset.objects.get(id=data.get("id"))
|
|||
|
|
uploadfiles = request.FILES.getlist("uploadfiles[]")
|
|||
|
|
dataset_type = str(data.get("dataset_type") or dataset.dataset_type or "")
|
|||
|
|
if "uploadxml" not in data:
|
|||
|
|
imported_count = 0
|
|||
|
|
uploadfilenames = []
|
|||
|
|
interval_seconds = int(data.get("interval_seconds") or 1)
|
|||
|
|
max_frames = int(data.get("max_frames") or 200)
|
|||
|
|
for uploadfile in uploadfiles:
|
|||
|
|
filename = uploadfile.name
|
|||
|
|
uploadfilenames.append(filename)
|
|||
|
|
ext = os.path.splitext(filename)[1].lower()
|
|||
|
|
file_bytes = uploadfile.read()
|
|||
|
|
uploadfile.seek(0)
|
|||
|
|
if ext == ".zip":
|
|||
|
|
imported = import_zip_bytes_to_dataset(dataset, file_bytes)
|
|||
|
|
imported_count += len(imported)
|
|||
|
|
continue
|
|||
|
|
if dataset_type == "02" and ext in VIDEO_EXTENSIONS:
|
|||
|
|
imported = extract_video_frames_to_dataset(
|
|||
|
|
dataset,
|
|||
|
|
file_bytes,
|
|||
|
|
filename,
|
|||
|
|
interval_seconds=interval_seconds,
|
|||
|
|
max_frames=max_frames,
|
|||
|
|
)
|
|||
|
|
imported_count += len(imported)
|
|||
|
|
continue
|
|||
|
|
if dataset_type_accepts_extension(dataset_type, ext):
|
|||
|
|
save_bytes_to_dataset(
|
|||
|
|
dataset=dataset,
|
|||
|
|
original_filename=filename,
|
|||
|
|
data=file_bytes,
|
|||
|
|
content_type=uploadfile.content_type,
|
|||
|
|
)
|
|||
|
|
imported_count += 1
|
|||
|
|
|
|||
|
|
total_count, annotated_count = refresh_dataset_sample_counters(data.get("id"))
|
|||
|
|
dataset.dataset_count = total_count
|
|||
|
|
dataset.annotated_count = annotated_count
|
|||
|
|
existing_original_names = dataset.original_file_name
|
|||
|
|
joined_upload_names = ",".join(uploadfilenames)
|
|||
|
|
dataset.original_file_name = (
|
|||
|
|
f"{existing_original_names},{joined_upload_names}"
|
|||
|
|
if existing_original_names and joined_upload_names
|
|||
|
|
else (existing_original_names or joined_upload_names)
|
|||
|
|
)
|
|||
|
|
dataset.save(update_fields=["dataset_count", "annotated_count", "original_file_name"])
|
|||
|
|
return Response({"message": f"文件上传成功,共导入了{imported_count}条数据!"})
|
|||
|
|
|
|||
|
|
uploadfiles = request.FILES.getlist("uploadfiles[]")
|
|||
|
|
dataset_type = str(data.get("dataset_type") or dataset.dataset_type or "")
|
|||
|
|
uploadfilenames = []
|
|||
|
|
video_items: List[dict] = []
|
|||
|
|
videos_dir_rel = ""
|
|||
|
|
videos_base_url = ""
|
|||
|
|
if dataset_type == "03":
|
|||
|
|
if dataset.custom2 and not str(dataset.custom2).strip().startswith(("[", "{")) and not dataset.custom3:
|
|||
|
|
dataset.custom3 = str(dataset.custom2).strip()
|
|||
|
|
dataset.custom2 = ""
|
|||
|
|
dataset.save(update_fields=["custom2", "custom3"])
|
|||
|
|
videos_dir_rel = _ensure_video_storage(dataset)
|
|||
|
|
videos_base_url = _videos_base_rel(videos_dir_rel) if videos_dir_rel else ""
|
|||
|
|
base_existing, raw_items = _load_video_manifest(dataset.custom2 or "")
|
|||
|
|
if base_existing:
|
|||
|
|
videos_base_url = _strip_nginx_image_prefix(base_existing)
|
|||
|
|
for it in raw_items:
|
|||
|
|
if not isinstance(it, dict):
|
|||
|
|
continue
|
|||
|
|
filename = it.get("f") or it.get("filename")
|
|||
|
|
if not filename and it.get("url"):
|
|||
|
|
filename = os.path.basename(str(it.get("url") or ""))
|
|||
|
|
if not filename and it.get("rel_path"):
|
|||
|
|
filename = os.path.basename(str(it.get("rel_path") or ""))
|
|||
|
|
filename = str(filename or "").strip()
|
|||
|
|
if not filename:
|
|||
|
|
continue
|
|||
|
|
g = it.get("g")
|
|||
|
|
if g is None:
|
|||
|
|
g = it.get("generated")
|
|||
|
|
video_items.append({"f": filename, "g": 1 if bool(g) else 0, "t": it.get("t") or it.get("generated_at")})
|
|||
|
|
|
|||
|
|
def save_single_xml_file(uploadfile):
|
|||
|
|
file_content = uploadfile.read()
|
|||
|
|
uploadfile.seek(0)
|
|||
|
|
try:
|
|||
|
|
image_name, result_list = _parse_voc_xml_annotations(file_content, dataset.dataset_labels)
|
|||
|
|
except Exception as exc:
|
|||
|
|
return {
|
|||
|
|
"ok": False,
|
|||
|
|
"name": uploadfile.name,
|
|||
|
|
"reason": f"XML解析失败: {exc}",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
dataset_sample = _find_dataset_sample_for_xml(data.get("id"), image_name, uploadfile.name)
|
|||
|
|
if dataset_sample is None:
|
|||
|
|
return {
|
|||
|
|
"ok": False,
|
|||
|
|
"name": uploadfile.name,
|
|||
|
|
"reason": "未找到对应样本",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
annotation_json = json.dumps(result_list, ensure_ascii=False)
|
|||
|
|
annotation_fields = split_dataset_sample_annotation_content(annotation_json)
|
|||
|
|
upload_sidecar_bytes(
|
|||
|
|
dataset_sample.saved_path,
|
|||
|
|
dataset_sample.saved_filename,
|
|||
|
|
".xml",
|
|||
|
|
file_content,
|
|||
|
|
"application/xml",
|
|||
|
|
)
|
|||
|
|
AiDatasetSample.objects.filter(id=dataset_sample.id).update(
|
|||
|
|
rectangle=annotation_fields["rectangle"],
|
|||
|
|
polygon=annotation_fields["polygon"],
|
|||
|
|
status="02",
|
|||
|
|
)
|
|||
|
|
return {
|
|||
|
|
"ok": True,
|
|||
|
|
"name": uploadfile.name,
|
|||
|
|
"sample_id": dataset_sample.id,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def process_compressed_file(sourcename, source):
|
|||
|
|
if not source:
|
|||
|
|
return
|
|||
|
|
source_name = os.path.basename(sourcename)
|
|||
|
|
source_ext = os.path.splitext(source_name)[1].lower()
|
|||
|
|
if dataset_type == "03" and source_ext in VIDEO_EXTENSIONS and videos_dir_rel:
|
|||
|
|
object_name = f"{str(videos_dir_rel).strip('/').replace('\\', '/')}/{source_name}".strip("/")
|
|||
|
|
content_type = "video/mp4" if source_ext == ".mp4" else None
|
|||
|
|
minio_storage.upload_bytes("dataset", object_name, source.read(), content_type=content_type)
|
|||
|
|
video_items.append({"f": source_name, "g": 0})
|
|||
|
|
return
|
|||
|
|
save_bytes_to_dataset(
|
|||
|
|
dataset=dataset,
|
|||
|
|
original_filename=source_name,
|
|||
|
|
data=source.read(),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
allowed_file_types = ["jpg", "png", "bmp", "mp3", "mp4", "soud", "txt", "csv"]
|
|||
|
|
file_count = 0
|
|||
|
|
if "uploadxml" in data:
|
|||
|
|
failures = []
|
|||
|
|
updated_ids = []
|
|||
|
|
for uploadfile in uploadfiles:
|
|||
|
|
resultxml = save_single_xml_file(uploadfile)
|
|||
|
|
if resultxml and resultxml.get("ok"):
|
|||
|
|
file_count += 1
|
|||
|
|
updated_ids.append(resultxml.get("sample_id"))
|
|||
|
|
uploadfilenames.append(uploadfile.name)
|
|||
|
|
else:
|
|||
|
|
failures.append(resultxml or {"name": uploadfile.name, "reason": "处理失败"})
|
|||
|
|
response_message = f"xml文件上传完成,成功更新{file_count}条样本"
|
|||
|
|
if failures:
|
|||
|
|
response_message += f",失败{len(failures)}条"
|
|||
|
|
return Response(
|
|||
|
|
{
|
|||
|
|
"message": response_message,
|
|||
|
|
"updated_count": file_count,
|
|||
|
|
"updated_sample_ids": updated_ids,
|
|||
|
|
"failed_count": len(failures),
|
|||
|
|
"failures": failures,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
for uploadfile in uploadfiles:
|
|||
|
|
uploadfilenames.append(uploadfile.name)
|
|||
|
|
file_bytes = uploadfile.read()
|
|||
|
|
uploadfile.seek(0)
|
|||
|
|
file_extension = os.path.splitext(uploadfile.name)[1].lower()
|
|||
|
|
|
|||
|
|
if dataset_type != "01":
|
|||
|
|
if file_extension == ".zip":
|
|||
|
|
with zipfile.ZipFile(io.BytesIO(file_bytes), "r") as zip_file:
|
|||
|
|
for file_info in zip_file.infolist():
|
|||
|
|
with zip_file.open(file_info.filename, "r", force_zip64=True) as source:
|
|||
|
|
decoded_filename = source.name.encode("cp437").decode("gbk")
|
|||
|
|
file_extension = decoded_filename.split(".")[-1].lower()
|
|||
|
|
if file_extension in allowed_file_types:
|
|||
|
|
file_count = file_count + 1
|
|||
|
|
process_compressed_file(decoded_filename, source)
|
|||
|
|
elif file_extension == ".tar":
|
|||
|
|
with tarfile.open(fileobj=io.BytesIO(file_bytes), mode="r:*") as tar_file:
|
|||
|
|
for member in tar_file.getmembers():
|
|||
|
|
extracted = tar_file.extractfile(member)
|
|||
|
|
if extracted is None:
|
|||
|
|
continue
|
|||
|
|
with extracted as source:
|
|||
|
|
file_extension = member.name.split(".")[-1].lower()
|
|||
|
|
if file_extension in allowed_file_types:
|
|||
|
|
file_count = file_count + 1
|
|||
|
|
process_compressed_file(member.name, source)
|
|||
|
|
else:
|
|||
|
|
if file_extension.lstrip(".") in allowed_file_types:
|
|||
|
|
file_count = file_count + 1
|
|||
|
|
if dataset_type == "03" and file_extension in VIDEO_EXTENSIONS and videos_dir_rel:
|
|||
|
|
object_name = f"{str(videos_dir_rel).strip('/').replace('\\', '/')}/{uploadfile.name}".strip("/")
|
|||
|
|
minio_storage.upload_bytes(
|
|||
|
|
"dataset",
|
|||
|
|
object_name,
|
|||
|
|
file_bytes,
|
|||
|
|
content_type=uploadfile.content_type or "application/octet-stream",
|
|||
|
|
)
|
|||
|
|
video_items.append({"f": uploadfile.name, "g": 0})
|
|||
|
|
else:
|
|||
|
|
save_bytes_to_dataset(
|
|||
|
|
dataset=dataset,
|
|||
|
|
original_filename=uploadfile.name,
|
|||
|
|
data=file_bytes,
|
|||
|
|
content_type=uploadfile.content_type,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
total_count, annotated_count = refresh_dataset_sample_counters(data.get("id"))
|
|||
|
|
dataset.dataset_count = total_count
|
|||
|
|
dataset.annotated_count = annotated_count
|
|||
|
|
existing_original_names = dataset.original_file_name
|
|||
|
|
joined_upload_names = ",".join(uploadfilenames)
|
|||
|
|
dataset.original_file_name = (
|
|||
|
|
f"{existing_original_names},{joined_upload_names}" if existing_original_names else joined_upload_names
|
|||
|
|
)
|
|||
|
|
if dataset_type == "03":
|
|||
|
|
dataset.custom2 = _dump_video_manifest(_strip_nginx_image_prefix(videos_base_url), video_items, max_len=1000)
|
|||
|
|
dataset.save()
|
|||
|
|
return Response({"message": f"文件上传成功,共上传了{file_count}条数据!"})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["POST"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
@transaction.atomic
|
|||
|
|
def create_dataset(request):
|
|||
|
|
data = request.data.copy()
|
|||
|
|
data["id"] = str(uuid.uuid4())
|
|||
|
|
data["create_time"] = datetime.now()
|
|||
|
|
data["status"] = "01"
|
|||
|
|
data["creator"] = request.user.username or "creator"
|
|||
|
|
new_dataset_code = _generate_next_dataset_code()
|
|||
|
|
data["dataset_code"] = new_dataset_code
|
|||
|
|
dataset_paths = _build_dataset_paths(new_dataset_code)
|
|||
|
|
data["original_file_path"] = data.get("original_file_path") or dataset_paths["original_file_path"]
|
|||
|
|
data["dataset_path"] = data.get("dataset_path") or dataset_paths["dataset_path"]
|
|||
|
|
data["custom1"] = data.get("custom1") or dataset_paths["custom1"]
|
|||
|
|
if data.get("dataset_type") == "03":
|
|||
|
|
videos_rel = data.get("custom3") or dataset_paths["custom3"]
|
|||
|
|
data["custom3"] = videos_rel
|
|||
|
|
data["custom2"] = _dump_video_manifest(_videos_base_rel(videos_rel), [], max_len=1000)
|
|||
|
|
serializer = AiDatasetSerializer(data=data, context={"request": request})
|
|||
|
|
|
|||
|
|
if serializer.is_valid():
|
|||
|
|
serializer.save()
|
|||
|
|
return Response({"message": "数据集创建成功"})
|
|||
|
|
return Response({"error": "无效的数据", "details": serializer.errors}, status=status.HTTP_400_BAD_REQUEST)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["GET"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def read_dataset(request):
|
|||
|
|
dataset_id = request.GET.get("id", None)
|
|||
|
|
dataset = get_object_or_404(AiDataset, id=dataset_id)
|
|||
|
|
serializer = AiDatasetSerializer(dataset)
|
|||
|
|
|
|||
|
|
STATUSES = ["00", "01", "02"]
|
|||
|
|
sample_stats = {}
|
|||
|
|
for status_code in STATUSES:
|
|||
|
|
sample_stats[f"count_{status_code}"] = Sum(
|
|||
|
|
Case(When(status=status_code, then=1), default=0, output_field=IntegerField())
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
sample_stats["total"] = Count("id")
|
|||
|
|
|
|||
|
|
dataset_status_counts = (
|
|||
|
|
AiDatasetSample.objects.values("dataset_id").annotate(**sample_stats).filter(dataset_id=dataset_id)
|
|||
|
|
)
|
|||
|
|
serializer_data = serializer.data
|
|||
|
|
serializer_data["dataset_status_counts"] = dataset_status_counts
|
|||
|
|
return Response(serializer_data)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["PUT"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def update_dataset(request):
|
|||
|
|
data = request.data.copy()
|
|||
|
|
dataset = get_object_or_404(AiDataset, id=data.get("id"))
|
|||
|
|
data.pop("dataset_code", None)
|
|||
|
|
serializer = AiDatasetSerializer(instance=dataset, data=data, partial=True)
|
|||
|
|
|
|||
|
|
if serializer.is_valid():
|
|||
|
|
serializer.save()
|
|||
|
|
return Response({"message": "数据集更新成功"})
|
|||
|
|
return Response({"error": "无效的数据", "details": serializer.errors}, status=status.HTTP_400_BAD_REQUEST)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["DELETE"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
@transaction.atomic
|
|||
|
|
def delete_dataset(request):
|
|||
|
|
dataset_id = request.GET.get("id", None)
|
|||
|
|
if not dataset_id:
|
|||
|
|
return Response({"error": "数据集 ID 不能为空"}, status=status.HTTP_400_BAD_REQUEST)
|
|||
|
|
|
|||
|
|
task_count = AiAnnotateTask.objects.filter(dataset_id=dataset_id).count()
|
|||
|
|
if task_count > 0:
|
|||
|
|
return Response(
|
|||
|
|
{"warn": "该数据集已经创建了样本标注任务,请先删除样本标注任务!"},
|
|||
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
train_count = AiAlgorithmTrainRecords.objects.filter(link_datasets__contains=dataset_id).count()
|
|||
|
|
if train_count > 0:
|
|||
|
|
return Response(
|
|||
|
|
{"warn": "该数据集已经创建了算法训练任务,请先删除算法训练任务!"},
|
|||
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
dataset = get_object_or_404(AiDataset, id=dataset_id)
|
|||
|
|
AiDatasetSample.objects.filter(dataset_id=dataset_id).delete()
|
|||
|
|
try:
|
|||
|
|
objects = minio_storage.list_objects("dataset", prefix=dataset.dataset_path)
|
|||
|
|
object_names = [obj["name"] for obj in objects if obj.get("name")]
|
|||
|
|
if object_names:
|
|||
|
|
minio_storage.delete_objects("dataset", object_names)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
dataset.delete()
|
|||
|
|
return Response({"message": "数据集删除成功"}, status=status.HTTP_200_OK)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
return Response(
|
|||
|
|
{"error": f"删除数据集失败: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["POST"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def delete_dataset_updatefile(request):
|
|||
|
|
data = request.data
|
|||
|
|
dataset = get_object_or_404(AiDataset, id=data.get("id"))
|
|||
|
|
serializer = AiDatasetSerializer(instance=dataset, data=data, partial=True)
|
|||
|
|
if serializer.is_valid():
|
|||
|
|
serializer.save()
|
|||
|
|
return Response({"message": "上传数据文件删除成功"})
|
|||
|
|
return Response({"error": "无效的数据", "details": serializer.errors}, status=status.HTTP_400_BAD_REQUEST)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["GET"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def search_datasets(request):
|
|||
|
|
dataset_code = request.GET.get("dataset_code", None)
|
|||
|
|
dataset_name = request.GET.get("dataset_name", None)
|
|||
|
|
features = request.GET.get("features", None)
|
|||
|
|
dataset_type = request.GET.get("dataset_type", None)
|
|||
|
|
creator = request.GET.get("creator", None)
|
|||
|
|
page_size = request.GET.get("page_size", None)
|
|||
|
|
filter_kwargs = {}
|
|||
|
|
if dataset_code:
|
|||
|
|
filter_kwargs["dataset_code__contains"] = dataset_code
|
|||
|
|
if dataset_name:
|
|||
|
|
filter_kwargs["dataset_name__contains"] = dataset_name
|
|||
|
|
if dataset_type:
|
|||
|
|
filter_kwargs["dataset_type"] = dataset_type
|
|||
|
|
if features:
|
|||
|
|
filter_kwargs["features__contains"] = features
|
|||
|
|
if creator:
|
|||
|
|
filter_kwargs["creator"] = creator
|
|||
|
|
|
|||
|
|
datasets = AiDataset.objects.filter(**filter_kwargs).order_by("dataset_code")
|
|||
|
|
paginator = PageNumberPagination()
|
|||
|
|
paginator.page_size = page_size or 10
|
|||
|
|
result_page = paginator.paginate_queryset(datasets, request)
|
|||
|
|
serializer = AiDatasetSerializer(result_page, many=True)
|
|||
|
|
return paginator.get_paginated_response(serializer.data)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["GET"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def get_dataset_list(request):
|
|||
|
|
datasets = AiDataset.objects.all().order_by("dataset_code")
|
|||
|
|
serializer = AiDatasetSerializer(datasets, many=True)
|
|||
|
|
return Response(serializer.data)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["POST"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def import_dataset_from_temp_file(request):
|
|||
|
|
data = request.data
|
|||
|
|
dataset_id = data.get("dataset_id")
|
|||
|
|
temp_file_ids = _normalize_temp_file_ids(data)
|
|||
|
|
if not dataset_id or not temp_file_ids:
|
|||
|
|
return Response({"error": "缺少 dataset_id 或 temp_file_id(s)"}, status=status.HTTP_400_BAD_REQUEST)
|
|||
|
|
|
|||
|
|
dataset = get_object_or_404(AiDataset, id=dataset_id)
|
|||
|
|
interval_seconds = int(data.get("interval_seconds") or 1)
|
|||
|
|
max_frames = int(data.get("max_frames") or 200)
|
|||
|
|
|
|||
|
|
if len(temp_file_ids) == 1:
|
|||
|
|
temp_file = get_object_or_404(AiTempFile, id=temp_file_ids[0])
|
|||
|
|
status_code, payload = _import_single_temp_file_to_dataset(dataset, temp_file, interval_seconds, max_frames)
|
|||
|
|
return Response(payload, status=status_code)
|
|||
|
|
|
|||
|
|
summary_results = []
|
|||
|
|
total_imported_count = 0
|
|||
|
|
total_duplicate_count = 0
|
|||
|
|
total_failed_count = 0
|
|||
|
|
success_temp_file_count = 0
|
|||
|
|
duplicate_temp_file_count = 0
|
|||
|
|
failed_temp_file_count = 0
|
|||
|
|
|
|||
|
|
temp_files = {str(item.id): item for item in AiTempFile.objects.filter(id__in=temp_file_ids)}
|
|||
|
|
for temp_file_id in temp_file_ids:
|
|||
|
|
temp_file = temp_files.get(str(temp_file_id))
|
|||
|
|
if not temp_file:
|
|||
|
|
payload = {"error": f"临时文件不存在: {temp_file_id}"}
|
|||
|
|
status_code = status.HTTP_404_NOT_FOUND
|
|||
|
|
else:
|
|||
|
|
try:
|
|||
|
|
status_code, payload = _import_single_temp_file_to_dataset(dataset, temp_file, interval_seconds, max_frames)
|
|||
|
|
except Exception as exc:
|
|||
|
|
logger.exception("batch temp file import failed: dataset=%s temp_file=%s", dataset.id, temp_file_id)
|
|||
|
|
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
|||
|
|
payload = {"error": f"导入失败:{exc}"}
|
|||
|
|
|
|||
|
|
imported_count = int(payload.get("imported_count") or 0)
|
|||
|
|
duplicate_count = int(payload.get("duplicate_count") or 0)
|
|||
|
|
failed_count = int(payload.get("failed_count") or 0)
|
|||
|
|
result_status = "failed"
|
|||
|
|
if status_code == status.HTTP_200_OK and failed_count > 0:
|
|||
|
|
result_status = "partial"
|
|||
|
|
elif status_code == status.HTTP_200_OK:
|
|||
|
|
result_status = "success"
|
|||
|
|
elif status_code == status.HTTP_409_CONFLICT:
|
|||
|
|
result_status = "duplicate"
|
|||
|
|
|
|||
|
|
if result_status in ("success", "partial"):
|
|||
|
|
success_temp_file_count += 1
|
|||
|
|
elif result_status == "duplicate":
|
|||
|
|
duplicate_temp_file_count += 1
|
|||
|
|
else:
|
|||
|
|
failed_temp_file_count += 1
|
|||
|
|
|
|||
|
|
total_imported_count += imported_count
|
|||
|
|
total_duplicate_count += duplicate_count
|
|||
|
|
total_failed_count += failed_count
|
|||
|
|
summary_results.append(
|
|||
|
|
{
|
|||
|
|
"temp_file_id": temp_file_id,
|
|||
|
|
"filename": getattr(temp_file, "original_name", "") if temp_file else "",
|
|||
|
|
"status": result_status,
|
|||
|
|
**payload,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if success_temp_file_count > 0:
|
|||
|
|
message = (
|
|||
|
|
f"同步导入完成,成功处理{success_temp_file_count}个临时文件,"
|
|||
|
|
f"新增样本{total_imported_count}条,跳过重复{total_duplicate_count}条,失败{total_failed_count}条"
|
|||
|
|
)
|
|||
|
|
response_status = status.HTTP_200_OK
|
|||
|
|
elif duplicate_temp_file_count > 0 and failed_temp_file_count == 0:
|
|||
|
|
message = "所选临时文件均已导入当前数据集,本次未新增样本"
|
|||
|
|
response_status = status.HTTP_200_OK
|
|||
|
|
else:
|
|||
|
|
message = "同步导入失败,请检查临时文件类型与数据集类型是否匹配"
|
|||
|
|
response_status = status.HTTP_400_BAD_REQUEST
|
|||
|
|
|
|||
|
|
return Response(
|
|||
|
|
{
|
|||
|
|
"message": message,
|
|||
|
|
"dataset_id": dataset.id,
|
|||
|
|
"temp_file_count": len(temp_file_ids),
|
|||
|
|
"success_temp_file_count": success_temp_file_count,
|
|||
|
|
"duplicate_temp_file_count": duplicate_temp_file_count,
|
|||
|
|
"failed_temp_file_count": failed_temp_file_count,
|
|||
|
|
"imported_count": total_imported_count,
|
|||
|
|
"duplicate_count": total_duplicate_count,
|
|||
|
|
"failed_count": total_failed_count,
|
|||
|
|
"results": summary_results,
|
|||
|
|
},
|
|||
|
|
status=response_status,
|
|||
|
|
)
|