A native iOS navigation aid for blind and low-vision users that reduces a LiDAR depth map to three spoken distances β left, ahead, right β and speaks them before you walk into anything.
Features β’ Architecture β’ Accessibility β’ Testing β’ Getting Started β’ Verify it
Beta. Three modes ship and work on device. Several things the earlier README listed as working are not wired into the app at all, and they are enumerated by name in Implemented vs delegated vs planned. Read that section before you read anything else here. In an assistive product an overstated capability is not a marketing problem, it is a safety problem, so this README names every gap rather than rounding it to a checkmark.
Visual Assist is an iPhone app for people who cannot see the obstacle in front of them. In Navigation
mode it runs an ARWorldTrackingConfiguration with sceneDepth enabled, reduces each LiDAR depth
frame to the nearest valid return in three horizontal zones, and announces the closest one through
AVSpeechSynthesizer with a matching Core Haptics pattern. Two other modes reuse the camera without
depth: Text Reading runs Vision OCR and reads the result aloud, and Object Awareness runs Vision's
animal and human detectors and describes what it found.
It is one Xcode project, 27 Swift files and 5,702 lines in the app target, written in SwiftUI
with @MainActor services published into the view tree. There are no third-party dependencies β
no Package.resolved, no Podfile, and zero XCRemoteSwiftPackageReference entries in
VisualAssist.xcodeproj/project.pbxproj. Every hard part is an Apple framework: ARKit for depth,
Vision for OCR and detection, AVFoundation for capture and speech, Core Haptics for feedback.
- The depth buffer is transposed and the code says so. In portrait, ARKit's depth
CVPixelBufferhas its axes swapped relative to the screen, so left/right zones index the buffer's height dimension, not its width.LiDARService.processDepthFramecarries a ten-line comment block deriving that mapping, and it is the one piece of genuinely non-obvious engineering in the repo. - Three numbers instead of a point cloud. The whole obstacle model is
leftMin,centerMin,rightMinover samples in0 m < depth < 10 m, strided by 4 in both axes. That is deliberately small; see Technical decisions. - Live OCR is stabilised across frames, not read per frame.
LiveTextProcessorbuffers up to 5 frames on a 2.5 s timer, picks the frame whose text is most similar to the others, and speaks it only if Jaccard word similarity against the last utterance is below 0.7 β so the app does not re-read the same sign every two seconds. - 71 tests, and the README is explicit that they cover the layer that is not the product. All 8 test classes exercise value types and one static utility. Nothing touches the sensor path, because the Simulator has neither a LiDAR sensor nor a camera. See Testing.
- Nothing leaves the device, and that is greppable. The app target contains no
URLSession, nodataTaskand no HTTP call. The onlyhttps://string in it is aLinkinSettingsViewthat hands a URL to Safari.
LiDARService runs an ARKit session with .sceneDepth and .smoothedSceneDepth frame semantics
(each inserted only if supportsFrameSemantics says so) and .mesh scene reconstruction. It prefers
the smoothed depth map and falls back to the raw one.
ARFrame.smoothedSceneDepth ?? ARFrame.sceneDepth (CVPixelBuffer, Float32)
β
βΌ portrait: buffer Y axis runs screen RIGHT β LEFT
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β sample x in [width/4, 3Β·width/4), y in [0, height), stride 4 β
β keep 0 m < depth < 10 m β
β β
β y < height/3 β RIGHT zone min() β
β y >= 2Β·height/3 β LEFT zone min() β
β otherwise β CENTER zone min() β
β β
β also tracked: farthest valid return β on-screen "clear space" dot β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
nearest = min(left, center, right) β "on your left" / "ahead" / "on your right"
β
βββ < 0.50 m critical β haptic .critical + speakNow("Stop! Obstacle β¦")
βββ < 1.00 m warning β haptic .warning + speakNow("Caution, β¦")
βββ < 2.00 m caution β shown on screen, not announced
Rate limiting is two independent cooldowns on LiDARService: haptics at most every 0.5 s,
speech at most every 3.0 s. The thresholds above are let constants on the service β the Settings
sliders do not reach them, which is stated again under
planned, not in this build.
On screen: three zone cards with per-zone distances, a large nearest-obstacle readout, a green indicator over the farthest measured point, and a debug overlay (bug icon) showing buffer size, valid-sample count and the three raw minima.
TextRecognitionService runs VNRecognizeTextRequest at .accurate with
usesLanguageCorrection = true and recognitionLanguages = ["en-US"]; on iOS 16+ it also sets
automaticallyDetectsLanguage = true. Observations are sorted top-to-bottom then left-to-right
before being joined.
- Freeze β captures the current frame, OCRs it, announces the word count, draws boxes over each recognised block, and waits for you to press Read.
- Live Read β
LiveTextProcessorcollects camera frames (the capture path is throttled to 10 fps byCameraService.frameProcessingInterval = 0.1), batches up to 5 every 2.5 s, discards results under 3 words, picks the most self-similar text in the batch, and enqueues it for speech only if it differs from the last utterance by Jaccard word similarity below 0.7. - Tap to focus β
FocusableCameraViewmaps the tap into camera coordinates and setsfocusPointOfInterestandexposurePointOfInterest, with a yellow ring drawn at the tap. - Faster / Slower β steps
AVSpeechUtterance.rateby 0.1, clamped to[0.1, 1.0]. Reading uses a "natural" mode that runs atrate Γ 0.9and injects pauses at sentence and clause boundaries.
This mode runs two Vision requests per frame and nothing else:
| Request | What it finds |
|---|---|
VNRecognizeAnimalsRequest |
cats and dogs, the classes that request supports |
VNDetectHumanRectanglesRequest |
people, labelled "person" |
There is no Core ML model in this repo and none is loaded. ObjectDetectionService imports
CoreML and declares setupClassificationRequest(), whose entire body is the comment "In a full
implementation, you would load a custom Core ML model here." Everything the mode can name is
therefore an animal or a person. Describe, Count People and the dominant-colour readout all operate
on that result set; the scene description is a template built from category counts, not a generated
caption.
An 80-case CommonObjectLabel enum mirroring the COCO class list exists in
Models/DetectedObject.swift and is unit-tested, but no code path reads it β it is the vocabulary
for a detector that was never wired.
flowchart TB
subgraph UI["SwiftUI (ContentView switches on AppState.currentMode)"]
Home[HomeView]
Nav[NavigationModeView]
Text[TextReadingModeView]
Obj[ObjectAwarenessModeView]
Set[SettingsView]
end
subgraph Sensors["Capture"]
AR[ARKit session<br/>sceneDepth + smoothedSceneDepth]
Cam[CameraService<br/>AVCaptureSession, 10 fps throttle]
end
subgraph Wired["Services on a live path"]
LiDAR[LiDARService<br/>3-zone depth reduce]
OCR[TextRecognitionService<br/>VNRecognizeTextRequest]
LTP[LiveTextProcessor<br/>2.5 s batch + similarity gate]
Det[ObjectDetectionService<br/>animals + human rects]
Speech[SpeechService<br/>AVSpeechSynthesizer]
Haptic[HapticService<br/>CHHapticEngine + UIKit fallback]
end
subgraph Unwired["Compiled and tested, never instantiated"]
VC[VoiceCommandService]
SA[SpatialAudioManager]
DP[DepthProcessor]
US[UserSettings]
AH[AccessibilityHelper]
end
Nav --> AR --> LiDAR --> Speech
LiDAR --> Haptic
Text --> Cam --> LTP --> OCR --> Speech
Obj --> Cam
Obj --> Det --> Speech
Home --> Set
Set --> Speech
Set --> Haptic
Everything in the Unwired box compiles, several parts of it are covered by the test suite, and no view or service constructs any of it. It is drawn separately because a diagram that routed those boxes into the flow would be the diagram lying.
Held in portrait, an iPhone's ARKit depth CVPixelBuffer is not laid out the way the screen is. The
buffer's X axis runs screen bottom-to-top and its Y axis runs screen right-to-left. Naively slicing
the buffer's width into thirds to get left/center/right zones gives you vertical bands of the
scene, which is exactly the bug that makes an obstacle aid announce the ceiling.
processDepthFrame handles it by slicing the height dimension for horizontal zones
(y < height/3 is the screen's right, y >= 2Β·height/3 is the left) and sampling only the middle
half of the width as the vertical window. The farthest-point indicator undoes the same rotation to
place its dot: screenX = 1 β y/height, screenY = 1 β x/width.
Two honest caveats sit on that code. The loop computes both a transposed index and a row-major index
and then reads the row-major one, so the layout assumption is asserted in a comment rather than
checked at runtime. And the depth callback runs on every ARSession frame with no throttle, unlike
the camera path, which is capped at 10 fps β the depth reduction is a strided min() and cheap, but
it is not rate-limited.
LiDARService, TextRecognitionService, VoiceCommandService, LiveTextProcessor and AppState
are @MainActor. ARSessionDelegate callbacks are nonisolated and hop back with
Task { @MainActor in β¦ }. CameraService is @unchecked Sendable with per-property @MainActor
isolation and an NSLock guarding frame timing, because AVCaptureVideoDataOutput delivers on its
own queue. CI builds with SWIFT_STRICT_CONCURRENCY=minimal, so these annotations are not enforced
at the strict setting.
Every claim in this section is checkable by grep, which is why it is short.
| Claim | How to check it |
|---|---|
| No network calls in the app target | grep -rE "URLSession|dataTask|https?://" VisualAssist/ returns exactly one hit: the Link in SettingsView.swift:215 that hands a URL to Safari |
| No analytics or telemetry SDK | No third-party dependencies at all β no Package.resolved, no Podfile, zero XCRemoteSwiftPackageReference in the pbxproj |
| No account, no sign-in | There is no auth code, no keychain use and no user record anywhere in the tree |
| All inference is on device | ARKit, Vision, AVSpeechSynthesizer and Core Haptics are local frameworks; nothing is uploaded because nothing can be |
The only data written anywhere is UserDefaults via @AppStorage in SettingsView β five
preference keys, all local.
The one wrinkle worth stating: Info.plist declares three usage strings (camera, microphone, speech
recognition) but only the camera permission is ever requested at runtime. Microphone and speech
recognition would be triggered by VoiceCommandService, which is never constructed, so the app
declares two permissions it never asks for.
This is an accessibility product, so this section states only what is traceable to code. Everything the previous README claimed here that is not in the list below has been moved to planned, not in this build β including "full VoiceOver support", "Dynamic Type compatible", "High Contrast mode" and "Reduce Motion respected".
What is actually implemented
| Feature | Where |
|---|---|
| Labels, hints, values and combined elements on interactive and status views | 53 .accessibility* modifiers across VisualAssist/Views/ β heaviest in SettingsView (17), HomeView (10), NavigationModeView (8), ObjectAwarenessModeView (6) |
Buttons carry .isButton and a spoken label |
AccessibleButton.swift, PrimaryActionButton, ModeCard.swift |
| Decorative art is hidden from VoiceOver | HomeView.swift:69 β .accessibilityHidden(true) on the header symbol |
| Live status is spoken independent of VoiceOver | SpeechService drives every mode; AppState.switchMode calls speakNow on every mode change |
| Launch announcement | VisualAssistApp.setupAccessibility() posts a .announcement notification |
| Touch targets larger than Apple's 44 pt minimum | ButtonSize.dimension is 64 / 76 / 88 pt in AccessibleButton.swift. Nothing in the codebase enforces 44 pt; these three values are simply above it |
| Dark UI, fixed | UIUserInterfaceStyle = Dark in Info.plist plus .preferredColorScheme(.dark) |
Haptic vocabulary β the seven patterns in HapticService.createPattern, transcribed from the
event lists rather than from the old README, which had one of them wrong:
| Pattern | Core Haptics events | Used for |
|---|---|---|
.tap |
one transient, intensity 0.5 | pause/resume, speed change, count people |
.doubleTap |
two transients at 0 s and 0.1 s | defined; no call site |
.success |
two transients, 0.6 then 0.8, at 0 s and 0.15 s | freeze captured, describe scene, live read started |
.warning |
three transients, intensity 0.7, every 0.15 s | obstacle inside 1.0 m |
.critical |
one continuous event, intensity 1.0, duration 0.5 s | obstacle inside 0.5 m |
.modeSwitch |
one transient, intensity 0.8, sharpness 0.8 | entering any mode |
.navigation |
two transients, intensity 0.6, every 0.2 s | defined; no call site |
.modeSwitch is a single tap. The old README drew it as two, which is the kind of error that matters
when the tap is the only signal a user gets.
When CHHapticEngine.capabilitiesForHardware().supportsHaptics is false, every pattern degrades to a
UIImpactFeedbackGenerator or UINotificationFeedbackGenerator equivalent.
No WCAG conformance is claimed. WCAG targets web content, nothing in this repo has been audited against it, and no VoiceOver audit by a blind or low-vision user has been run. Both would be real work and neither has been done.
All Apple frameworks; there is no third-party code in the build.
| Layer | Framework | Used for |
|---|---|---|
| Depth | ARKit, RealityKit | ARWorldTrackingConfiguration, sceneDepth / smoothedSceneDepth, .mesh reconstruction, ARView |
| Vision | Vision | VNRecognizeTextRequest (OCR), VNRecognizeAnimalsRequest, VNDetectHumanRectanglesRequest |
| Capture | AVFoundation | AVCaptureSession, AVCaptureVideoDataOutput, AVCapturePhotoOutput, focus/exposure control |
| Speech out | AVFoundation | AVSpeechSynthesizer, AVAudioSession in .playback / .spokenAudio |
| Speech in | Speech | SFSpeechRecognizer β present, not wired (see below) |
| Haptics | Core Haptics, UIKit | CHHapticEngine with UIFeedbackGenerator fallback |
| UI | SwiftUI | @StateObject / @EnvironmentObject, UIViewRepresentable bridges for ARView and the camera layer |
| Docs | DocC | VisualAssist/Documentation.docc/ |
| Build setting | Value | Source |
|---|---|---|
| Deployment target | iOS 17.0 | IPHONEOS_DEPLOYMENT_TARGET, both configurations, both targets |
| Swift language mode | 5 | SWIFT_VERSION = 5.0 |
| Device family | iPhone and iPad | TARGETED_DEVICE_FAMILY = "1,2" |
| Required capabilities | arkit, iphone-ipad-minimum-performance-a12 |
Info.plist |
| Orientation | portrait only | UISupportedInterfaceOrientations |
| Bundle id | com.visualassist.app |
pbxproj |
VisualAssistTests holds 71 tests across 8 XCTestCase classes: 13 Β· 11 Β· 10 Β· 9 Β· 9 Β· 8 Β· 6 Β· 5,
counted at the definition site as no-argument instance methods named testβ¦, with no argument-taking,
private or static variants that XCTest would silently skip.
Latest run: 71 passed, 0 failed, 0 skipped, read from the .xcresult bundle with xcresulttool
rather than from console text. Run locally on 2026-08-03 on an iPhone 17 Pro simulator, first on
iOS 26.5 and then on iOS 26.2 when the simulator resolver picked a different runtime β two runtimes
rather than one because the second run was accidental, and it is worth more than the first. CI runs the
same suite on macos-14 with Xcode 15.x, which is a different toolchain from either local run.
scripts/resolve-simulator.sh # pick an available iPhone sim
xcodebuild test -project VisualAssist.xcodeproj -scheme VisualAssist \
-destination "platform=iOS Simulator,id=$(scripts/resolve-simulator.sh)" \
-resultBundlePath TestResults.xcresult
scripts/assert-test-results.sh TestResults.xcresult 60Zero of the 71 tests touch the sensor path. Every class tests a value type or a static utility:
| Test class | Tests | Subject | On the app's live path? |
|---|---|---|---|
AccessibilityHelperTests |
13 | speech number/distance formatting, label generation | no β AccessibilityHelper has no call sites |
CommonObjectLabelTests |
11 | the 80-case COCO enum and its categories | no β nothing reads the enum |
ObjectPositionTests |
10 | bounding-box β nine-cell position | no |
DetectedObstacleTests |
9 | alert-level thresholds, spoken description | no β LiDARService.obstacles is declared and never populated |
ObjectCategoryTests |
9 | label β category mapping, SF Symbol names | partly β ObjectCategory.from runs in detection |
DepthProcessorTests |
8 | zone ranges, floor-change result types | no β LiDARService has its own inline loop |
RecognizedTextTests |
6 | heading heuristic, document ordering, average confidence | no β OCR uses TextBlock, not RecognizedText |
DetectionSummaryTests |
5 | counts and most-confident object | no |
That shape is not an accident and it is not laziness: the iOS Simulator has neither a LiDAR sensor
nor a camera, so the depth reduce, the OCR pipeline and the capture session cannot execute anywhere
CI can reach. The honest summary is 71 tests pass, and they cover the layer that is not the
product. Closing that gap needs either a recorded-ARFrame harness or on-device XCUITest against a
LiDAR iPhone, and neither exists here.
| Workflow | Gates? | What it does |
|---|---|---|
ci.yml β Build & Test |
yes | Debug simulator build, Release build, xcodebuild test, then scripts/assert-test-results.sh β¦ 60 |
ci.yml β Code Analysis |
no | SwiftLint and a TODO/FIXME grep, both continue-on-error: true |
ci.yml β Accessibility Compliance |
no | continue-on-error: true, and its only step echos counts. It prints β οΈ <file> - no accessibilityLabel found and passes anyway |
ci.yml β Documentation Check |
barely | fails only if README.md is deleted |
codeql.yml, gitleaks.yml, scorecard.yml |
yes | Swift CodeQL, secret scanning, OpenSSF Scorecard |
The Accessibility Compliance job is worth calling out by name. This repository has already shipped
one check whose title asserted something the check did not verify: a job called "Build & Test" that
ran no tests for the life of a branch while 71 test functions sat in the tree, green the whole time.
scripts/assert-test-results.sh exists because of that. "Accessibility Compliance" is the same bug,
still present β a green tick on an accessibility product from a job that grades nothing. It is
listed here rather than quietly relied on.
The test gate itself is deliberate in two ways, both of them scar tissue:
- The suite is asserted, not assumed.
xcodebuildexits 0 for a run that executed nothing β no test target attached, a destination matching no tests, a suite skipped wholesale. The assert step reads the result bundle for a real pass count and fails below a floor of 60, and it handles both the Xcode 16test-results summaryinterface and the Xcode 15 legacy schema, because the runner image and the laptop disagree. - The simulator is resolved, not named. Hard-coding a device model pins CI to one runner image; the images roll forward, the device disappears, and the failure reads like a broken test rather than a missing one.
The section the rest of this README depends on.
- Depth-to-zones reduction, including the portrait buffer-rotation correction, the strided
sampling, the 0β10 m validity window and the farthest-point tracker (
LiDARService). - Alert policy β the 0.5 / 1.0 / 2.0 m thresholds, the 0.5 s haptic and 3.0 s speech cooldowns, and the phrasing of each announcement.
- Seven Core Haptics patterns built from
CHHapticEventlists, with a fullUIFeedbackGeneratorfallback path for hardware without a haptic engine. - Live-OCR stabiliser (
LiveTextProcessor) β frame batching, the most-self-similar-text selection, Jaccard word similarity, the 3-word floor and the speech queue. - Natural-reading text transform β pause injection at sentence and clause boundaries, and the
rate Γ 0.9reading rate (SpeechService.formatForNaturalReading). - Tap-to-focus coordinate mapping and the focus indicator (
FocusableCameraView). - Template scene description and the averaged-pixel dominant-colour classifier
(
ObjectDetectionService). - The CI gate β
scripts/assert-test-results.shandscripts/resolve-simulator.sh, both written to be runnable and negative-testable on a laptop.
- Depth acquisition and sensor fusion β ARKit. This project reduces and interprets a depth map; it does not compute one.
- OCR β
VNRecognizeTextRequest. No custom recogniser, no training. - Object detection β
VNRecognizeAnimalsRequestandVNDetectHumanRectanglesRequest. - Speech synthesis β
AVSpeechSynthesizer, including voice selection and prosody. - Speech recognition β
SFSpeechRecognizer(in the unwiredVoiceCommandService). - Camera capture, focus and exposure β AVFoundation.
Delegating is the right call for all six: an assistive app that hand-rolled OCR or depth fusion would be worse at both and would carry the failure modes itself.
Everything below is either absent or present-but-unreachable. None of it ships.
Written, compiles, never instantiated. These types exist and some are unit-tested. No view or service constructs any of them, so at runtime they do nothing:
- Voice commands.
VoiceCommandServicedefines 11 commands with 44 alternative phrasings and a workingSFSpeechRecognizerpipeline. Nothing creates it. The "Voice Commands" toggles inHomeViewandSettingsViewbindappState.voiceCommandsEnabled, which no code reads. The earlier README listed voice commands as working, English-only; they are not working in any language. - Spatial audio.
SpatialAudioManagerbuilds anAVAudioEnvironmentNodewith HRTF rendering and generated 440/660/880 Hz tones. Never constructed. No directional audio is produced. - Floor and step detection.
DepthProcessor.detectFloorChangesreturns step-up/step-down/slope results and is unit-tested.LiDARServicenever calls it. The earlier README listed floor detection as a working Navigation-mode feature; it is not called at runtime. UserSettings. A 20-key preferences model. No view instantiates it;SettingsViewuses@AppStoragedirectly for 5 keys.AccessibilityHelper. Announcement posting, VoiceOver/Reduce-Motion/Bold-Text queries, speech formatting and four SwiftUI view modifiers. Zero call sites in app code.CommonObjectLabel. The 80-case COCO vocabulary, read by nobody.
Settings that do not reach the code they name. LiDARService constructs its own private
SpeechService() and HapticService() instances, separate from the ones AppState owns and the ones
the Settings sliders configure. So in Navigation mode the speech-rate and haptic-intensity sliders
have no effect on obstacle alerts. alertDistance, continuousScanning and autoAnnounce are stored
in UserDefaults and read by nothing; the thresholds are let constants.
Accessibility claims that are not implemented.
- Dynamic Type. There is no
dynamicTypeSize,ScaledMetricor.font(β¦, relativeTo:)anywhere. Semantic styles like.headlinedo scale by default, but five call sites use fixed.system(size:)fonts and the primary controls sit in fixed frames (64/76/88 pt buttons, 80 Γ 100 zone cards, 80 Γ 90 object cards), so large accessibility text sizes will clip. Not verified at any size. - High Contrast.
highContrastModeexists only as a key on the unwiredUserSettings. No view readscolorSchemeContrastorisDarkerSystemColorsEnabled. The heavy.ultraThinMaterialtranslucency throughout the UI is never reduced. - Reduce Motion. The only guard is inside
AccessibilityHelper.playSuccessHaptic(), which has no call sites. The spring animations inAccessibleButton, the crossfade inContentView, the repeatingsymbolEffectpulses inHomeViewand the depth-bar animation all run unconditionally. - "Full" VoiceOver support. Labels are present but uneven.
TextReadingModeViewcarries exactly one.accessibility*modifier across 654 lines and three interactive modes: its Slower and Faster speed controls and its debug toggle are bareButtons with no label, hint or trait. No VoiceOver audit by a screen-reader user has been performed. - WCAG conformance. Not claimed, not measured.
Not started. Apple Watch companion, indoor mapping and saved locations, currency recognition,
multi-language OCR and commands, Siri Shortcuts, CarPlay. Branches named
feature/apple-watch-companion, feature/currency-recognition, feature/indoor-mapping and
feature/multi-language exist on the remote and each is 0 commits ahead of main β they are
empty placeholders, not work in progress.
- A Mac with Xcode 15 or later. CI builds on Xcode 15.x; the project's
LastUpgradeCheckis 2620 (Xcode 26.2), and both work. - An Apple Developer team for signing β the project has a
DEVELOPMENT_TEAMset to the author's account, so you will need to change it to yours. - A LiDAR iPhone, for Navigation mode only. The app does not check a model list. Its single gate
is
ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh)inAppState.checkLiDARAvailability(). In practice that means the Pro-tier iPhones from iPhone 12 Pro onward (confirmed through iPhone 17 Pro) and the LiDAR-equipped iPad Pro; check Apple's current tech specs rather than trusting a table in a README.
Info.plist requires arkit and iphone-ipad-minimum-performance-a12 β it does not require
LiDAR. So the app installs and launches on a non-LiDAR iPhone: Home shows "LiDAR Not Available", Text
Reading and Object Awareness work normally, and Navigation mode runs with
debugInfo = "No depth data" and announces nothing. Text Reading and Object Awareness also run in the
Simulator only as far as the Simulator's lack of a camera allows, which is not far.
git clone https://github.com/yadava5/VisualAssist.git
cd VisualAssist
open VisualAssist.xcodeproj- Select your Development Team under Signing & Capabilities for both the
VisualAssistandVisualAssistTeststargets. - Connect a LiDAR iPhone and select it as the run destination.
- β + R.
On first launch the app posts "Visual Assist ready. Swipe to explore modes." and requests camera access the first time you enter any mode.
Key in Info.plist |
Requested at runtime? |
|---|---|
NSCameraUsageDescription |
yes β CameraService.startSession() and the ARKit session |
NSMicrophoneUsageDescription |
no β only VoiceCommandService would, and it is never constructed |
NSSpeechRecognitionUsageDescription |
no β same reason |
| Command | What it does |
|---|---|
scripts/resolve-simulator.sh |
prints the UDID of an available iPhone simulator, or fails loudly |
xcodebuild test -project VisualAssist.xcodeproj -scheme VisualAssist -destination "platform=iOS Simulator,id=$(scripts/resolve-simulator.sh)" -resultBundlePath TestResults.xcresult |
runs the 71 tests |
scripts/assert-test-results.sh TestResults.xcresult 60 |
fails unless at least 60 tests actually executed, none failed and none were skipped |
swiftlint lint |
lints against .swiftlint.yml (not a gate in CI) |
xcodebuild docbuild -scheme VisualAssist -derivedDataPath ./docs |
builds the DocC catalog |
VisualAssist/
βββ VisualAssist.xcodeproj/ # single project, 2 targets, 0 package dependencies
βββ VisualAssist/
β βββ App/
β β βββ VisualAssistApp.swift # @main; posts the launch announcement
β β βββ AppState.swift # mode switching; the LiDAR availability check lives here
β βββ Views/
β β βββ ContentView.swift # switches on AppState.currentMode
β β βββ HomeView.swift
β β βββ NavigationModeView.swift # AR container, 3 zone cards, debug overlay
β β βββ TextReadingModeView.swift # also holds LiveTextProcessor, the OCR stabiliser
β β βββ ObjectAwarenessModeView.swift
β β βββ SettingsView.swift # @AppStorage directly; does not use UserSettings
β β βββ DeviceLevelIndicator.swift
β β βββ Components/ # AccessibleButton, ModeCard, StatusOverlay, CameraPreview
β βββ Services/
β β βββ LiDARService.swift # the depth reduce; the rotation comment block
β β βββ CameraService.swift # AVCaptureSession, 10 fps frame throttle
β β βββ TextRecognitionService.swift
β β βββ ObjectDetectionService.swift # Vision only; no Core ML model is loaded
β β βββ SpeechService.swift
β β βββ HapticService.swift
β β βββ VoiceCommandService.swift # NEVER INSTANTIATED
β βββ Models/
β β βββ DetectedObject.swift # CommonObjectLabel (80 COCO cases), unused
β β βββ DetectedObstacle.swift # unused at runtime
β β βββ RecognizedText.swift # unused at runtime
β β βββ UserSettings.swift # NEVER INSTANTIATED
β βββ Utilities/
β β βββ DepthProcessor.swift # tested; LiDARService uses its own inline loop
β β βββ AccessibilityHelper.swift # tested; no call sites in app code
β β βββ SpatialAudioManager.swift # NEVER INSTANTIATED
β βββ Documentation.docc/ # DocC catalog β see the note below
β βββ Info.plist
βββ VisualAssistTests/ # 8 classes, 71 tests, all model/utility level
βββ scripts/
β βββ assert-test-results.sh # the CI gate that made "Build & Test" mean something
β βββ resolve-simulator.sh
βββ Assets/ # icon and banner sources plus their generator scripts
βββ .github/workflows/ # ci, codeql, gitleaks, scorecard, release
Documentation.docc/Accessibility.md and CHANGELOG.md still describe full VoiceOver support,
Dynamic Type, a 44 pt minimum, Reduce Motion, spatial audio and Core ML object detection. Those
documents are stale against this README and against the code; this README is the current statement.
Three minima instead of a point cloud, and a comment instead of a coordinate transform. The
obstacle model is min() over three strided zone samples, not segmentation or clustering. That gives
a reading fast enough to run on every ARKit frame and simple enough to state out loud in a phrase a
walking user can act on. The cost is real: it cannot distinguish a wall from a pole, it has no notion
of object persistence between frames, and the zone mapping depends on a portrait buffer layout that
is documented in a comment rather than verified at runtime. The alternative β projecting depth
through the camera intrinsics into world space β is correct and is what a shipping product should do;
it is not what this build does.
Vision detectors rather than a bundled Core ML model. Shipping a COCO detector would have meant
bundling weights, owning their licence, and taking responsibility for their failure modes on a device
that a blind user relies on. The build instead uses the two Vision requests that Apple maintains,
which is why Object Awareness can only name animals and people. That is a narrow mode, honestly
narrow, rather than a broad mode that is wrong. The CommonObjectLabel enum is the seam where a real
detector would attach.
A CI gate written as a script, not as YAML. scripts/assert-test-results.sh lives in a file so it
can be run and negative-tested on a laptop, and it handles both the Xcode 16 and Xcode 15
xcresulttool interfaces because the runner image and the developer machine disagree. A gate nobody
has watched fail is a gate nobody knows works β which is exactly how this repository ended up with a
job named "Build & Test" that ran no tests. That lesson has been applied to the test job and, as
Testing says, has still not been applied to the accessibility job.
You cannot casually verify the product, and it would be dishonest to imply otherwise. All three modes need physical hardware: Navigation needs a LiDAR sensor, and Text Reading and Object Awareness need a real camera pointed at real text. The iOS Simulator has none of those. There is no live demo, no TestFlight link and no recorded-frame harness, so no reader can check the behavioural claims in this README without a LiDAR iPhone and a build signed with their own team.
What you can check, without trusting the author:
| Claim | Where it terminates |
|---|---|
| 71 tests exist | VisualAssistTests/ β 8 XCTestCase classes, 13 Β· 11 Β· 10 Β· 9 Β· 9 Β· 8 Β· 6 Β· 5 no-argument testβ¦ methods |
| 71 tests pass | clone, run the three commands under Testing on any Mac; no LiDAR device required. CI does the same on every push and uploads the .xcresult bundle as an artifact with 14-day retention |
| The suite cannot be faked green | run scripts/assert-test-results.sh against a bundle from a run you sabotage; it is designed to be negative-tested on a laptop |
| No Core ML model is loaded | ObjectDetectionService.setupClassificationRequest() β the body is a comment |
Voice commands, spatial audio, floor detection and UserSettings are unreachable |
grep -rn "VoiceCommandService|SpatialAudioManager|DepthProcessor|UserSettings" --include="*.swift" VisualAssist/ β each appears only in its own defining file |
| No network code | grep -rE "URLSession|dataTask|https?://" VisualAssist/ β one hit, a Safari Link |
| No third-party dependencies | no Package.resolved, no Podfile, zero XCRemoteSwiftPackageReference in the pbxproj |
| The roadmap branches are empty | git rev-list --count main..feature/indoor-mapping and its three siblings all return 0 |
| Supply chain | OpenSSF Scorecard β computed and published by the OpenSSF, not by this repository; it read 4.6 on 2026-08-03. Several of its 18 checks grade repository settings that no committed file can change, so the figure starts modest and the direction of travel is what matters |
Ayush Yadav β sole author and maintainer. github.com/yadava5
Contributions are welcome; see CONTRIBUTING.md, SECURITY.md and the Code of Conduct. If you use VoiceOver or a screen reader daily, a report on where the labelling breaks down is more valuable than a feature.
Visual Assist is licensed under the Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0).
You may share and adapt it for any noncommercial purpose with attribution. Commercial use, distribution or monetisation requires explicit written permission from the author β the LICENSE directs commercial licensing requests through this repository.
Provided "as is", without warranty of any kind. Visual Assist is not affiliated with Apple Inc.; iPhone, LiDAR, ARKit and other Apple trademarks are the property of Apple Inc.
This is not a medical device or a certified mobility aid. It has not been clinically evaluated, it has no redundancy, and it will miss obstacles. Do not use it as a replacement for a white cane, a guide dog or orientation and mobility training.