전후 비교의 짝을 아직 확인하지 못했다고 해서 좋은 시공후 사진까지 모두 감출 필요는 없다. 반대로 시공후 사진을 공개할 수 있다는 이유로 그 앞에 임의의 시공전 사진을 붙여도 안 된다. 나는 두 선택을 따로 계산하는 작은 모듈을 만들었다. 사진을 보거나 보정하는 프로그램이 아니라 이미 검수한 메타데이터의 공개 목록을 만드는 예제다.

선별한 원본과 공개할 목록을 구별한다

입력 Photo에는 합성 사진 ID, 현장·물리 공간·방 분류, 단계, 표시 크기와 검수·보류 표시가 있다. Pair에는 연결할 두 ID와 동일 구도 검수 표시를 둔다. 원본 사진 파일이나 고객 이름, 실제 현장 자료는 패키지에 넣지 않았다. 입력 사진 목록은 작업 재료이고 public_photo_ids는 이번 조건에서 공개에 쓸 수 있는 ID 목록이다.

공유 예제는 reviewed가 정확히 참인 입력만 사용하도록 범위를 좁혔다. 원래 프로그램의 모든 검수 상태를 그대로 이식한 것은 아니다. 실서비스에 붙일 때는 선별·검수 완료·개별 보류라는 서로 다른 기록을 어떤 입력에 연결할지 정해야 한다. 검수 표시를 참으로 썼다는 사실 자체가 실제 사진 검수의 증거가 되지는 않는다.

일반 갤러리는 편집 순서를, 비교는 검수한 관계를 따른다

gallery_order는 일반 갤러리에 넣기로 한 사진의 순서다. 그중 대상 현장에 속하고 검수됐으며 보류가 없는 시공후만 gallery_ids에 남는다. 시공후라는 이유로 모든 입력을 자동으로 갤러리에 넣지는 않는다. 전후 비교에만 쓰기로 한 시공후 사진은 gallery_order에 넣지 않으면 비교 안에서만 보일 수 있다.

비교 후보는 같은 현장, 같은 물리 공간 ID, 일치하는 방 분류, 시공전→시공후 순서, 같은 표시 방향을 요구한다. 여기에 same_view_reviewed가 참이어야 한다. 방이라는 넓은 이름이나 같은 가로 사진이라는 조건만으로 동일 공간·방향을 증명할 수 없어서다. 누락된 상대 사진이 있으면 해당 비교는 제외하고 독립적으로 가능한 갤러리는 남긴다.

보류는 대표 사진과 비교 양쪽에도 적용한다

한쪽 사진에 보류가 있으면 그 비교는 나오지 않는다. 시공후가 보류라면 갤러리와 대표 사진의 후보에서도 빠진다. preferred_cover가 사용할 수 없는 사진이면 남은 gallery_ids에서 대체한다. 갤러리에 없는 비교 전용 사진을 대표로 승격시키지는 않는다. 아무것도 공개할 수 없을 때도 현장 ID와 사진 준비 상태를 결과에 남긴다.

public_photo_ids는 갤러리와 비교가 쓰는 ID의 중복 없는 합집합이다. 페이지의 확대보기나 미리 읽기 자료를 만들 때도 전체 입력 목록 대신 이 결과를 사용해야 한다. 다만 ID 목록 계산은 파일 접근 제어와 다르다. 실제 파일 주소의 접근 차단이나 이미 배포된 캐시 회수는 이 예제가 처리하지 않으며 별도 서비스 정책이 필요하다.

코드와 구현 살펴보기

독립 합성 전체 구현

"""Pure synthetic metadata projection, not visual review or media authorization."""
from dataclasses import dataclass


@dataclass(frozen=True)
class Photo:
    id: str
    case_id: str
    space_id: str
    room: str
    phase: str
    display_width: int
    display_height: int
    reviewed: bool = False
    held: bool = False


@dataclass(frozen=True)
class Pair:
    id: str
    before: str
    after: str
    room: str
    same_view_reviewed: bool = False
    priority: int = 10


def _ids(values):
    if any(not isinstance(v, str) or not v.strip() for v in values):
        raise ValueError('Identifiers must be nonempty strings')
    if len(set(values)) != len(values):
        raise ValueError('Duplicate identifier')


def _orientation(photo):
    return (photo.display_width > photo.display_height) - (photo.display_width < photo.display_height)


