Skip to content

Commit 65df204

Browse files
author
Chris Greening
committed
Toolbar buttons and view refactor
1 parent 3cec897 commit 65df204

10 files changed

Lines changed: 458 additions & 66 deletions

File tree

IrProCapture.xcodeproj/xcshareddata/xcschemes/IrProCapture.xcscheme

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
shouldAutocreateTestPlan = "YES">
3232
</TestAction>
3333
<LaunchAction
34-
buildConfiguration = "Release"
34+
buildConfiguration = "Debug"
3535
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
3636
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
3737
launchStyle = "0"

IrProCapture/Camera/Camera.swift

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class Camera: NSObject, ObservableObject, CaptureDelegate {
6767
private let ciContext = CIContext()
6868
private let temperatureProcessor = TemperatureProcessor(averagingEnabled: true, maxFrameCount: 2)
6969
private let videoRecorder = VideoRecorder()
70+
private let imageCapturer = ImageCapturer()
7071
private var isProcessing = false
7172
private var capture: Capture?
7273

@@ -89,6 +90,26 @@ class Camera: NSObject, ObservableObject, CaptureDelegate {
8990
super.init()
9091
}
9192

93+
/// Cycles to the next orientation option
94+
func nextOrientation() {
95+
guard !isRecording else { return }
96+
97+
if let currentIndex = orientationOptions.firstIndex(of: currentOrientation) {
98+
let nextIndex = (currentIndex + 1) % orientationOptions.count
99+
currentOrientation = orientationOptions[nextIndex]
100+
}
101+
}
102+
103+
/// Cycles to the previous orientation option
104+
func previousOrientation() {
105+
guard !isRecording else { return }
106+
107+
if let currentIndex = orientationOptions.firstIndex(of: currentOrientation) {
108+
let previousIndex = (currentIndex - 1 + orientationOptions.count) % orientationOptions.count
109+
currentOrientation = orientationOptions[previousIndex]
110+
}
111+
}
112+
92113
/// Starts the thermal camera capture session.
93114
///
94115
/// - Returns: A boolean indicating whether the camera started successfully.
@@ -119,28 +140,7 @@ class Camera: NSObject, ObservableObject, CaptureDelegate {
119140
return false
120141
}
121142

122-
// If the file already exists we need to delete it
123-
if FileManager.default.fileExists(atPath: outputURL.path) {
124-
try? FileManager.default.removeItem(at: outputURL)
125-
}
126-
127-
// Create an image destination for PNG format
128-
guard let destination = CGImageDestinationCreateWithURL(outputURL as CFURL, UTType.png.identifier as CFString, 1, nil) else {
129-
print("Failed to create image destination")
130-
return false
131-
}
132-
133-
// Add the CGImage to the destination
134-
CGImageDestinationAddImage(destination, resultImage, nil)
135-
136-
// Finalize the image writing
137-
if CGImageDestinationFinalize(destination) {
138-
print("Image saved successfully!")
139-
return true
140-
} else {
141-
print("Failed to save image")
142-
return false
143-
}
143+
return imageCapturer.saveImage(image: resultImage, outputURL: outputURL)
144144
}
145145

146146
/// Begins recording thermal video to disk.

