first commit
This commit is contained in:
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Virtual env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Env / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Jupyter
|
||||||
|
.ipynb_checkpoints/
|
||||||
|
|
||||||
|
# ClearML / logs
|
||||||
|
.clearml/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Market-1501 Person Attribute Recognition
|
||||||
|
|
||||||
|
이미지 URL과 사람 영역(`xywh`)으로 나이, 성별, 의상 색상 등 Market-1501 속성을 추론합니다.
|
||||||
|
|
||||||
|
`person_attr.py`와 `net_last.pth`를 같은 디렉터리에 두고 실행하세요.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uv run python person_attr.py \
|
||||||
|
--image_url "https://example.com/image.jpg" \
|
||||||
|
--xywh "404,290,74,193"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--image_url`: 이미지 URL 또는 로컬 경로
|
||||||
|
- `--xywh`: 좌상단 기준 `x,y,w,h`
|
||||||
|
|
||||||
|
결과는 ClearML Task(`Person_Attribute_Recognition` / `person_attribute`)에 `final_result` artifact로 업로드됩니다.
|
||||||
Binary file not shown.
Binary file not shown.
+306
@@ -0,0 +1,306 @@
|
|||||||
|
"""Market-1501 Person Attribute Recognition — standalone ClearML Agent.
|
||||||
|
|
||||||
|
Deploy as two files in the same directory:
|
||||||
|
person_attribute.py
|
||||||
|
net_last.pth
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import torch
|
||||||
|
from clearml import Task
|
||||||
|
from PIL import Image
|
||||||
|
from torch import nn
|
||||||
|
from torch.nn import init
|
||||||
|
from torchvision import models
|
||||||
|
from torchvision import transforms as T
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Market-1501 label / attribute constants (order must match training)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
NUM_LABEL = 30
|
||||||
|
|
||||||
|
MARKET_LABELS = [
|
||||||
|
'young',
|
||||||
|
'teenager',
|
||||||
|
'adult',
|
||||||
|
'old',
|
||||||
|
'backpack',
|
||||||
|
'bag',
|
||||||
|
'handbag',
|
||||||
|
'clothes',
|
||||||
|
'down',
|
||||||
|
'up',
|
||||||
|
'hair',
|
||||||
|
'hat',
|
||||||
|
'gender',
|
||||||
|
'upblack',
|
||||||
|
'upwhite',
|
||||||
|
'upred',
|
||||||
|
'uppurple',
|
||||||
|
'upyellow',
|
||||||
|
'upgray',
|
||||||
|
'upblue',
|
||||||
|
'upgreen',
|
||||||
|
'downblack',
|
||||||
|
'downwhite',
|
||||||
|
'downpink',
|
||||||
|
'downpurple',
|
||||||
|
'downyellow',
|
||||||
|
'downgray',
|
||||||
|
'downblue',
|
||||||
|
'downgreen',
|
||||||
|
'downbrown',
|
||||||
|
]
|
||||||
|
|
||||||
|
MARKET_ATTRIBUTES = {
|
||||||
|
'bag': ['carrying bag', ['no', 'yes']],
|
||||||
|
'upred': ['color of upper-body clothing', [None, 'red']],
|
||||||
|
'upblue': ['color of upper-body clothing', [None, 'blue']],
|
||||||
|
'hat': ['wearing hat', ['no', 'yes']],
|
||||||
|
'downgreen': ['color of lower-body clothing', [None, 'green']],
|
||||||
|
'downbrown': ['color of lower-body clothing', [None, 'brown']],
|
||||||
|
'upyellow': ['color of upper-body clothing', [None, 'yellow']],
|
||||||
|
'up': ['sleeve length', ['long sleeve', 'short sleeve']],
|
||||||
|
'upgreen': ['color of upper-body clothing', [None, 'green']],
|
||||||
|
'handbag': ['carrying handbag', ['no', 'yes']],
|
||||||
|
'downgray': ['color of lower-body clothing', [None, 'gray']],
|
||||||
|
'clothes': ['type of lower-body clothing', ['dress', 'pants']],
|
||||||
|
'adult': ['age', [None, 'adult']],
|
||||||
|
'downblack': ['color of lower-body clothing', [None, 'black']],
|
||||||
|
'backpack': ['carrying backpack', ['no', 'yes']],
|
||||||
|
'downwhite': ['color of lower-body clothing', [None, 'white']],
|
||||||
|
'upblack': ['color of upper-body clothing', [None, 'black']],
|
||||||
|
'gender': ['gender', ['male', 'female']],
|
||||||
|
'downyellow': ['color of lower-body clothing', [None, 'yellow']],
|
||||||
|
'downpink': ['color of lower-body clothing', [None, 'pink']],
|
||||||
|
'old': ['age', [None, 'old']],
|
||||||
|
'down': ['length of lower-body clothing', ['long lower body clothing', 'short']],
|
||||||
|
'uppurple': ['color of upper-body clothing', [None, 'purple']],
|
||||||
|
'downpurple': ['color of lower-body clothing', [None, 'purple']],
|
||||||
|
'young': ['age', [None, 'young']],
|
||||||
|
'teenager': ['age', [None, 'teenager']],
|
||||||
|
'hair': ['hair length', ['short hair', 'long hair']],
|
||||||
|
'downblue': ['color of lower-body clothing', [None, 'blue']],
|
||||||
|
'upgray': ['color of upper-body clothing', [None, 'gray']],
|
||||||
|
'upwhite': ['color of upper-body clothing', [None, 'white']],
|
||||||
|
}
|
||||||
|
|
||||||
|
TRANSFORMS = T.Compose([
|
||||||
|
T.Resize(size=(288, 144)),
|
||||||
|
T.ToTensor(),
|
||||||
|
T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
|
||||||
|
])
|
||||||
|
|
||||||
|
PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DEFAULT_CHECKPOINT = os.path.join(PACKAGE_DIR, 'net_last.pth')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Model (inference only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _weights_init_kaiming(m):
|
||||||
|
classname = m.__class__.__name__
|
||||||
|
if classname.find('Conv') != -1:
|
||||||
|
init.kaiming_normal_(m.weight.data, a=0, mode='fan_in')
|
||||||
|
elif classname.find('Linear') != -1:
|
||||||
|
init.kaiming_normal_(m.weight.data, a=0, mode='fan_out')
|
||||||
|
init.constant_(m.bias.data, 0.0)
|
||||||
|
elif classname.find('BatchNorm1d') != -1:
|
||||||
|
init.normal_(m.weight.data, 1.0, 0.02)
|
||||||
|
init.constant_(m.bias.data, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _weights_init_classifier(m):
|
||||||
|
classname = m.__class__.__name__
|
||||||
|
if classname.find('Linear') != -1:
|
||||||
|
init.normal_(m.weight.data, std=0.001)
|
||||||
|
init.constant_(m.bias.data, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class ClassBlock(nn.Module):
|
||||||
|
def __init__(self, input_dim, class_num=1, activ='sigmoid', num_bottleneck=512):
|
||||||
|
super().__init__()
|
||||||
|
add_block = nn.Sequential(
|
||||||
|
nn.Linear(input_dim, num_bottleneck),
|
||||||
|
nn.BatchNorm1d(num_bottleneck),
|
||||||
|
nn.LeakyReLU(0.1),
|
||||||
|
nn.Dropout(p=0.5),
|
||||||
|
)
|
||||||
|
add_block.apply(_weights_init_kaiming)
|
||||||
|
|
||||||
|
classifier_layers = [nn.Linear(num_bottleneck, class_num)]
|
||||||
|
if activ == 'sigmoid':
|
||||||
|
classifier_layers.append(nn.Sigmoid())
|
||||||
|
elif activ == 'softmax':
|
||||||
|
classifier_layers.append(nn.Softmax())
|
||||||
|
elif activ != 'none':
|
||||||
|
raise AssertionError(f'Unsupported activation: {activ}')
|
||||||
|
classifier = nn.Sequential(*classifier_layers)
|
||||||
|
classifier.apply(_weights_init_classifier)
|
||||||
|
|
||||||
|
self.add_block = add_block
|
||||||
|
self.classifier = classifier
|
||||||
|
|
||||||
|
def forward(self, x):
|
||||||
|
x = self.add_block(x)
|
||||||
|
return self.classifier(x)
|
||||||
|
|
||||||
|
|
||||||
|
class PersonAttributeModel(nn.Module):
|
||||||
|
"""ResNet50 + nFC, 30 Market-1501 attribute heads."""
|
||||||
|
|
||||||
|
def __init__(self, class_num: int = NUM_LABEL):
|
||||||
|
super().__init__()
|
||||||
|
self.class_num = class_num
|
||||||
|
backbone = models.resnet50(weights=None)
|
||||||
|
backbone.avgpool = nn.AdaptiveAvgPool2d((1, 1))
|
||||||
|
backbone.fc = nn.Sequential()
|
||||||
|
self.features = backbone
|
||||||
|
self.num_ftrs = 2048
|
||||||
|
for c in range(self.class_num):
|
||||||
|
self.__setattr__(
|
||||||
|
f'class_{c}',
|
||||||
|
ClassBlock(input_dim=self.num_ftrs, class_num=1, activ='sigmoid'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def forward(self, x):
|
||||||
|
x = self.features(x)
|
||||||
|
x = x.view(x.size(0), -1)
|
||||||
|
pred_label = [self.__getattr__(f'class_{c}')(x) for c in range(self.class_num)]
|
||||||
|
return torch.cat(pred_label, dim=1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Processor
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PersonAttribute:
|
||||||
|
"""image_url + xywh(top-left) → Market person attributes."""
|
||||||
|
|
||||||
|
def __init__(self, checkpoint_path: str | None = None, device: str | None = None):
|
||||||
|
self.checkpoint_path = checkpoint_path or DEFAULT_CHECKPOINT
|
||||||
|
self.device = torch.device(
|
||||||
|
device if device else ('cuda' if torch.cuda.is_available() else 'cpu')
|
||||||
|
)
|
||||||
|
self.model = self.load_model()
|
||||||
|
|
||||||
|
def load_model(self) -> PersonAttributeModel:
|
||||||
|
if not os.path.isfile(self.checkpoint_path):
|
||||||
|
raise FileNotFoundError(f'Checkpoint not found: {self.checkpoint_path}')
|
||||||
|
model = PersonAttributeModel(NUM_LABEL)
|
||||||
|
state = torch.load(self.checkpoint_path, map_location=self.device)
|
||||||
|
model.load_state_dict(state)
|
||||||
|
model.to(self.device)
|
||||||
|
model.eval()
|
||||||
|
return model
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_xywh(xywh: str) -> tuple[int, int, int, int]:
|
||||||
|
parts = str(xywh).split(',')
|
||||||
|
if len(parts) != 4:
|
||||||
|
raise ValueError(f"xywh must be 'x,y,w,h', got: {xywh}")
|
||||||
|
x, y, w, h = (int(v.strip()) for v in parts)
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
raise ValueError(f'xywh width/height must be positive, got: {xywh}')
|
||||||
|
return x, y, w, h
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load_image(image_source: str) -> Image.Image:
|
||||||
|
if image_source.startswith(('http://', 'https://')):
|
||||||
|
response = requests.get(image_source, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
return Image.open(BytesIO(response.content)).convert('RGB')
|
||||||
|
if not os.path.isfile(image_source):
|
||||||
|
raise FileNotFoundError(f'Image not found: {image_source}')
|
||||||
|
return Image.open(image_source).convert('RGB')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def crop_person(image: Image.Image, xywh: tuple[int, int, int, int]) -> Image.Image:
|
||||||
|
# Top-left based xywh (same as existing pipeline / start.py)
|
||||||
|
x, y, w, h = xywh
|
||||||
|
return image.crop((x, y, x + w, y + h))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def preprocess(image: Image.Image) -> torch.Tensor:
|
||||||
|
return TRANSFORMS(image).unsqueeze(dim=0)
|
||||||
|
|
||||||
|
def decode(self, pred: torch.Tensor) -> dict[str, str]:
|
||||||
|
pred = pred.squeeze(dim=0)
|
||||||
|
results: dict[str, str] = {}
|
||||||
|
for idx, label_key in enumerate(MARKET_LABELS):
|
||||||
|
name, choice = MARKET_ATTRIBUTES[label_key]
|
||||||
|
value = choice[int(pred[idx].item())]
|
||||||
|
if value:
|
||||||
|
results[name] = value
|
||||||
|
return results
|
||||||
|
|
||||||
|
def predict(self, image: Image.Image) -> dict[str, str]:
|
||||||
|
src = self.preprocess(image).to(self.device)
|
||||||
|
with torch.no_grad():
|
||||||
|
out = self.model.forward(src)
|
||||||
|
pred = torch.gt(out, torch.ones_like(out) / 2)
|
||||||
|
return self.decode(pred)
|
||||||
|
|
||||||
|
def process(self, image_url: str, xywh: str) -> dict[str, str]:
|
||||||
|
if not image_url or not str(image_url).strip():
|
||||||
|
raise ValueError('image_url is required')
|
||||||
|
box = self.parse_xywh(xywh)
|
||||||
|
image = self.load_image(str(image_url).strip())
|
||||||
|
crop = self.crop_person(image, box)
|
||||||
|
return self.predict(crop)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_result(attributes: dict[str, Any], status: str = 'PASS') -> dict[str, Any]:
|
||||||
|
return {'output': attributes, 'status': status}
|
||||||
|
|
||||||
|
def run(self, image_url: str, xywh: str) -> dict[str, Any]:
|
||||||
|
attributes = self.process(image_url, xywh)
|
||||||
|
return self.format_result(attributes)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ClearML entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def upload_final_result(task: Task, result: dict) -> None:
|
||||||
|
task.upload_artifact(name='final_result', artifact_object=result)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
# uv run python person_attr.py --image_url "https://acai.ketidev.kr:20443/detect/image/202606/20260619_145116_image.jpg" --xywh "404,290,74,193"
|
||||||
|
parser = argparse.ArgumentParser(description='Person Attribute Recognition (Market)')
|
||||||
|
parser.add_argument('--image_url', type=str, required=True)
|
||||||
|
parser.add_argument('--xywh', type=str, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
task = Task.init(
|
||||||
|
project_name='Person_Attribute_Recognition',
|
||||||
|
task_name='person_attribute',
|
||||||
|
)
|
||||||
|
task.connect(parser)
|
||||||
|
|
||||||
|
engine = PersonAttribute()
|
||||||
|
attributes = engine.process(args.image_url, args.xywh)
|
||||||
|
result = PersonAttribute.format_result(attributes)
|
||||||
|
|
||||||
|
print('\n' + '=' * 50)
|
||||||
|
print(' Person Attribute Recognition 결과 ')
|
||||||
|
print('=' * 50)
|
||||||
|
for name, value in attributes.items():
|
||||||
|
print(f'{name}: {value}')
|
||||||
|
print('=' * 50)
|
||||||
|
|
||||||
|
upload_final_result(task, result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
torch
|
||||||
|
torchvision
|
||||||
|
Pillow
|
||||||
|
requests
|
||||||
|
clearml
|
||||||
Reference in New Issue
Block a user