Skip to content

Commit 1fd3b9a

Browse files
authored
[feat] Divide Trim Script from Extract Script and Improve Trim Algolithm (#31)
1 parent c44ec50 commit 1fd3b9a

File tree

5 files changed

+175
-36
lines changed

5 files changed

+175
-36
lines changed

README.md

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,31 @@
11
# Char Lab
22

3-
キャラクターの360度画像を用いて、擬似的に3Dモデル風の表示を実現するソフトウェアです
3+
キャラクターの 360 度画像を用いて、擬似的に 3D モデル風の表示を実現するソフトウェアです
44

55
## 使い方
66

7-
### 画像の準備
7+
アプリを release からダウンロードして実行してください。アプリの使用には、キャラクターの 360 度画像が必要です。
8+
アプリを起動すると歯車が表示されるため、右クリックから「フォルダの選択」を選び、キャラクターの画像が保存されているフォルダを選択してください。選択したフォルダ内の画像が読み込まれ、キャラクターが表示されます。
89

9-
**既に背景除去済みの画像がある場合は、この手順は不要です。**
10+
フォルダには、キャラクターの画像が以下のように保存されている必要があります。
1011

11-
背景除去が必要な場合は、以下の手順に従ってスクリプトを実行してください。スクリプトの実行にはPython環境が必要です。推奨Pythonバージョンについては[.tool-versions](./.tool-versions)をご確認ください。
12+
- ファイル名の先頭に連番が割り振られていること
13+
- (連番に基づいて画像が並び替えられます)
14+
- 画像の拡張子が `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif` のいずれかであること
15+
- 画像のサイズが同一であること
16+
- (サイズが異なる場合、アプリの表示が崩れる可能性があります)
17+
- 画像の背景が透明であること
18+
- (動作に影響はありませんが、背景が除去されていない場合は背景も一緒に表示されます)
19+
- 最初の画像と最後の画像がつながるようになっていること
20+
- アプリ側で表示範囲の調整は行っていません。先頭と終端の画像がつながるようにフォルダ内に含める画像を調整してください。
21+
22+
## 使用画像の前処理
23+
24+
### 画像背景の除去
25+
26+
**既に背景除去済みの画像がある場合、背景除去が不要な場合はこの手順は不要です。**
27+
28+
背景除去が必要な場合は、以下の手順に従ってスクリプトを実行してください。スクリプトの実行には Python 環境が必要です。推奨 Python バージョンについては[.tool-versions](./.tool-versions)をご確認ください。
1229

1330
背景除去は機械学習モデルを利用して行います。そのため、出力結果が完璧でない場合や、再実行により結果が変わる可能性があります。より良い結果を得るためには、以下の条件を満たす画像の使用が望ましいです。
1431

@@ -18,7 +35,7 @@
1835

1936
#### 環境セットアップ
2037

21-
Pythonの実行環境には、Poetryで作成される仮想環境を利用します
38+
Python の実行環境には、Poetry で作成される仮想環境を利用します
2239

2340
```bash
2441
$ cd ./pre_process
@@ -41,6 +58,33 @@ $ poetry run python ./extract.py /path/to/input /path/to/output
4158
```
4259

4360
入力パスと出力パスには、各環境に合わせた適切なパスを指定してください。
44-
入力パスにはファイルまたはディレクトリを指定できます。ディレクトリを指定した場合、対象はディレクトリ内のすべての画像となり、ディレクトリ構造を反映して出力されます。ファイルを指定した場合は、単一の画像が処理され、出力は指定されたディレクトリの直下に保存されます。
61+
入力パスにはファイルまたはディレクトリを指定できます。ディレクトリを指定した場合、対象はディレクトリ内のすべての画像となり、ディレクトリ構造を反映して出力されます。ファイルを指定した場合は、単一の画像が処理され、出力は指定されたディレクトリの直下に保存されます。
4562

4663
出力先ディレクトリが存在しない場合は自動的に作成されます。既にディレクトリが存在し、かつファイルがある場合や、指定パスがファイルの場合は警告が表示され、続行するか確認が求められます。続行後、同名のファイルがある場合は上書きされます。
64+
65+
### 画像のトリミング
66+
67+
アプリでは背景を透明で描画するため、画像のトリミングを行わない場合、画像の周囲に余白が残るためウィジェットのサイズが見た目よりも大きくなります。
68+
トリミングを行うことで、画像の周囲の余白を削除し、ウィジェットのサイズを適切に調整できます。
69+
**アプリの使用前にトリミングを行うことを強く推奨します。**
70+
71+
#### 環境セットアップ
72+
73+
[画像背景の除去](./README.md#画像背景の除去)と同様に、Poetry で仮想環境をセットアップします。
74+
操作方法は[画像背景の除去](./README.md#画像背景の除去)の環境セットアップの手順を参照してください。
75+
76+
#### スクリプトの実行
77+
78+
画像のトリミングは `./pre_process/trim.py` を以下のコマンドで実行できます。
79+
80+
```bash
81+
$ poetry run python ./trim.py {画像ファイルまたはディレクトリのパス} {出力先ディレクトリのパス}
82+
```
83+
84+
たとえば、`/path/to/input` にある画像をトリミングし、結果を `/path/to/output` に出力する場合は、次のように実行します。
85+
86+
```bash
87+
$ poetry run python ./trim.py /path/to/input /path/to/output
88+
```
89+
90+
入力パスと出力パスの指定方法は、[画像背景の除去](./README.md#画像背景の除去)と同様です。

pre_process/extract.py

Lines changed: 21 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,68 +4,56 @@
44
from rembg import new_session, remove
55
from PIL import Image
66

7+
78
def parse_args():
89
p = argparse.ArgumentParser(
910
description="Extract the foreground from an image and save it to a new file. Accepts a file or a directory (processes recursively)."
1011
)
1112
p.add_argument(
12-
"input_file",
13-
type=Path,
14-
help="Path to the input image file or directory."
15-
)
16-
p.add_argument(
17-
"output_dir",
18-
type=Path,
19-
help="Path to the output directory."
13+
"input_file", type=Path, help="Path to the input image file or directory."
2014
)
15+
p.add_argument("output_dir", type=Path, help="Path to the output directory.")
2116
p.add_argument(
2217
"--model",
2318
default="isnet-anime",
24-
help="Model to use for foreground extraction (default: isnet-anime). See rembg documentation for available models. (https://github.com/danielgatis/rembg?tab=readme-ov-file#models)"
19+
help="Model to use for foreground extraction (default: isnet-anime). See rembg documentation for available models. (https://github.com/danielgatis/rembg?tab=readme-ov-file#models)",
2520
)
2621
p.add_argument(
2722
"--angle",
2823
type=float,
2924
default=-90,
30-
help="Angle for image rotation (default: -90)."
25+
help="Angle for image rotation (default: -90).",
3126
)
3227
return p.parse_args()
3328

29+
3430
def extract_foreground(img_bytes, model):
3531
print(f"Extracting foreground using model: {model}")
3632
session = new_session(model)
3733
return remove(img_bytes, session=session)
3834

39-
def trim_and_rotate(image, angle, alpha_threshold=10):
35+
36+
def rotate(image, angle):
4037
img = Image.open(io.BytesIO(image)).convert("RGBA")
4138
rotated = img.rotate(angle, expand=True)
4239

43-
alpha = rotated.getchannel("A")
44-
mask = alpha.point(lambda a: 255 if a > alpha_threshold else 0)
45-
bbox = mask.getbbox()
46-
47-
if bbox is None:
48-
print("Warning: Could not detect non-transparent area. Cropping will not be applied.")
49-
cropped = rotated
50-
else:
51-
cropped = rotated.crop(bbox)
52-
print(f"Image Cropped and Rotated. Original size: {img.size}, New size: {cropped.size}")
53-
5440
with io.BytesIO() as buffer:
55-
cropped.save(buffer, format="PNG")
41+
rotated.save(buffer, format="PNG")
5642
return buffer.getvalue()
5743

44+
5845
def process_file(file, model, angle):
5946
try:
6047
img_bytes = file.read_bytes()
6148
foreground = extract_foreground(img_bytes, model=model)
62-
result = trim_and_rotate(foreground, angle=angle)
49+
result = rotate(foreground, angle=angle)
6350
print(f"Processed {file}: completed processing.")
6451
return result
6552
except Exception as e:
6653
print(f"Failed to process file {file}: {e}")
6754
return None
6855

56+
6957
def main():
7058
args = parse_args()
7159
input_path = args.input_file
@@ -77,13 +65,17 @@ def main():
7765

7866
if output_dir.exists():
7967
if not output_dir.is_dir():
80-
ans = input(f"{output_dir} exists and is not a directory. Do you want to continue? (y/n): ")
81-
if ans.lower() != 'n':
68+
ans = input(
69+
f"{output_dir} exists and is not a directory. Do you want to continue? (y/n): "
70+
)
71+
if ans.lower() != "n":
8272
exit("Aborted by user.")
8373
else:
8474
if any(output_dir.iterdir()):
85-
ans = input(f"{output_dir} is not empty. Do you want to continue? (y/n): ")
86-
if ans.lower() != 'n':
75+
ans = input(
76+
f"{output_dir} is not empty. Do you want to continue? (y/n): "
77+
)
78+
if ans.lower() != "n":
8779
exit("Aborted by user.")
8880

8981
output_dir.mkdir(parents=True, exist_ok=True)
@@ -117,5 +109,6 @@ def main():
117109

118110
print(f"\nProcessing completed. Success: {success_count}, Failed: {failed_count}")
119111

112+
120113
if __name__ == "__main__":
121114
main()

pre_process/poetry.lock

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pre_process/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ readme = "README.md"
99
requires-python = ">=3.13,<3.14"
1010
dependencies = [
1111
"rembg (>=2.0.65,<3.0.0)",
12-
"onnxruntime (>=1.21.1,<2.0.0)"
12+
"onnxruntime (>=1.21.1,<2.0.0)",
13+
"np (>=1.0.2,<2.0.0)"
1314
]
1415

1516

pre_process/trim.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#!/usr/bin/env python3
2+
import os
3+
import sys
4+
import argparse
5+
from pathlib import Path
6+
from PIL import Image
7+
import numpy as np
8+
9+
10+
def parse_args():
11+
p = argparse.ArgumentParser(
12+
description="Trim the foreground from images in a directory and save them to a new directory. Accepts a directory of images."
13+
)
14+
p.add_argument(
15+
"input_path", type=Path, help="Path to the input image file or directory."
16+
)
17+
p.add_argument("output_dir", type=Path, help="Path to the output directory.")
18+
return p.parse_args()
19+
20+
21+
def get_foreground_bbox(file, alpha_threshold=10):
22+
with Image.open(file) as image:
23+
image = image.convert("RGBA")
24+
np_image = np.array(image)
25+
alpha = np_image[..., 3]
26+
mask = alpha > alpha_threshold
27+
28+
coords = np.argwhere(mask)
29+
y0, x0 = coords.min(axis=0)
30+
y1, x1 = coords.max(axis=0)
31+
print(f"Foreground bounding box for {file}: ({x0}, {y0}, {x1}, {y1})")
32+
return (x0, y0, x1 + 1, y1 + 1)
33+
34+
35+
def calc_union_bbox(bboxes):
36+
x0 = min([bbox[0] for bbox in bboxes])
37+
y0 = min([bbox[1] for bbox in bboxes])
38+
x1 = max([bbox[2] for bbox in bboxes])
39+
y1 = max([bbox[3] for bbox in bboxes])
40+
return (x0, y0, x1, y1)
41+
42+
43+
def main():
44+
args = parse_args()
45+
input_path = args.input_path
46+
output_dir = args.output_dir
47+
48+
if not input_path.exists():
49+
print(f"Input {input_path} does not exist.")
50+
return
51+
52+
if output_dir.exists():
53+
if not output_dir.is_dir():
54+
ans = input(
55+
f"{output_dir} exists and is not a directory. Do you want to continue? (y/n): "
56+
)
57+
if ans.lower() != "n":
58+
exit("Aborted by user.")
59+
else:
60+
if any(output_dir.iterdir()):
61+
ans = input(
62+
f"{output_dir} is not empty. Do you want to continue? (y/n): "
63+
)
64+
if ans.lower() != "n":
65+
exit("Aborted by user.")
66+
67+
output_dir.mkdir(parents=True, exist_ok=True)
68+
69+
files_to_process = []
70+
if input_path.is_dir():
71+
files_to_process = [f for f in input_path.rglob("*") if f.is_file()]
72+
else:
73+
files_to_process = [input_path]
74+
75+
union_bbox = calc_union_bbox(
76+
[get_foreground_bbox(file) for file in files_to_process]
77+
)
78+
print(f"Union bounding box: {union_bbox}")
79+
80+
for file in files_to_process:
81+
with Image.open(file) as image:
82+
trimmed_image = image.crop(union_bbox)
83+
output_filename = f"{file.stem}_trimmed{file.suffix}"
84+
output_path = output_dir / output_filename
85+
trimmed_image.save(output_path)
86+
print(f"Trimmed image saved to {output_path}")
87+
88+
89+
if __name__ == "__main__":
90+
main()

0 commit comments

Comments
 (0)