camera-llm 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- camera_llm-0.1.1/PKG-INFO +145 -0
- camera_llm-0.1.1/README.md +133 -0
- camera_llm-0.1.1/camera_llm/__init__.py +1 -0
- camera_llm-0.1.1/camera_llm/camera_thread.py +82 -0
- camera_llm-0.1.1/camera_llm/chat_session.py +81 -0
- camera_llm-0.1.1/camera_llm/chat_store.py +56 -0
- camera_llm-0.1.1/camera_llm/cli.py +72 -0
- camera_llm-0.1.1/camera_llm/icon.ico +0 -0
- camera_llm-0.1.1/camera_llm/llm_client.py +165 -0
- camera_llm-0.1.1/camera_llm/main_window.py +102 -0
- camera_llm-0.1.1/camera_llm/screens/__init__.py +1 -0
- camera_llm-0.1.1/camera_llm/screens/screen1_home.py +242 -0
- camera_llm-0.1.1/camera_llm/screens/screen2_capture.py +305 -0
- camera_llm-0.1.1/camera_llm/screens/screen3_crop.py +263 -0
- camera_llm-0.1.1/camera_llm/screens/screen4_model_select.py +184 -0
- camera_llm-0.1.1/camera_llm/screens/screen5_chat.py +514 -0
- camera_llm-0.1.1/camera_llm/screens/screen6_save.py +127 -0
- camera_llm-0.1.1/camera_llm/screens/screen7_done.py +88 -0
- camera_llm-0.1.1/camera_llm/styles.py +319 -0
- camera_llm-0.1.1/camera_llm.egg-info/PKG-INFO +145 -0
- camera_llm-0.1.1/camera_llm.egg-info/SOURCES.txt +25 -0
- camera_llm-0.1.1/camera_llm.egg-info/dependency_links.txt +1 -0
- camera_llm-0.1.1/camera_llm.egg-info/entry_points.txt +2 -0
- camera_llm-0.1.1/camera_llm.egg-info/requires.txt +5 -0
- camera_llm-0.1.1/camera_llm.egg-info/top_level.txt +1 -0
- camera_llm-0.1.1/pyproject.toml +26 -0
- camera_llm-0.1.1/setup.cfg +4 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: camera-llm
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A local application to feed images/videos from a webcam into local LLMs for analysis and conversations.
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: PySide6>=6.6.0
|
|
8
|
+
Requires-Dist: opencv-python>=4.9.0
|
|
9
|
+
Requires-Dist: openai>=1.30.0
|
|
10
|
+
Requires-Dist: numpy>=1.26.0
|
|
11
|
+
Requires-Dist: pyqtdarktheme==0.1.7
|
|
12
|
+
|
|
13
|
+
# Camera → Local LLM Inference App
|
|
14
|
+
|
|
15
|
+
## Description
|
|
16
|
+
|
|
17
|
+
A Python desktop app that captures images or short video clips from a webcam, optionally crops them, and sends them **in-memory** (no external file needed to be saved previously) to a local LLM via LM Studio for conversational analysis.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Project Structure (Main Files)
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
Camera_LLM_Inference/
|
|
25
|
+
└── camera_llm/
|
|
26
|
+
├── __init__.py
|
|
27
|
+
├── camera_thread.py # QThread for OpenCV camera capture
|
|
28
|
+
├── chat_session.py # ChatSession dataclass + JSON serialisation
|
|
29
|
+
├── chat_store.py # Read/write chat sessions to chats/*.json
|
|
30
|
+
├── cli.py # Entry point handling CLI and launching app (app initialization)
|
|
31
|
+
├── llm_client.py # OpenAI client → LM Studio, in-memory encode
|
|
32
|
+
├── main_window.py # Main window that aids navigation among the 7 screens
|
|
33
|
+
├── styles.py # Global stylesheet + design tokens
|
|
34
|
+
└── screens/
|
|
35
|
+
├── __init__.py
|
|
36
|
+
├── screen1_home.py # Dashboard + saved chats panel
|
|
37
|
+
├── screen2_capture.py # Live camera feed, capture/record controls
|
|
38
|
+
├── screen3_crop.py # Rubber-band crop with dimming overlay
|
|
39
|
+
├── screen4_model_select.py # LM Studio URL + model dropdown
|
|
40
|
+
├── screen5_chat.py # Chatbot with streaming, thumbnails, bubbles
|
|
41
|
+
├── screen6_save.py # Name & save the session
|
|
42
|
+
└── screen7_done.py # Confirmation + auto-redirect home
|
|
43
|
+
└── chats/ # Auto-created at runtime for saved sessions
|
|
44
|
+
├── pyproject.toml # Package metadata + entry points
|
|
45
|
+
├── requirements.txt # pip dependencies
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## How to Run
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
cd "c:\Users\kurei\Documents\Machine_Deep Learning\Camera_LLM_Inference"
|
|
54
|
+
pip install -e .
|
|
55
|
+
camera-llm run
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Prerequisites
|
|
59
|
+
1. **Webcam** connected or **IP camera** with reachable IP address (e.g. `http://[IP_ADDRESS]`)
|
|
60
|
+
2. **LM Studio** running with a **vision model** loaded (e.g. LLaVA, Qwen-VL)
|
|
61
|
+
3. LM Studio **local server started** (default: `http://localhost:1234`)
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Optional (download distribution zip file)
|
|
66
|
+
|
|
67
|
+
Alternatively, you can [Download the Distribution Zip File to Run as Standalone Application](https://drive.proton.me/urls/ASD3RNEVY0#txhcUP2W8rzJ). This saves you the time of downloading the repo and installing the other dependencies, but it also takes around 2.5 GB of disk space (in addition to less frequent updates). Once downloaded, unzip the file then navigate to "\CameraLLMInference\CameraLLMInference.exe" to run the application.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## User Flow
|
|
72
|
+
|
|
73
|
+
```mermaid
|
|
74
|
+
graph TD;
|
|
75
|
+
S1["Screen 1: Home"] -->|Image| S2I["Screen 2: Camera (Image)"];
|
|
76
|
+
S1 -->|Video| S2V["Screen 2: Camera (Video)"];
|
|
77
|
+
S2I -->|Capture| S3["Screen 3: Crop"];
|
|
78
|
+
S2V -->|Stop recording| S3;
|
|
79
|
+
S3 -->|Use Full / Apply Crop| S4["Screen 4: Model Select"];
|
|
80
|
+
S4 -->|Analyse| S5["Screen 5: Chat"];
|
|
81
|
+
S5 -->|Finish| S6["Screen 6: Save?"];
|
|
82
|
+
S6 -->|Save / Skip| S7["Screen 7: Done"];
|
|
83
|
+
S7 -->|3s auto| S1;
|
|
84
|
+
S1 -->|Open saved chat| S5P["Screen 5: Chat (past chat)"];
|
|
85
|
+
S5P -->|Continue Chatting| S5;
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Change Camera Feed Guide
|
|
91
|
+
|
|
92
|
+
1. If you are using a standard USB webcam, you can just type 0, 1, 2, etc. and hit Enter. It will connect as a regular USB camera.
|
|
93
|
+
2. If you want to use a smartphone, download a free app like IP Webcam (Android) or similar apps for iOS that broadcast your camera over WiFi. Start the server to use as a WiFi camera
|
|
94
|
+
3. Note the URL that will be set (ex: http://192.168.1.100:8080).
|
|
95
|
+
4. Paste that exact URL into the 'Camera' text box and press Enter.
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Key Design Decisions
|
|
101
|
+
|
|
102
|
+
| Decision | Choice | Rationale |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| **GUI framework** | PySide6 | Robust threading (QThread), rich widget set, no licensing issues |
|
|
105
|
+
| **Camera** | OpenCV in QThread | Non-blocking — GUI stays responsive at 30 fps |
|
|
106
|
+
| **In-memory encoding** | `cv2.imencode` → base64 | No file ever touches disk; data goes straight to the LLM API |
|
|
107
|
+
| **Video → LLM** | Sample up to 8 frames | Local VLMs don't accept video — sending evenly-spaced frames approximates it |
|
|
108
|
+
| **LLM API** | `openai` client → `localhost:1234` | LM Studio is OpenAI-compatible; swapping to a cloud provider later is trivial |
|
|
109
|
+
| **Chat persistence** | JSON files in `chats/` | Simple, portable, human-readable |
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Features
|
|
114
|
+
|
|
115
|
+
- 7 Screens (Home, Capture, Crop, Model Select, Chat, Save, Done)
|
|
116
|
+
- Image and Video Capture (With Rotate Feed Capability)
|
|
117
|
+
- LLM Chat with Streaming
|
|
118
|
+
- Save/Load Chat Sessions (Chats are listed with the last modified date and time)
|
|
119
|
+
- Model Selection (LLM URL + Model Dropdown)
|
|
120
|
+
- Delete or Rename Saved Chats
|
|
121
|
+
> [!CAUTION]
|
|
122
|
+
> Remember to load the same model that was used when first starting the chat (referenced in the title)
|
|
123
|
+
|
|
124
|
+
> [!NOTE]
|
|
125
|
+
> The video input feature works by sending multiple frames from the video to the LLM in order to process as multiple images. The multiple frames (images) are sent as a panel (chained images) so the LLM can interpret it as one image (full context). This is done primarily due to employing a local LLM.
|
|
126
|
+
|
|
127
|
+
## What Was Verified
|
|
128
|
+
|
|
129
|
+
- ✅ All dependencies install cleanly
|
|
130
|
+
- ✅ All module imports resolve without errors
|
|
131
|
+
- ✅ App launches and renders Screen 1 correctly
|
|
132
|
+
- ✅ Fixed `pyqtdarktheme` API for v0.1.7 (`load_stylesheet` instead of `setup_theme`)
|
|
133
|
+
|
|
134
|
+
## Physical Testing Passed
|
|
135
|
+
- 📷 Camera feed with a physical webcam
|
|
136
|
+
- 🎬 Video recording and frame sampling
|
|
137
|
+
- 🤖 End-to-end LLM chat (requires LM Studio with a vision model running)
|
|
138
|
+
- 💾 Save/load chat sessions
|
|
139
|
+
- 📱 Verify smartphone can be used as webcam (App used in Testing: **IP Webcam** for Android)
|
|
140
|
+
|
|
141
|
+
## ⏳ In Progress
|
|
142
|
+
- More robust UI
|
|
143
|
+
|
|
144
|
+
> [!IMPORTANT]
|
|
145
|
+
> Some dummy chats are present already under the Saved Chats section (`chats/` folder). This was collected during testing and left to provide various samples. As such, they can be safely deleted.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Camera → Local LLM Inference App
|
|
2
|
+
|
|
3
|
+
## Description
|
|
4
|
+
|
|
5
|
+
A Python desktop app that captures images or short video clips from a webcam, optionally crops them, and sends them **in-memory** (no external file needed to be saved previously) to a local LLM via LM Studio for conversational analysis.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Project Structure (Main Files)
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Camera_LLM_Inference/
|
|
13
|
+
└── camera_llm/
|
|
14
|
+
├── __init__.py
|
|
15
|
+
├── camera_thread.py # QThread for OpenCV camera capture
|
|
16
|
+
├── chat_session.py # ChatSession dataclass + JSON serialisation
|
|
17
|
+
├── chat_store.py # Read/write chat sessions to chats/*.json
|
|
18
|
+
├── cli.py # Entry point handling CLI and launching app (app initialization)
|
|
19
|
+
├── llm_client.py # OpenAI client → LM Studio, in-memory encode
|
|
20
|
+
├── main_window.py # Main window that aids navigation among the 7 screens
|
|
21
|
+
├── styles.py # Global stylesheet + design tokens
|
|
22
|
+
└── screens/
|
|
23
|
+
├── __init__.py
|
|
24
|
+
├── screen1_home.py # Dashboard + saved chats panel
|
|
25
|
+
├── screen2_capture.py # Live camera feed, capture/record controls
|
|
26
|
+
├── screen3_crop.py # Rubber-band crop with dimming overlay
|
|
27
|
+
├── screen4_model_select.py # LM Studio URL + model dropdown
|
|
28
|
+
├── screen5_chat.py # Chatbot with streaming, thumbnails, bubbles
|
|
29
|
+
├── screen6_save.py # Name & save the session
|
|
30
|
+
└── screen7_done.py # Confirmation + auto-redirect home
|
|
31
|
+
└── chats/ # Auto-created at runtime for saved sessions
|
|
32
|
+
├── pyproject.toml # Package metadata + entry points
|
|
33
|
+
├── requirements.txt # pip dependencies
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## How to Run
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
cd "c:\Users\kurei\Documents\Machine_Deep Learning\Camera_LLM_Inference"
|
|
42
|
+
pip install -e .
|
|
43
|
+
camera-llm run
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Prerequisites
|
|
47
|
+
1. **Webcam** connected or **IP camera** with reachable IP address (e.g. `http://[IP_ADDRESS]`)
|
|
48
|
+
2. **LM Studio** running with a **vision model** loaded (e.g. LLaVA, Qwen-VL)
|
|
49
|
+
3. LM Studio **local server started** (default: `http://localhost:1234`)
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Optional (download distribution zip file)
|
|
54
|
+
|
|
55
|
+
Alternatively, you can [Download the Distribution Zip File to Run as Standalone Application](https://drive.proton.me/urls/ASD3RNEVY0#txhcUP2W8rzJ). This saves you the time of downloading the repo and installing the other dependencies, but it also takes around 2.5 GB of disk space (in addition to less frequent updates). Once downloaded, unzip the file then navigate to "\CameraLLMInference\CameraLLMInference.exe" to run the application.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## User Flow
|
|
60
|
+
|
|
61
|
+
```mermaid
|
|
62
|
+
graph TD;
|
|
63
|
+
S1["Screen 1: Home"] -->|Image| S2I["Screen 2: Camera (Image)"];
|
|
64
|
+
S1 -->|Video| S2V["Screen 2: Camera (Video)"];
|
|
65
|
+
S2I -->|Capture| S3["Screen 3: Crop"];
|
|
66
|
+
S2V -->|Stop recording| S3;
|
|
67
|
+
S3 -->|Use Full / Apply Crop| S4["Screen 4: Model Select"];
|
|
68
|
+
S4 -->|Analyse| S5["Screen 5: Chat"];
|
|
69
|
+
S5 -->|Finish| S6["Screen 6: Save?"];
|
|
70
|
+
S6 -->|Save / Skip| S7["Screen 7: Done"];
|
|
71
|
+
S7 -->|3s auto| S1;
|
|
72
|
+
S1 -->|Open saved chat| S5P["Screen 5: Chat (past chat)"];
|
|
73
|
+
S5P -->|Continue Chatting| S5;
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Change Camera Feed Guide
|
|
79
|
+
|
|
80
|
+
1. If you are using a standard USB webcam, you can just type 0, 1, 2, etc. and hit Enter. It will connect as a regular USB camera.
|
|
81
|
+
2. If you want to use a smartphone, download a free app like IP Webcam (Android) or similar apps for iOS that broadcast your camera over WiFi. Start the server to use as a WiFi camera
|
|
82
|
+
3. Note the URL that will be set (ex: http://192.168.1.100:8080).
|
|
83
|
+
4. Paste that exact URL into the 'Camera' text box and press Enter.
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Key Design Decisions
|
|
89
|
+
|
|
90
|
+
| Decision | Choice | Rationale |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| **GUI framework** | PySide6 | Robust threading (QThread), rich widget set, no licensing issues |
|
|
93
|
+
| **Camera** | OpenCV in QThread | Non-blocking — GUI stays responsive at 30 fps |
|
|
94
|
+
| **In-memory encoding** | `cv2.imencode` → base64 | No file ever touches disk; data goes straight to the LLM API |
|
|
95
|
+
| **Video → LLM** | Sample up to 8 frames | Local VLMs don't accept video — sending evenly-spaced frames approximates it |
|
|
96
|
+
| **LLM API** | `openai` client → `localhost:1234` | LM Studio is OpenAI-compatible; swapping to a cloud provider later is trivial |
|
|
97
|
+
| **Chat persistence** | JSON files in `chats/` | Simple, portable, human-readable |
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Features
|
|
102
|
+
|
|
103
|
+
- 7 Screens (Home, Capture, Crop, Model Select, Chat, Save, Done)
|
|
104
|
+
- Image and Video Capture (With Rotate Feed Capability)
|
|
105
|
+
- LLM Chat with Streaming
|
|
106
|
+
- Save/Load Chat Sessions (Chats are listed with the last modified date and time)
|
|
107
|
+
- Model Selection (LLM URL + Model Dropdown)
|
|
108
|
+
- Delete or Rename Saved Chats
|
|
109
|
+
> [!CAUTION]
|
|
110
|
+
> Remember to load the same model that was used when first starting the chat (referenced in the title)
|
|
111
|
+
|
|
112
|
+
> [!NOTE]
|
|
113
|
+
> The video input feature works by sending multiple frames from the video to the LLM in order to process as multiple images. The multiple frames (images) are sent as a panel (chained images) so the LLM can interpret it as one image (full context). This is done primarily due to employing a local LLM.
|
|
114
|
+
|
|
115
|
+
## What Was Verified
|
|
116
|
+
|
|
117
|
+
- ✅ All dependencies install cleanly
|
|
118
|
+
- ✅ All module imports resolve without errors
|
|
119
|
+
- ✅ App launches and renders Screen 1 correctly
|
|
120
|
+
- ✅ Fixed `pyqtdarktheme` API for v0.1.7 (`load_stylesheet` instead of `setup_theme`)
|
|
121
|
+
|
|
122
|
+
## Physical Testing Passed
|
|
123
|
+
- 📷 Camera feed with a physical webcam
|
|
124
|
+
- 🎬 Video recording and frame sampling
|
|
125
|
+
- 🤖 End-to-end LLM chat (requires LM Studio with a vision model running)
|
|
126
|
+
- 💾 Save/load chat sessions
|
|
127
|
+
- 📱 Verify smartphone can be used as webcam (App used in Testing: **IP Webcam** for Android)
|
|
128
|
+
|
|
129
|
+
## ⏳ In Progress
|
|
130
|
+
- More robust UI
|
|
131
|
+
|
|
132
|
+
> [!IMPORTANT]
|
|
133
|
+
> Some dummy chats are present already under the Saved Chats section (`chats/` folder). This was collected during testing and left to provide various samples. As such, they can be safely deleted.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Camera LLM Inference App
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CameraThread — captures frames from OpenCV VideoCapture in a background QThread
|
|
3
|
+
and emits frame_ready(np.ndarray) signals to the GUI at ~30 fps.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import cv2
|
|
8
|
+
import numpy as np
|
|
9
|
+
from PySide6.QtCore import QThread, Signal
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CameraThread(QThread):
|
|
13
|
+
"""Background thread that continuously reads from a camera device."""
|
|
14
|
+
|
|
15
|
+
frame_ready = Signal(np.ndarray)
|
|
16
|
+
error = Signal(str)
|
|
17
|
+
|
|
18
|
+
def __init__(self, camera_source: int | str = 0, parent=None):
|
|
19
|
+
super().__init__(parent)
|
|
20
|
+
self.camera_source = camera_source
|
|
21
|
+
self._running = False
|
|
22
|
+
self._cap: cv2.VideoCapture | None = None
|
|
23
|
+
|
|
24
|
+
# ── Public API ──────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
def start_capture(self, camera_source: int | str | None = None) -> None:
|
|
27
|
+
if camera_source is not None:
|
|
28
|
+
self.camera_source = camera_source
|
|
29
|
+
self._running = True
|
|
30
|
+
self.start()
|
|
31
|
+
|
|
32
|
+
def stop_capture(self) -> None:
|
|
33
|
+
self._running = False
|
|
34
|
+
self.wait(2000) # give thread up to 2 s to finish
|
|
35
|
+
|
|
36
|
+
# ── QThread lifecycle ────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
def run(self) -> None:
|
|
39
|
+
if isinstance(self.camera_source, int):
|
|
40
|
+
self._cap = cv2.VideoCapture(self.camera_source, cv2.CAP_DSHOW)
|
|
41
|
+
if not self._cap.isOpened():
|
|
42
|
+
# Try without backend hint (Linux / macOS)
|
|
43
|
+
self._cap = cv2.VideoCapture(self.camera_source)
|
|
44
|
+
else:
|
|
45
|
+
# IP camera URL
|
|
46
|
+
url = str(self.camera_source).strip()
|
|
47
|
+
# If the user enters a bare IP Webcam URL like "http://10.0.0.249:8080",
|
|
48
|
+
# OpenCV needs the actual video stream endpoint, which is usually "/video".
|
|
49
|
+
if url.startswith("http") and url.count("/") == 2:
|
|
50
|
+
url += "/video"
|
|
51
|
+
elif url.startswith("http") and url.count("/") == 3 and url.endswith("/"):
|
|
52
|
+
url += "video"
|
|
53
|
+
self._cap = cv2.VideoCapture(url)
|
|
54
|
+
|
|
55
|
+
if not self._cap.isOpened():
|
|
56
|
+
self.error.emit(f"Cannot open camera source: {self.camera_source}")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# Prefer 720p for a good quality / performance balance
|
|
60
|
+
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
|
|
61
|
+
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
|
|
62
|
+
self._cap.set(cv2.CAP_PROP_FPS, 30)
|
|
63
|
+
|
|
64
|
+
while self._running:
|
|
65
|
+
ret, frame = self._cap.read()
|
|
66
|
+
if not ret:
|
|
67
|
+
self.error.emit("Failed to read frame from camera")
|
|
68
|
+
break
|
|
69
|
+
# Convert BGR → RGB for Qt display
|
|
70
|
+
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
71
|
+
self.frame_ready.emit(frame_rgb)
|
|
72
|
+
# ~30 fps → sleep ~33 ms
|
|
73
|
+
self.msleep(33)
|
|
74
|
+
|
|
75
|
+
if self._cap:
|
|
76
|
+
self._cap.release()
|
|
77
|
+
self._cap = None
|
|
78
|
+
|
|
79
|
+
def __del__(self):
|
|
80
|
+
self._running = False
|
|
81
|
+
if self._cap:
|
|
82
|
+
self._cap.release()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChatSession — dataclass for a single chat session (image or video + messages).
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ChatSession:
|
|
15
|
+
name: str
|
|
16
|
+
media_type: Literal["image", "video"]
|
|
17
|
+
# For image: single data-URL string.
|
|
18
|
+
# For video: list of frame data-URL strings.
|
|
19
|
+
media_data: str | list[str]
|
|
20
|
+
model: str
|
|
21
|
+
messages: list[dict] = field(default_factory=list)
|
|
22
|
+
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
23
|
+
saved_at: str = "" # populated at load time from file mtime
|
|
24
|
+
|
|
25
|
+
# ── Serialisation ────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict:
|
|
28
|
+
return {
|
|
29
|
+
"name": self.name,
|
|
30
|
+
"media_type": self.media_type,
|
|
31
|
+
"media_data": self.media_data,
|
|
32
|
+
"model": self.model,
|
|
33
|
+
"messages": self.messages,
|
|
34
|
+
"timestamp": self.timestamp,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def to_json(self) -> str:
|
|
38
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_dict(cls, data: dict) -> "ChatSession":
|
|
42
|
+
return cls(
|
|
43
|
+
name = data["name"],
|
|
44
|
+
media_type = data["media_type"],
|
|
45
|
+
media_data = data["media_data"],
|
|
46
|
+
model = data["model"],
|
|
47
|
+
messages = data.get("messages", []),
|
|
48
|
+
timestamp = data.get("timestamp", ""),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_json(cls, json_str: str) -> "ChatSession":
|
|
53
|
+
return cls.from_dict(json.loads(json_str))
|
|
54
|
+
|
|
55
|
+
# ── Helpers ──────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def display_timestamp(self) -> str:
|
|
59
|
+
try:
|
|
60
|
+
dt = datetime.fromisoformat(self.timestamp)
|
|
61
|
+
return dt.strftime("%b %d, %Y %H:%M")
|
|
62
|
+
except ValueError:
|
|
63
|
+
return self.timestamp
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def display_saved_at(self) -> str:
|
|
67
|
+
"""Display the file modification time, falling back to creation timestamp."""
|
|
68
|
+
if self.saved_at:
|
|
69
|
+
try:
|
|
70
|
+
dt = datetime.fromisoformat(self.saved_at)
|
|
71
|
+
return dt.strftime("%b %d, %Y %H:%M")
|
|
72
|
+
except ValueError:
|
|
73
|
+
pass
|
|
74
|
+
return self.display_timestamp
|
|
75
|
+
|
|
76
|
+
def get_thumbnail_data_url(self) -> str:
|
|
77
|
+
"""Return the first (or only) frame data-URL for thumbnail display."""
|
|
78
|
+
if self.media_type == "image":
|
|
79
|
+
return self.media_data # type: ignore[return-value]
|
|
80
|
+
frames = self.media_data # type: ignore[assignment]
|
|
81
|
+
return frames[0] if frames else ""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChatStore — persists and loads ChatSession objects as JSON files
|
|
3
|
+
in the `chats/` directory next to the project root.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from camera_llm.chat_session import ChatSession
|
|
11
|
+
|
|
12
|
+
CHATS_DIR = Path(__file__).parent.parent / "chats"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ensure_dir() -> None:
|
|
16
|
+
CHATS_DIR.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _safe_filename(name: str) -> str:
|
|
20
|
+
"""Strip characters that are not safe for filenames."""
|
|
21
|
+
safe = re.sub(r'[\\/:*?"<>|]', "_", name).strip()
|
|
22
|
+
return safe or "untitled"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save(session: ChatSession) -> Path:
|
|
26
|
+
"""Write a ChatSession to a JSON file. Returns the file path."""
|
|
27
|
+
_ensure_dir()
|
|
28
|
+
filename = _safe_filename(session.name) + ".json"
|
|
29
|
+
path = CHATS_DIR / filename
|
|
30
|
+
path.write_text(session.to_json(), encoding="utf-8")
|
|
31
|
+
return path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_all() -> list[ChatSession]:
|
|
35
|
+
"""Load every saved ChatSession from the chats/ directory."""
|
|
36
|
+
_ensure_dir()
|
|
37
|
+
sessions: list[ChatSession] = []
|
|
38
|
+
for fpath in sorted(CHATS_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
39
|
+
try:
|
|
40
|
+
session = ChatSession.from_json(fpath.read_text(encoding="utf-8"))
|
|
41
|
+
from datetime import datetime
|
|
42
|
+
session.saved_at = datetime.fromtimestamp(fpath.stat().st_mtime).isoformat()
|
|
43
|
+
sessions.append(session)
|
|
44
|
+
except Exception:
|
|
45
|
+
pass # skip malformed files
|
|
46
|
+
return sessions
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def delete(name: str) -> bool:
|
|
50
|
+
"""Delete a saved chat by its name. Returns True if deleted."""
|
|
51
|
+
filename = _safe_filename(name) + ".json"
|
|
52
|
+
path = CHATS_DIR / filename
|
|
53
|
+
if path.exists():
|
|
54
|
+
path.unlink()
|
|
55
|
+
return True
|
|
56
|
+
return False
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Camera → LLM Inference App
|
|
3
|
+
Entry point — creates the QApplication, applies the dark theme, and shows the MainWindow.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python main.py
|
|
7
|
+
"""
|
|
8
|
+
import sys
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
# Ensure the project root is on the path
|
|
12
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
13
|
+
|
|
14
|
+
from PySide6.QtWidgets import QApplication
|
|
15
|
+
from PySide6.QtGui import QFont, QIcon
|
|
16
|
+
from PySide6.QtCore import Qt
|
|
17
|
+
|
|
18
|
+
from camera_llm.main_window import MainWindow
|
|
19
|
+
|
|
20
|
+
# def get_resource_path(relative_path):
|
|
21
|
+
# """Get path to resource, works for both dev and PyInstaller bundle."""
|
|
22
|
+
# if hasattr(sys, '_MEIPASS'):
|
|
23
|
+
# # PyInstaller extracts files to a temp folder (_MEIPASS) at runtime
|
|
24
|
+
# return os.path.join(sys._MEIPASS, relative_path)
|
|
25
|
+
# return os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_app():
|
|
29
|
+
# High-DPI support
|
|
30
|
+
QApplication.setHighDpiScaleFactorRoundingPolicy(
|
|
31
|
+
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
app = QApplication(sys.argv)
|
|
35
|
+
app.setApplicationName("Camera LLM Inference")
|
|
36
|
+
app.setOrganizationName("CameraLLM")
|
|
37
|
+
|
|
38
|
+
# Set default font
|
|
39
|
+
font = QFont("Segoe UI", 10)
|
|
40
|
+
font.setHintingPreference(QFont.HintingPreference.PreferNoHinting)
|
|
41
|
+
app.setFont(font)
|
|
42
|
+
|
|
43
|
+
# Set app-wide icon (affects taskbar, window title bar, etc.)
|
|
44
|
+
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icon.ico")
|
|
45
|
+
|
|
46
|
+
if os.path.exists(icon_path):
|
|
47
|
+
app.setWindowIcon(QIcon(icon_path))
|
|
48
|
+
|
|
49
|
+
# Apply dark theme via pyqtdarktheme as a base, then layer our custom stylesheet
|
|
50
|
+
try:
|
|
51
|
+
import qdarktheme
|
|
52
|
+
base_sheet = qdarktheme.load_stylesheet("dark")
|
|
53
|
+
app.setStyleSheet(base_sheet)
|
|
54
|
+
except (ImportError, Exception):
|
|
55
|
+
pass # Our MAIN_STYLESHEET in styles.py covers everything standalone
|
|
56
|
+
|
|
57
|
+
window = MainWindow()
|
|
58
|
+
window.show()
|
|
59
|
+
|
|
60
|
+
sys.exit(app.exec())
|
|
61
|
+
|
|
62
|
+
def cli():
|
|
63
|
+
if len(sys.argv) > 1 and sys.argv[1] == "run":
|
|
64
|
+
# Remove 'run' so QApplication doesn't try to parse it
|
|
65
|
+
sys.argv = [sys.argv[0]] + sys.argv[2:]
|
|
66
|
+
run_app()
|
|
67
|
+
else:
|
|
68
|
+
print("Usage: camera-llm run")
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
run_app()
|
|
Binary file
|