Skip to content

Comments

X-ray scattering analysis refactor#5

Open
hpmartins wants to merge 163 commits intomlexchange:mainfrom
hpmartins:refactor
Open

X-ray scattering analysis refactor#5
hpmartins wants to merge 163 commits intomlexchange:mainfrom
hpmartins:refactor

Conversation

@hpmartins
Copy link

Summary

This PR delivers an enhanced web platform for X-ray scattering analysis with GISAXS/SAXS support, integrated batch processing and accelerated server-side computation.


Main features

Linecut and azimuthal integration

  • Server-side linecut extraction with Q-matrix caching
  • Azimuthal integration with backend batch processing
  • Interactive parameter tuning for linecuts, azimuthal integration, and calibration settings

GISAXS support

  • Q-space calculations using pyFAI FiberIntegrator
  • Converted data visualization in frontend

Mask management

  • Full mask support: upload, load from file, dimension validation
  • Supported formats: .npy, .tiff, .tif, .edf, .cbf, .csv

Batch processing

  • Backend endpoint for processing multiple scans
  • Frontend batch UI: scan selector, processing widget, results view
  • Export batch results as CSV or download images

Visualization

  • H5Web integration: Replaced Plotly.js with H5Web for scattering images and line plots
  • Zoom/pan sync: Synchronized zoom and pan across heatmaps, linecuts, and azimuthal integration
  • Real-time updates: Fast visualization with loading indicators during calculations
  • Exports: Snapshot capture, CSV download for linecut/integration data
  • Toolbar toggles: Beam center, mask, linecuts, Q-space, grid

UI/UX

Component libraries

  • Replaced Mantine with Radix UI primitives (Tabs, Tooltip, ToggleGroup, Popover, etc.)
  • Replaced lucide-react/react-icons with Phosphor Icons
  • Adopted ALS Computing styles and color scheme
  • Integrated Bluesky Finch components

User feedback

  • Toast notification system
  • Loading overlays and spinners during Q-space and linecut calculations
  • Tooltips throughout the interface
  • Calibration parameters and mask handling moved to dedicated overlay panel

Data management

  • Tiled integration: Full support for data selection and loading calibration parameters
  • Session persistence: State preservation via sessionStorage with IndexedDB for image caching
  • Smart caching: LRU-based backend caching for images, Q-matrices, masks, and GISAXS data

Architecture

Backend

  • Migrated to src-layout package structure (xscattering_backend)
  • Generic LRU cache base class
  • Centralized Tiled client management
  • Pydantic models for configuration and API schemas

Frontend

  • Generic factory pattern for linecut hooks
  • Enabled TypeScript strict mode and fixed all type errors
  • Performance optimizations with useMemo and React.memo

DevOps & tooling

  • Docker: Updated Dockerfile configurations for frontend and backend
  • uv: Backend now uses uv package manager with pyproject.toml
  • Pre-commit: ESLint, Prettier, and Python linting hooks
  • Environment: Unified env vars with SCATTERING_BACKEND_ prefix, standardized port to 4000
  • Standalone mode: Simplified App for standalone deployment only

Key dependency changes

Backend

Package Change
pyFAI 2025.1.0 → 2025.12.1
fabio 2024.9.0 → 2025.10.0
tiled 0.1.0b12 → 0.1.0b16
fastapi 0.115.6 → 0.124.0 (with standard extras)
requirements.txt Replaced with pyproject.toml

Frontend

Added:

