修:draw-ui / oil-motion 原被当子模块指针收录 ⇒ 改为正常文件入库(两份内容原先对别人是空的)
一、问题(本轮实测)
`draw-ui` 与 `oil-motion` 目录里**各自带一个内嵌 `.git`** ⇒ 上一次提交把它们记成了 **gitlink(子模块指针)**
⇒ 仓库里只存了一个不属于任何远端的 commit id,**别人克隆下来这两份是空的** ✗(`git status` 显示 ` m draw-ui` / ` m oil-motion` = 子模块内容有改动)。
二、处置(可回退)
· 把两处的 `.git` **挪走**(⛔ 不是删除)⇒ `归档/内嵌git-20261008/{draw-ui,oil-motion}.git`;
· `git rm --cached` 掉那两个 gitlink,再 `git add` 两个目录 ⇒ **按正常文件入库**(内容才真的进仓库)。
三、副作用(如实记)
挪走 `.git` 后,这两个技能**不能再原地 `git pull` 取上游更新**(要更新得重新拉一份覆盖);
如需恢复其本地仓库,把 `归档/内嵌git-20261008/` 里的 `.git` 挪回原处即可。
This commit is contained in:
1 parent
e03465c398
commit
237a09a5b0
161 files changed
+19429
-2
No files matched your search
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""清理 AI 闭环视频切帧中的重复帧和尾部停帧。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageChops, ImageStat
|
||||
|
||||
|
||||
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
|
||||
|
||||
|
||||
def difference(left: Path, right: Path, sample_size: int = 128) -> float:
|
||||
with Image.open(left) as first, Image.open(right) as second:
|
||||
def visible_rgb(image: Image.Image) -> Image.Image:
|
||||
if "A" not in image.getbands():
|
||||
return image.convert("RGB")
|
||||
rgba = image.convert("RGBA")
|
||||
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
|
||||
return Image.alpha_composite(background, rgba).convert("RGB")
|
||||
|
||||
first_rgb = visible_rgb(first)
|
||||
second_rgb = visible_rgb(second)
|
||||
first_rgb.thumbnail((sample_size, sample_size), Image.Resampling.LANCZOS)
|
||||
second_rgb.thumbnail((sample_size, sample_size), Image.Resampling.LANCZOS)
|
||||
if first_rgb.size != second_rgb.size:
|
||||
second_rgb = second_rgb.resize(first_rgb.size, Image.Resampling.LANCZOS)
|
||||
means = ImageStat.Stat(ImageChops.difference(first_rgb, second_rgb)).mean
|
||||
return sum(means) / (3 * 255)
|
||||
|
||||
|
||||
def frames_in(directory: Path) -> list[Path]:
|
||||
return sorted(
|
||||
path
|
||||
for path in directory.iterdir()
|
||||
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
|
||||
)
|
||||
|
||||
|
||||
def clean(args: argparse.Namespace) -> int:
|
||||
source = Path(args.source).expanduser().resolve()
|
||||
output = Path(args.output).expanduser().resolve()
|
||||
report_path = (
|
||||
Path(args.report).expanduser().resolve()
|
||||
if args.report
|
||||
else output.parent / f"{output.name}-cleanup.json"
|
||||
)
|
||||
frames = frames_in(source)
|
||||
if len(frames) < 3:
|
||||
raise ValueError("至少需要 3 帧")
|
||||
if output.exists() and any(output.iterdir()) and not args.force:
|
||||
raise FileExistsError(f"输出目录非空:{output};确认后使用 --force")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
if args.force:
|
||||
for item in output.iterdir():
|
||||
if item.is_file() or item.is_symlink():
|
||||
item.unlink()
|
||||
elif item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
|
||||
seam_window_start = max(1, len(frames) - args.seam_window)
|
||||
end_reference = (
|
||||
Path(args.end_reference).expanduser().resolve()
|
||||
if args.end_reference
|
||||
else frames[0]
|
||||
)
|
||||
seam_candidates = [
|
||||
(index, difference(end_reference, frames[index]))
|
||||
for index in range(seam_window_start, len(frames))
|
||||
]
|
||||
seam_index, seam_difference = min(seam_candidates, key=lambda item: item[1])
|
||||
|
||||
kept = [0]
|
||||
dropped: list[dict[str, float | int | str]] = []
|
||||
for index in range(1, seam_index):
|
||||
delta = difference(frames[kept[-1]], frames[index])
|
||||
if delta < args.duplicate_threshold:
|
||||
dropped.append(
|
||||
{
|
||||
"index": index,
|
||||
"file": frames[index].name,
|
||||
"difference": round(delta, 8),
|
||||
}
|
||||
)
|
||||
continue
|
||||
kept.append(index)
|
||||
if kept[-1] != seam_index:
|
||||
kept.append(seam_index)
|
||||
|
||||
digits = max(5, len(str(len(kept))))
|
||||
for output_index, source_index in enumerate(kept, start=1):
|
||||
source_path = frames[source_index]
|
||||
target = output / f"frame_{output_index:0{digits}d}{source_path.suffix.lower()}"
|
||||
shutil.copy2(source_path, target)
|
||||
|
||||
report = {
|
||||
"source": str(source),
|
||||
"output": str(output),
|
||||
"inputFrameCount": len(frames),
|
||||
"outputFrameCount": len(kept),
|
||||
"seamWindow": args.seam_window,
|
||||
"endReference": str(end_reference),
|
||||
"seamSourceIndex": seam_index,
|
||||
"seamDifference": round(seam_difference, 8),
|
||||
"duplicateThreshold": args.duplicate_threshold,
|
||||
"keptSourceIndices": kept,
|
||||
"droppedNearDuplicates": dropped,
|
||||
"trimmedTailSourceIndices": list(range(seam_index + 1, len(frames))),
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
result = argparse.ArgumentParser(
|
||||
description="选择最接近首帧的尾部接缝,并删除视觉近重复帧"
|
||||
)
|
||||
result.add_argument("source")
|
||||
result.add_argument("output")
|
||||
result.add_argument(
|
||||
"--seam-window",
|
||||
type=int,
|
||||
default=24,
|
||||
help="只在最后 N 帧中寻找最接近首帧的接缝",
|
||||
)
|
||||
result.add_argument(
|
||||
"--end-reference",
|
||||
help="单向转场的目标尾帧;省略时使用首帧,适合闭环",
|
||||
)
|
||||
result.add_argument(
|
||||
"--duplicate-threshold",
|
||||
type=float,
|
||||
default=0.003,
|
||||
help="与上一保留帧的归一化 RGB 差异低于此值时删除",
|
||||
)
|
||||
result.add_argument("--report")
|
||||
result.add_argument("--force", action="store_true")
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(clean(parser().parse_args()))
|
||||
except (FileNotFoundError, FileExistsError, ValueError) as error:
|
||||
raise SystemExit(f"错误:{error}") from error
|
||||
Reference in new issue
Block a user