This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

RawCull Tech Documentation

RawCull Developer Notes

These pages explain how RawCull is developed from a developer’s point of view. The goal is not user help. The goal is to make the source easier to reason about when you return to the project: which file owns which behavior, how data moves through the app, where expensive work happens, and what to check before changing a subsystem.

RawCull is a native macOS SwiftUI app for culling RAW photos, currently focused on Sony ARW and Nikon NEF files. This repository builds the Hugo documentation site. The Swift trees under sourcecode/ are source snapshots used by the documentation and by package tests; they are not compiled by the Hugo site itself.

Source Snapshot Layout

PathRole
sourcecode/RawCull/App target: SwiftUI views, @Observable view models, actors, cache owners, persistence, diagnostics, and rsync workflow
sourcecode/RawCullCore/Shared Swift package: value models, burst grouping/ranking, focus-point normalization, histogram calculation, and tests
sourcecode/RawParserKit/RAW parsing package: format registry, Sony/Nikon MakerNote parsers, embedded JPEG extraction, thumbnail extraction, cancellation bridge, and tests

How To Read The Docs

Start with the pipeline pages, then use the focused references when changing one area.

PageUse it when you need to understand
Thumbnails and Scan PipelineFolder selection, file discovery, EXIF/focus extraction, thumbnail preload, on-demand thumbnails, and RAW format dispatch
ConcurrencyActor boundaries, main-actor state, task groups, cancellation, and how blocking ImageIO is kept off Swift’s cooperative pool
Memory CacheThe RAM thumbnail caches, disk thumbnail cache, full-size JPEG disk cache, cache diagnostics, and burst-analysis cache
Memory PressureHow RawCull reads system/app memory and reacts to kernel pressure events
Focus MaskSharpness scoring, saliency, AF-point weighting, focus-mask generation, calibration, and scoring persistence
Detailed Sharpness ScoringStep-by-step source walk-through of sharpness scoring, formulas, examples, and score-impacting factors
Detailed Focus Mask ComputationStep-by-step source walk-through of focus-mask rendering, patch selection, visual thresholds, examples, and result-impacting factors
Burst GroupsVision similarity indexing, grouping rules, ranking weights, review states, manual winners, and culling actions
Artificial IntelligencePhotoAIKit architecture, model identity, reusable AI boundaries, and the complete CLIP activation and similarity flow in RawCull
AI Model DownloadsManaged Background Assets architecture, storage, validation, release gates, and self-hosted versus Apple-hosted activation
Saved Filessavedfiles.json, ratings, sharpness/saliency persistence, burst winner overrides, and debounced writes
File Read and WriteEvery app file RawCull reads or writes, including caches, bookmarks, JSON, extracted JPEGs, and rsync include files
Security-Scoped URLsSandbox access, bookmarks, active catalog scope, and rsync source/destination scope
SwiftUI ComponentsThe main view hierarchy and reusable SwiftUI components already present in the app
Calculations ReferenceFormula-level reference for memory, cache cost, histogram, sharpness, burst ranking, and rsync totals
Synchronous CodeThe places that still do blocking work and why they are wrapped the way they are
Sony/Nikon MakerNote ParserHow focus points and embedded JPEG locations are parsed from RAW metadata
FindingsCurrent source-review findings and follow-up items
EnhancementsSource-backed roadmap ideas for next development passes
Compiling RawCullBuilding the documentation site and notes for compiling the macOS app elsewhere
Repository Git WorkflowGit push/pull workflow for this documentation repository

Architectural Shape

flowchart LR
    UI["SwiftUI views"] --> VM["RawCullViewModel and feature view models"]
    VM --> Actors["Actors for scan, cache, thumbnail, export, JSON writes"]
    Actors --> Parser["RawParserKit: ARW/NEF dispatch and extraction"]
    VM --> Core["RawCullCore: value models and pure algorithms"]
    Actors --> Disk["Caches, savedfiles.json, security-scoped folders"]
    VM --> Core

The pattern to watch for is separation by risk:

  • UI state lives on @MainActor observable classes.
  • Long-running or shared mutable work lives in actors.
  • Pure reusable algorithms live in RawCullCore.
  • Vendor-specific RAW knowledge lives in RawParserKit.
  • Non-Sendable image types are converted or consumed before crossing concurrency boundaries.

1 - Thumbnails and Scan Pipeline

Thumbnails and Scan Pipeline

RawCull separates catalog scanning, thumbnail preloading, UI-driven thumbnail requests, and full-size preview extraction. They share RawParserKit and disk-cache infrastructure, but they have deliberately different memory-admission rules.

The key current invariant is: catalog preloading warms the 200 px grid cache and disk cache, but only UI demand may admit an image to the preview-size RAM cache. This keeps scan order from displacing the images the user is actively viewing.

Source Map

AreaMain files
Catalog lifecycleRawCullViewModel+Catalog.swift, RawCullMainView.swift
App/package loading boundaryModel/RawImageLoading.swift, RawParserKit/Sources/RawParserKit/RawImageLoader.swift
File discovery and metadata scanActors/DiscoverFiles.swift, Actors/ScanFiles.swift
Catalog thumbnail preloadActors/ScanAndCreateThumbnails.swift, Model/Handlers/CreateFileHandlers.swift
UI-driven thumbnailsActors/ThumbnailLoader.swift, Actors/RequestThumbnail.swift, ThumbnailImageView.swift
Thumbnail cachesActors/SharedMemoryCache.swift, Actors/DiskCacheManager.swift, Model/Cache/CachedThumbnail.swift
Full-size preview loadingModel/FullSizePreviewLoader.swift, Model/Handlers/ZoomPreviewHandler.swift, Actors/FullSizeJPGDiskCache.swift
Vendor dispatchRawParserKit/Sources/RawParserKit/RawFormat.swift, RawFormatRegistry.swift, SonyRawFormat.swift, NikonRawFormat.swift
Concurrency testsRawCullTests/ThumbnailLoaderConcurrencyTests.swift, ThumbnailProviderTests.swift, DiskCacheAndScanAdmissionTests.swift

Catalog Load Flow

flowchart TD
    A["User selects catalog folder"] --> B["startCatalogLoad"]
    B --> C["Acquire security-scoped access"]
    C --> D["ScanFiles.scanFiles"]
    D --> E["RawImageLoading.fileMetadata per file"]
    E --> F["Sort and filter on MainActor"]
    F --> G["Load ratings and valid saved scores"]
    G --> H["ScanAndCreateThumbnails.preloadCatalog"]
    H --> I["200 px grid RAM cache"]
    H --> J["JPEG thumbnail disk cache"]
    F --> K["SwiftUI grid and detail views"]
    K --> L["ThumbnailLoader / RequestThumbnail"]
    L --> M["Preview RAM cache"]
    L --> J

Catalog Load Ownership

RawCullViewModel.startCatalogLoad(for:) cancels the older load, starts security-scoped access, and creates a background-priority catalogLoadTask that runs handleSourceChange(url:).

handleSourceChange(url:) is main-actor orchestration. File I/O, metadata work, decoding, and cache I/O are awaited through actors or detached work. After every significant suspension, isActiveCatalogLoad(_:) and cancellation checks prevent an old catalog from publishing into the current UI.

Changing catalogs also cancels thumbnail/full-preview work, clears burst analysis state, stops the old security scope, and releases the current scanning actors.

File Discovery

DiscoverFiles enumerates a directory off the main actor and filters extensions through RawFormatRegistry.allExtensions. Its recursive parameter controls whether subdirectories are traversed.

The current registry contains:

FormatExtensionConformer
Sony ARW.arwSonyRawFormat
Nikon NEF.nefNikonRawFormat

ScanFiles performs its own non-recursive directory listing and asks RawFormatRegistry.format(for:) whether each item is supported. Discovery and scan therefore share registry policy rather than maintaining separate hard-coded extension lists.

Metadata Scan

ScanFiles.scanFiles(url:onProgress:) starts its own security-scoped access and creates one child task per supported file. Each child:

  1. reads URL resource values for name, byte size, content type, and modification date;
  2. calls the injected RawImageLoading.fileMetadata(for:) abstraction;
  3. builds a FileItem with EXIF metadata and the normalized AF point;
  4. records the package focus-location string when available.

The production adapter, RawParserKitImageLoader, maps RawParserKit.RawImageLoader.metadata(for:) into the app’s ExifMetadata. RawParserKit reads ImageIO EXIF/TIFF data, dispatches body-specific details through RawFormatRegistry, and resolves MakerNote or EXIF subject-area focus evidence.

This is a single metadata pass per file. The app no longer runs separate EXIF and MakerNote extraction passes.

If no native focus-location strings are found for the catalog, ScanFiles falls back to focuspoints.json. This is catalog-wide fallback behavior; it does not merge JSON entries into a partly populated native result.

FileItem is an app typealias for RawCullCore.RawCullFileItem, keeping scan output usable by the pure package engines and tests.

Thumbnail Preload

After scan, sort, ratings, and valid saved scores are applied, ScanAndCreateThumbnails.preloadCatalog(at:targetSize:) walks the catalog again to warm browsing caches. A catalog URL already recorded in processedURLs is not preloaded again during the same app session.

The outer task group admits at most:

RawImageLoadingConcurrency.batchExtractionLimit
= max(1, activeProcessorCount * 2)

RawParserKit adds an internal six-slot thumbnail decode limiter and coalesces identical URL/size requests. The outer limit bounds catalog work; the package limit prevents simultaneous RAW decodes from growing without bound.

For each file, preload uses this path:

flowchart LR
    A["RAW URL"] --> B{"Preview RAM hit?"}
    B -->|"yes"| C["Touch preview LRU"]
    B -->|"no"| D{"Disk JPEG hit?"}
    D -->|"yes"| E["Decode oriented JPEG"]
    D -->|"no"| F["RawImageLoading.thumbnailImage"]
    C --> G["Populate 200 px grid cache"]
    E --> G
    F --> G
    F --> H["Encode JPEG data"]
    H --> I["Background atomic disk save"]

All three branches populate the dedicated grid cache when needed. Disk and source branches do not insert into the preview-size RAM cache. RequestThumbnail is its only admitter, so preview LRU ordering reflects user demand instead of scan order.

On a cold extraction, ScanAndCreateThumbnails converts the actor-owned image to JPEG Data before launching the background disk save. This avoids sending CGImage or NSImage across the detached-task boundary.

The Two Memory Caches

SharedMemoryCache owns two NSCache instances:

CacheContentAdmission policy
Preview cache (memoryCache)Preview-size images used for detail/list demandOnly RequestThumbnail inserts; a hit touches its LRU position
Grid cache (gridThumbnailCache)Downscaled 200 px imagesPreload populates it; grid requests can return immediately

Both caches are cost-limited. The preview item count limit is intentionally high so byte cost is the normal binding constraint. Under warning memory pressure both limits shrink to 60%; under critical pressure both caches are cleared and the preview cache is temporarily capped at 50 MiB.

UI-Driven Thumbnail Loading

ThumbnailImageView has two routes:

  • .grid calls the shared ThumbnailLoader actor;
  • .list calls RequestThumbnail directly.

For grid targets of 200 px or less, ThumbnailLoader first checks the grid cache without acquiring a concurrency slot. A miss joins its FIFO-like slot queue, which allows at most six active requests and removes cancelled waiters safely.

After a slot is acquired, the loader reads saved settings and asks RequestThumbnail for settings.thumbnailSizePreview. The passed grid target controls the grid-cache fast path; the slower preview request uses the configured preview size.

RequestThumbnail resolves a request in this order:

  1. preview RAM cache;
  2. oriented JPEG disk cache;
  3. RawImageLoading.thumbnailCGImage source decode.

A disk hit is promoted into preview RAM. A cold source decode is inserted into preview RAM and asynchronously encoded to the thumbnail disk cache. The cache also records UI demand, cold extraction, eviction, and “boomerang” diagnostics for measuring whether a recently evicted image had to be reloaded from disk.

Disk Thumbnail Cache

DiskCacheManager stores quality-0.7 JPEGs. Its key is an MD5 filename hash of a version string plus the standardized source path. MD5 is used only as a compact filesystem key, not for security.

The current key version includes oriented-thumbnail semantics, so thumbnails from older orientation behavior are automatically invalidated. Loads use OrientationNormalizedImageLoader, and writes are atomic.

Full-Size And Zoom Preview Paths

Full-size inspection does not use the normal thumbnail disk cache.

flowchart TD
    A["Zoom / comparison source"] --> B{"Source choice"}
    B -->|"Thumbnail"| C["RequestThumbnail"]
    B -->|"Embedded JPEG"| D["FullSizePreviewLoader"]
    B -->|"Developed RAW"| E["ZoomPreviewHandler"]
    D --> F{"Sidecar JPEG exists?"}
    F -->|"yes"| G["Load oriented sidecar"]
    F -->|"no"| H{"Full-size disk cache hit?"}
    H -->|"yes"| I["Load cached embedded preview"]
    H -->|"no"| J["RawImageLoading.previewCGImage"]
    J --> K["Save embeddedJPG variant"]
    E --> L{"developedRAW cache hit?"}
    L -->|"no"| M["SonyRawFormat.createFullSizeJPEG"]

FullSizePreviewLoader prefers a same-basename .jpg sidecar, then the full-size disk cache, then RawParserKit extraction. RawParserKit itself coalesces duplicate preview requests and limits expensive full-size decode work to two concurrent operations.

The developed-RAW route is currently Sony-specific and uses its own cache variant. The full-size cache stores quality-0.85 JPEG data and has a versioned orientation-aware key. Keeping these larger images on disk prevents a few pixel-peeping previews from evicting many browsing thumbnails from RAM.

App Loading Abstraction

The app depends on the RawImageLoading protocol rather than calling vendor conformers directly:

RequirementUse
fileMetadata / exifMetadataScan metadata and normalized focus evidence
thumbnailCGImage / thumbnailImageOn-demand and preload thumbnail generation
previewCGImageEmbedded/sidecar full-size preview loading

RawParserKitImageLoader is the production adapter. Tests can inject alternate loaders without performing real RAW decoding.

Within RawParserKit, RawFormat provides vendor policy:

RequirementUse
extensions, displayNameRegistry lookup and diagnostics
extractThumbnailVendor thumbnail fallback
extractEmbeddedPreviewLargest usable embedded preview
focusLocationMakerNote AF location
rawFileTypeStringCompression labels
sizeClassThresholds, rawSizeClassBody-specific S/M/L size labels

extractFullJPEG remains only as a deprecated compatibility requirement; new code uses extractEmbeddedPreview.

What To Check When Changing This Area

  • Preserve the rule that scan/preload does not admit disk or source results to preview RAM.
  • Check both the grid-cache fast path and configured preview-size path when changing thumbnail settings.
  • Keep RawFormatRegistry.allExtensions and RawFormatRegistry.all aligned when adding a format.
  • Keep expensive decode limits and cancellation behavior covered by concurrency tests.
  • Convert actor-owned images to Data before detached cache writes.
  • Version cache keys when orientation or encoded-image semantics change.
  • Inspect isActiveCatalogLoad(_:) guards if stale results appear after switching folders.

2 - Memory Cache

Cache System

RawCull uses several caches because RAW decoding is expensive and because different UI surfaces need different image sizes. The normal thumbnail path is layered RAM -> disk -> RAW extraction. The zoom path has its own disk cache for larger embedded JPEGs. Burst analysis has a separate JSON cache for derived analysis data.

Source Map

CacheMain filesStores
Preview memory cacheActors/SharedMemoryCache.swift, Model/Cache/CachedThumbnail.swiftLarger NSImage thumbnails for detail/loupe use
Grid memory cacheActors/SharedMemoryCache.swiftSmall grid thumbnails, downscaled before insertion
Thumbnail disk cacheActors/DiskCacheManager.swiftJPEG thumbnail files under the app cache directory
Full-size JPEG disk cacheActors/FullSizeJPGDiskCache.swift, Model/Handlers/ZoomPreviewHandler.swiftLarger embedded JPEG previews for zoom
Burst analysis cacheActors/BurstAnalysisCache.swiftJSON snapshots of embeddings, scores, groups, rankings, and review state
DiagnosticsModel/Cache/CacheDelegate.swift, Model/Cache/CacheStatistics.swift, Views/Diagnostics/MemoryDiagnosticsView.swiftHit/miss/eviction/pressure counters

Thumbnail Lookup

flowchart TD
    A["Request thumbnail"] --> B{"Preview memory cache?"}
    B -->|"hit"| C["Return NSImage"]
    B -->|"miss"| D{"Disk thumbnail cache?"}
    D -->|"hit"| E["Decode JPEG and refill memory"]
    D -->|"miss"| F["RawParserKit extractThumbnail"]
    F --> G["Create NSImage"]
    G --> H["Store preview memory"]
    G --> I["Store grid memory copy"]
    G --> J["Save disk JPEG"]

The same lookup idea is used by preload and on-demand requests. ScanAndCreateThumbnails warms the cache after a catalog opens. ThumbnailLoader and RequestThumbnail resolve individual thumbnails while the user scrolls.

CachedThumbnail

CachedThumbnail wraps an immutable NSImage and stores its cache cost.

The cost is calculated from image representations:

cost = sum(rep.pixelsWide * rep.pixelsHigh * 4) * 1.1

The 4 is SharedMemoryCache.costPerPixel, fixed for RGBA. The 1.1 multiplier gives a small overhead buffer for the wrapper and image metadata.

CachedThumbnail is @unchecked Sendable. The project invariant is: construct the NSImage fully before caching it, then treat it as immutable.

The class intentionally does not conform to NSDiscardableContent. Earlier versions did, but diagnostics showed NSCache discarded entries too aggressively and destroyed the RAM hit rate. Eviction is now controlled by explicit cache limits and memory-pressure handling.

SharedMemoryCache

SharedMemoryCache is an actor singleton:

actor SharedMemoryCache {
    nonisolated static let shared = SharedMemoryCache()
}

It is still an actor because it owns configuration, pressure monitoring, disk-cache references, and statistics. Its two NSCache objects are marked nonisolated(unsafe) because NSCache is already thread-safe and the app needs synchronous lookup APIs.

That gives RawCull both:

  • actor isolation for mutable app-owned state,
  • fast synchronous cache reads for hot thumbnail paths.

Adaptive Limits

CacheRecommendationPolicy chooses cache caps from physical memory, current used memory, user maximums, and memory pressure.

Baseline limits:

Machine memoryPreview baselineGrid baseline
64 GB or more8000 MB2000 MB
32 GB or more4096 MB1024 MB
Less than 32 GB2048 MB768 MB

At normal pressure, RawCull calculates available headroom:

freeMB = physicalMB - usedMB
expandableMB = max(0, freeMB - 3072)
extraBudgetMB = expandableMB * 0.5
previewMB = baselinePreview + extraBudgetMB * 0.65
gridMB = baselineGrid + extraBudgetMB * 0.35

Values are rounded up to 256 MB steps and capped by both the machine tier and user settings.

At warning or critical pressure, the policy falls back toward 60 percent of the baseline, again respecting user limits and minimum settings.

Eviction Tracking

CacheDelegate is shared by both NSCache instances. It records three counters:

CounterMeaning
memory evictionsPreview-memory entries evicted
grid evictionsGrid-memory entries evicted
unknown evictionsA delegated NSCache that was neither known cache

The delegate also calls back into SharedMemoryCache to decrement manual cost/count mirrors. This is necessary because NSCache does not expose current cost or item count.

For preview-cache evictions, the delegate stores the evicted URL in a recent-eviction ring. If the same URL is requested shortly after and falls back to disk, diagnostics can label that as a boomerang miss: the item was useful, but the cache evicted it too soon.

Memory Pressure

SharedMemoryCache owns a DispatchSourceMemoryPressure.

Kernel eventRawCull response
.normalRestore cache configuration from settings/adaptive policy
.warningReduce preview and grid cache cost limits to 60 percent of their current limits
.criticalClear both memory caches, reset counters, set preview cache limit to 50 MB until recovery

The current pressure level is stored behind an OSAllocatedUnfairLock and exposed as a synchronous nonisolated property. This lets MemoryViewModel and diagnostics read pressure state without an actor hop.

Disk Caches

DiskCacheManager stores generated thumbnail JPEGs. It is used by both preload and on-demand thumbnail loading.

FullSizeJPGDiskCache stores larger embedded previews for the zoom overlay. It is deliberately separate from the normal thumbnail cache because zoom images are larger and should not compete with grid scrolling for memory.

Burst Analysis Cache

BurstAnalysisCache is not an image cache. It stores analysis artifacts under Application Support:

  • Vision feature-print embeddings,
  • sharpness scores and saliency info,
  • burst groups and boundary evidence,
  • ranking results,
  • review states.

A snapshot is accepted only when the catalog, file count, file sizes/modification dates, thumbnail size, sharpness signature, similarity signature, schema version, and algorithm version still match.

What To Check When Changing This Area

  • Keep CachedThumbnail immutable after insertion.
  • Update manual cost/count mirrors whenever adding or removing cache entries.
  • Check diagnostics if cache hit rate drops; boomerang misses often point to a limit or pollution problem.
  • Do not feed zoom-preview images into the normal thumbnail RAM cache unless you intentionally want them competing with grid thumbnails.
  • When changing scoring or burst algorithms, bump signatures/versions so stale burst-analysis cache data is rejected.

3 - Memory Pressure

How RawCull measures memory and reacts to macOS pressure events

Memory Pressure

RawCull can hold many images in memory while scanning and culling. The memory-pressure code exists to keep the app responsive when macOS reports pressure and to make cache behavior visible during development.

Source Map

FileRole
Actors/SharedMemoryCache.swiftOwns cache limits, memory-pressure dispatch source, pressure counters, and cache clearing
Model/ViewModels/MemoryViewModel.swiftSamples total system memory, used system memory, app memory, and pressure threshold for the UI
Model/Cache/CacheConfig.swiftContains CacheRecommendationPolicy and cache-limit calculation
Model/ViewModels/MemoryDiagnosticsViewModel.swiftWrites diagnostics samples for cache/memory review
Views/Settings/MemoryTab.swiftDisplays memory stats, cache limits, and pressure status
Views/RawCullSidebarMainView/MemoryWarningLabelView.swiftShows the sidebar warning during pressure

Runtime Flow

flowchart TD
    A["SharedMemoryCache.ensureReady"] --> B["startMemoryPressureMonitoring"]
    B --> C["DispatchSourceMemoryPressure"]
    C --> D{"event"}
    D -->|"normal"| E["refreshConfig from settings/adaptive policy"]
    D -->|"warning"| F["shrink cache limits to 60 percent"]
    D -->|"critical"| G["clear memory caches and set 50 MB floor"]
    F --> H["Notify file handler warning"]
    G --> H
    E --> I["Clear warning"]

SharedMemoryCache starts monitoring lazily during ensureReady(). That is called before thumbnail cache use, so the memory-pressure handler is active before large thumbnail work begins.

Measuring System Memory

MemoryViewModel.getUsedSystemMemory() uses Mach VM statistics:

usedMemory = min((wired + active + compressed) * pageSize, physicalMemory)

The model intentionally uses a simple definition of “used”:

VM fieldMeaning
wire_countWired pages that cannot be paged out
active_countPages currently active
compressor_page_countPages held by the VM compressor

The result is clamped to physical memory so the UI cannot show impossible totals.

Measuring App Memory

RawCull measures its own process footprint with:

task_info(... TASK_VM_INFO ...).phys_footprint

That is the value displayed as app memory usage. It is a better signal than simply adding image sizes because it reflects what the kernel says the process currently costs.

Percentages In The UI

MemoryViewModel exposes:

memoryPressurePercentage = memoryPressureThreshold / totalMemory * 100
usedMemoryPercentage = usedMemory / totalMemory * 100
appMemoryPercentage = appMemory / usedMemory * 100

The default pressure threshold is 85 percent of physical memory:

memoryPressureThreshold = totalMemory * 0.85

This UI threshold is informational. The actual pressure response is driven by macOS DispatchSourceMemoryPressure events.

Cache Limit Policy

CacheRecommendationPolicy.adaptiveLimits(...) is the first place to inspect when tuning memory behavior.

At normal pressure:

  1. Choose a baseline from physical RAM.
  2. Estimate free memory from Mach stats.
  3. Keep a 3 GB reserve.
  4. Use half of the remaining expandable memory as extra cache budget.
  5. Split that extra budget 65 percent preview cache and 35 percent grid cache.
  6. Round to 256 MB steps.
  7. Clamp by machine tier and user maximums.

At non-normal pressure, RawCull returns reduced baseline limits and then the live pressure handler may shrink or clear active caches immediately.

Pressure Responses

EventCode pathEffect
NormalhandleMemoryPressureEvent, .normalSet pressure level to normal, increment normal counter, refresh config, clear warning
Warning.warningSet pressure level to warning, increment warning counter, reduce current preview/grid cost limits to 60 percent, show warning
Critical.criticalSet pressure level to critical, increment critical counter, clear memory caches, reset manual counters, set preview limit to 50 MB

The grid cache is cleared on critical pressure too. The disk caches are not deleted; only RAM is released.

Why Pressure State Is Lock-Backed

currentPressureLevel is read by UI and diagnostics without await. The value is stored in an OSAllocatedUnfairLock, which makes the read/write contract explicit while avoiding a main-actor or cache-actor hop during frequent sampling.

The same pattern is used for pressure counters and cache cost/count mirrors.

What To Check When Changing This Area

  • Memory sampling should stay off the main actor; Mach calls run in Task.detached.
  • Do not rely only on the 85 percent UI threshold; the real emergency signal is the kernel pressure event.
  • If cache limits look strange, inspect both user settings and the adaptive tier caps.
  • If diagnostics miss short pressure spikes, use cumulative pressure counters rather than only sampled pressure labels.
  • Critical pressure should free RAM quickly and should not delete disk caches.

4 - Concurrency

Concurrency

This page documents concurrency in the RawCullAIModels branch of RawCull and in the first-party Swift packages checked out beside it. The audit covers production source in the RawCull app, DecodeEncodeGeneric, ParseRsyncOutput, PhotoAIKit, PhotoAnalysisKit, RawCullCore, RawParserKit, RsyncArguments, RsyncProcessStreaming, and RsyncAnalyse.

RawCull uses concurrency to keep SwiftUI responsive while it scans folders, reads RAW metadata, extracts and caches previews, calculates sharpness, creates similarity artifacts, groups bursts, runs AI models, downloads model assets, and streams rsync output.

The rule: isolate work, do not guess about threads

A Swift Task is not a thread. Swift schedules tasks on executors, and executors use threads. At an await, a task may suspend and later resume on a different worker thread even when it resumes on the same actor. RawCull must never depend on the worker thread being unchanged.

In this document, hop means a change of actor, executor, or dispatch queue that is required or explicitly requested by the source code:

  • Calling an actor-isolated method from outside that actor hops to the actor’s executor.
  • @MainActor, MainActor.run, and Task { @MainActor in ... } hop to the main actor when the caller is elsewhere.
  • @concurrent and Task { @concurrent in ... } explicitly move non-UI work off inherited actor isolation and onto the concurrent executor.
  • A task-group child is independently scheduled so that sibling work can run in parallel.
  • Task.detached creates an unstructured task that does not inherit actor isolation.
  • DispatchQueue.async submits work to the named GCD queue.

None of await, Task.sleep, task priority, or cancellation alone requests an executor hop. They may cause suspension and a later change of worker thread, but that is scheduler behavior and must not be observed as application logic.

Every executor hop in RawCull must be intentional. Add an explicit isolation annotation or concurrency primitive and document the reason. A change of worker thread after suspension is neither an error nor a behavior RawCull may rely on.

Compiler concurrency model

TargetLanguage and isolation settingsConsequence
RawCull appSwift 6, Approachable Concurrency, default MainActor isolationSwift 6 enforces data-race safety, and unannotated app declarations commonly begin main-actor isolated. Background work must opt out deliberately. The test target additionally spells out SWIFT_STRICT_CONCURRENCY = complete.
RawCullCoreSwift 6, default MainActor, InferIsolatedConformances, NonisolatedNonsendingByDefaultPure models and algorithms are explicitly nonisolated so they can run in the caller’s non-UI domain.
RawParserKitSwift 6, default MainActor, InferIsolatedConformances, NonisolatedNonsendingByDefaultParser utilities opt out with nonisolated; stateful loaders and limiters use actors.
PhotoAnalysisKitSwift 6, InferIsolatedConformances, NonisolatedNonsendingByDefaultAnalysis APIs are not main-actor-owned; explicit workers and task groups provide concurrency.
PhotoAIKitSwift 6Model runtimes, stores, and indexes use actors; workflows use bounded structured concurrency.
Remaining packagesSwift 6 toolchains; no package-level default actor isolationIsolation is supplied by declarations such as @MainActor or actor.

swift-tools-version selects the PackageDescription API and minimum toolchain. It is not, by itself, proof of the package’s Swift language mode; packages that declare swiftLanguageModes: [.v6] do so explicitly.

Concurrency architecture

flowchart TD
    UI["SwiftUI and @MainActor state"]
    APP["RawCull background actors"]
    GROUPS["Bounded task groups and async let"]
    PARSER["RawParserKit actors and ImageIO bridge"]
    ANALYSIS["PhotoAnalysisKit workers"]
    AI["PhotoAIKit model and storage actors"]
    CORE["RawCullCore nonisolated Sendable values"]
    RSYNC["Rsync callbacks, StreamAccumulator actor, GCD drain queue"]
    GCD["GCD worker threads for blocking framework and pipe work"]

    UI -->|"await: intentional actor hop"| APP
    UI -->|"@concurrent: intentional exit from MainActor"| GROUPS
    APP --> GROUPS
    APP -->|"await"| PARSER
    GROUPS --> ANALYSIS
    GROUPS --> AI
    APP --> CORE
    UI --> RSYNC
    PARSER --> GCD
    RSYNC --> GCD
    GCD -->|"Task @MainActor"| UI

1. Main-actor concurrency

The main actor owns SwiftUI and user-visible mutable state. This is correctness isolation, not a way to make work parallel.

Main-actor ownerResponsibility
RawCullViewModelCatalog lifecycle, selection, culling state, thumbnail and burst coordination
CullingModelRatings and saved culling state
SharpnessScoringModel, FocusMaskModelObservable scoring options, results, calibration, and focus-mask presentation
SimilarityScoringModelSimilarity artifacts, semantic search, burst groups, ranking, and progress
RawCullAISettingsModel, DeepAIReviewFeatureModel availability/download UI and deep-review UI state
SettingsViewModelSettings snapshots and persistence coordination
MemoryViewModel, MemoryDiagnosticsViewModel, SimilarityDiagnosticsViewModelDiagnostic state presented by SwiftUI
GridThumbnailViewModel, ComparisonGridImageCoordinatorView-specific selection and image-loading state
ExecuteCopyFiles, Params, ArgumentsSynchronize, RemoteDataNumbersrsync UI state and parameters
RawCullAIIntegrationMain-actor capability snapshot and service selection
CreateStreamingHandlers, ReadSavedFilesJSONApp-facing callbacks and state application

SwiftUI .task modifiers and button-created Task values inherit main-actor isolation when their synchronous prefix reads or mutates view state. That entry is intentional. They then hop only when awaiting a background actor or an explicitly concurrent function, and resume on the main actor before changing UI state.

DispatchQueue.main.async is used in the comparison filmstrip and vertical image table to defer scrollTo by one run-loop turn so lazy view IDs have entered scroll geometry. These calls do not exist merely to reach the main thread; they intentionally delay already-main-actor UI work.

2. Actors that serialize mutable state

An actor hop is wanted because it establishes exclusive access to the actor’s state. It may suspend the caller, but it does not promise a particular worker thread.

RawCull app actors

GroupActorsSerialized state or operation
Catalog and batch workScanFiles, ScanAndCreateThumbnails, ScanAndExtractJPGs, ExtractAndSaveJPGsScan state, stored batch task, progress counters, ETA, and cancellation
Thumbnail flowThumbnailLoader, RequestThumbnailAdmission slots, waiters, settings snapshots, one-time setup, and per-request cache flow
Cache and persistenceSharedMemoryCache, DiskCacheManager, FullSizeJPGDiskCache, BurstAnalysisCache, PerFileAnalysisArtifactStore, WriteSavedFilesJSON, SaveJPGImageCache configuration, disk-cache operations, atomic JSON/artifact commits, schema validation, and writes
DiagnosticsSimilarityDiagnosticsLogOrdered diagnostic log writes
AI resources and downloadsRawCullAIModelResourceManager, RawCullManagedBackgroundAssetsModelDownloadService, RawCullAIModelDownloadCoordinator, RawCullAIModelLicenceAcceptanceFileStoreProvider reuse, model download state, licence acceptance, and file-backed records
AI recoveryRawCullCLIPFailureRecorder, RawCullRecoveringCLIPArtifactProviderOrdered failure collection and primary-to-fallback provider decisions

SharedMemoryCache deliberately exposes its NSCache instances through nonisolated(unsafe): NSCache supplies its own thread safety. Synchronous counters, eviction history, and memory-pressure samples use OSAllocatedUnfairLock. This avoids an actor hop in synchronous cache delegate callbacks while retaining an explicit synchronization invariant.

Package actors

PackageActorsPurpose of isolation
RawParserKitRawImageLoader, DecodeConcurrencyLimiterCoalesce duplicate image/metadata tasks and bound full-size and thumbnail decodes
PhotoAnalysisKitVisionFeaturePrintBackendSerialize its Vision feature-print request state
PhotoAIKitCoreAICLIPProvider, CoreAISAM3Provider, VisionFeaturePrintBackend, SegmentationService, SubjectMaskCatalogIndex, SubjectMaskMemoryStore, SubjectMaskDiskStore, private ProgressRecorderOwn non-Sendable model runtimes, lazy model loads, repositories, cache inventory, disk/memory masks, and progress
RsyncProcessStreamingStreamAccumulatorPreserve partial stdout lines, errors, and counters across callback arrivals
RsyncAnalyseActorRsyncOutputAnalyserSerialize access to the parsed-output cache while returning Sendable analysis values

PhotoAIKit’s Core AI model providers are actors because model loading and inference resources are shared and not safe to use concurrently without ownership. An await from a RawCull workflow to a provider is therefore an intentional provider-actor hop. The pinned coreai-models implementation may use its own async execution internally; RawCull does not assume which thread it uses.

3. Structured parallelism

Structured child tasks inherit cancellation and cannot outlive their scope. RawCull uses them when operations are independent and results must be joined.

SitePrimitiveWork and concurrency limitWhy independent scheduling is wanted
ScanFiles.scanFileswithTaskGroupMetadata and native focus-point read per fileIndependent files can be decoded concurrently.
ScanAndCreateThumbnails, ScanAndExtractJPGs, ExtractAndSaveJPGswithTaskGroupPreview/cache/export work capped by activeProcessorCount * 2Bounded parallel file work improves throughput without unbounded bitmap allocation.
PhotoAnalysisKit.analyzeBatch and input loadingwithTaskGroupSliding window, caller-configured limit, default 8Loading and scoring different images are independent; output is restored to input order.
PhotoAnalysisKit calibrationwithTaskGroupSliding window of image energy calculationsCalibration samples are independent and cancellation discards partial output.
PhotoAIKit.EmbeddingIndexer, SimilarityArtifactIndexerwithThrowingTaskGroupSliding window controlled by maximumConcurrentTasksDecode/provider/store work overlaps while progress remains completion ordered.
Cache settingsfour async let childrenThumbnail, full-size preview, similarity-artifact, and burst-cache usageFour unrelated actor queries can complete in parallel.
AI capability refreshthree async let childrenSAM3 and two CLIP resource managersModel bundles are validated independently.
AI settings refreshtwo async let childrenCapability refresh and saved-evidence scanIndependent inputs are joined before one UI update.

Task-group children are scheduled independently of the actor running the group. That is the explicit parallelism boundary. Each child captures Sendable values and calls the actor or provider that owns mutable state.

4. Unstructured tasks and lifetime

RawCull creates unstructured tasks to bridge synchronous UI/callback entry points, coalesce duplicate requests, and retain cancellable long-running work.

PatternRepresentative ownersLifetime rule
Main-actor workflow taskRawCullViewModel, SharpnessScoringModel, SimilarityScoringModel, DeepAIReviewFeature, RawCullAISettingsModelStore the task, cancel the previous generation, check cancellation/generation before publishing results.
Actor-owned single-flight taskRawImageLoader, RequestThumbnail, SharedMemoryCacheReuse an in-flight task for the same setup or resource and remove it after awaiting the value.
Actor-owned batch taskscan, preload, export, catalog-index actorsActor serializes replacement; cancellation is propagated to child work.
SwiftUI .taskthumbnail, zoom, settings, diagnostics, and search viewsSwiftUI cancels it with view identity/lifetime; stored sub-tasks are cancelled on replacement.
Fire-and-forget notificationpreload/extraction progress and synchronous cancellation entry pointsThe task contains a single explicit actor call and owns no result needed by its creator.

Important stored tasks include catalog load, thumbnail preload, full-size cache warming, zoom extraction, burst analysis, similarity and semantic hydration, scoring, semantic search, model download, deep review, memory sampling, mask loading, and image-source loading. Their exact properties live with the owning view model or actor; ownership, not thread identity, defines their lifetime.

A bare Task { ... } inherits the surrounding actor. It does not hop away merely because it is asynchronous. This is wanted when its synchronous prefix accesses actor-owned state. If a new task has no actor work before its first await, use Task { @concurrent in ... } or call an explicitly @concurrent function so that leaving the main actor is visible in source.

5. Explicit concurrent-executor work

@concurrent is the Swift 6.2 spelling RawCull uses for work that must not inherit the caller’s actor. It is an executor hop, not a guarantee that a new OS thread will be created.

SiteWork moved off inherited isolationWhy the hop is wanted
CreateOutputforView.createOutputForViewParse collected rsync linesParsing is CPU work with no UI state.
ScanFiles.sortFilesSort immutable file snapshotsSorting may be large and does not need the scan actor or main actor.
RawCullSavedBurstEvidenceScanner.scanDirectory enumeration and JSON decodingCapability evidence scanning is file/CPU work, not UI work.
RawCullSemanticSearchService.rankText encoding and candidate rankingRanking can be substantial and publishes only Sendable progress/results.
RawCullDeepReviewImageDecoder.image, RawCullDeepAIReviewPipeline.reviewRAW/Core Image decode and review pipelineExpensive review must not occupy inherited main-actor isolation.
SubjectMaskFocusScorer.scoreMask-weighted focus scoringPure image analysis is non-UI CPU work.
SimilarityScoringModel workersArtifact indexing, semantic search, burst grouping, pairwise distance workMain-actor model owns publication; computation runs off it.
RawCullViewModel+DiagnosticsDiagnostic extractionFile diagnostics are computed away from UI and returned through MainActor.run.
RawCullAIModelDownloadService progress taskConsume asset status updatesThe stream wait is not UI-owned; its callback explicitly hops to @MainActor.
PhotoAnalysisKit.FocusMaskEngine workerSynchronous Core Image/Vision operationThe operation must not inherit UI isolation; cancellation cancels the worker.
PhotoAIKit.SubjectMaskDiskStore and storage-key readsSynchronous disk, identity, PNG, and JSON workExplicitly leave the store actor so the actor is not occupied during file work.

The PhotoAIKit disk-store tasks intentionally leave actor isolation, but @concurrent still uses Swift’s cooperative executor. It is appropriate for short bounded operations; any operation shown by profiling to block for a long time should use a dedicated blocking-I/O bridge like RawParserKit’s GCD continuation bridge.

6. Detached tasks and blocking work

Task.detached is used only where losing actor inheritance is deliberate. It does not inherit actor isolation or task-local values, and cancellation is not automatically structured unless the parent explicitly awaits and/or cancels it.

AreaDetached workReason
Catalog discovery and sidecar readsDirectory enumeration, Data(contentsOf:)Synchronous file work must not run on the main or scan actor.
Thumbnail and preview cachesImage decode, JPEG conversion, disk reads/writes, size calculation, pruningImageIO and filesystem work should not occupy cache actors.
RawParserKit.RawImageLoaderSidecar/preview decode and metadata readsExpensive synchronous ImageIO is separated from loader serialization.
Settings and burst cacheJSON reads/writes, cache usage, clearBlocking persistence is performed outside UI/actor isolation.
Memory diagnosticsMach and VM statisticsSystem calls are sampled away from the main actor, followed by MainActor.run.
Zoom/full-size previewPreview load and sharpeningPixel work is kept off UI isolation.

RawParserKit’s CancellableImageIOWork is the stronger escape hatch for blocking framework calls. It uses withCheckedThrowingContinuation and submits the operation to DispatchQueue.global(qos:). This is an intentional queue hop: blocking ImageIO/CoreImage work runs on a GCD worker rather than occupying a Swift cooperative-pool thread. ImageIOCancellationToken and WorkState use OSAllocatedUnfairLock so cancellation can race safely with exactly-once continuation resumption.

The queue submission may run on a different worker thread, but GCD does not promise a fresh thread or a stable thread identity. The guarantee RawCull relies on is queue/executor separation, not a thread number.

7. Async streams, callbacks, and continuations

BoundaryMechanismIsolation behavior
Memory settings timerAsyncStream plus a producer taskTask.sleep suspends between ticks; it does not request a hop. SwiftUI owns and cancels the consumer, and termination cancels the producer.
Copy progressAsyncStream<Int>Rsync callbacks yield Sendable progress; the main-actor owner consumes and presents it.
Managed model downloadframework AsyncSequenceA Task { @concurrent in ... } consumes updates, then awaits an explicitly main-actor progress closure.
Thumbnail/decode admissionchecked continuations plus cancellation handlersActors park callers without blocking a thread and resume each continuation exactly once when a slot is transferred or cancelled.
ImageIO bridgechecked throwing continuationA GCD callback resumes the Swift task; the waiting task resumes on its own required executor, not on the callback’s thread by contract.
rsync pipes and PTYFileHandle/DispatchSourceRead callbacksCallbacks arrive outside main-actor isolation; Task { @MainActor in ... } explicitly returns to UI/process state.

8. GCD, locks, and framework callbacks

GCD remains where the source API is callback-based or blocking:

  • RsyncProcessStreaming uses a named serial termination queue to wait briefly, drain stdout/stderr, clear handlers, and then creates Task { @MainActor in ... } to finalize process state. The first hop avoids blocking the main actor; the return hop protects main-actor process/UI state.
  • PTY and pipe callbacks create main-actor tasks before accessing RsyncProcess.
  • A short DispatchQueue.main.asyncAfter in PTY cleanup deliberately defers final drain/close; it is a timing boundary, not an accidental thread correction.
  • RawParserKit’s ImageIO bridge uses a global GCD queue specifically for blocking decode work.
  • The two SwiftUI DispatchQueue.main.async calls deliberately defer scrolling by one run loop.

Locks are limited to synchronous callback-compatible state:

  • SharedMemoryCache and CacheDelegate use OSAllocatedUnfairLock for counters, pressure state, and eviction history.
  • ImageIOCancellationToken uses it for the cancellation bit.
  • WorkState uses it to enforce exactly-once continuation completion.

Code must never hold one of these locks across await, call arbitrary client code while holding it, or use it as a substitute for actor ownership of asynchronous mutable state.

9. Sendable and nonisolated data flow

Values crossing task and actor boundaries are immutable or explicitly Sendable:

  • RawCullCore declares its catalog, EXIF, saliency, burst grouping, boundary, ranking, and review values nonisolated and Sendable. Its algorithms are synchronous and stateless; concurrency is supplied by callers.
  • RawParserKit exposes Sendable metadata/value types and actor-owned loaders. Images are consumed inside their owning isolation domain or encoded to Data before crossing a detached-task boundary.
  • PhotoAnalysisKit batch requests contain @Sendable async input providers. FocusMaskEngine is @unchecked Sendable under the documented invariant that its CIContext has no mutable model/UI state and every operation receives immutable configuration.
  • PhotoAIKit contracts are Sendable values and @Sendable provider/progress closures. CoreAISAM3Provider temporarily marks upstream runtime types @unchecked Sendable; the documented invariant is that they never leave that actor, and the conformances should be removed when upstream supplies correct annotations.
  • CachedThumbnail, rsync ProcessHandlers, and lock/continuation bridge state use @unchecked Sendable only with a local immutability or synchronization invariant.

nonisolated does not mean “background thread.” It means the declaration is not owned by an actor. A synchronous nonisolated function runs on the calling thread; an async one follows its declared/caller isolation rules. Use @concurrent when execution must explicitly leave inherited actor isolation.

Package coverage

PackageConcurrency present in production code
DecodeEncodeGenericAsync URLSession decode APIs. await URLSession.data(from:) suspends without promising a thread hop; the package owns no actor or task.
ParseRsyncOutputThe stateful parser class is @MainActor; parsing is serialized with its app-facing state. RawCull’s CreateOutputforView separately moves bulk line transformation off main actor.
PhotoAIKitEight actors, bounded throwing task groups, actor-owned catalog task, explicit concurrent disk/identity tasks, Sendable contracts, cancellation checks, and async progress callbacks.
PhotoAnalysisKitVision actor, bounded task groups, explicit concurrent cancellable Core Image/Vision worker, Sendable inputs/results.
RawCullCoreNo tasks or actors. Explicitly nonisolated, Sendable value models and pure algorithms support safe use from any isolation domain.
RawParserKitLoader and limiter actors, task coalescing, cancellation-aware admission continuations, detached ImageIO tasks, and a GCD/continuation bridge for blocking work.
RsyncProcessStreamingMain-actor process manager, accumulator actor, pipe/PTY callbacks, main-actor return tasks, a serial drain queue, dispatch source, timer, and Sendable handler boundary.
RsyncArgumentsNo asynchronous work, tasks, actors, queues, or locks; it builds argument values synchronously.
RsyncAnalyseActorRsyncOutputAnalyser protects its mutable analysis cache. Its parsing methods are synchronous actor operations and return nested Sendable result, statistics, change, and flag values. It is adjacent to RawCull but is not a direct package reference of the app target.

The app also pins external packages through PhotoAIKit, including Apple’s coreai-models. Their source is not part of the first-party checkouts under /Users/thomas/GitHub/RawCull; RawCull treats their async APIs as suspension points and does not make thread-affinity claims about their internals.

Cancellation, stale results, and backpressure

Cancellation is cooperative. Long-running loops check Task.isCancelled or Task.checkCancellation() before expensive steps and after suspension points. Task groups call cancelAll() when abandoning partial work. Continuation-based limiters remove and resume cancelled waiters. Framework work receives an explicit cancellation token where the underlying synchronous API cannot observe Swift task cancellation directly.

Cancellation alone does not prevent an old operation from publishing late. Main-actor workflows also use task identity, generation counters, selected catalog IDs, source IDs, or current-query checks before applying results. RawCullViewModel.cancelCatalogLoad() cancels catalog-related tasks, tells actors to cancel their inner work, resets dependent analysis state, and releases the active security-scoped resource.

Backpressure is intentional:

  • RAW thumbnail decoding is capped at 6 and full-size decoding at 2 in RawParserKit.
  • App batch extraction uses max(1, activeProcessorCount * 2).
  • PhotoAnalysisKit and PhotoAIKit batch APIs maintain a sliding window rather than enqueueing every file at once.
  • ThumbnailLoader parks excess callers with continuations instead of blocking threads.

Hop audit checklist

Before adding or changing concurrent work, answer these questions in code review:

  1. Who owns the mutable state? Use @MainActor for UI state and an actor for shared background state.
  2. Does the synchronous prefix need that actor? Keep a bare inherited Task only when work before the first await uses actor-owned state. Otherwise make the exit explicit with @concurrent.
  3. Is parallelism structured? Prefer async let or a bounded task group. Store an unstructured task only when a synchronous entry point or independently cancellable lifetime requires it.
  4. Is the operation blocking? An actor or @concurrent prevents main-actor occupation but does not make blocking safe for Swift’s cooperative pool. Use the GCD continuation bridge for long ImageIO/CoreImage or pipe-drain work.
  5. What causes each hop? Name the actor call, @concurrent, detached task, group child, MainActor.run, or dispatch queue. Do not write “await moves to a background thread.”
  6. How does work return? UI publication must occur through main-actor isolation; continuation waiters resume on their required executor automatically.
  7. What crosses the boundary? Transfer Sendable values. Encode non-Sendable images to Data, or consume them inside the actor that owns them.
  8. How does it stop? Define task ownership, cancellation checks, continuation cleanup, and stale-result rejection.
  9. Is a thread change being observed? Remove correctness logic based on Thread.current or thread IDs. Thread logging is diagnostic only.
  10. Is every hop wanted? If no correctness, responsiveness, or parallelism reason can be written beside the boundary, remove the boundary rather than relying on an accidental scheduling effect.

5 - Focus Mask and Sharpness

Focus Mask and Sharpness

RawCull’s focus system does two related jobs:

  1. produce a visible focus mask for image inspection,
  2. compute a scalar sharpness score for sorting, burst ranking, and saved scoring.

Both jobs use the same core engine, but the UI overlay and the numeric score are not identical. The overlay tries to show useful focus evidence. The score tries to rank files fairly across a catalog or burst.

Source Map

AreaFiles
UI-facing modelSharpnessScoringModel.swift, FocusMaskModel.swift
EngineFocusMaskEngine.swift, FocusMaskEngine+Scoring.swift, FocusMaskEngine+MaskGeneration.swift
ConfigurationFocusDetectorConfig.swift, SharpnessScoringOptions.swift, FocusMaskCalibration.swift
Data typesFocusMaskTypes.swift, RawCullCore/SaliencyInfo.swift
UI controlsSharpnessControlsView.swift, FocusMaskControlsView.swift, FocusSettingsTab.swift, FocusOverlayView.swift, ZoomOverlayView.swift
Metal kernelKernels.ci.metal
Persistence integrationRawCullViewModel+Sharpness.swift, CullingModel.swift, SavedFiles.swift

Scoring Flow

flowchart TD
    A["SharpnessScoringModel.scoreFiles"] --> B["Bounded task group"]
    B --> C["Per-file FocusDetectorConfig"]
    C --> D["FocusMaskEngine.computeSharpnessScore"]
    D --> E{"Scoring source"}
    E -->|"embeddedPreview"| F["Embedded JPEG or ImageIO thumbnail"]
    E -->|"rawDemosaic"| G["CIRAWFilter demosaiced thumbnail"]
    F --> H["Normalize to sRGB RGBA"]
    G --> H
    H --> I["Vision saliency and optional classification"]
    I --> J["Laplacian/detail scoring"]
    J --> K["SharpnessBreakdown"]
    K --> L["scores, saliencyInfo, breakdowns"]

SharpnessScoringModel is @Observable @MainActor. It owns progress, current options, scores, saliency info, and per-file breakdowns. It uses a bounded task group so multiple files can score in parallel without launching the whole catalog at once.

Scoring Options

The main knobs are:

SettingMeaning
photoTypeApplies subject-specific config presets, with .auto as the general mode
scoringQualityAdjusts scoring config, scoring size, and concurrent task count
scoringSourceChooses embedded preview or demosaiced RAW thumbnail
thumbnailMaxPixelSizeRequested scoring size, normalized by quality option
focusMaskModel.configThe active FocusDetectorConfig

effectiveFocusConfig combines photo type and quality into the config used for scoring. Per file, the model also injects ISO and an aperture hint derived from EXIF.

Image Source Choices

SourceStrengthCost
embeddedPreviewFast and good for normal cullingUses the camera-generated preview or ImageIO thumbnail
rawDemosaicMore precise final checkSlower; concurrency is capped more aggressively

For Sony ARW files, FocusMaskEngine has a fallback that reads an embedded JPEG directly through SonyMakerNoteParser when ImageIO cannot produce the preview.

Saliency And AF Evidence

The engine uses Vision saliency to find likely subject regions. When enabled, it also runs image classification and stores the best subject label in SaliencyInfo.

The camera AF point comes from FileItem.afFocusNormalized, which is parsed during catalog scan. When present, it helps choose the relevant subject/patch and improves burst-ranking confidence.

Region selection favors:

  1. saliency regions that contain or align with the AF point,
  2. regions closer to the AF point,
  3. higher Vision confidence,
  4. stronger local detail,
  5. larger region area as a final tie-breaker.

Sharpness Breakdown

SharpnessBreakdown is the main debugging record for a scored file.

FieldMeaning
finalScoreScore used by sorting and burst ranking
globalScoreFull-frame detail score when available
subjectScoreSalient-subject detail score when available
afPointScoreAF-region detail score when available
blurGateSigmaBlur-gate measurement used to reduce weak subject scores
subjectLabel / subjectConfidenceClassification result from Vision
focusFailureKindNone, motion blur, or missed focus
focusMaskRegionSourceWhether overlay evidence came from saliency, AF, both, or neither
focusEvidencePatch rankings, confidence, and explanation data
scoringSourceEmbedded preview or RAW demosaic

The breakdown is useful when a score looks wrong because it tells you whether the engine trusted subject detail, AF-local detail, global detail, or a fallback.

Normalization For UI

Raw scores can vary by catalog and subject. SharpnessScoringModel keeps maxScore as an O(1) normalization denominator for UI badges:

if score count < 2: use the only score or 1.0
if score count < 10: use raw max score
if score count >= 10: use 90th percentile

The 90th percentile avoids one extreme outlier compressing every other image badge.

Calibration

calibrateFromBurst(_:) samples a burst/catalog and calls FocusMaskModel.calibrateAndApplyFromBurstParallel(...). The calibration updates the active focus config from measured detail distribution. It needs enough scoreable images; otherwise it logs a warning and leaves the current config in place.

This is used before burst scoring when sharpness data is missing.

Persistence

Sharpness data is persisted through savedfiles.json:

  • raw sharpness score,
  • saliency subject label,
  • scoring signature,
  • source file size,
  • source modification date.

On catalog load, RawCull restores persisted scores only when the file identity and scoring signature still match. This prevents old scores from being reused after changing quality/source/config or after a RAW file changes.

Focus Mask Overlay

The overlay path uses the same engine family but is tuned for visual inspection. ZoomOverlayView can regenerate masks for the current zoom image and stores the resulting breakdown/saliency back into SharpnessScoringModel.

The visual mask can isolate to subject or AF evidence, and it has a “guarantee visible focus evidence” mode for making weak evidence inspectable. That visual relaxation should not be confused with the scalar score.

Tests

The package tests cover supporting pure/package behavior:

Test areaLocation
Focus-point normalizationRawCullCore/Tests/RawCullCoreTests/FocusPointParserTests.swift
Histogram calculationRawCullCore/Tests/RawCullCoreTests/HistogramCalculatorTests.swift
ImageIO cancellation bridgeRawParserKit/Tests/RawParserKitTests/CancellableImageIOWorkTests.swift
MakerNote parsingRawParserKit/Tests/RawParserKitTests/SonyMakerNoteParserTests.swift, NikonMakerNoteParserTests.swift

App-target focus scoring itself is still mainly verified through source review/manual behavior in this snapshot.

What To Check When Changing This Area

  • If a scoring option changes score meaning, update SharpnessScoringSignature.
  • If you tune scoring weights, inspect both single-image badges and burst ranking.
  • Keep RAW demosaic concurrency low; it is much heavier than embedded-preview scoring.
  • Treat overlay visibility tweaks separately from scalar scoring tweaks.
  • When a score looks wrong, inspect SharpnessBreakdown before changing thresholds.

6 - Detailed Sharpness Scoring

Detailed Sharpness Scoring

This page explains, step by step, where RawCull computes sharpness scores, what code is involved, what the score means, and which factors can move the result up or down.

The short version: RawCull does not ask “is the whole image contrasty?”. It asks “how much reliable edge detail exists in the useful part of the image, especially around the subject or the camera AF point?”. The score is then used for sharpness sorting, burst ranking, saved scoring results, and inspection badges.

Source Map

ResponsibilityMain code
User-facing score state, options, progress, cancellationsourcecode/RawCull/RawCull/Model/ViewModels/FocusandSharpness/SharpnessScoringModel.swift
Photo type, quality, source, scoring size optionssourcecode/RawCull/RawCull/Model/ViewModels/FocusandSharpness/SharpnessScoringOptions.swift
Focus detector and scoring parameterssourcecode/RawCull/RawCull/Model/ViewModels/FocusandSharpness/FocusDetectorConfig.swift
Image decode, saliency, Laplacian scoring, final blendsourcecode/RawCull/RawCull/Model/ViewModels/FocusandSharpness/FocusMaskEngine+Scoring.swift
Result diagnosticssourcecode/RawCull/RawCull/Model/ViewModels/FocusandSharpness/FocusMaskTypes.swift
Metal edge-energy kernelsourcecode/RawCull/RawCull/Kernels.ci.metal
UI trigger and controlssourcecode/RawCull/RawCull/Views/GridView/SharpnessControlsView.swift, sourcecode/RawCull/RawCull/Views/GridView/ScoringParametersSheetView.swift
App-level orchestration and persistencesourcecode/RawCull/RawCull/Model/ViewModels/RawCullViewModel+Sharpness.swift, sourcecode/RawCull/RawCull/Model/ViewModels/CullingModel.swift, sourcecode/RawCull/RawCull/Model/JSON/SavedFiles.swift
Sorting visible files by scoresourcecode/RawCull/RawCull/Model/ViewModels/RawCullViewModel+Catalog.swift
Badge labelssourcecode/RawCull/RawCull/Views/ThumbnailComponents/ImageItemView.swift
Saved and burst-cache scoring signaturesourcecode/RawCull/RawCull/Actors/BurstAnalysisCache.swift

End-To-End Flow

flowchart TD
    A["User clicks Score Sharpness"] --> B["RawCullViewModel.calibrateAndScoreCurrentCatalog"]
    B --> C["sharpnessScoringTargetFiles"]
    C --> D["SharpnessScoringModel.calibrateFromBurst"]
    D --> E["SharpnessScoringModel.scoreFiles"]
    E --> F["Bounded task group"]
    F --> G["Per-file FocusDetectorConfig"]
    G --> H["FocusMaskEngine.computeSharpnessScore"]
    H --> I["decodeScoringImage"]
    I --> J["Vision saliency and optional classification"]
    J --> K["buildScoringLaplacian"]
    K --> L["Crop Laplacian to the source image extent"]
    L --> M["full, saliency, AF, and patch scores"]
    M --> N["Blend + penalties + blur gate"]
    N --> O["SharpnessBreakdown"]
    O --> P["scores, saliencyInfo, breakdowns"]
    P --> Q["persistScoringResultsInMemory"]
    P --> R["sortBySharpness"]

The main call chain is:

SharpnessControlsView
  -> RawCullViewModel.calibrateAndScoreCurrentCatalog()
  -> RawCullViewModel.calibrateAndScoreFiles(_:)
  -> SharpnessScoringModel.calibrateFromBurst(_:)
  -> SharpnessScoringModel.scoreFiles(_:)
  -> FocusMaskEngine.computeSharpnessScore(...)
  -> FocusMaskEngine.computeSharpnessBreakdown(...)
  -> FocusMaskEngine.computeSharpnessAnalysis(...)

Step 1: The User Starts Scoring

The visible button lives in SharpnessControlsView.swift.

When the user presses Score Sharpness or Re-score, the button starts:

Task { await viewModel.calibrateAndScoreCurrentCatalog() }

The target set is computed by RawCullViewModel.sharpnessScoringTargetFiles:

sharpnessScoringTargetFiles = burstAnalysisOrderedFiles()

That means scoring follows the currently relevant catalog scope. Depending on selection and filtering, the UI description can be:

SituationDescription
Some files selected“N selected files”
Star filter active“N 3-star files”, “N 2-star files”, etc.
No narrower scope“N catalog files”

Example:

If a catalog contains 2,000 RAW files but the user has selected 45 files, RawCull scores those 45 selected files. If no files are selected but a 3-star filter is active, it scores the visible 3-star set. If neither is true, it scores the catalog files.

Step 2: RawCull Calibrates Before Scoring

RawCullViewModel+Sharpness.swift runs calibration before scoring:

await sharpnessModel.calibrateFromBurst(files)
await sharpnessModel.scoreFiles(files)

Calibration calls:

FocusMaskModel.calibrateAndApplyFromBurstParallel(...)

The purpose is to tune the active focus-mask threshold from the current file set. Calibration is mostly about the visual focus mask threshold, but it uses the same image/detail family as scoring. If there are too few scoreable files, RawCull logs a warning and keeps the existing config.

Important detail: calibration does not itself store final per-file sharpness scores. The score table is filled by scoreFiles(_:).

Step 3: SharpnessScoringModel Owns The Score Run

SharpnessScoringModel is @Observable @MainActor. It owns the UI-visible state:

PropertyMeaning
scores: [UUID: Float]Final raw sharpness score per FileItem.id
saliencyInfo: [UUID: SaliencyInfo]Optional Vision subject label and confidence per file
breakdowns: [UUID: SharpnessBreakdown]Debug information explaining why the score became what it became
isScoringDrives disabled controls, progress overlays, and cancel UI
sortBySharpnessWhen true, visible files are sorted sharpest first
photoTypePreset such as Auto, Birds/Wildlife, Portrait, Landscape, Action
scoringQualityFast, Balanced, or High Precision
scoringSourceEmbedded Preview or RAW Demosaic
thumbnailMaxPixelSizeRequested maximum scoring image size
maxScoreUI normalization denominator for badges

At the beginning of a run, scoreFiles(_:) resets:

scores = [:]
saliencyInfo = [:]
breakdowns = [:]
scoringProgress = 0
scoringTotal = files.count

The model stores results locally while the task group is running. It only assigns the dictionaries back to observable state at the end of a clean, non-cancelled run. This avoids partially completed score tables being treated as a successful catalog score.

Step 4: Work Is Parallel, But Bounded

scoreFiles(_:) creates a bounded task group. It does not launch one task for every file in a large catalog. Instead it keeps a fixed number of active tasks and adds another file each time one completes.

The maximum concurrency depends on the selected source and quality:

Source or qualityConcurrency behavior
Embedded Preview, FastUp to 6 scoring tasks
Embedded Preview, BalancedUp to 4 scoring tasks
Embedded Preview, High PrecisionUp to 3 scoring tasks
RAW DemosaicCapped to 2 tasks, even if quality would allow more

This cap matters because RAW demosaic uses CIRAWFilter and is much heavier than scoring camera previews.

Example:

If 300 files are scored in Fast Embedded Preview mode, RawCull starts 6 tasks. When one finishes, task 7 starts. The UI progress updates after every completed file. If the same 300 files are scored from RAW Demosaic, only 2 are active at a time to reduce memory and CPU pressure.

Step 5: Per-File Config Is Built From Global Options And EXIF

Before each file is sent to the engine, SharpnessScoringModel creates a per-file FocusDetectorConfig:

fileConfig = effectiveFocusConfig
fileConfig.iso = file.exifData?.isoValue ?? 400
fileConfig.apertureHint = FocusDetectorConfig.ApertureHint.from(aperture: file.exifData?.apertureValue)
afPoint = file.afFocusNormalized

The base config comes from:

effectiveFocusConfig =
    scoringQuality.applying(
        to: photoType.applying(
            to: focusMaskModel.config
        )
    )

So the final config is a blend of:

  1. the current focus-mask model config,
  2. photo-type preset,
  3. scoring quality preset,
  4. per-file ISO,
  5. per-file aperture hint,
  6. per-file AF point, when parsed during scan.

Photo Type Impact

SharpnessPhotoType changes how strongly RawCull trusts subject detail versus full-frame detail.

Photo typeImportant changesPractical result
AutoLeaves current config mostly as-isGeneral behavior
Birds/WildlifeHigh subject weight, smaller AF region, stronger silhouette penaltyA sharp bird matters more than a sharp background
PortraitHigh subject weight, larger AF region, lower silhouette penaltyFace/subject sharpness matters most
LandscapeLower subject weight, no subject isolation, smaller pre-blurWhole-frame detail matters more
ActionMedium-high subject weight, larger AF regionMoving subject detail matters, but less narrowly than birds

Example:

For a bird against a detailed forest, Birds/Wildlife uses a high subject weight so the bird can outrank a frame where the background is crisp but the bird is soft. Landscape does almost the opposite: a single Vision salient object should not dominate a mountain scene where detail across the frame is meaningful.

Quality Impact

SharpnessScoringQuality changes both speed and detail sensitivity.

QualityMinimum sizeMax concurrent tasksFine-detail pass
Fast512 px6Disabled
Balanced768 px4At least 0.25
High Precision1024 px3At least 0.45

Balanced and High Precision blend a second, finer Laplacian pass into the score. This helps small details survive the noise-reduction pre-blur.

Example:

A small bird eye may occupy only a few pixels in a 512 px preview. Fast mode may mostly score wing/body edges. High Precision at 2048 px with a fine-detail blend is more likely to preserve eye and feather detail, but it takes longer.

Source Impact

SharpnessScoringSource chooses which image is measured.

SourceCode pathUse
embeddedPreviewSony embedded JPEG fallback, then ImageIO thumbnailFast culling and normal catalog scoring
rawDemosaicCIRAWFilter output scaled downSlower final checks when preview scoring may not be enough

Embedded previews are usually what the photographer sees quickly. RAW demosaic is closer to measuring the RAW file itself, but it costs more and may produce slightly different contrast/detail characteristics.

Step 6: The Scoring Image Is Decoded

The engine entry point is:

FocusMaskEngine.computeSharpnessScore(
    fromRawURL: url,
    config: fileConfig,
    thumbnailMaxPixelSize: thumbSize,
    afPoint: afPoint,
    scoringSource: scoringSource
)

The first engine step is decodeScoringImage(...).

For Embedded Preview, RawCull tries:

  1. extractSonyEmbeddedPreview(...)
  2. decodeThumbnail(...)

The Sony-specific fallback reads embedded JPEG byte ranges through SonyMakerNoteParser for newer ARW files where ImageIO cannot create a thumbnail.

For RAW Demosaic, RawCull uses:

CIRAWFilter(imageURL: url)

with scoring-oriented settings:

SettingValueReason
sharpnessAmount0.0Avoid measuring artificial RAW-sharpening
detailAmount0.6Preserve detail during demosaic
contrastAmount1.0Neutral contrast
exposure0.0Do not shift exposure for scoring

After decode, every image is normalized through an 8-bit sRGB RGBA CGContext. This keeps the Metal pipeline predictable regardless of the source image color space or bit depth.

Step 7: Vision Finds The Subject

detectSaliencyAndClassify(for:classify:) runs Vision:

VNGenerateAttentionBasedSaliencyImageRequest
VNClassifyImageRequest, when enabled

The saliency pass returns candidate bounding boxes for visually important regions. RawCull filters candidates:

area > 0.03 OR confidence >= 0.9

This means a saliency region is kept if it covers at least 3 percent of the image, or if Vision is very confident even though the region is small.

When classification is enabled, RawCull also stores a useful subject label, such as a wildlife or portrait-related label, in SaliencyInfo. The label can later be persisted with the score.

How The Winning Saliency Region Is Chosen

If Vision finds multiple candidates, selectSaliencyCandidate(...) ranks them by:

  1. whether the candidate contains the AF point,
  2. distance to the AF point,
  3. Vision confidence,
  4. measured interior detail,
  5. candidate area,
  6. deterministic coordinate tie-breaker.

Example:

Vision finds two subjects:

CandidateContains AF pointConfidenceDetailResult
Bird in foregroundYes0.720.31Wins
Branch in backgroundNo0.910.45Loses

Even though the branch has higher Vision confidence and more detail, the camera AF point tells RawCull the intended subject was probably the bird.

Step 8: Metal Converts The Image To Edge Energy

The sharpness score is based on Laplacian edge energy. The Metal kernel is focusLaplacian in Kernels.ci.metal.

For every pixel, the kernel samples a 3 by 3 neighborhood:

laplace = 8 * center - sum(8 neighbors)
energy = luminance(abs(laplace.rgb))

Flat areas produce values near zero. Strong second-derivative edges produce larger values. The RGB edge response is collapsed into a single scalar using Rec. 601 luminance weights:

0.299 red + 0.587 green + 0.114 blue

Before the Metal kernel runs, buildAmplifiedLaplacian(...) applies Gaussian pre-blur:

effectiveRadius =
    preBlurRadius
    * isoScalingFactor(iso)
    * resolutionScalingFactor(imageSize)
    * apertureHint.blurDamp

The radius is capped at 100 px.

Extent Normalization After Blur

Core Image’s Gaussian blur expands the filter output beyond the input bounds. After the primary and optional fine-detail Laplacian passes are built, computeSharpnessAnalysis(...) now crops the result back to the decoded image extent:

rawBoosted = buildScoringLaplacian(...)
boosted = rawBoosted.cropped(to: inputImage.extent)

This is required before calculating bitmap width and height, border insets, and normalized AF/saliency rectangles. Without the crop, those coordinates would be interpreted against blur padding rather than the real photo and could sample shifted or incorrectly scaled regions.

Why Pre-Blur Exists

Without pre-blur, the Laplacian would fire on noise, hot pixels, sensor pattern noise, and JPEG artifacts. Pre-blur makes the score care more about meaningful edges than random high-frequency noise.

ISO Impact

isoScalingFactor(iso:) increases pre-blur as ISO rises:

ISO rangeFactor
Below 8001.0
800 to 3200Ramps from 1.0 to 1.6
Above 3200Ramps toward 2.2, capped

Example:

With preBlurRadius = 2.2 and a 1024 px image:

resolution factor = sqrt(1024 / 512) = 1.414

ISO 400:
effective radius = 2.2 * 1.0 * 1.414 = 3.11

ISO 3200:
effective radius = 2.2 * 1.6 * 1.414 = 4.98

The ISO 3200 image is blurred more before edge detection, so random noise is less likely to inflate the score.

Resolution Impact

resolutionScalingFactor(for:) is:

sqrt(max(longestSide, 512) / 512)

clamped from 1.0 to 3.0.

Example:

Longest sideResolution factor
512 px1.00
1024 px1.41
2048 px2.00
4608 px3.00 cap

Larger scoring images need a larger pre-blur radius so “detail scale” remains comparable across sizes.

Aperture Impact

FocusDetectorConfig.ApertureHint is derived from EXIF aperture:

ApertureHint
f/5.6 or wider.wide
Between f/5.6 and f/8.mid
f/8 or narrower.landscape

The aperture hint changes:

HintBlur-gate low/highBlur dampSubject weight override
Wide0.010 / 0.0251.0none
Mid0.008 / 0.0221.0none
Landscape0.006 / 0.0180.80.55

Landscape aperture reduces the blur radius and reduces subject dominance. This protects deep-depth-of-field scenes where sharpness across the frame matters.

Step 9: RawCull Collects Sample Sets

computeSharpnessAnalysis(...) renders the source-extent-cropped Laplacian output to an RGBAf bitmap and reads the red channel as a Float edge-energy sample. The bitmap dimensions therefore match the decoded scoring image.

It collects these sample sets:

Sample setWhat it contains
fullAll finite edge-energy pixels inside the border inset
salientPixels inside the winning Vision saliency rectangle
AFPixels in a square centered on the camera AF point
AF centerSmaller square around the AF point
AF neighborhoodLarger neighborhood around the AF point
local patchesBest detail patches inside AF neighborhood or saliency region

The full-frame sample excludes the border:

borderCols = width * borderInsetFraction
borderRows = height * borderInsetFraction

This avoids Gaussian edge artifacts near image boundaries.

Example:

For a 1024 by 683 scoring image with borderInsetFraction = 0.05:

borderCols = 1024 * 0.05 = 51 px
borderRows = 683 * 0.05 = 34 px

The full-frame score ignores the outer 51 columns on the left/right and 34 rows on the top/bottom.

Step 10: Each Sample Set Becomes A Robust Tail Score

RawCull does not average all edge pixels. A normal average would be dominated by large smooth areas, and a simple maximum would be dominated by one artifact.

Instead, robustTailScore(_:):

  1. sorts the sample values,
  2. finds p20, p90, and p97,
  3. takes values between p90 and p97,
  4. subtracts the p20 noise floor,
  5. averages that upper-tail band,
  6. applies a density factor if too few pixels are in the band.

In formula form:

tail samples = values where p90 <= value <= p97
band mean = average(max(0, value - p20))
density factor = min(1, (tailCount / totalCount) / 0.06)
robust tail score = band mean * density factor

The 6 percent density target penalizes sparse edge hits. A frame with only a few isolated hard edges can look deceptively sharp if measured by maximum value alone.

Example: Real Detail Versus Sparse Edge

Image A has many fine feathers:

p20 = 0.03
p90 = 0.28
p97 = 0.42
tailCount / totalCount = 0.06
bandMean after p20 subtraction = 0.32
densityFactor = 1.0
score = 0.32

Image B has one high-contrast branch but little subject texture:

p20 = 0.03
p90 = 0.26
p97 = 0.40
tailCount / totalCount = 0.015
bandMean after p20 subtraction = 0.31
densityFactor = 0.015 / 0.06 = 0.25
score = 0.31 * 0.25 = 0.0775

Both images have strong edge values, but Image B has too few useful edge pixels, so the robust score is much lower.

Step 11: AF, Saliency, And Local Patches Form A Subject Score

RawCull computes:

fullScore
salientScore
afScore
afCenterScore
afNeighborhoodScore
afLocalPatchScore
subjectInteriorPatchScore

First it builds a broad subject score:

if AF and saliency exist:
    broadSubjectScore = AF * 0.6 + saliency * 0.4
else if AF exists:
    broadSubjectScore = AF
else if saliency exists:
    broadSubjectScore = saliency

Then it blends in local patch detail:

if AF local patch and subject interior patch exist:
    localDetailScore = AFLocalPatch * 0.6 + subjectInteriorPatch * 0.4
else if only one exists:
    localDetailScore = that one

if broad subject and local detail exist:
    effectiveSubjectScore = broadSubjectScore * 0.75 + localDetailScore * 0.25
else:
    effectiveSubjectScore = whichever exists

This is intentionally conservative. A broad region can contain too much background. A tiny patch can overreact to one crisp edge. Blending them gives local detail a say without letting it fully override the broad subject region.

Example: AF And Saliency Agree

afScore = 0.36
salientScore = 0.32
afLocalPatchScore = 0.42
subjectInteriorPatchScore = 0.34

broadSubjectScore = 0.36 * 0.6 + 0.32 * 0.4
                  = 0.344

localDetailScore = 0.42 * 0.6 + 0.34 * 0.4
                 = 0.388

effectiveSubjectScore = 0.344 * 0.75 + 0.388 * 0.25
                      = 0.355

The local patch improves the subject score slightly because it found crisp detail near the intended focus area.

Example: AF Point On The Wrong Object

afScore = 0.12
salientScore = 0.34

broadSubjectScore = 0.12 * 0.6 + 0.34 * 0.4
                  = 0.208

The score does not blindly use saliency and ignore AF, or blindly use AF and ignore saliency. It records weaker subject evidence because the two signals disagree.

Step 12: Full-Frame And Subject Scores Are Blended

The main blend is:

base = fullScore * (1 - salientWeight)
     + effectiveSubjectScore * salientWeight

salientWeight comes from:

config.explicitSalientWeightOverride
    ?? apertureHint.salientWeightOverride
    ?? config.salientWeight

Photo type presets often set explicitSalientWeightOverride, so they usually win over the aperture hint. Landscape photo type uses a much lower subject weight than Birds/Wildlife.

Example: Birds/Wildlife

fullScore = 0.22
effectiveSubjectScore = 0.36
salientWeight = 0.85

base = 0.22 * 0.15 + 0.36 * 0.85
     = 0.033 + 0.306
     = 0.339

The final score is close to the subject score because the bird/wildlife preset cares strongly about the subject.

Example: Landscape

fullScore = 0.30
effectiveSubjectScore = 0.22
salientWeight = 0.35

base = 0.30 * 0.65 + 0.22 * 0.35
     = 0.195 + 0.077
     = 0.272

The full frame carries most of the result because the scene is expected to have broad depth of field.

If There Is No Subject

If RawCull has a full-frame score but no saliency or AF subject score, it applies a strong penalty:

p = 1 - salientWeight
base = fullScore * p * p * p

Example in Birds/Wildlife mode:

fullScore = 0.30
salientWeight = 0.85
p = 0.15

base = 0.30 * 0.15 * 0.15 * 0.15
     = 0.0010125

That looks harsh, but it is intentional for wildlife-first culling: a sharp background without a detected subject is usually not a strong keeper candidate.

In Landscape mode:

fullScore = 0.30
salientWeight = 0.35
p = 0.65

base = 0.30 * 0.65 * 0.65 * 0.65
     = 0.082

The penalty is much softer because whole-frame detail is meaningful for landscapes.

Step 13: Silhouette Penalty Reduces Rim-Only Sharpness

For each subject region, RawCull tracks how much edge energy lives in the outer 12 percent rim of the region versus the interior.

This matters for backlit birds, aircraft, or portraits where only the outline is sharp. A silhouette rim can produce a high edge score even if the face, eye, feathers, or body detail is soft.

When more than 62 percent of subject edge energy is in the rim:

over = (borderFraction - 0.62) / (1.0 - 0.62)
base *= 1.0 - silhouettePenaltyStrength * over

Example:

base = 0.34
borderFraction = 0.80
silhouettePenaltyStrength = 0.55

over = (0.80 - 0.62) / 0.38 = 0.474
multiplier = 1.0 - 0.55 * 0.474 = 0.739
base after penalty = 0.34 * 0.739 = 0.251

The photo still receives credit for real outline detail, but it is no longer ranked as if the subject interior were crisp.

Step 14: Subject Size Can Add A Bonus

If there is no AF region and the score depends on Vision saliency, RawCull can apply a subject-size bonus:

base *= 1.0 + area * subjectSizeFactor

This does not apply when AF is available, because AF already gives a high-confidence subject location and the bonus would double-count subject evidence.

Example:

base = 0.28
saliency area = 0.20
subjectSizeFactor = 0.10

bonus multiplier = 1.0 + 0.20 * 0.10 = 1.02
base after bonus = 0.2856

The default wildlife-style bonus is small. It nudges closer/larger subjects upward without overpowering real sharpness.

Step 15: Blur Gate Applies The Final Attenuation

The blur gate uses subject micro-contrast:

subjectMicro = standard deviation of subject-region Laplacian samples

If subject micro-contrast is below the aperture-specific low threshold, the final score is multiplied by 0.20. If it is above the high threshold, no attenuation is applied. Between low and high, it ramps linearly:

t = clamp((subjectMicro - blurGateLow) / (blurGateHigh - blurGateLow), 0, 1)
blurAttenuation = 0.20 + t * 0.80
finalScore = base * blurAttenuation

Example for .mid aperture:

blurGateLow = 0.008
blurGateHigh = 0.022
base = 0.34
subjectMicro = 0.015

t = (0.015 - 0.008) / (0.022 - 0.008)
  = 0.5

blurAttenuation = 0.20 + 0.5 * 0.80
                = 0.60

finalScore = 0.34 * 0.60
           = 0.204

This prevents low-texture or genuinely soft subjects from ranking too high, while avoiding an all-or-nothing cliff.

Step 16: Focus Failure Is Classified

classifyFocusFailure(...) stores a diagnostic label in SharpnessBreakdown.

It can return:

Failure kindCondition
.motionBlurGlobal, subject, and AF/subject scores are all below 0.08, and micro-contrast is below 0.012
.missedFocusGlobal score is at least 0.12, but subject score is less than 55 percent of global score
.noneNeither condition matched

Example: motion blur

globalScore = 0.05
subjectScore = 0.04
afPointScore = 0.03
blurGateSigma = 0.009

result = motionBlur

Everything is weak, including micro-contrast.

Example: missed focus

globalScore = 0.26
subjectScore = 0.10
subject / global = 0.385

result = missedFocus

The image has sharp detail somewhere, but not on the subject.

Step 17: SharpnessBreakdown Explains The Result

The final result is not only a number. computeSharpnessBreakdown(...) returns:

FieldMeaning
finalScoreThe score used for sorting and ranking
globalScoreRobust full-frame detail score
subjectScoreWinning saliency-region detail score
afPointScoreAF-region detail score
blurGateSigmaSubject micro-contrast used by the blur gate
subjectLabelOptional Vision classification label
subjectConfidenceVision confidence
focusFailureKindNone, motion blur, or missed focus
focusEvidence.winningRegionWhether strongest evidence came from AF center, AF neighborhood, AF point, saliency, global, mixed, or none
focusEvidence.scoringAFLocalPatchScoreBest local AF patch score used for scoring
focusEvidence.scoringSubjectInteriorPatchScoreBest local saliency patch score used for scoring
focusEvidence.scoringLocalDetailScoreLocal patch score after AF/saliency blend
focusEvidence.saliencySelectionReasonWhy the winning saliency region was selected
scoringSourceEmbedded Preview or RAW Demosaic

When a score looks surprising, inspect the breakdown before changing thresholds. It usually tells whether the problem is subject detection, AF disagreement, silhouette dominance, blur attenuation, or source image quality.

Step 18: Scores Are Stored And Sorting Turns On

At the end of a successful score run, SharpnessScoringModel assigns:

self.scores = localScores
self.saliencyInfo = localSaliency
self.breakdowns = localBreakdowns
self.sortBySharpness = true

Then RawCullViewModel.calibrateAndScoreFiles(_:) persists scoring results:

persistScoringResultsInMemory(files: files)
await handleSortOrderChange()

Sorting happens in RawCullViewModel+Catalog.swift:

if sharpnessModel.sortBySharpness, !sharpnessModel.scores.isEmpty {
    result.sort { (scores[$0.id] ?? -1) > (scores[$1.id] ?? -1) }
}

Files with higher scores appear first. Files without a score sort below scored files because they use -1 as the fallback.

Step 19: Scores Are Normalized For Badges

The raw score is a Float, often in a small range such as 0.03 to 0.50. UI badges need a relative label, so SharpnessScoringModel maintains maxScore.

recomputeMaxScore() uses:

Score countDenominator
0 or 1The only score, or 1.0
2 to 9Raw maximum
10 or more90th percentile

The 90th percentile prevents one extreme outlier from making every other file look soft.

SharpnessLabel then uses:

normalized = score / maxScore

0.85...1.00 = sharp
0.65...0.85 = good
0.35...0.65 = check
0.00...0.35 = soft

Example:

maxScore = 0.40
score = 0.34
normalized = 0.85
label = sharp

Another file:

maxScore = 0.40
score = 0.20
normalized = 0.50
label = check

Important: badge labels are relative to the current scored set. The raw finalScore is what is stored and sorted.

Step 20: Scores Are Persisted With A Signature

After scoring, RawCull merges results into savedfiles.json through CullingModel.mergeScoringResults(...).

The persisted fields are:

FieldPurpose
sharpnessScoreRaw final score
saliencySubjectOptional subject label
sharpnessScoringSignatureValidity signature for scoring settings and algorithm
sharpnessFileSizeFile identity check
sharpnessModificationDateFile identity check

On catalog load, loadPersistedScoringandSaliency() only restores a score when:

  1. the file name matches,
  2. the file size matches,
  3. the file modification date matches,
  4. the saved SharpnessScoringSignature equals the current signature.

The signature includes:

algorithmVersion
isoScalingPolicyVersion
apertureHintPolicyVersion
scoringPhotoType
scoringQuality
scoringSource
thumbnailMaxPixelSize
preBlurRadius
borderInsetFraction
salientWeight
explicitSalientWeightOverride
subjectSizeFactor
silhouettePenaltyStrength
afRegionRadius
fineDetailBlendWeight
stableScoringEnergyMultiplier

This protects against stale scores after changing scoring logic, quality/source, image size, or important config values.

Current Signature Compatibility Finding

SharpnessScoringSignature.currentAlgorithmVersion is currently 3. The July 2026 source update corrected score geometry by cropping the blur-expanded Laplacian back to the source extent, but it did not change that version. A score or burst cache written by an earlier version-3 build can therefore still pass signature validation even though it was computed with the old padded geometry.

Before distributing the corrected scoring behavior, increment currentAlgorithmVersion so persisted file scores and burst-analysis snapshots are recomputed.

Worked Examples

These examples use simplified numbers, but they match the structure of the code.

Example 1: Sharp Bird, Soft Background

Settings:

photoType = Birds/Wildlife
salientWeight = 0.85
subjectSizeFactor = 0.05
silhouettePenaltyStrength = 0.55

Measurements:

fullScore = 0.18
afScore = 0.38
salientScore = 0.34
afLocalPatchScore = 0.44
subjectInteriorPatchScore = 0.36
subjectMicro = 0.026
blur gate = 1.0
borderFraction = 0.45

Calculation:

broadSubject = 0.38 * 0.6 + 0.34 * 0.4 = 0.364
localDetail = 0.44 * 0.6 + 0.36 * 0.4 = 0.408
effectiveSubject = 0.364 * 0.75 + 0.408 * 0.25 = 0.375

base = 0.18 * 0.15 + 0.375 * 0.85 = 0.346
silhouette penalty = none
finalScore = 0.346

Result:

The file sorts high because the subject and AF region are sharp, even though the whole frame is not.

Example 2: Sharp Background, Soft Bird

Settings:

photoType = Birds/Wildlife
salientWeight = 0.85

Measurements:

fullScore = 0.32
afScore = 0.09
salientScore = 0.11
subjectMicro = 0.011
blur gate for mid aperture = about 0.37

Calculation:

broadSubject = 0.09 * 0.6 + 0.11 * 0.4 = 0.098
base = 0.32 * 0.15 + 0.098 * 0.85 = 0.131
finalScore = 0.131 * 0.37 = 0.048

Failure classification:

globalScore = 0.32
subjectScore = 0.11
subject/global = 0.34
result = missedFocus

Result:

This file should not rank high for wildlife culling. The image has detail, but the intended subject is soft.

Example 3: Backlit Bird Silhouette

Measurements:

fullScore = 0.22
effectiveSubjectScore = 0.36
salientWeight = 0.85
borderFraction = 0.82
silhouettePenaltyStrength = 0.55
blur gate = 1.0

Calculation:

base before penalty = 0.22 * 0.15 + 0.36 * 0.85 = 0.339

over = (0.82 - 0.62) / 0.38 = 0.526
multiplier = 1.0 - 0.55 * 0.526 = 0.711

finalScore = 0.339 * 0.711 = 0.241

Result:

The strong outline still counts, but RawCull avoids ranking the file as if internal subject detail were sharp.

Example 4: Landscape With Broad Detail

Settings:

photoType = Landscape
salientWeight = 0.35
apertureHint = landscape
blurDamp = 0.8

Measurements:

fullScore = 0.31
salientScore = 0.24
no AF point
subjectMicro = 0.020
blur gate = 1.0

Calculation:

base = 0.31 * 0.65 + 0.24 * 0.35 = 0.286
finalScore = 0.286

Result:

The file can score well because landscape scoring trusts broad full-frame detail.

Example 5: High ISO Noise Does Not Automatically Win

Assume two files have similar raw edge peaks, but one is ISO 6400.

At ISO 6400:

isoScalingFactor = about 1.9

The pre-blur radius becomes larger before Laplacian detection. Random noise is smoothed more aggressively. If true subject detail remains, it will still produce robust edge energy. If the apparent sharpness was mostly noise, the robust tail score drops.

Result:

High ISO can still score well, but only when there is stable edge structure after noise-aware smoothing.

Factors That Increase The Score

FactorWhy it helps
Strong repeated edge detail in the p90-p97 bandRaises robust tail score without relying on one outlier
AF point and saliency agreeProduces stronger subject confidence
Crisp local patch inside AF or subject regionImproves effective subject score
High subject micro-contrastAvoids blur-gate attenuation
Larger real subject region without AFMay receive a small subject-size bonus
Landscape photo type on deep-DoF scenesGives more weight to full-frame detail

Factors That Decrease The Score

FactorWhy it hurts
Soft subject, even with sharp backgroundSubject-weighted modes prioritize the intended subject
No saliency/AF subject in wildlife-like modesFull-frame fallback is heavily penalized
Sparse isolated edgesDensity factor reduces robust tail score
Edge energy only on the silhouette rimSilhouette penalty reduces rim-only scores
Low subject micro-contrastBlur gate attenuates the final score
High ISO noise without stable structureISO-scaled pre-blur suppresses noise-driven edge energy
Old persisted score signatureScore is rejected and must be recomputed

Common Debugging Questions

Why did a visually crisp photo get a low score?

Check SharpnessBreakdown:

SymptomLikely reason
globalScore high, subjectScore lowMissed focus or wrong subject
blurGateSigma lowSubject region lacks micro-contrast
focusEvidence.winningRegion = global in wildlife modeSubject/AF evidence was weak or missing
High borderFractionSilhouette penalty may have applied
No saliency infoVision may not have found a subject

Why did changing quality change scores?

Balanced and High Precision can use larger images and a fine-detail Laplacian blend. They may rank small eyes, feather detail, eyelashes, or texture differently than Fast mode. This is expected, and the scoring signature prevents old Fast scores from being reused as High Precision scores.

Why are badge labels relative?

The raw score range depends on source image size, source type, ISO, subject matter, and config. Badge labels use score / maxScore, where maxScore is usually the 90th percentile for scored sets of 10 or more images. This gives useful in-catalog labels without letting one outlier flatten the scale.

Why can RAW Demosaic differ from Embedded Preview?

Embedded Preview scores the camera-generated JPEG preview or ImageIO thumbnail. RAW Demosaic scores a CIRAWFilter rendering with RawCull’s scoring settings. They can differ because camera JPEG processing, sharpening, noise reduction, tone curves, and embedded preview size are not identical to RawCull’s demosaic path.

Changing This Code Safely

When changing sharpness scoring:

  1. Increment SharpnessScoringSignature.currentAlgorithmVersion when score meaning or sampling geometry changes; the current blur-extent crop still needs that invalidation bump.
  2. Check SharpnessBreakdown fields before tuning thresholds.
  3. Test wildlife, portrait, landscape, and high-ISO examples separately.
  4. Keep RAW demosaic concurrency low.
  5. Treat overlay visibility changes separately from numeric scoring changes.
  6. Re-check burst ranking, because burst winner selection uses sharpness as one of its strongest signals.

The most important rule: do not tune from the final number alone. The breakdown tells which part of the pipeline produced the result.

7 - Detailed Focus Mask Computation

Detailed Focus Mask Computation

This page explains, step by step, how RawCull computes the visible focus mask: where the work starts in the UI, which model and engine methods run, how the bitmap overlay is produced, what the intermediate values mean, and which factors can change the final result.

The short version: the focus mask is not a camera focus-point display. It is a computed overlay built from local edge energy. RawCull first finds useful regions to inspect, then computes a Laplacian edge-energy image, ranks local patches, thresholds the strongest evidence, colorizes it, clips it to the selected patch areas, and finally draws the result over the photo.

The red focus-point marker and the focus mask are related but separate:

  • the focus-point marker shows where the camera says autofocus was attempted,
  • the focus mask shows where RawCull found likely in-focus edge detail,
  • when an autofocus point exists, the mask can use it to choose and rank evidence.

Source Map

ResponsibilityMain code
Focus-mask state and engine wrappersourcecode/RawCull/Model/ViewModels/FocusandSharpness/FocusMaskModel.swift
Immutable compute engine and cancellable worker helpersourcecode/RawCull/Model/ViewModels/FocusandSharpness/FocusMaskEngine.swift
Focus-mask bitmap generationsourcecode/RawCull/Model/ViewModels/FocusandSharpness/FocusMaskEngine+MaskGeneration.swift
Shared Laplacian edge-energy buildersourcecode/RawCull/Model/ViewModels/FocusandSharpness/FocusMaskEngine+Scoring.swift
Config defaults and tunable factorssourcecode/RawCull/Model/ViewModels/FocusandSharpness/FocusDetectorConfig.swift
Diagnostics and patch evidence typessourcecode/RawCull/Model/ViewModels/FocusandSharpness/FocusMaskTypes.swift
Metal edge-energy kernelsourcecode/RawCull/Kernels.ci.metal
Zoom overlay trigger and displaysourcecode/RawCull/Views/ZoomViews/ZoomOverlayView.swift
Main thumbnail trigger and displaysourcecode/RawCull/Views/ThumbnailComponents/MainThumbnailImageView.swift
Comparison-grid trigger and displaysourcecode/RawCull/Views/ComparisonGridView/ComparisonGridImageCoordinator.swift, ComparisonImagePaneView.swift
Focus-mask buttonsourcecode/RawCull/Views/FocusPeek/FocusMaskControlsView.swift
Settings sliderssourcecode/RawCull/Views/Settings/FocusSettingsTab.swift
AF marker overlaysourcecode/RawCull/Views/FocusPoints/FocusOverlayView.swift

End-To-End Flow

flowchart TD
    A["User opens image or toggles focus mask"] --> B{"View context"}
    B -->|"Zoom"| C["ZoomOverlayView.regenerateMaskFromCG"]
    B -->|"Main thumbnail"| D["MainThumbnailImageView.regenerateMask"]
    B -->|"Comparison grid"| E["ComparisonGridImageCoordinator.focusResult"]
    C --> F["Build per-file FocusDetectorConfig"]
    D --> F
    E --> F
    F --> G["FocusMaskModel.generateFocusMask or generateFocusMaskWithBreakdown"]
    G --> H["FocusMaskEngine.generateFocusMask..."]
    H --> I["Detached cancellable worker"]
    I --> J["Vision saliency and optional sharpness breakdown"]
    J --> K["buildFocusMask"]
    K --> L["Scale image and build amplified Laplacian"]
    L --> M["Select saliency, AF, mixed, or global region"]
    M --> N["Rank local patches"]
    N --> O["Choose up to 3 evidence patches"]
    O --> P["Adaptive visual threshold"]
    P --> Q["Color threshold + morphology + feather"]
    Q --> R["Clip mask to selected patches"]
    R --> S["Return CGImage overlay and diagnostics"]

Step 1: The User Asks For A Mask

The focus mask can be generated from three user-facing places.

PlaceTriggerWhat image is used
Zoom overlayOpening/changing the zoom image, changing focus config, or toggling the mask buttonviewModel.zoomOverlayCGImage, downscaled to width 1024 when larger
Main thumbnail/detail imageToggling the focus-mask button or changing source/configThe currently displayed NSImage
Comparison gridLoading comparison images or regenerating masksEach comparison CGImage, downscaled to width 1024 when larger

In ZoomOverlayView, mask generation is kicked off by .task(id: viewModel.zoomOverlayCGImage?.hashValue) and by changes to viewModel.sharpnessModel.effectiveFocusConfig. The concrete method is:

ZoomOverlayView.regenerateMaskFromCG()
  -> FocusMaskModel.generateFocusMaskWithBreakdown(...)

In MainThumbnailImageView, the flow is:

FocusMaskControlsView toggles showFocusMask
  -> MainThumbnailImageView.generateFocusMaskIfNeeded()
  -> MainThumbnailImageView.regenerateMask()
  -> FocusMaskModel.generateFocusMask(...)

In the comparison grid, the flow is:

ComparisonGridImageCoordinator.loadState(...)
  -> populateFocusMask(...)
  -> focusResult(...)
  -> FocusMaskModel.generateFocusMaskWithBreakdown(...)

Example:

If the user opens a 6000 x 4000 RAW preview in the zoom overlay, ZoomOverlayView downsizes the CGImage to 1024 pixels wide before computing the mask. The displayed image can still be larger in the UI, but the mask computation runs on the smaller inspection image so the overlay is fast and memory-friendly.

Step 2: RawCull Builds A Per-File Config

Before calling the engine, each view builds a FocusDetectorConfig.

The base config comes from:

viewModel.sharpnessModel.effectiveFocusConfig

Then the view injects per-file metadata:

config.iso = file.exifData?.isoValue ?? 400
config.apertureHint = FocusDetectorConfig.ApertureHint.from(aperture: file.exifData?.apertureValue)
afPoint = file.afFocusNormalized

If the file already has a sharpness score and the UI label says it is sharp, the view can also enable:

config.guaranteeVisibleFocusEvidence = true

That flag is visual-only. It lets the mask relax the threshold if the selected evidence would otherwise be nearly invisible.

Example:

Suppose two files have the same subject and same edge detail:

FileMetadataExpected mask behavior
ISO 400, f/5.6Normal pre-blurFine edges survive more easily
ISO 6400, f/5.6Larger ISO blur factorNoise is suppressed before edge detection, so random grain is less likely to be highlighted

The ISO 6400 image may show fewer tiny red speckles, because buildAmplifiedLaplacian increases the blur radius before the Laplacian kernel runs.

Step 3: FocusMaskModel Bridges UI And Engine

FocusMaskModel is @Observable @MainActor, because it belongs to UI state. It owns:

var config = FocusDetectorConfig()
private nonisolated let engine = FocusMaskEngine()

It exposes two public generation methods:

MethodUsed whenReturns
generateFocusMask(from:scale:configOverride:afPoint:evidence:)Main thumbnail/detail pathAn NSImage? mask
generateFocusMaskWithBreakdown(from:scale:configOverride:afPoint:)Zoom and comparison pathsA CGImage? mask plus SaliencyInfo? and SharpnessBreakdown?

The distinction matters. The simple method can reuse existing FocusEvidence from previous scoring. The breakdown method computes or refreshes diagnostic information while generating the overlay.

Example:

If a file was already sharpness-scored, MainThumbnailImageView passes:

viewModel.sharpnessModel.breakdowns[file.id]?.focusEvidence

That lets the overlay reuse the same winning region or patch evidence that explained the score, so the visual mask and the inspector diagnostics stay aligned.

Step 4: FocusMaskEngine Runs Off The Main Actor

FocusMaskEngine is immutable and @unchecked Sendable. It owns a shared CIContext configured with:

.workingColorSpace: NSNull()
.workingFormat: CIFormat.RGBAf

The engine methods call runCancellableWorker, which creates a detached task. That keeps Core Image, Vision, patch sampling, and Metal-backed filtering away from the main actor.

Cancellation checks appear throughout the pipeline:

  • before saliency detection,
  • before and after Laplacian creation,
  • before patch ranking returns,
  • before the UI stores the final mask.

Example:

If the user holds the right-arrow key in the zoom overlay, the selected image changes repeatedly. Each old maskTask is cancelled. RawCull avoids spending time finishing masks for images that are no longer visible.

Step 5: The Engine Chooses Saliency And Breakdown Data

There are two engine entry points:

FocusMaskEngine.generateFocusMask(...)
FocusMaskEngine.generateFocusMaskWithBreakdown(...)

The simpler path decides a saliency region only when needed:

if evidence has winningSaliencyRect:
    use it
else if config.isolateMaskToSubject:
    run Vision saliency and select a candidate
else:
    use no salient region

The breakdown path always does more diagnostic work:

detectSaliencyAndClassify(...)
computeSharpnessBreakdown(...)
buildFocusMask(...)
store mask diagnostics back into breakdown

This is why zoom and comparison views can later show fields like:

  • mask region,
  • mask threshold,
  • winning region,
  • rendered region,
  • location confidence,
  • selected patch summaries.

Step 6: buildFocusMask Starts With A Scaled CIImage

The private method that actually builds the bitmap is:

FocusMaskEngine.buildFocusMask(...)

Its first real image operation is:

scaledImage = inputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
rawLaplacian = buildAmplifiedLaplacian(from: scaledImage, config: config)

Most callers pass scale: 1.0, because they already choose an appropriate source image size before calling the engine.

The Laplacian image is the raw focus-evidence map. Bright pixels mean strong local second-derivative edge energy. Flat pixels are dark.

Step 7: The Shared Laplacian Pipeline Computes Edge Energy

buildAmplifiedLaplacian lives in FocusMaskEngine+Scoring.swift, because both sharpness scoring and focus-mask rendering use it.

It does three things:

  1. apply Gaussian pre-blur,
  2. run the Metal focusLaplacian kernel,
  3. multiply the edge energy by config.energyMultiplier.

The effective blur radius is:

effectiveRadius = preBlurRadius * isoFactor * resFactor * apertureBlurDamp

Where:

FactorCode sourceImpact
preBlurRadiusFocus settings / presetHigher values ignore more tiny texture and noise
isoFactorisoScalingFactor(iso:)Higher ISO increases blur to suppress noise
resFactorresolutionScalingFactor(for:)Larger images get proportionally more blur
apertureBlurDampconfig.apertureHint.blurDampLandscape apertures damp the blur to preserve whole-frame detail

The Metal kernel in Kernels.ci.metal samples a 3 x 3 neighborhood:

laplace = 8 * center - sum(8 neighbors)
energy = luminance_weighted_absolute_laplace

It writes the same scalar energy to RGB with alpha 1. Downstream Core Image filters can then threshold or colorize the result without converting formats.

Example:

Imagine a black bird eye surrounded by bright feathers:

  • at the dark pupil edge, neighboring pixels change abruptly, so Laplacian energy is high,
  • on a smooth out-of-focus background, neighboring pixels are similar, so energy is near zero,
  • on high-ISO grain, raw energy could be high, but pre-blur reduces isolated noise before the kernel sees it.

Step 8: Border Artifacts Are Removed

If config.borderInsetFraction > 0, buildFocusMask masks out the outer border of the Laplacian:

innerRect = scaledImage.extent.insetBy(
    dx: width * borderInsetFraction,
    dy: height * borderInsetFraction
)
boostedLaplacian = rawLaplacian cropped to innerRect over black background

This prevents Gaussian blur and kernel edge behavior near the image border from creating false highlighted edges.

Example:

A bright sky frame with a dark border or vignette can produce strong edge energy at the frame boundary. The border inset stops that artifact from becoming the “best focus” evidence.

Step 9: Raw Laplacian Debug Mode Can Return Early

When:

config.showRawLaplacian == true

buildFocusMask returns the boosted Laplacian directly instead of a colorized, thresholded, clipped focus mask.

This mode is useful for debugging. It shows the whole edge-energy field rather than the final user-facing mask. It is not the normal overlay users see during culling.

Step 10: RawCull Selects The Search Region

The normal path decides where to look for focus evidence.

If:

config.isolateMaskToSubject == true

RawCull calls:

focusMaskRegionSelection(
    extent: scaledImage.extent,
    salientRegion: salientRegion,
    afPoint: afPoint,
    afRegionRadius: config.afRegionRadius
)

That can produce:

Region sourceMeaning
saliencyVision found a subject region
afPointCamera AF point exists and creates an AF rectangle
saliencyAndAFBoth are available
noneNeither is available

If subject isolation is disabled, the search region becomes the full image extent.

Coordinate note: AF points are stored in normalized image coordinates, but Core Image and Vision-style rectangles use different vertical orientation conventions in this code path. The engine flips the Y coordinate with:

visionY = 1.0 - afPoint.y

Example:

If the camera AF point is at (0.50, 0.35) in normalized display coordinates, the Core Image search center becomes approximately (0.50, 0.65) in the unit rectangle used by the mask selection helper.

Step 11: The Visual Evidence Region Is Chosen

The mask can be asked to visualize a specific kind of evidence. FocusEvidenceRegion can be:

ValueMeaning
afCenterVery tight region around the AF point
afNeighborhoodWider neighborhood around the AF point
afPointAF rectangle from afRegionRadius
saliencyVision subject rectangle
mixedAF and saliency regions together
globalWhole image
noneLet the code pick a fallback

If no explicit evidence region is available, the fallback order is:

AF point if available
else saliency if available
else global

Example:

For a bird photo with a parsed AF point on the eye, the mask usually prefers AF-anchored evidence. For a portrait without an AF point but with a detected salient face/body region, it uses saliency. For a landscape with no subject and no AF point, it falls back to global edges.

Step 12: AF-Anchored Masks Get A Finer Local Laplacian

When AF evidence exists, the mask builds a second local Laplacian with less blur:

fineConfig.preBlurRadius = max(0.35, config.preBlurRadius * 0.52)
fineLaplacian = buildAmplifiedLaplacian(from: scaledImage, config: fineConfig)

This is visual-overlay behavior. It helps small AF-local details survive the normal noise-reduction blur.

Example:

A small bird eye may be only a few pixels wide in the downscaled preview. The primary Laplacian is good at avoiding noisy false positives, but it can soften the tiny eye highlight. The finer AF pass helps the local patch ranking see that compact detail.

Step 13: AF-Weighted Source Can Favor The Center

For AF-local search rectangles, the engine can apply:

centerWeightedLaplacian(...)

This creates a radial gradient centered on the AF point and multiplies it with the Laplacian. Details closer to the AF point keep more weight; details near the edge of the AF rectangle lose weight.

Example:

If the AF rectangle covers both the bird’s eye and a high-contrast branch just beside the head, center weighting helps the eye region win when it is close to the camera’s AF point.

Step 14: Search Regions Become Patch Rankings

For each selected search region, the engine calls:

patchRankings(in:sourceImage:extent:afPoint:visualRegion:context:)

That method tiles the region with overlapping patches:

patchWidth  = clamp(region.width  * 0.34, image.width  * 0.035, image.width  * 0.14)
patchHeight = clamp(region.height * 0.34, image.height * 0.035, image.height * 0.14)
stepX = patchWidth  * 0.50
stepY = patchHeight * 0.50

It also adds a patch centered on the AF point when possible.

Each patch is scored by:

patchRanking(for:searchRegion:sourceImage:extent:afPoint:visualRegion:context:)

The ranking records:

  • robust high-end edge energy,
  • micro-contrast,
  • threshold coverage,
  • distance to AF point,
  • silhouette fraction,
  • ring/compact detail shape,
  • linear-edge penalty,
  • whether the patch contains the AF point,
  • final composite score.

Step 15: Patch Composite Score Decides Which Area Is Worth Showing

The patch composite is built roughly like this:

composite =
    robustTailScore
  + microContrast * 0.35
  + coverage * 0.08
  + afProximity * 0.12
  + interiorBonus
  - silhouetteFraction * silhouettePenalty
  + eyeHeadHeuristicAdjustment

The exact code clamps the result to zero or above.

The intent is to prefer patches with real interior detail, not just any contrast line.

ComponentWhat it rewards or penalizes
robustTailScoreStrong but robust edge-energy tail
microContrastLocal variation inside the patch
coverageEnough pixels above a local threshold
afProximityCloseness to the AF point
interiorBonusPatches not touching the search boundary
silhouetteFractionPenalizes outline-only detail
ringDetailScore / compactDetailScoreRewards compact subject-like detail
linearEdgePenaltyPenalizes a single long line masquerading as focus
belowAFPenaltyIn AF mode, penalizes patches too far below the AF point

Example:

Consider a bird against the sky:

  • Patch A covers the bird’s outline only. It has strong edges but little interior texture, so silhouette penalty applies.
  • Patch B covers the eye and feather detail. It has compact/ring detail and real micro-contrast, so it gets a stronger composite score.
  • Patch C covers a branch near the AF point. It has high linear edge energy, but the linear-edge penalty can reduce it.

The final mask should prefer Patch B.

Step 16: Up To Three Evidence Patches Are Selected

After ranking all patches, RawCull calls:

selectEvidencePatches(from:rankings, visualRegion:visualRegion)

The selection rules are:

  1. discard patches with non-finite or zero composite score,
  2. sort strongest first,
  3. in AF-anchored mode, prefer the nearest AF patch if it is within 15% of the strongest patch’s score,
  4. keep patches that do not overlap too much,
  5. stop after three patches.

Overlap is rejected when:

overlapRatio(candidate, selected) >= 0.55

Example:

If the top three ranked patches all sit on the same bird eye, RawCull keeps the best one and skips heavily overlapping duplicates. It may then include a nearby feather patch or head patch so the overlay shows a compact area of focus evidence rather than three identical rectangles.

Step 17: The Visual Threshold Is Adaptive

RawCull samples the selected patch rectangles from the boosted Laplacian:

visualSamples = redSamples(in: selectedPatchRects, from: boostedLaplacian)

Then it computes:

adaptiveVisualThreshold(
    samples,
    fallback: config.threshold,
    percentile: AF anchored ? 0.82 : 0.90,
    floorMultiplier: AF anchored ? 0.32 : 0.55,
    capAtFallback: AF anchored
)

This means the threshold is derived from the actual local evidence distribution, not only from the settings slider.

ModePercentilePractical effect
AF-anchored82nd percentileMore willing to show local evidence around AF
Saliency/global90th percentileStricter; highlights only stronger subject/global edges

The result is clamped to:

max(fallback * floorMultiplier, 0.01) ... 0.95

Example:

If a subject has weak but consistent feather detail, the local percentile threshold may settle below the global config.threshold, allowing visible edges. If a subject has very strong contrast, the threshold rises so the mask does not flood the entire region.

Step 18: The Mask Can Relax Threshold For Visibility

If:

config.guaranteeVisibleFocusEvidence == true

RawCull checks coverage:

coverage = pixelsAboveThreshold / sampledPixels

If coverage is below:

config.minimumEvidenceCoverage

it tries a relaxed threshold using the 70th percentile and a lower floor.

Example:

A file can score as sharp because a small AF-local detail patch is genuinely strong, but the visual threshold might produce only a few red pixels. With visibility guarantee enabled, the overlay can relax enough to make that focus evidence inspectable. This changes the overlay, not the saved score.

Step 19: Edges Are Thresholded, Cleaned, And Colorized

The method:

buildColorizedThresholdedEdges(from:threshold:config:)

turns the boosted Laplacian into the red/orange overlay.

It performs:

  1. CIColorMatrix to extract edge energy into grayscale,
  2. CIColorThreshold using the adaptive visual threshold,
  3. optional CIMorphologyMinimum for erosion,
  4. optional CIMorphologyMaximum for dilation,
  5. a small CIMorphologyMinimum to thin the dilated result,
  6. CIColorMatrix to colorize the binary mask.

The colorization uses strong red, a little green, very little blue, and high alpha:

red   = 1.0
green = 0.22
blue  = 0.02
alpha = 0.92

Example:

If erosionRadius is raised, isolated noise pixels disappear more aggressively. If dilationRadius is raised, nearby edge fragments connect and look more continuous. Too much dilation can make the mask look thick; too much erosion can remove legitimate fine detail.

Step 20: The Mask Is Clipped To Selected Evidence Patches

The red edge image still covers the whole Laplacian extent. RawCull clips it to selected patch rectangles:

clip(edgeMask, to: patchRects, extent: scaledImage.extent)

This creates a white mask from the selected rectangles and blends the colorized edges through it.

If config.featherRadius > 0, the clipped mask is then Gaussian-blurred slightly:

feather.radius = config.featherRadius

Finally the result is cropped back to the image extent and rendered:

context.createCGImage(croppedMask, from: croppedMask.extent)

Example:

A photo may contain sharp grass in the background and a slightly soft bird in the foreground. If the selected evidence region is saliency or AF, clipping prevents background grass from covering the whole overlay. The mask can still show the best subject-local evidence, even if other parts of the frame have stronger raw texture.

Step 21: Diagnostics Are Returned With The Mask

The engine returns:

FocusMaskRenderResult(
    image: CGImage?,
    diagnostics: FocusMaskDiagnostics,
    evidence: FocusEvidence?
)

For the breakdown path, generateFocusMaskWithBreakdown stores:

breakdown.focusMaskRegionSource = maskResult.diagnostics.regionSource
breakdown.focusMaskVisualThreshold = maskResult.diagnostics.visualThreshold
breakdown.focusEvidence = maskResult.evidence

The UI can then show those values in CandidateInspectorView.

Important diagnostic fields include:

FieldMeaning
focusMaskRegionSourceWhether the mask had saliency, AF, both, or neither
focusMaskVisualThresholdFinal threshold used for the overlay
focusEvidence.winningRegionRegion selected by the scoring/evidence logic
focusEvidence.visualizedRegionRegion actually rendered by the mask
focusEvidence.maskCoverageFraction of sampled pixels above threshold
focusEvidence.relaxedForVisibilityWhether visibility relaxation was used
focusEvidence.patchRankingsRanked local patches and their metrics
focusEvidence.focusEvidenceConfidenceHigh, medium, or low confidence explanation

Example:

If the inspector says:

Mask Region: AF + saliency
Rendered Region: AF point
Evidence Threshold: 0.31
Location Confidence: High
Reason: AF-local patch is spatially aligned

that means RawCull had both subject and AF data, chose an AF-local visualization, used an adaptive threshold of 0.31, and found the selected patch close enough to the AF point to trust its location.

Step 22: The UI Draws The Mask Over The Image

In ZoomOverlayView, the final drawing is:

if showFocusMask, let mask = focusMask {
    Image(decorative: mask, scale: 1.0, orientation: .up)
        .resizable()
        .scaledToFit()
        .blendMode(.screen)
        .opacity(0.95)
}

The same visual idea is used by thumbnail and comparison image views: the generated bitmap is drawn above the source image, aligned to the same aspect-fit frame.

The separate focus-point overlay uses FocusPointMarker and red corner brackets. It is controlled by showFocusPoints, not by the focus-mask image.

What Most Affects The Result

FactorWhere it comes fromImpact on mask
Source image size and qualityZoom/thumbnail/comparison loadersMore pixels can expose fine detail, but increase compute and may require more blur
preBlurRadiusFocus settings and presetsHigher ignores noise/texture; lower preserves tiny details
ISOEXIF per fileHigher ISO increases blur factor to avoid grain highlights
Aperture hintEXIF f-numberLandscape apertures reduce blur damp and scoring saliency dominance
thresholdFocus settings/calibrationHigher shows fewer, stronger edges; lower shows more edges
energyMultiplierFocus settingsRaises visual edge energy before thresholding
erosionRadiusFocus settingsRemoves isolated small mask pixels
dilationRadiusFocus settingsConnects and expands nearby edge pixels
featherRadiusConfig default/settings persistenceSoftens the clipped overlay
AF point availabilityMakerNote parsing during scanCan anchor patch selection near the focus attempt
Saliency availabilityVision saliencyCan isolate the mask to subject-like regions
Existing score breakdownSharpnessScoringModel.breakdownsCan reuse previous winning evidence
guaranteeVisibleFocusEvidenceEnabled for images labeled sharp in some viewsCan relax the threshold so weak but valid evidence is visible
Cancellation/navigationUI task lifecyclePrevents stale masks from being shown after image changes

Practical Examples

Example 1: Sharp Bird Eye, Busy Background

Inputs:

  • AF point exists on or near the bird’s eye,
  • Vision saliency finds the bird,
  • background branches contain many high-contrast lines.

Likely flow:

  1. region source becomes saliencyAndAF,
  2. visual region falls back to AF-local evidence,
  3. fine Laplacian and center weighting favor the AF area,
  4. patches around the eye/head outrank branch patches,
  5. the mask is clipped to one to three subject-local patches.

Result:

The overlay should highlight small red/orange edges around the eye, head, or feather detail instead of painting every sharp branch in the frame.

Example 2: Bird Outline Sharp, Interior Soft

Inputs:

  • subject silhouette has strong edge contrast,
  • eye and feather interior are soft,
  • saliency finds the bird.

Likely flow:

  1. outline patches produce high raw Laplacian energy,
  2. silhouetteFraction rises because edge energy is concentrated near the outside of the patch,
  3. the silhouette penalty reduces composite score,
  4. if no stronger interior patch exists, confidence may be medium or low.

Result:

The mask may show less than expected, or it may highlight only the best available edge. This is intentional: a sharp outline alone does not prove the subject detail is in focus.

Example 3: High-ISO Indoor Portrait

Inputs:

  • ISO is high,
  • face/subject is visible,
  • image contains sensor noise in flat areas.

Likely flow:

  1. isoScalingFactor increases the pre-blur radius,
  2. isolated grain is smoothed before the Metal Laplacian,
  3. patches with real facial detail keep stronger micro-contrast,
  4. erosion removes remaining isolated noise pixels.

Result:

The overlay should avoid sparkling across flat walls or skin noise. It should prefer consistent detail such as eyes, eyelashes, hair, or clothing texture.

Example 4: Landscape With No Clear Subject

Inputs:

  • no useful AF point,
  • Vision saliency may be weak or irrelevant,
  • aperture is f/8 or smaller.

Likely flow:

  1. if subject isolation cannot find a useful region, mask falls back toward global evidence,
  2. landscape aperture hint damps blur so whole-frame detail is not smoothed away,
  3. global patches are ranked by robust edge energy and micro-contrast.

Result:

The mask can show distributed detail in trees, rocks, buildings, or horizon texture. This is different from a wildlife frame, where background detail is intentionally less important.

Example 5: Mask Looks Sparse On A Sharp Image

Inputs:

  • image has a high sharpness score,
  • selected patch is small,
  • adaptive threshold is strict.

Likely flow:

  1. the score can be high from compact, reliable evidence,
  2. the visual mask may still have low coverage,
  3. if the image is labeled sharp, guaranteeVisibleFocusEvidence may relax the threshold,
  4. diagnostics set relaxedForVisibility = true.

Result:

The overlay becomes easier to inspect, but this does not change the computed sharpness score.

Common Misreadings

ObservationWhat it usually means
Mask does not cover the whole sharp subjectRawCull clips to the strongest evidence patches, not every sharp pixel
Mask appears near AF point instead of most contrasty backgroundAF evidence is being trusted as the focus attempt
Mask is global-onlyNo usable AF/saliency evidence was available, or the winning evidence asked for global
Mask is sparseThreshold is high, evidence is compact, or visibility relaxation is off
Mask highlights outline but confidence is low/mediumSilhouette/linear-edge penalties may see outline contrast without enough interior detail
Focus-point brackets and mask disagreeThe camera AF point and computed edge evidence are separate signals

How To Debug A Surprising Mask

Start with the inspector fields, because they tell you why a mask was drawn where it was.

  1. Check Mask Region: did the engine have saliency, AF, both, or neither?
  2. Check Winning Region and Rendered Region: did it choose AF, saliency, mixed, or global?
  3. Check Evidence Threshold: a high value means only very strong edges survived.
  4. Check Evidence Coverage: very low coverage means the mask is intentionally sparse.
  5. Check Visibility Relaxed: if yes, the overlay was made more visible for inspection.
  6. Check the top patch summaries: look for AF distance, coverage, silhouette fraction, and composite score.
  7. Toggle focus points: compare the red AF marker with the red/orange computed mask.
  8. Adjust settings one at a time: threshold, pre-blur, amplify, erosion, dilation.

Change Checklist

When changing focus-mask behavior, check these code paths together:

  • FocusMaskEngine+MaskGeneration.swift for visual overlay logic,
  • FocusMaskEngine+Scoring.swift for shared Laplacian behavior,
  • FocusDetectorConfig.swift for defaults and settings impact,
  • ZoomOverlayView.swift for zoom mask generation and diagnostics persistence,
  • MainThumbnailImageView.swift for thumbnail/detail mask generation,
  • ComparisonGridImageCoordinator.swift for comparison-grid masks,
  • CandidateInspectorView.swift for diagnostics display.

Be especially careful with buildAmplifiedLaplacian. It is shared by visual focus masks and sharpness scoring. A change there can alter both the overlay and the numeric score behavior.

8 - Burst Groups

Burst Groups

Burst analysis turns a flat catalog into groups of adjacent, visually similar frames. It ranks each multi-frame group, presents review queues, and supports culling decisions such as keeping the best frame, keeping the top two, deferring a group, or setting a manual pick.

The implementation separates orchestration, Vision feature-print indexing, pure grouping and ranking engines, cache persistence, and the review UI.

Source Map

AreaMain files
Analysis orchestration and user actionssourcecode/RawCull/RawCull/Model/ViewModels/RawCullViewModel+BurstGrouping.swift
Similarity indexing and grouping statesourcecode/RawCull/RawCull/Model/ViewModels/SimilarityScoringModel.swift
Pure grouping and rankingsourcecode/RawCullCore/Sources/RawCullCore/BurstGroupingEngine.swift, BurstRankingEngine.swift
Shared modelssourcecode/RawCullCore/Sources/RawCullCore/BurstAnalysisModels.swift, app BurstAnalysisModels.swift, BurstReviewQueueModels.swift
Cachesourcecode/RawCull/RawCull/Actors/BurstAnalysisCache.swift
Ratings and manual overridesCullingModel.swift, SavedFiles.swift
Burst home and review listBurstGroupsHomeView.swift, SimilarityGridSelectionView.swift, CullingGridView.swift
Single-burst workspace and comparisonBurstCullingWorkspaceView.swift, ComparisonGridView.swift
TestsRawCullCore/Tests/RawCullCoreTests/BurstGroupingEngineTests.swift, BurstRankingEngineTests.swift, app burst/culling tests

End-to-End Flow

flowchart TD
    A["Analyze Bursts"] --> B["RawCullViewModel.analyzeBursts"]
    B --> C{"Valid BurstAnalysisCache?"}
    C -->|"yes"| D["Remap cached IDs and apply snapshot"]
    C -->|"no"| E{"Sharpness scores missing?"}
    E -->|"yes"| F["Calibrate and score target files"]
    E -->|"no"| G["Reuse scores"]
    F --> H{"Similarity embeddings missing?"}
    G --> H
    H -->|"yes"| I["Index Vision feature prints"]
    H -->|"no"| J["Reuse embeddings"]
    I --> K["Group adjacent frames"]
    J --> K
    K --> L["Rank multi-frame groups"]
    L --> M["Apply manual winner overrides"]
    M --> N["Save cache and review-state snapshots"]
    N --> O["Show home dashboard and review queues"]

Every awaited phase is protected by the analysis generation and selected catalog. A cancelled or superseded run cannot publish late results into a newer catalog.

The target is normally every catalog file sorted by localized filename, which acts as shot order. If files are selected, visible selected files are followed by hidden selected files. If no selection exists and a star filter is active, only files with that rating are analyzed.

Similarity Embeddings

SimilarityScoringModel creates Vision feature-print embeddings from thumbnail-resolution images. It archives each VNFeaturePrintObservation as Data keyed by FileItem.id, avoiding a large collection of live Vision objects.

SettingCurrent value
Embedding thumbnail maximum512 px
Embedding pipeline version1
Vision revisionVNGenerateImageFeaturePrintRequestRevision2
Maximum concurrent indexing tasks4

Embedding decode first uses the RawParserKit thumbnail path and then falls back to ImageIO. The feature-print request uses .scaleFill.

The same model can rank a catalog by distance from an anchor image. Burst grouping instead calculates feature-print distances only between adjacent files. Those adjacent distances are cached in memory and reused when possible during regrouping.

Grouping Rules

BurstGroupingEngine.group(...) makes one sequential pass. It starts a new group when any boundary rule fires.

Boundary reasonTrigger
Visual distance changedAdjacent feature-print distance is at or above visualDistanceThreshold
Similarity evidence missingNo adjacent distance is available
Capture gapAbsolute modification-date gap is greater than maxTimeGapSeconds
Camera changedNormalized camera value changed and requireSameCamera is enabled
Focal length changedParsed focal-length delta exceeds maxFocalLengthDeltaMM
Exposure changedAperture changes by more than 0.2, ISO changes, or shutter-speed text changes

Lens changes are recorded as evidence but do not independently split a group. They do make group metadata unstable during ranking.

Default configuration:

visualDistanceThreshold = 0.25
maxTimeGapSeconds = 2.0
requireSameCamera = true
requireSimilarFocalLength = true
maxFocalLengthDeltaMM = 3.0
algorithmVersion = 2

The burst sensitivity control changes only the visual threshold. reGroupBursts() cancels older grouping work, reuses embeddings and adjacent-distance data, rebuilds rankings, and saves a new cache. The current home/category presentation is preserved instead of being forced into the grouped grid.

Ranking Formula

BurstRankingEngine computes:

overall =
    rankingSharpness * 0.62
  + focusPoint      * 0.12
  + saliency        * 0.10
  + metadata        * 0.16

Sharpness is normalized by SharpnessScoringModel.maxScore. When at least two group members have scores and their normalized spread is at least 0.03, global and burst-relative sharpness are blended:

rankingSharpness = normalizedSharpness * 0.65
                 + burstRelativeSharpness * 0.35

The other components are heuristic evidence:

  • Focus is 0.70 when camera AF data exists and 0.45 otherwise.
  • Saliency is 0.75 when the subject label matches the group’s dominant label, 0.25 on a mismatch, and 0.45–0.60 when evidence is incomplete.
  • Metadata starts from group stability, gains 0.15 for tight similarity, loses 0.10 at ISO 6400 or above, and gains 0.05 at f/5.6 or wider.

Ties in overall score retain original shot order.

Confidence And One-Click Safety

ConfidenceConditions
HighScores exist, group has at least 3 files, best leads second by at least 0.12, best normalized sharpness is at least 0.65, metadata is stable, and all internal visual distances are below 0.22
MediumBest leads by at least 0.05 and metadata is stable
LowScores are absent, candidates are close, or evidence is unstable

isSafeForOneClickCulling is true only for high-confidence results. keepBestInGroup and keepTopTwoInGroup also require the current sharpness score table to be non-empty; otherwise they return without changing ratings.

Home Dashboard And Review Queues

After analysis, BurstGroupsHomeView shows catalog coverage, group counts, the active similarity threshold, up to three suggested picks, and these queue categories:

CategorySelection rule
AllEvery computed group, including singleton groups
Single ImagesGroups containing exactly one file
Needs ReviewMulti-frame groups with explicit review-needed state or unsafe/uncertain ranking evidence
DeferredMulti-frame groups explicitly deferred
Marked ReviewedMulti-frame groups explicitly marked .reviewed
ReviewedEffective reviewed results, decisions already applied, and manual-winner groups

The grouped culling grid can collapse a burst to its top three ranked frames. A group header opens the dedicated workspace and toggles Reviewed or Deferred state.

BurstCullingWorkspaceView displays ranked frames as a filmstrip with rating, source, zoom, focus overlay, manual-pick, defer, and review controls. P/N move between frames and G advances to the next eligible multi-frame group in the current queue. The detailed comparison grid remains available from the workspace.

Review States

RawCullCore.BurstReviewState currently defines:

  • .none
  • .needsReview
  • .reviewed
  • .deferred
  • .algorithmReviewed
  • .manualWinnerOverride
  • .decisionApplied

The app and package enum are now aligned. algorithmReviewed remains for cache compatibility.

BurstReviewQueuePolicy.effectiveState(for:) preserves explicit modern states. For .none or legacy .algorithmReviewed, it derives Needs Review when confidence is not high, cautions exist, no recommendation exists, or one-click culling is unsafe; otherwise it treats the group as reviewed.

Toggling Reviewed or Deferred a second time resets the result to .none and removes that group from the explicit state dictionary. Review states are persisted using a catalog-and-membership BurstGroupSignature, so they can be restored after a threshold change even if numeric group IDs change.

Manual Winner Overrides

A manual winner is stored in savedfiles.json as BurstWinnerOverride:

FieldMeaning
winnerFileNameUser-selected winner
memberFileNamesGroup membership snapshot

Overrides use filenames because saved-file persistence is filename-based. CullingModel canonicalizes member names so lookup is order-independent and prunes overrides whose files no longer exist. Applying an override promotes the selected file, recalculates second place, and sets .manualWinnerOverride.

User Actions

ActionMethodEffect
Keep bestkeepBestInGroupOn a safe result, rate the winner 3 stars and reject the rest
Keep top twokeepTopTwoInGroupOn a safe result, rate first place 3 stars, second place 2 stars, and reject the rest
Set manual picksetManualBurstWinnerPersist the winner override and rate the selected frame 3 stars
Open groupcompareBurstGroupOpen the workspace with up to four ranked comparison IDs
Next groupadvanceToNextBurstGroupOpen the next eligible multi-frame group in the active queue
Toggle reviewed/deferredtoggleBurstGroupReviewed, toggleBurstGroupDeferredPersist or clear the explicit review state
Undo last burst actionundoLastBurstActionRestore the previous ratings captured for the last one-click action
ReindexreindexBurstAnalysisClear loaded analysis, delete the catalog cache, and recompute

One-click rating actions capture a BurstUndoEntry before writing ratings. Only the most recent burst action is retained for undo.

Cache Validity

BurstAnalysisCache stores embeddings, scores, saliency, groups, boundary evidence, ranked results, and review-state snapshots. A snapshot is accepted only when all of these still match:

  • cache schema version (currently 4),
  • grouping algorithm version,
  • catalog path,
  • effective sharpness thumbnail size and complete sharpness signature,
  • grouping, embedding, and Vision similarity signature,
  • file count,
  • every file path, size, and modification date.

On load, cached UUIDs are remapped to the current scan’s UUIDs by file path because FileItem.id values are recreated. Cache saves are also guarded by the completed analysis context, generation, catalog, and similarity signature so stale asynchronous work cannot overwrite a newer result.

What To Check When Changing This Area

  • Bump BurstGroupingConfig.algorithmVersion when grouping semantics change.
  • Update the embedding pipeline version or similarity signature when embedding inputs change.
  • Update the sharpness scoring signature when score meaning changes.
  • Keep review-state decoding backward compatible and preserve signature-based restoration across regrouping.
  • Keep manual winner overrides durable across cache invalidation and UUID remapping.
  • Treat high confidence plus available sharpness scores as the one-click culling gate.

9 - RawCull Packages

Architecture guide to the four reusable Swift packages used by RawCull.

RawCull Packages

RawCull is split across four reusable Swift packages. Each package owns one kind of knowledge and deliberately avoids importing the others. RawCull is the composition root: it decodes a file and capture metadata with RawParserKit, asks PhotoAnalysisKit or PhotoAIKit to measure or index it, translates the evidence into RawCullCore values and decisions, and presents or persists the outcome.

flowchart LR
    Files["ARW, NEF, and rendered files"] --> Parser["RawParserKit\ndecode + capture metadata"]
    Parser --> Host["RawCull adapters\nsource and cache policy"]
    Host --> Analysis["PhotoAnalysisKit\nsharpness + focus evidence"]
    Host --> AI["PhotoAIKit\nimage/text embeddings + segmentation"]
    Analysis --> Host
    AI --> Host
    Host --> Core["RawCullCore\nburst grouping + ranking"]
    Core --> App["RawCull UI, persistence, and culling actions"]

The arrows are runtime data flow, not package dependencies. The four sibling packages do not depend on one another; the RawCull application imports and adapts them.

PackageOwnsDeliberately leaves to RawCull
PhotoAIKitModel validation, CLIP image and text embeddings, image/text semantic comparison, SAM 3, Vision similarity artifacts, AI workflows, and optional storage codecsModel installation, RAW decoding, query and result presentation, cache locations, and culling policy
PhotoAnalysisKitSharpness scoring, saliency and classification, focus evidence and masks, calibration, and Vision feature printsFile decoding, source identity, persistence, settings wording, and culling decisions
RawCullCorePackage-safe metadata, capture-time and exposure-aware burst grouping, evidence-based ranking, focus-point normalization, and luminance histogramsRAW parsing, image analysis, application state, storage, and presentation
RawParserKitVendor dispatch, MakerNote and embedded-JPEG parsing, image loading, orientation, structured capture/EXIF extraction, and bounded decode workAnalysis, scoring, catalogs, cache policy, and UI
  1. Start with RawParserKit to see how a file becomes a normalized image and metadata.
  2. Continue with PhotoAnalysisKit to see how a decoded image becomes sharpness and focus evidence.
  3. Read RawCullCore to see how measurements become burst boundaries and recommendations.
  4. Read PhotoAIKit for optional CLIP image similarity, text-query semantic search, and SAM 3 segmentation workflows.

The source for this section is the current PhotoAIKit, PhotoAnalysisKit, RawCullCore, RawParserKit, and RawCull repositories under /Users/thomas/GitHub/RawCull. In each package guide, paths beginning with Sources/ and Tests/ are relative to that package. Paths beginning with RawCull/ are relative to the RawCull application repository.

9.1 - How PhotoAIKit Is Constructed

A detailed guide to PhotoAIKit’s contracts, CLIP image and text inference, semantic comparison, SAM 3, workflows, storage, concurrency, and model identity.

How PhotoAIKit Is Constructed

PhotoAIKit is a reusable Swift package extracted from application code. Its most important achievement is not merely that CLIP and SAM 3 run. It is that reusable AI behavior has been separated from RawCull’s UI, RAW-file handling, paths, sandbox rules, and culling policy.

This document explains the construction from the bottom up and gives the reason for each boundary.

1. Begin With The Package Boundary

The package owns:

  • typed, Sendable contracts;
  • model-bundle validation and fingerprinted model identity;
  • Core AI CLIP image and text inference plus SAM 3 inference;
  • validated image/text semantic comparison;
  • Apple Vision feature-print generation and comparison;
  • bounded similarity indexing and explicit fallback;
  • segmentation, mask selection, geometry, and catalog workflows;
  • optional artifact codecs and mask stores.

The host application owns:

  • model download, installation, candidate URLs, and sandbox bookmarks;
  • RAW decoding and application image models;
  • SwiftUI, Observation state, and display wording;
  • app-specific task staleness such as “latest selection wins”;
  • query admission, result ordering, filtering, and semantic-search presentation;
  • culling, burst ranking, sharpness, saliency, and rating policy;
  • helper-process launch and application restart behavior.

This boundary makes PhotoAIKit reusable. A package that imports FileItem or searches Bundle.main for a RawCull resource might be convenient for one app, but it would silently encode application policy into the AI layer.

2. Read Package.swift As An Architecture Diagram

PhotoAIKit/Package.swift declares Swift tools 6.4, macOS 27, Swift 6 language mode, six library products, and one test target. The only external package dependency is a pinned revision of apple/coreai-models.

flowchart TD
    Contracts["PhotoAIContracts\nvalues + protocols"]
    CLIP["CoreAICLIPBackend"] --> Contracts
    SAM3["CoreAISAM3Backend"] --> Contracts
    Vision["VisionFeaturePrintBackend"] --> Contracts
    Workflows["PhotoAIWorkflows"] --> Contracts
    Storage["PhotoAIStorage"] --> Contracts
    CoreAI["apple/coreai-models\nCoreAISegmentation product"] --> CLIP
    CoreAI --> SAM3
    Tests["PhotoAIKitTests"] --> Contracts
    Tests --> CLIP
    Tests --> SAM3
    Tests --> Vision
    Tests --> Workflows
    Tests --> Storage

There is intentionally no large umbrella target in which every feature can reach every implementation. Each higher-level product depends on PhotoAIContracts, but the backends, workflows, and storage products do not depend on one another.

Why this shape helps:

  • A host can import only the products it uses.
  • Workflow code is testable with fake providers and decoders.
  • Storage does not need to know which inference backend produced a value.
  • A backend cannot accidentally reach into RawCull or into another backend.
  • Framework-heavy dependencies remain concentrated in concrete backend targets.

3. PhotoAIContracts: The Stable Center

Contracts are the innermost layer. They contain data and protocol definitions, not application decisions.

3.1 Translate Host Photos Into AIImageSource

Sources/PhotoAIContracts/AIImageSource.swift defines the package-owned source value:

public struct AIImageSource: Codable, Hashable, Identifiable, Sendable {
    public let id: UUID
    public let url: URL
    public let displayName: String
}

RawCull maps FileItem into this type at its integration boundary. PhotoAIKit therefore receives only what a reusable indexing or segmentation workflow needs. It never learns ratings, focus data, camera metadata, or view state.

SourceFileIdentity reads size and modification date. SourceFingerprint adds the standardized path. These values let persisted artifacts answer a crucial cache question: “Was this result produced for the current contents of this source file?”

3.2 Invert Image Decoding

PhotoAIKit declares:

public protocol ImageDecoding: Sendable {
    func image(for source: AIImageSource) async throws -> CGImage
}

The package needs a CGImage, but it should not prescribe how one is obtained. A JPEG host can use ImageIO; RawCull can try RawParserKit first; tests can return a generated image. The workflow depends on the capability, not on a camera-format implementation.

This is the dependency-inversion principle in a small, concrete form:

flowchart LR
    Workflow["PhotoAIKit indexer"] --> Protocol["ImageDecoding protocol"]
    Raw["RawCull RAW decoder"] -. conforms .-> Protocol
    Test["Test decoder"] -. conforms .-> Protocol

3.3 Separate Generation From Comparison

Sources/PhotoAIContracts/SimilarityArtifact.swift defines two protocols:

  • ImageSimilarityArtifactProviding creates an artifact from a CGImage and source.
  • ImageSimilarityArtifactComparing computes the distance between two compatible artifacts.

The combined ImageSimilarityBackend type alias requires both.

The split matters because generation can be actor-isolated and expensive, while comparison may be synchronous and nonisolated. It also keeps distance semantics with the backend that understands its payload. RawCull does not decode a VNFeaturePrintObservation, nor does it implement CLIP cosine distance from unverified arbitrary data.

3.4 Keep Text Queries Query-Scoped

Sources/PhotoAIContracts/TextEmbedding.swift defines a second, deliberately different similarity value. TextEmbedding contains a normalized text vector and a TextEmbeddingDescriptor with the complete backend identity, dimensions, tokenizer version, and schema version.

Text embeddings are not file-backed SimilarityArtifact values. They have no source fingerprint and are intended to live for one query. The split public protocols mirror the image API:

  • TextEmbeddingProviding tokenizes and encodes a query.
  • ImageTextSimilarityComparing compares a compatible image artifact with the text embedding.
  • ImageTextSimilarityBackend combines both capabilities.

The comparison returns cosine similarity in -1...1, where a larger value is a closer semantic match. It is a relative retrieval score, not a confidence or a keep/reject decision.

3.5 Make Persisted Artifacts Self-Describing

A SimilarityArtifact has two fields:

SimilarityArtifact
├── descriptor
│   ├── backend
│   ├── model fingerprint
│   ├── dimensions
│   ├── representation
│   ├── preprocessing version
│   ├── normalization version
│   ├── configuration version
│   ├── source fingerprint
│   └── schema version
└── payload (backend-owned Data)

This looks more elaborate than storing [Float], but it prevents subtle cache bugs. A vector is not reusable merely because its dimension matches. Reuse is valid only if the source, model asset, preprocessing, normalization, configuration, representation, and schema are still the same.

SimilarityArtifactDescriptor.isCompatibleForDistance(with:) intentionally compares every backend/configuration field but excludes the source fingerprint. Two different photos must have different source fingerprints, yet their artifacts can still be compared when they were produced by the same backend definition.

3.6 Treat Model Identity As Data

The model types are spread across:

  • ModelIdentity.swift;
  • ModelAssetFingerprint.swift;
  • ModelBundleResolver.swift;
  • ModelResource.swift.

ModelIdentity.cacheIdentifier preserves compatibility with older cache naming. artifactIdentifier adds the selected asset fingerprint for new artifacts. This distinction allows migration without pretending that two different model binaries are the same model.

ModelResourceDescriptor.clip and .sam3 describe package-neutral requirements and version strings. ModelCapabilityStatus reports available, missing, or invalid without user-facing wording. The host translates that status into its own settings UI.

4. Model Bundles Are Supplied, Not Discovered

PhotoAIKit never chooses an application directory. The host supplies one URL or an ordered list of candidates.

A valid bundle has this conceptual layout:

ModelBundle/
├── metadata.json
├── tokenizer/
│   └── tokenizer.json
└── selected-model.aimodel  (or .aimodelc)

metadata.json names the selected model in assets.main. New exports can also provide asset_fingerprints.main.

ModelBundleResolver validates, in order:

  1. the URL exists and is a directory;
  2. metadata.json decodes;
  3. assets.main is present and non-empty;
  4. the asset extension is accepted;
  5. the selected asset exists;
  6. required resources such as tokenizer/tokenizer.json exist;
  7. the model asset can be fingerprinted;
  8. a manifest checksum, when present, matches the actual asset.

For a file asset, the cryptographic algorithm is SHA-256. For a compiled model directory, PhotoAIKit hashes a stable sorted tree representation. Older manifests without a checksum receive a size/modification-time fallback fingerprint. That fallback can detect replacement in place but is marked as not cryptographically verified.

ModelProviderFactory<Provider> connects validation to a backend constructor. A backend supplies “how to make me from a validated URL”; the host supplies “which URLs should be considered, and in what order.”

Candidate order is policy, not just presentation. ModelResourceResolver skips a candidate that is missing, but returns immediately when it finds an invalid candidate. This prevents a damaged higher-priority installation from being silently hidden by a lower-priority fallback.

CLIP has a second validation layer after generic bundle resolution. CLIPRuntimeConfiguration reads the model contract from metadata: source model and revision, architecture, pretrained checkpoint, embedding dimensions, preprocessing dimensions and normalization, tokenizer context and padding, named image/text functions, normalization version, and configuration version. Current bundles use shortest-side resize, a centered square crop, and bicubic interpolation. Older PhotoAIKit bundles remain compatible through the original 224-pixel stretch and bilinear defaults.

This separation prevents a library update from unexpectedly changing an application’s installation or sandbox policy.

5. Concrete Backend Products

5.1 CoreAICLIPBackend

CoreAICLIPProvider is an actor conforming to image embedding, artifact generation/comparison, text embedding, and image/text comparison protocols.

Its responsibilities are deliberately backend-specific:

  • validate the supplied CLIP bundle;
  • lazily load and cache the named image and text Core AI functions;
  • validate tensor names, shapes, scalar types, dimensions, and metadata;
  • create the image, token, and attention-mask NDArrays;
  • apply the model-declared resize/crop policy in sRGB and CLIP channel normalization;
  • L2-normalize image vectors through ImageEmbedding;
  • validate that the exported graph’s text vector is finite, non-empty, correctly shaped, and already L2-normalized;
  • encode the vector as an artifact payload;
  • compute cosine distance only after descriptor and payload validation.

The actor owns loadedModel, so lazy initialization is isolated from concurrent callers.

For a semantic query, the same provider creates a TextEmbedding and similarity(image:text:) rejects every incompatible model, representation, dimension, preprocessing, normalization, configuration, tokenizer, schema, or payload before computing a dot product. Vision artifacts are rejected because they are not in CLIP’s image/text embedding space.

RawCull’s RawCullCLIPSemanticSearchService keeps the product policy outside the backend: it trims and admits a literal query, selects already-persisted compatible image artifacts, isolates per-file failures, reports progress, and orders equal scores deterministically. It never decodes an image or persists a query embedding.

5.2 CoreAISAM3Backend

CoreAISAM3Provider conforms to SubjectSegmenting. It owns tokenizer setup, lazy model loading, request inference, query selection, confidence conversion, mask decoding, resizing, thresholding, feathering, timing, and diagnostics.

The public contract speaks in SubjectSegmentationRequest and SubjectSegmentationResult, not Core AI tensors. This lets workflows and hosts work at the domain level while the backend handles framework details.

5.3 VisionFeaturePrintBackend

This actor uses VNGenerateImageFeaturePrintRequest. The produced VNFeaturePrintObservation is securely archived into an opaque artifact payload.

Comparison returns to this backend, which unarchives the observations and calls Vision’s native computeDistance. The observation never becomes part of RawCull’s persistence API. This is an example of information hiding: callers can store and route the artifact without learning its private representation.

6. PhotoAIWorkflows: Reusable Orchestration

Backends operate on one image or request. Workflows coordinate many sources and reusable policies.

6.1 Similarity Indexing

EmbeddingIndexer is the vector-specific API. SimilarityArtifactIndexer is the more general API used when the fallback might have an opaque representation such as a Vision feature print.

Both indexers accept:

  • package-owned sources;
  • an injected decoder;
  • a primary provider;
  • an optional fallback provider;
  • an explicit fallback policy;
  • a concurrency limit;
  • an async progress callback.

They use a throwing task group but enqueue only concurrencyLimit children. Each time one finishes, one new source is added. This is bounded concurrency: a catalog with 20,000 files does not create 20,000 live tasks and decoded images.

The fallback policies are:

PolicyBehaviorAppropriate when
.noneKeep primary successes and failuresThere is no compatible fallback
.perItemRetry only a failed itemPrimary and fallback results can safely coexist
.wholeBatchIf any primary item fails, rerun every requested source through fallbackA batch must use one comparable representation

The indexer does not decide which policy is correct for RawCull. The host selects .wholeBatch for CLIP-to-Vision fallback.

6.2 Segmentation Workflows

The SAM 3 side is split into focused units:

TypeResponsibility
SegmentationServiceResize/preprocess coordination, cache lookup, inference, persistence, partitioning, and prefetch
SegmentationBatchPipelineBatch generation, progress events, summary, and versioned JSON-lines transport
SubjectMaskRepositoryCache-only access using explicit prompt/model/size configuration
SubjectMaskSelectorOrdered prompt fallback, quality/confidence thresholds, and best-mask selection
SubjectMaskCatalogIndexIncremental cache inventory without invoking inference
SubjectMaskGeometryAlpha coverage, bounds, centroid, and freshness
SubjectMaskQualityReusable geometry-based quality classification

The package can decide which mask best satisfies a general selection strategy. RawCull still decides which photo is a culling candidate or winner. Reusable mask quality is not the same concern as product ranking policy.

7. PhotoAIStorage: Optional Persistence Mechanics

Storage depends only on contracts.

For similarity data:

  • SimilarityArtifactCodec wraps an artifact in a versioned envelope.
  • EmbeddingCodec writes descriptor-complete CLIP artifacts.
  • decodeMigrating reads current data and two version-1 formats, but legacy values are accepted only against the real current model identity and are marked for immediate rewrite.
  • LegacyCLIPEmbeddingCodec remains a staged migration reader; its identity-incomplete writer is deprecated.

For segmentation:

  • SubjectMaskMemoryStore is an injected actor-backed dictionary.
  • SubjectMaskDiskStore stores PNG mask data plus JSON metadata, validates the storage key, reports disk usage, supports pruning, and rejects stale or corrupt entries.

The host injects the disk directory. PhotoAIKit owns how the record is encoded and validated, while the application owns where records live and when they are removed.

8. Concurrency And Isolation

PhotoAIKit targets Swift 6 and makes boundary values Sendable.

The main patterns are:

  • Actors for mutable runtime state: providers lazily cache loaded models; services and stores protect mutable state.
  • Value types for transport: sources, identities, descriptors, progress, failures, and configuration values cross tasks safely.
  • Bounded task groups: indexers and prefetch workflows limit simultaneous work.
  • Cooperative cancellation: workflow boundaries and expensive stages call Task.checkCancellation().
  • Async progress callbacks: the package reports neutral progress values; a host decides how and where to publish UI state.

Actor isolation does not remove the need for a host policy. RawCull still owns generation tokens and catalog checks that prevent an older task from replacing results for a newer selection.

9. Testing The Architecture, Not Only The Math

Most of Tests/PhotoAIKitTests/ exercises the products through public APIs. The CLIP text suite also uses @testable import for deterministic tokenizer, batch, tensor-shape, and preprocessing checks that sit below the provider boundary. Together the tests cover architectural promises such as:

  • model bundles are accepted through supplied URLs;
  • manifest fingerprints become artifact identity;
  • provider factories and resource resolvers share validation;
  • CLIP normalization and cosine distance remain stable;
  • model-declared CLIP preprocessing and legacy preprocessing remain compatible;
  • token batches, attention masks, text-output validation, and image/text compatibility checks reject malformed or mismatched data;
  • whole-batch fallback produces a homogeneous result set;
  • Vision artifacts stay opaque and use the native metric;
  • segmentation caches by package-owned source values;
  • disk stores reject stale or corrupt entries;
  • legacy data is a rewrite candidate, not silently current data;
  • batch transport has an explicit versioned schema;
  • cancellation crosses the service boundary.

Fake decoders, providers, and stores make these tests possible. That testability is a direct consequence of putting protocols in the innermost target.

10. Developer Tools And Model Assets

The package includes tools under Tools/ for exporting CLIP and SAM 3 assets, selecting a SAM 3 asset, generating fingerprints, and producing reference CLIP image/text similarities. Output paths are explicit.

export_clip.py supports the existing OpenAI clip-vit-base-patch32 model and OpenCLIP ViT-B-32-256 with datacomp_s34b_b86k weights. It writes the image and text encoders as named functions in one Core AI asset, verifies tokenizer parity for the OpenCLIP export, and records the runtime metadata consumed by CLIPRuntimeConfiguration.

The Swift package itself declares no model resources. It does not embed .aimodel, .aimodelc, tokenizer, or metadata assets. Model binaries are large deployment inputs with their own lifecycle; keeping them out of the library avoids coupling package source, application installation, and model distribution.

11. How To Add Another Similarity Backend

Use the existing layering as a checklist:

  1. Add or reuse package-neutral contract values in PhotoAIContracts; do not add host types.
  2. Create a separate backend target that depends on contracts.
  3. Conform to artifact providing and comparing protocols.
  4. Give the backend a complete SimilarityBackendDescriptor.
  5. Put framework-specific payload encoding and distance semantics inside that backend.
  6. If the backend supports text, add a query-scoped descriptor and keep image/text compatibility checks with that backend.
  7. Reuse SimilarityArtifactIndexer and inject the host’s decoder.
  8. Decide explicitly whether fallback is none, per-item, or whole-batch.
  9. Add public-API tests with fake sources and model-free inputs where possible.
  10. Let the host choose model URLs, cache locations, settings, and product policy.

If a proposed type needs SwiftUI, FileItem, a RawCull path, or a burst rating, it probably belongs in RawCull’s adapter layer instead.

Source Map

TopicPhotoAIKit source
Products and dependency graphPackage.swift
Host-neutral image and decoder boundarySources/PhotoAIContracts/AIImageSource.swift, ImageEmbedding.swift
Model validation and identitySources/PhotoAIContracts/ModelBundleResolver.swift, ModelResource.swift, ModelIdentity.swift, ModelAssetFingerprint.swift
Similarity descriptors and protocolsSources/PhotoAIContracts/SimilarityArtifact.swift
Text embeddings and image/text comparisonSources/PhotoAIContracts/TextEmbedding.swift
Segmentation contractsSources/PhotoAIContracts/SubjectSegmentation.swift, SubjectMaskStorage.swift
CLIP runtime and backendSources/CoreAICLIPBackend/CLIPRuntimeConfiguration.swift, CoreAICLIPProvider.swift
SAM 3 backendSources/CoreAISAM3Backend/CoreAISAM3Provider.swift
Vision backendSources/VisionFeaturePrintBackend/VisionFeaturePrintBackend.swift
Similarity orchestrationSources/PhotoAIWorkflows/EmbeddingIndexer.swift, SimilarityArtifactIndexer.swift
Segmentation orchestrationSources/PhotoAIWorkflows/SegmentationService.swift, SegmentationBatchPipeline.swift, SubjectMask*.swift
Codecs and storesSources/PhotoAIStorage/
Package boundary auditDocumentation/ExtractionMap.md
Public behavior testsTests/PhotoAIKitTests/
RawCull semantic-search policyRawCull/Model/AIIntegration/RawCullSemanticSearchService.swift, RawCull/Model/ViewModels/SimilarityScoringModel.swift

Next, follow these abstractions into the host application in How RawCull Enables and Uses CLIP.

9.2 - How PhotoAnalysisKit Is Constructed

A detailed guide to PhotoAnalysisKit’s image-analysis boundary, sharpness pipeline, focus evidence, masks, calibration, batching, feature prints, resources, and concurrency.

How PhotoAnalysisKit Is Constructed

PhotoAnalysisKit is the reusable measurement layer extracted from RawCull. It turns a decoded CGImage plus neutral capture metadata into sharpness, saliency, focus evidence, and an optional focus-mask image. It can also create opaque Apple Vision feature prints.

The package measures a photo; it does not decide whether to keep it. That distinction prevents image-processing code from becoming coupled to RawCull’s files, settings, views, caches, or culling policy.

1. Begin With The Package Boundary

PhotoAnalysisKit owns:

  • sRGB normalization for predictable analysis input;
  • Core Image and Metal Laplacian processing;
  • Vision saliency and optional subject classification;
  • scalar sharpness metrics and failure classification;
  • AF-point, subject, local-patch, and global focus evidence;
  • focus-mask selection and rendering;
  • numeric configuration, presets, quality levels, and analysis identity;
  • bounded batch analysis and calibration;
  • Vision feature-print generation and native comparison.

The host application owns:

  • RAW, JPEG, or other source decoding;
  • file URLs, security-scoped access, and sandbox bookmarks;
  • source identity, cache keys, cache directories, and persistence;
  • observable task state, progress presentation, and cancellation policy;
  • settings labels and saved-settings migration;
  • sorting, ratings, burst decisions, and culling actions.

The input boundary is intentionally small:

flowchart LR
    Source["RAW or rendered source"] --> Decoder["Host decoder"]
    Decoder --> Input["PhotoAnalysisInput\nCGImage + ISO + aperture + AF point"]
    Input --> Analyzer["PhotoAnalyzer"]
    Analyzer --> Result["PhotoAnalysisResult\nsaliency + breakdown + optional mask"]
    Result --> Host["Host cache, UI, and culling policy"]

PhotoAnalysisKit does not import RawParserKit or RawCullCore. RawCull provides the adapters between them.

2. Read Package.swift As The First Design Document

The manifest declares Swift tools 6.2, Swift 6 language mode, macOS 26, one library product, and one test target.

PhotoAnalysisKit package
├── PhotoAnalysisKit library
│   ├── public contracts and facade
│   ├── Core Image, Vision, and Accelerate implementation
│   └── packaged default.metallib
└── PhotoAnalysisKitTests

There are no third-party package dependencies. The implementation uses Apple frameworks including Core Graphics, Core Image, Vision, Accelerate, and Foundation.

The target enables InferIsolatedConformances and NonisolatedNonsendingByDefault. The package is therefore built under the same strict Swift 6 concurrency assumptions as the host without adopting UI isolation.

3. PhotoAnalysisInput Is The Decoding Boundary

Sources/PhotoAnalysisKit/PhotoAnalysisInput.swift defines the package’s source value:

public struct PhotoAnalysisInput: Sendable {
    public let image: CGImage
    public let iso: Int
    public let aperture: Double?
    public let normalizedAFPoint: CGPoint?
}

The AF point uses normalized 0...1 coordinates with the origin at the visual top-left. This matches the camera metadata shape used by RawCull. Vision uses a bottom-left coordinate system, so the package performs the vertical conversion internally.

ISO is clamped to at least 1. Aperture and AF point are optional because not every image or camera provides them. The package can still compute a global result when either is absent.

This value has no URL or file identifier. Two consequences follow:

  1. The package cannot reopen a source behind the host’s back.
  2. The host must associate returned results with the correct file and invalidate them when that file or decode policy changes.

RawCull makes that ownership concrete in RawCullPhotoAnalysisAdapter. The adapter chooses an embedded-preview decode through RawParserKit or a host-owned RAW demosaic, bounds the requested pixel size, and then constructs PhotoAnalysisInput. It also translates the returned neutral saliency value into RawCullCore’s SaliencyInfo. Neither package needs to import the other to participate in that flow.

PhotoAnalysisResult returns:

FieldMeaning
saliencyOptional neutral subject label and confidence
breakdownScalar score plus detailed evidence and diagnostics
focusMaskOptional rendered overlay image
scoreConvenience access to breakdown.finalScore

An absent breakdown means analysis could not produce a valid result or was cancelled. It is different from a valid score of zero.

4. PhotoAnalyzer Is The Public Facade

Sources/PhotoAnalysisKit/PhotoAnalyzer.swift keeps the public entry points small:

APIWork performed
analyzeSaliency, classification, scalar scoring, and focus evidence; no overlay rendering
analyzeWithFocusMaskAnalysis plus a mask derived from the same evidence
focusMaskMask rendering, optionally reusing previously computed evidence
calibrateCatalog-sample calibration of the visual edge threshold
sharpnessDescriptorStable identity for cacheable non-mask analysis behavior

The facade contains an immutable FocusMaskEngine. Both types are @unchecked Sendable because CIContext does not declare sendability even though this package holds no mutable model or UI state and reuses the context concurrently.

For each input, PhotoAnalyzer copies the supplied configuration, replaces its ISO with the input ISO, and derives an aperture hint from the input aperture. Callers can safely reuse one base configuration across many files.

When a host already stores SharpnessBreakdown.focusEvidence, passing it to focusMask avoids repeating the saliency selection step. This makes the measurement result useful as an input to later presentation work.

5. Follow The Scalar Sharpness Pipeline

The core implementation is in FocusMaskEngine+Scoring.swift.

flowchart TD
    Image["CGImage"] --> SRGB["8-bit sRGB normalization"]
    SRGB --> Vision["Vision saliency + optional classification"]
    SRGB --> Preblur["ISO- and aperture-aware Gaussian pre-blur"]
    Preblur --> Laplacian["Metal focusLaplacian kernel"]
    Laplacian --> Samples["Global, saliency, AF, and local-patch samples"]
    Vision --> Samples
    Samples --> Tail["Robust p90-p97 tail scores"]
    Tail --> Blend["Subject/global blend"]
    Blend --> Adjust["Silhouette, subject-size, and blur-gate adjustments"]
    Adjust --> Breakdown["SharpnessBreakdown"]

5.1 Normalize Before Measuring

normalizeToSRGB redraws the input into an 8-bit sRGB RGBA bitmap. The Metal pipeline therefore receives a predictable pixel format regardless of the source image’s bit depth or color space.

5.2 Find Candidate Subject Regions

VNGenerateAttentionBasedSaliencyImageRequest supplies salient-object rectangles. Small weak objects are removed, and candidates are ordered using AF overlap or distance, saliency confidence, interior detail, area, and deterministic coordinates.

When classification is enabled, VNClassifyImageRequest supplies a neutral subject label. The package filters out broad environment descriptions and prefers likely subjects such as animals or people. This label is evidence, not a keep/reject decision.

5.3 Build Edge Energy

The packaged focusLaplacian Metal kernel runs after Gaussian pre-blur. The pre-blur grows with ISO so high-frequency sensor noise is less likely to masquerade as detail. Aperture hints damp or strengthen parts of this behavior for wide, middle, and landscape apertures.

The result is rendered as floating-point RGBA data. The red channel carries edge energy.

5.4 Score Several Regions

The engine samples:

  • the full image after excluding a configurable border;
  • the selected salient region;
  • the camera AF region;
  • smaller AF-center and AF-neighborhood regions;
  • ranked local patches within subject or AF regions.

robustTailScore sorts the sample values, measures the p90-p97 energy band relative to a p20 noise floor, and penalizes a band that is too sparse. microContrast computes the standard deviation of finite edge samples.

The normal blend combines full-frame and subject evidence. When both AF and saliency scores exist, AF has the larger share of the subject score. A conservative local-detail component can then refine the broad subject measurement.

The final value may be adjusted for:

  • a silhouette-dominated subject whose strongest energy is mostly at the outer rim;
  • the area of a saliency-only subject;
  • an aperture-aware soft blur gate driven by subject micro-contrast.

SharpnessBreakdown preserves the component scores and selected evidence so a host can explain the result instead of presenting a single unexplained number.

5.5 Distinguish Failure Shapes

FocusFailureKind classifies the evidence as:

  • .motionBlur when global, subject, and AF detail are all weak and micro-contrast is low;
  • .missedFocus when the frame has usable global detail but the subject is much weaker;
  • .none when neither pattern is established.

These are algorithmic classifications, not final user-facing copy or culling actions.

Scalar scoring answers “how much reliable detail is present?” Mask rendering answers “where should the UI draw visible focused edges?” They share evidence but have different configuration needs.

FocusEvidence records the winning region, AF scores, selected patch rankings, spatial alignment, dominance, silhouette handling, visual threshold, coverage, and confidence diagnostics. FocusPatchRanking exposes the detail, coverage, shape, position, and penalty components behind each candidate patch.

The overlay pipeline:

  1. chooses AF-center, AF-neighborhood, AF, saliency, mixed, or global evidence;
  2. builds primary and, for AF regions, fine-detail Laplacians;
  3. ranks local patches and selects the best evidence patches;
  4. chooses an adaptive percentile threshold;
  5. optionally relaxes the threshold to guarantee minimum visible coverage;
  6. applies erosion and dilation to the binary edge mask;
  7. colorizes, clips, feathers, and crops the mask;
  8. returns updated evidence and render diagnostics with the image.

FocusMaskRegionSource describes whether saliency, AF, both, or neither provided the overlay region. FocusEvidenceOverlayStyle distinguishes subject edges from global edges. RawCull decides how these neutral values appear in its interface.

7. Configuration, Presets, And Cache Identity

SharpnessConfiguration is a value snapshot containing numeric algorithm settings. It separates host-editable behavior from observable settings models.

Notable groups are:

Configuration groupExamples
Edge pipelinepreBlurRadius, threshold, energyMultiplier
Mask morphologydilationRadius, erosionRadius, featherRadius
VisibilityguaranteeVisibleFocusEvidence, minimumEvidenceCoverage
Region selectionAF radii, border inset, saliency weight
Scoring adjustmentssubject-size factor, silhouette strength, fine-detail weight
Capture hintsiso, apertureHint

SharpnessPreset applies high-level subject tuning for automatic, birds and wildlife, portrait, landscape, or general action use. SharpnessQuality selects fast, balanced, or high-precision fine-detail work. The host may persist its own UI enums, but should map them into these package values instead of duplicating constants.

Persisted results need more than a filename. SharpnessAnalysisDescriptor records:

  • descriptor schema version;
  • scalar algorithm version;
  • ISO-scaling policy version;
  • aperture-hint policy version;
  • every host-configurable value that affects non-mask scoring;
  • the stable scoring energy multiplier.

The descriptor deliberately excludes per-image ISO and aperture, mask-only presentation settings, decoded dimensions, source choice, and source-file identity. A host must add those values to its cache identity.

Host sharpness cache identity
├── PhotoAnalysisKit SharpnessAnalysisDescriptor
├── per-image ISO and aperture
├── source file identity
├── selected preview/decode policy
└── decoded pixel dimensions

8. Bounded Batch Analysis

PhotoAnalysisBatchRequest<Identifier> contains an identifier and an async input provider. The provider inversion is important: PhotoAnalysisKit coordinates work, while the host retains file access and decoding policy.

analyzeBatch starts only up to maximumConcurrentTasks child tasks. When one completes, it enqueues one more request. This prevents a large catalog from creating an unbounded number of decoded bitmaps.

The API has three ordering and failure guarantees:

  • progress is emitted in completion order;
  • the returned results preserve request order;
  • a decode failure is represented by a result whose analysis is nil.

If the parent task is cancelled, the method cancels the group and returns nil, telling the host to discard partial results rather than mistake them for a complete batch.

9. Calibration Changes The Overlay, Not The Score

Calibration samples Laplacian energies from host-provided decoded inputs with bounded concurrency. It downsamples the collected sample set, sorts it, and returns p50, p90, p95, and p99 statistics plus a clamped threshold at the requested percentile.

At least minimumSuccessfulImages inputs must succeed. Cancellation or too few samples returns nil.

Calibration changes only the visual edge threshold. Core sharpness scores keep a stable gain and do not depend on the current catalog. This prevents the same photo from receiving a different scalar score merely because unrelated photos were added or removed.

10. Vision Feature Prints Stay Opaque

VisionFeaturePrintBackend is an actor that creates a VNFeaturePrintObservation using Vision revision 2 by default. The observation is securely archived inside VisionFeaturePrint.payload.

The value also stores its Vision revision and representation version. Before comparison, the backend verifies both values, securely unarchives each observation, and calls Vision’s native computeDistance.

The host can persist the opaque payload, but it must associate it with source-file identity. Incompatible prints return nil; corrupt archives or failed Vision operations throw a typed VisionFeaturePrintError.

PhotoAnalysisKit intentionally does not copy PhotoAIKit’s general similarity artifact descriptors, CLIP fallback, or batch indexing. It supplies only the focused Vision measurement primitive.

11. Metal Resources Are Part Of The Algorithm

Sources/PhotoAnalysisKit/Resources/Kernels.ci.metal is the source for the Core Image kernel. SwiftPM copies Metal source resources but does not compile them for command-line builds, so the package also checks in default.metallib.

Tools/build_metallib.sh regenerates the binary. A kernel change is incomplete until the checked-in library has been rebuilt and tests have been run. The binary resource affects algorithm output and must be treated like source, not an optional deployment file.

12. Concurrency And Cancellation

The package uses four complementary techniques:

  • Immutable facade and engine: callers pass input and configuration snapshots; no app state is retained.
  • Explicit concurrent workers: synchronous Core Image and Vision work runs outside caller UI isolation while retaining task priority and task-local context.
  • Bounded task groups: batch analysis and calibration cap simultaneous inputs.
  • Cooperative checks: expensive stages test cancellation before Vision work, rendering, large loops, and result publication.

@unchecked Sendable is confined to the immutable Core Image facade and engine. Public transport values are Sendable value types.

13. Testing The Public Contract

Tests/PhotoAnalysisKitTests/ imports the library through import PhotoAnalysisKit, not @testable import. Tests therefore exercise the public surface used by a host.

The suite covers:

  • end-to-end sharpness analysis with the packaged Metal kernel;
  • focus-mask images, evidence, and diagnostics;
  • calibration from decoded images and neutral metadata;
  • bounded concurrency, order preservation, progress, decode failure, and cancellation;
  • descriptor encoding and changes in identity-affecting settings;
  • robust-tail, micro-contrast, ISO scaling, and focus-failure metrics;
  • configuration presets and aperture behavior;
  • Vision feature-print compatibility, round trips, and malformed payloads.

Synthetic images keep the suite deterministic and independent of RAW files, model downloads, cache directories, and application state.

14. How To Add Another Analysis

Use the existing boundary as a checklist:

  1. Accept CGImage and only the neutral metadata the algorithm needs.
  2. Keep URLs, camera decoding, settings state, and source selection in the host.
  3. Return a Sendable package-owned result with enough evidence to explain it.
  4. Put numeric tuning in a value configuration, not UI preferences.
  5. Define a versioned descriptor when hosts may persist the output.
  6. Make synchronous framework work cancellation-aware and independent of UI isolation.
  7. Bound multi-image work instead of spawning one live task per catalog item.
  8. Test through the public API with synthetic images and values.

If the proposed API needs FileItem, SwiftUI, a cache directory, or a rating, that concern belongs in RawCull’s adapter or policy layer.

Source Map

TopicPhotoAnalysisKit source
Product and resource declarationPackage.swift
Input and result boundarySources/PhotoAnalysisKit/PhotoAnalysisInput.swift
Public analysis facade and metricsSources/PhotoAnalysisKit/PhotoAnalyzer.swift
Batch orchestrationSources/PhotoAnalysisKit/PhotoAnalysisBatch.swift
Configuration and presetsSources/PhotoAnalysisKit/SharpnessConfiguration.swift, SharpnessPresets.swift
Cache descriptorSources/PhotoAnalysisKit/SharpnessAnalysisDescriptor.swift
Public evidence valuesSources/PhotoAnalysisKit/FocusMaskTypes.swift
Engine isolationSources/PhotoAnalysisKit/FocusMaskEngine.swift
Saliency and scalar scoringSources/PhotoAnalysisKit/FocusMaskEngine+Scoring.swift
Overlay generation and patch rankingSources/PhotoAnalysisKit/FocusMaskEngine+MaskGeneration.swift
CalibrationSources/PhotoAnalysisKit/FocusMaskCalibration.swift
Vision feature printsSources/PhotoAnalysisKit/VisionFeaturePrintBackend.swift
Metal source and compiled resourceSources/PhotoAnalysisKit/Resources/
Extraction decisionsDocumentation/ExtractionMap.md
Public behavior testsTests/PhotoAnalysisKitTests/
RawCull decode and result adapterRawCull/Model/ViewModels/FocusandSharpness/RawCullPhotoAnalysisAdapter.swift

Next, see how package-neutral measurements become culling-domain decisions in How RawCullCore Is Constructed.

9.3 - How RawCullCore Is Constructed

A detailed guide to RawCullCore’s package-safe models, capture-time and EV-aware burst grouping, ranking evidence, confidence rules, histograms, concurrency, and tests.

How RawCullCore Is Constructed

RawCullCore is the small domain layer at the center of RawCull. It does not decode or analyze photos. Instead, it receives file metadata and measurements already produced elsewhere, groups sequential files into bursts, and ranks the candidates using deterministic culling rules.

Its main architectural value is that a recommendation can be tested without opening a RAW file, running Vision, creating a Metal context, loading application settings, or constructing a view model.

1. Begin With The Package Boundary

RawCullCore owns:

  • package-safe file, EXIF, source-catalog, and saliency values;
  • normalized focus-point parsing;
  • burst group, boundary-evidence, ranking, confidence, and review-state models;
  • pure burst grouping rules;
  • pure burst ranking and one-click-safety rules;
  • a lightweight 256-bin luminance histogram.

RawCullCore deliberately does not own:

  • TIFF, MakerNote, ARW, or NEF parsing;
  • thumbnail, preview, or full RAW decoding;
  • Vision, Core Image, Metal, CLIP, or SAM 3 analysis;
  • caches, settings, security-scoped URLs, and saved-file coordination;
  • observable view models, SwiftUI, selection, sorting, and culling actions.

This produces a clear flow:

flowchart LR
    Parser["RawParserKit\nmetadata + AF location"] --> Adapter["RawCull adapter"]
    Analysis["PhotoAnalysisKit\nsharpness + saliency"] --> Adapter
    AI["PhotoAIKit\nvisual distance"] --> Adapter
    Adapter --> Values["RawCullCore values"]
    Values --> Group["BurstGroupingEngine"]
    Group --> Rank["BurstRankingEngine"]
    Rank --> Decision["RawCull presentation and action"]

The arrows describe how RawCull composes runtime values. RawCullCore imports none of the other packages.

2. The Manifest Optimizes For A Small Domain Library

Package.swift declares Swift tools 6.2, Swift 6 language mode, macOS 26, one RawCullCore library target, and one test target. It has no external package dependencies and no bundled resources.

The target uses:

.defaultIsolation(MainActor.self)
.enableUpcomingFeature("InferIsolatedConformances")
.enableUpcomingFeature("NonisolatedNonsendingByDefault")

Default main-actor isolation matches the surrounding application environment, but the package’s public value types and pure engines are explicitly nonisolated. Background grouping and ranking therefore do not acquire unnecessary main-actor hops.

This is a useful distinction: the build default is conservative, while each proven pure API opts out explicitly.

3. Package-Safe Models Replace Application State

The package uses transport values instead of importing RawCull’s FileItem or view models.

3.1 ExifMetadata

ExifMetadata is a Codable, Hashable, and Sendable snapshot containing display strings and numeric values for shutter speed, focal length, aperture, ISO, exposure compensation, camera, lens, RAW type, size class, and pixel dimensions.

It contains both strings and numbers because they serve different purposes:

  • strings preserve display-ready metadata such as shutter notation and lens names;
  • numeric exposure time, focal length, aperture, ISO, and exposure compensation support deterministic stop-based rules without reparsing display text.

RawCull or an adapter constructs this value from RawParserKit’s RawImageMetadata. RawCullCore does not know where the values came from.

3.2 RawCullFileItem

RawCullFileItem contains an ID, URL, name, byte size, modification date, optional capture date and capture-time-zone offset, optional EXIF snapshot, and optional normalized AF point.

Equality and hashing use only id. Two snapshots with the same ID are the same logical file item even if another stored field changed. This matches the identity behavior expected by RawCull collections.

effectiveCaptureDate prefers the EXIF capture instant and falls back to the file modification date. usesFileModificationDateForCaptureTime exposes which path was used so grouping and confidence can treat the fallback conservatively. formattedSize is a lightweight Foundation convenience. File access and mutable scan state remain outside the value.

3.3 Catalog And Saliency Summaries

RawCullSourceCatalog identifies a named source URL without adding bookmark or access-lifecycle behavior.

SaliencyInfo stores only an optional subject label and confidence. It deliberately does not import Vision or expose a Vision observation. A PhotoAnalysisKit result can be translated into this small culling-domain value at the host boundary.

4. Focus-Point Parsing Connects Vendor Metadata To Analysis

RawParserKit’s vendor parsers return a focus string in this shape:

imageWidth imageHeight focusX focusY

FocusPointParser.normalizedPoint(from:) splits on arbitrary whitespace, requires exactly four numeric values and positive image dimensions, and returns:

x = focusX / imageWidth
y = focusY / imageHeight

The origin remains at the visual top-left. PhotoAnalysisKit accepts that convention and performs its own Vision coordinate conversion.

The parser does not clamp the returned point. Vendor parsers and host validation are expected to supply meaningful sensor coordinates. Malformed input or non-positive dimensions return nil.

5. Burst Models Preserve Evidence, Not Just A Winner

BurstAnalysisModels.swift defines the values exchanged by the two engines.

BurstGroupingOutput
├── groups: [BurstGroup]
└── boundaryEvidence: [BurstBoundaryEvidence]

BurstAnalysisResult
├── ordered candidate scores
├── recommended and second-best IDs
├── confidence and review state
├── one-click safety flag
├── reasons
└── cautions

This design avoids throwing away intermediate decisions. RawCull can show why a boundary was created, why a file won, and why automation is or is not considered safe.

BurstGroupingConfig.algorithmVersion is currently 4 and gives hosts a version marker for persisted grouping output. Its configurable thresholds cover visual distance, EXIF and fallback time gaps, camera equality, focal-length similarity, and maximum shutter, aperture, ISO, and exposure-compensation changes in EV. Custom decoding supplies defaults for fields that are absent from older saved configurations.

BurstReviewState keeps historical states for cache compatibility. Unknown decoded raw values fall back to .none. BurstWinnerOverride also migrates older data by generating a missing ID and defaulting missing member filenames to an empty array.

6. BurstGroupingEngine: Decide Where A Burst Splits

The grouping engine expects files in shot order. It starts with the first file and evaluates each adjacent pair.

flowchart TD
    Pair["Previous + current file"] --> Visual{"Visual distance present\nand below threshold?"}
    Visual -- No --> Split["Start new group"]
    Visual -- Yes --> Time{"Time gap allowed?"}
    Time -- No --> Split
    Time -- Yes --> Camera{"Required camera same?"}
    Camera -- No --> Split
    Camera -- Yes --> Focal{"Required focal delta allowed?"}
    Focal -- No --> Split
    Focal -- Yes --> Exposure{"Exposure stable?"}
    Exposure -- No --> Split
    Exposure -- Yes --> Continue["Append to current group"]

A new group begins when any enabled boundary rule fires:

  • similarity evidence is missing;
  • visual distance is greater than or equal to visualDistanceThreshold;
  • the absolute capture gap exceeds maxTimeGapSeconds, or maxFallbackTimeGapSeconds when either file lacks a parsed EXIF capture instant;
  • camera identity changes when requireSameCamera is enabled;
  • numeric or parsed focal length changes by more than maxFocalLengthDeltaMM when similarity is required;
  • shutter speed, aperture, ISO, or exposure compensation changes by more than its configured EV threshold.

The default EXIF capture gap is 2 seconds; the default file-date fallback gap is 10 seconds. Each boundary records captureTimeUsedFallback, allowing later ranking to distinguish precise camera time from filesystem time.

Exposure comparison works in photographic stops:

shutter delta  = |log2(current seconds / previous seconds)|
aperture delta = 2 × |log2(current f-number / previous f-number)|
ISO delta      = |log2(current ISO / previous ISO)|
compensation   = |current EV - previous EV|

The defaults are 0.5 EV for shutter, aperture, and ISO and 0.34 EV for exposure compensation. The engine prefers numeric values. When a numeric shutter, aperture, or ISO comparison is unavailable but both normalized display strings are present and differ, it still treats the exposure as changed. The largest available adjustment is retained as exposureAdjustmentEV.

Focal length similarly prefers focalLengthMM and falls back to the first number in the display string. If either side has no usable value, that rule has no delta to evaluate.

Lens changes are recorded in BurstBoundaryEvidence, but do not by themselves split a group. They later make the group’s ranking metadata unstable. Keeping evidence separate from the grouping decision makes this behavior visible rather than implicit.

Missing similarity is conservative: it creates a boundary instead of assuming two files belong together. BurstPairKey.cacheKey standardizes the ordered adjacent-pair key used to supply those distances.

Each boundary record stores the measured values, boolean changes, final decision, and human-readable reasons. The output assigns stable sequential group IDs beginning at zero for that run.

7. BurstRankingEngine: Turn Evidence Into A Recommendation

Ranking consumes groups, files keyed by ID, sharpness scores, a score normalization maximum, saliency summaries, boundary evidence, and optional review states.

7.1 Determine Group Conditions

For each group, the engine derives:

  • metadata stability: none of its internal boundaries report exposure, camera, or lens changes;
  • capture-time reliability: every member has a parsed EXIF capture date rather than the modification-date fallback;
  • tight similarity: every internal boundary has a visual distance below 0.22;
  • dominant subject: the most frequent non-nil saliency label;
  • burst-relative sharpness: a within-group 0...1 normalization when at least two valid scores exist and their normalized spread is at least 0.03.

The configured grouping threshold decides membership; the fixed tighter 0.22 check contributes to ranking confidence. They answer different questions.

7.2 Score Each Candidate

If burst-relative sharpness is available, the ranking sharpness component is:

ranking sharpness = 0.65 × catalog-normalized sharpness
                  + 0.35 × burst-relative sharpness

The final candidate score is:

overall = 0.62 × ranking sharpness
        + 0.12 × focus-point evidence
        + 0.10 × saliency consistency
        + 0.16 × metadata evidence

The supporting components are deliberately simple and inspectable:

ComponentRule
Focus point0.70 when AF metadata exists, otherwise 0.45
Saliency0.75 for the dominant label, 0.25 for a different label, 0.45 when absent
Metadata base0.70 when stable, otherwise 0.40
Tight-similarity adjustment+0.15
ISO adjustmentAbove ISO 1600, -0.05 per stop, capped at -0.15
Wide-aperture adjustment+0.05 at f/5.6 or wider
Motion-risk adjustment+0.05 for a clearly fast shutter; up to -0.15 for a slower shutter

Every component is clamped or normalized into a predictable range before use. Candidates are sorted by descending overall score; equal scores preserve the group’s original shot order.

Motion risk uses exposureTimeSeconds with focalLengthMM when both are available. A shutter at least twice as fast as the reciprocal focal-length rule gets the positive adjustment; a shutter slower than the reciprocal rule gets a stop-based penalty. Without focal length, 1/500 second or faster is treated as lower risk and 1/60 second or slower as elevated risk.

Each candidate also carries reasons and cautions such as measured sharpness, available AF evidence, classified subject, missing sharpness, changed metadata, fast or slow shutter behavior, and high ISO.

7.3 Assign Confidence Separately

Winning a group does not automatically mean a decision is safe to automate.

High confidence requires:

  • at least one sharpness score;
  • at least three files in the group;
  • a best-versus-second score gap of at least 0.12;
  • best absolute sharpness of at least 0.65 after normalization;
  • stable metadata;
  • tight visual similarity;
  • parsed capture times for every member.

Medium confidence requires a gap of at least 0.05 and stable metadata. Other results are low confidence.

isSafeForOneClickCulling is true only for high confidence. canApplyOneClickCulling(hasSharpnessScores:) adds an explicit host-supplied confirmation that sharpness data is available before an action is enabled. RawCull still owns the action itself.

Reasons and cautions on BurstAnalysisResult are capped to three each so the result remains concise enough for inspection UI and persistence.

A modification-date fallback therefore does not prevent grouping, but it does prevent high-confidence automation and adds a capture-time caution.

8. Histogram Calculation Is Intentionally Lightweight

HistogramCalculator.normalizedLuminanceHistogram(from:) directly reads an 8-bit RGB or RGBA-style CGImage buffer. Each pixel is placed in one of 256 bins using Rec. 601 luminance:

Y = 0.299R + 0.587G + 0.114B

The bins are normalized by the largest bin count, so the peak has value 1. This is shape normalization, not probability normalization; the bins do not necessarily sum to 1.

The implementation requires positive dimensions, provider data, 8 bits per component, and at least three bytes per pixel. Unsupported input returns a zero-filled 256-bin array, preserving a stable return shape for views and callers.

The helper is appropriate for display and lightweight comparisons. Color conversion, RAW development, high-bit-depth analysis, and channel-layout generalization are outside this package.

9. Concurrency Is Achieved Through Purity

RawCullCore contains no actor because it owns no shared mutable runtime state. Models are Sendable values and engines are namespaces of nonisolated static functions.

The package performs no asynchronous I/O. Callers do expensive decoding, Vision work, embeddings, and sharpness analysis on the appropriate tasks, then pass snapshots into RawCullCore.

This design has several practical benefits:

  • grouping and ranking are deterministic for the same inputs;
  • no application singleton can change a result midway through a call;
  • tests do not require async setup or framework assets;
  • background work does not cross the main actor merely because the host uses main-actor default isolation.

10. Testing Rules And Migrations

Tests/RawCullCoreTests/ uses Swift Testing and synthetic values. Coverage includes:

  • model coding, hashing, identity, and source-catalog values;
  • valid, decimal, whitespace-varied, and malformed focus strings;
  • empty and stable groups;
  • boundaries caused by missing distance, time, camera, focal length, and exposure;
  • EXIF capture ordering, time-zone offsets, modification-date fallback, and the different fallback gap;
  • numeric and display-string exposure comparisons in photographic stops;
  • boundary evidence content;
  • absolute and burst-relative sharpness ranking, motion risk, and progressive ISO penalties;
  • missing scores, stable tie order, confidence, review-state propagation, and one-click eligibility;
  • RGB and RGBA histogram bins, peak normalization, and unsupported input.

No test needs an actual catalog, RAW file, Vision model, Metal device, cache directory, or settings store. That is the strongest evidence that the package boundary is doing useful work.

11. How To Extend Culling Logic

Use these constraints when adding a rule:

  1. Pass the required fact into a Sendable package value; do not reach into a view model.
  2. Preserve raw evidence separately from the final boolean or winner.
  3. Keep scoring weights and confidence thresholds deterministic and testable.
  4. Decide whether a rule affects group membership, candidate ranking, confidence, or only a caution.
  5. Version persisted behavior when a changed rule can invalidate cached results.
  6. Maintain decoding fallbacks for existing review and override data.
  7. Leave framework observations and heavy image processing in the producing package.
  8. Leave user actions and presentation state in RawCull.

Source Map

TopicRawCullCore source
Product and concurrency settingsPackage.swift
EXIF transport valueSources/RawCullCore/ExifMetadata.swift
File and source identitySources/RawCullCore/RawCullFileItem.swift, RawCullSourceCatalog.swift
Saliency summarySources/RawCullCore/SaliencyInfo.swift
Focus normalizationSources/RawCullCore/FocusPointParser.swift
Burst contracts and migrationsSources/RawCullCore/BurstAnalysisModels.swift
Boundary decisionsSources/RawCullCore/BurstGroupingEngine.swift
Ranking and confidenceSources/RawCullCore/BurstRankingEngine.swift
HistogramSources/RawCullCore/HistogramCalculator.swift
Behavior testsTests/RawCullCoreTests/
RawCull metadata adapter and burst orchestrationRawCull/Model/RawImageLoading.swift, RawCull/Model/ViewModels/RawCullViewModel+BurstGrouping.swift

Return to the package overview in RawCull Packages, or continue with the file-decoding layer in How RawParserKit Is Constructed.

9.4 - How RawParserKit Is Constructed

A detailed guide to RawParserKit’s vendor dispatch, TIFF and MakerNote parsing, embedded previews, structured capture and exposure metadata, orientation, decode limiting, cancellation, compatibility APIs, and tests.

How RawParserKit Is Constructed

RawParserKit is RawCull’s camera-file boundary. It knows how Sony ARW and Nikon NEF files are structured, how to locate their embedded JPEGs and AF metadata, and how to turn those sources into orientation-normalized images and display-ready metadata.

The package stops at decoding. It does not score sharpness, generate embeddings, group bursts, cache application results, or decide which photo should be kept.

1. Begin With The Package Boundary

RawParserKit owns:

  • vendor-neutral RAW format dispatch;
  • Sony ARW and Nikon NEF format knowledge;
  • TIFF IFD and vendor MakerNote traversal;
  • focus-location and embedded-JPEG offset parsing;
  • RAW and rendered-image thumbnails and previews;
  • source-orientation normalization;
  • display-ready and numeric EXIF/RAW metadata extraction, including a structured capture instant and time-zone offset;
  • Sony full sensor development to JPEG;
  • decode task deduplication, concurrency limiting, and cooperative cancellation;
  • diagnostics and staged compatibility APIs.

The host application owns:

  • security-scoped URL access and sandbox bookmarks;
  • directory discovery, catalogs, and source selection;
  • memory and disk cache locations and eviction policy;
  • image-analysis inputs and result identity;
  • observable loading state, placeholders, retries, and presentation;
  • ratings, burst grouping, ranking, and saved-file behavior.

RawParserKit supplies inputs to higher layers without importing them:

flowchart LR
    File["ARW, NEF, JPEG, PNG, or TIFF"] --> Parser["RawParserKit"]
    Parser --> Image["CGImage or NSImage"]
    Parser --> Metadata["RawImageMetadata + RawFocusPoint"]
    Image --> Host["RawCull adapters"]
    Metadata --> Host
    Host --> Analysis["PhotoAnalysisKit / PhotoAIKit"]
    Host --> Core["RawCullCore"]

2. Package Shape And Framework Boundary

Package.swift declares Swift tools 6.2, Swift 6 language mode, macOS 26, one library product, and one test target. There are no third-party package dependencies.

The implementation uses Apple frameworks appropriate to file decoding: Foundation, AppKit, Core Graphics, ImageIO, Core Image, OSLog, and synchronization primitives from os.

Like RawCullCore, the target enables main-actor default isolation plus InferIsolatedConformances and NonisolatedNonsendingByDefault. Pure parsing and static format APIs opt out with nonisolated; the stateful loader and limiter use actors.

The source is arranged in layers:

RawImageLoader                         high-level deduplicated facade
├── RawFormatRegistry + RawFormat      vendor-neutral dispatch
│   ├── SonyRawFormat
│   └── NikonRawFormat
├── thumbnail and preview extractors   ImageIO + binary fallback
├── MakerNote parsers                  TIFF byte traversal
├── OrientationNormalizedImageLoader   rendered/embedded image helpers
└── cancellation + decode limiter      concurrency control

Callers can use the facade for normal browser behavior or a lower layer when they need explicit control.

3. RawFormat Makes Vendor Dispatch Explicit

Sources/RawParserKit/RawFormat.swift describes the static capabilities every camera format supplies:

  • supported filename extensions and a display name;
  • thumbnail extraction;
  • embedded-preview extraction;
  • AF focus-location parsing;
  • a readable label for compression codes;
  • camera-specific megapixel thresholds for S, M, and L size classes.

Conformers are stateless enums. RawFormatRegistry.all currently registers:

ConformerExtensionVendor responsibilities
SonyRawFormat.arwSony extractors, MakerNote parser, compression labels, body thresholds, and full sensor JPEG creation
NikonRawFormat.nefNikon extractors, MakerNote parser, compression labels, and body thresholds

format(for:) lowercases a URL’s extension and returns a format metatype. Callers invoke static protocol requirements on that value without switching on brands.

The default rawSizeClass implementation converts dimensions to megapixels, obtains body-specific L and M thresholds, and returns L, M, or S. Unknown bodies use a generic fallback supplied by the conformer.

This is a simple plugin architecture inside the package. Adding a camera brand means adding a conformer and registering it, not adding vendor switches throughout the loader.

4. RawImageLoader Is The High-Level Facade

RawImageLoader.shared is an actor. It offers four current operations:

APIResult
thumbnail(for:maxPixelSize:)An NSImage suitable for grids and browsers
thumbnailCGImage(for:maxPixelSize:)The same thumbnail as CGImage
previewImage(for:)A larger sidecar or embedded preview
metadata(for:)A neutral RawImageMetadata snapshot

4.1 Deduplicate Equivalent Work

The actor stores in-flight tasks:

  • thumbnails keyed by URL and requested pixel size;
  • previews keyed by URL;
  • metadata keyed by URL.

If another caller requests the same work while it is running, both await the existing task. The entry is removed after completion. This prevents rapid view updates from decoding the same large image repeatedly.

4.2 Bound Expensive Decodes

The facade uses two DecodeConcurrencyLimiter actors:

  • up to six concurrent thumbnail decodes;
  • up to two concurrent full-size preview decodes.

These limits control memory as much as CPU. A few full-resolution bitmaps can consume substantially more memory than the compressed RAW files that produced them.

4.3 Follow The Thumbnail Strategy

For a rendered JPEG, PNG, or TIFF, the loader asks ImageIO for an orientation-aware thumbnail. For RAW input, it tries an embedded ImageIO thumbnail first and then dispatches to the registered vendor extractor. The vendor result is orientation-normalized before it becomes an NSImage.

4.4 Follow The Preview Strategy

The preview path checks for a same-basename .jpg sidecar first. If absent, it tries an orientation-aware embedded preview and then the registered format’s embedded-preview extractor. Vendor output is normalized using the source orientation.

The sidecar-first decision is host-facing convenience, not RAW parsing. A caller that requires only bytes physically embedded in the RAW can call the format or extractor API directly.

5. Metadata Is Normalized Into A Neutral Snapshot

RawImageMetadata contains optional display and numeric values for camera, lens, shutter speed, exposure time in seconds, aperture, focal length in millimeters, ISO, exposure compensation in EV, capture time, dimensions, focus point, RAW compression label, size class, and pixel dimensions.

RawImageLoader.metadata(for:) combines several sources:

  1. ImageIO properties from the source or a sidecar fallback;
  2. TIFF make, model, compression, and display-date fields;
  3. EXIF exposure time, aperture, focal length, ISO, exposure bias, lens, and dimensions;
  4. EXIF DateTimeOriginal, SubsecTimeOriginal, and OffsetTimeOriginal;
  5. the registered vendor MakerNote parser for an AF point;
  6. EXIF subject-area coordinates when vendor focus data is unavailable;
  7. vendor-specific compression labels and size-class thresholds.

captureDate parses the original capture timestamp as a real Date, preserving variable-length fractional seconds. OffsetTimeOriginal accepts Z, +HH:MM, -HH:MM, and compact +HHMM/-HHMM forms; the parsed offset is also retained as captureTimeZoneOffsetSeconds. When the EXIF offset is missing, parsing uses the current time zone. A missing or invalid DateTimeOriginal leaves captureDate nil rather than substituting the TIFF display date.

capturedAt remains a display string and can fall back to TIFF DateTime. Keeping it separate from captureDate prevents formatted UI text from becoming burst-ordering evidence.

rows returns non-empty display label/value pairs, while isEmpty lets the facade omit an empty metadata object. Numeric exposure time, aperture, focal length, ISO, exposure compensation, width, and height remain available so hosts do not have to parse formatted strings for analysis or domain rules.

RawFocusPoint stores normalized X and Y coordinates. Its failable focus-string initializer accepts the common "width height x y" shape, checks positive dimensions, and rejects coordinates outside 0...1.

RawCull’s RawParserKitImageLoader maps this parser-owned snapshot into its own ExifMetadata and RawCullFileItem values. ScanFiles carries the parsed capture instant and offset into the catalog; RawCullCore can then prefer camera time and explicitly detect a modification-date fallback. The similar types exist on purpose: each package owns the vocabulary at its boundary and neither must depend on the other.

6. Thumbnail Extraction Prefers Embedded Work

Thumbnails should not require full RAW development when a camera already stored a usable JPEG.

6.1 Sony

SonyThumbnailExtractor first asks SonyMakerNoteParser for embedded JPEG locations and decodes a selected JPEG directly. This bypasses macOS RAW decoder failures seen with newer ARW layouts. If the binary path cannot locate a JPEG, it falls back to ImageIO’s embedded-thumbnail behavior.

6.2 Nikon

NikonThumbnailExtractor asks ImageIO for a transformed embedded thumbnail. Both vendor extractors then redraw into an 8-bit premultiplied sRGB bitmap using interpolation quality derived from qualityCost.

Both APIs run their synchronous ImageIO work through CancellableImageIOWork and throw ThumbnailError for an invalid source, failed generation, or failed bitmap context.

ThumbnailSharpener is an optional lower-level Core Image helper for producing a sharpened preview at a requested maximum dimension. The high-level boundary does not force sharpening on every caller.

7. Embedded Preview Extraction Has Two Paths

The two vendors use the same building blocks in a different order. SonyEmbeddedJPEGExtractor prefers the binary TIFF locator, avoiding RAW decoder initialization on affected ARW files, then falls back to ImageIO. NikonEmbeddedJPEGExtractor inspects ImageIO sub-images first and uses the binary locator when the preview is not exposed there.

flowchart TD
    Source["RAW URL"] --> ImageIO["Inspect ImageIO image indexes"]
    Source --> Parser["Vendor TIFF parser"]
    ImageIO -->|usable JPEG| Decode["Decode or downsample"]
    Parser --> Offset["Absolute JPEG offset + length"]
    Offset --> Bytes["Read JPEG bytes"]
    Bytes --> Decode
    Decode --> Result["CGImage"]

fullSize: true permits a longest edge up to 8640 pixels. The normal preview path limits large images to 4320 pixels.

The Nikon fallback prefers the full-resolution SubIFD preview for full-size requests and IFD1 for smaller requests. Sony chooses the largest available JPEG first, with preview and thumbnail fallbacks.

Extractor-level limiters default to two concurrent operations, and a caller can inject a shared limiter. This allows a host facade to enforce one budget across several decode paths instead of accidentally stacking independent limits.

8. Sony Full-Size JPEG Creation Is Different From Extraction

SonyRawFormat.createFullSizeJPEG(from:quality:) does not return a camera-embedded preview. SonyRAWJPEGCreator develops the ARW sensor data through macOS CIRAWFilter, renders it into sRGB, and encodes JPEG data.

Quality must be in 0...1. The operation can fail with:

  • invalidQuality;
  • unsupportedOrInvalidRAW when the installed macOS RAW decoder cannot develop that file;
  • encodingFailed.

This distinction matters in UI and caching. An embedded preview and a developed sensor image have different cost, pixels, appearance, and invalidation semantics even if both are JPEG-encoded at the end.

9. Sony MakerNote And TIFF Parsing

Sony ARW is TIFF-based. Focus parsing follows this structure:

TIFF IFD0
└── ExifIFD tag 0x8769
    └── MakerNote tag 0x927C
        └── Sony MakerNote IFD
            └── FocusLocation tag 0x2027
                (fallback: 0x204A)

The focus tag contains four unsigned 16-bit values: image width, image height, X, and Y. Sony MakerNote IFD offsets are interpreted as absolute file offsets.

The production parser uses a fast path and a fallback:

  • focus location reads the first 4 MB, then retries with the full file when necessary;
  • embedded-JPEG discovery reads the first 512 KB, then retries the full file when no locations were found.

The embedded locator walks TIFF IFDs and returns optional absolute locations for a small thumbnail, preview, and full JPEG. readEmbeddedJPEGData seeks directly to a validated location and reads its byte range.

The fast path keeps normal scans inexpensive; the full-file fallback supports bodies that place relevant TIFF structures near the end of the file.

10. Nikon MakerNote And TIFF Parsing

Nikon NEF is also TIFF-based, but modern Nikon Type-3 MakerNotes contain their own TIFF header:

TIFF IFD0
└── ExifIFD tag 0x8769
    └── MakerNote tag 0x927C
        ├── "Nikon\0" signature + version
        └── inner TIFF header
            └── Nikon IFD
                └── AFInfo2 tag 0x00B7

Offsets in the inner TIFF are relative to the MakerNote TIFF-header base, not the start of the NEF file. Keeping this offset rule inside the Nikon parser prevents a generic loader from acquiring vendor-specific exceptions.

For supported modern AFInfo2 layouts, the parser reads AF image dimensions, area position, and area size, and returns the same "width height x y" shape as Sony. The public shape lets all downstream code use one focus-point adapter.

Nikon embedded preview discovery examines Compression=6 SubIFDs referenced from IFD0 and the IFD1 JPEG interchange fields. It returns optional locations for the largest preview and IFD1 JPEG.

As on Sony, focus parsing starts with 4 MB and falls back to the full file. Embedded-location parsing uses a 1 MB fast path followed by a full-file retry when required.

11. Diagnostics Report The Failed Stage

The ordinary parser APIs return optionals because missing or unsupported MakerNote data is expected during normal browsing.

For troubleshooting, each vendor also exposes diagnostic forms for focus and embedded-JPEG lookup. RawParserDiagnostics<Value> contains:

  • the optional parsed value;
  • an ordered trace of stages and offsets checked;
  • an optional final failure explanation.

This keeps logging policy outside the binary parser while allowing RawCull’s diagnostics UI to show whether failure occurred at file access, TIFF validation, IFD lookup, MakerNote traversal, tag interpretation, or fallback.

12. Orientation Helpers Normalize Visual Coordinates

OrientationNormalizedImageLoader provides lower-level operations for rendered URLs, encoded JPEG data, source thumbnails, embedded thumbnails, and embedded previews.

ImageIO’s transform option is used when available. The loader also implements the eight EXIF orientation transforms, including mirrored and transposed cases, and can read orientation from the RAW source when decoding separately extracted JPEG data.

SupportedFileType enumerates .arw, .nef, .jpeg, .jpg, .png, .tif, and .tiff. Its rendered-image set distinguishes sources that can be loaded directly from formats that need RAW dispatch.

Orientation is part of the parser boundary because AF coordinates and subject analysis must refer to the same visual image the user sees.

13. Cancellation And Decode Limiting Solve Different Problems

CancellableImageIOWork bridges a synchronous ImageIO closure to async code on a global dispatch queue. It creates an ImageIOCancellationToken and uses a locked state machine to ensure the checked continuation is resumed exactly once, even when cancellation races completion.

Cancellation is cooperative. The token is checked before and after synchronous framework calls and between multi-stage loops. A framework function already executing may not stop internally, but its result is discarded when cancellation is observed.

DecodeConcurrencyLimiter is an actor that solves admission control. It:

  1. grants work immediately while slots are available;
  2. queues additional continuations;
  3. removes and resumes a cancelled waiter;
  4. transfers a released slot directly to the next waiter;
  5. releases the slot with defer when work completes.

Cancellation prevents obsolete work; limiting prevents too much valid work from running simultaneously. The loader needs both.

14. Compatibility APIs Support Staged Migration

The package retains deprecated names such as:

  • BrowserExifInfo and BrowserFocusPoint;
  • thumbnail200px, extractembeddedJPG, and exifInfo;
  • JPGSonyARWExtractor and JPGNikonNEFExtractor;
  • extractFullJPEG on RawFormat.

Each shim forwards to a current neutral name. This lets RawCull migrate call sites without forcing an all-at-once source break, while deprecation warnings make the intended direction visible.

New code should use RawImageMetadata, RawFocusPoint, thumbnail, previewImage, metadata, the embedded-JPEG extractors, and extractEmbeddedPreview.

15. Test Binary Rules Without Shipping Camera Files

Tests/RawParserKitTests/ uses Swift Testing and mostly synthetic TIFF-like byte buffers. The suite covers:

  • registry extension matching and dispatch;
  • Sony focus tags, offset rules, invalid byte-order markers, fallback tags, embedded JPEG locations, and diagnostics;
  • Nikon Type-3 MakerNote and AFInfo2 layouts, SubIFDs, IFD1 JPEGs, offset rules, and diagnostics;
  • direct reading of embedded JPEG bytes;
  • cancellation before decode and cancellation behavior in vendor extractors;
  • decode-limiter capacity;
  • Sony full-size JPEG quality validation and generated JPEG properties;
  • numeric exposure-time, focal-length, aperture, ISO, and exposure-compensation metadata;
  • capture-date offsets, subsecond precision, invalid-date behavior, and offset parsing;
  • current public naming and format-helper behavior.

Synthetic binary fixtures make edge cases reproducible and avoid committing large proprietary ARW and NEF samples. Framework integration is tested with small generated images where needed.

16. How To Add Another Camera Vendor

Use the existing extension points:

  1. Add a stateless RawFormat conformer with its extension and display name.
  2. Implement vendor-specific thumbnail, preview, focus, compression, and size-class behavior.
  3. Put TIFF or MakerNote byte rules in a dedicated parser, including explicit offset bases and bounds checks.
  4. Return the common normalized focus-location string at the public boundary.
  5. Add ImageIO behavior first and a binary embedded-JPEG fallback when the framework does not expose the preview reliably.
  6. Make blocking decode stages cooperative with CancellableImageIOWork.
  7. Register the conformer in RawFormatRegistry.all.
  8. Add synthetic binary tests for endian, offsets, missing tags, corrupt lengths, and diagnostics.
  9. Leave analysis, cache placement, and UI policy in their owning layers.

Source Map

TopicRawParserKit source
Product and concurrency settingsPackage.swift
Format contract and dispatchSources/RawParserKit/RawFormat.swift, RawFormatRegistry.swift
Vendor conformersSources/RawParserKit/SonyRawFormat.swift, NikonRawFormat.swift
High-level facadeSources/RawParserKit/RawImageLoader.swift
Metadata and focus valuesSources/RawParserKit/BrowserExifInfo.swift, BrowserFocusPoint.swift
Sony TIFF and MakerNote parsingSources/RawParserKit/SonyMakerNoteParser.swift
Nikon TIFF and MakerNote parsingSources/RawParserKit/NikonMakerNoteParser.swift
Embedded preview extractionSources/RawParserKit/JPGSonyARWExtractor.swift, JPGNikonNEFExtractor.swift
Thumbnail extractionSources/RawParserKit/SonyThumbnailExtractor.swift, NikonThumbnailExtractor.swift
Full Sony sensor developmentSources/RawParserKit/SonyRAWJPEGCreator.swift
Orientation and rendered filesSources/RawParserKit/OrientationNormalizedImageLoader.swift
Cancellation bridgeSources/RawParserKit/CancellableImageIOWork.swift
Decode admission controlSources/RawParserKit/DecodeConcurrencyLimiter.swift
Parser diagnosticsSources/RawParserKit/RawParserDiagnostics.swift
Behavior and binary-fixture testsTests/RawParserKitTests/
RawCull metadata adapter and catalog scanRawCull/Model/RawImageLoading.swift, RawCull/Actors/ScanFiles.swift

Continue from decoded images into How PhotoAnalysisKit Is Constructed, or return to the package overview.

10 - Artificial Intelligence

Learning guide to PhotoAIKit and RawCull’s AI integration.

Artificial Intelligence in RawCull

This section explains how RawCull uses reusable AI components without allowing model-runtime details to spread through the application. It is written as a learning path: first understand the boundary between RawCull and PhotoAIKit, then study the package itself, and finally follow CLIP from application startup to a persisted similarity artifact.

The source for this section comes from the sibling PhotoAIKit and RawCull projects. Paths beginning with Sources/ refer to PhotoAIKit. Paths beginning with Model/, Main/, Views/, or Actors/ refer to the RawCull app target.

What This Section Documents

DocumentMain questionStart here when
This overviewWhere does AI belong in the system?You need the vocabulary and responsibility split
How RawCull Enables and Uses CLIPHow does the CLIP preference become a running backend?You are tracing model discovery, activation, RAW decoding, inference, fallback, distance calculation, or persistence
How RawCull Download ModelsHow does RawCull download the models

PhotoAIKit currently contains two main AI families:

  • CLIP image embeddings for visual similarity.
  • SAM 3 subject segmentation for subject masks.

It also contains an Apple Vision feature-print backend. In RawCull, Vision is both the always-available similarity implementation and the whole-batch fallback when CLIP indexing fails.

The detailed CLIP document follows code that is connected to RawCull’s similarity and burst-analysis features. The package architecture document also covers the SAM 3 contracts, workflows, and storage so that the complete package design is understandable. Not every reusable package capability is necessarily exposed as a finished RawCull user workflow.

The Central Design Idea

RawCull and PhotoAIKit answer different kinds of questions.

PhotoAIKit asks:

  • What does an image source look like at a package boundary?
  • How is a model bundle validated and identified?
  • How does a backend produce and compare a similarity artifact?
  • How is bounded indexing or segmentation orchestrated?
  • How can reusable artifacts and masks be encoded or cached?

RawCull asks:

  • Where are models installed for this application?
  • How is a Sony or Nikon RAW file decoded for AI input?
  • Which backend did the user request?
  • When should a catalog be indexed or reindexed?
  • How do similarity distances affect burst grouping and culling?
  • What state and wording should SwiftUI present?

This is dependency inversion in practical form. PhotoAIKit defines small protocols such as ImageDecoding, ImageSimilarityArtifactProviding, and ImageSimilarityArtifactComparing. RawCull injects app-specific implementations and keeps its file model, UI, sandbox policy, and culling decisions outside the package.

flowchart LR
    UI["RawCull SwiftUI and settings"] --> Integration["RawCullAIIntegration composition root"]
    Integration --> AppAdapter["RawCull adapters: paths, RAW decoding, policy"]
    AppAdapter --> Contracts["PhotoAIContracts"]
    Integration --> CLIP["CoreAICLIPBackend"]
    Integration --> SAM3["CoreAISAM3Backend"]
    Integration --> Vision["VisionFeaturePrintBackend"]
    AppAdapter --> Workflows["PhotoAIWorkflows"]
    Workflows --> Contracts
    Storage["PhotoAIStorage"] --> Contracts
    CLIP --> Contracts
    SAM3 --> Contracts
    Vision --> Contracts

The arrow direction matters: PhotoAIKit does not import RawCull. A reusable package should not need to know what FileItem, RawCullViewModel, an app-specific model folder, or a burst winner means.

Responsibility Boundary

ConcernOwnerReason
Typed model, image, artifact, and segmentation contractsPhotoAIKitBackends and hosts need one stable language
Core AI CLIP and SAM 3 inferencePhotoAIKit backend productsFramework-specific tensor and inference code is reusable
Vision feature-print generation and native distancePhotoAIKit backend productThe opaque Vision payload stays behind its backend boundary
Bounded indexing, fallback, segmentation, and mask selectionPhotoAIKit workflowsThese algorithms do not depend on RawCull UI or culling policy
Optional embedding codecs and mask storesPhotoAIKit storagePersistence mechanics are reusable, but locations are not
Model installation directories and candidate orderRawCullPaths and sandbox policy belong to the host application
RAW decodingRawCullPhotoAIKit should not depend on RawParserKit or camera formats
Settings and capability wordingRawCullUser-facing state and localization belong to the app
Similarity ranking adjustments and burst groupingRawCullThese are photo-culling product decisions, not CLIP behavior
Burst-analysis cache location and lifecycleRawCullThe host owns when and where catalog results persist

Current Runtime Shape

RawCull deliberately has a safe startup path:

  1. RawCullApp creates one RawCullAIIntegration composition root.
  2. The main view model initially receives the Vision similarity service.
  3. RawCullAISettingsModel.refresh() asks the integration to validate the CLIP and SAM 3 model candidates.
  4. PhotoAIKit validates the selected model bundle and fingerprints its asset.
  5. If CLIP validation and provider construction succeed, the saved CLIP preference can select RawCullCLIPSimilarityService.
  6. If CLIP is disabled, missing, invalid, or cannot be constructed, RawCull continues with Vision.
  7. If CLIP starts a batch but any item fails, the complete requested batch is retried with Vision.
stateDiagram-v2
    [*] --> VisionStartup
    VisionStartup --> CheckingModels: refresh capabilities
    CheckingModels --> VisionSelected: CLIP disabled, missing, or invalid
    CheckingModels --> CLIPSelected: preference enabled and provider ready
    CLIPSelected --> CLIPArtifacts: all requested items succeed
    CLIPSelected --> VisionFallbackArtifacts: any requested item fails
    VisionSelected --> VisionArtifacts: index catalog

The whole-batch fallback is an integrity rule, not only an error-recovery convenience. CLIP vectors and Vision feature prints have different representations and distance semantics. Recomputing the entire batch prevents a catalog from containing artifacts that cannot be compared with one another.

Vocabulary

TermMeaning in this codebase
ProviderA backend object that performs inference or creates an artifact
Backend descriptorIdentity of the backend, model, representation, preprocessing, normalization, and configuration
Similarity artifactA descriptor plus a backend-owned payload; CLIP stores an encoded vector, while Vision stores an opaque archived observation
Source fingerprintStandardized file path, size, and modification date used to detect changed source images
Model fingerprintIdentity derived from the selected .aimodel or .aimodelc, cryptographically verified when the manifest provides a checksum
Composition rootThe one place where concrete providers, stores, paths, and app adapters are assembled
Whole-batch fallbackDiscard the primary pass after any failure and run every requested source through one fallback backend
HostThe application integrating PhotoAIKit; here, RawCull

Suggested Learning Order

Read How PhotoAIKit Is Constructed first if dependency boundaries, protocols, or package products are unfamiliar. It builds the mental model from the bottom up.

Then read How RawCull Enables and Uses CLIP. That document follows the runtime path and shows where the clean package abstractions meet app-specific concerns such as model locations, RAW files, settings, burst grouping, and cache validation.

Finally, keep AI Runtime Step by Step beside the source code. It expands the runtime path into exact function hops and branches, including what does and does not invoke CLIP when a developer indexes similarity, analyzes bursts, opens a burst, or enters the detailed comparison view.

10.1 - How RawCull Loads and Uses CLIP

A beginner-friendly source walk-through of RawCull’s OpenAI and DataComp CLIP models, model loading, PhotoAIKit packages, image similarity, semantic search, recovery, and persistence.

How RawCull Loads and Uses CLIP

RawCull supports two CLIP models:

  • OpenAI CLIP ViT-B/32, which processes a 224 × 224 image;
  • OpenCLIP DataComp ViT-B-32-256, which processes a 256 × 256 image.

The user selects one model in Settings > AI. That one selection defines the vector space used for both image-to-image similarity and text-to-image semantic search. RawCull never mixes vectors from the two models.

This page starts with the basic idea behind CLIP and then follows the current source code from application startup, through model validation and Core AI inference, to search results, burst groups, and persisted artifacts.

1. CLIP In Plain Language

CLIP stands for Contrastive Language-Image Pre-training. It was trained to place matching images and text descriptions near each other in a shared mathematical space.

For example, CLIP can turn these two inputs into vectors:

image: a photograph of a red fox at dusk  ──► [0.012, -0.084, ...]
text:  "red fox at dusk"                  ──► [0.018, -0.079, ...]

A vector is just an ordered list of numbers. RawCull does not display those numbers to the user. It compares their directions:

  • two image vectors pointing in similar directions represent visually or semantically related images;
  • an image vector pointing in a similar direction to a text vector is a possible match for that text;
  • vectors produced by different CLIP models are not comparable, even when they contain the same number of values.

CLIP does not write a caption, locate the exact pixels of an object, rate a photo, or decide which burst frame is sharpest. It provides similarity evidence. RawCull decides how to use that evidence.

The original OpenAI CLIP introduction and CLIP paper explain the contrastive training method in more depth.

2. Why There Are Two CLIP Models

Both choices use the same general CLIP idea and the same size of output vector, but they use different learned weights and different image resolutions.

PropertyOpenAI modelDataComp model
RawCull nameOpenAIDataComp
Bundle folderCLIP-OpenAICLIP-DataComp
Export sourceopenai/clip-vit-base-patch32mlfoundations/open_clip
ArchitectureViT-B-32ViT-B-32-256
Pretrained checkpointOriginal OpenAI weightsdatacomp_s34b_b86k
Model input224 × 224 RGB256 × 256 RGB
Image patch size32 × 32 pixels32 × 32 pixels
Embedding size512 floating-point values512 floating-point values
Text context77 tokens77 tokens
Token paddingEnd-of-text token 49407Zero
Text attention maskSuppliedNot required by the exported text encoder

The model details do not come from hard-coded branches inside CoreAICLIPProvider. The exporter writes them into each bundle’s metadata.json, and the provider builds a CLIPRuntimeConfiguration from that metadata.

2.1 OpenAI CLIP ViT-B/32

The OpenAI choice is the original clip-vit-base-patch32 checkpoint used by PhotoAIKit’s exporter.

ViT-B/32 can be read as:

  • ViT: the image encoder is a Vision Transformer;
  • B: the base-size transformer family;
  • 32: the image is divided into 32 × 32 pixel patches before transformer processing.

The exported image encoder receives a 224 × 224 image and returns a 512-dimensional, L2-normalized image vector. The exported text encoder receives up to 77 CLIP BPE tokens plus an attention mask and returns a normalized 512-dimensional text vector.

The original CLIP family was trained on 400 million image-text pairs collected from the internet. The aim was not to learn a fixed list of RawCull categories. It learned a broad relationship between visual content and natural-language descriptions.

2.2 OpenCLIP DataComp ViT-B-32-256

The DataComp choice uses the open-source OpenCLIP implementation and the datacomp_s34b_b86k checkpoint. Its architecture is ViT-B-32-256: a base Vision Transformer with 32 × 32 patches and a 256 × 256 image input.

This checkpoint was trained on the DataComp-1B dataset and its published name records that the training run saw approximately 34 billion samples. The DataComp model card describes the released checkpoint.

The DataComp model also produces 512-dimensional normalized vectors and uses a 77-token CLIP BPE context. Its exported text graph follows OpenCLIP’s input contract: token IDs are padded with zero and no separate attention-mask input is required.

2.3 What The Model Choice Means In RawCull

The two models can rank the same catalog differently because their weights, training data, and input resolution differ. RawCull does not declare one model universally better. It treats them as two valid alternatives.

The important rule is:

An OpenAI image vector can be compared only with OpenAI image or text vectors from the same model fingerprint and processing configuration. The equivalent rule applies to DataComp.

Both output 512 values, but matching dimensions alone do not make two vector spaces compatible.

3. Packages And Frameworks In The CLIP Path

RawCull is the host application. Most reusable CLIP mechanics live in PhotoAIKit, while RAW decoding and culling policy remain in RawCull.

3.1 Swift Packages Used At Runtime

Package or productHow CLIP uses it
PhotoAIContractsDefines model identities, bundle validation, backend descriptors, image and text embeddings, similarity artifacts, source fingerprints, and the small provider/decoder protocols shared by RawCull and the backends.
CoreAICLIPBackendSupplies CoreAICLIPProvider, the actor that reads CLIP metadata, tokenizes text, preprocesses images, lazily runs Core AI, creates artifacts, and compares compatible image and text vectors.
PhotoAIWorkflowsSupplies SimilarityArtifactIndexer, which performs bounded asynchronous decode-and-inference work and reports per-source progress and failures.
PhotoAIStorageSupplies the descriptor-complete artifact codec used by RawCull’s per-file similarity store.
VisionFeaturePrintBackendSupplies the always-available Apple Vision similarity backend used when CLIP is disabled, missing, invalid, or cannot create a provider. It is not a per-image fallback during a selected CLIP indexing pass.
RawParserKitExtracts a bounded thumbnail CGImage from supported camera RAW files before PhotoAIKit sees the image.
RawCullCoreGroups adjacent photos using the distances RawCull computed from CLIP artifacts and owns shared burst-domain types.

RawCull also links CoreAISAM3Backend, but SAM 3 is a separate segmentation model. Its resource check happens alongside the two CLIP checks; it is not used to calculate CLIP vectors.

PhotoAIKit has one external Swift package dependency: apple/coreai-models, pinned to an exact revision. Its CoreAISegmentation product makes the Core AI runtime APIs available to the CLIP and SAM 3 backend targets.

3.2 Apple Frameworks Used Around The Packages

FrameworkRole
Core AILoads the .aimodel or .aimodelc, specializes it for the Mac, creates NDArrays, and runs the named image and text functions.
Core GraphicsConverts a decoded CGImage into the model’s sRGB pixel input.
ImageIOProvides RawCull’s secondary thumbnail-decoding path when RawParserKit cannot return an image.
VisionProduces feature prints when RawCull selects its non-CLIP similarity service.
FoundationProvides URLs, JSON coding, file metadata, UserDefaults, tasks, and application-support paths.
CryptoKitSupports stable cache keys and model/artifact fingerprinting code.
SwiftUI and ObservationPresent the model picker, CLIP toggle, capability state, indexing progress, and semantic-search controls.
OSLogRecords backend selection, model fingerprints, failures, and diagnostics.

3.3 Python Packages Used Only To Export Models

The model bundle is created before RawCull runs. PhotoAIKit’s Tools/export_clip.py declares these build-time tools:

Python packageExport-time job
transformersLoads the OpenAI CLIP checkpoint and saves its tokenizer.
open_clip_torchLoads the OpenCLIP DataComp architecture and checkpoint.
torch and torchvisionHold the source model and tensors used during export.
coreai-torchConverts the PyTorch image and text encoders into Core AI functions.
coreai-coreSaves and optimizes the portable Core AI model asset.

These Python packages are not shipped as part of RawCull’s Swift runtime.

4. The Complete Runtime Shape

The main path is:

flowchart TD
    Settings["Settings: choose OpenAI or DataComp"] --> Integration["RawCullAIIntegration"]
    Integration --> Managers["Two CLIP resource managers"]
    Managers --> Validate["PhotoAIKit validates both bundles"]
    Validate --> Providers["One provider per valid bundle"]
    Providers --> Selected{"Which model is selected?"}
    Selected -->|OpenAI| OpenAI["OpenAI CoreAICLIPProvider"]
    Selected -->|DataComp| DataComp["DataComp CoreAICLIPProvider"]
    OpenAI --> Similarity["Image similarity and burst grouping"]
    OpenAI --> Search["Text-to-image semantic search"]
    DataComp --> Similarity
    DataComp --> Search

There may be two validated providers in memory, but RawCull exposes only the selected provider to the active similarity and semantic-search features.

5. Startup Uses Vision First

Main/RawCullApp.swift creates the object graph synchronously:

let integration = RawCullAIIntegration()
let viewModel = RawCullViewModel(
    similarityService: integration.visionSimilarityService,
    semanticSearchCapability: integration.capabilities()
        .semanticSearchStatus(for: .defaultSelection),
    deepAIReviewFeature: integration.deepAIReviewFeature
)

Vision is installed first because it needs no downloaded model bundle. The app can open even when both CLIP folders are absent.

RawCullAISettingsModel then receives two callbacks:

  • one installs a new RawCullSimilarityServicing value in the main view model;
  • one installs the selected model’s semantic-search capability and service.

The asynchronous .task attached to the main window calls aiSettingsModel.refresh(). Model directory inspection, fingerprinting, and provider construction therefore do not block RawCullApp.init().

6. Preferences And The Selected Model

Model/ViewModels/RawCullAISettingsModel.swift stores two values:

RawCullAI.useCLIPForSimilarity
RawCullAI.selectedCLIPModel

When no values have been saved:

  • CLIP similarity defaults to enabled;
  • the OpenAI model is the default selection.

The model picker is mutually exclusive. Choosing DataComp replaces OpenAI as the selected model; it does not combine their results.

The CLIP toggle is a request, not proof that a model is usable:

CLIP toggleSelected bundle validSimilarity backend
OffNo or yesVision feature print
OnNoVision feature print
OnYesSelected CLIP model

Semantic-search readiness is tracked separately because Vision can compare two images but cannot compare an image with text. A valid selected CLIP provider is required for semantic search.

RawCull can read already-cached compatible CLIP artifacts for semantic search. To create missing semantic-search artifacts through the normal indexing button, the selected CLIP model must also be active as the similarity backend.

Changing the selected model immediately reapplies both services. It is not necessary to restart RawCull.

7. Model Locations

RawCullAIPaths.live() creates two separate installed-model locations:

~/Library/Application Support/RawCull/Models/
├── CLIP-DataComp/
└── CLIP-OpenAI/

A sandboxed build resolves the same relative folders inside its container:

~/Library/Containers/no.blogspot.RawCull/Data/Library/Application Support/
└── RawCull/Models/
    ├── CLIP-DataComp/
    └── CLIP-OpenAI/

Settings > AI shows the exact paths for the running build.

RawCullAIModelCandidates.urls(...) puts the installed directory first. Debug builds may add app-bundle resource candidates such as Models/CLIP-OpenAI; release builds do not use bundled fallback unless it is explicitly enabled during construction.

PhotoAIKit does not search these paths. RawCull owns path policy and passes an ordered URL list into the package.

8. What A CLIP Bundle Contains

Each model has its own self-contained directory:

CLIP-OpenAI/ or CLIP-DataComp/
├── metadata.json
├── tokenizer/
│   └── tokenizer.json
└── selected-model.aimodel

The selected model may instead be a compiled .aimodelc directory.

A current metadata version 0.4 export describes:

  • model family, source, revision, architecture, and pretrained checkpoint;
  • 512 embedding dimensions;
  • input width and height;
  • shortest-side resize, center crop, and interpolation policy;
  • RGB mean and standard deviation;
  • tokenizer type, version, context length, and padding token;
  • the image_encoder and text_encoder function names;
  • output normalization and configuration versions;
  • assets.main, which names the selected Core AI asset;
  • asset_fingerprints.main, which records the expected asset fingerprint.

This matters because a model is more than its weight file. The tokenizer and preprocessing rules must match the weights used during training.

9. Validation And Provider Construction

RawCullAIIntegration owns one resource manager for each model:

clipDataCompModelResourceManager
clipOpenAIModelResourceManager

During refreshCapabilities(), RawCull loads SAM 3, DataComp CLIP, and OpenAI CLIP concurrently:

async let sam3Load = sam3ModelResourceManager.load()
async let clipDataCompLoad = clipDataCompModelResourceManager.load()
async let clipOpenAILoad = clipOpenAIModelResourceManager.load()

Each RawCullAIModelResourceManager is an actor. It first builds a lightweight snapshot from paths, file kinds, sizes, modification dates, and resolved symbolic-link paths. If nothing changed, it can reuse the previous capability and provider instead of hashing a large model again.

When the snapshot changes, PhotoAIKit remains the authority:

flowchart TD
    Candidate["RawCull candidate URL"] --> Exists{"Directory exists?"}
    Exists -->|No| Next["Try the next candidate"]
    Exists -->|Yes| Metadata["Decode metadata.json"]
    Metadata --> Asset["Resolve assets.main"]
    Asset --> Resources["Check tokenizer and selected asset"]
    Resources --> Fingerprint["Fingerprint asset and verify manifest checksum"]
    Fingerprint --> Valid{"Valid?"}
    Valid -->|No| Invalid["Report invalid and stop"]
    Valid -->|Yes| Provider["Construct CoreAICLIPProvider"]

A missing candidate is skipped. An invalid higher-priority candidate stops resolution instead of being hidden by a lower-priority debug bundle.

The provider constructor validates the bundle again, stores its fingerprinted ModelIdentity, decodes metadata.json, and validates the model-specific CLIPRuntimeConfiguration. It does not load the large Core AI model yet.

Capability and provider-construction failure are recorded separately. Settings can therefore distinguish a missing or damaged bundle from a bundle that validated but could not initialize its runtime configuration.

10. Lazy Core AI Loading

The first image or text embedding request calls CoreAICLIPProvider.loadModel().

The provider then:

  1. reads assets.main and creates an AIModel;
  2. asks Core AI to prefer GPU specialization;
  3. creates CLIPTokenizer from the bundle’s tokenizer folder;
  4. finds and loads the metadata-selected image and text functions;
  5. requires pixel_values and image_embeds on the image side;
  6. requires input_ids and text_embeds on the text side;
  7. accepts attention_mask when the text function declares it;
  8. validates tensor ranks, dimensions, scalar types, resolution, and token context length;
  9. caches the functions, descriptors, tokenizer, and compatibility inputs inside the provider actor.

New bundles use two named functions in one asset:

image_encoder(pixel_values)                 -> image_embeds
text_encoder(input_ids[, attention_mask])  -> text_embeds

This avoids running the text tower while indexing an image and avoids running the image tower for every search query.

PhotoAIKit still supports older single-function CLIP exports. If an older image function also requires text inputs, the provider supplies tokens for "a photo". If an older text function requires an image, it supplies a zero image. These are compatibility inputs, not RawCull’s semantic-search query.

The loaded model is cached in the actor, so later requests reuse it. The first request can take longer because macOS may specialize a portable model for the current Mac.

11. From A RAW File To Model Pixels

PhotoAIKit deliberately does not know how to decode camera RAW formats. RawCull’s RawCullSimilarityImageDecoder supplies that application-specific step.

For each AIImageSource, the decoder:

  1. checks task cancellation;
  2. asks RawParserKitImageLoader for a thumbnail no larger than 512 pixels;
  3. if that fails, asks ImageIO for an existing embedded thumbnail;
  4. reports imageDecodeFailed if neither path returns a CGImage.

The 512-pixel value belongs to RawCull’s embedding pipeline. It gives the model provider a bounded image instead of decoding an unnecessarily large RAW preview.

The selected provider then applies the exact preprocessing declared by that model:

  1. convert to an 8-bit sRGB RGBA drawing buffer;
  2. resize the shortest side while preserving aspect ratio;
  3. take a centered 224 × 224 OpenAI crop or 256 × 256 DataComp crop;
  4. convert red, green, and blue from bytes to values in 0 ... 1;
  5. subtract CLIP means 0.48145466, 0.4578275, and 0.40821073;
  6. divide by CLIP standard deviations 0.26862954, 0.26130258, and 0.27577711;
  7. change interleaved RGB into channel-first [1, 3, height, width] order;
  8. write a Float16 or Float32 Core AI NDArray, as required by the model.

The center crop can remove pixels near the long edges of a photograph. That is intentional: preprocessing must match the model’s training and export contract.

12. Creating A CLIP Image Artifact

After preprocessing, the provider runs image_encoder and reads image_embeds.

Both current graphs export an L2-normalized vector. PhotoAIKit wraps the result in ImageEmbedding, which also applies safe L2 normalization:

magnitude = sqrt(sum(value²))
normalizedValue = value / magnitude

The provider then creates a SimilarityArtifact:

SimilarityArtifact
├── descriptor
│   ├── backend = "clip"
│   ├── model fingerprint
│   ├── dimensions = 512
│   ├── representation version
│   ├── preprocessing version
│   ├── normalization version
│   ├── configuration version
│   ├── source path, size, and modification date
│   └── artifact schema version
└── payload
    └── JSON-encoded ImageEmbedding vector

The descriptor prevents RawCull from treating a vector as reusable merely because it can be decoded.

13. Indexing, Concurrency, And Failure Recovery

RawCullCLIPSimilarityService creates a PhotoAIKit SimilarityArtifactIndexer with:

  • the selected CLIP provider;
  • RawCull’s diagnosing RAW decoder;
  • no per-image or whole-batch fallback provider;
  • a current concurrency limit of 1.

The current CLIP behavior is:

flowchart TD
    Source["One catalog image"] --> Decode["Decode bounded CGImage"]
    Decode --> Inference["Run selected CLIP image encoder"]
    Inference --> Finite{"Vector finite and non-empty?"}
    Finite -->|Yes| Keep["Validate, persist, and keep artifact"]
    Finite -->|No| Retry["Retry once with the loaded provider"]
    Retry --> RetryOK{"Valid now?"}
    RetryOK -->|Yes| Keep
    RetryOK -->|No| Replace["Construct a fresh provider and retry"]
    Replace --> ReplaceOK{"Valid now?"}
    ReplaceOK -->|Yes| Keep
    ReplaceOK -->|No| Exclude["Record failure; exclude this image"]

Only non-finite embedding output receives the two recovery attempts. A decode error or another model error is recorded for that source without discarding valid CLIP artifacts already created for other images.

This is different from the earlier whole-batch fallback design:

  • RawCull no longer throws away a successful CLIP pass because one image failed;
  • it does not mix Vision artifacts into a selected CLIP indexing pass;
  • a failed image has no current artifact and is excluded from similarity comparisons and burst grouping until it can be indexed successfully.

When CLIP is disabled or its selected bundle is unavailable at service selection time, RawCull uses RawCullVisionSimilarityService instead. Vision currently indexes with a concurrency limit of 4.

14. How RawCull Uses Image-To-Image Distance

For two compatible CLIP artifacts, PhotoAIKit computes cosine distance:

cosineDistance(a, b) =
    1 - dot(a, b) / (magnitude(a) × magnitude(b))

For normalized vectors:

  • 0 means the directions are effectively identical;
  • 1 means the directions are orthogonal;
  • 2 means the directions are opposite.

Before computing the value, the provider verifies that both artifacts have the same model fingerprint, dimensions, representation, preprocessing, normalization, configuration, and schema. It also checks that each JSON payload matches its descriptor.

RawCull uses this primitive distance in three ways:

  1. Find similar images. It compares a selected anchor image with every compatible catalog artifact and sorts smaller distances first.
  2. Apply a small product-policy adjustment. If available saliency labels identify different subjects, SimilarityScoringModel adds 0.10 to the distance.
  3. Group bursts. It compares adjacent images in capture order and passes the distances to RawCullCore.BurstGroupingEngine. The current default visual threshold is 0.25.

CLIP supplies the distance. RawCull owns the saliency adjustment, threshold, burst rules, sharpness scoring, recommendation, and rating behavior.

If an image has no valid artifact, burst grouping splits the ordered catalog into eligible runs around that gap. It does not invent a distance across the missing image.

15. How Semantic Search Works

Semantic search uses the text half of the same selected CLIP provider and the already-persisted image artifacts.

For a query such as red fox at dusk, RawCull:

  1. trims surrounding whitespace but otherwise sends the literal query;
  2. tokenizes it with the bundle’s CLIP BPE tokenizer;
  3. truncates or pads it to the model’s 77-token context;
  4. runs text_encoder;
  5. validates that the returned 512-value vector is finite, non-empty, and L2-normalized;
  6. compares it only with cached image artifacts that match the selected model and every backend descriptor field;
  7. sorts larger cosine-similarity scores first.

Image-to-text uses cosine similarity, not distance:

cosineSimilarity(image, text) = dot(imageVector, textVector)

The provider clamps the result to -1 ... 1. It is a relative ranking score, not a calibrated probability or confidence percentage.

The text embedding is query-scoped and is not written to disk. The expensive image encoder does not run during a search. Only the text encoder runs once, followed by ordinary vector dot products over the compatible cached images.

RawCull’s deterministic tie order is:

  1. higher score;
  2. earlier catalog order;
  3. localized filename order;
  4. UUID string.

The UI initially presents the highest-ranked 20 images. The user can expand the selection without recomputing embeddings or scores. Rating and filename filters are applied before semantic ranking, and the selected semantic results become the active catalog working set until the search is cleared.

16. Persisting And Reusing Image Artifacts

RawCull persists successful similarity artifacts per source file under:

Application Support/RawCull/
└── AnalysisArtifacts/
    └── Similarity/

PerFileAnalysisArtifactStore writes one atomic JSON record per source/backend/pipeline identity. This lets an artifact survive application restart and be reused by both image similarity and semantic search.

The store validates:

  • standardized source path, file size, and modification date;
  • selected model fingerprint;
  • artifact schema and vector representation;
  • preprocessing, normalization, and configuration versions;
  • RawCull’s 512-pixel input limit;
  • RawCull embedding pipeline version 3.

Moving or renaming a source file changes its standardized path and is therefore a cache miss. The default pruning policy removes records unused for 90 days and caps the store at 50,000 entries.

RawCull also saves catalog-wide burst analysis under:

Application Support/RawCull/BurstAnalysis/

The current burst cache schema is 9. Its similarity signature includes grouping configuration, the selected backend descriptor, accepted artifact descriptors, artifact schema, 512-pixel input limit, and embedding pipeline version. It also verifies the catalog, file identities, and a digest of the artifact set.

These identities create independent invalidation paths:

model asset changed       -> model fingerprint mismatch
selected model changed    -> backend descriptor mismatch
preprocessing changed     -> preprocessing or pipeline mismatch
source file changed       -> source fingerprint mismatch
artifact format changed   -> schema mismatch
grouping policy changed   -> burst signature mismatch

17. Switching Between OpenAI And DataComp

When the selected model changes, RawCullAISettingsModel asks the composition root for new similarity and semantic-search services.

RawCullViewModel.setSimilarityService(_:) then:

  1. cancels and resets active burst analysis;
  2. resets image indexing, distances, grouping, ranking, progress, and diagnostics;
  3. installs the new backend;
  4. tries to hydrate only artifacts compatible with the new descriptor.

The semantic-search service performs a similar reset and compatible-artifact hydration.

This reset is required even though both models return 512 values. Keeping an OpenAI vector while labeling the active service DataComp would make every subsequent comparison mathematically meaningless.

18. Cancellation And Stale-Result Protection

The code uses two complementary mechanisms:

  • Task cancellation asks ongoing decode, inference, persistence, or ranking work to stop.
  • Generation counters prevent a result from being published after the user has started a newer operation or switched models.

The decoder checks cancellation before and after image loading. The CLIP recovery path never converts CancellationError into a model failure. Indexing, image ranking, semantic search, semantic artifact hydration, and burst grouping each keep their own generation.

Cancellation answers “should this work continue?” A generation check answers “even if it finished, does it still belong to the current user action?”

19. Troubleshooting

Use this order because it follows the runtime path:

  1. Check the selected model. OpenAI and DataComp use different folders.
  2. Check the CLIP toggle. It must be on to index missing CLIP artifacts through the normal similarity workflow.
  3. Check Settings capability text. A saved preference does not mean that the selected provider exists.
  4. Check the displayed folder. Use the exact path shown by the running sandboxed or non-sandboxed build.
  5. Check the bundle structure. Confirm metadata.json, tokenizer/tokenizer.json, and the .aimodel or .aimodelc selected by assets.main.
  6. Check the fingerprint. A declared checksum must match the selected asset.
  7. Check metadata compatibility. Resolution, crop policy, token context, function names, tensor names, shapes, and scalar types must match the Core AI asset.
  8. Remove or repair an invalid higher-priority install. Resolution stops at an invalid installed directory instead of silently using a debug-bundled copy.
  9. Expect the first inference to be slower. Core AI may specialize the model for the Mac.
  10. Inspect similarity diagnostics. A partial CLIP result identifies decode or inference failures by image. It does not mean that the complete catalog switched to Vision.
  11. Reindex missing images. Images that still produced invalid output after recovery remain excluded until a later successful indexing pass.
  12. Check semantic-search coverage. Search ranks compatible cached CLIP artifacts only; it never decodes and indexes missing images as a side effect of submitting a query.

20. Source Map

ConcernCurrent source
App construction and callbacksRawCull/Main/RawCullApp.swift
CLIP choices and application-support pathsRawCull/Model/AIIntegration/RawCullAIModels.swift
Candidate URLs and cached resource checksRawCull/Model/AIIntegration/RawCullAIModelResourceManager.swift
Provider dictionaries, selection, and refreshRawCull/Model/AIIntegration/RawCullAIIntegration.swift
Saved preference and selected modelRawCull/Model/ViewModels/RawCullAISettingsModel.swift
Settings model picker and capability statusRawCull/Views/Settings/AISettingsTab.swift
RAW decoding, CLIP retry, Vision service, and distance routingRawCull/Model/AIIntegration/RawCullVisionSimilarityService.swift
Image indexing, ranking, grouping, and semantic-search stateRawCull/Model/ViewModels/SimilarityScoringModel.swift
Literal query ranking serviceRawCull/Model/AIIntegration/RawCullSemanticSearchService.swift
Semantic-search controls and coverageRawCull/Views/SimilarityGridView/SemanticSearchViews.swift
Per-file artifact persistenceRawCull/Actors/PerFileAnalysisArtifactStore.swift
Catalog-wide burst persistenceRawCull/Actors/BurstAnalysisCache.swift
PhotoAIKit package productsPhotoAIKit/Package.swift
Metadata-driven runtime configurationPhotoAIKit/Sources/CoreAICLIPBackend/CLIPRuntimeConfiguration.swift
Image and text inferencePhotoAIKit/Sources/CoreAICLIPBackend/CoreAICLIPProvider.swift
Bundle resolution and fingerprint identityPhotoAIKit/Sources/PhotoAIContracts/ModelBundleResolver.swift, ModelIdentity.swift, and ModelAssetFingerprint.swift
Model exporter and exact model specificationsPhotoAIKit/Tools/export_clip.py

For the package layering behind these types, continue with How PhotoAIKit Is Constructed.

10.2 - AI Model Download Service

Build, validate, package, and download the three optional RawCull AI models.

AI model download service

RawCull supports exactly three optional model bundles:

ModelPhotoAIKit bundleRuntime assetUse
DataComp CLIPCLIP-DataCompViT-B-32-256-datacomp_s34b_b86k_float16_static.aimodelSimilarity and semantic search
OpenAI CLIPCLIP-OpenAIclip-vit-base-patch32_float16_static.aimodelSimilarity and semantic search
Meta SAM 3SAM3sam3_float16.aimodelPromptable segmentation

SigLIP2 and EfficientSAM are not part of the RawCull download catalogue or release manifest. Do not include their bundles, notices, archives, or asset-pack IDs in a RawCull model release.

RawCull uses Managed Background Assets. The app asks AssetPackManager for a known asset-pack ID, resolves the catalogue-owned model path, and gives that directory to PhotoAIKit for validation. RawCull does not unpack an AAR itself.

The current production manifest is pinned to:

https://github.com/rsyncOSX/RawCull-AI-Models/releases/download/v1/manifest.json

The current RawCull source enables DataComp only. OpenAI CLIP and SAM 3 remain hidden and release-blocked until their redistribution reviews and new archives are complete. The three-model release procedure is documented in Publishing new RawCull AI models.

Release gates

A working local conversion is not automatically publishable. Every downloadable model must pass all of these gates:

  1. Pin and verify the exact upstream checkpoint.
  2. Record source file checksums, PhotoAIKit revision, conversion command, runtime fingerprint, and archive checksum.
  3. Confirm that the exact trained weights may be converted, used, and redistributed through the chosen hosting channel.
  4. Include the applicable complete licence and notice files in the pack.
  5. Validate the generated bundle with PhotoAIKit and RawCull.
  6. Package only runtime files; never publish conversion intermediates.

DataComp is currently the only .ready production descriptor. OpenAI CLIP is blocked pending weight-level redistribution clearance. SAM 3 is gated upstream and remains blocked until ungated redistribution of the converted derivative is confirmed. SAM 3 also requires explicit in-app licence acceptance.

Use the reviewed PhotoAIKit revision

The procedures below are verified against PhotoAIKit commit:

6e3216027b267c27ccaf99d334807b18ea1aaec9

That revision exports CLIP metadata version 0.4, SAM 3 metadata version 0.3, separate CLIP image_encoder and text_encoder functions, normalized embeddings, and the corrected Pillow-compatible bicubic CLIP preprocessing.

Use a clean detached checkout for release evidence:

PHOTOAIKIT_REVISION='6e3216027b267c27ccaf99d334807b18ea1aaec9'
PHOTOAIKIT_DIR='/Users/thomas/ModelAssets/ReleaseEvidence/PhotoAIKit'

git clone https://github.com/rsyncOSX/PhotoAIKit.git "$PHOTOAIKIT_DIR"
git -C "$PHOTOAIKIT_DIR" switch --detach "$PHOTOAIKIT_REVISION"
test "$(git -C "$PHOTOAIKIT_DIR" rev-parse HEAD)" = "$PHOTOAIKIT_REVISION"
test -z "$(git -C "$PHOTOAIKIT_DIR" status --porcelain)"

If a later PhotoAIKit revision is used, review changes to Tools/export_clip.py, Tools/export_sam3.py, preprocessing, metadata, and runtime validation. Record the exact revision actually executed; do not silently reuse the value above.

Create the DataComp CLIP bundle

The release model is OpenCLIP ViT-B-32-256 with the registered datacomp_s34b_b86k pretrained tag.

FieldRequired value
Checkpoint repositorylaion/CLIP-ViT-B-32-256x256-DataComp-s34B-b86K
Revision4afec35ffe57a943d569ff7ee888061830164da8
Weight fileopen_clip_model.safetensors
Weight bytes605189364
Weight SHA-25692c26d60d3200ed5ed040dff31a8d19f8140648da8007216c25744c478deef27

Create an evidence-specific Hugging Face cache and verify that its main resolution is the pinned revision required by OpenCLIP:

DATACOMP_REPOSITORY='laion/CLIP-ViT-B-32-256x256-DataComp-s34B-b86K'
DATACOMP_REVISION='4afec35ffe57a943d569ff7ee888061830164da8'
DATACOMP_SHA256='92c26d60d3200ed5ed040dff31a8d19f8140648da8007216c25744c478deef27'
DATACOMP_BYTES='605189364'
DATACOMP_ROOT="/Users/thomas/ModelAssets/ReleaseEvidence/CLIP-DataComp/$DATACOMP_REVISION"
DATACOMP_HF_HOME="$DATACOMP_ROOT/huggingface"
DATACOMP_EXPORT_DIR="$DATACOMP_ROOT/export"

mkdir -p "$DATACOMP_HF_HOME" "$DATACOMP_EXPORT_DIR"
export HF_HOME="$DATACOMP_HF_HOME"

DATACOMP_SNAPSHOT="$(hf download "$DATACOMP_REPOSITORY" \
  open_clip_model.safetensors open_clip_config.json README.md \
  --revision "$DATACOMP_REVISION" --quiet)"
DATACOMP_MAIN_SNAPSHOT="$(hf download "$DATACOMP_REPOSITORY" \
  open_clip_model.safetensors open_clip_config.json README.md \
  --revision main --quiet)"

test "$(basename "$DATACOMP_SNAPSHOT")" = "$DATACOMP_REVISION"
test "$DATACOMP_MAIN_SNAPSHOT" = "$DATACOMP_SNAPSHOT"
test "$(shasum -a 256 "$DATACOMP_SNAPSHOT/open_clip_model.safetensors" | cut -d ' ' -f 1)" = "$DATACOMP_SHA256"
test "$(stat -f '%z' "$DATACOMP_SNAPSHOT/open_clip_model.safetensors")" = "$DATACOMP_BYTES"

Export with PhotoAIKit’s registered preset:

HF_HUB_OFFLINE=1 \
TRANSFORMERS_OFFLINE=1 \
uv run --script "$PHOTOAIKIT_DIR/Tools/export_clip.py" \
  --model openclip-datacomp \
  --architecture ViT-B-32-256 \
  --pretrained datacomp_s34b_b86k \
  --output-dir "$DATACOMP_EXPORT_DIR" \
  --bundle-name CLIP-DataComp \
  --dtype float16

Do not pass open_clip_model.safetensors as --pretrained. That creates the historical custom-named bundle and bypasses the registered preset identity. The tested, non-collapsing bundle uses datacomp_s34b_b86k and must contain:

CLIP-DataComp/
├── metadata.json
├── tokenizer/
├── ViT-B-32-256-datacomp_s34b_b86k_float16_static.aimodel/
└── ViT-B-32-256-datacomp_s34b_b86k_float16_static_source.aimodel/

The _source.aimodel directory is private conversion evidence. Exclude it from the downloadable pack. metadata.json must select the optimized directory in assets.main and contain its asset_fingerprints.main value.

Create the OpenAI CLIP bundle

FieldRequired value
Checkpoint repositoryopenai/clip-vit-base-patch32
Revision3d74acf9a28c67741b2f4f2ea7635f0aaf6f0268
Weight filepytorch_model.bin
Weight bytes605247071
Weight SHA-256a63082132ba4f97a80bea76823f544493bffa8082296d62d71581a4feff1576f

The exporter loads the repository ID for both the model and tokenizer. Use an evidence-specific Hugging Face cache, require its main reference to resolve to the pinned revision, and then run offline. Loading through that verified cache also lets Transformers record the immutable source revision in the generated metadata:

OPENAI_REVISION='3d74acf9a28c67741b2f4f2ea7635f0aaf6f0268'
OPENAI_SHA256='a63082132ba4f97a80bea76823f544493bffa8082296d62d71581a4feff1576f'
OPENAI_BYTES='605247071'
OPENAI_ROOT="/Users/thomas/ModelAssets/ReleaseEvidence/CLIP-OpenAI/$OPENAI_REVISION"
OPENAI_HF_HOME="$OPENAI_ROOT/huggingface"
OPENAI_EXPORT_DIR="$OPENAI_ROOT/export"

mkdir -p "$OPENAI_HF_HOME" "$OPENAI_EXPORT_DIR"
export HF_HOME="$OPENAI_HF_HOME"

OPENAI_SNAPSHOT="$(hf download openai/clip-vit-base-patch32 \
  config.json pytorch_model.bin merges.txt preprocessor_config.json \
  special_tokens_map.json tokenizer.json tokenizer_config.json vocab.json README.md \
  --revision "$OPENAI_REVISION" --quiet)"
OPENAI_MAIN_SNAPSHOT="$(hf download openai/clip-vit-base-patch32 \
  config.json pytorch_model.bin merges.txt preprocessor_config.json \
  special_tokens_map.json tokenizer.json tokenizer_config.json vocab.json README.md \
  --revision main --quiet)"

test "$(basename "$OPENAI_SNAPSHOT")" = "$OPENAI_REVISION"
test "$OPENAI_MAIN_SNAPSHOT" = "$OPENAI_SNAPSHOT"
test "$(shasum -a 256 "$OPENAI_SNAPSHOT/pytorch_model.bin" | cut -d ' ' -f 1)" = "$OPENAI_SHA256"
test "$(stat -f '%z' "$OPENAI_SNAPSHOT/pytorch_model.bin")" = "$OPENAI_BYTES"

HF_HUB_OFFLINE=1 \
TRANSFORMERS_OFFLINE=1 \
uv run --script "$PHOTOAIKIT_DIR/Tools/export_clip.py" \
  --model openai \
  --output-dir "$OPENAI_EXPORT_DIR" \
  --bundle-name CLIP-OpenAI \
  --dtype float16

The result must contain:

CLIP-OpenAI/
├── metadata.json
├── tokenizer/
├── clip-vit-base-patch32_float16_static.aimodel/
└── clip-vit-base-patch32_float16_static_source.aimodel/

metadata.json must report source revision 3d74acf9a28c67741b2f4f2ea7635f0aaf6f0268. Exclude the _source.aimodel directory from the downloadable pack.

Create the Meta SAM 3 bundle

FieldRequired value
Checkpoint repositoryfacebook/sam3
Revision3c879f39826c281e95690f02c7821c4de09afae7
Weight filemodel.safetensors
Weight bytes3439938512
Weight SHA-2566d06f0a5f84e435071fe6603e61d0b4cc7b40e0d39d487cfd4d67d8cc11cc14a
SAM License SHA-256b08db9d32c687054e99cbd41eb1dad19c76936dfb9e2b58e186a01204d8be9ab

SAM 3 is gated. Complete Meta’s Hugging Face access flow and authenticate with hf auth login. Never put the token in a command transcript, provenance file, or archive.

The exporter loads the literal path facebook/sam3 three times. As with OpenAI CLIP, stage that relative local path and force offline conversion:

SAM3_REVISION='3c879f39826c281e95690f02c7821c4de09afae7'
SAM3_SHA256='6d06f0a5f84e435071fe6603e61d0b4cc7b40e0d39d487cfd4d67d8cc11cc14a'
SAM3_BYTES='3439938512'
SAM3_LICENSE_SHA256='b08db9d32c687054e99cbd41eb1dad19c76936dfb9e2b58e186a01204d8be9ab'
SAM3_ROOT="/Users/thomas/ModelAssets/ReleaseEvidence/SAM3/$SAM3_REVISION"
SAM3_SOURCE_ROOT="$SAM3_ROOT/source"
SAM3_SOURCE_DIR="$SAM3_SOURCE_ROOT/facebook/sam3"
SAM3_EXPORT_DIR="$SAM3_ROOT/export"

mkdir -p "$SAM3_SOURCE_DIR" "$SAM3_EXPORT_DIR"
hf download facebook/sam3 \
  model.safetensors config.json processor_config.json tokenizer.json \
  tokenizer_config.json special_tokens_map.json merges.txt vocab.json LICENSE README.md \
  --revision "$SAM3_REVISION" --local-dir "$SAM3_SOURCE_DIR"

test "$(shasum -a 256 "$SAM3_SOURCE_DIR/model.safetensors" | cut -d ' ' -f 1)" = "$SAM3_SHA256"
test "$(stat -f '%z' "$SAM3_SOURCE_DIR/model.safetensors")" = "$SAM3_BYTES"
test "$(shasum -a 256 "$SAM3_SOURCE_DIR/LICENSE" | cut -d ' ' -f 1)" = "$SAM3_LICENSE_SHA256"

cd "$SAM3_SOURCE_ROOT"
HF_HUB_OFFLINE=1 \
TRANSFORMERS_OFFLINE=1 \
uv run --script "$PHOTOAIKIT_DIR/Tools/export_sam3.py" \
  --model facebook/sam3 \
  --output-dir "$SAM3_EXPORT_DIR" \
  --bundle-name SAM3 \
  --dtype float16

The result must contain:

SAM3/
├── metadata.json
├── tokenizer/
├── sam3_float16.aimodel/
└── sam3_float16_source.aimodel/

The current SAM 3 exporter records the model ID but not the upstream commit. Keep the verified source manifest and PhotoAIKit revision in PROVENANCE.json; do not claim that metadata.json alone binds the revision. Exclude sam3_float16_source.aimodel from the downloadable pack.

Validate every generated bundle

Do not use --dynamic or --overwrite for a release conversion. Preserve the terminal log and record uv --version, sw_vers, xcodebuild -version, the PhotoAIKit commit, source checksums, generated metadata, and runtime fingerprint.

For each runtime asset, compare the generated fingerprint with asset_fingerprints.main:

python3 "$PHOTOAIKIT_DIR/Tools/model_fingerprint.py" \
  /path/to/bundle/runtime.aimodel

Run PhotoAIKit’s package tests:

swift test --package-path "$PHOTOAIKIT_DIR"

For each CLIP bundle, generate PyTorch reference embeddings from the same model identity and compare them with the Core AI bundle using clipbench. The fixture set must contain representative portraits, animals, vehicles, landscapes, interiors, night scenes, macro images, motion blur, readable signs, and flowers.

cd "$PHOTOAIKIT_DIR"
swift build -c release --product clipbench

uv run --script Tools/generate_clip_reference.py \
  --model openclip-datacomp \
  --architecture ViT-B-32-256 \
  --pretrained datacomp_s34b_b86k \
  --image /path/to/01-dog-outdoors.jpg \
  --text 'a woman with a dog outdoors' \
  --output /private/tmp/datacomp-clip-reference.json

.build/release/clipbench parity \
  --reference /private/tmp/datacomp-clip-reference.json \
  --model /path/to/CLIP-DataComp \
  --minimum-cosine 0.998

Repeat with --model openai and the OpenAI bundle. Add every fixture image and matching prompt with repeated --image and --text arguments. Investigate any result below the chosen threshold; do not lower the threshold merely to publish the pack.

Finally, install each complete candidate in RawCullFB, rebuild its model-specific index, and run the same semantictest.txt for both CLIP models. Compare:

  • query-by-query top results and scores;
  • image-to-image nearest-neighbour results;
  • duplicate or near-duplicate retrieval;
  • score spread and repeated ranking patterns;
  • missing results, errors, and elapsed time.

A new model fingerprint requires a new index. Never compare one model against an index created by another model or by an older conversion.

For SAM 3, install the candidate in RawCull and verify text-prompt segmentation, mask dimensions, mask placement, licence acceptance, relaunch, and removal.

Prepare the release staging tree

Only the optimized runtime assets belong in the pack:

ModelAssets/Release/
├── Models/
│   ├── CLIP-DataComp/
│   │   ├── metadata.json
│   │   ├── tokenizer/
│   │   └── ViT-B-32-256-datacomp_s34b_b86k_float16_static.aimodel/
│   ├── CLIP-OpenAI/
│   │   ├── metadata.json
│   │   ├── tokenizer/
│   │   └── clip-vit-base-patch32_float16_static.aimodel/
│   └── SAM3/
│       ├── metadata.json
│       ├── tokenizer/
│       └── sam3_float16.aimodel/
├── Notices/
│   ├── CLIP-DataComp/
│   ├── CLIP-OpenAI/
│   └── SAM3/
├── Packaging/
│   ├── clip-datacomp.json
│   ├── clip-openai.json
│   └── sam3.json
└── Output/

Each packaging manifest must select exactly metadata.json, tokenizer, the optimized runtime directory, and the matching notice directory. Example:

{
  "assetPackID": "no.blogspot.RawCull.models.clip-datacomp",
  "downloadPolicy": { "onDemand": {} },
  "fileSelectors": [
    { "file": "Models/CLIP-DataComp/metadata.json" },
    { "directory": "Models/CLIP-DataComp/tokenizer" },
    { "directory": "Models/CLIP-DataComp/ViT-B-32-256-datacomp_s34b_b86k_float16_static.aimodel" },
    { "directory": "Notices/CLIP-DataComp" }
  ],
  "platforms": ["macOS"]
}

The three stable asset-pack IDs and installed paths are:

ModelAsset-pack IDInstalled model path
DataComp CLIPno.blogspot.RawCull.models.clip-datacompModels/CLIP-DataComp
OpenAI CLIPno.blogspot.RawCull.models.clip-openaiModels/CLIP-OpenAI
SAM 3no.blogspot.RawCull.models.sam3Models/SAM3

Generate one .aar per manifest from the release staging root:

cd /Users/thomas/ModelAssets/Release
xcrun ba-package package Packaging/clip-datacomp.json --output-path Output/clip-datacomp.aar
xcrun ba-package package Packaging/clip-openai.json --output-path Output/clip-openai.aar
xcrun ba-package package Packaging/sam3.json --output-path Output/sam3.aar

shasum -a 256 Output/*.aar
stat -f '%N %z bytes' Output/*.aar

Treat published archives as immutable. Record each AAR checksum and byte count in provenance and the matching RawCull catalogue descriptor. Upload archives before uploading the generated download manifest.json.

Runtime download and activation

Self-hosting is used for the Developer ID distribution. The app and downloader extension share group.no.blogspot.RawCull.model-assets. For an App Store build, the downloader can instead use Apple hosting; do not enable both hosting modes in one product.

When the user selects Download, RawCull:

  1. checks inclusion and release readiness;
  2. verifies any required licence acceptance;
  3. asks Managed Background Assets for the exact asset-pack ID;
  4. reports download progress and supports cancellation;
  5. resolves the catalogue-owned installed model path;
  6. validates metadata, tokenizer, runtime asset, fingerprint, and configuration;
  7. constructs the matching CLIP or SAM 3 provider only after validation passes.

Removing a model asks Managed Background Assets to remove its pack and then refreshes RawCull capabilities. Manually installed models and managed packs must not be merged into the same candidate directory.

Signing and provisioning

The application, downloader extension, and App Group identifiers are:

PurposeIdentifier
RawCull applicationno.blogspot.RawCull
Model downloader extensionno.blogspot.RawCull.ModelDownloader
Shared App Groupgroup.no.blogspot.RawCull.model-assets

Both App IDs must have the App Group capability. The app and extension use the same development team and provisioning setup. Verify the final archive contains and signs the nested downloader extension before notarizing the Developer ID release.

10.3 - AI Model Licence

AI Model Licence

AI model licence and provenance clearance procedure

Status: publication hold
Last reviewed: 2026-08-03

Purpose and current decision

This document defines how RawCull clears the DataComp CLIP, OpenAI CLIP, and Meta SAM 3 model packs for public download. It covers technical provenance, licence evidence, upstream contacts, questions to ask, acceptable answers, and the final release gate.

No .aar model archive has been uploaded. Do not create a public GitHub Release, publish a download manifest, or change a production descriptor to ready until every model is either:

  1. cleared under this procedure; or
  2. deliberately excluded from the release and manifest.

The current AARs are unpublished development artifacts. The safest remedy for the recorded provenance gaps is to create new release candidates from pinned, hashed source files after the applicable licence has been cleared.

This is an engineering and evidence-preservation procedure, not legal advice. For an unresolved interpretation, obtain advice from a qualified lawyer who works with software copyright, open-source licensing, AI model weights, and commercial distribution in Norway and the EEA.

What “resolved” means

There are two independent gates.

Provenance gate

RawCull must be able to demonstrate this complete chain:

upstream owner and repository
immutable revision and exact source-weight filename
SHA-256 of the downloaded source file
pinned conversion code, command, dependencies, and toolchain
SHA-256 of the converted runtime model
SHA-256 and byte size of the packaged AAR

A filename, a local cache timestamp, or a likely upstream snapshot is not a cryptographic binding. Re-exporting from a deliberately selected and hashed source is preferable to trying to infer the origin of an old conversion.

Licence and distribution gate

RawCull must have a defensible basis for all of the following:

  • the licence applies to the exact trained weight file, not only source code;
  • conversion into an Apple Core AI model is allowed;
  • the converted derivative can be redistributed to third parties;
  • the intended distribution can be public and commercial, if RawCull is commercially distributed;
  • all notices, agreement copies, acceptance steps, use restrictions, and attribution requirements have been implemented; and
  • a gated source checkpoint may be redistributed through RawCull’s proposed delivery mechanism, if applicable.

Silence, an unanswered ticket, a community member’s assumption, widespread third-party mirroring, or the technical ability to download a file does not resolve this gate. Prefer a written response from the model owner or an authorized representative. If that is unavailable, obtain a written opinion from qualified counsel or omit the model.

Current recorded evidence

The canonical records are:

  • ModelAssets/Notices/CLIP-DataComp/PROVENANCE.json
  • ModelAssets/Notices/CLIP-OpenAI/PROVENANCE.json
  • ModelAssets/Notices/SAM3/PROVENANCE.json
  • the licence and notice files beside each provenance record

The current relevant identifiers are:

PackUpstream revision presently recordedSource-weight evidenceConverted runtime SHA-256Open issue
DataComp CLIP4afec35ffe57a943d569ff7ee888061830164da8 is a reference revision, not exporter-recorded proofExact selected file and SHA-256 are missing70b6a8b3502626dbfd4e228dff1e060e06606c93e7a19c8f260dba95b9a7d01eRebuild from one explicitly selected upstream weight file and confirm its licence scope
OpenAI CLIPLocal cache snapshot 3d74acf9a28c67741b2f4f2ea7635f0aaf6f0268pytorch_model.bin, SHA-256 a63082132ba4f97a80bea76823f544493bffa8082296d62d71581a4feff1576f; not exporter-bound34b1c3f2eccfac50e5c47eeb33029b8c488fb7f3712d50d6d46965625a6a3798Confirm the exact weights’ licence and rebuild from the pinned source
SAM 3Local cache snapshot 3c879f39826c281e95690f02c7821c4de09afae7model.safetensors, SHA-256 6d06f0a5f84e435071fe6603e61d0b4cc7b40e0d39d487cfd4d67d8cc11cc14a; not exporter-bound43a9b88e40d193f5a6608a7fee536a78f4ba4ec5d95f1eb24db03031630f0a31Confirm whether an ungated public derivative download is compatible with Meta’s gated access flow, then rebuild

These hashes identify current evidence; they do not themselves approve a release.

Common provenance remediation

Perform these steps separately for every model that passes its licence gate.

  1. Choose one exact upstream repository, immutable commit, and source-weight file. Never use main, latest, an unpinned model alias, or an automatically changing download URL as the release input.
  2. Download into a new, dated evidence directory. Preserve the upstream URL, immutable revision, filename, byte size, and SHA-256 before conversion.
  3. Save the model card, licence or agreement, repository metadata, and any access terms as they appeared on the download date. Record their URLs and retrieval dates.
  4. Record the converter repository and commit, Apple coreai-models commit, coreai-core version, Python environment or package lock, conversion command, macOS version, Xcode version, and conversion timestamp.
  5. Run the conversion from that evidence directory. Do not allow the exporter to resolve or download a floating model identifier internally.
  6. Hash the complete converted model directory with the established directory-tree-sha256-v1 method and hash its runtime main.mlirb file.
  7. Validate the converted model with the same PhotoAIKit checks used by RawCull.
  8. Update the corresponding PROVENANCE.json with the actual source revision, source filename, source SHA-256, conversion command or record, tool versions, output fingerprints, and supporting evidence references.
  9. Rebuild the AAR using the explicit selectors. Verify that the chosen runtime model, tokenizer, metadata.json, and complete notice catalog are present, and that _source.aimodel and conversion intermediates are absent.
  10. Record the new AAR byte size and SHA-256. The previous unpublished AAR hash must not be reused for the rebuilt archive.

An example pinned Hugging Face acquisition has this form; select the correct source filename before running it:

hf download OWNER/MODEL SOURCE_WEIGHT_FILE \
  --revision IMMUTABLE_COMMIT \
  --local-dir /path/to/private/release-evidence/MODEL/source

shasum -a 256 \
  /path/to/private/release-evidence/MODEL/source/SOURCE_WEIGHT_FILE

Keep raw correspondence, access tokens, account data, and legal advice out of the public repository. Store them in private, access-controlled records. A public provenance summary may record the response date, organization, scope, and internal evidence-record identifier without publishing personal data or privileged legal advice.

DataComp CLIP clearance

Current position

The pinned DataComp repository page identifies the checkpoint as MIT licensed, which is positive evidence. The remaining technical problem is that the existing exporter record does not identify and hash the exact source-weight file it converted.

Official references:

Who to contact

  1. Email LAION at contact@laion.ai. LAION publishes this address on its official legal contact page.
  2. Open a discussion on the exact Hugging Face model repository so the question and any maintainer response are tied to that checkpoint.
  3. If necessary, open an issue with the OpenCLIP maintainers to identify which upstream file the datacomp_s34b_b86k configuration resolves. OpenCLIP can help with technical identity; the model owner or counsel should resolve licence scope.

Recorded LAION email request

On 2026-08-02 at 17:20 CEST, the RawCull maintainer emailed contact@laion.ai with the subject “Written clarification requested for DataComp CLIP weight licensing and redistribution.” The request identified:

  • repository: laion/CLIP-ViT-B-32-256x256-DataComp-s34B-b86K;
  • immutable revision: 4afec35ffe57a943d569ff7ee888061830164da8;
  • proposed source-weight file: open_clip_model.safetensors;
  • model configuration: OpenCLIP ViT-B-32-256 with datacomp_s34b_b86k weights;
  • transformation: a float16 Apple Core AI runtime representation for local photo similarity and text-to-image semantic search; and
  • delivery: an optional model archive downloaded by RawCull from a public GitHub Release, including possible use with a commercially distributed version of RawCull.

The email stated that RawCull has not publicly distributed the model or its converted derivative and will not redistribute the DataComp training dataset. It proposed preserving the source byte size and SHA-256 and including the applicable MIT licence, copyright and attribution notices, model information, provenance, and checksums with the archive.

LAION was asked to confirm whether the displayed MIT licence covers the exact weight file, conversion into the proposed runtime representation, public and commercial redistribution of the derivative, and whether any additional DataComp, OpenCLIP, attribution, acceptable-use, or other conditions apply. The request also asked whether the model card’s “out of scope” deployment language is safety guidance or an additional legal restriction.

Status: awaiting a substantive response from LAION or another person authorized to clarify the rights applicable to the weights. Sending the email does not clear the pack. Preserve the original message and any response in the private evidence register; do not add the maintainer’s personal email address or complete mail headers to this public repository.

Questions to ask

Identify the exact repository, revision, and proposed source file, then ask:

  1. Is that exact trained weight file offered under the MIT licence shown on the model repository?
  2. Does that permission cover conversion into another runtime representation and redistribution of the converted weights with a desktop application?
  3. Is public and commercial redistribution permitted, provided the MIT notice is included?
  4. Are any DataComp dataset terms, OpenCLIP terms, attribution requirements, or use restrictions additional to the displayed MIT licence applicable to the weight file?

Required technical work

  1. Select exactly one of the upstream weight files rather than leaving the exporter to resolve a model alias.
  2. Download it at revision 4afec35ffe57a943d569ff7ee888061830164da8 and record its SHA-256 and byte size.
  3. Re-export DataComp CLIP from that local file under the common procedure.
  4. Replace the null source revision and source checksum in ModelAssets/Notices/CLIP-DataComp/PROVENANCE.json.

Sufficient resolution

The pack may pass this gate when both conditions hold:

  • the new conversion has a complete pinned-and-hashed provenance chain; and
  • LAION or another demonstrably authorized model owner confirms the licence scope in writing, or qualified counsel concludes in writing that the repository’s MIT designation and accompanying materials are sufficient for the intended distribution.

If the answer is negative or remains materially ambiguous, replace the model with a checkpoint having explicit weight-level redistribution terms or omit the DataComp pack.

DataComp CLIP - no answer

If LAION does not provide a substantive answer after a documented follow-up, silence neither grants additional permission nor withdraws the permission already stated in the published materials. A release under the displayed MIT licence would rely on the public licence evidence rather than individualized clearance from LAION. Record it as a maintainer risk-acceptance decision, not as an upstream-approved or upstream-cleared release.

The evidence supporting that decision is:

  • the exact pinned model repository identifies the model as License: mit and contains the weight files;
  • Hugging Face’s licence documentation describes model-card licence metadata as communicating the permissions attributed to repository content; and
  • the MIT licence permits use, modification, publication, redistribution, sublicensing, and sale when its copyright and permission notice accompanies copies or substantial portions.

The residual ambiguity is that the model repository has MIT metadata but no standalone LICENSE file identifying the trained weights and their copyright holder. The OpenCLIP MIT notice clearly covers the OpenCLIP software, but an unanswered inquiry leaves no individualized confirmation that it is also the intended notice for the trained weights and converted derivative. The model card’s “out of scope” deployment language appears as safety and intended-use guidance rather than licence text, but this interpretation has not been confirmed by LAION. Qualified Norwegian counsel remains the recommended way to resolve that ambiguity before a public or commercial release.

If the maintainer nevertheless decides to release without an answer, complete all of the following before publication:

  1. Send and preserve one documented follow-up to LAION. Record the original request, follow-up date, response deadline, and absence of a substantive answer in the private evidence register.
  2. Select open_clip_model.safetensors from revision 4afec35ffe57a943d569ff7ee888061830164da8 as the only conversion input. Its pinned Hugging Face metadata reports a byte size of 605189364 and SHA-256 92c26d60d3200ed5ed040dff31a8d19f8140648da8007216c25744c478deef27. Download the file independently and verify both values before conversion.
  3. Re-export the Apple Core AI model from that pinned local file. Do not merely add the upstream hash to the provenance record for the existing conversion; the released derivative must be cryptographically tied to the verified source file.
  4. Preserve dated copies of the pinned repository tree, model card, repository API metadata, MIT licence, OpenCLIP notice, conversion command, dependency versions, and all input and output checksums.
  5. Package the complete applicable MIT copyright and permission notice, OpenCLIP and tokenizer notices, model card, safety limitations, provenance, and conversion information with every redistributed model archive. A link alone is not a substitute for including the required notice.
  6. Keep DataComp CLIP identified as a separate third-party model asset. RawCull’s own MIT licence does not relicense the model, and RawCull must not claim ownership of or permission to redistribute the DataComp training dataset.
  7. Complete the common provenance procedure, PhotoAIKit validation, AAR inspection, archive hashing, manifest verification, and download tests. Change PROVENANCE.json and the production catalogue to ready only after those technical controls describe the new release candidate accurately.
  8. Add a signed and dated release decision stating the evidence relied upon, the unresolved licence ambiguity, whether RawCull is free or commercial, the intended distribution countries and channels, and the responsible maintainer’s acceptance of the residual risk.
  9. Keep the model pack independently removable from the manifest and release so distribution can be suspended promptly if a credible rights claim or contrary clarification is received.

Releasing after these steps may provide a defensible MIT-compliance position, but it does not eliminate the residual legal risk created by the absence of a weight-specific licence file or an authorized response. This procedure records the evidence and decision; it is not a legal opinion.

OpenAI CLIP clearance

Current position

The OpenAI CLIP source repository contains an MIT licence covering the software and associated documentation. The Hugging Face checkpoint currently lacks a clear weight-level licence designation. A community discussion contains only an assumption that the repository licence covers the weights; that assumption is not authoritative evidence.

The model card also characterizes deployed uses as out of scope. Clarify whether this is safety guidance or an enforceable distribution/use condition, and independently assess RawCull’s use, testing, limitations, and disclosures.

Official references:

Who to contact

  1. Contact OpenAI Support using the chat control at help.openai.com. Ask that the request be routed to the team responsible for CLIP/open-source model licensing. Retain the ticket or conversation identifier.
  2. Submit the same narrowly framed question through the feedback form linked by the official CLIP model card.
  3. Open a model-specific discussion on the Hugging Face Community page using its New discussion action. This requires a Hugging Face login. Use a discussion rather than a pull request so the licensing question remains attached to the exact model distribution.
  4. A response from an OpenAI employee or repository maintainer authorized to address the model’s licence is preferred. An unsupported answer from another community member is not sufficient.

GitHub issue creation for openai/CLIP is currently restricted, so it should not be the only planned contact route.

Recorded support outcome and Hugging Face escalation

OpenAI Support declined to confirm or provide an authoritative interpretation that the MIT licence in openai/CLIP applies to the pretrained weight files in openai/clip-vit-base-patch32. Support also declined to confirm that the licence permits RawCull’s intended commercial redistribution. The response said that the applicable terms must be determined from the licence and notices shipped with the exact code and weights being used.

This response does not clear the pack. Record the current conclusion as:

CLIP source code: MIT licensed.
openai/clip-vit-base-patch32 pretrained weights: no explicit weight-specific
licence identified in the downloaded distribution, and OpenAI Support did not
confirm that the source-repository MIT licence applies.
Commercial redistribution: unresolved and blocked pending sufficient evidence.

The OpenAI Report Content form is intended for reports of potentially illegal or policy-violating content. It is not evidence of permission and should not be treated as the route for prospective licensing clearance. Support’s recommended next step was to ask the maintainers on the distribution source. For Hugging Face, use the model-specific Community page linked above rather than the general Hugging Face forum.

On 2026-08-02, the RawCull maintainer opened Hugging Face discussion #72, “License applicable to pretrained CLIP ViT-B/32 weights,” asking the OpenAI maintainers to identify the licence covering the hosted weights and to confirm whether it permits commercial use and redistribution. The request also asks the maintainers to add the applicable licence identifier or licence file to the model repository. Opening the discussion records the escalation but does not clear the pack; retain the response and assess the responder’s authority and the scope of any answer before changing the release decision.

Suggested discussion title:

Licence applicable to pretrained CLIP ViT-B/32 weights

Suggested discussion body:

Could the OpenAI maintainers clarify the licence applicable specifically to
the pretrained weight files in openai/clip-vit-base-patch32?

In particular, does OpenAI intend the MIT License from the official
openai/CLIP repository to cover the original ViT-B/32 checkpoint and the
converted weight files hosted here, including commercial use, conversion to
another runtime representation, and redistribution subject to the MIT
conditions?

If so, could you add the applicable licence identifier and/or LICENSE file to
this model repository so downstream users can establish a reliable licensing
record?

A maintainer comment may clarify intent, but the strongest resolution is a licence file, model-card licence declaration, or written statement from the rights holder that expressly covers the exact weights and proposed use. Until then, do not approve commercial redistribution of this pack.

Questions to ask

Provide this exact identity:

  • repository: openai/clip-vit-base-patch32;
  • revision: 3d74acf9a28c67741b2f4f2ea7635f0aaf6f0268;
  • file: pytorch_model.bin;
  • SHA-256: a63082132ba4f97a80bea76823f544493bffa8082296d62d71581a4feff1576f.

Ask:

  1. What licence governs this exact trained weight file?
  2. Does the OpenAI CLIP MIT licence apply to it, or is there a separate licence or set of terms?
  3. May RawCull convert the weights into an Apple Core AI representation and publicly redistribute that converted derivative as an optional desktop-app download, including in a commercial application?
  4. Which copyright notice, attribution, model card, use limitation, or other terms must accompany the derivative?
  5. Is the model card’s statement that deployed uses are out of scope guidance, or does OpenAI intend it as a legal restriction on deployment or redistribution?

Required technical work

After licence clearance, re-export from the exact local source file and revision above. Record the conversion command and bind the new output to the source SHA-256. Update the provenance record and rebuild the AAR.

Sufficient resolution

The pack may pass this gate only with:

  • an authoritative written statement identifying terms that permit the exact intended conversion and redistribution; or
  • a written legal opinion accepting a clearly documented alternative basis.

A response that merely restates the code repository’s MIT licence without addressing the trained weights is not sufficient. If OpenAI does not answer or the answer does not permit the proposed distribution, omit OpenAI CLIP or use a replacement checkpoint with explicit weight-level terms.

Meta SAM 3 clearance

Current position

The SAM License dated November 19, 2025 defines the trained weights as SAM Materials and grants rights to use, reproduce, distribute, modify, and create derivatives. Distribution must remain under that agreement and a copy of the agreement must accompany the materials. RawCull now packages the complete agreement and requires acceptance of its verified text.

The separate unresolved question arises because Meta’s official checkpoint is gated. The model page requires a user to log in and share contact information, and Meta’s repository instructs users to request access and authenticate before downloading. Hugging Face documents that a gated model’s authors control access. The licence text appears permissive about redistribution, but a public GitHub download would let downstream users obtain the derivative without going through Meta’s upstream access flow.

Official references:

Who to contact

  1. Email Meta Open Source at opensource@meta.com. Meta publishes this contact address on its official Open Source terms page. Ask that the request be routed to the SAM 3 model/licensing owner.
  2. Open a narrowly scoped issue in facebookresearch/sam3 and/or a discussion on facebook/sam3. Link the public question from the private email so the project team can answer in its preferred channel.
  3. Contact Hugging Face only for clarification of how its gate operates. Hugging Face is the hosting platform; its support cannot substitute for permission or interpretation from Meta as the model owner.
  4. If Meta does not give a clear response, obtain a written opinion from qualified counsel or omit SAM 3 from public hosting.

Questions to ask Meta

Provide this exact identity and delivery proposal:

  • repository: facebook/sam3;
  • revision: 3c879f39826c281e95690f02c7821c4de09afae7;
  • file: model.safetensors;
  • SHA-256: 6d06f0a5f84e435071fe6603e61d0b4cc7b40e0d39d487cfd4d67d8cc11cc14a;
  • transformation: float16 Apple Core AI derivative for local inference;
  • delivery: optional RawCull Managed Background Asset from a public GitHub Release;
  • licence handling: complete SAM License packaged with the derivative and explicit in-app acceptance tied to the licence-text SHA-256.

Ask:

  1. Does section 1 of the SAM License permit this converted derivative to be distributed from a public, ungated GitHub Release?
  2. Must every downstream RawCull user independently request access through Meta’s Hugging Face gate before receiving the converted derivative?
  3. If downstream gating is required, what information and approval must RawCull collect, and may RawCull technically administer that gate?
  4. Is packaging the complete agreement and requiring verified in-app acceptance sufficient to distribute under the same agreement?
  5. Are there additional attribution, branding, reporting, geographic, trade control, or prohibited-use measures RawCull must implement?
  6. Does the answer apply to both free and commercial distribution of RawCull?

Possible outcomes

Meta confirms public ungated redistribution. Preserve the response, comply with every stated condition, re-export from the pinned source, update the provenance record, and have counsel review any material ambiguity before release.

Meta requires each recipient to pass its gate. Do not publish SAM 3 as a public GitHub Release asset. Managed Background Assets with an anonymous public origin will not reproduce individualized Hugging Face approval. Omit SAM 3 or design a separate authenticated acquisition flow and review it independently.

Meta declines, gives an unclear answer, or does not respond. Keep SAM 3 blocked. Obtain a legal opinion or omit it. Do not treat the licence’s general redistribution wording as resolving a separately imposed access condition without an accountable decision.

Required technical work after clearance

Re-export from the recorded revision and model.safetensors SHA-256, recording the complete conversion command and environment. Preserve the full SAM License with the pack, maintain explicit acceptance against its checksum, and rebuild the AAR. Re-check the official licence immediately before publication because the agreement states that Meta may modify it.

Contact-request template

Use a real name and reply-capable address. Do not send an archive, proprietary source, access token, or confidential material unless the recipient requests it through an appropriate channel.

Subject: Written clarification requested for redistribution of [MODEL] in RawCull

Hello,

I maintain RawCull, a macOS photo-culling application:
https://github.com/rsyncOSX/RawCull

I have not published or uploaded the model derivative described below. I am
seeking written clarification before doing so.

Upstream repository: [URL]
Immutable revision: [COMMIT]
Source weight file: [FILENAME]
Source SHA-256: [SHA256]

RawCull converts this file to a float16 Apple Core AI model for local inference.
The proposed delivery is an optional Managed Background Asset downloaded from a
public GitHub Release. The archive will include the applicable complete licence,
notices, attribution, provenance, and checksums. [Describe explicit acceptance,
if applicable.] RawCull is [free/paid; choose the accurate description].

Please confirm:

1. Which licence or terms govern this exact trained weight file?
2. May it be converted into this runtime representation?
3. May the converted derivative be publicly redistributed in this manner,
   including as part of a commercial application if applicable?
4. What notices, acceptance flow, attribution, gating, use restrictions, or
   other conditions must RawCull implement?

I would appreciate a response from the model owner or a person authorized to
clarify its licensing and distribution conditions. If another team handles
this request, please route it or identify the correct contact.

Thank you,
[NAME]
[CONTACT DETAILS]

For SAM 3, append the six SAM-specific questions above. For OpenAI CLIP, append the exact question about whether the MIT licence covers the trained weights and whether the model card’s deployment language is guidance or a restriction. For DataComp, ask whether the repository’s MIT designation covers the exact selected file.

When to involve a lawyer

Engage a Norwegian lawyer before publication if any of these apply:

  • the model owner does not answer;
  • the answer is informal, conditional, or ambiguous;
  • a licence permits redistribution but an upstream gate suggests a different access policy;
  • commercial use, downstream acceptance, trade controls, prohibited uses, or indemnification requires interpretation;
  • the responder’s authority to bind the rights holder is uncertain; or
  • RawCull will distribute in multiple jurisdictions.

Look for counsel with experience in copyright, software and open-source licensing, technology transactions, AI model weights, EEA law, and US/EU trade controls. The Norwegian Bar Association member search can verify professional membership. The Norwegian Industrial Property Office also publishes guidance on choosing an IP adviser.

Give counsel a private evidence bundle containing:

  • the exact model card and licence captured on the download date;
  • upstream repository, revision, source filename, SHA-256, and download method;
  • the conversion recipe and explanation of what the derivative contains;
  • complete notice catalogs;
  • every upstream response with message headers, dates, ticket IDs, and links;
  • RawCull’s intended countries, free or paid status, distribution channels, end-user flow, and licence-acceptance UI; and
  • the proposed GitHub release and Managed Background Assets architecture.

Ask for a written conclusion for each model covering conversion, redistribution, commercial use, notices, downstream terms, gating, and any required technical controls. Keep privileged advice private. Record only the resulting release decision and non-confidential obligations in the repository, unless counsel approves broader disclosure.

Evidence and decision record

Maintain a private register with at least these fields:

FieldRequired content
ModelExact upstream owner and repository
Source identityImmutable revision, filename, byte size, SHA-256
Licence identityName/version, official URL, captured file SHA-256, retrieval date
Contact recordOrganization, channel, date, ticket/issue ID, responder and stated authority
Permission scopeConversion, derivative redistribution, public access, commercial use, territories
ConditionsNotices, attribution, acceptance, gating, use restrictions, trade controls
Legal reviewCounsel, date, private matter/reference number, approved/blocked conclusion
ConversionScript/commit, command, dependencies, toolchain, timestamp, output hashes
PackExplicit selector manifest, AAR byte size and SHA-256, notice verification
DecisionReady, blocked, replaced, or omitted; responsible approver and date

Do not mark a contact item complete merely because a message was sent. Record the actual answer and whether it addresses the exact file and proposed delivery.

Final release checklist

A pack can change from blocked to ready only when every applicable item is complete:

  • Exact upstream owner, repository, immutable revision, and source file are recorded.
  • Source byte size and SHA-256 were computed before conversion.
  • The licence is captured from an official source and its checksum is recorded.
  • The licence demonstrably covers the trained weights and converted derivative.
  • Public and commercial redistribution is permitted for RawCull’s actual delivery model.
  • Any gated-access question is answered by the owner or resolved by qualified counsel.
  • All required notices, agreement copies, attribution, acceptance, and use controls are implemented.
  • The model was re-exported from the pinned local source under a recorded command and environment.
  • Converted output fingerprints and runtime hashes are recorded.
  • PhotoAIKit validation passes.
  • A new AAR was built with explicit selectors and inspected.
  • The AAR byte size and SHA-256 are recorded.
  • PROVENANCE.json, NOTICE.md, and the application catalogue agree.
  • A responsible human has signed and dated the release decision.

If even one required item remains open, keep that pack blocked or omit it.

Publication sequence after clearance

RawCull’s current policy is to wait until all three model decisions are resolved. A decision to omit or replace a model counts as resolution only when it is documented and the omitted asset is absent from the manifest.

After the decisions are complete:

  1. Re-export every approved model from its pinned and hashed source.
  2. Update the notice catalogs and change only genuinely approved catalogue descriptors to ready.
  3. Rebuild and inspect the AARs; record their new hashes and sizes.
  4. Generate and inspect the self-hosted download manifest with a non-beta or corrected ba-package toolchain.
  5. Create the dedicated RawCull-AI-Models release as a draft and upload only approved packs, their required remote asset names, the manifest, and public evidence.
  6. Verify every URL, redirect, byte size, and checksum while authenticated to the draft if necessary.
  7. Run download, acceptance, validation, removal, and licence-change tests against a non-production environment.
  8. Publish the release only after a final human review confirms that the manifest contains no blocked model and all obligations are satisfied.

Official-source summary

The following sources were checked on 2026-08-02:

Recheck all licence text, model-card metadata, gates, contacts, and official links immediately before release because they can change.

10.4 - Evaluating CLIP Models

Evaluating CLIP Models Against Source-Framework Cosine Values and RawCullFB

Evaluating CLIP Models Against Source-Framework Cosine Values and RawCullFB

This document is the complete repeatable procedure for validating CLIP model bundles used by RawCullFB. It covers both supported candidates:

  • OpenAI CLIP ViT-B/32 at 224 × 224.
  • OpenCLIP DataComp ViT-B/32-256 with datacomp_s34b_b86k weights.

SigLIP2 is outside the current evaluation scope.

The procedure has two independent validation layers:

  1. Numerical parity: compare embeddings produced by the Core AI bundle with reference embeddings produced by the original Hugging Face Transformers or OpenCLIP implementation.
  2. Product behavior: use RawCullFB to build a real catalog index, execute the same 77 semantic queries, inspect image-similarity neighborhoods, and measure labeled retrieval quality.

A model must pass both layers. Numerical parity proves that the conversion and integration reproduce the source model; it does not prove that the model retrieves useful photographs. Conversely, plausible search results do not prove that the converted model is correct.

1. What cosine parity means

For a reference embedding r and Core AI embedding c, CLIPBench calculates:

cosine(r, c) = dot(r, c) / (norm(r) × norm(c))

It also reports the largest element-wise difference:

max_absolute_error = max(abs(r[i] - c[i]))

Both source and exported models produce L2-normalized embeddings. Identical normalized vectors have cosine 1.0. Float16 conversion, platform image decoding, and framework implementation details can produce small differences.

Use these gates:

Test pathMinimum cosinePurpose
Text embeddings0.999Tokenizer, text encoder, output selection, and normalization
Canonical lossless image inputs0.999Resize, crop, normalization, image encoder, and output selection
End-to-end JPEG/PNG fixtures0.998Real platform decoding plus preprocessing and inference

The existing ten-image fixture set contains JPEG files and one PNG. Its release gate is therefore 0.998. A strict 0.999 run is useful diagnostically but can fail because Apple ImageIO and Pillow decode some JPEG pixels differently. Do not lower a threshold to hide a broad or unexplained mismatch.

Raw semantic-search scores are different from parity cosine values:

  • Parity cosine compares the same embedding from two implementations.
  • Semantic score compares a text embedding with an image embedding.
  • Image-similarity score compares two image embeddings.

Do not apply the parity threshold to semantic search or duplicate detection.

2. Current validated components

Record the live values again whenever this procedure is run. As of 2026-08-12, the relevant local state is:

ComponentCurrent identity/path
PhotoAIKit used by RawCullFBrevision 6e3216027b267c27ccaf99d334807b18ea1aaec9
OpenAI bundle/Users/thomas/ModelAssets/Release/Models/CLIP-OpenAI
OpenAI source revision3d74acf9a28c67741b2f4f2ea7635f0aaf6f0268
DataComp bundle/Users/thomas/ModelAssets/Release/Models/CLIP-DataComp
DataComp presetViT-B-32-256 / datacomp_s34b_b86k
CLIPBench/Users/thomas/GitHubCLIPtests/CLIPBench
RawCullFB/Users/thomas/GitHub/RawCull/RawCullFB
PhotoAIKit source tools/Users/thomas/GitHub/RawCull/PhotoAIKit
Fixture set/Users/thomas/Library/Mobile Documents/com~apple~CloudDocs/TestPhotos/CLIPParityFixtures
Semantic catalog/Users/thomas/Library/Mobile Documents/com~apple~CloudDocs/TestPhotos/testphotos
Canonical 77 queries/Users/thomas/GitHub/RawCull/RawCullFB/semantictest.txt

The DataComp metadata now explicitly identifies datacomp_s34b_b86k; older bundles containing only a generic open_clip_model.safetensors label must not be reused without proving their weight identity.

3. Requirements

  • Apple Silicon Mac.
  • macOS 27 or the version required by the current projects.
  • Xcode 27 and its command-line tools.
  • uv for the pinned Python environments declared in the PhotoAIKit scripts.
  • Access to the Hugging Face/OpenCLIP weights when regenerating references or bundles.
  • Enough disk space for Python model caches, Core AI bundles, Xcode DerivedData, and two catalog indexes.
  • The exact same image bytes for source-framework and Core AI parity.

The Python reference scripts declare pinned package versions in their inline uv metadata. Do not change those dependencies during a comparison.

4. Establish one evaluation workspace

Open a new zsh terminal and define the paths once:

RAW_CULL_ROOT=/Users/thomas/GitHub/RawCull/RawCull
RAW_CULL_FB_ROOT=/Users/thomas/GitHub/RawCull/RawCullFB
PHOTO_AI_KIT_ROOT=/Users/thomas/GitHub/RawCull/PhotoAIKit
CLIP_BENCH_ROOT=/Users/thomas/GitHubCLIPtests/CLIPBench

FIXTURE_ROOT='/Users/thomas/Library/Mobile Documents/com~apple~CloudDocs/TestPhotos/CLIPParityFixtures'
REFERENCE_ROOT="$FIXTURE_ROOT/reference"
CATALOG_ROOT='/Users/thomas/Library/Mobile Documents/com~apple~CloudDocs/TestPhotos/testphotos'
QUERY_FILE="$RAW_CULL_FB_ROOT/semantictest.txt"

OPENAI_BUNDLE=/Users/thomas/ModelAssets/Release/Models/CLIP-OpenAI
DATACOMP_BUNDLE=/Users/thomas/ModelAssets/Release/Models/CLIP-DataComp

EVALUATION_ROOT='/Users/thomas/Library/Mobile Documents/com~apple~CloudDocs/TestPhotos/CLIPModelEvaluations'
mkdir -p "$REFERENCE_ROOT" "$EVALUATION_ROOT"

Keep these variables in the same terminal session. If a path changes, update it here rather than editing individual commands later.

Create a run identifier so reports are not overwritten:

RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_ROOT="$EVALUATION_ROOT/$RUN_ID"
mkdir -p "$RUN_ROOT"

5. Record the software and model identities

Record source revisions before building or generating references:

{
  date -u
  sw_vers
  xcodebuild -version
  xcrun swift --version
  uv --version
  git -C "$RAW_CULL_FB_ROOT" rev-parse HEAD
  git -C "$PHOTO_AI_KIT_ROOT" rev-parse HEAD
  git -C "$CLIP_BENCH_ROOT" rev-parse HEAD
} > "$RUN_ROOT/environment.txt"

Save the model metadata and hashes:

cp "$OPENAI_BUNDLE/metadata.json" "$RUN_ROOT/openai-metadata.json"
cp "$DATACOMP_BUNDLE/metadata.json" "$RUN_ROOT/datacomp-metadata.json"

shasum -a 256 \
  "$OPENAI_BUNDLE/metadata.json" \
  "$DATACOMP_BUNDLE/metadata.json" \
  > "$RUN_ROOT/model-metadata-sha256.txt"

Verify the important fields manually:

sed -n '1,130p' "$OPENAI_BUNDLE/metadata.json"
sed -n '1,130p' "$DATACOMP_BUNDLE/metadata.json"

Required OpenAI identity:

source_model: openai/clip-vit-base-patch32
source_revision: 3d74acf9a28c67741b2f4f2ea7635f0aaf6f0268
architecture: ViT-B-32
input: 1 × 3 × 224 × 224
resize/crop/interpolation: shortest-side / center / bicubic
embedding dimensions: 512
token context: 77
padding token: 49407

Required DataComp identity:

source_model: mlfoundations/open_clip
architecture: ViT-B-32-256
pretrained: datacomp_s34b_b86k
input: 1 × 3 × 256 × 256
resize/crop/interpolation: shortest-side / center / bicubic
embedding dimensions: 512
token context: 77
padding token: 0

Stop if a bundle’s checkpoint, input size, tokenizer, normalization, function names, or preprocessing metadata differs from the source reference that will be generated.

5.1 Optional: recreate the Core AI bundles

Skip this subsection when testing the existing release bundles. Use it when the converted assets themselves must be rebuilt. Export into an isolated staging directory; do not overwrite the release bundles before parity succeeds.

STAGING_MODEL_ROOT=/Users/thomas/ModelAssets/Staging/CLIP-Model-Evaluation
mkdir -p "$STAGING_MODEL_ROOT"

cd "$PHOTO_AI_KIT_ROOT"

uv run Tools/export_clip.py \
  --model openai \
  --dtype float16 \
  --output-dir "$STAGING_MODEL_ROOT" \
  --bundle-name CLIP-OpenAI

uv run Tools/export_clip.py \
  --model openclip-datacomp \
  --architecture ViT-B-32-256 \
  --pretrained datacomp_s34b_b86k \
  --dtype float16 \
  --output-dir "$STAGING_MODEL_ROOT" \
  --bundle-name CLIP-DataComp

The DataComp exporter verifies tokenizer IDs against OpenCLIP before completing. Inspect both staged metadata files and run the entire parity procedure against the staged paths. Promote a staged bundle only after it passes. If a staging bundle already exists, choose a new staging directory or intentionally use the exporter’s --overwrite option after verifying the exact target.

To evaluate the staged bundles in the remaining commands, replace the definitions from Section 4:

OPENAI_BUNDLE="$STAGING_MODEL_ROOT/CLIP-OpenAI"
DATACOMP_BUNDLE="$STAGING_MODEL_ROOT/CLIP-DataComp"

6. Verify PhotoAIKit and RawCullFB use the same implementation

RawCullFB currently pins PhotoAIKit remotely. Confirm the project and resolved lock file agree:

rg -n -C 5 'PhotoAIKit|photoaikit' \
  "$RAW_CULL_FB_ROOT/RawCullFB.xcodeproj/project.pbxproj" \
  "$RAW_CULL_FB_ROOT/RawCullFB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"

CLIPBench currently uses /Users/thomas/GitHubCLIPtests/PhotoAIKit through ../PhotoAIKit. Confirm that checkout is the same revision and contains the Pillow-compatible preprocessing fix:

git -C /Users/thomas/GitHubCLIPtests/PhotoAIKit rev-parse HEAD
rg -n 'pillowBicubicPreprocessingVersion' \
  /Users/thomas/GitHubCLIPtests/PhotoAIKit/Sources/CoreAICLIPBackend/CoreAICLIPProvider.swift

The revision must equal the PhotoAIKit revision resolved by RawCullFB. If it does not, update the dependency or checkout before proceeding. Otherwise CLIPBench and RawCullFB would test different implementations.

7. Verify the immutable fixture set

The fixture set must contain ten exact images plus manifest.json:

find "$FIXTURE_ROOT/images" -maxdepth 1 -type f -print | sort
sed -n '1,220p' "$FIXTURE_ROOT/manifest.json"

Expected images:

01-dog-outdoors.jpg
02-person-portrait.jpg
03-car-road.jpg
04-city-night.jpg
05-mountain-landscape.png
06-beetle-macro.jpg
07-kitchen-interior.jpg
08-motion-blur.jpg
09-sign-text.jpg
10-yellow-sunflower.jpg

Compute and save their hashes:

shasum -a 256 "$FIXTURE_ROOT"/images/0*.* \
  "$FIXTURE_ROOT"/images/10-yellow-sunflower.jpg \
  > "$RUN_ROOT/fixture-sha256.txt"

Compare every hash with manifest.json. Stop if a file is missing or changed. A regenerated reference from different image bytes is a different benchmark.

The ten prompts come from manifest.json and must remain in the same order as the images.

8. Test PhotoAIKit before model parity

Run the focused preprocessing/text tests and then the complete package suite:

cd "$PHOTO_AI_KIT_ROOT"
swift test --filter CLIPTextCapabilityTests
swift test

Required result: all tests pass. In particular, the suite must cover:

  • tokenizer construction and padding behavior;
  • center crop rather than stretch;
  • integer-floor crop origin;
  • Pillow-compatible bicubic pixels;
  • top-to-bottom orientation;
  • output shape and L2 normalization; and
  • preprocessing-version cache invalidation.

Do not start model parity if these tests fail.

9. Generate the OpenAI Hugging Face reference

Use the fixture-specific script because it writes:

  • normalized embeddings consumed by CLIPBench;
  • exact preprocessed pixel tensors;
  • token IDs and attention masks;
  • raw and normalized image/text embeddings;
  • the 10 × 10 image-to-text cosine matrix; and
  • reference rankings.

Generate all artifacts:

cd "$FIXTURE_ROOT"

uv run Scripts/generate_openai_clip_reference.py \
  --manifest "$FIXTURE_ROOT/manifest.json" \
  --output-directory "$REFERENCE_ROOT"

Expected outputs:

openai-clip-reference.json
openai-clip-reference-details.json
openai-clip-reference-tensors.npz

Verify that the Hugging Face revision recorded in the details file exactly equals the bundle’s source_revision:

rg -n 'model_id|model_revision|transformers_version|torch_version' \
  "$REFERENCE_ROOT/openai-clip-reference-details.json"

rg -n 'source_model|source_revision' "$OPENAI_BUNDLE/metadata.json"

If the revisions differ, the reference and Core AI bundle do not represent the same source model. Stop and regenerate one side from the intended pinned revision.

Save reference hashes:

shasum -a 256 \
  "$REFERENCE_ROOT/openai-clip-reference.json" \
  "$REFERENCE_ROOT/openai-clip-reference-details.json" \
  "$REFERENCE_ROOT/openai-clip-reference-tensors.npz" \
  > "$RUN_ROOT/openai-reference-sha256.txt"

10. Generate the DataComp OpenCLIP reference

DataComp weights may be hosted through model registries, but the authoritative runtime behavior for this model is the pinned OpenCLIP implementation—not the OpenAI Hugging Face Transformers class. Use the same architecture and pretrained tag used to export the Core AI bundle.

First record the OpenCLIP registry configuration:

cd "$PHOTO_AI_KIT_ROOT"

uv run --with open_clip_torch==3.2.0 python -c \
  'import json, open_clip; print(json.dumps(open_clip.get_pretrained_cfg("ViT-B-32-256", "datacomp_s34b_b86k"), indent=2, default=str))' \
  > "$RUN_ROOT/datacomp-openclip-registry.json"

Generate the reference directly under the fixture reference directory so all image paths remain valid relative paths:

uv run Tools/generate_clip_reference.py \
  --model openclip-datacomp \
  --architecture ViT-B-32-256 \
  --pretrained datacomp_s34b_b86k \
  --image "$FIXTURE_ROOT/images/01-dog-outdoors.jpg" \
  --image "$FIXTURE_ROOT/images/02-person-portrait.jpg" \
  --image "$FIXTURE_ROOT/images/03-car-road.jpg" \
  --image "$FIXTURE_ROOT/images/04-city-night.jpg" \
  --image "$FIXTURE_ROOT/images/05-mountain-landscape.png" \
  --image "$FIXTURE_ROOT/images/06-beetle-macro.jpg" \
  --image "$FIXTURE_ROOT/images/07-kitchen-interior.jpg" \
  --image "$FIXTURE_ROOT/images/08-motion-blur.jpg" \
  --image "$FIXTURE_ROOT/images/09-sign-text.jpg" \
  --image "$FIXTURE_ROOT/images/10-yellow-sunflower.jpg" \
  --text "a woman with a dog outdoors" \
  --text "a portrait of a woman wearing sunglasses" \
  --text "an old car driving on a country road" \
  --text "an illuminated city building at night" \
  --text "mountains surrounding a large lake" \
  --text "a macro photograph of an insect" \
  --text "the interior of a kitchen" \
  --text "a colorful amusement ride with motion blur" \
  --text "a green road sign with white text" \
  --text "a yellow sunflower" \
  --output "$REFERENCE_ROOT/datacomp-clip-reference.json"

Verify identity, counts, and relative paths:

sed -n '1,25p' "$REFERENCE_ROOT/datacomp-clip-reference.json"
rg -c '"path"' "$REFERENCE_ROOT/datacomp-clip-reference.json"
rg -c '"text"' "$REFERENCE_ROOT/datacomp-clip-reference.json"

Required source block:

model: openclip-datacomp
architecture: ViT-B-32-256
pretrained: datacomp_s34b_b86k

Required counts: 10 images and 10 texts.

Each image path must resolve from REFERENCE_ROOT. Test all paths before parity:

cd "$REFERENCE_ROOT"
test -f ../images/01-dog-outdoors.jpg
test -f ../images/10-yellow-sunflower.jpg

Do not reuse a DataComp reference containing paths to a deleted Downloads/CLIPParityFixtures directory or the obsolete missing _DSC7115.ARW.png fixture.

Save its hash:

shasum -a 256 "$REFERENCE_ROOT/datacomp-clip-reference.json" \
  > "$RUN_ROOT/datacomp-reference-sha256.txt"

11. Build CLIPBench from the intended PhotoAIKit revision

Verify its dependency first:

cd "$CLIP_BENCH_ROOT"
sed -n '1,45p' Package.swift
git -C ../PhotoAIKit rev-parse HEAD

Then test and build:

swift test
swift build -c release --product clipbench

Use the freshly built executable:

/Users/thomas/GitHubCLIPtests/CLIPBench/.build/release/clipbench

Inspect both bundles through the same runtime that will perform parity:

.build/release/clipbench inspect-model --model "$OPENAI_BUNDLE" \
  | tee "$RUN_ROOT/openai-model-inspection.txt"

.build/release/clipbench inspect-model --model "$DATACOMP_BUNDLE" \
  | tee "$RUN_ROOT/datacomp-model-inspection.txt"

Confirm that the printed source, architecture, pretrained tag, input geometry, tokenizer version, embedding dimensions, and model fingerprint match the recorded metadata.

12. Run OpenAI Core AI parity

Run the release gate and preserve its output:

cd "$CLIP_BENCH_ROOT"
set -o pipefail

.build/release/clipbench parity \
  --reference "$REFERENCE_ROOT/openai-clip-reference.json" \
  --model "$OPENAI_BUNDLE" \
  --minimum-cosine 0.998 \
  | tee "$RUN_ROOT/openai-parity.txt"

Expected characteristics from the corrected implementation:

  • all text rows approximately 1.0000;
  • nine image rows at or above approximately 0.999;
  • minimum image cosine approximately 0.9988 for the existing JPEG-heavy fixture set; and
  • command exits successfully at the 0.998 threshold.

Also run the strict diagnostic threshold:

.build/release/clipbench parity \
  --reference "$REFERENCE_ROOT/openai-clip-reference.json" \
  --model "$OPENAI_BUNDLE" \
  --minimum-cosine 0.999 \
  > "$RUN_ROOT/openai-parity-strict.txt" 2>&1

This strict end-to-end command may return nonzero because of the known JPEG decoder variance. Preserve the output; do not treat that specific documented result as a model failure if the 0.998 release gate passes and lossless tests remain green.

13. Run DataComp Core AI parity

Use its independently generated OpenCLIP reference:

cd "$CLIP_BENCH_ROOT"
set -o pipefail

.build/release/clipbench parity \
  --reference "$REFERENCE_ROOT/datacomp-clip-reference.json" \
  --model "$DATACOMP_BUNDLE" \
  --minimum-cosine 0.998 \
  | tee "$RUN_ROOT/datacomp-parity.txt"

Required result:

  • command exits successfully;
  • every text row is at least 0.999;
  • end-to-end minimum across all rows is at least 0.998; and
  • no individual maximum-absolute-error value is unexpectedly large relative to the other fixtures.

Run the strict diagnostic threshold as well:

.build/release/clipbench parity \
  --reference "$REFERENCE_ROOT/datacomp-clip-reference.json" \
  --model "$DATACOMP_BUNDLE" \
  --minimum-cosine 0.999 \
  > "$RUN_ROOT/datacomp-parity-strict.txt" 2>&1

Do not assume DataComp inherits OpenAI’s numerical result merely because both use the same PhotoAIKit preprocessing code. It has different weights, input resolution, padding behavior, and exported functions.

14. Diagnose parity failures

Use the failure pattern to select the first investigation:

PatternLikely first checks
Text and images failWrong checkpoint, stale CLIPBench build, wrong model asset, or wrong output tensor
Text fails; images passToken IDs, padding token, EOT handling, attention mask, text output, or normalization
Text passes; all images failResize dimension, crop origin, interpolation, color order, mean/std, orientation, image output, or normalization
Only JPEG images are slightly below 0.999Apple ImageIO versus Pillow decoding
One image fails badlyMissing/corrupt fixture, EXIF orientation, unsupported decoding, or stale relative path
Correct cosine but changed top-k on a fixture matrixNear-tie or ranking instability; inspect exact cosine matrix

For OpenAI, inspect openai-clip-reference-tensors.npz and the details JSON to isolate:

  1. preprocessed pixels;
  2. token IDs and masks;
  3. raw model outputs;
  4. normalized outputs; and
  5. the cosine/ranking matrix.

For DataComp, extend the reference generator to emit equivalent intermediate tensors if a parity failure cannot be localized from embeddings alone.

Parity failure blocks RawCullFB quality evaluation. Fix the integration before judging semantic retrieval.

15. Build and test RawCullFB

Confirm the project has no unexpected changes and still resolves the intended PhotoAIKit revision:

cd "$RAW_CULL_FB_ROOT"
git status --short
rg -n -C 5 'PhotoAIKit|photoaikit' \
  RawCullFB.xcodeproj/project.pbxproj \
  RawCullFB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Run its tests:

xcodebuild \
  -project RawCullFB.xcodeproj \
  -scheme RawCullFB \
  -destination 'platform=macOS,arch=arm64' \
  test

Build an unsigned Release product for local validation:

xcodebuild \
  -project RawCullFB.xcodeproj \
  -scheme RawCullFB \
  -configuration Release \
  -destination 'platform=macOS,arch=arm64' \
  -derivedDataPath /tmp/RawCullFB-CLIP-Evaluation \
  CODE_SIGNING_ALLOWED=NO \
  build

Expected product:

/tmp/RawCullFB-CLIP-Evaluation/Build/Products/Release/RawCullFB.app

CODE_SIGNING_ALLOWED=NO validates compilation only. Use the project’s normal signing, archive, notarization, and DMG workflow for distribution.

16. Freeze the semantic test catalog and queries

Both models must use:

  • the exact same catalog root;
  • the exact same image bytes;
  • the exact same query file;
  • the same result limit;
  • a complete model-specific index; and
  • the same RawCullFB/PhotoAIKit revisions.

Copy the canonical query file into the catalog root under the required name:

cp "$QUERY_FILE" "$CATALOG_ROOT/semantictest.txt"
wc -l "$CATALOG_ROOT/semantictest.txt"
cmp "$QUERY_FILE" "$CATALOG_ROOT/semantictest.txt"

Expected query count: 77 non-comment queries.

Record catalog files without including RawCullFB’s hidden index or generated reports:

find "$CATALOG_ROOT" -type f \
  ! -path '*/.clipbench/*' \
  ! -name '*-semantic-test-results.txt' \
  ! -name 'semantictest.txt' \
  -print | sort > "$RUN_ROOT/catalog-files.txt"

wc -l "$RUN_ROOT/catalog-files.txt"
shasum -a 256 "$CATALOG_ROOT/semantictest.txt" \
  > "$RUN_ROOT/semantic-query-sha256.txt"

For strict reproducibility, compute content hashes for the complete catalog and save them with the run. This can be slow for a large photo collection.

17. RawCullFB test for OpenAI CLIP

Launch the validated RawCullFB build, then:

  1. Open RawCullFB > Settings > CLIP.
  2. Choose /Users/thomas/ModelAssets/Release/Models/CLIP-OpenAI.
  3. Wait until verification reports a valid model and note its fingerprint.
  4. Select CATALOG_ROOT as the photo-folder root.
  5. Choose Index Selected Folder or Update Index.
  6. Wait until indexing completes with no unexplained failures.
  7. Confirm search is enabled and the index belongs to the selected model.
  8. Set Maximum results to 50.
  9. Choose Run Model Test in the main toolbar.
  10. Wait for all 77 queries and the image-similarity evaluation to finish.

RawCullFB writes:

<model-name>-semantic-test-results.txt

in the selected catalog root. Immediately preserve it under the run directory:

find "$CATALOG_ROOT" -maxdepth 1 -type f \
  -name '*semantic-test-results.txt' -print

cp "$CATALOG_ROOT/ViT-B-32-semantic-test-results.txt" \
  "$RUN_ROOT/openai-rawcullfb-results.txt"

If the generated filename differs, use the filename RawCullFB reports rather than assuming the example name.

18. RawCullFB test for DataComp CLIP

Repeat the workflow without changing catalog or query settings:

  1. Open RawCullFB > Settings > CLIP.
  2. Choose /Users/thomas/ModelAssets/Release/Models/CLIP-DataComp.
  3. Wait for valid model verification and record its distinct fingerprint.
  4. Keep the same CATALOG_ROOT selected.
  5. Choose Index Selected Folder or Update Index.
  6. Confirm RawCullFB creates/uses a DataComp-specific .clipbench/clip-<model-hash>.clipindex.
  7. Wait for the complete DataComp index; never evaluate a partial or stale index.
  8. Keep the result limit at 50.
  9. Choose Run Model Test.
  10. Wait for all semantic queries and image-similarity anchors to complete.

Preserve the report:

cp "$CATALOG_ROOT/datacomp_s34b_b86k-semantic-test-results.txt" \
  "$RUN_ROOT/datacomp-rawcullfb-results.txt"

Again, use the actual filename if RawCullFB emits a different model label.

OpenAI and DataComp indexes must remain separate. A model fingerprint, preprocessing version, or dimension mismatch must invalidate reuse. If switching models does not require its own index, stop and inspect index identity handling.

19. Validate RawCullFB result-file integrity

Inspect both headers:

for REPORT in \
  "$RUN_ROOT/openai-rawcullfb-results.txt" \
  "$RUN_ROOT/datacomp-rawcullfb-results.txt"
do
  echo "--- $REPORT"
  sed -n '1,18p' "$REPORT"
done

Require:

  • correct model name;
  • correct and distinct model fingerprint;
  • identical catalog path;
  • status equal to completed;
  • completed_queries equal to 77;
  • total_queries equal to 77;
  • identical result limit, normally 50; and
  • similarity_status equal to completed.

Count query and anchor sections:

for REPORT in \
  "$RUN_ROOT/openai-rawcullfb-results.txt" \
  "$RUN_ROOT/datacomp-rawcullfb-results.txt"
do
  printf '%s\tqueries=' "$REPORT"
  rg -c '^QUERY\t' "$REPORT"
  printf '%s\tanchors=' "$REPORT"
  rg -c '^ANCHOR\t' "$REPORT"
done

The established catalog contains 453 similarity anchors. If the catalog changes, record and explain the new anchor count; do not compare it silently with an older run.

20. Semantic sanity checks before labeling

Calculate or inspect at least:

  • distinct top-1 images across 77 queries;
  • most frequent top-1 image and its frequency;
  • unique images appearing across all top-50 results;
  • mean top-1 minus top-2 score margin;
  • mean shared top-10 results across all query pairs;
  • mean shared top-10 results within the five paraphrase groups;
  • first-query time; and
  • warmed mean, median, and P95 query time.

The five paraphrase groups are queries 63–77:

  1. dog on a beach;
  2. city street at night;
  3. sharp portrait;
  4. blurry photograph; and
  5. beautiful landscape.

Expected healthy behavior:

  • unrelated prompts do not all return the same small pool;
  • no single universal image wins a large fraction of unrelated queries;
  • paraphrase overlap is materially higher than unrelated-query overlap;
  • object prompts return visibly plausible objects in top positions; and
  • query latency stabilizes after model warm-up.

The corrected OpenAI integration previously produced 53 distinct top-1 images, a maximum hub frequency of 4/77, and substantially higher paraphrase than general overlap. Treat a return to the old collapsed pattern as an integration or index problem.

These are sanity checks, not accuracy metrics.

21. Create one pooled relevance-label set

For every query, pool the first five results from both models:

OpenAI top five union DataComp top five

Deduplicate identical query/image pairs and, if practical, hide the originating model during review. Create a CSV:

query_id,query,file,path,openai_rank,datacomp_rank,relevance,notes

Use the same graded scale for both models:

RelevanceDefinition
2Clearly and directly satisfies the query
1Partially relevant, ambiguous, or missing an important attribute/relation
0Irrelevant

Inspect the actual image. Do not label from filenames, cosine scores, or which model retrieved it.

Calculate for each model:

  • Precision@1;
  • Precision@5;
  • mean reciprocal rank (MRR);
  • nDCG@5 using relevance 0/1/2; and
  • metrics by query category.

Categories should include:

  • objects and scenes;
  • colors and attributes;
  • actions and relations;
  • photographic quality/style;
  • composition;
  • count, spatial relation, and negation; and
  • paraphrases.

With only 77 queries, report raw counts as well as averages. Do not select a model from a tiny aggregate difference without reviewing category failures and uncertainty.

22. Evaluate image similarity separately

RawCullFB’s anchor neighborhoods are useful for finding obvious failures but are not a calibrated duplicate benchmark. Build a labeled pair set containing:

  • exact duplicates;
  • resized/re-encoded duplicates;
  • color and exposure edits;
  • crops;
  • adjacent burst frames;
  • same subject in a different photograph;
  • semantically related but visually different hard negatives; and
  • unrelated negatives.

For each model, calculate:

  • ROC-AUC;
  • precision-recall AUC;
  • false-positive rate at the chosen recall;
  • false-negative rate at the chosen threshold; and
  • a confusion matrix for the proposed action.

Choose separate thresholds for OpenAI and DataComp. Their cosine distributions are not interchangeable. A threshold suitable for suggestions is not automatically safe for grouping, deletion, or another destructive action.

23. Compare operational behavior

Use the same Mac, OS, app revision, catalog, and measurement method for both models. Record:

  • model bundle size;
  • cold model-load/first-query latency;
  • warmed P50/P95/P99 search latency;
  • image-indexing throughput;
  • peak memory during indexing and searching;
  • complete index size; and
  • energy impact if available.

Run at least 30 unmeasured warm-up queries before measuring steady-state latency. DataComp uses 256 × 256 inputs versus OpenAI’s 224 × 224 and may have a different indexing cost even though both produce 512-dimensional embeddings.

24. Decision table

Complete this table and save it with the run:

Gate or metricOpenAI ViT-B/32DataComp ViT-B/32-256Decision
Source identity verified
Minimum text parity
Minimum end-to-end image parity
Precision@1
Precision@5
MRR
nDCG@5
Weakest query category
Largest semantic hub
Paraphrase top-10 overlap
Similarity PR-AUC
Similarity FPR at selected recall
Cold first-query latency
Warm P95 latency
Indexing throughput
Peak memory
Bundle size
Index size

Selection rules:

  • Reject any model whose source identity or numerical parity is unresolved.
  • Reject any model showing semantic collapse or unacceptable labeled quality.
  • Never select a model because it produces numerically higher raw similarity scores.
  • Prefer DataComp only if it delivers a material labeled-quality improvement at acceptable operational cost.
  • If quality is effectively tied, OpenAI is the simpler established control.
  • It is acceptable to ship OpenAI and keep DataComp experimental.

25. Archive the complete evidence package

At minimum, RUN_ROOT should contain:

environment.txt
fixture-sha256.txt
semantic-query-sha256.txt
catalog-files.txt
openai-metadata.json
datacomp-metadata.json
model-metadata-sha256.txt
openai-reference-sha256.txt
datacomp-reference-sha256.txt
datacomp-openclip-registry.json
openai-model-inspection.txt
datacomp-model-inspection.txt
openai-parity.txt
openai-parity-strict.txt
datacomp-parity.txt
datacomp-parity-strict.txt
openai-rawcullfb-results.txt
datacomp-rawcullfb-results.txt
relevance-labels.csv
semantic-metrics.json
similarity-pairs.csv
similarity-metrics.json
decision.md

Also preserve the exact source reference JSON files or their immutable hashes. The reference JSON stores paths relative to its own directory; moving only the JSON without its fixture images can break future parity runs.

26. Final release checklist

Shared implementation

  • PhotoAIKit focused and full tests pass.
  • CLIPBench and RawCullFB use the same PhotoAIKit revision.
  • RawCullFB tests and Release build pass.
  • Model metadata and asset fingerprints are archived.

OpenAI

  • Hugging Face revision matches bundle source_revision.
  • Ten-image/ten-text reference regenerated from immutable fixtures.
  • Text parity is at least 0.999.
  • End-to-end fixture parity is at least 0.998.
  • RawCullFB completes 77 queries and all anchors using a fresh model-specific index.

DataComp

  • Architecture and datacomp_s34b_b86k identity are recorded.
  • OpenCLIP registry configuration is archived.
  • DataComp reference is regenerated from the same preset and fixtures.
  • All reference image paths resolve.
  • Text parity is at least 0.999.
  • End-to-end fixture parity is at least 0.998.
  • RawCullFB completes 77 queries and all anchors using a fresh model-specific index.

Product quality

  • Pooled top-five results are labeled consistently.
  • Precision@1, Precision@5, MRR, and nDCG@5 are reported.
  • Category-level failures are reviewed.
  • No universal semantic hub exists.
  • Paraphrases are more consistent than unrelated prompts.
  • Similarity positive and hard-negative pairs are labeled.
  • Model-specific similarity thresholds meet the approved false-positive ceiling.
  • Latency, throughput, memory, bundle size, and index size are acceptable.
  • The final OpenAI/DataComp decision is documented.

27. Short execution order

When repeating the evaluation later:

  1. Define paths and create RUN_ROOT.
  2. Record software revisions and model metadata.
  3. Verify immutable fixture hashes.
  4. Test PhotoAIKit.
  5. Generate the pinned OpenAI Hugging Face reference.
  6. Generate the exact DataComp OpenCLIP reference.
  7. Build CLIPBench from the matching PhotoAIKit revision.
  8. Run and archive both parity tests.
  9. Test and compile RawCullFB.
  10. Freeze the catalog and 77-query file.
  11. Build a clean OpenAI index and run the RawCullFB model test.
  12. Build a clean DataComp index and run the same test.
  13. Validate report integrity and collapse diagnostics.
  14. Label the pooled top-five results.
  15. Calculate semantic and image-similarity metrics.
  16. Compare operational costs.
  17. Complete the decision table and release checklist.

The result is defensible only when the archived evidence links the exact source checkpoint, converted asset, preprocessing implementation, fixture bytes, catalog, query file, RawCullFB build, and human relevance labels.

10.5 - New RawCull AI models

Release runbook for publishing DataComp CLIP, OpenAI CLIP, and Meta SAM 3.

Publishing new RawCull AI models

This runbook publishes exactly three optional models:

ModelAsset-pack IDInstalled destination
DataComp CLIPno.blogspot.RawCull.models.clip-datacompModels/CLIP-DataComp
OpenAI CLIPno.blogspot.RawCull.models.clip-openaiModels/CLIP-OpenAI
Meta SAM 3no.blogspot.RawCull.models.sam3Models/SAM3

SigLIP2 and EfficientSAM are deliberately excluded. Do not add them to the release staging tree, packaging manifests, download manifest, production catalogue, settings UI, notices, or tests.

The examples use the v2 release in RawCull-AI-Models. Tag names and GitHub release URLs are case-sensitive. If another tag is chosen, replace v2 everywhere and keep both RawCull manifest URLs identical.

The current v2 manifest publishes DataComp CLIP and OpenAI CLIP only. Meta SAM 3 remains blocked and must not be uploaded or added to the deployable manifest until its redistribution review is complete.

1. Build the three canonical bundles

Follow AI Model Download Service for the pinned source checks, exact PhotoAIKit commands, runtime validation, and packaging layout. The canonical release outputs are:

CLIP-DataComp/
├── metadata.json
├── tokenizer/
└── ViT-B-32-256-datacomp_s34b_b86k_float16_static.aimodel/

CLIP-OpenAI/
├── metadata.json
├── tokenizer/
└── clip-vit-base-patch32_float16_static.aimodel/

SAM3/
├── metadata.json
├── tokenizer/
└── sam3_float16.aimodel/

Use PhotoAIKit revision 6e3216027b267c27ccaf99d334807b18ea1aaec9, or document and review the exact later revision actually used. Release CLIP bundles must use Float16, static batch dimensions, metadata version 0.4, separate image_encoder and text_encoder functions, and corrected bicubic preprocessing. The SAM 3 bundle currently uses metadata version 0.2.

DataComp must be exported with:

uv run --script Tools/export_clip.py \
  --model openclip-datacomp \
  --architecture ViT-B-32-256 \
  --pretrained datacomp_s34b_b86k \
  --output-dir /path/to/export \
  --bundle-name CLIP-DataComp \
  --dtype float16

Do not use --pretrained open_clip_model.safetensors. The historical bundle with open_clip_model.safetensors in its runtime name produced collapsed semantic rankings. The corrected tested bundle uses the registered datacomp_s34b_b86k identity and runtime filename.

Keep every *_source.aimodel as private conversion evidence. Never select it in a packaging manifest or place it in a downloadable pack.

2. Validate model behavior before packaging

For each bundle:

  1. Confirm metadata.json selects the optimized runtime in assets.main.
  2. Recompute the runtime directory fingerprint with PhotoAIKit/Tools/model_fingerprint.py and compare it with asset_fingerprints.main.
  3. Run swift test for the exact PhotoAIKit revision used to convert it.
  4. Install the complete candidate in a clean RawCull or RawCullFB model path.
  5. Rebuild all model-specific indexes after every model or fingerprint change.

For both CLIP models, run PyTorch/Core AI parity and then the same RawCullFB semantictest.txt. The report must include semantic-search rankings and image-to-image similarity comparisons. Review rankings, score spread, repeated results, failures, and elapsed time. Do not compare models against a reused index.

For SAM 3, test text prompts, mask dimensions and placement, licence acceptance, relaunch, and removal.

3. Clear release blockers

Do not mark a model .ready because it works locally.

DataComp CLIP

Confirm that the new archive uses the pinned DataComp checkpoint and corrected runtime filename. Replace the existing archive checksum and byte count whenever the bundle or packaging changes. Update its notice and provenance records; do not reuse values from the old custom-named runtime.

OpenAI CLIP

Before enabling the OpenAI pack:

  1. bind the pinned checkpoint revision and source SHA-256 to the conversion evidence;
  2. confirm that the applicable terms cover the exact trained weights and their redistribution, not only source code and tokenizer files;
  3. record the PhotoAIKit revision, command, tokenizer checksum, runtime fingerprint, archive checksum, and byte count in the external release catalogue;
  4. include the complete required notices in Notices/CLIP-OpenAI.

Leave the descriptor blocked and omit its pack from a public manifest while weight-level redistribution clearance is unresolved.

Meta SAM 3

Before enabling the SAM 3 pack:

  1. preserve the accepted gated-source revision and source SHA-256 as private evidence;
  2. confirm that an ungated GitHub release of the converted derivative complies with the SAM License and Meta’s gated checkpoint access conditions;
  3. record the PhotoAIKit revision, command, tokenizer checksum, runtime fingerprint, archive checksum, and byte count in the external release catalogue;
  4. include the complete SAM License and notices in Notices/SAM3;
  5. keep explicit in-app licence acceptance enabled.

Technical provenance does not resolve the redistribution decision. Omit SAM 3 from the public manifest until that review is complete.

4. Prepare release staging

The staging tree must contain only the three approved runtime bundles and their notice catalogues:

ModelAssets/Release/
├── Models/
│   ├── CLIP-DataComp/
│   ├── CLIP-OpenAI/
│   └── SAM3/
├── Notices/
│   ├── CLIP-DataComp/
│   ├── CLIP-OpenAI/
│   └── SAM3/
├── Packaging/
│   ├── clip-datacomp.json
│   ├── clip-openai.json
│   └── sam3.json
└── Output/

Each packaging manifest selects only:

  • the bundle’s metadata.json;
  • its tokenizer directory;
  • its optimized runtime .aimodel directory;
  • its matching notice directory.

The DataComp selector must be:

Models/CLIP-DataComp/ViT-B-32-256-datacomp_s34b_b86k_float16_static.aimodel

If any checked-in packaging manifest still selects ViT-B-32-256-open_clip_model.safetensors_float16_static.aimodel, it is stale and must be corrected before packaging.

5. Generate and record the asset-pack archives

Check the selected Xcode tool before every release:

xcrun --find ba-package
xcrun ba-package --version
xcrun ba-package help package

Run one package operation per ready model from the staging root. For the current v2 republish, package the two CLIP models only:

cd /Users/thomas/ModelAssets/Release

xcrun ba-package package Packaging/clip-datacomp.json \
  --output-path Output/clip-datacomp.aar
xcrun ba-package package Packaging/clip-openai.json \
  --output-path Output/clip-openai.aar

shasum -a 256 Output/*.aar
stat -f '%N %z bytes' Output/*.aar

Run the SAM 3 operation only after its redistribution review is complete and its status changes from blocked to ready:

xcrun ba-package package Packaging/sam3.json \
  --output-path Output/sam3.aar

The .aar suffix here means an Apple Archive produced for Managed Background Assets; it is not an Android library.

Do not copy old archive checksums into the application catalogue. A changed model, metadata file, tokenizer, notice, selector, or packaging tool can change the archive. Record the newly generated SHA-256 and exact byte count for every pack. Keep these archive-level values in the generated download manifest, RawCull’s production download catalogue, release documentation, and GitHub release metadata. Do not embed an archive checksum or size in a provenance file inside that same archive; doing so creates a self-referential checksum.

6. Generate the download manifest

The catalogue used in this chapter is the release staging directory:

/Users/thomas/ModelAssets/Release

Run every ba-package command in chapter 6 from that directory. Relative paths such as Output/clip-datacomp.aar and Packaging/clip-datacomp.json are resolved from the current directory; running the commands from /Users/thomas/ModelAssets or a RawCull source checkout selects the wrong paths.

Create a self-hosted manifest from the exact approved AAR files in /Users/thomas/ModelAssets/Release/Output. The generated manifest must contain only packs that have passed both technical and legal release gates. It may contain one, two, or all three packs while reviews are in progress; RawCull must expose only the same ready set.

Verify every entry before publishing:

  • exact asset-pack ID;
  • monotonically increasing asset-pack version;
  • exact download byte count;
  • tag-pinned HTTPS archive URL;
  • correct destination selected by RawCull’s catalogue.

Do not use GitHub’s /releases/latest/download/ redirect. It excludes prereleases and makes the resolved version less explicit. Use URLs under:

https://github.com/rsyncOSX/RawCull-AI-Models/releases/download/v2/

6.1. How to create the manifest

Create the manifest with the same Xcode installation used to package the AAR files. First verify the working directory and inputs. These checks must list the release packaging catalogues and the AAR files generated in chapter 5:

cd /Users/thomas/ModelAssets/Release
pwd
ls -l Packaging/*.json Output/*.aar

Pass only approved archives. ba-package matches version numbers to archive paths by position: the first value after --asset-pack-versions applies to the first AAR path, the second value to the second AAR path, and so on. Supply exactly one version for each AAR. Version 2 is used below for the new v2 release; replace it with the next monotonically increasing integer for any pack that has already used that version.

The base URL is the tag-pinned release directory, including its trailing slash:

DOWNLOAD_BASE_URL="https://github.com/rsyncOSX/RawCull-AI-Models/releases/download/v2/"

xcrun ba-package download-manifest create \
  Output/clip-datacomp.aar \
  --asset-pack-versions 2 \
  --macos \
  --download-base-url "$DOWNLOAD_BASE_URL" \
  --output-path Output/manifest.json

Generate Output/manifest.json with one of the commands below. Do not edit the generated JSON to add a blocked model. If the ready set changes, regenerate the manifest from exactly that set of AAR files.

After generation, validate the JSON, print the included IDs, and inspect every generated pack entry before publishing:

jq empty Output/manifest.json
jq -r '.assetPacks[].id' Output/manifest.json
jq -r '.assetPacks[] | [.id, (.version | tostring), (.downloadSize | tostring), .url] | @tsv' \
  Output/manifest.json

The generated key is id, not assetPackID; assetPackID is used only in the packaging catalogue. Verify the version, archive URL, and download size in each entry. The Background Assets tool constructs the URL by appending the pack ID to DOWNLOAD_BASE_URL. Consequently, the GitHub release asset must be named exactly like the final URL component, for example no.blogspot.RawCull.models.clip-datacomp, with no .aar suffix. Chapter 7 prepares those upload names. Do not hand-edit the generated JSON or URL.

6.2. Manifest for both CLIP models

Use this manifest after DataComp CLIP and OpenAI CLIP have both passed their technical and legal gates, while SAM 3 remains blocked:

xcrun ba-package download-manifest create \
  Output/clip-datacomp.aar \
  Output/clip-openai.aar \
  --asset-pack-versions 2 2 \
  --macos \
  --download-base-url "$DOWNLOAD_BASE_URL" \
  --output-path Output/manifest.json

The result must contain exactly these asset-pack IDs:

no.blogspot.RawCull.models.clip-datacomp
no.blogspot.RawCull.models.clip-openai

6.3. Manifest for all three models

Use this manifest only after both CLIP models and SAM 3 have passed all release gates:

xcrun ba-package download-manifest create \
  Output/clip-datacomp.aar \
  Output/clip-openai.aar \
  Output/sam3.aar \
  --asset-pack-versions 2 2 2 \
  --macos \
  --download-base-url "$DOWNLOAD_BASE_URL" \
  --output-path Output/manifest.json

The result must contain exactly these asset-pack IDs:

no.blogspot.RawCull.models.clip-datacomp
no.blogspot.RawCull.models.clip-openai
no.blogspot.RawCull.models.sam3

7. Publish the GitHub release

Chapter 7 uses two different locations:

  • local files come from /Users/thomas/ModelAssets/Release/Output;
  • the release itself is in the GitHub repository rsyncOSX/RawCull-AI-Models.

gh release view does not read a local model or packaging catalogue. Because the command supplies --repo rsyncOSX/RawCull-AI-Models, it can be run from any directory. Change to the release staging directory anyway so the later upload paths resolve correctly:

cd /Users/thomas/ModelAssets/Release
gh auth status
gh repo view rsyncOSX/RawCull-AI-Models --json nameWithOwner,url

7.1. Create or inspect v2

gh release view only inspects an existing release; it does not create the tag or release. To inspect v2, use this one-line form, which also avoids shell errors caused by spaces after a line-continuation backslash:

gh release view v2 --repo rsyncOSX/RawCull-AI-Models --json tagName,isDraft,isPrerelease,isImmutable

For the existing v2 release, the command returns:

{
  "isDraft": false,
  "isImmutable": false,
  "isPrerelease": true,
  "tagName": "v2"
}

isImmutable:false means GitHub is not currently enforcing immutable-release protection for this repository. Normally treat a release as immutable once clients have downloaded or consumed its manifest. The narrowly scoped republish procedure in section 7.4 is permitted only when the release is known to be unused and the entire affected payload-and-manifest set is replaced.

If the command fails, interpret the error before changing its arguments:

  • accepts at most 1 arg(s) usually means a copied multiline command lost a continuation backslash; use the one-line command above;
  • release not found or Could not resolve to a Release means v2 does not exist in the repository selected by --repo;
  • an authentication error requires gh auth login or a valid GH_TOKEN;
  • error connecting to api.github.com is a network or proxy failure, not a problem with v2, --repo, or --json.

If GitHub reports release not found, create the release explicitly from the repository’s main branch. Choose --prerelease only when that is the intended publication state:

gh release create v2 --repo rsyncOSX/RawCull-AI-Models \
  --target main \
  --title "RawCull AI models v2" \
  --prerelease \
  --notes "RawCull AI model asset packs for manifest version 2."

Do not run gh release create when gh release view v2 already succeeds. At verification time, tagName must be v2 and isDraft must be false. A tag-pinned URL can use a published prerelease, but record that decision.

7.2. Prepare the exact release asset names

The local package filenames end in .aar, but the generated manifest URLs do not. Make extensionless upload copies whose basenames exactly match the id values in Output/manifest.json:

cd /Users/thomas/ModelAssets/Release
mkdir -p Output/Upload

cp Output/clip-datacomp.aar \
  Output/Upload/no.blogspot.RawCull.models.clip-datacomp
cp Output/clip-openai.aar \
  Output/Upload/no.blogspot.RawCull.models.clip-openai
# Only when SAM 3 is present in Output/manifest.json:
cp Output/sam3.aar \
  Output/Upload/no.blogspot.RawCull.models.sam3

Before uploading, confirm that every manifest URL basename has a matching local file and that its byte count equals downloadSize:

jq -r '.assetPacks[] | [.id, (.downloadSize | tostring)] | @tsv' \
  Output/manifest.json
stat -f '%N %z' Output/Upload/no.blogspot.RawCull.models.*

Do not upload clip-datacomp.aar or clip-openai.aar under those local names; they would produce URLs that do not match the generated manifest.

7.3. Upload and verify

Upload files in this order:

  1. all approved AAR payloads under their exact extensionless manifest IDs;
  2. download each payload anonymously and verify its checksum and size;
  3. upload manifest.json last;
  4. download and inspect the published manifest anonymously.

For the current two-CLIP manifest, upload the extensionless assets explicitly:

gh release upload v2 \
  Output/Upload/no.blogspot.RawCull.models.clip-datacomp \
  Output/Upload/no.blogspot.RawCull.models.clip-openai \
  --repo rsyncOSX/RawCull-AI-Models

Do not use --clobber during a normal new-tag publication. Verify the remote filenames:

gh release view v2 --repo rsyncOSX/RawCull-AI-Models \
  --json assets \
  --jq '.assets[] | [.name, (.size | tostring), .url] | @tsv'

Download the asset-pack URLs from the generated manifest, compare them with the local upload copies, and only then upload the manifest:

curl --fail --location --output /tmp/no.blogspot.RawCull.models.clip-datacomp \
  https://github.com/rsyncOSX/RawCull-AI-Models/releases/download/v2/no.blogspot.RawCull.models.clip-datacomp
curl --fail --location --output /tmp/no.blogspot.RawCull.models.clip-openai \
  https://github.com/rsyncOSX/RawCull-AI-Models/releases/download/v2/no.blogspot.RawCull.models.clip-openai

shasum -a 256 \
  Output/Upload/no.blogspot.RawCull.models.clip-datacomp \
  /tmp/no.blogspot.RawCull.models.clip-datacomp
shasum -a 256 \
  Output/Upload/no.blogspot.RawCull.models.clip-openai \
  /tmp/no.blogspot.RawCull.models.clip-openai

gh release upload v2 Output/manifest.json \
  --repo rsyncOSX/RawCull-AI-Models

Each local/remote checksum pair must match. Finally, download and inspect the published manifest anonymously:

curl --fail --location --output /tmp/rawcull-v2-manifest.json \
  https://github.com/rsyncOSX/RawCull-AI-Models/releases/download/v2/manifest.json
jq empty /tmp/rawcull-v2-manifest.json
jq -r '.assetPacks[] | [.id, (.version | tostring), (.downloadSize | tostring), .url] | @tsv' \
  /tmp/rawcull-v2-manifest.json

If clients might already have consumed the release, never replace files under its tag. Publish a corrective tag, for example v2.0.1, and update both RawCull manifest URLs. If the release is confirmed unused, follow section 7.4 instead.

7.4. Republish unused models on the same v2 tag

Use this exception only when all of the following are true:

  • no user or client has downloaded or cached the model release;
  • the GitHub release reports isImmutable:false;
  • the replacement archives, manifest, RawCull catalogue, tests, notices, and documentation have already been updated and verified together;
  • every model omitted for legal or technical reasons remains omitted. For the current republish, SAM 3 remains blocked and absent.

First verify the local replacement set. The manifest must contain exactly the two ready CLIP packs, and each downloadSize must match its extensionless upload file:

AssetExpected v2 bytesExpected SHA-256
no.blogspot.RawCull.models.clip-datacomp282,966,632cf433dcd199b44635a4ff0260bd8e79177e4907a4cfcb2f72043066b8cbe4ef7
no.blogspot.RawCull.models.clip-openai282,866,068e9181157c2d4012db2e6478949488f9906696a4ed78ecaa10235d9762621136c
manifest.json783d9c58b6ff6752f5ae4e3d692a6d6d5839edca733d7d60da6ed4f54eca336d8a6
cd /Users/thomas/ModelAssets/Release

jq -r '.assetPacks[] | [.id, (.version | tostring), (.downloadSize | tostring), .url] | @tsv' \
  Output/manifest.json
stat -f '%N %z' \
  Output/Upload/no.blogspot.RawCull.models.clip-datacomp \
  Output/Upload/no.blogspot.RawCull.models.clip-openai
shasum -a 256 \
  Output/Upload/no.blogspot.RawCull.models.clip-datacomp \
  Output/Upload/no.blogspot.RawCull.models.clip-openai \
  Output/manifest.json

Confirm that the manifest includes no SAM 3 entry:

test "$(jq -r '.assetPacks[].id' Output/manifest.json | sort | tr '\n' ' ')" = \
  "no.blogspot.RawCull.models.clip-datacomp no.blogspot.RawCull.models.clip-openai "

Inspect the current remote assets and record their names, sizes, and digests before deleting anything:

gh release view v2 --repo rsyncOSX/RawCull-AI-Models \
  --json tagName,isDraft,isPrerelease,isImmutable,assets \
  --jq '{tagName,isDraft,isPrerelease,isImmutable,assets:[.assets[]|{name,size,digest,url}]}'

Delete the old manifest.json first. This prevents a client from discovering the old manifest while its referenced payloads are being replaced. Then delete the two old CLIP uploads by exact name. Do not delete or recreate the release or tag itself:

gh release delete-asset v2 manifest.json \
  --repo rsyncOSX/RawCull-AI-Models --yes
gh release delete-asset v2 no.blogspot.RawCull.models.clip-datacomp \
  --repo rsyncOSX/RawCull-AI-Models --yes
gh release delete-asset v2 no.blogspot.RawCull.models.clip-openai \
  --repo rsyncOSX/RawCull-AI-Models --yes

Verify those three names are absent. If any deletion failed, stop and resolve it before uploading replacements:

gh release view v2 --repo rsyncOSX/RawCull-AI-Models \
  --json assets --jq '.assets[].name'

Upload the two replacement payloads first. SAM 3 must not be included:

gh release upload v2 \
  Output/Upload/no.blogspot.RawCull.models.clip-datacomp \
  Output/Upload/no.blogspot.RawCull.models.clip-openai \
  --repo rsyncOSX/RawCull-AI-Models

Download both published payloads anonymously and compare their byte counts and SHA-256 values with the exact local upload files. Upload manifest.json only after both comparisons succeed:

curl --fail --location --output /tmp/no.blogspot.RawCull.models.clip-datacomp \
  https://github.com/rsyncOSX/RawCull-AI-Models/releases/download/v2/no.blogspot.RawCull.models.clip-datacomp
curl --fail --location --output /tmp/no.blogspot.RawCull.models.clip-openai \
  https://github.com/rsyncOSX/RawCull-AI-Models/releases/download/v2/no.blogspot.RawCull.models.clip-openai

cmp Output/Upload/no.blogspot.RawCull.models.clip-datacomp \
  /tmp/no.blogspot.RawCull.models.clip-datacomp
cmp Output/Upload/no.blogspot.RawCull.models.clip-openai \
  /tmp/no.blogspot.RawCull.models.clip-openai
shasum -a 256 \
  Output/Upload/no.blogspot.RawCull.models.clip-datacomp \
  /tmp/no.blogspot.RawCull.models.clip-datacomp \
  Output/Upload/no.blogspot.RawCull.models.clip-openai \
  /tmp/no.blogspot.RawCull.models.clip-openai

gh release upload v2 Output/manifest.json \
  --repo rsyncOSX/RawCull-AI-Models

Finally, download the new manifest anonymously and verify it byte-for-byte, then cross-check every remote asset against its manifest entry and local catalogue:

curl --fail --location --output /tmp/rawcull-v2-manifest.json \
  https://github.com/rsyncOSX/RawCull-AI-Models/releases/download/v2/manifest.json
cmp Output/manifest.json /tmp/rawcull-v2-manifest.json
jq -r '.assetPacks[] | [.id, (.version | tostring), (.downloadSize | tostring), .url] | @tsv' \
  /tmp/rawcull-v2-manifest.json
gh release view v2 --repo rsyncOSX/RawCull-AI-Models \
  --json assets \
  --jq '.assets[] | [.name, (.size | tostring), .digest, .url] | @tsv'

Do not leave v2 without a manifest longer than necessary. If replacement or verification fails after deletion, keep the manifest absent until the two payloads are complete and verified; never restore a manifest that references a missing or mismatched archive.

8. Update RawCull’s production catalogue

The current two-CLIP v2 release uses:

static let includeOpenAICLIP = true
static let includeDataCompCLIP = true
static let includeSAM3 = false

SAM 3 remains blocked. Change its switch only after its redistribution review and all technical release gates are complete. The eventual three-model configuration is:

static let includeOpenAICLIP = true
static let includeDataCompCLIP = true
static let includeSAM3 = true

For every rebuilt archive, update its production descriptor with actual values:

upstreamRevision: "<verified immutable source revision>",
expectedArchiveSHA256: "<64 lowercase hexadecimal characters>",
downloadByteCount: <exact AAR bytes>,
installedByteCount: <exact installed bytes or nil>,
releaseReadiness: .ready,

Do not change .blocked to .ready until the corresponding pack is present in the published manifest and every release gate is complete. Keep the verified SAM 3 licence resource, SHA-256, and requiresExplicitAcceptance: true synchronized with its notice catalogue.

9. Point both configurations to the release

Update both locations to the same exact URL:

https://github.com/rsyncOSX/RawCull-AI-Models/releases/download/v2/manifest.json

The locations are:

  • RawCullAIModelDownloadSource.productionManifestURL in RawCullAIModelDownloadService.swift;
  • BAManifestURL in RawCull-Info.plist.

Keep BAUsesAppleHosting false for GitHub self-hosting. Do not point RawCull to the new manifest until every referenced archive is anonymously downloadable and verified.

10. Update provenance, documentation, and tests

For every released pack, update ModelAssets/Notices/<model>/PROVENANCE.json and NOTICE.md with the source revision, source checksum, PhotoAIKit revision, command, runtime filename, model fingerprint, and review status. Keep archive SHA-256 and byte-count values in RawCull’s production download catalogue, release documentation, generated manifest, and release-host metadata—not in the provenance packaged inside that archive. Update ModelAssets/README.md to list the exact release URL and available packs.

Tests must verify:

  • the catalogue contains exactly the enabled ready models;
  • every ready model has an exact archive checksum and byte count;
  • bundled licence texts match their recorded checksums;
  • both manifest URL locations use the same tag;
  • provenance and notice records describe the generated archives;
  • excluded models do not appear in settings or the production catalogue.

Run the focused download and release-metadata tests, then the full suite:

xcodebuild \
  -project RawCull.xcodeproj \
  -scheme RawCull \
  -destination 'platform=macOS' \
  -only-testing:RawCullTests/RawCullAIModelDownloadsTests \
  -only-testing:RawCullTests/ReleaseMetadataTests \
  test

xcodebuild \
  -project RawCull.xcodeproj \
  -scheme RawCull \
  -destination 'platform=macOS' \
  test

11. Perform a clean-machine test

Before shipping:

  1. confirm Settings lists only the enabled subset of DataComp, OpenAI, and SAM 3;
  2. download each enabled model through Managed Background Assets;
  3. rebuild each CLIP index and test semantic search and similarity;
  4. verify that DataComp and OpenAI artifacts never cross-load;
  5. after SAM 3 becomes ready and is published, accept its licence and verify segmentation;
  6. relaunch and verify installation and selection persistence;
  7. remove each pack independently;
  8. interrupt a download and verify retry;
  9. verify Vision feature-print similarity remains the safe fallback when no CLIP model is available.

Release checklist

  • The supported model scope remains DataComp CLIP, OpenAI CLIP, and SAM 3.
  • The current manifest contains exactly the ready subset (both CLIP packs for v2).
  • DataComp uses datacomp_s34b_b86k, not the custom filename preset.
  • Exact upstream revisions and source checksums are recorded.
  • The exact PhotoAIKit revision and conversion commands are recorded.
  • Runtime fingerprints match metadata.json.
  • CLIP parity, semantic search, and image similarity are reviewed.
  • SAM 3 segmentation and licence acceptance are tested.
  • OpenAI redistribution blockers are resolved before its release.
  • SAM 3 remains blocked and absent, or its redistribution review is complete.
  • Only optimized runtime assets are packaged.
  • Fresh archive checksums and byte counts are in RawCull, the generated manifest, and release records.
  • Archives are uploaded and verified before manifest.json.
  • Both RawCull manifest URLs point to the same tag-pinned manifest.
  • Inclusion flags and readiness values match the published manifest.
  • Focused, full, and clean-machine tests pass.

Rollback

Before an app release, restore both manifest URLs and catalogue readiness to the last known-good release if the new model release fails. After clients have consumed a publication, do not mutate its tag. Publish a corrected tag, verify it, and update both RawCull URLs together. The same-tag procedure in section 7.4 is only for a confirmed-unused release.

10.6 - CLIP Model Evaluation Results

Detailed comparison of OpenAI CLIP ViT-B/32 and OpenCLIP DataComp ViT-B/32-256 using RawCullFB semantic-search and image-similarity reports.

CLIP Model Evaluation Results

Report date: 2026-08-12

This report compares the product-behavior results produced by RawCullFB for:

  • OpenAI CLIP ViT-B/32 at 224 × 224; and
  • OpenCLIP DataComp ViT-B/32-256 using the datacomp_s34b_b86k checkpoint.

The runs follow the product-behavior portion of Evaluating CLIP Models. They cover the canonical 77 semantic queries and a complete all-pairs image-similarity pass over 453 indexed images.

The reports show that both integrations completed cleanly, neither model shows semantic collapse, and both produce coherent image-neighbourhood structures. DataComp is modestly faster and more consistent across paraphrases. Those are promising sanity-check results, but they are not labeled accuracy metrics.

No final release-model selection can be made from these two reports alone. Source-framework parity evidence, pooled relevance labels, a calibrated labeled similarity benchmark, and full operational measurements are still required by the evaluation procedure.

1. Evidence analyzed

EvidenceOpenAIDataComp
RawCullFB reportViT-B-32-semantic-test-results.txtdatacomp_s34b_b86k-semantic-test-results.txt
Model labelViT-B-32datacomp_s34b_b86k
Run started, UTC2026-08-09 15:56:462026-08-10 05:07:09
Report updated, UTC2026-08-09 15:56:532026-08-10 05:07:16
Catalog recorded by report/Users/thomas/Downloads/testphotos/Users/thomas/Downloads/testphotos
Result limit5050

The complete model fingerprints recorded in the reports are:

OpenAI
clip:clip-vit-base-patch32_float16_static:openai/clip-vit-base-patch32:clip-vit-base-patch32_float16_static.aimodel:0.4:directory-tree-sha256-v1:a71ceca116e13b334b0679c95bd85d7ecda14fd68bd7a3ea84b83c286005f45b

DataComp
clip:ViT-B-32-256-datacomp_s34b_b86k_float16_static:mlfoundations/open_clip:ViT-B-32-256-datacomp_s34b_b86k_float16_static.aimodel:0.4:directory-tree-sha256-v1:6a3639a2049b8a4ea23fe04c3083e199a4f505433f7c8bd0748b3c8d4fcb1572

The fingerprints are distinct, as required. Each result set therefore identifies a separate model asset and embedding space.

This analysis did not receive the run’s environment record, catalog hashes, query hash, model metadata, parity outputs, or relevance-label CSV. Matching catalog paths and image counts support comparability, but do not independently prove that every catalog byte and software revision was unchanged between the two run times.

2. Report-integrity checks

Integrity checkOpenAIDataCompResult
Overall statuscompletedcompletedPass
Completed queries77/7777/77Pass
Result limit5050Pass
Similarity statuscompletedcompletedPass
Similarity anchors453453Pass
Pair comparisons102,378102,378Pass
Incompatible pairs00Pass

For 453 images, the number of unique unordered pairs is:

453 × 452 / 2 = 102,378

The reported pair count is therefore complete for both models. There is no evidence of a partial query run, partial similarity run, incompatible vector, or accidental reuse of one model fingerprint for both reports.

3. Semantic sanity-check methodology

The following report-derived diagnostics were calculated independently for each model:

  • distinct images returned at rank one across all 77 queries;
  • the most frequently reused rank-one image;
  • unique images appearing anywhere in the 77 top-50 result sets;
  • mean rank-one minus rank-two score margin;
  • mean shared images between the top ten of every pair of unrelated and related queries;
  • mean shared top-ten images within the five three-query paraphrase groups;
  • first-query latency; and
  • warmed mean, median, and P95 latency, excluding the first query.

These are collapse, diversity, consistency, and operational diagnostics. They do not establish whether an image is relevant to a query. Relevance must be judged from the image itself using the pooled labeling procedure in the main evaluation guide.

4. Semantic retrieval results

4.1 Diversity and hub behavior

MetricOpenAIDataCompInterpretation
Distinct rank-one images53/7758/77DataComp uses a slightly broader set of winners
Largest rank-one hub4/774/77Neither model has a universal winner
Unique images across all top-50 results418/453423/453Both search broadly across the catalog
Mean top-one minus top-two margin0.007370.00994DataComp has wider within-model separation on this run
Median top-one minus top-two margin0.005330.00708Same direction as the mean

The most frequently returned rank-one image for OpenAI is 62480371.jpg, selected for four queries. The most frequent DataComp winner is 2132732266.jpg, also selected for four queries.

Both results are consistent with healthy retrieval diversity. The established OpenAI sanity reference in the evaluation procedure is 53 distinct top-one images and a maximum hub frequency of 4/77; this run reproduces those exact values. DataComp slightly increases rank-one and top-50 coverage without creating a larger semantic hub.

The score-margin values may be compared between ranks from the same model, but not treated as calibrated confidence or compared directly as accuracy between models. The two models produce different embedding spaces and score distributions.

4.2 Paraphrase consistency

Across all 2,926 pairs of queries, the mean number of shared images in two top-ten result lists is low, as expected:

MetricOpenAIDataComp
Mean shared top-ten images, all query pairs0.7240.703
Median shared top-ten images, all query pairs00
Mean shared top-ten images, paraphrase pairs4.935.60
Median shared top-ten images, paraphrase pairs55
Paraphrase/general overlap ratio6.82×7.97×

Paraphrases are materially more consistent than unrelated prompts for both models. This is an important sanity check: changing the wording while retaining the intent produces strongly overlapping results, without causing unrelated queries to collapse onto a common result pool.

The group-level mean shared top-ten counts are:

Paraphrase groupOpenAIDataComp
Dog on a beach6.006.33
City street at night7.337.67
Sharp portrait3.334.67
Blurry photograph3.674.33
Beautiful landscape4.335.00

DataComp has higher top-ten overlap in all five groups. Its largest advantage is on the sharp-portrait group. This is the clearest positive signal for DataComp in the current unlabeled reports.

Top-one identity is less stable than top-ten membership for both models. That is not necessarily a failure: several images can have near-equal relevance, and a small score change can reorder the first few results. Graded relevance labels and nDCG@5 are needed to distinguish harmless reordering from a real quality difference.

4.3 Agreement between models

The models return the same rank-one image for 19 of 77 queries, or 24.7%. Their top-ten lists contain an average of 4.69 identical images, or 46.9% of a ten-result list.

Query categoryQueriesSame rank oneMean shared top-ten images
Objects and scenes1665.63
Colors and attributes945.56
Actions and relations725.00
Photographic quality and style1324.54
Composition and subjective judgment912.44
Count, spatial relation, and negation833.63
Paraphrases1515.07

Concrete objects, scenes, colors, and attributes show the strongest agreement. Composition and subjective photographic judgments show the weakest agreement, followed by count, spatial, and negative-language prompts.

The strongest top-ten agreement includes:

  • a yellow flower: 10/10 shared results;
  • a lake surrounded by mountains: 9/10;
  • food on a plate: 8/10;
  • dog on a beach: 8/10; and
  • canine at the seaside: 8/10.

The most model-sensitive prompts include:

  • a subject positioned using the rule of thirds: 0/10 shared results;
  • a photograph with strong leading lines: 1/10;
  • a candid emotional moment: 1/10;
  • a reflection of a person in a mirror: 1/10;
  • sharp portrait: 1/10; and
  • a photograph where the subject is not sharply focused: 1/10.

Disagreement is not itself proof that either result is wrong. It identifies the queries that should receive the closest attention during blind relevance labeling.

5. Query latency

MetricOpenAIDataCompDataComp difference
First query157 ms130 ms17.2% faster
Warm mean67.25 ms65.67 ms2.3% faster
Warm median66 ms65 ms1.5% faster
Warm P9572 ms69 ms4.2% faster
Warm range63–130 ms61–129 msSimilar

DataComp is consistently but only modestly faster in this report. The first query is the largest difference. Warmed latency is effectively in the same operational class for both models.

These timings are useful observations, not the complete operational benchmark required by the evaluation procedure. The reports do not establish that 30 unmeasured warm-up queries were run, and they do not contain P99 latency, model load time, indexing throughput, peak memory, energy impact, bundle size, or index size.

6. Image-similarity results

MetricOpenAIDataComp
Similarity pass duration861 ms842 ms
Mutual top-one pairs122124
Unique top-one targets298318
Maximum top-one target reuse79
Nearest-distance minimum0.000820.00586
Nearest-distance median0.171910.30235
Nearest-distance mean0.162900.27934
Nearest-distance P950.306120.50179
Nearest-distance maximum0.452590.78558

DataComp completes the all-pairs similarity pass 19 ms, or 2.2%, faster. It produces two more mutual top-one pairs and a broader set of unique top-one targets. OpenAI has a lower maximum target-reuse count.

OpenAI’s distances are numerically lower throughout the distribution. This does not mean that OpenAI is more accurate: distance and cosine values are calibrated differently in the two embedding spaces. A threshold selected for one model must never be copied to the other model.

As an additional, non-gating diagnostic, 50 obvious two-file groups were identified from shared numeric filename prefixes under images/. This gives 100 eligible anchors. Both models retrieved the apparent partner at the same rates:

Heuristic paired-file recoveryOpenAIDataComp
Recall at rank 198/10098/100
Recall within rank 399/10099/100
Recall within rank 5100/100100/100

This heuristic is encouraging, but filenames are not ground-truth labels. It must not replace the labeled pair set prescribed by the evaluation procedure: exact duplicates, re-encodes, edits, crops, burst frames, same-subject images, hard negatives, and unrelated negatives.

7. What the reports establish

The two reports establish that:

  1. RawCullFB completed the same 77-query and 453-anchor workload for both distinct model fingerprints.
  2. No incompatible image pairs were encountered.
  3. Neither model exhibits obvious semantic collapse or a universal top-one image.
  4. Both models distinguish paraphrases from unrelated queries.
  5. DataComp has stronger paraphrase overlap in this run.
  6. DataComp has slightly broader retrieval diversity.
  7. DataComp is modestly faster for semantic queries and the all-pairs similarity pass.
  8. The models disagree most on composition, subjective photographic quality, counting, spatial relations, and negation.

The reports do not establish that:

  • either converted bundle matches its source framework numerically;
  • one model has higher semantic Precision@1, Precision@5, MRR, or nDCG@5;
  • one model has a better duplicate-detection ROC-AUC or precision-recall AUC;
  • a shared image-similarity threshold is valid;
  • all catalog bytes, query bytes, software revisions, and index identities were frozen and archived; or
  • either model has acceptable indexing throughput, memory, energy, and storage costs for release.

8. Provisional decision table

Gate or metricOpenAI ViT-B/32DataComp ViT-B/32-256Current decision
Distinct model identity in reportPassPassBoth fingerprints recorded
Complete RawCullFB reportPassPassBoth completed
Source identity verified against archiveNot providedNot providedOpen
Minimum text parityNot providedNot providedOpen
Minimum end-to-end image parityNot providedNot providedOpen
Semantic collapse diagnosticPassPassBoth healthy
Distinct semantic rank-one images5358DataComp signal
Largest semantic hub4/774/77Tie
Mean paraphrase top-ten overlap4.935.60DataComp signal
Precision@1Not labeledNot labeledOpen
Precision@5Not labeledNot labeledOpen
MRRNot labeledNot labeledOpen
nDCG@5Not labeledNot labeledOpen
Similarity PR-AUCNot labeledNot labeledOpen
Similarity FPR at selected recallNot labeledNot labeledOpen
First-query latency157 ms130 msDataComp signal
Warm P95 latency72 ms69 msSmall DataComp signal
Indexing throughputNot providedNot providedOpen
Peak memoryNot providedNot providedOpen
Bundle and index sizeNot providedNot providedOpen

9. Recommendation and next steps

DataComp is the more promising candidate in this unlabeled RawCullFB run. It is more paraphrase-consistent, slightly more diverse, and modestly faster, while retaining healthy similarity-neighbourhood behavior.

The current evidence is not sufficient to prefer DataComp for release. The evaluation procedure explicitly requires a material labeled-quality improvement before replacing the established OpenAI control. If labeled quality is effectively tied, OpenAI remains the simpler established choice and DataComp may remain experimental.

Complete the following work before making the model decision:

  1. Archive the environment, source revisions, model metadata, fingerprints, catalog manifest, catalog hashes, and semantic-query hash for this run.
  2. Attach the OpenAI Hugging Face and DataComp OpenCLIP parity results, proving text cosine of at least 0.999 and end-to-end fixture cosine of at least 0.998.
  3. Pool and blind-label the union of both models’ top five results for every query using relevance grades 0, 1, and 2.
  4. Calculate Precision@1, Precision@5, MRR, and nDCG@5 overall and by query category.
  5. Prioritize manual review of composition, subjective-quality, count, spatial, and negation prompts because those categories differ most strongly.
  6. Build the labeled image-pair benchmark and calculate ROC-AUC, PR-AUC, false-positive rate, false-negative rate, and a model-specific threshold.
  7. Measure indexing throughput, peak memory, P99 latency, bundle size, index size, and energy impact under controlled conditions.
  8. Revisit the provisional decision using the completed decision table.

Until those steps are complete, the defensible conclusion is:

Both models pass the RawCullFB report-integrity and semantic-collapse sanity checks. DataComp shows better paraphrase consistency and small operational advantages, but the release choice remains open pending parity evidence and blinded relevance and similarity labels.

11 - File Read and Write Reference

Files, folders, and persistent data touched by RawCull

File Read and Write Reference

This page lists the main places RawCull reads and writes files. Use it before changing sandbox access, cache locations, persistence, or export behavior.

File Map

File/folderAccessOwner
User-selected catalog folderReadRawCullViewModel, ScanFiles, DiscoverFiles, parser package
RAW files (.arw, .nef)Readscan, thumbnails, focus parsing, zoom, export, diagnostics
focuspoints.json beside catalogRead optionalScanFiles fallback
App Support savedfiles.jsonRead/writeCullingModel, ReadSavedFilesJSON, WriteSavedFilesJSON
App Support burst cacheRead/write/deleteBurstAnalysisCache
Thumbnail cache directoryRead/write/deleteDiskCacheManager
Full-size JPEG preview cacheRead/write/pruneFullSizeJPGDiskCache, ZoomPreviewHandler
Extracted .jpg sidecarsWriteExtractAndSaveJPGs, SaveJPGImage
rsync include files / process outputWrite/read process streamExecuteCopyFiles, ArgumentsSynchronize, PrepareOutputFromRsync
Security-scoped bookmarksRead/write UserDefaultsOpencatalogView, copy workflow
SettingsRead/write UserDefaultsSettingsViewModel

Catalog Reads

The active catalog comes from the sidebar folder selection. RawCullViewModel.startCatalogLoad(for:) starts security-scoped access and then runs the scan.

Catalog reads include:

  • directory enumeration,
  • URL resource values,
  • EXIF metadata via ImageIO,
  • MakerNote focus points via RawParserKit,
  • embedded thumbnails/JPEGs,
  • optional focuspoints.json.

DiscoverFiles uses RawFormatRegistry.allExtensions so it follows the parser registry.

App Support Files

Application Support is used for durable app-owned data:

~/Library/Application Support/RawCull/

Important files:

FilePurpose
savedfiles.jsonRatings, sharpness/saliency persistence, manual burst winner overrides
burst-analysis cache filesDerived similarity/sharpness/grouping/ranking artifacts

savedfiles.json is written atomically. Burst-analysis cache validity is checked against file metadata and algorithm/signature versions before reuse.

Cache Files

Generated caches live under the user cache directory for the RawCull app identifier. They are performance data, not source-of-truth data.

CachePurpose
Thumbnail disk cacheStores generated JPEG thumbnails for preload/on-demand loading
Full-size JPEG disk cacheStores larger embedded JPEG previews for zoom

Deleting these caches should only make RawCull slower until they are rebuilt. It should not lose ratings or manual decisions.

Exported JPEGs

ExtractAndSaveJPGs extracts embedded JPEG previews from RAW files and writes .jpg files next to the source RAW files through SaveJPGImage.

Because this writes into user-selected folders, it depends on the active security-scoped catalog access. The export path encodes image data before crossing concurrency boundaries where needed.

rsync Copy Workflow

The copy workflow is separate from thumbnail/scoring export. It uses rsync to copy selected RAW files based on rating/tag choices.

Main files:

FileRole
CopyFilesView.swiftUI and execution lifecycle
OpencatalogView.swiftSource/destination picker and bookmark creation
ExecuteCopyFiles.swiftProcess owner and progress/result state
ArgumentsSynchronize.swiftBuilds rsync arguments
PrepareOutputFromRsync.swiftParses process output
RemoteDataNumbers.swiftSummarizes copied file counts and sizes

Source and destination folders are restored from security-scoped bookmarks or fall back to selected paths.

Security-Scoped Bookmarks

The copy workflow stores bookmarks in UserDefaults after the user picks folders. The catalog browsing flow uses an active security-scoped URL owned by RawCullViewModel instead of creating a bookmark at picker time.

See Security-Scoped URLs for lifecycle details.

Settings

SettingsViewModel stores user settings with UserDefaults. Settings affect:

  • thumbnail sizes,
  • cache size maximums,
  • focus/scoring options,
  • memory/cache defaults.

Background actors use SettingsViewModel.shared.asyncgetsettings() to snapshot settings into a SavedSettings value before using them off the main actor.

Diagnostics Reads

RawFileDiagnostics reads RAW files and parser metadata for developer-facing reports. It can call both Sony and Nikon parser diagnostics and report ImageIO properties, embedded JPEG locations, focus-parser output, and format classification.

Diagnostics should be read-only.

What To Check When Changing This Area

  • Writes outside the app container need active security-scoped access.
  • App-owned durable data belongs in Application Support, not Caches.
  • Rebuildable performance data belongs in Caches, not Application Support.
  • If a cache stores derived algorithm output, include enough version/signature metadata to reject stale data.
  • Keep process-output parsing separate from process lifecycle management.

12 - Synchronous Code

Synchronous Code

Most RawCull code uses async/await, actors, and task groups. Some Apple framework work is still fundamentally synchronous: ImageIO thumbnail decode, CoreImage RAW rendering, JPEG encoding, and direct binary MakerNote parsing. This page explains where that work lives and how the app keeps it from freezing the UI or starving Swift’s cooperative executor.

Source Map

AreaFiles
Cancellation-aware blocking bridgeRawParserKit/Sources/RawParserKit/CancellableImageIOWork.swift
Thumbnail extractionSonyThumbnailExtractor.swift, NikonThumbnailExtractor.swift
Embedded JPEG extractionJPGSonyARWExtractor.swift, JPGNikonNEFExtractor.swift, SonyRAWJPEGCreator.swift
MakerNote parsingSonyMakerNoteParser.swift, NikonMakerNoteParser.swift
App callersRequestThumbnail.swift, ScanAndCreateThumbnails.swift, ExtractAndSaveJPGs.swift, ScanAndExtractJPGs.swift, ZoomPreviewHandler.swift
Sharpness scoringFocusMaskEngine+Scoring.swift, FocusMaskEngine+MaskGeneration.swift

Why Blocking Work Matters

Swift’s cooperative thread pool expects async tasks to suspend instead of occupying threads for long periods. ImageIO and CoreImage calls often do not suspend; they block until decode/render work is done.

If RawCull ran those calls directly inside task-group children, many RAW files could occupy the cooperative pool at once. The UI might still be on the main actor, but background progress would become sluggish and cancellation would feel late.

The Bridge

CancellableImageIOWork.run(qos:_:) wraps blocking work like this:

flowchart LR
    A["Swift async caller"] --> B["withTaskCancellationHandler"]
    B --> C["withCheckedThrowingContinuation"]
    C --> D["DispatchQueue.global(qos).async"]
    D --> E["Synchronous ImageIO/CoreImage operation"]
    E --> F["Resume continuation once"]

The operation receives an ImageIOCancellationToken. The token checks both its own locked cancellation flag and Task.isCancelled.

WorkState protects the continuation with a lock so cancellation and completion races resume exactly once.

Extractor Pattern

The parser package uses stateless enum extractors. A typical extractor exposes an async public API and a private synchronous implementation:

public async extractThumbnail(...)
    -> CancellableImageIOWork.run(...)
        -> private extractSync(...)

That shape appears in Sony and Nikon thumbnail/JPEG extractors.

Quality Of Service

The chosen GCD QoS communicates user impact:

WorkTypical QoSReason
On-demand thumbnailsuserInitiatedUser is scrolling or selecting images
Bulk cache warmingutility/background through caller contextUseful but should yield to direct UI work
JPEG export/cache warmingutilityBatch work that can run behind UI interaction
Memory diagnostics samplingutility/detachedShould not block UI rendering

The exact QoS is set in the package extractor or caller. When adding a new path, choose based on whether the user is waiting for the result right now.

Sharpness Scoring

FocusMaskEngine.computeSharpnessScore(...) performs decode, saliency detection, and focus-detail scoring off the main actor. It uses runCancellableWorker around the scoring body and checks cancellation between decode, Vision, and scoring phases.

The scoring image can come from:

SourceMeaning
embeddedPreviewPrefer embedded JPEG or ImageIO thumbnail for speed
rawDemosaicUse CIRAWFilter for a slower but more precise demosaiced thumbnail

The code normalizes decoded images to 8-bit sRGB RGBA before the Metal/CoreImage focus pipeline. That makes the scoring pipeline less sensitive to source color space or bit depth.

Direct Binary Parsing

Sony and Nikon MakerNote parsers read bytes directly from the RAW file to locate focus-point data and embedded JPEG offsets. This is synchronous file/data parsing, but it is small compared with RAW image decode and usually runs inside scan/parser work already off the main UI path.

The parsers are written as stateless enums, so they do not need actor isolation.

Safe Rules For New Blocking Work

  • Keep blocking APIs out of SwiftUI view bodies and @MainActor methods.
  • Prefer adding blocking ImageIO work to RawParserKit behind an async API.
  • Use CancellableImageIOWork when the operation may be slow or repeated across many files.
  • Add cancellation checkpoints before and after expensive framework calls.
  • Convert non-Sendable image objects before crossing actor/task boundaries when needed.
  • Put pure parsing or calculation logic in package code with tests.

When A Synchronous Call Is Acceptable

A synchronous call is usually acceptable when all of these are true:

  • it is small and bounded,
  • it does not decode or render full images,
  • it runs outside view rendering,
  • it does not capture mutable UI state,
  • cancellation delay would not be visible to the user.

Examples include formatting values, parsing small strings, or calculating simple per-model display data.

13 - Repository Git Workflow

Repository Git Workflow: Linear History

This page documents the preferred repository workflow for RawCull documentation changes: branch-based development, signed commits when configured, rebasing onto main, and fast-forward integration without merge commits.

Create a Branch

git checkout -b <new-branch>
git push --set-upstream origin <new-branch>

Daily Workflow

You always work on a dedicated branch. Commit often as you make progress.

Stage changes

git add .

Commit

If you use the Claude CLI, it can generate a Conventional Commits message from the staged diff:

git commit -m "$(git diff --staged | claude -p 'Write a short conventional commit message. Output only the message, nothing else.')"

Claude pipes the staged diff into the claude CLI and returns a single-line message such as feat(cache): add LRU eviction for thumbnail layer. The -p flag runs Claude non-interactively (print mode) so the output can be captured directly into -m.

If you want to review the message before committing, capture it first:

MSG=$(git diff --staged | claude -p 'Write a short conventional commit message. Output only the message, nothing else.')
echo "$MSG"
git commit -m "$MSG"

Repeat staging and committing as often as needed while working.

Push

git push origin <your-branch>

Keep your branch current

If others have pushed to main while you were working, rebase your commits on top of their work rather than merging.

git fetch origin
git rebase origin/main

This puts your commits aside, fast-forwards your branch to the latest main, then replays your commits on top — keeping history a straight line.


Integrate into main (fast-forward)

When your branch is finished and ready to ship, follow this procedure to keep history linear.

1. Update main from the server

git checkout main
git pull --rebase origin main

2. Rebase your branch onto the fresh main

git checkout <your-branch>
git rebase main

3. Fast-forward merge into main

--ff-only makes Git abort instead of creating a merge commit.

git checkout main
git merge --ff-only <your-branch>

4. Push main to GitHub

git push origin main

5. Delete the branch locally

git branch -d <your-branch>

6. Delete the branch on GitHub

git push origin --delete <your-branch>

Using git fetch and git diff

The git fetch command is used to update your local repository with the latest changes from the remote repository without merging them. You can then use git diff to compare the branches.

Step 1: Fetch the Latest Changes

Fetch the latest changes from the remote repository to ensure you have the most up-to-date information.

git fetch origin

Step 2: Compare the Branches

Use the git diff command to compare your local branch with the remote branch.

git diff <local-branch> origin/<remote-branch>

For example, if you want to compare your local main branch with the remote main branch:

git diff main origin/main

Using git log

The git log command can be used to compare commit histories between your local and remote branches. This is useful for seeing which commits are present in one branch but not the other.

Fetch the Latest Changes:

Ensure your local repository is updated with the latest changes from the remote repository.

git fetch origin

Compare Commit Histories:

Use the git log to see the differences in commit histories.

git log <local-branch>..origin/<remote-branch>

For example, to compare your local main branch with the remote main branch:

git log main..origin/main

You can also reverse the comparison to see commits in the remote branch that are not in the local branch:

git log origin/main..main

Using git status

The git status command provides a quick summary of the differences between your local branch and the remote branch.

Fetch the Latest Changes:

Update your local repository with the latest changes from the remote repository.

git fetch origin

Check the Status:

Use git status to see the differences between your local branch and the remote branch.

git status

The output will show messages like “Your branch is ahead of ‘origin/’ by X commits” or “Your branch is behind ‘origin/’ by X commits”, indicating the differences.


Pull and track a remote repository

The error means your local branch has no upstream tracking set. Fix it with:

git branch --set-upstream-to=origin/<your-branch> <your-branch>
git pull

Or do both in one step:

git pull origin <your-branch>

To avoid this in the future, whenever you create or checkout a new local branch that should track a remote, use:

git checkout --track origin/<your-branch>

Verify linear history

git log --oneline --graph --decorate

A clean linear history shows a straight vertical line with no merge nodes.


Re-sign and remediation

Re-sign the last commit without changing its message

git commit --amend --no-edit -S

Push after re-signing (the commit hash changed)

git push origin <your-branch> --force-with-lease

Fix GPG if signing fails

git config --global gpg.format openpgp
git config --global gpg.program gpg

# Confirm the Key ID is correct (no leading '0x')
git config --global user.signingkey <YOUR_KEY_ID>

# Restart the agent
gpgconf --kill gpg-agent

One-time setup

Run these commands once per machine to configure Git correctly for this workflow.

1. Set your identity

git config --global user.name "Your Name"
git config --global user.email "you@example.com"

2. Verify the remote uses SSH

git remote -v

If the URL starts with https://, switch it to SSH:

git remote set-url origin git@github.com:<user>/<repo>.git

3. Configure GPG signing

# Find your GPG Key ID (the 16-character code after '/' on the 'sec' line)
gpg --list-secret-keys --keyid-format LONG

# Tell Git which key to use (omit the leading '0x')
git config --global user.signingkey <YOUR_KEY_ID>

# Auto-sign all commits and tags
git config --global commit.gpgsign true
git config --global tag.gpgSign true
git config --global gpg.program gpg

4. SSH connection

  1. Go to github.com/settings/keys and delete the old key.
  2. Copy your current public key to the clipboard:
pbcopy < ~/.ssh/id_ed25519.pub
  1. On GitHub: Settings → SSH and GPG keys → New SSH key → paste → Save.
  2. Test again:
ssh -T git@github.com

5. Test the SSH connection to GitHub

ssh -T git@github.com

A successful response looks like:

Hi <username>! You've successfully authenticated, but GitHub does not provide shell access.

If you get a Permission denied (publickey) error, the most likely cause is a stale key.

6. Enforce linear history (no merge commits)

# Always rebase instead of merge when pulling
git config --global pull.rebase true

# Refuse any merge that would create a merge commit
git config --global merge.ff only

# Simplify first push of a new branch (Git ≥ 2.37)
git config --global push.autoSetupRemote true

14 - Sony/Nikon MakerNote Parser

Sony/Nikon MakerNote Parser

RawCull reads autofocus points and embedded JPEG locations directly from RAW metadata. This is implemented in RawParserKit, while the app consumes the parser through the vendor-neutral RawFormat protocol.

Source Map

AreaFiles
Format protocol/registryRawParserKit/Sources/RawParserKit/RawFormat.swift, RawFormatRegistry.swift
Sony parserSonyMakerNoteParser.swift, SonyRawFormat.swift
Nikon parserNikonMakerNoteParser.swift, NikonRawFormat.swift
Thumbnail/JPEG extractorsSonyThumbnailExtractor.swift, NikonThumbnailExtractor.swift, JPGSonyARWExtractor.swift, JPGNikonNEFExtractor.swift
DiagnosticsRawParserDiagnostics.swift, app RawFileDiagnostics.swift
App scan consumerActors/ScanFiles.swift
Focus-point normalizationRawCullCore/Sources/RawCullCore/FocusPointParser.swift
TestsRawParserKit/Tests/RawParserKitTests/, RawCullCore/Tests/RawCullCoreTests/FocusPointParserTests.swift

Vendor-Neutral Contract

App code usually does not call SonyMakerNoteParser or NikonMakerNoteParser directly. It resolves a format first:

guard let format = RawFormatRegistry.format(for: url) else { return }
let focus = format.focusLocation(from: url)

RawFormat also exposes thumbnail extraction, full embedded JPEG extraction, compression labels, and size-class thresholds. This keeps ScanFiles, thumbnail actors, and diagnostics mostly vendor-neutral.

Focus Location Shape

Both parsers return the same string shape:

imageWidth imageHeight focusX focusY

Example:

6000 4000 3000 1000

FocusPointParser.normalizedPoint(from:) converts that into a normalized CGPoint:

x = focusX / imageWidth
y = focusY / imageHeight

Invalid dimensions, malformed strings, and out-of-range points are rejected.

Sony ARW Structure

Sony ARW is TIFF-based. The focus point is found by walking TIFF/EXIF structures:

flowchart TD
    A["TIFF header"] --> B["IFD0"]
    B --> C["ExifIFD tag 0x8769"]
    C --> D["MakerNote tag 0x927C"]
    D --> E["Sony MakerNote IFD"]
    E --> F["FocusLocation tag 0x2027"]

The Sony parser also locates embedded JPEG candidates:

CandidateUse
thumbnailSmall embedded preview
previewPreferred scoring/zoom preview when available
fullJPEGLargest embedded JPEG candidate

For newer Sony files where ImageIO cannot expose a preview, focus scoring can use the parser’s embedded JPEG fallback.

Nikon NEF Structure

Nikon NEF is also TIFF-based, but the MakerNote has a Nikon Type-3 inner TIFF layout.

flowchart TD
    A["TIFF IFD0"] --> B["ExifIFD tag 0x8769"]
    B --> C["MakerNote tag 0x927C"]
    C --> D["Nikon signature + inner TIFF header"]
    D --> E["Nikon MakerNote IFD"]
    E --> F["AFInfo2 tag 0x00B7"]

The Nikon parser supports modern Z-series style AFInfo versions used by bodies such as Z9/Z8/Z7/Z6 class cameras. Tests also cover unsupported older AFInfo layouts returning nil instead of producing misleading coordinates.

For embedded JPEGs, Nikon parsing can walk TIFF SubIFDs when ImageIO does not expose the preview as a top-level image.

Diagnostics

RawFileDiagnostics.log(for:) is the developer entry point from the app. It reports:

  • file identity and size,
  • selected RawFormat,
  • scanned EXIF metadata,
  • ImageIO image count and per-index properties,
  • compression-code labels,
  • parser traces for embedded JPEG locations,
  • parser traces for AF focus location.

Diagnostics are intentionally read-only. They are useful when adding a camera body or debugging a RAW file that does not produce focus points or previews.

Tests

Test fileCovers
SonyMakerNoteParserTests.swiftSony focus and embedded JPEG parsing
NikonMakerNoteParserTests.swiftNikon focus and embedded JPEG parsing with synthetic NEF blobs
RawFormatRegistryTests.swiftExtension-to-format dispatch
SonyRAWJPEGCreatorTests.swiftSony RAW JPEG creation behavior
FocusPointParserTests.swiftNormalizing parser strings into points

The Nikon tests use synthetic binary blobs so parser offsets and AFInfo behavior can be checked without real camera files.

Adding A New RAW Format

  1. Add a new RawFormat conformer.
  2. Implement thumbnail extraction, full JPEG extraction, focus location, compression labels, and size thresholds.
  3. Register the conformer in RawFormatRegistry.all.
  4. Add parser/extractor diagnostics.
  5. Add package tests for registry dispatch and parser edge cases.
  6. Update scan/thumbnail docs if the new format changes behavior.

What To Check When Changing This Area

  • Parser failures should return nil or diagnostics, not crash.
  • Keep byte-offset code covered by synthetic tests where possible.
  • Do not let app code branch deeply on file extension when RawFormat can dispatch.
  • If focus-location string shape changes, update FocusPointParser and scan consumers together.
  • Embedded JPEG parser changes can affect thumbnails, zoom previews, and sharpness scoring.

15 - Security-Scoped URLs

Security-Scoped URLs

RawCull is a sandboxed macOS app. Any access outside the app container must come from user consent, usually a file/folder picker. RawCull uses two security-scope patterns:

  1. active catalog access for browsing/culling,
  2. persistent bookmarks for the rsync copy source and destination.

Source Map

AreaFiles
Active catalog scopeRawCullViewModel.swift, RawCullViewModel+Catalog.swift, RawCullApp.swift
Catalog scan scopeActors/ScanFiles.swift
Copy-folder bookmarksViews/CopyFiles/OpencatalogView.swift, SourceAndDestinationSection.swift
rsync runtime scopeModel/ParametersRsync/ExecuteCopyFiles.swift
File writes needing scopeExtractAndSaveJPGs.swift, SaveJPGImage.swift, rsync workflow

API Basics

The core calls are:

let ok = url.startAccessingSecurityScopedResource()
url.stopAccessingSecurityScopedResource()

Persistent access is stored as bookmark data:

let data = try url.bookmarkData(options: .withSecurityScope, ...)
let url = try URL(resolvingBookmarkData: data, options: .withSecurityScope, ...)

Every successful startAccessing... must eventually be paired with stopAccessing....

Active Catalog Scope

The catalog browsing flow is owned by RawCullViewModel.

sequenceDiagram
    participant UI as Sidebar picker
    participant VM as RawCullViewModel
    participant Work as Scan/thumbnail/export work
    UI->>VM: startCatalogLoad(source)
    VM->>VM: cancelCatalogLoad()
    VM->>VM: startSecurityScopedAccess(url)
    VM->>Work: scan and preload
    Work-->>VM: results/progress
    VM->>VM: stopActiveSecurityScopedAccess() on cancel/empty/deinit/app cleanup

startSecurityScopedAccess(for:) is idempotent for the currently active URL. If a different catalog is selected, it stops the previous active scope before starting the new one.

cancelCatalogLoad() releases the active scope and cancels related work. RawCullApp.performCleanupTask() also calls stopActiveSecurityScopedAccess() on termination.

ScanFiles Scope

ScanFiles.scanFiles(url:onProgress:) also starts and stops access around directory scanning:

guard url.startAccessingSecurityScopedResource() else { return [] }
defer { url.stopAccessingSecurityScopedResource() }

This is a local defensive scope for the scan actor. The broader catalog scope remains owned by RawCullViewModel so later preload/export/zoom work can still access files while the catalog is active.

Copy Workflow Bookmarks

The copy workflow needs persistent source/destination folders for rsync. OpencatalogView creates bookmarks when the user picks folders.

flowchart TD
    A["User picks source/destination"] --> B["startAccessing"]
    B --> C["bookmarkData(.withSecurityScope)"]
    C --> D["UserDefaults sourceBookmark/destBookmark"]
    D --> E["stopAccessing"]
    E --> F["Later: ExecuteCopyFiles resolves bookmark"]
    F --> G["startAccessing during rsync"]
    G --> H["cleanup stops both scopes"]

The bookmark keys are:

KeyMeaning
sourceBookmarkrsync source folder
destBookmarkrsync destination folder

If bookmark resolution fails, ExecuteCopyFiles tries the fallback path from the UI.

rsync Runtime Cleanup

ExecuteCopyFiles stores the accessed URLs in:

  • sourceAccessedURL,
  • destAccessedURL.

cleanup() finishes the progress stream, stops both security-scoped resources, clears process references, and is guarded by didCleanUp so multiple termination paths are safe.

close() sets isClosing, cancels the process, and calls cleanup. Normal process termination waits briefly after onCompletion before cleanup so completion code can still use the scoped URLs.

File Writes

Writes outside the app container require an active user-granted scope. The main examples are:

WriteScope source
Extracted JPEG sidecars next to RAW filesactive catalog scope
rsync destination writesdestBookmark scope
rsync include fileapp Documents folder, no external scope required

App-owned JSON/cache files under Application Support or Caches do not need security-scoped access.

What To Check When Changing This Area

  • Keep one clear owner for each long-lived scope.
  • Pair every successful start with a stop on all exit paths.
  • Use bookmarks for persistent copy-folder access, not for every temporary catalog scan.
  • When adding a new file write, ask whether it targets the app container or a user folder.
  • If copy closes early, verify ExecuteCopyFiles.cleanup() still runs exactly once.