playback and import issues
This commit is contained in:
@@ -77,6 +77,10 @@ pub struct ImportedCoco {
|
||||
enum Format {
|
||||
LabelStudioCoco,
|
||||
SamAnno,
|
||||
/// `{"video": {"width", "height", ...}, "fixed_annotations": [
|
||||
/// { "label", "frame_number", "type": "bbox"|"polygon",
|
||||
/// "vertices": [{"x","y"}, ...] (normalized 0..1) }, ... ]}`
|
||||
FixedAnno,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
@@ -87,6 +91,9 @@ fn detect_format(v: &serde_json::Value) -> Format {
|
||||
if obj.contains_key("images") && obj.contains_key("annotations") {
|
||||
return Format::LabelStudioCoco;
|
||||
}
|
||||
if obj.contains_key("fixed_annotations") {
|
||||
return Format::FixedAnno;
|
||||
}
|
||||
if !obj.is_empty()
|
||||
&& obj
|
||||
.keys()
|
||||
@@ -111,8 +118,9 @@ pub fn import(path: &Path) -> Result<ImportedCoco, String> {
|
||||
Ok(build_from_coco(coco))
|
||||
}
|
||||
Format::SamAnno => build_from_sam_anno(&root),
|
||||
Format::FixedAnno => build_from_fixed_anno(&root),
|
||||
Format::Unknown => Err(
|
||||
"unrecognized annotation format — expected Label Studio COCO or SAM anno JSON"
|
||||
"unrecognized annotation format — expected Label Studio COCO, SAM anno JSON, or fixed_annotations JSON"
|
||||
.into(),
|
||||
),
|
||||
}
|
||||
@@ -322,6 +330,150 @@ fn build_from_sam_anno(root: &serde_json::Value) -> Result<ImportedCoco, String>
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Fixed-annotations format ----------------------------------------------
|
||||
//
|
||||
// {
|
||||
// "video": { "width": 1920, "height": 1080, "fps": 30, ... },
|
||||
// "fixed_annotations": [
|
||||
// { "label": "...", "frame_number": 1278,
|
||||
// "type": "bbox", // 4 corner vertices, or
|
||||
// "type": "polygon", // N >= 3 vertices
|
||||
// "vertices": [ {"x": 0..1, "y": 0..1}, ... ],
|
||||
// ... }
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// Coordinates are normalized to the video frame, so we scale by video.width
|
||||
// and video.height to get absolute pixels. The "side" / "annotated_by" /
|
||||
// "created_at" fields are metadata we don't need for the shape import; we
|
||||
// only keep label, frame, geometry.
|
||||
fn build_from_fixed_anno(root: &serde_json::Value) -> Result<ImportedCoco, String> {
|
||||
let obj = root
|
||||
.as_object()
|
||||
.ok_or_else(|| "fixed_anno root is not an object".to_string())?;
|
||||
let video = obj
|
||||
.get("video")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| "missing `video` block".to_string())?;
|
||||
let w = video
|
||||
.get("width")
|
||||
.and_then(|v| v.as_f64())
|
||||
.ok_or_else(|| "video.width missing or non-numeric".to_string())?;
|
||||
let h = video
|
||||
.get("height")
|
||||
.and_then(|v| v.as_f64())
|
||||
.ok_or_else(|| "video.height missing or non-numeric".to_string())?;
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return Err(format!("video dimensions invalid: {w}x{h}"));
|
||||
}
|
||||
let items = obj
|
||||
.get("fixed_annotations")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| "`fixed_annotations` is not an array".to_string())?;
|
||||
|
||||
let mut class_names = BTreeSet::<String>::new();
|
||||
let mut frames: HashMap<String, Vec<Shape>> = HashMap::new();
|
||||
let mut stats = ImportStats {
|
||||
source_format: "fixed".into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for item in items {
|
||||
let Some(obj) = item.as_object() else { continue };
|
||||
let label = obj
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let frame = obj
|
||||
.get("frame_number")
|
||||
.and_then(|v| v.as_u64())
|
||||
.or_else(|| obj.get("frame").and_then(|v| v.as_u64()));
|
||||
let Some(frame) = frame else {
|
||||
stats.skipped_no_image += 1;
|
||||
continue;
|
||||
};
|
||||
let kind = obj
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("bbox")
|
||||
.to_lowercase();
|
||||
let Some(verts) = obj.get("vertices").and_then(|v| v.as_array()) else {
|
||||
continue;
|
||||
};
|
||||
let pts: Vec<[f64; 2]> = verts
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
let o = v.as_object()?;
|
||||
let x = o.get("x")?.as_f64()?;
|
||||
let y = o.get("y")?.as_f64()?;
|
||||
Some([x * w, y * h])
|
||||
})
|
||||
.collect();
|
||||
if pts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.total_annotations += 1;
|
||||
class_names.insert(label.clone());
|
||||
let bucket = frames.entry(frame.to_string()).or_default();
|
||||
|
||||
if kind == "bbox" {
|
||||
// Tight axis-aligned bbox of whatever vertices were given.
|
||||
let (mut xmin, mut ymin) = (f64::INFINITY, f64::INFINITY);
|
||||
let (mut xmax, mut ymax) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for &[x, y] in &pts {
|
||||
if x < xmin { xmin = x; }
|
||||
if y < ymin { ymin = y; }
|
||||
if x > xmax { xmax = x; }
|
||||
if y > ymax { ymax = y; }
|
||||
}
|
||||
bucket.push(Shape::Bbox {
|
||||
class_id: 0,
|
||||
class_name: label,
|
||||
bbox: [xmin, ymin, xmax - xmin, ymax - ymin],
|
||||
});
|
||||
} else if pts.len() >= 3 {
|
||||
bucket.push(Shape::Polygon {
|
||||
class_id: 0,
|
||||
class_name: label,
|
||||
points: pts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Assign class ids by sorted class name (same convention as SAM anno).
|
||||
let categories: Vec<Category> = class_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| Category {
|
||||
id: i as i64,
|
||||
name: name.clone(),
|
||||
})
|
||||
.collect();
|
||||
let name_to_id: HashMap<String, i64> =
|
||||
categories.iter().map(|c| (c.name.clone(), c.id)).collect();
|
||||
for shapes in frames.values_mut() {
|
||||
for shape in shapes.iter_mut() {
|
||||
let (cid, cname) = match shape {
|
||||
Shape::Bbox { class_id, class_name, .. }
|
||||
| Shape::Polygon { class_id, class_name, .. } => (class_id, class_name),
|
||||
};
|
||||
if let Some(&id) = name_to_id.get(cname) {
|
||||
*cid = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats.categories = categories.len();
|
||||
stats.frame_count = frames.len();
|
||||
Ok(ImportedCoco {
|
||||
categories,
|
||||
frames,
|
||||
stats,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
fn trailing_int(file_name: &str) -> Option<u64> {
|
||||
|
||||
@@ -5,14 +5,15 @@ use std::path::PathBuf;
|
||||
use std::process::{Child, ChildStdout, Command, Stdio};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const DEFAULT_CACHE_CAPACITY: usize = 500;
|
||||
pub const MIN_CACHE_CAPACITY: usize = 200;
|
||||
pub const MAX_CACHE_CAPACITY: usize = 2000;
|
||||
pub const DEFAULT_CACHE_CAPACITY: usize = 160;
|
||||
pub const MIN_CACHE_CAPACITY: usize = 50;
|
||||
pub const MAX_CACHE_CAPACITY: usize = 800;
|
||||
const READ_BUF_BYTES: usize = 256 * 1024;
|
||||
/// If the requested frame is this far ahead of the worker, kill + reseek.
|
||||
const SEEK_AHEAD_THRESHOLD: u64 = 60;
|
||||
const DECODE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
struct FrameCache {
|
||||
map: HashMap<u64, Vec<u8>>,
|
||||
@@ -158,6 +159,7 @@ impl DecoderSession {
|
||||
}
|
||||
|
||||
// Wait for the worker to produce idx.
|
||||
let wait_started = Instant::now();
|
||||
let mut st = self.shared.state.lock().unwrap();
|
||||
loop {
|
||||
if st.cache.contains(idx) {
|
||||
@@ -175,7 +177,17 @@ impl DecoderSession {
|
||||
if st.shutdown {
|
||||
return Err("decoder shut down".into());
|
||||
}
|
||||
st = self.shared.cond.wait(st).unwrap();
|
||||
let (next_st, timeout) = self
|
||||
.shared
|
||||
.cond
|
||||
.wait_timeout(st, Duration::from_millis(250))
|
||||
.unwrap();
|
||||
st = next_st;
|
||||
if timeout.timed_out() && wait_started.elapsed() >= DECODE_TIMEOUT {
|
||||
drop(st);
|
||||
self.stop_worker();
|
||||
return Err(format!("timed out decoding frame {idx}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,9 +251,7 @@ fn spawn_ffmpeg(
|
||||
info: &VideoInfo,
|
||||
idx: u64,
|
||||
) -> Result<(Child, BufReader<ChildStdout>), String> {
|
||||
let path_str = path
|
||||
.to_str()
|
||||
.ok_or_else(|| "non-utf8 path".to_string())?;
|
||||
let path_str = path.to_str().ok_or_else(|| "non-utf8 path".to_string())?;
|
||||
let t = idx as f64 / info.fps;
|
||||
let mut cmd = Command::new("ffmpeg");
|
||||
cmd.args(["-v", "error", "-threads", "0"]);
|
||||
@@ -263,7 +273,7 @@ fn spawn_ffmpeg(
|
||||
"mjpeg",
|
||||
"pipe:1",
|
||||
]);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
let mut child = cmd.spawn().map_err(|e| format!("ffmpeg spawn: {e}"))?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
|
||||
@@ -23,14 +23,22 @@ fn ping() -> String {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_video(state: State<'_, AppState>, path: String) -> Result<VideoInfo, String> {
|
||||
fn open_video(
|
||||
state: State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
path: String,
|
||||
) -> Result<VideoInfo, String> {
|
||||
let p = PathBuf::from(&path);
|
||||
if !p.exists() {
|
||||
return Err(format!("file not found: {}", p.display()));
|
||||
}
|
||||
let info = video::probe(&p)?;
|
||||
let decoder = DecoderSession::open(p, info.clone());
|
||||
if let Ok(settings) = settings::load(&app) {
|
||||
decoder.set_cache_capacity(settings.cache_capacity);
|
||||
}
|
||||
let mut guard = state.decoder.lock().map_err(|e| format!("lock: {e}"))?;
|
||||
*guard = Some(DecoderSession::open(p, info.clone()));
|
||||
*guard = Some(decoder);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
@@ -120,9 +128,7 @@ fn bulk_extract(
|
||||
let empty: Vec<coco::Shape> = Vec::new();
|
||||
let mut done: u64 = 0;
|
||||
for idx in start..=end {
|
||||
let shapes = shapes_by_frame
|
||||
.get(&idx.to_string())
|
||||
.unwrap_or(&empty);
|
||||
let shapes = shapes_by_frame.get(&idx.to_string()).unwrap_or(&empty);
|
||||
extract::extract_one(decoder, idx, &dir, shapes, &author)?;
|
||||
done += 1;
|
||||
if done == 1 || done % 5 == 0 || done == total {
|
||||
@@ -133,12 +139,7 @@ fn bulk_extract(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn bulk_undo(
|
||||
app: AppHandle,
|
||||
start: u64,
|
||||
end: u64,
|
||||
out_dir: String,
|
||||
) -> Result<u64, String> {
|
||||
fn bulk_undo(app: AppHandle, start: u64, end: u64, out_dir: String) -> Result<u64, String> {
|
||||
if end < start {
|
||||
return Err("end must be >= start".into());
|
||||
}
|
||||
@@ -253,10 +254,8 @@ fn save_image_annotations(
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = serde_json::to_string_pretty(&payload)
|
||||
.map_err(|e| format!("serialize: {e}"))?;
|
||||
std::fs::write(&json_path, text)
|
||||
.map_err(|e| format!("write {}: {e}", json_path.display()))
|
||||
let text = serde_json::to_string_pretty(&payload).map_err(|e| format!("serialize: {e}"))?;
|
||||
std::fs::write(&json_path, text).map_err(|e| format!("write {}: {e}", json_path.display()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -22,7 +22,15 @@ fn default_sam_url() -> String {
|
||||
}
|
||||
|
||||
fn default_cache_capacity() -> usize {
|
||||
500
|
||||
crate::decoder::DEFAULT_CACHE_CAPACITY
|
||||
}
|
||||
|
||||
fn normalize(mut s: Settings) -> Settings {
|
||||
s.cache_capacity = s.cache_capacity.clamp(
|
||||
crate::decoder::MIN_CACHE_CAPACITY,
|
||||
crate::decoder::MAX_CACHE_CAPACITY,
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -51,12 +59,15 @@ pub fn load(app: &AppHandle) -> Result<Settings, String> {
|
||||
return Ok(Settings::default());
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
serde_json::from_str(&text).map_err(|e| format!("parse settings: {e}"))
|
||||
serde_json::from_str(&text)
|
||||
.map(normalize)
|
||||
.map_err(|e| format!("parse settings: {e}"))
|
||||
}
|
||||
|
||||
pub fn save(app: &AppHandle, s: &Settings) -> Result<(), String> {
|
||||
let path = settings_path(app)?;
|
||||
let text = serde_json::to_string_pretty(s).map_err(|e| format!("serialize: {e}"))?;
|
||||
let normalized = normalize(s.clone());
|
||||
let text = serde_json::to_string_pretty(&normalized).map_err(|e| format!("serialize: {e}"))?;
|
||||
fs::write(&path, text).map_err(|e| format!("write {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user