完善韩泰 EOT 标注编辑器并更新标注规范与项目资料
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""Convert Hantai XLSX rows to an editable, approximate CSV draft.
|
||||
|
||||
Requires ffmpeg and numpy. Never writes the source workbook or 工作数据.
|
||||
"""
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import uuid
|
||||
import zipfile
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SOURCE = ROOT / "data/韩泰机器人抢话/韩泰机器人抢话测试数据集-已校对.xlsx"
|
||||
OUTPUT = ROOT / "labels/EOT标注/韩泰初稿"
|
||||
NS = {"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
||||
|
||||
|
||||
def source_rows():
|
||||
with zipfile.ZipFile(SOURCE) as archive:
|
||||
strings = []
|
||||
if "xl/sharedStrings.xml" in archive.namelist():
|
||||
strings = ["".join(item.itertext()) for item in ET.fromstring(archive.read("xl/sharedStrings.xml"))]
|
||||
rows = []
|
||||
for row in ET.fromstring(archive.read("xl/worksheets/sheet1.xml")).findall(".//s:row", NS):
|
||||
values = {}
|
||||
for cell in row.findall("s:c", NS):
|
||||
value = cell.find("s:v", NS)
|
||||
text = value.text if value is not None else ""
|
||||
if cell.get("t") == "s":
|
||||
text = strings[int(text)]
|
||||
elif cell.get("t") == "inlineStr":
|
||||
text = "".join(cell.find("s:is", NS).itertext())
|
||||
values[cell.get("r").rstrip("0123456789")] = text or ""
|
||||
try:
|
||||
uuid.UUID(values.get("A", ""))
|
||||
except ValueError:
|
||||
continue
|
||||
rows.append({"row": int(row.get("r")), **values})
|
||||
return rows
|
||||
|
||||
|
||||
def speech_spans(audio):
|
||||
raw = subprocess.check_output(["ffmpeg", "-v", "error", "-i", str(audio), "-f", "f32le", "-ac", "2", "-ar", "8000", "pipe:1"])
|
||||
samples = np.frombuffer(raw, dtype=np.float32).reshape(-1, 2)
|
||||
frames = len(samples) // 160
|
||||
rms = np.sqrt((samples[:frames * 160].reshape(frames, 160, 2) ** 2).mean(axis=1))
|
||||
channels = []
|
||||
for channel in range(2):
|
||||
threshold = max(.004, float(np.quantile(rms[:, channel], .95)) * .16)
|
||||
runs = []
|
||||
for index in np.flatnonzero(rms[:, channel] > threshold):
|
||||
if runs and index - runs[-1][1] < 35:
|
||||
runs[-1][1] = int(index)
|
||||
else:
|
||||
runs.append([int(index), int(index)])
|
||||
channels.append([(a * 20, (b + 1) * 20) for a, b in runs if b - a >= 12])
|
||||
return channels, round(len(samples) / 8)
|
||||
|
||||
|
||||
def match_prompts(groups, spans):
|
||||
# Greeting is absent from the source. Match remaining prompts in order,
|
||||
# allowing extra robot speech (e.g. a retry) to be skipped.
|
||||
candidates = spans[1:]
|
||||
cache = {}
|
||||
|
||||
def solve(i, j):
|
||||
if i == len(groups):
|
||||
return .35 * (len(candidates) - j), []
|
||||
if len(candidates) - j < len(groups) - i:
|
||||
return float("inf"), []
|
||||
if (i, j) in cache:
|
||||
return cache[i, j]
|
||||
expected = max(.6, len(groups[i][0]["C"]) / 6.7)
|
||||
actual = (candidates[j][1] - candidates[j][0]) / 1000
|
||||
score, rest = solve(i + 1, j + 1)
|
||||
best = (score + abs(math.log(actual / expected)), [candidates[j]] + rest)
|
||||
if len(candidates) - j > len(groups) - i:
|
||||
score, rest = solve(i, j + 1)
|
||||
if score + .35 < best[0]:
|
||||
best = score + .35, rest
|
||||
cache[i, j] = best
|
||||
return best
|
||||
|
||||
matches = solve(0, 0)[1]
|
||||
if len(matches) != len(groups):
|
||||
raise ValueError("Not enough robot speech spans; inspect the recording before conversion.")
|
||||
return matches
|
||||
|
||||
|
||||
def main():
|
||||
grouped = defaultdict(list)
|
||||
for row in source_rows():
|
||||
grouped[row["A"]].append(row)
|
||||
transcripts, annotations, provenance = [], [], []
|
||||
for call, rows in grouped.items():
|
||||
channels, duration = speech_spans(ROOT / f"data/韩泰机器人抢话/音频/{call}.mp3")
|
||||
groups = []
|
||||
for row in rows:
|
||||
if groups and groups[-1][0]["C"] == row["C"]:
|
||||
groups[-1].append(row)
|
||||
else:
|
||||
groups.append([row])
|
||||
prompts = match_prompts(groups, channels[1])
|
||||
for index, (group, (start, end)) in enumerate(zip(groups, prompts)):
|
||||
transcripts.append([call, "agent", start, end, group[0]["C"]])
|
||||
window_end = prompts[index + 1][0] if index + 1 < len(prompts) else duration
|
||||
user_spans = [(a, b) for a, b in channels[0] if a >= end - 300 and a < window_end]
|
||||
user_start = user_spans[0][0] if user_spans else min(end + 300, window_end - 1)
|
||||
user_end = user_spans[-1][1] if user_spans else window_end
|
||||
# Multiple answers to the same robot prompt share its response window.
|
||||
# These boundaries are provisional, not word-level forced alignment.
|
||||
weights = [max(1, len(row["B"])) for row in group]
|
||||
cursor = user_start
|
||||
for offset, row in enumerate(group):
|
||||
finish = user_end if offset == len(group) - 1 else user_start + round((user_end - user_start) * sum(weights[:offset + 1]) / sum(weights))
|
||||
finish = max(cursor + 1, min(duration, finish))
|
||||
transcripts.append([call, "user", cursor, finish, row["B"]])
|
||||
conflict = row.get("E") in ("是", "否") and row["E"] != row["D"]
|
||||
label = "" if conflict else "0" if row["D"] == "是" else "1" if row["D"] == "否" else ""
|
||||
reason = "uncertain" if label == "" else "unfinished_expression" if label == "0" else "completed_answer"
|
||||
annotations.append([call, finish, row["B"], label, "low", reason, "xlsx_draft"])
|
||||
provenance.append({"call_id": call, "source_row": row["row"], "decision_ms": finish, "original_interrupted": row["D"], "unheaded_column_e": row.get("E", ""), "conflict": conflict})
|
||||
cursor = finish
|
||||
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||||
for name, header, records in [
|
||||
("asr_history.csv", ["call_id", "role", "start_ms", "end_ms", "transcript"], sorted(transcripts, key=lambda r: (r[0], r[2], r[1]))),
|
||||
("eot_labels.csv", ["call_id", "decision_ms", "user_text_at_decision", "can_reply_now", "confidence", "reason", "annotator"], annotations),
|
||||
]:
|
||||
with (OUTPUT / name).open("w", encoding="utf-8-sig", newline="") as file:
|
||||
writer = csv.writer(file)
|
||||
writer.writerow(header)
|
||||
writer.writerows(records)
|
||||
(OUTPUT / "source_rows.json").write_text(json.dumps(provenance, ensure_ascii=False, indent=2) + "\n")
|
||||
print(json.dumps({"calls": len(grouped), "transcripts": len(transcripts), "annotations": len(annotations), "conflicts": sum(row["conflict"] for row in provenance)}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user