Compare commits
5 Commits
be82843eef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 985d9244cd | |||
| 1fcbf14ed8 | |||
| fc963007b7 | |||
| 5a44c90ea6 | |||
| 470020cf57 |
@@ -37,25 +37,31 @@ jobs:
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Setup Python ${{ matrix.python-version }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Install deps
|
||||
shell: bash
|
||||
run: |
|
||||
uv venv .venv --python ${{ matrix.python-version }}
|
||||
uv pip install --python .venv -e ".[dev]"
|
||||
# Py3.13은 호환 핀 별도
|
||||
set -e
|
||||
if [ "${{ matrix.python-version }}" = "3.13" ]; then
|
||||
uv pip install --python .venv -e ".[py313,dev]"
|
||||
EXTRAS=".[py313,dev]"
|
||||
else
|
||||
EXTRAS=".[dev]"
|
||||
fi
|
||||
# uv 가 자동으로 Py 버전 fetch + .venv 생성. 별도 `uv python install`
|
||||
# step 불필요 (uv venv 가 내부적으로 처리).
|
||||
uv venv .venv --python ${{ matrix.python-version }}
|
||||
source .venv/bin/activate
|
||||
uv pip install -e "$EXTRAS"
|
||||
|
||||
- name: Ruff lint
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
ruff check --output-format=github
|
||||
|
||||
- name: py_compile (전체 .py)
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
python -c "
|
||||
@@ -74,6 +80,7 @@ jobs:
|
||||
"
|
||||
|
||||
- name: pytest (회귀)
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest -ra --tb=short -m "not slow and not integration"
|
||||
@@ -81,6 +88,7 @@ jobs:
|
||||
- name: pytest (slow + integration, allow failure)
|
||||
if: ${{ matrix.python-version == '3.13' }}
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest -ra --tb=short -m "slow or integration"
|
||||
|
||||
221
CHANGELOG.md
221
CHANGELOG.md
@@ -10,6 +10,227 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-08 (후속 — UI 3차: 인라인 로그 제거 + InlinePanel)
|
||||
|
||||
### [feat] 사용자 피드백 #4 핵심 — 인라인 로그 제거 + 모든 popup 인라인화
|
||||
|
||||
> **사용자 명시 요청**: "기존 구조에 지도 아래에 있는 로그는 백엔드로 빼고, 프로세스를 클릭할 때마다 새로운 창이 뜨는 것이 아니라 한 화면에서 바로 구동되게끔 적용".
|
||||
|
||||
#### 1. 인라인 로그 패널 제거 (`scanvas_maker.py`)
|
||||
|
||||
- **제거**: `self.textbox = ctk.CTkTextbox(self.main_frame, height=80, ...)` 위젯과 그 grid 배치 (이전 라운드에서 이미 80 으로 축소했지만 본 라운드에서 **완전 제거**).
|
||||
- **layout 재배치**:
|
||||
- `main_frame.grid_rowconfigure(0)` weight 3 → 1, 지도/캔버스가 row 0 전체 차지.
|
||||
- `main_frame.grid_rowconfigure(1)` (로그 행) 제거 — weight 설정 자체 삭제.
|
||||
- `status_bar.grid(row=2, ...)` → `row=1`. 로그 행 제거 후 한 칸 위로.
|
||||
- **`self.log()` 동작 변경**: textbox.insert 대신 백엔드 logger 사용.
|
||||
- 파일 sink: `logs/scanvas.log` (RotatingFileHandler 5MB×5, `harness/crash_logger.py` 의 `get_logger()` 활용).
|
||||
- 추가 sink: `%LOCALAPPDATA%\\S-CANVAS\\scanvas_harness.log` (`harness/logger.py` 의 `setup_logging` 통해 구조화 로그).
|
||||
- **즉시 가시성**: status_bar 의 `status_text` 가 짧은 미리보기 (≤80자, 그 이상은 잘림 + …). 사용자가 진행 상황을 한 눈에 보되 인라인 로그 패널은 사라짐.
|
||||
- **효과**: 메인 캔버스 영역 ~25% 확대 (이전 row 1 weight 1 영역 흡수). GUI 메인 thread 의 textbox.insert 부담 제거.
|
||||
|
||||
#### 2. `harness/inline_panel.py` 신규 — `ctk.CTkToplevel` 호환 인라인 오버레이
|
||||
|
||||
별도 OS 창 없이 main_frame 안에 floating frame 으로 렌더하는 drop-in 대체. **드러난 인터페이스가 CTkToplevel 와 동일**해서 호출지 코드는 1줄만 변경 (`ctk.CTkToplevel` → `InlinePanel`).
|
||||
|
||||
**구현 핵심**:
|
||||
- `ctk.CTkFrame` 상속 → `tk.Misc.wait_window(self)` 가 widget destruction 을 기다리는 매커니즘이라 Frame 에서도 동작 (Toplevel 만 동작 X). `wait_window()` 호출지 5곳 (line 971, 2518, 2662, 2968, 6662) 그대로 유지 가능.
|
||||
- `place(relx=0.5, rely=0.5, anchor="center")` 으로 main_frame 중앙 배치.
|
||||
- `geometry("WxH+X+Y")` 파싱 — X/Y 무시 (always center). main_frame 의 95% cap.
|
||||
- 다중 패널 z-order: `_z_counter` + `lift()` 으로 최신 패널이 위.
|
||||
- 타이틀 바: MC Red (#EB001B) 배경, 흰 텍스트, ✕ 닫기 버튼 (피드백 #4 색감 일관).
|
||||
- `protocol("WM_DELETE_WINDOW", fn)` → 내부 핸들러 등록. `grab_set()` → lift+focus 시뮬레이트.
|
||||
- **Toplevel 전용 메서드 no-op**: `iconbitmap`, `iconphoto`, `wm_*`, `attributes`, `overrideredirect`. 호출은 silently ignored.
|
||||
|
||||
#### 3. 12개 `ctk.CTkToplevel` → `InlinePanel` 일괄 치환
|
||||
|
||||
`scanvas_maker.py` 의 popup 생성 라인 12 곳:
|
||||
|
||||
| # | line | 호출 | 용도 |
|
||||
|---|---|---|---|
|
||||
| T1 | 851 | `win = ctk.CTkToplevel(self)` | DXF 레이어 분류 (900×650) |
|
||||
| T2 | 1504 | `win = ctk.CTkToplevel(self)` | 구조물 상세 3D 빌드 (1100×650) |
|
||||
| T3 | 1681 | `win = ctk.CTkToplevel(self)` | 빌드 진행 |
|
||||
| T4 | 1974 | `opt_win = ctk.CTkToplevel(win)` | 렌더 옵션 (T3 자식) |
|
||||
| T5 | 2129 | `dwin = ctk.CTkToplevel(win)` | VLM 결과 (T3 자식) |
|
||||
| T6 | 2451 | `win = ctk.CTkToplevel(self)` | 상세도면 업로드 |
|
||||
| T7 | 2571 | `win = ctk.CTkToplevel(self)` | 치수 확인 (650×500) |
|
||||
| T8 | 2808 | `win = ctk.CTkToplevel(self)` | 계획선 고도 설정 (1280×560) |
|
||||
| T9 | 4710 | `win = ctk.CTkToplevel(self)` | TIN 이용 범위 (1100×920) |
|
||||
| T10 | 6625 | `time_win = ctk.CTkToplevel(self)` | 렌더링 옵션 (380×360) |
|
||||
| T11 | 6985 | `win = ctk.CTkToplevel(self)` | Blender 결과 |
|
||||
| T12 | 7058 | `win = ctk.CTkToplevel(self)` | AI 렌더 결과 |
|
||||
|
||||
치환 패턴 2종 (replace_all): `ctk.CTkToplevel(self)` → `InlinePanel(self)` (10곳), `ctk.CTkToplevel(win)` → `InlinePanel(win)` (2곳, 자식 패널).
|
||||
|
||||
**12 popup 모두 별도 OS 창 없이 main 창 안에서 동작.** 사용자가 ALT-TAB 으로 창 사이 오갈 필요 없음.
|
||||
|
||||
#### 4. 검증
|
||||
|
||||
- `python -m py_compile scanvas_maker.py harness/perf.py harness/crash_logger.py harness/inline_panel.py` 통과.
|
||||
- AST parse OK.
|
||||
- Import smoke test: `import scanvas_maker` 성공, `InlinePanel` 이 진짜 `harness.inline_panel.InlinePanel` 클래스로 로드 (CTkToplevel 폴백 아님).
|
||||
- ruff `check scanvas_maker.py harness/`: All checks passed.
|
||||
- 잔존 검사:
|
||||
- `self.textbox` refs: **0** (완전 제거).
|
||||
- `CTkToplevel` refs: 3 (모두 import 폴백/주석).
|
||||
- `InlinePanel` refs: 15 (12 호출지 + import + fallback).
|
||||
|
||||
#### 5. 잔여 (#4 next round, multi-session)
|
||||
|
||||
- **InlinePanel 동작 검증 (실 GUI)**: 자동 import 검증은 끝났지만 사용자가 실제로 도면 로드 → DXF 레이어 분류 (T1) → 구조물 빌드 (T2) → 등 워크플로 한 번 돌려봐야 모달 동작/wait_window/grab_set 시뮬레이션의 실효성 확인.
|
||||
- **VTK 임베딩**: 6개 `pv.Plotter().show()` → `vtkmodules.tk.vtkTkRenderWidget` 또는 `pyvistaqt.QtInteractor`. PyQt 의존성 추가 필요.
|
||||
- **`messagebox` 63회** → 인라인 토스트/배너 (위험 4건 askyesno 만 모달 유지).
|
||||
- **Inspector 패널 컬럼 (3-column 레이아웃)**: 현재 InlinePanel 은 floating overlay. 영구적 우측 인스펙터 컬럼 (UI_REDESIGN_PLAN.md §2.1) 은 별도 작업.
|
||||
- **메인 thread 블로킹 작업 worker thread 분리**: 그래야 progress_bar animation 실제 동작.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-08 (후속 — CI fix + UI 2차)
|
||||
|
||||
### [fix] Gitea CI uv setup 실패 — `**/uv.lock` 미존재 → setup-uv 액션 abort (#6 후속)
|
||||
|
||||
- **증상**: 7개 push 모두 CI run 10-20초 만에 `failure`. 로그:
|
||||
```
|
||||
::error::No file in /workspace/HYUNJUNGLEE/s-canvas matched to [**/uv.lock]
|
||||
❌ Failure - Main Setup uv (fast Python pkg manager)
|
||||
```
|
||||
- **원인**: `astral-sh/setup-uv@v3` 의 `enable-cache: true` 옵션이 cache key 산출용 `**/uv.lock` 검색 → 미발견 시 hard fail. UV_GUIDE.md §3 에서 권장만 했고 실제 lock 파일은 없었음.
|
||||
- **추가 발견 (uv.lock 생성 시도 시 노출)**:
|
||||
1. `[tool.uv] no-progress = false` — uv 0.11+ 가 모르는 옵션 (deprecated). 제거.
|
||||
2. **dependency 핀 충돌**: `dependencies` 의 `scipy==1.13.1` / `pyproj==3.6.1` vs `[py313]` extras 의 `scipy>=1.14` / `pyproj>=3.7,<4` — uv resolver 가 동시 만족 불가.
|
||||
3. `pyproj>=3.7` 가 Py3.11+ 만 wheel 배포 — `requires-python = ">=3.9"` 와 충돌.
|
||||
|
||||
#### 수정안 (5건)
|
||||
- **`.gitea/workflows/ci.yml`**:
|
||||
- `enable-cache: true` + `cache-dependency-glob: "uv.lock"` (lock 파일 변경 시에만 캐시 갱신).
|
||||
- 별도 `Setup Python` step 제거 — `uv venv --python <ver>` 가 자동 fetch.
|
||||
- install step 단순화: matrix 분기에서 EXTRAS 변수로 `[dev]` vs `[py313,dev]` 선택 후 단일 `uv pip install`.
|
||||
- 모든 `run:` 에 `shell: bash` 명시 (Gitea act-runner 호환).
|
||||
- **`pyproject.toml`**:
|
||||
- `scipy==1.13.1` → `scipy>=1.13,<2`.
|
||||
- `pyproj==3.6.1` → `pyproj>=3.6,<4`.
|
||||
- `numpy==2.0.2` → `numpy>=2.0.2,<3`.
|
||||
- `requires-python = ">=3.9"` → `">=3.11"` (CI matrix Py3.11/3.13 와 일치, Py3.9/3.10 legacy 종료).
|
||||
- `[tool.uv] no-progress = false` 제거.
|
||||
- **`uv.lock` 신규** (438 KB, 89 packages 해결): 다른 머신/CI에서 동일 환경 재현. `uv sync --frozen` 또는 `uv pip install -e ".[dev]" --frozen` 으로 lock 기준 install.
|
||||
|
||||
검증 (로컬): `uv lock` 성공 89 packages 해결, `ruff check` All checks passed.
|
||||
|
||||
### [feat] UI 진행률 인디케이터 + 로그 패널 축소 (#4 부분)
|
||||
|
||||
- **사용자 피드백 #4**: "느리게 느껴짐" → 긴 작업 중 시각적 피드백 부재.
|
||||
- **신규 위젯**: `self.progress_bar = ctk.CTkProgressBar(self.status_bar, mode="indeterminate", width=180, height=10, progress_color="#FF5F00")`. 기본 hidden (pack 안 함). MC overlap orange 색.
|
||||
- **신규 메서드** (`scanvas_maker.py` `SCanvasApp` 안):
|
||||
- `start_progress(label: str | None = None)`: progress_bar pack(side="right") + indeterminate animation 시작 + 옵션 status_text 갱신. `self.after(0, ...)` 로 메인 thread 안전.
|
||||
- `stop_progress(final_label: str | None = None)`: animation 정지 + pack_forget + status_text 옵션 갱신.
|
||||
- **로그 패널 축소**: `self.textbox` height 120 → 80. 인라인 로그 비중 줄여 캔버스 영역 확보. 사용자 피드백 "로그는 백엔드로" 의 점진적 적용 — 완전 제거가 아니라 디스크 (`%LOCALAPPDATA%\\S-CANVAS\\scanvas_harness.log` + `logs/scanvas.log`)가 주 기록처임을 주석으로 명시. 다음 라운드에서 toggle 버튼 또는 완전 제거.
|
||||
- **잔여 (#4 next round)**:
|
||||
- `start_progress`/`stop_progress` 를 실제 핫스팟 호출지에 wire (capture pipeline, 위성 타일, TIN densify 등).
|
||||
- 메인 thread 블로킹 작업을 worker thread 로 분리 — 그래야 progress animation 실제 동작.
|
||||
- 12개 `CTkToplevel` 인스펙터 패널 통합 (별도 multi-session).
|
||||
|
||||
### [chore] `harness/perf.py` ruff 정리
|
||||
|
||||
- `Optional[Callable[...]]` → `Callable[...] | None` (UP045, Py3.11+ native union).
|
||||
- `try: ...; except Exception: pass` → `with contextlib.suppress(Exception):` (SIM105).
|
||||
- 사용 안 되는 `# noqa: BLE001` 제거 (RUF100).
|
||||
- 결과: ruff `--no-cache` All checks passed.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-08 (후속 — UI 1차)
|
||||
|
||||
### [feat] Mastercard 팔레트 1차 적용 + 인트로 비디오 제거 (#4 부분)
|
||||
|
||||
> **사용자 피드백 #4 부분 진행**: 색감/텍스쳐 위주의 1차 라운드. 단일 창 구조 (CTkToplevel 12개 통합, VTK 임베딩 등) 는 multi-session 작업이라 별도 라운드. 본 commit 은 색채 정체성 + 인트로 제거에 한정.
|
||||
|
||||
#### Mastercard 디자인 토큰 매핑 (10색)
|
||||
- `#2ECC71` (Bootstrap green) → `#22A06B` Brand Green (READY 인디케이터, 12곳).
|
||||
- `#E74C3C` (Bootstrap red) → `#EB001B` Mastercard Red (에러 status, 14곳).
|
||||
- `#F1C40F` (Bootstrap yellow) → `#F79E1B` Mastercard Yellow (경고 status, 7곳).
|
||||
- `#27AE60` / `#1E8449` (CTA 그린/hover) → `#22A06B` / `#1B8454` (5+3곳).
|
||||
- `#E67E22` / `#BA6116` (오렌지 CTA/hover) → `#EB001B` / `#A30013` MC Red (3+1곳) — 주요 액션 버튼이 MC 레드로 통일.
|
||||
- `#343A40` / `#212529` (다크 슬레이트 버튼/hover) → `#1A1A1A` / `#000000` MC Near-black (1+1곳).
|
||||
- `#2b2b2b` (CTk 기본 다크 캔버스) → `#1A1A1A` (1곳, 다크 모드 일관성).
|
||||
|
||||
총 50+ hex literal 갱신. Mastercard 공식 brand 가이드라인 (공개) 기반 — `npx getdesign@latest add mastercard` 시도는 외부 npm 코드 실행 차단으로 건너뛰고 공개 팔레트 적용.
|
||||
|
||||
#### 디자인 의도
|
||||
- **PRIMARY (MC Red `#EB001B`)**: 주 CTA + 에러 상태 일관 적용. 위험 신호와 액션 강조 단일 색.
|
||||
- **ACCENT (MC Yellow `#F79E1B`)**: 경고/노란 status 일관 — Step3 진행 인디케이터 등.
|
||||
- **SUCCESS (Brand Green `#22A06B`)**: READY/완료 인디케이터. MC 자체 그린 토큰 없음 → brand-friendly 톤 선정.
|
||||
- **DARK (`#1A1A1A`)**: 다크 모드 페이지/카드 bg, 다크 버튼 — pure black 직전. 텍스트 가독성 ↑.
|
||||
- 팔레트 문서화 블록: `scanvas_maker.py` line ~33-47 (`Mastercard 디자인 시스템 팔레트` 주석).
|
||||
|
||||
#### 인트로 영상 제거
|
||||
- `splash.py` 삭제 (178 LOC) — `show_intro_splash` 함수 단일 진입점, 더 이상 호출 없음.
|
||||
- `Design/logo_intro.mp4` 삭제 (3.7 MB).
|
||||
- `scanvas_maker.py` 의 호출부 제거 (line ~7044-7054 13줄): try/except + show_intro_splash + import.
|
||||
- 효과: 메인 앱 즉시 기동, 첫 화면까지 12초 fade-in 사라짐 → "느리게 느껴짐" 피드백 일부 완화.
|
||||
|
||||
#### 잔여 (#4 다음 라운드)
|
||||
- **단일 창 구조**: CTkToplevel 12개 (T1~T12) 인스펙터 패널로 통합.
|
||||
- **인라인 로그 패널 제거**: `main_frame.row=1` `CTkTextbox` → harness_log_path() 백엔드 파일.
|
||||
- **VTK 임베딩**: `pv.Plotter().show()` 6개 호출지 → `vtkmodules.tk.vtkTkRenderWidget` 또는 `pyvistaqt.QtInteractor`.
|
||||
- **3-column 레이아웃**: Sidebar(240) / Main Canvas(flex) / Inspector(340).
|
||||
- **messagebox 63회** → 인라인 토스트/배너 (위험한 askyesno 4건만 모달 유지).
|
||||
|
||||
이는 multi-session 작업이라 본 라운드 범위 외. UI_REDESIGN_PLAN.md §3 참조.
|
||||
|
||||
#### 검증
|
||||
- `python -m py_compile scanvas_maker.py harness/perf.py harness/crash_logger.py` 통과.
|
||||
- AST parse OK.
|
||||
- `splash`/`show_intro_splash` 호출 잔존 검사 — 0건 (주석 블록의 1회 'splash' 언급 제외).
|
||||
- `git ls-files | grep -E '...gcp.*key|\\.log$|\\.db$|\\.bak|venv|__pycache__|cache/'` — 0건. .gitignore 정상 동작.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-08
|
||||
|
||||
### [merge] Gitea s-canvas 원격(raw upload, 184185c)과 로컬 lint+Phase 0 history 통합
|
||||
|
||||
- **상황**: 원격 `https://gitea.hmac.kr/HYUNJUNGLEE/s-canvas.git` 에 사용자가 site에서 raw upload 한 1회 commit (`184185c`)이 존재. 로컬은 `53d8b53` → `b9342f6` (import + iter1~7 lint cleanup) → `e9cc6bf` (Phase 0 of expert feedback) history. **공통 조상 없음 (unrelated histories)**.
|
||||
- **분석**: 원격에만 있는 파일 0개 (원격은 로컬의 부분집합). 양쪽 다 있고 내용 다른 파일 29개 (raw upload vs lint-applied 버전). 로컬에만 있는 파일 80+ (Phase 0 산출물 + workspace/`_unused/` 등).
|
||||
- **전략**: `git merge --allow-unrelated-histories -X ours`. 충돌 시 로컬 우선으로 lint cleanup 보존.
|
||||
- **결과**: 머지 commit `8c6d7f0`. 자동 머지된 31파일 중 README.md 만 실질 변경 (로컬 0줄 → 원격 404줄 README 흡수 — 빈 파일 vs 내용있음은 `-X ours` 적용 외 단순 합병). 나머지 source/config 28개는 로컬 lint 버전 유지.
|
||||
- **푸시**: 머지 후 fast-forward push 가능. 원격 history 손실 없이 통합.
|
||||
|
||||
### [feat] #11 perf instrumentation — `harness/perf.py` 신규 + `scanvas_maker.py` 5곳 wire
|
||||
|
||||
- **사용자 피드백 #11**: "로딩이 오래 걸리는 부분(위성지도 결합·구조물 빌드 시 등)은 CPU 이용률이 대폭 증가하는 프로세스를 ms 단위로 추적해서 원인을 규명하고 최적화하는 조치 필요".
|
||||
|
||||
#### 신규 모듈 — `harness/perf.py` (54 LOC)
|
||||
- `perf_block(label)` — 컨텍스트 매니저. `with perf_block("XYZ tiles"): ...` 형태로 블록 실행 시간(wall + CPU)을 ms 단위로 측정.
|
||||
- `set_perf_log(callable)` — 외부 sink 등록 (예: `set_perf_log(app.log)` 시 GUI 로그 패널에도 표시).
|
||||
- 출력 형식: `[PERF] {label}: wall={NNN}ms cpu={NNN}ms ({CPU|I/O/Net}-bound)`. `cpu/wall > 0.5` 면 CPU-bound로 분류.
|
||||
|
||||
#### Setup — `scanvas_maker.py` 2곳
|
||||
1. **import 블록 (~line 58)**: `from harness.perf import perf_block, set_perf_log` + ImportError 시 `@contextlib.contextmanager` 노옵 폴백 → `harness/` 모듈 누락 환경에서도 안전.
|
||||
2. **`SCanvasApp.__init__` (~line 613)**: `set_perf_log(self.log)` 등록 — perf 측정 라인이 GUI 텍스트박스에도 표시됨.
|
||||
|
||||
#### Hotspot wraps — `scanvas_maker.py` 3곳 (PERFORMANCE_BASELINE.md 매핑)
|
||||
1. **TIN densify Phase C (line ~4430) → H3**: `with perf_block("TIN densify Phase C (10m→1m)")` 로 10단계 점진 격자 루프 감쌈.
|
||||
2. **위성 타일 다운로드 (line ~5384) → H1**: `with perf_block("위성 타일 다운로드+병합")` 로 `_download_xyz_tiles()` 감쌈 — 사용자 피드백 #11이 명시한 "위성지도 결합" 핫스팟.
|
||||
3. **제어맵 캡처 파이프라인 (line ~5864) → H12**: `with perf_block("control map capture x3 + composite")` 로 textured + depth + lineart 3-stage 캡처 + composite 감쌈.
|
||||
|
||||
#### 출력 예 (실제 측정 시)
|
||||
```
|
||||
[PERF] 위성 타일 다운로드+병합: wall=12340.5ms cpu=860.3ms (I/O/Net-bound)
|
||||
[PERF] TIN densify Phase C (10m→1m): wall=2150.7ms cpu=2080.4ms (CPU-bound)
|
||||
[PERF] control map capture x3 + composite: wall=4520.1ms cpu=3760.8ms (CPU-bound)
|
||||
```
|
||||
|
||||
#### 검증
|
||||
- `python -m py_compile scanvas_maker.py harness/perf.py` 통과. AST parse OK (39 top-level statements).
|
||||
- ruff Green 정식 검증은 다음 세션 (글로벌 ruff 미설치, `uv pip install -e ".[dev]"` 후 `ruff check`).
|
||||
|
||||
#### 다음 라운드 (#11 추가)
|
||||
- 사용자 실제 도면으로 [PERF] 출력 1회 측정 → PERFORMANCE_BASELINE.md "측정 후 비교 표" 채움.
|
||||
- 측정 결과 기반 추가 hotspot wrap (H7·H9·H13·H18 등) + 최적화 (numpy 벡터화 / 스레드 풀 / GIL 해제).
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-28
|
||||
|
||||
### [fix] 화면비 버튼이 텍스트만 떠서 안 보이는 문제 — vtkButtonWidget 로 교체
|
||||
|
||||
Binary file not shown.
219
harness/inline_panel.py
Normal file
219
harness/inline_panel.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""S-CANVAS InlinePanel — `ctk.CTkToplevel` 호환 인라인 오버레이 (#4).
|
||||
|
||||
피드백 #4: "프로세스를 클릭할 때마다 새로운 창이 뜨는 것이 아니라 한 화면에서
|
||||
바로 구동되게끔 적용".
|
||||
|
||||
본 모듈은 `ctk.CTkToplevel(parent)` 자리에 그대로 들어가는 drop-in 대체. main 앱
|
||||
창의 `main_frame` 안에 floating overlay 로 렌더되어 별도 OS 창을 만들지 않는다.
|
||||
|
||||
호환 API (Toplevel subset):
|
||||
- title(str)
|
||||
- geometry("WxH" | "WxH+X+Y") — X,Y 는 무시 (centering)
|
||||
- transient(parent) — no-op
|
||||
- grab_set() / grab_release() — lift + focus 시뮬레이트
|
||||
- protocol("WM_DELETE_WINDOW", fn)
|
||||
- wait_window() — tk.Misc.wait_window 그대로 동작 (Frame 도 OK)
|
||||
- destroy()
|
||||
- iconbitmap / iconphoto / wm_* — no-op (Toplevel 전용)
|
||||
|
||||
사용:
|
||||
from harness.inline_panel import InlinePanel
|
||||
win = InlinePanel(self) # ctk.CTkToplevel(self) 와 동일하게
|
||||
win.title("DXF 레이어 분류")
|
||||
win.geometry("900x650")
|
||||
win.grab_set()
|
||||
# ... 자식 위젯 .pack() / .grid()
|
||||
btn_close = ctk.CTkButton(win, text="닫기", command=win.destroy)
|
||||
btn_close.pack()
|
||||
win.wait_window() # 패널 destroy 까지 블록
|
||||
|
||||
기존 Toplevel 와 거의 동일한 코드 변경 = 1줄 (`CTkToplevel` → `InlinePanel`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from collections.abc import Callable
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
|
||||
# Mastercard 팔레트 (scanvas_maker.py 와 일관) — 타이틀 바 색상.
|
||||
_MC_RED = "#EB001B"
|
||||
_MC_RED_DARK = "#A30013"
|
||||
_MC_WHITE = "#FFFFFF"
|
||||
|
||||
|
||||
class InlinePanel(ctk.CTkFrame):
|
||||
"""`ctk.CTkToplevel` 인터페이스를 구현한 inline overlay frame.
|
||||
|
||||
상위 SCanvasApp 인스턴스의 `main_frame` 안에 `place(relx=0.5, rely=0.5)` 로
|
||||
중앙 배치. 같은 시점에 여러 패널이 열리면 가장 마지막 것이 위 (lift).
|
||||
|
||||
구현 노트:
|
||||
- `tk.Misc.wait_window(self)` 는 widget destruction 을 기다리는 메커니즘
|
||||
이라 Toplevel 외 Frame 에서도 동작. CTkFrame → tk.Frame → tk.Widget.
|
||||
- `grab_set()` 은 OS-level focus 잡는 게 본래 의미인데, 인라인 오버레이는
|
||||
하나의 창 안이라 무의미 → lift + focus 로 시뮬레이트.
|
||||
- geometry 의 X+Y 좌표는 무시 (panel 은 항상 main_frame 중앙).
|
||||
"""
|
||||
|
||||
_z_counter = 0 # 다중 패널 z-order 카운터
|
||||
|
||||
def __init__(self, parent: ctk.CTkBaseClass, **kwargs):
|
||||
# 상위 SCanvasApp 찾기 — winfo_toplevel() 이 root window 반환.
|
||||
# parent 가 SCanvasApp 또는 다른 InlinePanel 둘 다 OK.
|
||||
# 안전 폴백: main_frame 없는 root 면 parent 자체에 그림.
|
||||
app = parent.winfo_toplevel()
|
||||
host = app.main_frame if hasattr(app, "main_frame") else parent
|
||||
|
||||
super().__init__(
|
||||
host,
|
||||
fg_color=("#FFFFFF", "#1A1A1A"),
|
||||
border_width=2,
|
||||
border_color=("#E0E0E0", "#333333"),
|
||||
corner_radius=10,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._app = app
|
||||
self._host = host
|
||||
self._title_text: str = ""
|
||||
self._title_bar: ctk.CTkFrame | None = None
|
||||
self._title_label: ctk.CTkLabel | None = None
|
||||
self._content_frame: ctk.CTkFrame | None = None
|
||||
self._on_close: Callable[[], None] | None = None
|
||||
self._closed = False
|
||||
self._geom_w = 640
|
||||
self._geom_h = 480
|
||||
|
||||
InlinePanel._z_counter += 1
|
||||
self._z = InlinePanel._z_counter
|
||||
|
||||
self._build_chrome()
|
||||
self._reposition()
|
||||
|
||||
# --- chrome (title bar + content area) -----------------------------------
|
||||
|
||||
def _build_chrome(self) -> None:
|
||||
"""타이틀 바 (MC red) + 컨텐츠 프레임. 자식 위젯은 컨텐츠 프레임에 들어감."""
|
||||
# title_bar: MC red 배경, 흰 텍스트, ✕ 버튼
|
||||
self._title_bar = ctk.CTkFrame(
|
||||
self, height=34, fg_color=(_MC_RED, _MC_RED_DARK), corner_radius=8,
|
||||
)
|
||||
self._title_bar.pack(side="top", fill="x", padx=4, pady=(4, 0))
|
||||
self._title_bar.pack_propagate(False)
|
||||
|
||||
self._title_label = ctk.CTkLabel(
|
||||
self._title_bar, text="", text_color=_MC_WHITE,
|
||||
font=ctk.CTkFont(size=13, weight="bold"),
|
||||
)
|
||||
self._title_label.pack(side="left", padx=12, pady=4)
|
||||
|
||||
close_btn = ctk.CTkButton(
|
||||
self._title_bar, text="✕", width=28, height=24,
|
||||
fg_color="transparent", hover_color=_MC_RED_DARK,
|
||||
text_color=_MC_WHITE, corner_radius=4,
|
||||
font=ctk.CTkFont(size=14, weight="bold"),
|
||||
command=self._user_close,
|
||||
)
|
||||
close_btn.pack(side="right", padx=4, pady=4)
|
||||
|
||||
# content frame: 자식 위젯 컨테이너. 사용자가 .pack/.grid(self)로 추가하면
|
||||
# 자동으로 여기 자식이 됨 (CTkFrame 의 _children 위임). 직접 호출은 따로.
|
||||
# 다만 기존 코드는 `widget(win, ...)` 처럼 win 자체를 parent 로 쓰니 그게
|
||||
# InlinePanel 의 직접 자식이 됨 — 그래서 content_frame 은 reserved 안 만들고
|
||||
# title_bar 만 packed 후 자식들이 그 아래로 채워짐 (pack 자동 layout).
|
||||
# title bar 와 content 사이 분리선
|
||||
sep = ctk.CTkFrame(self, height=1, fg_color=("#E0E0E0", "#333333"))
|
||||
sep.pack(side="top", fill="x", padx=8, pady=(0, 4))
|
||||
|
||||
def _reposition(self) -> None:
|
||||
"""geometry str 파싱 + 중앙 배치 (main_frame 의 중앙)."""
|
||||
try:
|
||||
mw = self._host.winfo_width() or 1200
|
||||
mh = self._host.winfo_height() or 800
|
||||
# main_frame 의 95% 안으로 cap
|
||||
w = min(self._geom_w, int(mw * 0.95))
|
||||
h = min(self._geom_h, int(mh * 0.95))
|
||||
self.place(relx=0.5, rely=0.5, anchor="center", width=w, height=h)
|
||||
self.lift()
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
self.place(relx=0.5, rely=0.5, anchor="center")
|
||||
self.lift()
|
||||
|
||||
# --- ctk.CTkToplevel 호환 메서드 -----------------------------------------
|
||||
|
||||
def title(self, t: str) -> None:
|
||||
self._title_text = t
|
||||
if self._title_label is not None:
|
||||
self._title_label.configure(text=t)
|
||||
|
||||
def geometry(self, geom_str: str) -> None:
|
||||
"""\"WxH\" 또는 \"WxH+X+Y\" 형식 파싱. X,Y 는 무시 (always center)."""
|
||||
try:
|
||||
size_part = geom_str.split("+", 1)[0]
|
||||
w_str, h_str = size_part.split("x")
|
||||
self._geom_w = int(w_str)
|
||||
self._geom_h = int(h_str)
|
||||
self._reposition()
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
def transient(self, master) -> None:
|
||||
"""no-op (이미 main_frame 안)."""
|
||||
|
||||
def grab_set(self) -> None:
|
||||
"""모달 시뮬레이트 — lift + focus."""
|
||||
with contextlib.suppress(Exception):
|
||||
self.lift()
|
||||
self.focus_set()
|
||||
|
||||
def grab_release(self) -> None:
|
||||
"""no-op."""
|
||||
|
||||
def protocol(self, name: str, handler: Callable[[], None]) -> None:
|
||||
if name == "WM_DELETE_WINDOW":
|
||||
self._on_close = handler
|
||||
|
||||
def iconbitmap(self, *args, **kwargs) -> None:
|
||||
"""Toplevel 전용 — no-op (인라인 패널은 OS 창이 아님)."""
|
||||
|
||||
def iconphoto(self, *args, **kwargs) -> None:
|
||||
"""Toplevel 전용 — no-op."""
|
||||
|
||||
def wm_iconbitmap(self, *args, **kwargs) -> None:
|
||||
"""Toplevel 전용 — no-op."""
|
||||
|
||||
def wm_iconphoto(self, *args, **kwargs) -> None:
|
||||
"""Toplevel 전용 — no-op."""
|
||||
|
||||
def attributes(self, *args, **kwargs):
|
||||
"""\"-topmost\", \"-alpha\" 등 — no-op (인라인 패널은 z-order 관리만)."""
|
||||
return None
|
||||
|
||||
def overrideredirect(self, *args, **kwargs) -> None:
|
||||
"""no-op."""
|
||||
|
||||
# --- 종료 처리 -----------------------------------------------------------
|
||||
|
||||
def _user_close(self) -> None:
|
||||
"""타이틀 바 ✕ 또는 protocol(WM_DELETE_WINDOW) 호출 시 진입점."""
|
||||
if self._on_close is not None:
|
||||
try:
|
||||
self._on_close()
|
||||
except Exception:
|
||||
self.destroy()
|
||||
else:
|
||||
self.destroy()
|
||||
|
||||
def destroy(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
with contextlib.suppress(Exception):
|
||||
self.place_forget()
|
||||
super().destroy()
|
||||
|
||||
# wait_window() 는 별도 정의 안 함 — tk.Misc.wait_window(self) 가 widget
|
||||
# destruction 을 기다리는데, CTkFrame 이 tk.Widget 상속이라 Frame 에서도 동작.
|
||||
64
harness/perf.py
Normal file
64
harness/perf.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""S-CANVAS perf instrumentation — ms 단위 wall/CPU 시간 측정.
|
||||
|
||||
피드백 #11: "로딩이 오래 걸리는 부분(위성지도 결합·구조물 빌드 시 등)은
|
||||
CPU 이용률이 대폭 증가하는 프로세스를 ms 단위로 추적해서 원인을 규명하고
|
||||
최적화하는 조치 필요"
|
||||
|
||||
사용:
|
||||
from harness.perf import perf_block, set_perf_log
|
||||
|
||||
set_perf_log(app.log) # GUI 로그에 함께 기록 (옵션)
|
||||
|
||||
with perf_block("XYZ tiles 5x5"):
|
||||
download_tiles(...)
|
||||
|
||||
출력:
|
||||
[PERF] XYZ tiles 5x5: wall=2540.3ms cpu=120.1ms (I/O/Net-bound)
|
||||
|
||||
판별: cpu/wall > 0.5 → CPU-bound, 그 외 → I/O/Net-bound (GIL 풀린 시간 비율).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
|
||||
_log_callable: Callable[[str], None] | None = None
|
||||
_logger = logging.getLogger("scanvas.perf")
|
||||
|
||||
|
||||
def set_perf_log(fn: Callable[[str], None] | None) -> None:
|
||||
"""app.log 등 외부 sink로 perf 라인 라우팅. None이면 logger 만."""
|
||||
global _log_callable # noqa: PLW0603 (module-level singleton)
|
||||
_log_callable = fn
|
||||
|
||||
|
||||
def _emit(line: str) -> None:
|
||||
_logger.info(line)
|
||||
if _log_callable is not None:
|
||||
# 로그 sink 실패가 측정 흐름을 끊으면 안 됨 — 폭넓게 suppress.
|
||||
with contextlib.suppress(Exception):
|
||||
_log_callable(line)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def perf_block(label: str):
|
||||
"""블록 단위 wall-clock + CPU 시간을 한 줄로 출력.
|
||||
|
||||
Args:
|
||||
label: 출력 prefix (예: "TIN densify Phase C", "capture x3").
|
||||
|
||||
측정 단위는 ms. CPU-bound vs I/O/Net-bound를 cpu/wall 비율로 거칠게 분류.
|
||||
"""
|
||||
t_wall = time.perf_counter()
|
||||
t_cpu = time.process_time()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
dt_wall = (time.perf_counter() - t_wall) * 1000
|
||||
dt_cpu = (time.process_time() - t_cpu) * 1000
|
||||
ratio = dt_cpu / dt_wall if dt_wall > 1e-3 else 0.0
|
||||
kind = "CPU" if ratio > 0.5 else "I/O/Net"
|
||||
_emit(f"[PERF] {label}: wall={dt_wall:.1f}ms cpu={dt_cpu:.1f}ms ({kind}-bound)")
|
||||
@@ -16,7 +16,9 @@ name = "scanvas"
|
||||
version = "0.7.0"
|
||||
description = "S-CANVAS — Generative Design & Visualization Engine (DXF + DEM + AI)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
# pyproj>=3.7 (py313 extras) 이 Py3.11+ 만 지원. CI matrix Py3.11/3.13 와 일치.
|
||||
# Py3.9/3.10 legacy 지원이 필요하면 pyproj 범위 좁혀야 함.
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "Proprietary" }
|
||||
authors = [
|
||||
{ name = "Saman Corp.", email = "saman@example.com" },
|
||||
@@ -35,12 +37,12 @@ dependencies = [
|
||||
|
||||
# --- Geospatial / DXF ---
|
||||
"ezdxf==1.4.2",
|
||||
"pyproj==3.6.1",
|
||||
"pyproj>=3.6,<4", # 3.6.1 (build pin) ~ 3.7+ (py313 extras) 동시 수용. lock 파일이 정확 핀.
|
||||
"rasterio==1.4.3",
|
||||
|
||||
# --- Numerical ---
|
||||
"numpy==2.0.2",
|
||||
"scipy==1.13.1",
|
||||
"numpy>=2.0.2,<3", # py313 extras 와 충돌 방지 위해 범위 핀.
|
||||
"scipy>=1.13,<2", # 1.13.x (Py3.9~3.12) ~ 1.14+ (Py3.13) 둘 다 lock 가능.
|
||||
"matplotlib==3.9.4",
|
||||
|
||||
# --- Image / video ---
|
||||
@@ -99,9 +101,6 @@ Repository = "https://gitea.hmac.kr/HYUNJUNGLEE/scanvas.git"
|
||||
# Python 인터프리터 선택 우선순위 (uv가 자동 검색).
|
||||
python-preference = "managed" # managed = uv가 직접 받아 관리 (3.13 자동 다운로드 가능)
|
||||
|
||||
# 색상/진행률 표시.
|
||||
no-progress = false
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 빌드 시스템 (편집 가능 설치 / pip install -e . 가능)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
347
scanvas_maker.py
347
scanvas_maker.py
@@ -1,3 +1,7 @@
|
||||
# Lazy annotation 평가 — `str | None` 등 PEP 604 union 이 Py3.9 미만 시스템
|
||||
# Python 으로도 import-time 파싱 통과. 실 검증은 venv313 (Py3.13) 권장.
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import customtkinter as ctk
|
||||
import datetime
|
||||
@@ -30,6 +34,22 @@ from PIL import Image, ImageDraw, ImageFilter
|
||||
import tkintermapview
|
||||
from matplotlib.colors import LinearSegmentedColormap
|
||||
|
||||
# ===== Mastercard 디자인 시스템 팔레트 (피드백 #4 — UI/UX 재설계) =====
|
||||
# 본 색상은 코드 전반에 hex literal 로 직접 적용된다 (constants import 부담 회피).
|
||||
# 본 주석 블록은 디자인 의도 문서화용. UI_REDESIGN_PLAN.md 참조.
|
||||
#
|
||||
# PRIMARY #EB001B Mastercard Red — 주 CTA, 에러 상태
|
||||
# PRIMARY' #A30013 Red Dark — CTA hover/active
|
||||
# ACCENT #F79E1B Mastercard Yellow — 보조 강조, 경고 (Step3 노란 status 등)
|
||||
# FOCUS #FF5F00 Overlap Orange — 포커스 / focus accent (예약)
|
||||
# SUCCESS #22A06B Brand-friendly Green — 성공 / READY 인디케이터
|
||||
# SUCCESS' #1B8454 Green Dark — 성공 hover
|
||||
# DARK #1A1A1A Near-black — 다크 모드 페이지/버튼 bg
|
||||
# BLACK #000000 Pure Black — 텍스트, 다크 버튼 hover
|
||||
# BORDER_L #E0E0E0 Light Border — 라이트 모드 보더
|
||||
# BORDER_D #333333 Dark Border — 다크 모드 보더 (현 코드는 #3F3F3F 유지)
|
||||
# 인트로 splash 비디오는 본 라운드에서 제거됨 (피드백 #4).
|
||||
|
||||
# 지형(TIN) 컬러맵 — **파란색 금지** (피드백 #3: 물과 헷갈림).
|
||||
# 어두운 토양 → 밝은 모래/건조 톤 → 능선 광택. matplotlib "terrain" 대체.
|
||||
_TIN_EARTH_CMAP = LinearSegmentedColormap.from_list(
|
||||
@@ -55,6 +75,30 @@ try:
|
||||
except ImportError:
|
||||
HARNESS_AVAILABLE = False
|
||||
|
||||
# Perf instrumentation (#11) — ms 단위 wall/CPU 측정. import 실패 시 no-op 폴백.
|
||||
try:
|
||||
from harness.perf import perf_block, set_perf_log
|
||||
except ImportError:
|
||||
@contextlib.contextmanager
|
||||
def perf_block(label): # type: ignore[no-redef]
|
||||
yield
|
||||
def set_perf_log(fn): # type: ignore[no-redef]
|
||||
pass
|
||||
|
||||
# InlinePanel (#4) — CTkToplevel 호환 인라인 오버레이. 별도 OS 창 안 띄우고
|
||||
# main_frame 안 floating frame 으로 렌더. 실패 시 폴백으로 CTkToplevel 사용.
|
||||
try:
|
||||
from harness.inline_panel import InlinePanel
|
||||
except ImportError:
|
||||
InlinePanel = ctk.CTkToplevel # type: ignore[assignment,misc]
|
||||
|
||||
# 크래시 로거의 일반 로그 채널 — self.log() 가 인라인 textbox 대신 백엔드 파일에
|
||||
# 흘리도록 하기 위해 (#4 "로그는 백엔드로"). harness.logger 의 get_logger 와 별개.
|
||||
try:
|
||||
from harness.crash_logger import get_logger as _get_crash_logger
|
||||
except ImportError:
|
||||
_get_crash_logger = None # type: ignore[assignment]
|
||||
|
||||
# 구조물 상세도면 치수 파서
|
||||
try:
|
||||
from detail_parser import DetailParser, dimensions_to_structure_params
|
||||
@@ -460,14 +504,14 @@ class SCanvasApp(ctk.CTk):
|
||||
self.btn_step4 = ctk.CTkButton(
|
||||
self.sidebar_frame, text="4. AI 렌더링",
|
||||
command=self.btn_ai_render_callback, height=40,
|
||||
fg_color="#E67E22", hover_color="#BA6116",
|
||||
fg_color="#EB001B", hover_color="#A30013",
|
||||
font=ctk.CTkFont(weight="bold"))
|
||||
self.btn_step4.grid(row=_next_row(), column=0, pady=(6, 6), sticky="ew", **pad)
|
||||
|
||||
self.btn_struct_build = ctk.CTkButton(
|
||||
self.sidebar_frame, text="구조물 상세 3D 빌드",
|
||||
command=self._open_structure_template_dialog, height=32,
|
||||
fg_color="#27AE60", hover_color="#1E8449", text_color="white",
|
||||
fg_color="#22A06B", hover_color="#1B8454", text_color="white",
|
||||
font=ctk.CTkFont(size=12, weight="bold"))
|
||||
self.btn_struct_build.grid(row=_next_row(), column=0, pady=(3, 0), sticky="ew", **pad)
|
||||
|
||||
@@ -484,8 +528,8 @@ class SCanvasApp(ctk.CTk):
|
||||
self.btn_reopen_3d = ctk.CTkButton(
|
||||
self.sidebar_frame, text="🗔 3D 뷰 다시 열기",
|
||||
command=self._reopen_3d_preview, height=30,
|
||||
fg_color=("#343A40", "#2C3E50"),
|
||||
hover_color=("#212529", "#34495E"),
|
||||
fg_color=("#1A1A1A", "#2C3E50"),
|
||||
hover_color=("#000000", "#34495E"),
|
||||
text_color="#FFFFFF",
|
||||
font=ctk.CTkFont(size=11, weight="bold"))
|
||||
self.btn_reopen_3d.grid(row=_next_row(), column=0, pady=(6, 0), sticky="ew", **pad)
|
||||
@@ -574,14 +618,14 @@ class SCanvasApp(ctk.CTk):
|
||||
self.main_frame = ctk.CTkFrame(self, corner_radius=15, fg_color="transparent")
|
||||
self.main_frame.grid(row=0, column=1, padx=20, pady=20, sticky="nsew")
|
||||
self.main_frame.grid_columnconfigure(0, weight=1)
|
||||
self.main_frame.grid_rowconfigure(0, weight=3) # 지도 (넓게)
|
||||
self.main_frame.grid_rowconfigure(1, weight=1) # 로그 (좁게)
|
||||
# 피드백 #4: 인라인 로그 패널 제거. row 0 (지도/캔버스) 전체, row 1 (status_bar) 만 남음.
|
||||
self.main_frame.grid_rowconfigure(0, weight=1) # 지도/캔버스 (전체)
|
||||
|
||||
# 1. 지도 (상단 — 넓게). Light/Dark 테마별 배경 쌍 — tkintermapview
|
||||
# 타일 주변의 얇은 padding 에 이 색이 보임.
|
||||
self.map_frame = ctk.CTkFrame(
|
||||
self.main_frame, corner_radius=12,
|
||||
fg_color=("#FFFFFF", "#2b2b2b"),
|
||||
fg_color=("#FFFFFF", "#1A1A1A"),
|
||||
border_width=1, border_color=("#DEE2E6", "#3F3F3F"),
|
||||
)
|
||||
self.map_frame.grid(row=0, column=0, padx=0, pady=(0, 8), sticky="nsew")
|
||||
@@ -594,22 +638,36 @@ class SCanvasApp(ctk.CTk):
|
||||
self.map_view.set_zoom(6)
|
||||
self.map_view.set_position(36.5, 127.5)
|
||||
|
||||
# 2. 로그 (하단 — 스크롤 가능, 높이 줄임)
|
||||
self.textbox = ctk.CTkTextbox(self.main_frame, height=120, font=ctk.CTkFont(family="Consolas", size=12), border_width=1)
|
||||
self.textbox.grid(row=1, column=0, padx=0, pady=0, sticky="nsew")
|
||||
# 2. 로그 패널 — 피드백 #4: **제거** (인라인 → 백엔드 파일).
|
||||
# 파일 위치: %LOCALAPPDATA%\\S-CANVAS\\scanvas_harness.log + logs/scanvas.log.
|
||||
# self.log() 호출은 그대로 유지하되 logger 로 흘러가고 status_text 만 갱신.
|
||||
|
||||
# 3. 하단 상태 바
|
||||
# 3. 하단 상태 바 — 로그 제거로 row 2 → row 1
|
||||
self.status_bar = ctk.CTkFrame(self.main_frame, height=28, fg_color="transparent")
|
||||
self.status_bar.grid(row=2, column=0, sticky="ew", pady=(5, 0))
|
||||
self.status_bar.grid(row=1, column=0, sticky="ew", pady=(5, 0))
|
||||
|
||||
self.status_indicator = ctk.CTkLabel(self.status_bar, text="● READY", text_color="#2ECC71", font=ctk.CTkFont(size=12, weight="bold"))
|
||||
self.status_indicator = ctk.CTkLabel(self.status_bar, text="● READY", text_color="#22A06B", font=ctk.CTkFont(size=12, weight="bold"))
|
||||
self.status_indicator.pack(side="left", padx=10)
|
||||
|
||||
self.status_text = ctk.CTkLabel(self.status_bar, text="지형 데이터를 로드해 주세요.", font=ctk.CTkFont(size=12))
|
||||
self.status_text.pack(side="left")
|
||||
|
||||
# 진행률 인디케이터 (피드백 #4 — "느리게 느껴짐" 일부 해결).
|
||||
# 기본 hidden. start_progress/stop_progress 로 토글. indeterminate animation
|
||||
# 으로 "시스템이 살아있다" 시그널 — 실 진행률 측정은 future work (#11 perf 와 연계).
|
||||
# MC accent 색상 (#FF5F00 overlap orange) 적용.
|
||||
self.progress_bar = ctk.CTkProgressBar(
|
||||
self.status_bar, mode="indeterminate", width=180, height=10,
|
||||
progress_color="#FF5F00", fg_color=("#E0E0E0", "#333333"),
|
||||
)
|
||||
# 초기엔 hidden (pack 안 함). start_progress 시 등장.
|
||||
|
||||
self.log("S-CANVAS Generative Design Engine 구동 완료.")
|
||||
|
||||
# Perf 측정 라인을 GUI 로그에도 함께 표시 (#11). harness/perf.py 폴백 import 시
|
||||
# set_perf_log는 no-op이라 실패해도 안전.
|
||||
set_perf_log(self.log)
|
||||
|
||||
def create_sidebar_button(self, text, command, row, **kwargs):
|
||||
btn = ctk.CTkButton(
|
||||
self.sidebar_frame, text=text, command=command, height=34, **kwargs)
|
||||
@@ -705,11 +763,47 @@ class SCanvasApp(ctk.CTk):
|
||||
print(f"[Warning] 윈도우 아이콘 설정 실패: {e}")
|
||||
|
||||
def log(self, message):
|
||||
timestamp = datetime.datetime.now().strftime("[%H:%M:%S]")
|
||||
def _update():
|
||||
self.textbox.insert("end", f"{timestamp} {message}\n")
|
||||
self.textbox.see("end")
|
||||
self.after(0, _update)
|
||||
"""피드백 #4: 인라인 로그 패널 제거 후, 모든 메시지는 백엔드 파일로.
|
||||
|
||||
- 백엔드: %LOCALAPPDATA%\\S-CANVAS\\scanvas_harness.log + logs/scanvas.log
|
||||
(RotatingFileHandler 5MB×5).
|
||||
- 사용자 즉시 피드백: status_bar 의 status_text 가 짧은 미리보기로 갱신
|
||||
(긴 메시지는 80자에서 잘림 + …).
|
||||
- 매 호출이 textbox.insert 으로 GUI 메인 thread 부담 주던 패턴 제거됨.
|
||||
"""
|
||||
if _get_crash_logger is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
_get_crash_logger().info(message)
|
||||
if hasattr(self, "status_text"):
|
||||
short = message if len(message) <= 80 else message[:77] + "…"
|
||||
self.after(0, lambda s=short: self.status_text.configure(text=s))
|
||||
|
||||
def start_progress(self, label: str | None = None) -> None:
|
||||
"""진행률 인디케이터 표시 + indeterminate animation 시작.
|
||||
|
||||
피드백 #4 — 긴 작업 시 "시스템 살아있음" 시그널. label 주면 status_text 도
|
||||
함께 갱신. 메인 thread 블로킹 작업이라도 호출 직전/직후에 표시 가능 (실
|
||||
애니메이션은 idle time 에 의존).
|
||||
"""
|
||||
def _start():
|
||||
with contextlib.suppress(Exception):
|
||||
self.progress_bar.pack(side="right", padx=(8, 12), pady=4)
|
||||
self.progress_bar.start()
|
||||
if label is not None:
|
||||
self.status_text.configure(text=label)
|
||||
self.update_idletasks()
|
||||
self.after(0, _start)
|
||||
|
||||
def stop_progress(self, final_label: str | None = None) -> None:
|
||||
"""진행률 인디케이터 숨김 + animation 정지."""
|
||||
def _stop():
|
||||
with contextlib.suppress(Exception):
|
||||
self.progress_bar.stop()
|
||||
self.progress_bar.pack_forget()
|
||||
if final_label is not None:
|
||||
self.status_text.configure(text=final_label)
|
||||
self.update_idletasks()
|
||||
self.after(0, _stop)
|
||||
|
||||
def _diag(self, message, *, reset=False):
|
||||
"""구조물 분류/추출 진단 로그 (scanvas_diagnostic.log).
|
||||
@@ -725,7 +819,7 @@ class SCanvasApp(ctk.CTk):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_status(self, text, indicator_color="#2ECC71"):
|
||||
def set_status(self, text, indicator_color="#22A06B"):
|
||||
def _update():
|
||||
self.status_text.configure(text=text)
|
||||
self.status_indicator.configure(text_color=indicator_color)
|
||||
@@ -753,7 +847,7 @@ class SCanvasApp(ctk.CTk):
|
||||
"cofferdam_downstream": {"name_ko": "하류 가물막이", "render_mode": "wall_extrude", "color": "#8D9CA6"},
|
||||
"building": {"name_ko": "건축물/가설건물", "render_mode": "box_extrude", "color": "#BDC3C7"},
|
||||
"temp_facility": {"name_ko": "가설부지/야적장", "render_mode": "surface_overlay", "color": "#E8DACC"},
|
||||
"boundary": {"name_ko": "경계선 (참고용)", "render_mode": "line_only", "color": "#E74C3C"},
|
||||
"boundary": {"name_ko": "경계선 (참고용)", "render_mode": "line_only", "color": "#EB001B"},
|
||||
"ignore": {"name_ko": "무시 (사용 안 함)", "render_mode": "none", "color": "#CCCCCC"},
|
||||
}
|
||||
except Exception:
|
||||
@@ -779,7 +873,7 @@ class SCanvasApp(ctk.CTk):
|
||||
option_list = list(type_options.values())
|
||||
|
||||
# 팝업 창
|
||||
win = ctk.CTkToplevel(self)
|
||||
win = InlinePanel(self)
|
||||
win.title("S-CANVAS: DXF 레이어 분류")
|
||||
win.geometry("900x650")
|
||||
win.grab_set()
|
||||
@@ -840,7 +934,7 @@ class SCanvasApp(ctk.CTk):
|
||||
z_marker = "★ " if has_z else " "
|
||||
name_text = f"{z_marker}{layer_name}"
|
||||
|
||||
name_color = "#2ECC71" if guessed == "terrain" else None
|
||||
name_color = "#22A06B" if guessed == "terrain" else None
|
||||
lbl = ctk.CTkLabel(scroll_frame, text=name_text, font=ctk.CTkFont(size=12),
|
||||
anchor="w", width=320, text_color=name_color)
|
||||
lbl.grid(row=i, column=0, padx=(5, 3), pady=2, sticky="w")
|
||||
@@ -1432,7 +1526,7 @@ class SCanvasApp(ctk.CTk):
|
||||
"완료하여 구조물 레이어를 등록해 주세요.")
|
||||
return
|
||||
|
||||
win = ctk.CTkToplevel(self)
|
||||
win = InlinePanel(self)
|
||||
win.title("S-CANVAS: 구조물 상세 3D 빌드 (템플릿 기반)")
|
||||
win.geometry("1100x650")
|
||||
win.grab_set()
|
||||
@@ -1505,7 +1599,7 @@ class SCanvasApp(ctk.CTk):
|
||||
status_var.set(f"빌드됨 ({len(info['template_meshes'])}개)")
|
||||
status_label = ctk.CTkLabel(list_frame, textvariable=status_var,
|
||||
font=ctk.CTkFont(size=10),
|
||||
text_color="#2ECC71")
|
||||
text_color="#22A06B")
|
||||
status_label.grid(row=ri, column=4, padx=3, pady=3, sticky="w")
|
||||
|
||||
# 작업 버튼
|
||||
@@ -1585,7 +1679,7 @@ class SCanvasApp(ctk.CTk):
|
||||
self.show_3d_preview(textured=False)
|
||||
|
||||
ctk.CTkButton(bottom, text="TIN에 구조물 반영 + 3D 보기", width=200,
|
||||
fg_color="#E67E22", hover_color="#D35400",
|
||||
fg_color="#EB001B", hover_color="#D35400",
|
||||
text_color="white",
|
||||
font=ctk.CTkFont(size=11, weight="bold"),
|
||||
command=_apply_structures_to_tin
|
||||
@@ -1609,7 +1703,7 @@ class SCanvasApp(ctk.CTk):
|
||||
messagebox.showerror("오류", f"템플릿을 찾을 수 없습니다: {tid}")
|
||||
return
|
||||
|
||||
win = ctk.CTkToplevel(self)
|
||||
win = InlinePanel(self)
|
||||
win.title(f"구조물 검토: {name}")
|
||||
win.geometry("760x820")
|
||||
win.grab_set()
|
||||
@@ -1902,7 +1996,7 @@ class SCanvasApp(ctk.CTk):
|
||||
return
|
||||
|
||||
# 옵션 다이얼로그 — 시간대 + 투명배경 + 샘플
|
||||
opt_win = ctk.CTkToplevel(win)
|
||||
opt_win = InlinePanel(win)
|
||||
opt_win.title("Blender Cycles 렌더 옵션")
|
||||
opt_win.geometry("420x340")
|
||||
opt_win.transient(win); opt_win.grab_set()
|
||||
@@ -2057,7 +2151,7 @@ class SCanvasApp(ctk.CTk):
|
||||
|
||||
def _show_diff_dialog(diff: dict):
|
||||
"""Gemini가 반환한 diff를 테이블로 보여주고 사용자가 체크한 항목만 적용."""
|
||||
dwin = ctk.CTkToplevel(win)
|
||||
dwin = InlinePanel(win)
|
||||
dwin.title(f"AI 검증 결과: {name}")
|
||||
dwin.geometry("820x680")
|
||||
dwin.grab_set()
|
||||
@@ -2067,8 +2161,8 @@ class SCanvasApp(ctk.CTk):
|
||||
score_f = float(score)
|
||||
except (TypeError, ValueError):
|
||||
score_f = 0.0
|
||||
score_color = ("#27AE60" if score_f >= 0.85 else
|
||||
"#F39C12" if score_f >= 0.6 else "#E74C3C")
|
||||
score_color = ("#22A06B" if score_f >= 0.85 else
|
||||
"#F39C12" if score_f >= 0.6 else "#EB001B")
|
||||
|
||||
hdr = ctk.CTkFrame(dwin, fg_color="transparent")
|
||||
hdr.pack(fill="x", padx=15, pady=(12, 4))
|
||||
@@ -2130,10 +2224,10 @@ class SCanvasApp(ctk.CTk):
|
||||
if excess:
|
||||
ctk.CTkLabel(scroll, text="참고: 모델에만 있는 요소 (적용 아님)",
|
||||
font=ctk.CTkFont(size=12, weight="bold"),
|
||||
text_color="#E74C3C", anchor="w").pack(fill="x", pady=(10, 2))
|
||||
text_color="#EB001B", anchor="w").pack(fill="x", pady=(10, 2))
|
||||
for note in excess:
|
||||
ctk.CTkLabel(scroll, text=f"• {note}", font=ctk.CTkFont(size=10),
|
||||
text_color="#E67E22", wraplength=760, justify="left",
|
||||
text_color="#EB001B", wraplength=760, justify="left",
|
||||
anchor="w").pack(fill="x", padx=8, pady=1)
|
||||
|
||||
valves_incorrect = diff.get("valves_incorrect", []) or []
|
||||
@@ -2190,7 +2284,7 @@ class SCanvasApp(ctk.CTk):
|
||||
fg_color="transparent", border_width=1,
|
||||
command=dwin.destroy).pack(side="right", padx=4)
|
||||
ctk.CTkButton(btns, text="✓ 선택 항목 적용 + 재빌드", width=220,
|
||||
fg_color="#27AE60", hover_color="#1E8449",
|
||||
fg_color="#22A06B", hover_color="#1B8454",
|
||||
font=ctk.CTkFont(size=11, weight="bold"),
|
||||
command=_apply_and_rebuild).pack(side="right", padx=4)
|
||||
# 생성된 비교 이미지 경로 안내
|
||||
@@ -2292,7 +2386,7 @@ class SCanvasApp(ctk.CTk):
|
||||
fg_color="transparent", border_width=1,
|
||||
command=win.destroy).pack(side="right", padx=3)
|
||||
ctk.CTkButton(bottom, text="✓ 확정 (레지스트리 저장)", width=190,
|
||||
fg_color="#27AE60", hover_color="#1E8449",
|
||||
fg_color="#22A06B", hover_color="#1B8454",
|
||||
text_color="white",
|
||||
font=ctk.CTkFont(size=11, weight="bold"),
|
||||
command=_do_confirm).pack(side="right", padx=3)
|
||||
@@ -2379,7 +2473,7 @@ class SCanvasApp(ctk.CTk):
|
||||
messagebox.showerror("오류", "detail_parser 모듈을 찾을 수 없습니다.")
|
||||
return
|
||||
|
||||
win = ctk.CTkToplevel(self)
|
||||
win = InlinePanel(self)
|
||||
win.title("S-CANVAS: 구조물 상세도면 추가")
|
||||
win.geometry("950x600")
|
||||
win.grab_set()
|
||||
@@ -2422,7 +2516,7 @@ class SCanvasApp(ctk.CTk):
|
||||
# 파싱 결과 표시
|
||||
result_var = ctk.StringVar(value=self._format_detail_params(info.get("detail_params")))
|
||||
result_label = ctk.CTkLabel(list_frame, textvariable=result_var,
|
||||
font=ctk.CTkFont(size=10), text_color="#2ECC71")
|
||||
font=ctk.CTkFont(size=10), text_color="#22A06B")
|
||||
result_label.grid(row=ri, column=4, padx=5, pady=3, sticky="w")
|
||||
|
||||
# 파일 선택 버튼
|
||||
@@ -2499,7 +2593,7 @@ class SCanvasApp(ctk.CTk):
|
||||
"""
|
||||
info = self.structure_registry[layer_name]
|
||||
|
||||
win = ctk.CTkToplevel(self)
|
||||
win = InlinePanel(self)
|
||||
win.title(f"치수 확인: {info['name']}")
|
||||
win.geometry("650x500")
|
||||
win.grab_set()
|
||||
@@ -2529,7 +2623,7 @@ class SCanvasApp(ctk.CTk):
|
||||
).grid(row=di, column=1, padx=5, pady=1, sticky="w")
|
||||
ctk.CTkLabel(detail_frame, text=f"{dim.value:.3f} {dim.unit}", font=ctk.CTkFont(size=10)
|
||||
).grid(row=di, column=2, padx=5, pady=1, sticky="w")
|
||||
conf_color = "#2ECC71" if dim.confidence >= 0.9 else "#F1C40F" if dim.confidence >= 0.8 else "#E74C3C"
|
||||
conf_color = "#22A06B" if dim.confidence >= 0.9 else "#F79E1B" if dim.confidence >= 0.8 else "#EB001B"
|
||||
ctk.CTkLabel(detail_frame, text=f"{dim.confidence:.0%}", font=ctk.CTkFont(size=10),
|
||||
text_color=conf_color
|
||||
).grid(row=di, column=3, padx=5, pady=1, sticky="w")
|
||||
@@ -2736,7 +2830,7 @@ class SCanvasApp(ctk.CTk):
|
||||
except Exception as e:
|
||||
self.log(f" 마커 표시 오류: {e}")
|
||||
|
||||
win = ctk.CTkToplevel(self)
|
||||
win = InlinePanel(self)
|
||||
win.title("S-CANVAS: 계획선 고도 설정")
|
||||
win.geometry("1280x560")
|
||||
win.grab_set()
|
||||
@@ -4094,7 +4188,7 @@ class SCanvasApp(ctk.CTk):
|
||||
|
||||
self.dxf_path = file_path
|
||||
self.log(f">>> [Step 1] DXF 로드: {os.path.basename(file_path)}")
|
||||
self.set_status("DXF 분석 중...", "#F1C40F")
|
||||
self.set_status("DXF 분석 중...", "#F79E1B")
|
||||
|
||||
# 진단 로그 초기화 (세션 시작)
|
||||
self._diag(f"=== Step 1 시작: {file_path} ===", reset=True)
|
||||
@@ -4173,13 +4267,13 @@ class SCanvasApp(ctk.CTk):
|
||||
|
||||
self.update_map_view_to_mesh()
|
||||
reg_n = len(self.structure_registry)
|
||||
self.set_status(f"TIN 생성 완료 · 구조물 등록 {reg_n}개", "#2ECC71")
|
||||
self.set_status(f"TIN 생성 완료 · 구조물 등록 {reg_n}개", "#22A06B")
|
||||
self.btn_step2.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0)
|
||||
|
||||
self.show_3d_preview(textured=False)
|
||||
except Exception as e:
|
||||
self.log(f"오류: {e!s}")
|
||||
self.set_status("오류 발생", "#E74C3C")
|
||||
self.set_status("오류 발생", "#EB001B")
|
||||
messagebox.showerror("오류", f"TIN 생성 중 문제가 발생했습니다:\n{e}")
|
||||
|
||||
def create_tin_from_dxf(self, filepath, terrain_layers=None):
|
||||
@@ -4427,40 +4521,41 @@ class SCanvasApp(ctk.CTk):
|
||||
from matplotlib.path import Path as _MplPath
|
||||
total_phase_c = 0
|
||||
steps_log = []
|
||||
for _step in (10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0):
|
||||
try:
|
||||
hull_c = _ConvexHull(pts[:, :2])
|
||||
except Exception:
|
||||
break
|
||||
hull_poly_xy = pts[hull_c.vertices, :2]
|
||||
hull_path_c = _MplPath(hull_poly_xy, closed=True)
|
||||
gx = np.arange(x0_abs, x1_abs + _step * 0.5, _step)
|
||||
gy = np.arange(y0_abs, y1_abs + _step * 0.5, _step)
|
||||
ggx, ggy = np.meshgrid(gx, gy)
|
||||
grid_xy_c = np.column_stack([ggx.ravel(), ggy.ravel()])
|
||||
inside_bbox = (
|
||||
(grid_xy_c[:, 0] >= x0_abs - 1e-6)
|
||||
& (grid_xy_c[:, 0] <= x1_abs + 1e-6)
|
||||
& (grid_xy_c[:, 1] >= y0_abs - 1e-6)
|
||||
& (grid_xy_c[:, 1] <= y1_abs + 1e-6)
|
||||
)
|
||||
grid_xy_c = grid_xy_c[inside_bbox]
|
||||
if len(grid_xy_c) == 0:
|
||||
continue
|
||||
inside_hull = hull_path_c.contains_points(grid_xy_c)
|
||||
outside_hull_xy = grid_xy_c[~inside_hull]
|
||||
if len(outside_hull_xy) == 0:
|
||||
continue
|
||||
# 기존 점과 너무 가까운 격자점(≤ step×0.4) 제외 — 중복 방지
|
||||
tree_ex = _cKDTreeC(pts[:, :2])
|
||||
d_ex, _ = tree_ex.query(outside_hull_xy, k=1)
|
||||
new_only_xy = outside_hull_xy[d_ex > _step * 0.4]
|
||||
if len(new_only_xy) == 0:
|
||||
continue
|
||||
new_z_c = _dem_sample_minus_offset(new_only_xy)
|
||||
pts = np.vstack([pts, np.column_stack([new_only_xy, new_z_c])])
|
||||
total_phase_c += len(new_only_xy)
|
||||
steps_log.append(f"{_step:.0f}m:{len(new_only_xy)}")
|
||||
with perf_block("TIN densify Phase C (10m→1m)"):
|
||||
for _step in (10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0):
|
||||
try:
|
||||
hull_c = _ConvexHull(pts[:, :2])
|
||||
except Exception:
|
||||
break
|
||||
hull_poly_xy = pts[hull_c.vertices, :2]
|
||||
hull_path_c = _MplPath(hull_poly_xy, closed=True)
|
||||
gx = np.arange(x0_abs, x1_abs + _step * 0.5, _step)
|
||||
gy = np.arange(y0_abs, y1_abs + _step * 0.5, _step)
|
||||
ggx, ggy = np.meshgrid(gx, gy)
|
||||
grid_xy_c = np.column_stack([ggx.ravel(), ggy.ravel()])
|
||||
inside_bbox = (
|
||||
(grid_xy_c[:, 0] >= x0_abs - 1e-6)
|
||||
& (grid_xy_c[:, 0] <= x1_abs + 1e-6)
|
||||
& (grid_xy_c[:, 1] >= y0_abs - 1e-6)
|
||||
& (grid_xy_c[:, 1] <= y1_abs + 1e-6)
|
||||
)
|
||||
grid_xy_c = grid_xy_c[inside_bbox]
|
||||
if len(grid_xy_c) == 0:
|
||||
continue
|
||||
inside_hull = hull_path_c.contains_points(grid_xy_c)
|
||||
outside_hull_xy = grid_xy_c[~inside_hull]
|
||||
if len(outside_hull_xy) == 0:
|
||||
continue
|
||||
# 기존 점과 너무 가까운 격자점(≤ step×0.4) 제외 — 중복 방지
|
||||
tree_ex = _cKDTreeC(pts[:, :2])
|
||||
d_ex, _ = tree_ex.query(outside_hull_xy, k=1)
|
||||
new_only_xy = outside_hull_xy[d_ex > _step * 0.4]
|
||||
if len(new_only_xy) == 0:
|
||||
continue
|
||||
new_z_c = _dem_sample_minus_offset(new_only_xy)
|
||||
pts = np.vstack([pts, np.column_stack([new_only_xy, new_z_c])])
|
||||
total_phase_c += len(new_only_xy)
|
||||
steps_log.append(f"{_step:.0f}m:{len(new_only_xy)}")
|
||||
if total_phase_c > 0:
|
||||
self.log(
|
||||
f" [Phase C] hull 바깥 × bbox 내부 점진 densify: "
|
||||
@@ -4637,7 +4732,7 @@ class SCanvasApp(ctk.CTk):
|
||||
pts_abs = pts_zero + origin
|
||||
x0p, y0p, x1p, y1p = [float(v) for v in self.projected_bounds]
|
||||
|
||||
win = ctk.CTkToplevel(self)
|
||||
win = InlinePanel(self)
|
||||
win.title("🎯 TIN 이용 범위 선택")
|
||||
win.geometry("1100x920")
|
||||
win.minsize(900, 640)
|
||||
@@ -4732,7 +4827,7 @@ class SCanvasApp(ctk.CTk):
|
||||
bx0, by0, bx1, by1 = state["bbox"]
|
||||
state["core_rect"] = _MplRect(
|
||||
(bx0, by0), bx1 - bx0, by1 - by0,
|
||||
fill=False, edgecolor="#E74C3C", linewidth=2.2, label="정밀 TIN core")
|
||||
fill=False, edgecolor="#EB001B", linewidth=2.2, label="정밀 TIN core")
|
||||
ax.add_patch(state["core_rect"])
|
||||
# 통계 갱신
|
||||
in_core = ((pts_abs[:, 0] >= bx0) & (pts_abs[:, 0] <= bx1)
|
||||
@@ -4790,8 +4885,8 @@ class SCanvasApp(ctk.CTk):
|
||||
_clear_live()
|
||||
drag["live_rect"] = _MplRect(
|
||||
(bx0, by0), bx1 - bx0, by1 - by0,
|
||||
fill=True, facecolor="#E74C3C", alpha=0.18,
|
||||
edgecolor="#E74C3C", linewidth=2.0)
|
||||
fill=True, facecolor="#EB001B", alpha=0.18,
|
||||
edgecolor="#EB001B", linewidth=2.0)
|
||||
ax.add_patch(drag["live_rect"])
|
||||
stat_lbl.configure(text=(
|
||||
f"드래그 중 {bx1-bx0:.0f}×{by1-by0:.0f} m "
|
||||
@@ -4903,7 +4998,7 @@ class SCanvasApp(ctk.CTk):
|
||||
text="✅ 선택 결과 제출 (이 범위를 정밀 TIN core 로 확정)",
|
||||
command=_on_confirm,
|
||||
height=56,
|
||||
fg_color="#2ECC71", hover_color="#27AE60",
|
||||
fg_color="#22A06B", hover_color="#22A06B",
|
||||
text_color="white",
|
||||
font=ctk.CTkFont(size=16, weight="bold"))
|
||||
submit_btn.pack(side="top", fill="x", padx=10, pady=10)
|
||||
@@ -5021,7 +5116,7 @@ class SCanvasApp(ctk.CTk):
|
||||
feather_m = max(150.0, dem_buffer_m * 0.2)
|
||||
src_crs = self.crs_option.get()
|
||||
|
||||
self.set_status("DEM으로 TIN 확장 중...", "#F1C40F")
|
||||
self.set_status("DEM으로 TIN 확장 중...", "#F79E1B")
|
||||
self.log(f">>> [Step 1.5] DEM으로 TIN 확장 (buffer={dem_buffer_m:.0f}m, feather={feather_m:.0f}m)...")
|
||||
|
||||
# [1.5-CORE] "TIN 이용 범위" 가 설정되어 있으면 **3-zone 블렌드** 선행.
|
||||
@@ -5087,7 +5182,7 @@ class SCanvasApp(ctk.CTk):
|
||||
self.log(f" [DEM 확장] {result.n_points}개 정점, {result.n_faces}개 삼각형")
|
||||
except Exception as e:
|
||||
self.log(f" [Step 1.5] DEM 확장 실패: {e}")
|
||||
self.set_status("DEM 확장 실패", "#E74C3C")
|
||||
self.set_status("DEM 확장 실패", "#EB001B")
|
||||
messagebox.showerror("오류", f"DEM 확장 실패:\n{e}")
|
||||
return
|
||||
|
||||
@@ -5098,7 +5193,7 @@ class SCanvasApp(ctk.CTk):
|
||||
|
||||
# UV 매핑/텍스처 초기화 — 다음 Step 2에서 재생성
|
||||
self.total_mesh = None
|
||||
self.set_status("Step 1.5 완료 — 위성지도 결합 준비", "#2ECC71")
|
||||
self.set_status("Step 1.5 완료 — 위성지도 결합 준비", "#22A06B")
|
||||
self.btn_step2.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0)
|
||||
self.show_3d_preview(textured=False)
|
||||
|
||||
@@ -5334,7 +5429,7 @@ class SCanvasApp(ctk.CTk):
|
||||
messagebox.showwarning("주의", "먼저 TIN을 생성해야 합니다.")
|
||||
return
|
||||
|
||||
self.set_status("위성 이미지 다운로드 중...", "#F1C40F")
|
||||
self.set_status("위성 이미지 다운로드 중...", "#F79E1B")
|
||||
source_name = self.tile_source_option.get()
|
||||
self.log(f">>> [Step 2] 위성 타일 다운로드 ({source_name})...")
|
||||
|
||||
@@ -5380,7 +5475,8 @@ class SCanvasApp(ctk.CTk):
|
||||
if not vk:
|
||||
raise ValueError("Vworld 타일 사용 시 API Key가 필요합니다. 사이드바에 입력해주세요.")
|
||||
tile_url_template = tile_url_template.replace("{vworld_key}", vk)
|
||||
satellite_img = self._download_xyz_tiles(tile_url_template, min_lat, min_lon, max_lat, max_lon)
|
||||
with perf_block("위성 타일 다운로드+병합"):
|
||||
satellite_img = self._download_xyz_tiles(tile_url_template, min_lat, min_lon, max_lat, max_lon)
|
||||
|
||||
img_path = "satellite_temp.png"
|
||||
satellite_img.save(img_path)
|
||||
@@ -5441,14 +5537,14 @@ class SCanvasApp(ctk.CTk):
|
||||
self._uv_mapping_params = None
|
||||
self.log("텍스처 UV 매핑 완료.")
|
||||
|
||||
self.set_status("위성지도 결합 완료", "#2ECC71")
|
||||
self.set_status("위성지도 결합 완료", "#22A06B")
|
||||
self.btn_step3.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0)
|
||||
|
||||
self.show_3d_preview(textured=True, texture_obj=texture)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"결합 실패: {e}")
|
||||
self.set_status("결합 실패", "#E74C3C")
|
||||
self.set_status("결합 실패", "#EB001B")
|
||||
messagebox.showerror("오류", f"위성지도 결합 중 오류 발생:\n{e}")
|
||||
|
||||
def _download_xyz_tiles(self, url_template, min_lat, min_lon, max_lat, max_lon, zoom=17):
|
||||
@@ -5839,7 +5935,7 @@ class SCanvasApp(ctk.CTk):
|
||||
self.log(" ◆ 좌클릭+드래그: 회전 | 휠: 줌 | 우클릭+드래그: 이동")
|
||||
self.log(" ◆ 하단 화면비 버튼 클릭 → 창 크기/캡처 비율 즉시 잠금")
|
||||
self.log(" ◆ 원하는 뷰가 잡히면 Enter 키(또는 q)를 누르거나 창을 닫으세요")
|
||||
self.set_status("뷰포인트를 선택하세요 (Enter로 확정)", "#F1C40F")
|
||||
self.set_status("뷰포인트를 선택하세요 (Enter로 확정)", "#F79E1B")
|
||||
|
||||
try:
|
||||
# 인터랙티브 3D 뷰어 열기 — 사용자가 자유롭게 회전/줌
|
||||
@@ -5847,7 +5943,7 @@ class SCanvasApp(ctk.CTk):
|
||||
|
||||
if self._saved_camera is None:
|
||||
self.log(" 뷰포인트 선택 취소됨.")
|
||||
self.set_status("뷰포인트 미선택", "#E74C3C")
|
||||
self.set_status("뷰포인트 미선택", "#EB001B")
|
||||
return
|
||||
|
||||
# 선택된 카메라 위치 로그 (focal/up은 카메라 복원 시 다시 읽음)
|
||||
@@ -5861,30 +5957,31 @@ class SCanvasApp(ctk.CTk):
|
||||
ar_label = f"비율 {ar[0]}:{ar[1]}" if ar else f"뷰어 창 {self._saved_window_size or '미저장'}"
|
||||
self.log(f" 캡처 해상도: {out_w}x{out_h} ({ar_label} 기반)")
|
||||
|
||||
# 1. 위성 텍스처 3D 캡처
|
||||
self.capture_image = self._capture_from_camera(out_w, out_h, textured=True)
|
||||
self.capture_image.save("capture_textured.png")
|
||||
self.log(f" 캡처 완료: {self.capture_image.size}")
|
||||
with perf_block("control map capture x3 + composite"):
|
||||
# 1. 위성 텍스처 3D 캡처
|
||||
self.capture_image = self._capture_from_camera(out_w, out_h, textured=True)
|
||||
self.capture_image.save("capture_textured.png")
|
||||
self.log(f" 캡처 완료: {self.capture_image.size}")
|
||||
|
||||
# 2. Depth Map
|
||||
self.log(" Depth Map 추출 중...")
|
||||
self.depth_map = self._capture_depth_from_camera(out_w, out_h)
|
||||
self.depth_map.save("depth_map.png")
|
||||
self.log(" Depth Map 완료.")
|
||||
# 2. Depth Map
|
||||
self.log(" Depth Map 추출 중...")
|
||||
self.depth_map = self._capture_depth_from_camera(out_w, out_h)
|
||||
self.depth_map.save("depth_map.png")
|
||||
self.log(" Depth Map 완료.")
|
||||
|
||||
# 3. Lineart Map
|
||||
self.log(" Lineart Map 추출 중...")
|
||||
self.lineart_map = self._capture_lineart_from_camera(out_w, out_h)
|
||||
self.lineart_map.save("lineart_map.png")
|
||||
self.log(" Lineart Map 완료.")
|
||||
# 3. Lineart Map
|
||||
self.log(" Lineart Map 추출 중...")
|
||||
self.lineart_map = self._capture_lineart_from_camera(out_w, out_h)
|
||||
self.lineart_map.save("lineart_map.png")
|
||||
self.log(" Lineart Map 완료.")
|
||||
|
||||
# 4. 가이드 이미지 합성
|
||||
self.guide_image = self._compose_guide_image(
|
||||
self.capture_image, self.depth_map, self.lineart_map
|
||||
)
|
||||
self.guide_image.save("guide_composite.png")
|
||||
# 4. 가이드 이미지 합성
|
||||
self.guide_image = self._compose_guide_image(
|
||||
self.capture_image, self.depth_map, self.lineart_map
|
||||
)
|
||||
self.guide_image.save("guide_composite.png")
|
||||
|
||||
self.set_status("제어맵 추출 완료", "#2ECC71")
|
||||
self.set_status("제어맵 추출 완료", "#22A06B")
|
||||
self.btn_step4.configure(fg_color=["#3a7ebf", "#1f538d"])
|
||||
self.log(" 저장 완료: capture_textured.png, depth_map.png, lineart_map.png, guide_composite.png")
|
||||
|
||||
@@ -5894,7 +5991,7 @@ class SCanvasApp(ctk.CTk):
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"제어맵 추출 실패: {e}")
|
||||
self.set_status("추출 실패", "#E74C3C")
|
||||
self.set_status("추출 실패", "#EB001B")
|
||||
messagebox.showerror("오류", f"제어맵 추출 중 오류:\n{e}")
|
||||
|
||||
def _open_interactive_viewer(self):
|
||||
@@ -6550,7 +6647,7 @@ class SCanvasApp(ctk.CTk):
|
||||
engine = self.render_engine.get()
|
||||
|
||||
# 시간대 + 출력 화질 선택 (Step 4 실행 시)
|
||||
time_win = ctk.CTkToplevel(self)
|
||||
time_win = InlinePanel(self)
|
||||
time_win.title("렌더링 옵션")
|
||||
time_win.geometry("380x360")
|
||||
time_win.grab_set()
|
||||
@@ -6655,7 +6752,7 @@ class SCanvasApp(ctk.CTk):
|
||||
location = self.vertex_location.get().strip() or "global"
|
||||
self.set_status(
|
||||
f"Gemini 렌더링 중 ({('Vertex AI' if use_vertex else 'API')})...",
|
||||
"#F1C40F"
|
||||
"#F79E1B"
|
||||
)
|
||||
thread = threading.Thread(
|
||||
target=self._run_gemini_render,
|
||||
@@ -6672,7 +6769,7 @@ class SCanvasApp(ctk.CTk):
|
||||
"또는 'Gemini (Nano Banana)'로 변경하세요.")
|
||||
return
|
||||
|
||||
self.set_status("AI 렌더링 중... (15~60초 소요)", "#F1C40F")
|
||||
self.set_status("AI 렌더링 중... (15~60초 소요)", "#F79E1B")
|
||||
thread = threading.Thread(
|
||||
target=self._run_stability_render,
|
||||
args=(key, final_prompt, strength),
|
||||
@@ -6740,7 +6837,7 @@ class SCanvasApp(ctk.CTk):
|
||||
if self.job_logger and db and job_id:
|
||||
self.job_logger.fail_job(db, job_id, "모든 API 방법 실패")
|
||||
self.after(0, lambda: self.log(" 모든 방법 실패"))
|
||||
self.after(0, lambda: self.set_status("AI 렌더링 실패", "#E74C3C"))
|
||||
self.after(0, lambda: self.set_status("AI 렌더링 실패", "#EB001B"))
|
||||
return
|
||||
|
||||
# 출력 화질 후처리 — 사용자가 Step 4에서 고른 HD/FHD/UHD 로 리사이즈
|
||||
@@ -6774,7 +6871,7 @@ class SCanvasApp(ctk.CTk):
|
||||
self.after(0, lambda: self.log(
|
||||
f" AI 렌더링 완료! → {output_path} ({rendered.size}) "
|
||||
f"[{latency_ms:.0f}ms, 품질={quality_score:.2f}]"))
|
||||
self.after(0, lambda: self.set_status("AI 렌더링 완료", "#2ECC71"))
|
||||
self.after(0, lambda: self.set_status("AI 렌더링 완료", "#22A06B"))
|
||||
self.after(0, lambda: self._show_rendered_result(output_path))
|
||||
|
||||
except Exception as e:
|
||||
@@ -6782,7 +6879,7 @@ class SCanvasApp(ctk.CTk):
|
||||
with contextlib.suppress(Exception):
|
||||
self.job_logger.fail_job(db, job_id, str(e))
|
||||
self.after(0, lambda e=e: self.log(f" 렌더링 오류: {e}"))
|
||||
self.after(0, lambda: self.set_status("렌더링 실패", "#E74C3C"))
|
||||
self.after(0, lambda: self.set_status("렌더링 실패", "#EB001B"))
|
||||
self.after(0, lambda e=e: messagebox.showerror("오류", f"AI 렌더링 중 오류:\n{e}"))
|
||||
finally:
|
||||
if db:
|
||||
@@ -6910,7 +7007,7 @@ class SCanvasApp(ctk.CTk):
|
||||
bg.convert("RGBA"), pil_img
|
||||
).convert("RGB")
|
||||
|
||||
win = ctk.CTkToplevel(self)
|
||||
win = InlinePanel(self)
|
||||
win.title(f"🎨 Blender 렌더 결과 - {Path(image_path).name}")
|
||||
|
||||
sw = self.winfo_screenwidth(); sh = self.winfo_screenheight()
|
||||
@@ -6983,7 +7080,7 @@ class SCanvasApp(ctk.CTk):
|
||||
try:
|
||||
from PIL import ImageTk
|
||||
|
||||
win = ctk.CTkToplevel(self)
|
||||
win = InlinePanel(self)
|
||||
win.title("S-CANVAS: AI 렌더링 결과")
|
||||
win.geometry("820x860")
|
||||
|
||||
@@ -7024,17 +7121,5 @@ if __name__ == "__main__":
|
||||
except Exception as _ch_err:
|
||||
print(f"[crash_logger] 설치 실패 (계속 진행): {_ch_err}")
|
||||
|
||||
# 인트로 스플래시 — Design/logo_intro.mp4 재생 후 메인 앱 기동.
|
||||
# 실패·파일 없음 시 조용히 skip(메인 앱은 항상 뜸).
|
||||
try:
|
||||
from splash import show_intro_splash
|
||||
show_intro_splash(
|
||||
resource_path("Design", "logo_intro.mp4"),
|
||||
max_duration_s=12.0,
|
||||
fade_ms=400,
|
||||
)
|
||||
except Exception as _splash_err:
|
||||
print(f"[Intro] 스플래시 경고: {_splash_err}")
|
||||
|
||||
app = SCanvasApp()
|
||||
app.mainloop()
|
||||
|
||||
178
splash.py
178
splash.py
@@ -1,178 +0,0 @@
|
||||
"""S-CANVAS 인트로 로딩 스플래시.
|
||||
|
||||
Design/logo_intro.mp4 를 frameless Toplevel 중앙에 재생한 뒤 자동 종료.
|
||||
메인 앱(`scanvas_maker.SCanvasApp`) 기동 직전에 호출한다.
|
||||
|
||||
기술 스택:
|
||||
- cv2 (VideoCapture) : MP4 프레임 디코드 (harness/quality_validator 가
|
||||
이미 의존하는 프로젝트 공통 dep).
|
||||
- PIL + tkinter.PhotoImage : 프레임 렌더.
|
||||
- tk.Tk + attributes("-alpha", ...) : frameless + 페이드 인/아웃 효과.
|
||||
|
||||
dynamic effects(사용자 요구 "역동적으로"):
|
||||
1. 시작 시 알파 0 → 1 페이드인 (400ms)
|
||||
2. 비디오 자체 애니메이션(logo_intro.mp4 는 고유 모션 포함)
|
||||
3. 종료 전 알파 1 → 0 페이드아웃 (400ms)
|
||||
4. 비디오 하단에 브랜드 tagline bar (오렌지 italic)
|
||||
5. max_duration_s 초과 시 강제 종료(safety)
|
||||
|
||||
실패 조건(조용히 skip):
|
||||
- logo_intro.mp4 없음
|
||||
- cv2/PIL import 실패
|
||||
- VideoCapture.open 실패
|
||||
메인 앱 기동은 항상 보장된다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
import contextlib
|
||||
|
||||
|
||||
def show_intro_splash(
|
||||
video_path,
|
||||
max_duration_s: float = 12.0,
|
||||
fade_ms: int = 400,
|
||||
tagline: str = "S-CANVAS — Generative Design & Visualization Engine",
|
||||
max_display_w: int = 1000,
|
||||
) -> None:
|
||||
"""video_path 의 MP4 를 스플래시로 재생 후 블로킹 반환.
|
||||
|
||||
Args:
|
||||
video_path: Path-like, .mp4 파일.
|
||||
max_duration_s: 비디오가 이 시간을 넘기면 강제 종료.
|
||||
fade_ms: 페이드인/아웃 각 구간 길이(ms).
|
||||
tagline: 비디오 아래에 표시할 문구.
|
||||
max_display_w: 화면상 최대 가로(px). 이보다 크면 aspect 유지 축소.
|
||||
|
||||
동작:
|
||||
- 임시 tk.Tk 루트를 만들고 mainloop → 종료 시 완전 destroy.
|
||||
- 이후 `SCanvasApp()` 이 새 ctk.CTk 인스턴스를 만들어도 충돌 없음
|
||||
(Tk 루트가 이미 파괴됐으므로).
|
||||
"""
|
||||
try:
|
||||
import cv2
|
||||
from PIL import Image, ImageTk
|
||||
except ImportError as e:
|
||||
print(f"[Intro] cv2/PIL 미설치 — 스플래시 skip ({e})")
|
||||
return
|
||||
|
||||
vp = Path(video_path)
|
||||
if not vp.exists():
|
||||
print(f"[Intro] 비디오 없음 ({vp}) — 스플래시 skip")
|
||||
return
|
||||
|
||||
cap = cv2.VideoCapture(str(vp))
|
||||
if not cap.isOpened():
|
||||
print("[Intro] VideoCapture 열기 실패 — 스플래시 skip")
|
||||
return
|
||||
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||
frame_ms = max(int(1000.0 / max(fps, 1.0)), 8)
|
||||
vw = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 800)
|
||||
vh = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 450)
|
||||
|
||||
if vw > max_display_w:
|
||||
disp_w = max_display_w
|
||||
disp_h = max(1, int(vh * max_display_w / vw))
|
||||
else:
|
||||
disp_w, disp_h = vw, vh
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
screen_w = root.winfo_screenwidth()
|
||||
screen_h = root.winfo_screenheight()
|
||||
|
||||
TAG_H = 44
|
||||
total_h = disp_h + TAG_H
|
||||
x = max(0, (screen_w - disp_w) // 2)
|
||||
y = max(0, (screen_h - total_h) // 2)
|
||||
|
||||
splash = tk.Toplevel(root)
|
||||
splash.overrideredirect(True)
|
||||
try:
|
||||
splash.attributes("-topmost", True)
|
||||
splash.attributes("-alpha", 0.0)
|
||||
except tk.TclError:
|
||||
pass
|
||||
splash.configure(bg="#0A0F1C")
|
||||
splash.geometry(f"{disp_w}x{total_h}+{x}+{y}")
|
||||
|
||||
vid_label = tk.Label(splash, bg="#0A0F1C", borderwidth=0, highlightthickness=0)
|
||||
vid_label.place(x=0, y=0, width=disp_w, height=disp_h)
|
||||
|
||||
tag_label = tk.Label(
|
||||
splash, text=tagline,
|
||||
bg="#0A0F1C", fg="#E67E22",
|
||||
font=("Segoe UI", 10, "italic"),
|
||||
borderwidth=0, highlightthickness=0,
|
||||
)
|
||||
tag_label.place(x=0, y=disp_h, width=disp_w, height=TAG_H)
|
||||
|
||||
state = {"closed": False, "t_start": time.time()}
|
||||
|
||||
def _safe_alpha(a):
|
||||
with contextlib.suppress(tk.TclError):
|
||||
splash.attributes("-alpha", max(0.0, min(1.0, a)))
|
||||
|
||||
def _fade_to(target, duration_ms, done_cb=None):
|
||||
steps = max(int(duration_ms / 16), 1)
|
||||
start_alpha = float(splash.attributes("-alpha") or 0.0)
|
||||
|
||||
def _step(i):
|
||||
if state["closed"]:
|
||||
return
|
||||
ratio = i / steps
|
||||
_safe_alpha(start_alpha + (target - start_alpha) * ratio)
|
||||
if i < steps:
|
||||
splash.after(16, lambda: _step(i + 1))
|
||||
elif done_cb:
|
||||
done_cb()
|
||||
|
||||
_step(0)
|
||||
|
||||
def _close():
|
||||
if state["closed"]:
|
||||
return
|
||||
state["closed"] = True
|
||||
with contextlib.suppress(Exception):
|
||||
cap.release()
|
||||
with contextlib.suppress(Exception):
|
||||
splash.destroy()
|
||||
with contextlib.suppress(Exception):
|
||||
root.quit()
|
||||
|
||||
def _play_next_frame():
|
||||
if state["closed"]:
|
||||
return
|
||||
if (time.time() - state["t_start"]) > max_duration_s:
|
||||
_fade_to(0.0, fade_ms, done_cb=_close)
|
||||
return
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
# 비디오 끝 — 페이드아웃 후 종료
|
||||
_fade_to(0.0, fade_ms, done_cb=_close)
|
||||
return
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
if (vw, vh) != (disp_w, disp_h):
|
||||
rgb = cv2.resize(rgb, (disp_w, disp_h), interpolation=cv2.INTER_AREA)
|
||||
img = Image.fromarray(rgb)
|
||||
photo = ImageTk.PhotoImage(img)
|
||||
vid_label.configure(image=photo)
|
||||
vid_label.image = photo # GC 방지
|
||||
splash.after(frame_ms, _play_next_frame)
|
||||
|
||||
# 페이드인 → 첫 프레임 재생 시작
|
||||
_fade_to(1.0, fade_ms, done_cb=_play_next_frame)
|
||||
|
||||
try:
|
||||
root.mainloop()
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
root.destroy()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 단독 테스트
|
||||
show_intro_splash(Path(__file__).resolve().parent / "Design" / "logo_intro.mp4")
|
||||
Reference in New Issue
Block a user