def project(*, case_id, photos, pairs, gallery_order, preferred_cover=None):
    """Project already-reviewed metadata. Never infer scene identity from pixels.

    Gallery order is an explicit editorial input. A comparison-only after image
    does not become a gallery image merely because it is used in a valid pair.
    Width/height must describe the upright displayed frame, not raw EXIF data.
    """
    if not isinstance(case_id, str) or not case_id.strip():
        raise ValueError('A target case is required')
    photos, pairs, gallery_order = tuple(photos), tuple(pairs), tuple(gallery_order)
    if not all(isinstance(p, Photo) for p in photos) or not all(isinstance(p, Pair) for p in pairs):
        raise ValueError('Expected Photo and Pair records')
    _ids([p.id for p in photos]); _ids([p.id for p in pairs]); _ids(gallery_order)
    lookup = {p.id: p for p in photos}
    if any(k not in lookup for k in gallery_order):
        raise ValueError('Gallery references a missing photo')
    for p in photos:
        if p.phase not in ('before', 'after'):
            raise ValueError('Unknown construction phase')
        if type(p.reviewed) is not bool or type(p.held) is not bool:
            raise ValueError('Review and hold flags must be booleans')
        if any(type(v) is not int or v <= 0 for v in (p.display_width, p.display_height)):
            raise ValueError('Displayed dimensions must be positive integers')
        if any(not isinstance(v, str) for v in (p.case_id, p.space_id, p.room)):
            raise ValueError('Photo context must use string identifiers')
    for p in pairs:
        if type(p.same_view_reviewed) is not bool or type(p.priority) is not int:
            raise ValueError('Invalid pair review or priority')
        if not all(isinstance(v, str) for v in (p.before, p.after, p.room)):
            raise ValueError('Invalid pair references')
    def usable(p):
        return p.reviewed and not p.held and p.case_id == case_id
    gallery = [k for k in gallery_order if usable(lookup[k]) and lookup[k].phase == 'after']
    accepted = []
    for pair in sorted(pairs, key=lambda p: (p.priority, p.id)):
        before, after = lookup.get(pair.before), lookup.get(pair.after)
        if not pair.same_view_reviewed or before is None or after is None:
            continue
        if not usable(before) or not usable(after):
            continue
        if (before.phase, after.phase) != ('before', 'after'):
            continue
        if not before.space_id or before.space_id != after.space_id:
            continue
        if not pair.room or not before.room or not (pair.room == before.room == after.room):
            continue
        if _orientation(before) != _orientation(after):
            continue
        if any(p.room == pair.room for p in accepted):
            continue
        accepted.append(pair)
    compare = list(dict.fromkeys(k for p in accepted for k in (p.before, p.after)))
    public = list(dict.fromkeys(gallery + compare))
    cover = preferred_cover if preferred_cover in gallery else next(iter(gallery), None)
    return {'case_id': case_id, 'gallery_ids': gallery, 'pair_ids': [p.id for p in accepted],
            'comparison_ids': compare, 'public_photo_ids': public, 'cover_id': cover,
            'empty_state': 'after_photos_pending' if not public else None}

한 방에 여러 쌍이 있어도 선택 이유가 남게 한다

여러 후보가 있으면 우선순위가 높은 유효 쌍 하나를 방 분류별로 고른다. 먼저 골랐던 후보에 보류가 생기면 다음 유효 후보를 쓸 수 있다. 동점일 때는 ID 순서로 정해 반복 실행의 결과가 흔들리지 않게 했다. 이 동점 규칙은 새 예제의 선택이며 원제품의 정렬 규칙을 그대로 복제했다고 주장하지 않는다.

시험은 서로 다른 현장과 실제 방, 다른 단계 순서, 표시 방향 불일치, 검수되지 않은 쌍과 보류된 사진을 다룬다. 비교 전용 시공후, 시공전만 있는 현장, 대표 대체, 중복 ID와 잘못된 크기·표시도 확인한다. 입력을 고치지 않고 같은 결과를 다시 만들 수 있는지까지 19개 시험 메서드로 검사한다.

선택기로 읽는 것은 합성 실행 결과다

아래에는 같은 합성 자료에서 조건을 바꿔 실행한 여섯 기록을 넣었다. 검수한 쌍, 미검수 쌍, 다른 물리 공간, 시공전 보류, 시공전만 있는 현장, 비교 전용 시공후를 차례로 볼 수 있다. 비교가 사라져도 gallery_ids에 시공후가 남는 경우를 직접 대조할 수 있다.

선택기는 저장한 Python 결과를 보여 줄 뿐 방문자의 사진을 읽거나 검수 표시를 바꾸지 않는다. 초기 표시와 여섯 선택은 한국어·영어 인라인 스크립트를 DOM 대역으로 실행해 확인한다. 실제 브라우저의 사진 표시나 키보드·모바일 동작을 검수했다는 뜻은 아니다. 기록에 이미지가 없는 것도 이 예제가 메타데이터 계약을 설명하기 때문이다.

합성 로컬 기록의 읽기 뷰어

다른 사이트에 붙일 때 바꿀 것과 남길 것

ZIP을 풀고 표준 라이브러리 unittest와 run_records.py를 실행하면 코드와 기록을 다시 확인할 수 있다. 현장·공간의 식별자, 방 분류, 우선순위, 갤러리 순서는 사이트의 자료 구성에 맞춰 바꾼다. 사진의 단계나 동일 구도를 자동으로 추정하는 기능을 이 함수 앞에 몰래 추가하면 검수했다는 입력의 의미부터 다시 정해야 한다.

원본 목록과 공개 투영을 분리하고 보류를 모든 공개 경로에 적용한다는 계약은 남겨 두고 싶다. 기존 근거 표현 모듈은 자산과 설명의 연결에 재사용할 수 있지만 이 목록 계산을 대신하지 않는다. 사용법·시험·실행 기록·MIT 라이선스를 함께 공개하며 압축을 푼 별도 폴더에서도 같은 시험을 다시 실행한다.

코드·시험·실행 기록 ZIP 받기
Python 표준 라이브러리 · 합성 자료 · 수정과 재사용 가능