Package Description
@h5web/lib Scientific visualization
@radix-ui/* UI primitives (tabs, tooltip, popover, toast, toggle, switch, select, dropdown-menu)
@phosphor-icons/react Icon library
@blueskyproject/finch UI components
@blueskyproject/tiled React Tiled viewer
msgpackr Fast msgpack serialization
html-to-image Snapshot export
three 3D rendering for H5Web
ndarray N-dimensional arrays
react-router-dom Routing
tailwindcss-animate Animation utilities

Removed:

Package Reason
plotly.js, react-plotly.js Replaced by H5Web
@mantine/core, @mantine/notifications Replaced by Radix UI
lucide-react, react-icons Replaced by Phosphor Icons
@deck.gl/* Unused
@mui/material, @mui/lab Unused
@emotion/react, @emotion/styled Unused (was Mantine dep)
openseadragon Unused
@msgpack/msgpack, msgpack-lite Replaced by msgpackr
@esbuild-plugins/* Removed polyfills

- Using HubAppLayout to show both modules now
- Updated some packages
- Removed column variables
…ionality

- Added @blueskyproject/tiled dependency for Tiled integration.
- Updated Scattering component to include Tiled selection interface.
- Modified RawDataOverviewAccordion to remove fetch button and improve data fetching.
- Enhanced ScatterSubplot to handle scan URIs for image fetching.
- Updated useAzimuthalIntegration hook to accept scan URIs for processing.
- Refactored useRawDataOverview to manage scan URIs and handle Tiled folder selection.
- Improved error handling and loading states across components.
- Updated Vite configuration to support environment variables for Tiled integration.
- Using uv to handle backend dependencies
- Added build arguments for SCATTERING_TILED_URL and SCATTERING_TILED_API_KEY in docker-compose.yml.
- Updated frontend Dockerfile to use Node.js 24-alpine and accept build arguments for environment variables.
- Set environment variables in Dockerfile to be used during the Vite build process.
…ration

- Updated package.json to reflect new package name, version, and structure for exports
- Removed old App component and created a new App component with routing for standalone and HubLayout pages
- Added HubLayoutPage and StandalonePage components to demonstrate usage of Scattering component
- Updated main entry point to use the new App structure
- Configured Vite to build the library
@hpmartins
Copy link
Author

Addressed part of the feedback in the following commits:

  • c4e0ea1 - Auto-select first tab with batch results: Results are now displayed by default after batch processing completes instead of requiring a click
  • 65a5a17 - Done button enabled when uploading mask: Fixed issue where the "Done" button in the calibration overlay remained disabled after manually loading a mask
  • 89e4ad7 - Dynamic Q slider step size: Slider step sizes now match actual Q data spacing
  • b37aadb - Q value precision: Slider limits and tooltips now use 4 decimal places; axis tick labels use 2 decimal places
  • f220d5f - Center linecut overlays: Fixed linecut overlay lines to be drawn at pixel centers (N + 0.5) instead of pixel edges
  • 1646696 - Increase max upload size: Added client_max_body_size 50M to nginx config for larger image uploads

Copy link
Member

@Wiebke Wiebke left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together. I left some comments based on a initial read-through of the code. Note that this focusses exclusively on the backend.

From a high-level, this is structured quite nicely.
My main concern is that line cuts (aside from azimuthal integration) make use of code that is custom defined here. For grazing incidence it would be more sensible to utilize the equivalent already available functionality in pyFAI. The custom code here looks sensible overall, but relies on snapping to integer positions. Ensuring that there aren't small pixel conversation mistakes or corner cases would ideally involve some tests.

Comment on lines 77 to 81
q_array_initial_1 = ai.qArray(scatter_image_array_1.shape)
chi_array_1 = ai.center_array(scatter_image_array_1.shape, unit="chi_rad")

q_array_initial_2 = ai.qArray(scatter_image_array_2.shape)
chi_array_2 = ai.center_array(scatter_image_array_2.shape, unit="chi_rad")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both images use the same settings for integration, meaning there is an implicit assumption that both images also have the same calibration settings and data shape.

q and chi array's and derived masks for the current region of interest should thus also be the same. Is there another reason to compute them twice?

Comment on lines +113 to +116
for integration in azimuthal_integrations:
results["azimuthal"][integration["id"]] = create_error_linecut_result(
"Azimuthal integration not supported for GISAXS"
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In principle, azimuthal (and radial) integration can still be supported with GISAXS, but the functions to use are different for FiberIntegrator (integrate1d_polar).

"""Perform 1D azimuthal integration. Returns (q_values, intensities)."""
method = ("full", "csr", "cython")

result = ai.integrate1d(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correctSolidAngle is afaik just important if the detector is not mounted normally to the incident beam. This would be the case if tilt is != 0.

Since we haven't worked with a lot of non-normally mounted detectors I don't have a good sense how large the effect of this is.

For the polarization_factor, we have the default set to 0.99 based on prior code. pyFAI's documentation lists "synchrotrons are around 0.95".

Both are corrections to the intensity mapping and thus have less relevance for relative comparisons.

Comment on lines +48 to +75
def calculate_q_to_pixel_width(
q_position: float,
q_width: float,
q_matrix: np.ndarray,
direction: Literal["horizontal", "vertical"],
) -> int:
"""
Convert a q-space width to pixel width.

Args:
q_position: Center position in q-space
q_width: Width in q-space units
q_matrix: 2D array of q-values
direction: "horizontal" or "vertical"

Returns:
Width in pixels
"""
if q_width <= 0 or q_matrix.size == 0:
return 0

upper_q = q_position + q_width / 2
lower_q = q_position - q_width / 2

upper_pixel = find_pixel_position_for_q_value(upper_q, q_matrix, direction)
lower_pixel = find_pixel_position_for_q_value(lower_q, q_matrix, direction)

return abs(upper_pixel - lower_pixel)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it might introduce some inaccuracies if q_position is not exactly at the pixel center.

Comment on lines 240 to 248
def extract_inclined_linecut(
image_array: np.ndarray,
q_x_matrix: np.ndarray,
q_y_matrix: np.ndarray,
q_x_position: float,
q_y_position: float,
angle: float,
q_width: float = 0.0,
) -> Tuple[np.ndarray, np.ndarray]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has nothing to do with the code, but I do not quite understand the rationale behind an inclined line cut, as in contrast to all other integration methodologies it doesn't integrate over a variable that stays constant.

I wonder if this functionality is left over from a misunderstanding where the requirement actually intended supporting a narrowly angled azimuthal integration.

unit_ip="qip_nm^-1",
unit_oop="qoop_nm^-1",
incident_angle=incident_angle_deg,
tilt_angle=0.0,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do incident_angle and tilt_angle interplay if we are working with an actually slightly titled detector (as determined through calibration)?

I think setting the tilt_angle to 0 is incorrect in this case.

Comment on lines +218 to +222
# Negate qoop to match image display convention (same as SAXS qy inversion):
# pyFAI's qoop follows detector coords where y increases downward,
# but we want positive qoop at top of image (Q increases going up).
qoop_values = -result.outofplane
qoop_pixel_matrix_inverted = -qoop_pixel_matrix
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a data transformation for the purpose of visualization. Is there a reason why would we want to go against pyFAI's convention?

Worth noting that pyFAI's visualizations also show qoop increasing going up (https://pyfai.readthedocs.io/en/stable/usage/tutorial/FiberGrazingIncidence.html). Could this be a mismatch between specifying extent and pixel-coordinate to be treated at (0,0) when plotting?



# =============================================================================
# GISAXS Linecut Extraction (from transformed Q-space grid)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyFAI supports direct horizontal and vertical integration. This also bypasses some of the issues of needing to snap to integer pixel coordinates.

###
horizontal_cut_pyFAI = fi.integrate1d_grazing_incidence(
    data=detector_image,
    mask=mask,
    sample_orientation=1,
    npt_ip=...,
    ### To integrate over a single pixel only (not what is usually done, just to show as the example)
    oop_range=[target_qz - pixel_size_q_oop / 2, target_qz + pixel_size_q_oop / 2],
    vertical_integration=False,
)
vertical_cut_pyFAI = fi.integrate1d_grazing_incidence(
    data=detector_image,
    mask=mask,
    sample_orientation=1,
    npt_oop=...,
    ### To integrate over a single pixel only
    ### (not what is usually done, just to show as the example)
    ip_range=[target_qxy - pixel_size_q_ip / 2, target_qxy + pixel_size_q_ip / 2],
    vertical_integration=True,
)

@taxe10
Copy link
Member

taxe10 commented Jan 28, 2026

Priority summary:

  • Change line cuts logic (horizontal and vertical) to pyfai, which currently supports grazing incidence
  • Tentatively check whether the 2D integration in transmission (setting the number of points in a given direction as 1) can have a similar effect for horizontal and vertical line cuts
  • Missing input from frontend for incident angle and tilt angle - may not be as straightforward tho
  • Saving results to Tiled
  • Conversion tests to make sure that pixel to q-value transformations are correct
  • Adding small warning to incline line cuts - labeled as experimental

Moving to issues:

  • Incline line cuts
  • Reload previous results in the application from tiled
  • Tiled authentication

Other notes:

  • The missing pixels in gisaxs is likely a dependency issue - need follow-up
  • Azimuthal integration for grazing incidence can be added later

Allow users to persist linecut extractions and batch processing results to a writable Tiled container.  The feature is opt-in: setting SCATTERING_TILED_RESULTS_URL enables "Save to Tiled" buttons across all linecut graph cards and the batch results view.
Add enableTiledCalibration prop to Scattering component that controls whether the Tiled calibration browser is shown. The prop can only force-disable the feature; when omitted, the backend infrastructure check is the sole controller. The Tiled browser, calibration status, and mask resolution dialog are all hidden when disabled and only manual calibration input and mask upload remain available.
Backend save endpoints now capture the write_dataframe() return value and include tiled_id and tiled_uri in the response. Frontend shows a centered overlay with the saved item label, ID, and URI with copy-to-clipboard buttons. Saved items are tracked in sessionStorage for later display in the system status overlay.
- New HealthOverlay component shows service health and saved-to-Tiled items history with copy-to-clipboard support
- Sidebar with fixed footer containing a health status button
- When backend is unreachable, overlay opens automatically and blocks interaction until the backend recovers
The CalibrationWidget was making a direct fetch to the Tiled calibration server with an API key header and was the only direct frontend-to-Tiled HTTP request outside the <Tiled> component. This moves that logic to a new GET /api/load-calibration endpoint, which also resolves the associated mask in the same request (eliminating the separate /api/resolve-mask call and endpoint entirely).
@hpmartins
Copy link
Author

hpmartins commented Jan 30, 2026

Changes:

Save results to Tiled

  • New /api/save-linecuts and /api/save-batch-results endpoints to persist analysis results as DataFrames to a writable Tiled container.
  • Save endpoints return Tiled ID and URI, shown in a confirmation popup with copy-to-clipboard. A sessionStorage store tracks all saved items per session.
  • enableTiledResults prop toggle save buttons.
image

Dedicated calibration Tiled server

  • Support for a separate Tiled server for calibration data (PONI/masks) to decouple calibration from the main data server.
  • enableTiledCalibration prop controls visibility of the Tiled calibration browser.

Calibration loading moved to backend

  • New /api/load-calibration endpoint replaces client-side PONI parsing.

Health check

  • New /api/health endpoint, returning live status (ok/error/not_configured) for all services.
  • Status overlay shows service health indicators and saved-to-Tiled history. Frontend is disabled and overlay is non-dismissible when backend is unreachable
image

React unit tests

  • Vitest suite for Scattering, HeatmapPanel, LinecutFig, AzimuthalIntegrationFig, and SummaryFig covering rendering, states, interactions, and configuration.

Other

  • Unified logging through Uvicorn's log_config
  • Added tilt_angle to GISAXS Q-space and converted all angles to radians for pyFAI.
  • Deduplicated q/chi array computation in azimuthal integration (computed once, reused for both images)
  • Added tooltip information for linecuts to inform that inclined linecuts are experimental and how the linecut widths work (-w/2:w/2)

Backend:
- GISAXS horizontal/vertical linecut extraction now uses pyFAI
- Default azimuthal integration npt to max(image.shape) for detector resolution

Frontend:
- Switch Q-space tick formatting and overlay width calculations from 2D matrices (qXMatrix/qYMatrix) to 1D vectors (qXVector/qYVector)
- Add beam center overlay correction for GISAXS Q-space mode (maps to q_ip=0, q_oop=0 pixel indices)
- Add per-figure Y-axis scale selector (linear/log/symlog) for all linecut and azimuthal integration figures
- Add "Sync zoom" toggle to image toolbar (on by default) controlling whether heatmap zoom propagates to linecut figure X-axis domains
- Add interaction help to image toolbar
@hpmartins
Copy link
Author

Changes:

  • Switched GISAXS horizontal/vertical linecut extraction to use pyFAI direct integration
  • Added configurable number of integration points (npt) for GISAXS pyFAI linecuts
  • Added Y-axis scale selector (linear/log/symlog) for all linecut and azimuthal integration figures
  • Added "Sync zoom" toggle to image toolbar controlling whether heatmap zoom propagates to linecut figures
  • Added beam center overlay correction for GISAXS Q-space mode
  • Added interaction help to image toolbar
  • Added test notebook for backend SAXS/GISAXS integration validation
  • Changed axis labels to qᵢₚ/qₒₒₚ across GISAXS mode (unfortunately h5web uses plain strings so I had to use unicode characters for the subscripts)

Mask handling, pixel-splitting, and GISAXS oversampling

The pyFAI integration methods now receive the detector mask. For GISAXS, the grazing-incidence transformation reshapes and oversamples the image, introducing empty (unfilled) pixels that appear as gaps in the transformed detector frame. Pixel-splitting is not used for GISAXS linecuts (pyFAI's integrate1d_grazing_incidence defaults to no pixel-splitting). Without pixel-splitting, intensity values are not interpolated across neighboring bins, so the empty pixels from the oversampled image are preserved as-is in the integration output. When a large number of integration points is requested (the default is the detector size), many bins land on these empty pixels, causing the linecut profile to oscillate sharply between real signal and zeros and producing very noisy, sawtooth-like curves. See:

image

Following the pyFAI Fiber/Grazing-Incidence tutorial, it seems that the solution is to reduce npt (the number of integration points). A smaller npt bins the data more coarsely so that each bin averages over a wider Q-range, effectively smoothing out the empty-pixel gaps. Enabling pixel-splitting would be an alternative, but they say that it can artificially fill the missing wedge region and smear the gap at q_ip=0, so the current approach avoids it. The npt parameter is now user-configurable in the UI so operators can tune the trade-off between resolution and noise for their specific dataset.

Full range (1679 points):
image

Half range (840 points):
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants