Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bc42cabd9 | |||
| 8148971aae | |||
| a8d8bd7fdc | |||
| f0484fac86 | |||
| fd25e8fa37 | |||
| c3c480acc7 |
@@ -5,6 +5,7 @@ locale.setlocale(locale.LC_NUMERIC, "C") # required by libmpv before any import
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -45,7 +46,6 @@ def build_ffmpeg_command(
|
|||||||
portrait_ratio: str | None = None,
|
portrait_ratio: str | None = None,
|
||||||
crop_center: float = 0.5,
|
crop_center: float = 0.5,
|
||||||
image_sequence: bool = False,
|
image_sequence: bool = False,
|
||||||
fps: float | None = None,
|
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
# -ss before -i: fast input-seeking. Safe here because we always re-encode
|
# -ss before -i: fast input-seeking. Safe here because we always re-encode
|
||||||
# (libx264/aac), so there is no keyframe-alignment issue from pre-input seek.
|
# (libx264/aac), so there is no keyframe-alignment issue from pre-input seek.
|
||||||
@@ -67,8 +67,6 @@ def build_ffmpeg_command(
|
|||||||
filters.append(
|
filters.append(
|
||||||
f"scale='if(lt(iw,ih),{short_side},-2)':'if(lt(iw,ih),-2,{short_side})':flags=lanczos"
|
f"scale='if(lt(iw,ih),{short_side},-2)':'if(lt(iw,ih),-2,{short_side})':flags=lanczos"
|
||||||
)
|
)
|
||||||
if fps is not None:
|
|
||||||
filters.append(f"fps={fps}")
|
|
||||||
if filters:
|
if filters:
|
||||||
cmd += ["-vf", ",".join(filters)]
|
cmd += ["-vf", ",".join(filters)]
|
||||||
|
|
||||||
@@ -99,22 +97,40 @@ def build_audio_extract_command(input_path: str, start: float, sequence_dir: str
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def build_annotation_tsv_path(folder: str) -> str:
|
def build_annotation_json_path(folder: str) -> str:
|
||||||
return os.path.join(folder, "dataset.tsv")
|
return os.path.join(folder, "dataset.json")
|
||||||
|
|
||||||
|
|
||||||
def append_to_tsv(folder: str, clip_stem: str, label: str) -> None:
|
def upsert_clip_annotation(folder: str, clip_path: str, label: str) -> None:
|
||||||
"""Append one line to <folder>/dataset.tsv (creates file if absent).
|
"""Insert or update one entry in <folder>/dataset.json.
|
||||||
|
|
||||||
Format: ``{clip_stem}\\t{label}`` — matches VGGSound training TSV (2 columns).
|
Each entry stores a path relative to *folder* and the sound label.
|
||||||
Category is stored in the database only, not in the TSV.
|
Matches on ``path``; if an entry for the same clip already exists it is
|
||||||
|
replaced (overwrite-export case). Nothing is written when *label* is
|
||||||
|
empty.
|
||||||
"""
|
"""
|
||||||
if not label.strip():
|
if not label.strip():
|
||||||
return
|
return
|
||||||
tsv_path = build_annotation_tsv_path(folder)
|
|
||||||
os.makedirs(folder, exist_ok=True)
|
os.makedirs(folder, exist_ok=True)
|
||||||
with open(tsv_path, "a", encoding="utf-8") as f:
|
json_path = build_annotation_json_path(folder)
|
||||||
f.write(f"{clip_stem}\t{label}\n")
|
entries: list[dict] = []
|
||||||
|
if os.path.exists(json_path):
|
||||||
|
with open(json_path, "r", encoding="utf-8") as f:
|
||||||
|
try:
|
||||||
|
entries = json.load(f)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
entries = []
|
||||||
|
abs_path = os.path.abspath(clip_path)
|
||||||
|
entry: dict = {"path": abs_path, "label": label}
|
||||||
|
for i, e in enumerate(entries):
|
||||||
|
if e.get("path") == abs_path:
|
||||||
|
entries[i] = entry
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
entries.append(entry)
|
||||||
|
with open(json_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(entries, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
def build_mask_output_dir(video_path: str) -> str:
|
def build_mask_output_dir(video_path: str) -> str:
|
||||||
@@ -308,8 +324,7 @@ class ExportWorker(QThread):
|
|||||||
short_side: int | None = None,
|
short_side: int | None = None,
|
||||||
portrait_ratio: str | None = None,
|
portrait_ratio: str | None = None,
|
||||||
crop_center: float = 0.5,
|
crop_center: float = 0.5,
|
||||||
image_sequence: bool = False,
|
image_sequence: bool = False):
|
||||||
fps: float | None = None):
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._input = input_path
|
self._input = input_path
|
||||||
self._start = start
|
self._start = start
|
||||||
@@ -318,7 +333,6 @@ class ExportWorker(QThread):
|
|||||||
self._portrait_ratio = portrait_ratio
|
self._portrait_ratio = portrait_ratio
|
||||||
self._crop_center = crop_center
|
self._crop_center = crop_center
|
||||||
self._image_sequence = image_sequence
|
self._image_sequence = image_sequence
|
||||||
self._fps = fps
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
@@ -330,7 +344,6 @@ class ExportWorker(QThread):
|
|||||||
portrait_ratio=self._portrait_ratio,
|
portrait_ratio=self._portrait_ratio,
|
||||||
crop_center=self._crop_center,
|
crop_center=self._crop_center,
|
||||||
image_sequence=self._image_sequence,
|
image_sequence=self._image_sequence,
|
||||||
fps=self._fps,
|
|
||||||
)
|
)
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
@@ -339,11 +352,6 @@ class ExportWorker(QThread):
|
|||||||
self._input, self._start, self._output
|
self._input, self._start, self._output
|
||||||
)
|
)
|
||||||
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
|
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
|
||||||
# Write fps.txt alongside the .wav file.
|
|
||||||
if self._fps is not None:
|
|
||||||
fps_path = self._output + ".fps.txt"
|
|
||||||
with open(fps_path, "w") as f:
|
|
||||||
f.write(f"{self._fps}\n")
|
|
||||||
# Audio extraction failure (e.g. no audio stream) is ignored —
|
# Audio extraction failure (e.g. no audio stream) is ignored —
|
||||||
# the frame sequence is the primary output.
|
# the frame sequence is the primary output.
|
||||||
self.finished.emit(self._output)
|
self.finished.emit(self._output)
|
||||||
@@ -1563,7 +1571,6 @@ class MainWindow(QMainWindow):
|
|||||||
portrait_ratio=portrait_ratio,
|
portrait_ratio=portrait_ratio,
|
||||||
crop_center=self._crop_center,
|
crop_center=self._crop_center,
|
||||||
image_sequence=image_sequence,
|
image_sequence=image_sequence,
|
||||||
fps=self._fps,
|
|
||||||
)
|
)
|
||||||
self._export_worker.finished.connect(self._on_export_done)
|
self._export_worker.finished.connect(self._on_export_done)
|
||||||
self._export_worker.error.connect(self._on_export_error)
|
self._export_worker.error.connect(self._on_export_error)
|
||||||
@@ -1579,8 +1586,8 @@ class MainWindow(QMainWindow):
|
|||||||
label=label,
|
label=label,
|
||||||
category=category,
|
category=category,
|
||||||
)
|
)
|
||||||
clip_stem = os.path.splitext(os.path.basename(path))[0]
|
folder = self._txt_folder.text()
|
||||||
append_to_tsv(self._txt_folder.text(), clip_stem, label)
|
upsert_clip_annotation(folder, path, label)
|
||||||
# For MP4 exports path is a file; for WebP sequence it is a directory.
|
# For MP4 exports path is a file; for WebP sequence it is a directory.
|
||||||
# build_mask_output_dir handles both correctly via Path.stem.
|
# build_mask_output_dir handles both correctly via Path.stem.
|
||||||
self._last_export_path = path
|
self._last_export_path = path
|
||||||
|
|||||||
+38
-28
@@ -1,5 +1,5 @@
|
|||||||
import tempfile, os
|
import tempfile, os, json
|
||||||
from main import build_export_path, format_time, build_ffmpeg_command, build_mask_output_dir, build_sequence_dir, build_audio_extract_command, build_annotation_tsv_path, append_to_tsv
|
from main import build_export_path, format_time, build_ffmpeg_command, build_mask_output_dir, build_sequence_dir, build_audio_extract_command, build_annotation_json_path, upsert_clip_annotation
|
||||||
from main import _normalize_filename, ProcessedDB
|
from main import _normalize_filename, ProcessedDB
|
||||||
|
|
||||||
|
|
||||||
@@ -217,10 +217,7 @@ def test_ffmpeg_command_image_sequence():
|
|||||||
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/seq_001", image_sequence=True)
|
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/seq_001", image_sequence=True)
|
||||||
assert "-c:v" in cmd
|
assert "-c:v" in cmd
|
||||||
assert cmd[cmd.index("-c:v") + 1] == "libwebp"
|
assert cmd[cmd.index("-c:v") + 1] == "libwebp"
|
||||||
assert "-lossless" in cmd
|
assert "-quality" in cmd
|
||||||
assert cmd[cmd.index("-lossless") + 1] == "1"
|
|
||||||
assert "-compression_level" in cmd
|
|
||||||
assert cmd[cmd.index("-compression_level") + 1] == "4"
|
|
||||||
assert cmd[-1] == "/out/seq_001/frame_%04d.webp"
|
assert cmd[-1] == "/out/seq_001/frame_%04d.webp"
|
||||||
|
|
||||||
def test_ffmpeg_command_image_sequence_with_resize():
|
def test_ffmpeg_command_image_sequence_with_resize():
|
||||||
@@ -237,28 +234,47 @@ def test_ffmpeg_command_image_sequence_no_audio():
|
|||||||
assert "aac" not in cmd
|
assert "aac" not in cmd
|
||||||
|
|
||||||
|
|
||||||
def test_annotation_tsv_path():
|
def test_annotation_json_path():
|
||||||
assert build_annotation_tsv_path("/out") == "/out/dataset.tsv"
|
assert build_annotation_json_path("/out") == "/out/dataset.json"
|
||||||
|
|
||||||
def test_append_to_tsv_creates_file():
|
def test_upsert_creates_file():
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
append_to_tsv(d, "clip_001", "dog barking")
|
clip = os.path.join(d, "clip_001.mp4")
|
||||||
with open(os.path.join(d, "dataset.tsv")) as f:
|
upsert_clip_annotation(d, clip, "dog barking")
|
||||||
lines = f.readlines()
|
with open(os.path.join(d, "dataset.json")) as f:
|
||||||
assert lines == ["clip_001\tdog barking\n"]
|
entries = json.load(f)
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0]["label"] == "dog barking"
|
||||||
|
assert entries[0]["path"] == clip
|
||||||
|
|
||||||
def test_append_to_tsv_appends():
|
def test_upsert_appends_new_clips():
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
append_to_tsv(d, "clip_001", "dog barking")
|
upsert_clip_annotation(d, os.path.join(d, "clip_001.mp4"), "dog barking")
|
||||||
append_to_tsv(d, "clip_002", "cat meowing")
|
upsert_clip_annotation(d, os.path.join(d, "clip_002.mp4"), "cat meowing")
|
||||||
with open(os.path.join(d, "dataset.tsv")) as f:
|
with open(os.path.join(d, "dataset.json")) as f:
|
||||||
lines = f.readlines()
|
entries = json.load(f)
|
||||||
assert len(lines) == 2
|
assert len(entries) == 2
|
||||||
|
|
||||||
def test_append_to_tsv_empty_label_skips():
|
def test_upsert_replaces_existing():
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
append_to_tsv(d, "clip_001", "")
|
clip = os.path.join(d, "clip_001.mp4")
|
||||||
assert not os.path.exists(os.path.join(d, "dataset.tsv"))
|
upsert_clip_annotation(d, clip, "dog barking")
|
||||||
|
upsert_clip_annotation(d, clip, "cat meowing")
|
||||||
|
with open(os.path.join(d, "dataset.json")) as f:
|
||||||
|
entries = json.load(f)
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0]["label"] == "cat meowing"
|
||||||
|
|
||||||
|
def test_upsert_empty_label_skips():
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
upsert_clip_annotation(d, os.path.join(d, "clip_001.mp4"), "")
|
||||||
|
assert not os.path.exists(os.path.join(d, "dataset.json"))
|
||||||
|
|
||||||
|
def test_upsert_missing_folder_creates_it():
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
nested = os.path.join(d, "subdir", "deep")
|
||||||
|
upsert_clip_annotation(nested, os.path.join(nested, "clip_001.mp4"), "dog barking")
|
||||||
|
assert os.path.exists(os.path.join(nested, "dataset.json"))
|
||||||
|
|
||||||
def test_db_stores_label_and_category():
|
def test_db_stores_label_and_category():
|
||||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||||
@@ -272,9 +288,3 @@ def test_db_stores_label_and_category():
|
|||||||
assert row == ("dog barking", "Animal")
|
assert row == ("dog barking", "Animal")
|
||||||
finally:
|
finally:
|
||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
|
|
||||||
def test_append_to_tsv_missing_folder_creates_it():
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
|
||||||
nested = os.path.join(d, "subdir", "deep")
|
|
||||||
append_to_tsv(nested, "clip_001", "dog barking")
|
|
||||||
assert os.path.exists(os.path.join(nested, "dataset.tsv"))
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""One-shot migration: generate dataset.json files from ~/.8cut.db history.
|
||||||
|
|
||||||
|
For each export folder that has records in the database, writes (or merges
|
||||||
|
into) a dataset.json with path (relative to the folder) and label per clip.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/migrate_dataset_json.py [--dry-run]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DB_PATH = Path.home() / ".8cut.db"
|
||||||
|
|
||||||
|
|
||||||
|
def load_db_records(db_path: Path) -> list[dict]:
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
rows = con.execute(
|
||||||
|
"SELECT output_path, label FROM processed WHERE label != ''"
|
||||||
|
).fetchall()
|
||||||
|
con.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def build_entries_for_folder(folder: str, records: list[dict]) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{"path": os.path.abspath(rec["output_path"]), "label": rec["label"]}
|
||||||
|
for rec in records
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def merge_into_json(json_path: str, new_entries: list[dict], dry_run: bool) -> int:
|
||||||
|
"""Merge new_entries into existing json_path, returning count added/updated."""
|
||||||
|
existing: list[dict] = []
|
||||||
|
if os.path.exists(json_path):
|
||||||
|
with open(json_path, "r", encoding="utf-8") as f:
|
||||||
|
try:
|
||||||
|
existing = json.load(f)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
existing = []
|
||||||
|
|
||||||
|
by_path = {e["path"]: e for e in existing}
|
||||||
|
changed = 0
|
||||||
|
for entry in new_entries:
|
||||||
|
if by_path.get(entry["path"]) != entry:
|
||||||
|
by_path[entry["path"]] = entry
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
merged = list(by_path.values())
|
||||||
|
if not dry_run and changed:
|
||||||
|
with open(json_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(merged, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
|
help="Show what would be written without writing")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
print(f"Database not found: {DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
records = load_db_records(DB_PATH)
|
||||||
|
if not records:
|
||||||
|
print("No labelled records in database.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Group records by parent folder of output_path
|
||||||
|
by_folder: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
for rec in records:
|
||||||
|
folder = os.path.dirname(rec["output_path"])
|
||||||
|
by_folder[folder].append(rec)
|
||||||
|
|
||||||
|
total_written = 0
|
||||||
|
for folder, recs in sorted(by_folder.items()):
|
||||||
|
entries = build_entries_for_folder(folder, recs)
|
||||||
|
json_path = os.path.join(folder, "dataset.json")
|
||||||
|
changed = merge_into_json(json_path, entries, args.dry_run)
|
||||||
|
status = "(dry run) " if args.dry_run else ""
|
||||||
|
print(f"{status}{json_path}: {len(entries)} clips, {changed} added/updated")
|
||||||
|
total_written += changed
|
||||||
|
|
||||||
|
suffix = " (dry run)" if args.dry_run else ""
|
||||||
|
print(f"\nTotal: {total_written} entries written{suffix}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user