feat: add ImageFind application and release pipelines
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify live audio indexing with a run-owned video containing clear speech."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def call(client: httpx.Client, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
||||
response = client.request(method, path, **kwargs)
|
||||
if response.is_error:
|
||||
raise RuntimeError(f"{method} {path} -> {response.status_code}: {response.text[:1000]}")
|
||||
return response
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
while block := handle.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def find_video(client: httpx.Client, source_id: str, filename: str) -> dict[str, Any] | None:
|
||||
videos = call(client, "GET", "/api/v1/videos?limit=500").json()
|
||||
return next(
|
||||
(
|
||||
item
|
||||
for item in videos
|
||||
if item.get("source_id") == source_id
|
||||
and item.get("source_key", "").rsplit("/", 1)[-1] == filename
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def upload_fixture(
|
||||
client: httpx.Client,
|
||||
fixture: Path,
|
||||
state: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], list[float]]:
|
||||
filename = f"{state['run_id']}-{fixture.name}"
|
||||
uploads = call(client, "GET", "/api/v1/uploads?limit=500").json()
|
||||
upload = next((item for item in uploads if item.get("filename") == filename), None)
|
||||
if upload is None or upload.get("status") in {"failed", "cancelled"}:
|
||||
upload = call(
|
||||
client,
|
||||
"POST",
|
||||
"/api/v1/uploads",
|
||||
json={
|
||||
"source_id": state["source_id"],
|
||||
"relative_path": "ingest",
|
||||
"filename": filename,
|
||||
"title": "音频 GPU 验收",
|
||||
"collection_id": state["collection_id"],
|
||||
"tag_ids": [state["tag_id"]],
|
||||
"size_bytes": fixture.stat().st_size,
|
||||
"sha256": sha256(fixture),
|
||||
"conflict": "skip",
|
||||
},
|
||||
).json()
|
||||
|
||||
latencies: list[float] = []
|
||||
if upload.get("status") != "completed":
|
||||
hidden = find_video(client, state["source_id"], filename)
|
||||
if hidden is not None:
|
||||
raise AssertionError("unfinished speech upload leaked into /videos")
|
||||
chunk_size = int(upload["chunk_size"])
|
||||
received = {int(index) for index in upload.get("received_chunks", upload.get("received", []))}
|
||||
with fixture.open("rb") as handle:
|
||||
for index in range(int(upload["total_chunks"])):
|
||||
data = handle.read(chunk_size)
|
||||
if index in received:
|
||||
continue
|
||||
started = time.perf_counter()
|
||||
call(
|
||||
client,
|
||||
"PUT",
|
||||
f"/api/v1/uploads/{upload['id']}/chunks/{index}",
|
||||
content=data,
|
||||
headers={"X-Chunk-SHA256": hashlib.sha256(data).hexdigest()},
|
||||
)
|
||||
latencies.append(round((time.perf_counter() - started) * 1000, 1))
|
||||
upload_id = upload["id"]
|
||||
upload = call(client, "POST", f"/api/v1/uploads/{upload_id}/complete").json()
|
||||
else:
|
||||
upload_id = upload["id"]
|
||||
|
||||
deadline = time.monotonic() + 240
|
||||
while upload.get("status") not in {"completed", "failed", "cancelled"}:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(f"speech upload did not finish: {upload.get('status')}")
|
||||
time.sleep(1)
|
||||
uploads = call(client, "GET", "/api/v1/uploads?limit=500").json()
|
||||
upload = next(item for item in uploads if item["id"] == upload_id)
|
||||
if upload["status"] != "completed":
|
||||
raise AssertionError(f"speech upload failed: {upload.get('message')}")
|
||||
return upload, latencies
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", required=True)
|
||||
auth = parser.add_mutually_exclusive_group(required=True)
|
||||
auth.add_argument("--token-file", type=Path)
|
||||
auth.add_argument("--password")
|
||||
parser.add_argument("--run-dir", type=Path, required=True)
|
||||
parser.add_argument("--fixture", default="e2e-speech.mp4")
|
||||
parser.add_argument(
|
||||
"--query",
|
||||
action="append",
|
||||
dest="queries",
|
||||
help="Required audio-search keyword; repeat to verify multiple words (default: Americans)",
|
||||
)
|
||||
parser.add_argument("--timeout", type=int, default=1200)
|
||||
args = parser.parse_args()
|
||||
|
||||
state_path = args.run_dir / "state.json"
|
||||
state = json.loads(state_path.read_text())
|
||||
fixture = args.run_dir / args.fixture
|
||||
if not fixture.is_file():
|
||||
raise FileNotFoundError(fixture)
|
||||
headers = {}
|
||||
if args.token_file is not None:
|
||||
headers["Authorization"] = f"Bearer {args.token_file.read_text().strip()}"
|
||||
client = httpx.Client(
|
||||
base_url=args.base_url.rstrip("/"),
|
||||
headers=headers,
|
||||
timeout=httpx.Timeout(60, connect=10),
|
||||
)
|
||||
if args.password is not None:
|
||||
login = call(
|
||||
client,
|
||||
"POST",
|
||||
"/api/v1/auth/login",
|
||||
json={"password": args.password, "remember_device": False},
|
||||
).json()
|
||||
csrf = str(login.get("csrf_token") or "")
|
||||
if not csrf:
|
||||
raise AssertionError("login response omitted CSRF token")
|
||||
client.headers["X-CSRF-Token"] = csrf
|
||||
|
||||
def logout() -> None:
|
||||
try:
|
||||
client.post("/api/v1/auth/logout")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
atexit.register(logout)
|
||||
|
||||
# The repository's default live fixture contains the JFK inauguration line
|
||||
# "And so, my fellow Americans...". Keep the default assertion tied to
|
||||
# the actual fixture; callers using another recording can repeat --query.
|
||||
required_queries = [value.strip() for value in (args.queries or ["Americans"]) if value.strip()]
|
||||
if not required_queries:
|
||||
raise ValueError("at least one non-empty --query is required")
|
||||
|
||||
initial_models = call(client, "GET", "/api/v1/models").json()
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"audio_model": (initial_models.get("manifest") or {})
|
||||
.get("components", {})
|
||||
.get("audio"),
|
||||
"audio_health": (initial_models.get("component_health") or {}).get("audio"),
|
||||
"audio_accelerator": (initial_models.get("accelerator") or {})
|
||||
.get("components", {})
|
||||
.get("audio"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
upload, chunk_latencies = upload_fixture(client, fixture, state)
|
||||
filename = upload["filename"]
|
||||
deadline = time.monotonic() + args.timeout
|
||||
observed_devices: list[str] = []
|
||||
latest_message = "等待视频入库"
|
||||
positives: dict[str, dict[str, Any]] = {}
|
||||
video: dict[str, Any] | None = None
|
||||
last_report_at = 0.0
|
||||
while time.monotonic() < deadline:
|
||||
video = find_video(client, state["source_id"], filename)
|
||||
models = call(client, "GET", "/api/v1/models").json()
|
||||
audio = models.get("accelerator", {}).get("components", {}).get("audio", {})
|
||||
device = audio.get("actual_device") or audio.get("device")
|
||||
if device and device not in observed_devices:
|
||||
observed_devices.append(device)
|
||||
if video is not None:
|
||||
for query in required_queries:
|
||||
if query in positives:
|
||||
continue
|
||||
result = call(
|
||||
client,
|
||||
"POST",
|
||||
"/api/v1/search",
|
||||
json={"text": query, "recognition_types": ["audio"]},
|
||||
).json()
|
||||
positive = next(
|
||||
(item for item in result.get("items", []) if item.get("video_id") == video["id"]),
|
||||
None,
|
||||
)
|
||||
if positive is not None:
|
||||
positives[query] = positive
|
||||
if len(positives) == len(required_queries):
|
||||
break
|
||||
jobs = call(client, "GET", "/api/v1/jobs?page=1&page_size=10").json()["items"]
|
||||
audio_jobs = [item for item in jobs if item.get("kind") == "transcribe_audio"]
|
||||
if audio_jobs:
|
||||
latest_message = audio_jobs[0].get("message") or audio_jobs[0].get("status", "")
|
||||
if audio_jobs[0].get("status") == "failed":
|
||||
raise AssertionError(f"audio job failed: {audio_jobs[0].get('error')}")
|
||||
now = time.monotonic()
|
||||
if now - last_report_at >= 10:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"waiting": latest_message,
|
||||
"video_found": video is not None,
|
||||
"observed_devices": observed_devices,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
last_report_at = now
|
||||
time.sleep(2)
|
||||
if video is None:
|
||||
raise AssertionError("speech video never appeared in /videos")
|
||||
missing_queries = [query for query in required_queries if query not in positives]
|
||||
if missing_queries:
|
||||
raise TimeoutError(
|
||||
"audio search never matched required keywords "
|
||||
f"{missing_queries}; matched={sorted(positives)}; latest={latest_message}"
|
||||
)
|
||||
for query, positive in positives.items():
|
||||
if positive.get("segment_start_ms") is None or positive.get("segment_end_ms") is None:
|
||||
raise AssertionError(f"audio match omitted time segment for {query!r}: {positive}")
|
||||
match_details = positive.get("match_details", [])
|
||||
audio_text = " ".join(
|
||||
str(detail.get("text") or "")
|
||||
for detail in match_details
|
||||
if detail.get("type") == "audio"
|
||||
).strip()
|
||||
if not audio_text:
|
||||
raise AssertionError(f"audio match omitted recognized text for {query!r}: {positive}")
|
||||
if len([character for character in audio_text if character.isalnum()]) < 8:
|
||||
raise AssertionError(
|
||||
f"audio transcript is implausibly short for {query!r}: {audio_text!r}"
|
||||
)
|
||||
negative = call(
|
||||
client,
|
||||
"POST",
|
||||
"/api/v1/search",
|
||||
json={"text": "pineapple submarine 9834721", "recognition_types": ["audio"]},
|
||||
).json()
|
||||
if any(item.get("video_id") == video["id"] for item in negative.get("items", [])):
|
||||
raise AssertionError("non-matching audio search unexpectedly returned the speech video")
|
||||
|
||||
jobs = call(client, "GET", "/api/v1/jobs?page=1&page_size=10").json()["items"]
|
||||
latest_audio = next((item for item in jobs if item.get("kind") == "transcribe_audio"), None)
|
||||
report = {
|
||||
"upload_id": upload["id"],
|
||||
"video_id": video["id"],
|
||||
"chunk_latency_ms": chunk_latencies,
|
||||
"observed_audio_devices": observed_devices,
|
||||
"audio_job": latest_audio,
|
||||
"matches": {
|
||||
query: {
|
||||
"segment_start_ms": positive.get("segment_start_ms"),
|
||||
"segment_end_ms": positive.get("segment_end_ms"),
|
||||
"details": positive.get("match_details", []),
|
||||
}
|
||||
for query, positive in positives.items()
|
||||
},
|
||||
"negative_matches": len(negative.get("items", [])),
|
||||
}
|
||||
if not any(str(device).upper().startswith("GPU") for device in observed_devices):
|
||||
raise AssertionError(f"GPU was not observed during speech indexing: {observed_devices}")
|
||||
state[f"speech_upload_id_{fixture.stem}"] = upload["id"]
|
||||
state[f"speech_video_id_{fixture.stem}"] = video["id"]
|
||||
state_path.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n")
|
||||
(args.run_dir / "speech-report.json").write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
||||
)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user