IrProCapture/Camera/Components/Capture.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@ class Capture: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
5353
// Find our USB Camera device
5454
let discoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: [.external], mediaType: .video, position: .unspecified)
5555
let devices = discoverySession.devices
56-
guard let videoCaptureDevice = devices.filter({ $0.localizedName.contains("USB Camera") }).first else {
56+
guard let videoCaptureDevice = devices.filter({ $0.modelID == "UVC Camera VendorID_3034 ProductID_22576" }).first else {
5757
throw IrProError.noDevicesFound
5858
}
59-
59+
6060
// Set up the session
6161
guard let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice) else {
6262
throw IrProError.failedToCreateDeviceInput
@@ -104,4 +104,4 @@ class Capture: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
104104
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
105105
delegate?.capture(self, didOutput: sampleBuffer)
106106
}
107-
}
107+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//
2+
// ImageCapturer.swift
3+
// IrProCapture
4+
//
5+
// Created by Chris Greening on 17/3/25.
6+
//
7+
8+
import Foundation
9+
import CoreGraphics
10+
import CoreImage
11+
import ImageIO
12+
import UniformTypeIdentifiers
13+
14+
/// A class responsible for saving thermal images to disk.
15+
///
16+
/// The `ImageCapturer` class handles the process of saving thermal images to disk
17+
/// as PNG files, separating this concern from the main Camera class.
18+
class ImageCapturer {
19+
20+
/// The CoreImage context used for image processing
21+
private let ciContext = CIContext()
22+
23+
/// Saves the provided image to disk as a PNG file.
24+
///
25+
/// - Parameters:
26+
/// - image: The CGImage to save
27+
/// - outputURL: The URL where the image should be saved
28+
/// - Returns: A boolean indicating whether the save operation was successful
29+
func saveImage(image: CGImage, outputURL: URL) -> Bool {
30+
// If the file already exists we need to delete it
31+
if FileManager.default.fileExists(atPath: outputURL.path) {
32+
try? FileManager.default.removeItem(at: outputURL)
33+
}
34+
35+
// Create an image destination for PNG format
36+
guard let destination = CGImageDestinationCreateWithURL(outputURL as CFURL, UTType.png.identifier as CFString, 1, nil) else {
37+
print("Failed to create image destination")
38+
return false
39+
}
40+
41+
// Add the CGImage to the destination
42+
CGImageDestinationAddImage(destination, image, nil)
43+
44+
// Finalize the image writing
45+
if CGImageDestinationFinalize(destination) {
46+
print("Image saved successfully!")
47+
return true
48+
} else {
49+
print("Failed to save image")
50+
return false
51+
}
52+
}
53+
}

IrProCapture/ContentView.swift

Lines changed: 22 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,58 +7,36 @@
77

88
import SwiftUI
99
import Charts
10+
import Foundation
1011

1112
struct ContentView: View {
1213
@EnvironmentObject var model: Camera
1314
@State var isRunning = false
1415
@State private var alertMessage: String? = nil
15-
16+
1617
var body: some View {
17-
HStack {
18-
if isRunning {
18+
VStack {
19+
CaptureToolbar()
20+
.padding(.top)
21+
HStack {
1922
if let image = model.resultImage {
20-
ZStack {
21-
// the image
22-
Image(image, scale: 1.0, label: Text("Temperature"))
23-
.resizable() // Make the image resizable
24-
.scaledToFit() // Scale it to fit the container, maintaining aspect ratio
25-
.frame(maxWidth: .infinity, maxHeight: .infinity)
26-
}
23+
// the image
24+
Image(image, scale: 1.0, label: Text("Temperature"))
25+
.resizable() // Make the image resizable
26+
.scaledToFit() // Scale it to fit the container, maintaining aspect ratio
27+
.frame(maxWidth: .infinity, maxHeight: .infinity)
2728
} else {
2829
Spacer()
2930
}
30-
} else {
31-
Spacer()
32-
Button("Start Camera") {
33-
do {
34-
isRunning = try model.start()
35-
} catch let error as IrProError {
36-
// Handle the error and show the alert
37-
alertMessage = error.rawValue
38-
} catch {
39-
alertMessage = "Unknown error occurred"
40-
}
41-
}
42-
}
43-
Spacer()
44-
VStack {
45-
Text(String(format: "%.1f", model.maxTemperature))
4631
Spacer()
47-
Text(String(format: "%.1f", model.minTemperature))
48-
}
49-
LinearGradient(gradient: Gradient(colors: model.currentColorMap.colors.map { Color(red: CGFloat($0.r), green: CGFloat($0.g), blue: CGFloat($0.b)) }), startPoint: .bottom, endPoint: .top)
50-
.frame(width: 50)
51-
Chart(model.histogram) {
52-
LineMark(
53-
x: .value("Count", $0.y),
54-
y: .value("Temperature", $0.x)
55-
).interpolationMethod(.catmullRom)
56-
}
57-
.chartYScale(domain: model.minTemperature...model.maxTemperature)
58-
.chartXAxis(.hidden)
59-
.frame(width: 100)
32+
ColorMapDisplay(colorMap: model.currentColorMap, maxTemperature: model.maxTemperature, minTemperature: model.minTemperature)
33+
TemperatureHistogramChart(
34+
histogram: model.histogram,
35+
minTemperature: model.minTemperature,
36+
maxTemperature: model.maxTemperature
37+
)
38+
}.padding()
6039
}
61-
.padding()
6240
.onAppear {
6341
}
6442
.onDisappear() {
@@ -77,3 +55,7 @@ struct ContentView: View {
7755
}
7856
}
7957

58+
#Preview {
59+
ContentView()
60+
.environmentObject(Camera())
61+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
//
2+
// CaptureToolbar.swift
3+
// IrProCapture
4+
//
5+
// Created on 21/5/25.
6+
//
7+
8+
import SwiftUI
9+
10+
struct CaptureToolbar: View {
11+
@EnvironmentObject var model: Camera
12+
@State private var alertMessage: String? = nil
13+
14+
var body: some View {
15+
HStack(spacing: 20) {
16+
Spacer()
17+
18+
// Capture image button
19+
Button(action: {
20+
guard let success = try? model.start(),
21+
success else {
22+
alertMessage = "Failed to start camera."
23+
return
24+
}
25+
}) {
26+
Image(systemName: "play.square")
27+
.font(.title)
28+
}
29+
.disabled(model.isRunning)
30+
.buttonStyle(.bordered)
31+
.help("Start Camera")
32+
33+
34+
// Capture image button
35+
Button(action: {
36+
captureImage()
37+
}) {
38+
Image(systemName: "camera")
39+
.font(.title)
40+
}
41+
.disabled(!model.isRunning)
42+
.buttonStyle(.bordered)
43+
.help("Capture Image")
44+
45+
// Record video button
46+
Button(action: {
47+
if model.isRecording {
48+
model.stopRecording()
49+
} else {
50+
startRecording()
51+
}
52+
}) {
53+
if (model.isRecording) {
54+
Image(systemName: "record.circle")
55+
.foregroundColor(model.isRecording ? .red : .primary)
56+
} else {
57+
Image(systemName: "stop.circle")
58+
.font(.title)
59+
}
60+
}
61+
.disabled(!model.isRunning)
62+
.buttonStyle(.bordered)
63+
.help(model.isRecording ? "Stop Recording" : "Start Recording")
64+
65+
// Rotate left button
66+
Button(action: {
67+
rotateToPreviousOrientation()
68+
}) {
69+
Image(systemName: "rotate.left")
70+
.font(.title)
71+
}
72+
.disabled(!model.isRunning)
73+
.buttonStyle(.bordered)
74+
.help("Previous Orientation")
75+
// Rotate right button
76+
Button(action: {
77+
rotateToNextOrientation()
78+
}) {
79+
Image(systemName: "rotate.right")
80+
.font(.title)
81+
}
82+
.disabled(!model.isRunning)
83+
.buttonStyle(.bordered)
84+
.help("Next Orientation")
85+
86+
Spacer()
87+
}
88+
.alert(isPresented: Binding<Bool>(
89+
get: { alertMessage != nil },
90+
set: { _ in alertMessage = nil }
91+
)) {
92+
Alert(
93+
title: Text("Error"),
94+
message: Text(alertMessage ?? ""),
95+
dismissButton: .default(Text("OK"))
96+
)
97+
}
98+
}
99+
100+
/// Rotates to the next orientation option in the list
101+
private func rotateToNextOrientation() {
102+
// Call the Camera model's method to cycle to the next orientation
103+
model.nextOrientation()
104+
}
105+
106+
/// Rotates to the previous orientation option in the list
107+
private func rotateToPreviousOrientation() {
108+
// Call the Camera model's method to cycle to the previous orientation
109+
model.previousOrientation()
110+
}
111+
112+
/// Handles image capture using a save dialog
113+
private func captureImage() {
114+
let panel = NSSavePanel()
115+
panel.nameFieldLabel = "Save image as:"
116+
panel.nameFieldStringValue = "thermal.png"
117+
panel.canCreateDirectories = true
118+
panel.begin { response in
119+
if response == NSApplication.ModalResponse.OK, let fileUrl = panel.url {
120+
if !model.saveImage(outputURL: fileUrl) {
121+
alertMessage = "Failed to save image"
122+
}
123+
}
124+
}
125+
}
126+
127+
/// Handles starting video recording using a save dialog
128+
private func startRecording() {
129+
let panel = NSSavePanel()
130+
panel.nameFieldLabel = "Save video as:"
131+
panel.nameFieldStringValue = "recording.mp4"
132+
panel.canCreateDirectories = true
133+
panel.begin { response in
134+
if response == NSApplication.ModalResponse.OK, let fileUrl = panel.url {
135+
if !model.startRecording(outputURL: fileUrl) {
136+
alertMessage = "Failed to start recording"
137+
}
138+
}
139+
}
140+
}
141+
142+
}
143+
144+
// Commenting out the preview for now due to dependency issues
145+
#Preview {
146+
CaptureToolbar()
147+
.environmentObject(Camera())
148+
}

0 commit comments

Comments
 (0)