---
id: ar-photo-booth
type: project
doc: portfolio
url: https://auejin.com/en/projects/ar-photo-booth/portfolio/
lang: en
---

# AR Photo Booth — Real-Time AI Compositing Kiosk

**2024–2026 · Seven furry conventions in South Korea · Product, engineering, and operations, solo**

Repository: https://github.com/auejin/mirror-selfie
한국어판: the full portfolio

---

## Contents

1. [Executive Summary](#1-executive-summary)
2. [Problem Definition](#2-problem-definition)
3. [System Architecture](#3-system-architecture)
4. [Technical Highlights](#4-technical-highlights)
5. [Product Iteration](#5-product-iteration)
6. [Field Operations and Running a Brand](#6-field-operations-and-running-a-brand)
7. [Quantified Impact](#7-quantified-impact)
8. [Incidents and Structural Fixes](#8-incidents-and-structural-fixes)
9. [Limitations and Roadmap](#9-limitations-and-roadmap)
10. [Appendix — Tech Stack](#10-appendix--tech-stack)

---

## Evidence grades

Every figure in this document carries a grade.

| Grade | Meaning |
|---|---|
| `[Verified]` | Derived directly from code, files, or git. A reproduction command exists |
| `[Ledger]` | Measured and recorded by the operator in the event expense ledger. Not third-party reproducible |
| `[Git history]` | Inferred from commit history |

Nothing was omitted for having a weak grade, and nothing is presented as stronger than it is.

> Currency conversions throughout use **₩1,400 ≈ $1**, rounded. They exist to give
> non-Korean readers a sense of scale, not as precise figures.

---

## 1. Executive Summary

**AR Photo Booth** is an unattended kiosk that separates a person in a full-body costume
from the background using a single webcam, composites them between illustrated character
layers, and prints the result immediately. The entire pipeline runs on-device and requires
no network connection.

Between August 2024 and April 2026 it captured **703 photos across seven conventions**
`[Ledger]`. For the two 2026 events the application auto-saved the source frame, the mask,
and the composite, so **141 complete sets survive** `[Verified]`. The longest single-day
run was **7 hours 53 minutes**, with a median interval between captures of
**2 minutes 13 seconds** `[Verified]`.

What this document is actually about is not model accuracy — it is **design decisions under
constraints**. The constraint that participants cannot operate a screen produced the
automatic prompt-generation architecture. The constraint that convention halls have no
internet produced the on-device architecture. And that architecture turned out to coincide
exactly with the privacy policy the project needed.

---

## 2. Problem Definition

### 2.1 Domain context

> **On furry fandom, fursuits, and conventions**
>
> Furry fandom is a creative and costuming community built around anthropomorphic animal
> characters. A *fursuit* is a full-body costume realizing such a character, typically
> consisting of a sculpted head covering the entire face and a body made of synthetic fur.
> South Korea hosts several conventions a year, and a substantial share of attendees
> participate in fursuit.
>
> This domain matters here for a **technical** reason rather than a cultural one:
> a fursuit is a naturally occurring adversarial sample for human matting models.

### 2.2 Why I built it

Convention attendees want photographs of their fursuits, but professional shoots involve
booking, waiting, and post-production lead time. Walking away with the result in hand is a
categorically different experience.

Commercial photo booths satisfy half of this. They handle capture and instant printing, but
they cannot **composite a background and props matched to the participant's own character**.
Chroma-key booths can composite, but they demand floor space, setup time, and lighting
control — and a convention booth controls none of those.

### 2.3 What made it hard

**(a) Fursuits break general-purpose human segmentation**

Nearly every cue such models rely on is invalidated:

| What the model expects | What a fursuit actually presents |
|---|---|
| A silhouette matching human body statistics | Muzzle, ears, and tail push the outline outside that distribution |
| Crisp skin/clothing boundaries | Synthetic fur edges are translucent at the pixel level |
| Matte surfaces | Glossy fur reflects the background |
| Face and skin-tone cues | None — the wearer is fully covered |
| Consistent lighting | Convention halls have mixed lighting |

**(b) There is nobody available to operate it**

Participants wearing sculpted heads have restricted vision and limited hand dexterity. The
operator is attending to the next person in line. In other words, **no human can intervene
anywhere in the pipeline.**

**(c) The field violates every assumption of the development environment**

No internet. Multiple cameras enumerated. A window accidentally closed. A printer jam.
And all of it happens **while people are standing in line watching.**

**(d) A quality problem with no ground truth**

Whether a composite looks natural is not measurable by any automatic metric. High IoU means
nothing if the tip of an ear is clipped — the participant will notice. The evaluation
function is a human being, and nothing else.

---

## 3. System Architecture

### 3.1 Overall structure

```mermaid
graph TB
    subgraph Entry["Entry point"]
        Main["main.py · Application<br/>tkinter + cv2 dual loop"]
    end
    subgraph Cam["Camera layer"]
        ICamera["ICamera (ABC)"] --> WC["Webcam<br/>4K · per-OS backend"]
    end
    subgraph AI["AI pipeline"]
        DEPTH["DepthEstimator<br/>Depth Anything V2 Small"]
        SEG["SAM2ImageProcessor<br/>SAM 2.1 Tiny"]
        DEPTH -->|"rough mask + smart points"| SEG
    end
    subgraph Comp["Compositing layer"]
        MIRROR["MirrorCompositor<br/>layer compositing + color harmonization"]
        CV["OpenCVUtils<br/>UMat/numpy abstraction"]
        MIRROR --> CV
    end
    PRINTER["BasePrinter<br/>CUPS / Win32"]

    Main --> ICamera
    Main --> DEPTH
    Main --> SEG
    Main --> MIRROR
    Main --> PRINTER
```

### 3.2 Segmentation pipeline — depth generates the prompt

```mermaid
flowchart LR
    A["RGB frame<br/>4K"] --> B["downscale to 1024px"]
    B --> C["Depth Anything V2<br/>depth estimation"]
    C --> D["suppress_depth_gradient<br/>remove floor/wall low-frequency"]
    D --> E["get_rough_mask<br/>depth 0.7 + center prior 0.3<br/>→ Otsu → connected component"]
    E --> F["FPS sampling<br/>FG/BG points"]
    E --> G["rough mask"]
    F --> H["SAM 2.1<br/>hybrid prompt"]
    G --> H
    H --> I["union"]
    G --> I
    I --> J["depth-gradient<br/>edge refinement"]
    J --> K["mask upscaled to 4K"]
```

The key idea is that **depth produces the prompt SAM 2 requires.** SAM 2 is promptable, but
an unattended booth has nobody to supply the prompt.

### 3.3 Threading model — separating preview from capture

```mermaid
sequenceDiagram
    participant T0 as Main thread
    participant T3 as Detection thread
    participant D as Display / disk

    loop every frame
        T0->>T0: get_frame()
        T0->>T0: consume _pending_result (lock)
        alt previous detection finished
            T0->>T3: start _run_detection_async
            T3->>T3: depth → points → SAM2 → refine
            T3->>T0: store _pending_result (lock)
        end
        T0->>D: composite with cached mask → imshow
    end

    Note over T0,D: shutter pressed
    T0->>T3: join(timeout=5s)
    T0->>T0: synchronous re-inference on current frame
    T0->>D: save pixel-accurate composite + print
```

The preview path and the capture path are separate. The preview mask may be up to one frame
stale, and that is **a cost accepted deliberately** — while someone is settling into a pose,
the discrepancy is not perceptible.

### 3.4 Layer compositing

```
Background (background.png)
  └ Rear character layer      ← illustration behind the subject
      └ Person cutout          ← participant, extracted by mask (color-harmonized)
          └ Shared stickers
              └ Front character layer  ← illustration occluding the subject
```

This structure only works if each character asset is **drawn as two separate images, front
and rear**. The software architecture dictates how the illustration is produced, and the
composition of the illustration in turn dictates a segmentation parameter (`center_ratio`).
Section 5 returns to this.

---

## 4. Technical Highlights

### 4.1 Automatic prompt generation — taking the human out of the loop

**The problem.** SAM 2 accepts point, box, or mask prompts to segment arbitrary objects. Not
needing per-class training is the advantage; the flip side is that **someone has to supply
the prompt**. In an unattended booth, that someone does not exist.

**The approach.** Monocular depth estimation became the prompt generator.

1. Estimate a depth map with Depth Anything V2 Small
2. `suppress_depth_gradient()` — remove the slowly varying low-frequency component
   contributed by floors and walls: extract it via Gaussian blur, subtract, renormalize to
   the [2, 98] percentile range
3. `get_rough_mask()` — Otsu-threshold a blended score of depth × 0.7 + center Gaussian
   prior × 0.3, clean up morphologically, keep **only the center connected component**
   (dropping floor and wall), then expand using LAB color distance
4. Sample foreground points inside the rough mask with **farthest point sampling**, and fix
   eight image-border points as background
5. Feed the rough mask (as logits) plus the points to SAM 2.1 as a **hybrid prompt**

**Trade-off.** Adding a model increased latency. In exchange, the system adapts to variation
in height, pose, and costume type with no human involvement. Section 4.2 absorbs the added
latency.

> A fixed-coordinate prompt was the cheaper option. I rejected it because of the variance in
> participant height and the freedom of pose. The value of the booth lies in participants
> striking the pose they want, and a fixed prompt converts that freedom directly into a
> failure rate.

*Evidence: `utils/depth.py`, `utils/segment.py`, `main.py:_run_auto_detect`,
commits `b7d9114`, `06c7856`, `75546e4`*

### 4.2 Asynchronous double buffering — accuracy only when it counts

**The problem.** Running depth + SAM2 + refinement synchronously on every frame stalls the
preview. Slowing the inference cadence instead means the mask at the moment of capture
belongs to **the previous pose**, and arms or ears get clipped in the output. Two
requirements with different time scales were sharing one path.

**The approach.** Split the paths.

- Main loop: composite immediately from the cached mask and `imshow`. If the detection
  thread is idle, dispatch new work; consume completed results under a lock.
- On capture: `_flush_detection()` joins any in-flight thread (5 s timeout), consumes the
  pending result, then **runs one more synchronous inference pass on the current frame** and
  re-composites with that mask before saving.

**Trade-off.** The preview mask lags by one frame. That is imperceptible while someone is
posing, and the accuracy cost is paid only at the decisive moment.

**Second-order effect.** Growing to six background threads (segmenter load, depth load,
detection, asset load, file write, print) made shared-state protection a real concern. In
particular, the `_smart_points` / `_rough_mask` caches are protected **not by a lock but by
structural serialization** — they are read and written only within the same thread, and
`_flush_detection` runs only after a join. That reasoning cannot be reconstructed from the
code alone, so it is written down in `.agent/knowledge/threading-model.md`.

*Evidence: `main.py:253-357`, `.agent/knowledge/threading-model.md`, commits `6837615`, `3bf6ddf`*

### 4.3 Decoupled resolution — print quality without losing real time

Printing needs 4K, but inference at 4K is not real-time. Naively upscaling a low-resolution
mask produces staircase artifacts along the boundary that are **immediately visible in
print**.

The mask alone is computed at 1024px on the long axis while the pixels stay at 4K. The
staircase artifacts are handled by the next section.

*Evidence: `main.py:33, 195-211`, commit `026c54d`*

### 4.4 Edge refinement — reusing depth as a discriminator

**The problem.** Applying a uniform blur to remove staircase artifacts also destroys real
detail — ear tips, fingers, tail. Not applying it leaves the artifacts. **From the mask
alone there is no way to tell which pixels are true boundaries and which are upscaling
artifacts.**

**The approach.** Reuse the depth map already computed upstream as the discriminator.

1. Apply Sobel to the depth map, take gradient magnitude, normalize to 0–1
   (higher = more likely a true boundary)
2. Prepare a morphologically smoothed (Close/Open) version of the mask
3. Define a **boundary band** as the difference between dilate and erode, and blend only
   inside it: `original × edge_weight + smoothed × (1 − edge_weight)`
4. Binarize at the end to retain a crisp, vector-like edge

Where there is a genuine depth discontinuity the original edge survives; only the staircase
in flat regions is smoothed away. The point of the design is that it solves the problem by
recycling an existing computation rather than adding another model.

*Evidence: `main.py:374-425`, commit `7b5a2b7`*

### 4.5 Color harmonization — three generations, and a regression test for a problem with no metric

There is no ground truth here. It cannot be validated automatically, the hall lighting
differs every event, and synthetic fur has reflectance characteristics unlike human skin.

| Generation | Method | Date |
|---|---|---|
| 1st | Match statistics to the background in LAB color space | Oct 2025 |
| 2nd | Teal & orange cinematic LUT — per-channel tone curves interpolated from five anchor points | Apr 2, 2026 |
| 3rd | Dynamic luma matching | Apr 4, 2026 |

**What matters about the third generation** is that it does not shift brightness linearly.

- Target luminance = subject L mean × 0.4 + background L mean × 0.6, clipped to `[80, 180]`
- Gamma exponent = `log(target_normalized) / log(current_normalized)`, clipped to `[0.4, 2.5]`
- Apply that gamma to the L channel

A linear offset crushes shadows or blows out highlights. Gamma correction **shifts overall
luminance while preserving tonal gradation**. Targeting a 6:4 blend rather than matching the
background outright is an art-direction judgment — blend fully into the background and the
subject dies. The two clips are guardrails against divergence on extreme inputs.

**How it was validated.** `scripts/test_color_grading.py` renders a **four-way comparison —
original / 1st / 2nd / 3rd generation** — from the same input. It substitutes a human-eye
regression test for a metric that does not exist, making it possible to check whether a new
algorithm is worse than its predecessor under some particular lighting.

*Evidence: `utils/mirror.py:106-184`, `scripts/test_color_grading.py`,
commits `ba9489b` → `3e9355f` → `845d41e`*

### 4.6 Cross-platform acceleration

`cv2.UMat` does not work on macOS, and scalar-UMat operations (`1.0 - mask`) fail on
Windows. Scatter that asymmetry across a codebase and one platform silently breaks.

Every call routes through an `OpenCVUtils` static-method layer, and **direct `cv2.UMat()`
construction is codified as a prohibited pattern**. PyTorch device selection falls back
MPS → CUDA → CPU.

| | Windows | macOS |
|---|---|---|
| OpenCV acceleration | `cv2.UMat` (OpenCL) | `np.ndarray` |
| PyTorch | CUDA → CPU | MPS → CPU |
| Camera backend | `CAP_MSMF` | `CAP_AVFOUNDATION` |
| Printing | Win32 API | `lpr` (CUPS) |

*Evidence: `utils/opencv.py`, `.agent/knowledge/cross-platform-patterns.md`,
commits `b8be00e`, `62a6f4e`*

### 4.7 Externalizing tacit knowledge as machine-readable rules

With six threads sharing models, mask caches, and compositor layers, a judgment like "why
does this variable *not* need a lock?" (answer: same-thread serialization) is not
recoverable from the code. Not by me three months later, and not by an AI coding agent.

I wrote **1,371 lines** into an `.agent/` tree in the repository `[Verified]`:

| Directory | Contents | Lines |
|---|---|---|
| `knowledge/` | Architecture, AI pipeline, threading model, cross-platform patterns | 512 |
| `skills/` | SAM2 integration, thread safety, cross-platform OpenCV, compositor layers | 459 |
| `workflows/` | Environment setup, running, adding a character, adding a camera, refactoring | 400 |

The `threading-safety` skill is written in a **checkable** form rather than a descriptive
one: four explicit prohibitions and a five-item checklist for adding new features.

*Evidence: the `.agent/` tree, commits `05ce587`, `3bf6ddf`, `358ab59`*

---

## 5. Product Iteration

### 5.1 The event *was* the release cycle

Classifying all 61 commits by their distance to an event date `[Git history]`:

| Event | D-day | D-30 to D-1 | D-day to D+7 |
|---|---|---|---|
| FUR:RAID 2025 | 2025-10-12 | 26 | 0 |
| HowlSoup 2026 | 2026-01-24 | 16 | 1 |
| FurryJoA 2026 | 2026-02-21 | 22 | 3 |
| FurstClass 2026 | 2026-04-04 | 2 | 1 |

**All 61 of 61 commits fall inside a D-30 to D+7 window.**

> **The base rate, stated up front.** The repository spans 2025-09-21 to 2026-04-04, or 196
> days; the union of the four D-30/D+7 windows covers **126 days (64%)**. Commits scattered
> at random would land in that window 64% of the time. The observed 100% is a meaningful
> concentration, but quoted alone the number reads more dramatic than it is.
>
> Also, **only three of the event dates are firmly established** (FUR:RAID 2024 from a
> filename; FurryJoA and FurstClass 2026 from capture timestamps). The rest are inferred
> from archive file modification times and dated folder names. A ±3-day error does not flip
> the window classification, but the inference should be visible.

There is no such thing as "development during a quiet period" in this project. Every line of
code was tied to the deadline of the next event, and that was the prioritization mechanism.

> **Honestly, this is a risky way to work.** Nine commits landed on D-1 before HowlSoup
> 2026, and among them was an architectural swap from MODNet to SAM2. Had it failed, the
> booth would not have opened. Two things mitigated it: commits were split into
> individually revertible units, and a working previous version always remained. All seven
> events ran without incident — but I would not present this as good process.

### 5.2 What I chose *not* to build

Product judgment shows more clearly in what gets cut than in what gets added, and this
repository has the evidence.

**(a) I deleted the manual editing feature** — commit `d463b89`, "remove manual editing"

Early on the operator could correct the mask by clicking. Accuracy improves, but it creates
**operator time consumed per capture**, which directly reduces throughput in a booth with a
queue. Once automatic detection (`06c7856`) proved good enough, the feature was removed
entirely. Lowering the accuracy ceiling to buy throughput.

**(b) I added a depth camera and then removed it** — `e42dc1a` (Oct 2025) → `b0e64e7` (Feb 2026)

Covered in detail as CASE 2 in section 8.

**(c) I did not insist on full automation** — commit `9b7fe70`

Each character has a different composition, so the subject's center position differs too.
Estimating it automatically means solving another problem. Instead, `CHARACTER_CONFIG` holds
per-character `center_ratio_x/y` constants. A problem that terminates in 9 × 2 = 18 numbers
did not need an algorithm.

### 5.3 The path from feedback to code

The event archives retain **194 files of post-event feedback** `[Verified]`.

| Event | Feedback files |
|---|---|
| FUR:RAID 2024 | 36 |
| HowlSoup 2024 | 28 |
| FurryJoA 2025 | 97 |
| HowlSoup 2026 | 10 |
| FurryJoA 2026 | 23 |

Commits record where that feedback turned into code:

| Surfaced in the field | Resulting commit | Timing |
|---|---|---|
| The venue has no internet | `de9d569` offline mode | HowlSoup 2026 **D+0** |
| macOS picks up an iPhone as a camera | `b3d2b61` exclude Continuity Camera | FurryJoA 2026 **D+2** |
| Composites look detached under hall lighting | `845d41e` dynamic luma matching | FurstClass 2026 **D+0** |
| Crash while models load | `25f3b6a` loading bug fix | FurryJoA 2026 **D+0** |

**A commit timestamped on the event day** means the problem was found while running the
booth and fixed on the spot. These four are the most direct evidence of the field feedback
loop.

### 5.4 Supply followed demand

Classifying the HowlSoup 2024 captures by character `[Verified]`:

| Rank | Character | Captures | Share |
|---|---|---|---|
| 1 | A | 42 | 31.1% |
| 2 | B | 27 | 20.0% |
| 3 | C | 25 | 18.5% |
| 4 | D | 25 | 18.5% |
| 5 | E | 10 | 7.4% |
| 6 | F | 6 | 4.4% |

The top two account for 51%; the bottom two together reach 12%. This distribution is not a
capture log — it is **the product metric that decides which assets deserve more production
time.**

The roster subsequently grew from 6 to 9, and every addition landed immediately before a
specific event (`6443616` "new characters for howlsoup 2026"; `a812ad2`, one character
added at **D-2**).

### 5.5 The software constrains the artwork, and the artwork constrains the parameters

The layer structure in §3.4 only works if each character is **drawn as two separate images,
front and rear**. Once the composition is fixed, the segmentation `center_ratio` has to be
tuned to match it.

With a designer and an engineer as separate people, that round trip takes days. Working solo
made the round trip **zero**, which is why a new character could ship two days before an
event.

The cost is a real ceiling on quality. A professional illustrator would have produced better
assets; a dedicated engineer would have produced a better structure. Working solo was a
trade of **quality ceiling for iteration speed**.

---

## 6. Field Operations and Running a Brand

### 6.1 Seven events over three years, and the booth opened every time

| Event | Date | Captures `[Ledger]` |
|---|---|---|
| FUR:RAID | 2024-08 | 80 |
| HowlSoup | 2024-12 | 120 |
| FurryJoA | 2025-02 | 64 |
| FUR:RAID | 2025-10 | 200 |
| HowlSoup | 2026-01 | 100 |
| FurryJoA | 2026-02 | 64 |
| FurstClass | 2026-04 | 75 |
| **Total** | **7 events, 3 years** | **703** |

On growth: FUR:RAID went from 80 (Aug 2024) to 200 (Oct 2025), a 2.5× increase.
**I make no causal claim.** Event size, booth location, operating hours, and competing
booths were all uncontrolled. What is certain is seven consecutive returns with the booth
running every time.

### 6.2 Measured booth operations

For the two 2026 events the application auto-saved three files per capture, so operating
metrics come out directly `[Verified]`:

| Metric | FurryJoA 2026 | FurstClass 2026 |
|---|---|---|
| Captures | 63 | 75 |
| Operating window | 10:08 → 16:02 (**5h 53m**) | 08:29 → 16:22 (**7h 53m**) |
| Throughput | 10.7/hour | 9.5/hour |
| Inter-capture interval, p50 | **2m 13s** | **2m 31s** |
| Inter-capture interval, p90 | 16m 46s | 17m 25s |
| Peak hour | 15:00 (18) | 15:00 (22) |

> **What these numbers do and do not prove.** The operating window is the span between the
> first and last capture timestamp. It proves the booth kept taking photographs across that
> span; it **does not prove the program never restarted.** A crash and relaunch partway
> through would produce the same range. Read it as "operating window," not "uninterrupted."

**p50 of roughly 2.5 minutes** is the real per-group service time — greeting, character
selection, posing, capture, printing, handoff. When a queue exists, that is the system's
throughput ceiling.

The gap between p50 and p90 (2.5 min vs 17 min) is not instability but **variance in demand
across the day**. Both events are quiet right after opening and around lunch and peak at
15:00 — the same shape twice.

**For FurstClass, the ledger count of 75 and the auto-saved 75 sets match exactly**, which
is a cross-validation of the ledger's reliability.

### 6.3 I made the booth branding myself

The event archives contain deliverables beyond software `[Verified]`:

Booth signage · menu boards · nameplates · four A3 posters · business cards · participation
cards · display panels · kiosk button icons · per-character face icons · merchandise ·
photo-frame templates · **a Korean-language operations manual**

Tracking what was added year over year shows the booth becoming more refined:
2024 menu board → 2025 participation cards and panels → 2026 four A3 posters.

The character-selection button icons on the kiosk screen correspond to the
`change_character()` popup in `main.py` — evidence that **the same person moved back and
forth between design and implementation.**

### 6.4 Earning the right to the data

Fursuit photographs are assets the community feels strongly about in terms of ownership and
likeness rights. Scraping them is technically trivial and permanently destroys trust.

**Collection procedure** `[Ledger]`

1. An **open call on Twitter** within the furry fandom community
2. Purpose stated up front — *the AR photo booth, and potential future AI training for the
   furry / fursuit fandom*
3. **Opt-in intake via Google Form**, with donors submitting links to their own drives
4. Booth participant photos are **stored locally only and never leave the machine**

**Four things worth noting about this design**

- Opt-in open call rather than scraping — legitimacy of provenance
- **Future training use included in the stated purpose** — purpose specification
- **Separate handling tiers** for donated data (training) and participant photos (service output)
- The local-only rule for participant photos **coincides with the on-device inference
  architecture**. Not using cloud APIs was a latency and cost decision and a privacy design
  — **one architectural choice satisfying two requirements.**

The result is a small dataset, but one whose **right to use is unambiguous.** What scraping
would have accomplished in days took months; what it bought was community trust.

> **This document honors that policy too.** Neither donated images nor participant photos
> appear here. The scarcity of result imagery is not a defect in the document — it is the
> policy applied to the document itself.

---

## 7. Quantified Impact

### 7.1 Summary

| Axis | Figure | Grade |
|---|---|---|
| Operations | 7 events over 3 years, 703 captures | `[Ledger]` |
| Uptime | Longest single-day run **7h 53m** | `[Verified]` |
| Throughput | **9.5–10.7/hour**, interval p50 **2m 13s** | `[Verified]` |
| Data | **9 donors / 1,289 images** consent-sourced + **141 sets** auto-collected | `[Verified]` |
| Development | **All 61** commits inside an event D-30/D+7 window | `[Git history]` |
| Code | **3,436 lines** of Python + **1,371 lines** of agent knowledge docs | `[Verified]` |
| Expansion | 6 → 9 characters, new-character lead time **D-2** | `[Verified]` |

### 7.2 Unit economics — from the ledger `[Ledger]`

The booth charged **₩5,000 (~$3.60) per print.** The figures below are actual transactions
recorded in the operator's event ledger, not industry estimates.

**Scope — the photo booth segment only**

> The same booth also carried a **separate product line** (merchandise, acrylic goods,
> plushies). Its production costs and revenue are **entirely excluded** here. Assessing the
> photo booth's profitability requires matching only its own direct costs against its own
> revenue. What follows is the AR Photo Booth as a single segment.

**Per-print economics**

| Item | Amount |
|---|---|
| Price | ₩5,000 (~$3.60) |
| Paper cost (RP-54 pack, ₩27,500 / 54 prints) | **₩509 (~$0.36)** |
| **Contribution margin** | **₩4,491 (~$3.20), 90% margin** |

**Fixed costs**

| Item | Amount | Nature |
|---|---|---|
| **Commissioned background artwork** | **₩1,000,000 (~$715)** | One-time. The AR composite background |
| Depth camera | ₩317,517 (~$225) | One-time. Removed in CASE 2 → **sunk cost** |
| Webcam (4K) | ₩206,520 (~$148) | One-time |
| Dye-sublimation photo printer | ₩186,990 (~$134) | One-time |
| Booth interior props | ₩73,000 (~$52) | One-time |
| **One-time subtotal** | **₩1,784,027 (~$1,275)** | |
| Booth/event fees | ₩80,000 × 7 = ₩560,000 (~$400) | Per event. **Shared cost** with the merchandise line |
| Standing-sign reprints | ₩16,000 × 7 = ₩112,000 (~$80) | Per event. Photo booth direct cost |

> **The single largest expense was not technology. It was a drawing.**
> The ₩1,000,000 commissioned background is **56%** of one-time spend and equals **223
> prints** — 1.4× the entire AI hardware budget (₩711,027). Foundation models were free to
> use, but **a background people actually want to stand in front of had to be paid for.**
> In this project, technology was the cheapest component.

**Break-even, under two allocations of the shared cost**

| Allocation basis | Fixed cost | Break-even | vs. 703 cumulative |
|---|---|---|---|
| A. Direct costs only (event fee excluded as shared) | ₩1,896,027 | **422 prints** | 60% |
| **B. Full event-fee allocation (baseline, conservative)** | ₩2,456,027 | **547 prints** | **78%** |

The photo booth consumed most of the booth's floor space and operating hours, so B is closer
to reality, and **this document uses B as its baseline** — choosing the unfavorable basis
rather than the flattering one.

**Break-even took 2 years and 5 months, and five events.**

| # | Event | Captures | Cumulative |
|---|---|---|---|
| 1 | 2024-08 FUR:RAID | 80 | 80 |
| 2 | 2024-12 HowlSoup | 120 | 200 |
| 3 | 2025-02 FurryJoA | 64 | 264 |
| 4 | 2025-10 FUR:RAID | 200 | 464 |
| **5** | **2026-01 HowlSoup** | **100** | **564** ← crosses 547 |
| 6 | 2026-02 FurryJoA | 64 | 628 |
| 7 | 2026-04 FurstClass | 75 | 703 |

Looking only at per-event recurring cost (₩96,000 for fee plus signage), break-even is
**21 prints**. Since events ran 64–200 captures, **every one of the seven comfortably
covered its own recurring cost.** In other words, **each event was profitable from the
first one; what took five events was recovering the upfront investment.** That is the
difference between "absorbing a loss each time and holding on" and "profitable each time,
with a large initial outlay" — and it is the actual reason three years and seven events
were possible.

> **The scrapped depth camera cost ₩317,517, or 71 prints.**
> That is **45%** of the AI hardware budget (₩711,027) and 18% of all one-time spend.
> It is the monetary size of the misjudgment described in CASE 2 — optimizing for accuracy
> without putting field operational risk into the decision.
> Without that purchase, break-even would have been **476 prints instead of 547** — one
> event sooner.

**Cumulative P&L**

Excluding print failures (under 5%), captures converted to paid prints essentially
one-for-one `[Ledger]`. Failed prints consume paper without revenue, which the conservative
scenario reflects.

Revenue is 668–703 paid prints × ₩5,000 = **₩3,339,000 – ₩3,515,000 (~$2,385–2,510)**;
paper cost is 703–738 sheets × ₩509 = **₩358,000 – ₩376,000 (~$256–269)**.

| Allocation basis | Net (conservative) | Net (upper bound) |
|---|---|---|
| A. Direct costs only | +₩1,067,000 (~$762) | +₩1,261,000 (~$901) |
| **B. Full allocation (baseline)** | **+₩507,000 (~$362)** | **+₩701,000 (~$501)** |

**Baseline net: roughly ₩507K–701K (~$360–500).** That is what remains after running seven
events across three years. Because print success exceeded 95%, the two scenarios differ by
under ₩200K — with margins this thin, **print reliability *is* the P&L**. At a 20% failure
rate the net would fall by more than half.

**What was excluded, and why**

| Excluded | Reason |
|---|---|
| Merchandise / acrylic / plushie production | **Separate product line.** It has its own revenue; mixing it in distorts the segment |
| Fursuit transport and travel incidentals | **Not an incremental cost.** I would have attended the convention regardless, so these were not caused by the booth |
| Labor | See below |

The photo booth segment's direct costs are **closed** as enumerated above. No further line
items are outstanding.

**Two caveats — read them alongside the numbers**

1. **Labor is booked at zero.** Product definition, engineering, asset creation, and on-site
   operation were all unpaid self-labor. Development alone amounts to 61 commits; price that
   at market rates and **the net goes immediately negative.** The ₩507K–701K remaining after
   three years is not compensation for labor — it is only the fact that **cash flow was
   never negative.**
2. This booth was never intended to make money. The figures demonstrate that it **sustained
   itself for three years without consuming my own money**, not that it is a business.
   Sustainability is precisely what made seven consecutive events possible.

---

### 7.3 Opportunity-cost comparison (reference)

> Unlike §7.2, this section is **a comparison against alternatives**, not actual spend.
> All assumptions are stated and figures are given as ranges.

**① Commercial photo booth rental**

7 events × ₩500K–800K = **₩3.5M–5.6M (~$2,500–4,000)** worth of booth service replaced by
building it myself.

*Assumptions*: one operating day per event, rental including unlimited printing and staff,
VAT excluded. Rates are Korean public list prices
([SimpleCube](https://simplecube.net/pricing/) from ₩500K;
[photo kiosk rental](https://www.yubi.co.kr/www/kiosk_photo/photo2/rental) ₩800K minimum).

*Important limitation*: commercial rentals **do not provide per-character AR compositing**,
which is the core feature. This is therefore **a reference figure, not an equivalent
comparison.** Requiring feature parity turns it into a custom development quote, which would
be far larger but unverifiable, so it is not included.

**② Eliminating post-production lead time**

At an assumed 5–15 minutes per image for manual compositing (cutout, layer assembly, color
correction), 703 images × 5–15 min = **59–176 hours**.

*Assumptions*: a Photoshop-proficient operator working from prepared character layer
templates; 5 min optimistic, 15 min conservative. **This range is my own assumption, not a
sourced figure.**

*The real point*: the substance is not saved hours but **the elimination of lead time
itself.** Manual post-production, however fast, cannot hand the participant a print on the
spot. The value of this booth is not labor savings — it is **making "walk away with it now"
possible at all.**

**③ Marginal cost of adding a character**

Adding a character to a physical set or prop-based booth incurs fabrication, transport, and
storage. Here it costs **two PNGs and three config lines.**
Evidence: commit `a812ad2` added a character **two days before an event** and it ran
normally. A D-2 addition is not possible with physical props. The roster grew from 6 (2024)
to 9 (2026).

§7.2's segment direct costs are closed, so this section is the only estimated territory in
the document.

---

## 8. Incidents and Structural Fixes

### CASE 1 · General-purpose matting collapses on fursuits

**Symptom** — A matting model that worked well on people would severely clip the silhouette
of fursuit wearers, or mistake background for subject.

**Field impact** — Output quality varied unpredictably between participants. Some were
delighted, some disappointed — a direct hit to booth credibility.

**Root cause** — The five factors in §2.3(a). Nearly every cue the model relies on is
invalidated.

**Options and trade-offs**

| Option | Upside | Downside | Decision |
|---|---|---|---|
| Solve with data (fine-tuning) | Strong domain accuracy | One person carrying the retraining, labeling, and versioning burden | Adopted first |
| Solve with architecture (drop the "person" prior) | Almost no maintenance; generalizes | Residual domain error | Adopted second |
| Solve physically (chroma key) | Reliable | Requires floor space, setup time, lighting control | Rejected |

**Resolution** — First, MODNet fine-tuning on community-donated images with a
human-in-the-loop verification cycle. Later, a switch to depth-based foreground separation
combined with SAM2 promptable segmentation (`b7d9114`).

**Why I switched is the important part.** Not because fine-tuning was inaccurate.
Fine-tuning is not a decision to build one model — it is a decision to carry **the entire
apparatus of data collection, labeling, retraining, versioning, and donor relationships,
alone, for three years.** Every new costume type restarts that cycle. I chose **not the more
accurate option but the one a single person could sustain.**

**Prevention** — The "person" prior was removed from the pipeline entirely so it generalizes
to any costume. Residual error is absorbed by 18 per-character constants (`9b7fe70`).

### CASE 2 · Adding a depth camera, then removing it four months later

**Symptom** — The depth camera (`e42dc1a`, Oct 2025) genuinely did improve separation
accuracy. The problem was not accuracy; it was the field.

**Field impact** — Additional equipment to transport, USB bandwidth, per-OS drivers, keeping
the rig aligned. And decisively, it was a **single point of failure that would take the
entire booth down.**

**Root cause** — I adopted it looking only at accuracy and **did not put field operational
risk into the decision.** That was the actual mistake.

**Monetary size** — The unit cost **₩317,517 (~$225)**, which is **45%** of the AI hardware
budget (₩711,027) and equals **71 prints**. It was removed from the pipeline after four
months, making it entirely sunk. Without it, project break-even would have been **476 prints
instead of 547** — recovery one event sooner. The reason for recording the misjudgment as a
number rather than a qualitative note is that **remembering the size is what prevents the
repeat.**

**Resolution** — Fully replaced by Depth Anything V2 Small (`b0e64e7`, Feb 2026). The lost
accuracy was recovered through SAM2 hybrid prompting.

**Prevention** — Subsequent hardware decisions now begin with **"does the booth stop if this
device fails?"** Trading accuracy to remove operational risk became an explicit criterion.

### CASE 3 · Preview smoothness vs. capture accuracy

**Symptom** — Running the AI pipeline synchronously every frame stalled the preview. Slowing
the cadence meant the mask at capture belonged to the previous pose, misaligning the saved
frame.

**Field impact** — Either the screen stuttered while participants were posing, making
adjustment difficult, or arms and ears were clipped in the output. **Once it is printed,
there is no undo.**

**Root cause** — Two requirements on different time scales sharing one path. The preview
needs continuous smoothness; the save needs single-moment accuracy.

**Resolution** — Asynchronous detection with double buffering, and a synchronous flush only
on capture (`6837615`). See §4.2.

**Prevention** — The shared-state protection contract for all six threads is documented in
`.agent/knowledge/threading-model.md`, with a new-feature checklist codified in
`.agent/skills/threading-safety/` (`3bf6ddf`).

### CASE 4 · Failure modes manufactured by the field

**Symptom** — ① No internet at the venue, so automatic model download fails ② macOS
enumerates an iPhone as a Continuity Camera and the wrong device is selected ③ accidentally
closing the button window makes the booth unusable ④ the print button is clickable while the
printer is not ready.

**Field impact** — All of these occur **while a queue is waiting.** The booth stops in front
of an audience.

**Root cause** — The development environment's implicit assumptions — internet available,
one webcam, windows stay open, printer always ready — all break in the field.

**Resolution**

| Failure mode | Response | Commit |
|---|---|---|
| No network | Offline mode + model download split into a pre-run script | `de9d569`, `cb375ba` |
| Camera misidentification | Skip Continuity Camera when a USB camera is present | `b3d2b61` (**D+2**) |
| Window closed | Detect close and recreate automatically | `main.py:138-146` |
| Printer not ready | 3-second polling with button gating | `main.py:561-579` |

**Prevention** — Each failure mode is absorbed by **its own recovery path** rather than
crash-and-restart. The added code complexity was offset by codifying the rules in
`.agent/skills/`. Three one-click launch scripts (`run.command` / `run.bat` / `run.ps1`)
provide a handoff path for an operator.

### CASE 5 · Losing data the community entrusted to me — **unresolved**

**Symptom** — The open call brought in donations from **21 people**. Only **9 (1,289
images)** remain.

**Root cause** — **I had the capability to collect but no policy to preserve.** A free
storage tier's capacity limit, no backup or lifecycle policy, no local mirroring at intake.
Donor contact channels were not maintained systematically enough to re-request.

**What was lost** — **57%** by donor count. Not merely files, but **assets the community
entrusted to me**; recovering them means going back to the same people and asking again.
That weighs more than the technical loss.

**What I would do now**

- Local mirroring at intake plus cold-storage duplication
- Checksum-based inventory
- A separate donation register (donor, quantity, intake date, scope of consent)
- Capacity threshold alerts

**Prevention — unresolved.** Even the surviving nine donors' data has no duplication yet.
This is carried to the roadmap as the top priority.

---

## 9. Limitations and Roadmap

### 9.1 Known limitations

| Limitation | Detail |
|---|---|
| **No quantitative quality metric** | Segmentation accuracy was never measured via IoU or similar. Quality judgment was entirely visual. The 141 raw+mask pairs in `photos/` now make measurement possible |
| **Donated data not duplicated** | CASE 5. The surviving nine donors' data still has no backup policy |
| **Fine-tuning pipeline not preserved** | Training code and configuration live outside the repository, so it is not reproducible |
| **`main.py` is a single 597-line class** | `Application` owns camera, AI, compositing, UI, and printing. A refactoring guide exists at `.agent/workflows/refactor-module.md` but has not been executed |
| **No automated tests** | `scripts/test_*.py` are comparison-image generators, not unit tests |
| **Agent docs are stale in places** | Some documents still reference an old branch name after a branch change |
| **No operating metrics for 2024–2025** | Auto-save arrived in Feb 2026, so the first five events have no measured throughput |
| **Confounds in the growth trend** | Event size, booth location, and operating hours were uncontrolled, so capture growth cannot be attributed to system improvements |

### 9.2 Roadmap

**Priority 1 — Data preservation** (resolving CASE 5)
Local mirroring plus cold-storage duplication, checksum inventory, a proper donation register.

**Priority 2 — Quality measurement**
Produce manual ground-truth masks for a subset of the 141 sets in `photos/` and measure IoU
and boundary F1. Confirm the third-generation color harmonization improvement numerically
rather than visually.

**Priority 3 — Structural cleanup**
Separate `main.py`'s responsibilities, add unit tests for the segmentation pipeline, refresh
the agent documentation.

**Priority 4 — Feature expansion**
Multi-person capture (the current pipeline assumes a single centered subject), video/GIF
output, participant-facing self-service UI.

---

## 10. Appendix — Tech Stack

### Models

| Model | Role | Size |
|---|---|---|
| SAM 2.1 Hiera Tiny | Promptable segmentation (mask + points) | ~149 MB |
| Depth Anything V2 Small | Monocular depth → automatic prompt generation | HuggingFace snapshot |
| MediaPipe Selfie Segmenter / Pose Landmarker | Early experiments. Present in the download script but not imported by the current pipeline | — |

### Runtime

`Python 3.11` · `PyTorch` (MPS / CUDA / CPU auto-selection) · `OpenCV` (UMat/OpenCL) ·
`transformers` · `NumPy` · `tkinter` · `uv`

### Platform support

| | Windows | macOS |
|---|---|---|
| OpenCV acceleration | `cv2.UMat` (OpenCL) | `np.ndarray` |
| PyTorch | CUDA → CPU | MPS → CPU |
| Camera backend | `CAP_MSMF` | `CAP_AVFOUNDATION` |
| Printing | Win32 API | `lpr` (CUPS) |

### Hardware

4K USB webcam · dye-sublimation photo printer · laptop (GPU acceleration optional)

### Repository layout

```
main.py                 Application (597 lines)
camera/                 ICamera ABC + Webcam
utils/
  depth.py              DepthEstimator, rough mask, smart points   (602 lines)
  segment.py            SAM2ImageProcessor, hybrid prompting        (498 lines)
  mirror.py             MirrorCompositor, color harmonization       (259 lines)
  opencv.py             UMat/numpy abstraction                      (149 lines)
  printer.py            Per-OS printer factory                      (250 lines)
scripts/                Model download; segmentation & color-grading comparison tests
asset/                  Character front/rear layers + background + stickers (23 PNGs)
.agent/                 knowledge 4 / skills 4 / workflows 5 (1,371 lines)
```

---

## Notes on presentation

- Character names include characters owned by other people, so all are anonymized as labels.
- No third-party identifying information (donors, photographers) appears in this document.
- Donated images and booth participant photographs are excluded, as consent covered training
  use rather than publication.
- Dates are given to year-month precision only.
