영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
중복 탐지 - 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시) - 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(8변형 질의, 선택 옵션) - 오디오 지문(chromaprint)을 같은 디코드 패스에서 뽑아 레터박스 사본을 잡는다. 배경음만 같은 남남은 시각 확인 패스(트림 축 프레임 거리)로 걸러낸다 — 실측 9 vs 29. - 비교 재생(CompareView): 여러 사본을 offset 정렬해 나란히 재생 - 지문 생성 속도 조절(느림·보통·빠름) — 재시작 없이 반영 간단 편집 (원본을 직접 고침, Ctrl+Z로 되돌림) - 회전·반전: JPEG은 EXIF 방향 태그 2바이트만, MP4는 회전 행렬만 — 화질 손실 0. 방향값 전이표는 군의 성질(90도x4=제자리)로 검산하고, 실제 화소를 transpose=1과 비교해 "시계 방향"이 정말 시계인지 확인한다. - 자르기: 화면 좌표 기준(회전 메타가 붙어 있어도 어긋나지 않는다) - 구간 자르기: 스트림 복사라 무손실. 필름스트립 타임라인 + 손잡이 끌기 + 구간 미리보기. - 되돌릴 수 없는 편집(자르기·반전)은 원본을 .archive_trash/edits로 옮겨 보관 - 편집이 수정시각을 바꾸지 않는다 — 기본 정렬이 촬영일이라 항목이 튀면 다시 찾아야 한다 동시 재생 - 고른 영상을 최대 9개 격자에 놓고 함께 재생. 소리는 한 칸만. - 실시간 변환이 필요한 코덱은 2개까지만 — 그 이상은 ffmpeg가 기계를 멈춘다 그 외 - 별점(스냅샷 flags 남는 비트에 얹어 항목당 바이트 증가 0) - 태그: 검색 없이 클릭으로 붙이기, 세트+단축키, 이름 수정 시 붙은 항목에 전파 - 파일 로깅(tracing-appender) — 릴리스는 콘솔이 없어 stdout만으로는 원인을 못 찾았다 - ARM64 크로스 컴파일 스크립트(관리자 권한 없이 VS 카탈로그에서 조립) - DB 마이그레이션 v7~v10 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
67d2ddc59e
commit
8aa74c1a8d
@@ -0,0 +1,137 @@
|
||||
//! 근거 측정 — 레터박스 사본을 어떻게 잡는지, 그리고 왜 프레임만으로는 못 잡는지.
|
||||
//! `cargo test -p archive-indexer --test letterbox_evidence -- --ignored --nocapture`
|
||||
//!
|
||||
//! 측정 결과(2026-08)와 그로부터 나온 설계:
|
||||
//!
|
||||
//! 1. 검정 띠 트림 자체는 정확하다 — 32×32에서 x6..26 y6..26, 딱 안쪽 콘텐츠다.
|
||||
//! 2. 그런데 20×20을 9×8로 다시 표본화한 해시는 원본(32×32→9×8)과 **거리 7~16**(중앙값 9.5)이고
|
||||
//! **정확히 같은 값은 0개**다. 후보 생성이 값 기반 토큰이라 두 사본은 영영 만나지 못한다.
|
||||
//! → 그래서 트림 해시를 후보 토큰으로 쓰려던 시도는 폐기했다(`dedupe::video_candidates` 주석).
|
||||
//! 3. 오디오는 화면 변형에 영향받지 않는다 — 레터박스 사본이 chromaprint 221/221 일치.
|
||||
//! → 오디오가 후보를 만든다.
|
||||
//! 4. 다만 **배경음만 같은 남남**도 221/221로 붙는다(`vidC_sharedaudio`). 둘을 가르는 건 시각뿐:
|
||||
//! 진짜 사본은 트림 축 프레임 거리 9, 남남은 29. → `AUDIO_VOTE_MAX_DIST = 16`이 그 사이다.
|
||||
//!
|
||||
//! 이 파일은 그 네 숫자를 다시 뽑아본다. 임계값을 손댈 일이 생기면 여기부터 돌려라.
|
||||
|
||||
use archive_indexer::{ffmpeg::{self, FfTools}, sigmatch};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn repo_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap()
|
||||
}
|
||||
|
||||
fn tools() -> Option<FfTools> {
|
||||
let bin = repo_root().join("src-tauri/binaries");
|
||||
let triple = if cfg!(all(windows, target_arch = "x86_64")) {
|
||||
"x86_64-pc-windows-msvc"
|
||||
} else {
|
||||
"aarch64-pc-windows-msvc"
|
||||
};
|
||||
let ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
let (a, b) = (bin.join(format!("ffmpeg-{triple}{ext}")), bin.join(format!("ffprobe-{triple}{ext}")));
|
||||
(a.is_file() && b.is_file()).then_some(FfTools { ffmpeg: a, ffprobe: b })
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "근거 측정용"]
|
||||
fn print_letterbox_evidence() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음");
|
||||
return;
|
||||
};
|
||||
let dir = repo_root().join(".fixtures/videos");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("픽스처 없음");
|
||||
return;
|
||||
}
|
||||
let grab = |name: &str| -> Vec<(u32, Vec<u8>)> {
|
||||
ffmpeg::frame_gray_sequence(
|
||||
&t,
|
||||
&dir.join(name),
|
||||
1000,
|
||||
sigmatch::FRAME as u32,
|
||||
std::time::Duration::from_secs(120),
|
||||
None,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let a = grab("vidA.mp4");
|
||||
let l = grab("vidA_letterbox.mp4");
|
||||
println!("프레임 수: vidA {} · letterbox {}", a.len(), l.len());
|
||||
if a.is_empty() || l.is_empty() {
|
||||
return;
|
||||
}
|
||||
for i in [0usize, 5, 10] {
|
||||
let (Some((_, pa)), Some((_, pl))) = (a.get(i), l.get(i)) else { continue };
|
||||
let (sa, sl) = (sigmatch::derive(pa).unwrap(), sigmatch::derive(pl).unwrap());
|
||||
let bounds = sigmatch::letterbox(pl);
|
||||
// 32×32 픽셀에서 검정(값 <= 16) 비율 — 패딩이 얼마나 되는지
|
||||
let black = pl.iter().filter(|v| **v <= 16).count() * 100 / pl.len();
|
||||
println!("── 프레임 {i}");
|
||||
println!(
|
||||
" letterbox 판정 x{}..{} y{}..{} (검정 픽셀 {black}%)",
|
||||
bounds.0, bounds.2, bounds.1, bounds.3
|
||||
);
|
||||
println!(
|
||||
" 전체↔전체 {} · 트림(레터박스)↔전체(원본) {} · 전체↔트림 {}",
|
||||
sigmatch::hamming(sa.dhash, sl.dhash),
|
||||
sigmatch::hamming(sl.scales[2], sa.dhash),
|
||||
sigmatch::hamming(sa.dhash, sl.scales[2])
|
||||
);
|
||||
println!(
|
||||
" 원본의 scales[2]는 전체와 같은가: {} (거리 {})",
|
||||
sa.scales[2] == sa.dhash,
|
||||
sigmatch::hamming(sa.scales[2], sa.dhash)
|
||||
);
|
||||
}
|
||||
|
||||
// 후보 생성은 '값이 정확히 같은 토큰'을 요구한다 — 공유 토큰이 몇 개인지가 결정적이다
|
||||
let sig_of = |frames: &[(u32, Vec<u8>)]| -> (Vec<u64>, Vec<u64>) {
|
||||
let mut whole = Vec::new();
|
||||
let mut lb = Vec::new();
|
||||
for (_, px) in frames {
|
||||
if let Some(s) = sigmatch::derive(px) {
|
||||
whole.push(s.dhash);
|
||||
lb.push(s.scales[2]);
|
||||
}
|
||||
}
|
||||
(whole, lb)
|
||||
};
|
||||
let (aw, _al) = sig_of(&a);
|
||||
let (lw, ll) = sig_of(&l);
|
||||
let set: std::collections::HashSet<u64> = aw.iter().copied().collect();
|
||||
println!("\n── 후보 토큰 공유 (정확히 일치하는 값의 개수)");
|
||||
println!(" 원본 전체해시 {}개 중", set.len());
|
||||
println!(" 레터박스본 전체해시와 공유: {}", lw.iter().filter(|h| set.contains(h)).count());
|
||||
println!(" 레터박스본 트림해시와 공유: {}", ll.iter().filter(|h| set.contains(h)).count());
|
||||
// 거리 분포 — 몇 비트나 벌어지는지
|
||||
let mut d: Vec<u32> = ll
|
||||
.iter()
|
||||
.zip(aw.iter())
|
||||
.map(|(x, y)| sigmatch::hamming(*x, *y))
|
||||
.collect();
|
||||
d.sort_unstable();
|
||||
println!(" 트림↔전체 프레임별 거리(정렬): {d:?}");
|
||||
|
||||
// ── 오디오가 후보를 만들 때 시각으로 진짜/남남을 가를 수 있는가 ──────────
|
||||
// 오디오는 vidA_letterbox(진짜 사본)와 vidC_sharedaudio(배경음만 같은 남남)를
|
||||
// 똑같이 221/221 일치로 붙인다. 둘을 가르는 건 시각뿐이므로 그 간격을 잰다.
|
||||
let median = |mut v: Vec<u32>| -> u32 {
|
||||
v.sort_unstable();
|
||||
v.get(v.len() / 2).copied().unwrap_or(0)
|
||||
};
|
||||
println!("\n── 오디오 후보를 시각으로 거를 때의 간격 (중앙값, 64비트 중)");
|
||||
for (name, use_lb) in [("vidA_letterbox.mp4", true), ("vidC_sharedaudio.mp4", false)] {
|
||||
let f = grab(name);
|
||||
if f.is_empty() {
|
||||
println!(" {name}: 프레임 없음");
|
||||
continue;
|
||||
}
|
||||
let (w, lb) = sig_of(&f);
|
||||
let seq = if use_lb { &lb } else { &w };
|
||||
let n = seq.len().min(aw.len());
|
||||
let dm = median((0..n).map(|i| sigmatch::hamming(seq[i], aw[i])).collect());
|
||||
println!(" vidA ↔ {name:<22} {dm} {}", if use_lb { "(트림 축)" } else { "(전체 축)" });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user