Skip to content

A real Fiji plugin, matching what the QuPath extension does - #9

Merged
alex1075 merged 11 commits into
mainfrom
feature/fiji-plugin
Aug 25, 2026
Merged

alex1075 merged 11 commits into
mainfrom
feature/fiji-plugin

Conversation

@alex1075

Copy link
Copy Markdown
Member

The Fiji side was a 137-line Groovy script that hardcoded a 7x5 image and posted one fixed triangle. It was an integration test, not a plugin: no menu, no window a user keeps, no way to send a region anyone drew.

Closes #4.

This is nine commands under Plugins > FLIMKit, the same set the QuPath extension carries, built one image at a time rather than around a project. Connect, Open FLIM file, Fetch FLIMKit images, Send and Fetch ROIs, Fit ROI decays, Fit per-pixel lifetimes, Phasor plot, and Stitch and fit a mosaic. File > Open handles .ptu, .sdt, .photons and the rest through an IOPlugin, and opening a file registers it so fitting works straight afterwards.

Every fit opens its numbers beside its image. Per-pixel shows the global summed fit that seeds it, stitch shows its own summary, and ROI fits get a Results table.

The phasor draws density and the universal semicircle in Swing. Click to place a cursor, drag to move it, Draw region to trace an outline, Add to ROI Manager to bring the pixels back as traced selections.

Maven against the jars Fiji already ships, everything provided, nothing shaded. Eighteen Java tests cover the logic that needs no toolkit, four Python tests drive a real headless Fiji, and a CI job runs mvn verify on JDK 21 and keeps the jar as an artifact.

I tested this against real files as it was built. Five bugs came out of that rather than out of seeded fixtures:

  • IOPlugin.supportsOpen(Location) defaults to false and AbstractIOPlugin only overrides the String variant, so File > Open never matched and reported no opener for a FileLocation.
  • Job progress is an object carrying a fraction, not a number.
  • tile_fit output is not a stitched output directory, so it cannot be reopened as a dataset. The real-unit TIFFs in the summary are opened instead.
  • The label image gives a pixel only to the first cursor claiming it, so an overlapping region came back empty. Each cursor's mask is requested on its own now.
  • The mask request used a photon floor of 1.0 while the density plot used 0.01. On a dim file that hid almost everything from selection: Ado_1.ptu has a median of 0.015 photons per binned pixel, so three cursors placed on visible density selected 78, 0 and 46 pixels where the plot held 10848, 10273 and 17299. One floor governs both now, and a test pins it with the reason.

Stitch and fit was run against a real mosaic. The rest was driven against Ado_1.ptu and convallaria.ptu.

🤖 Generated with Claude Code

alex1075 and others added 11 commits August 25, 2026 09:05
Four commands under Plugins > FLIMKit: Connect, Fetch FLIMKit images,
Send ROIs to FLIMKit, Fetch ROIs from FLIMKit. SciJava commands in a
Maven build against the jars Fiji already ships, so every dependency is
provided and nothing is shaded in.

It finds the bridge itself through ~/.flimkit/bridge.json, falling back
to qupath-bridge.json, and checks protocol_version rather than comparing
version strings. Regions travel as GeoJSON polygons in image-pixel
coordinates, outer ring only, because FLIMKit's own importer discards
interior rings. Sending refuses unless the regions were drawn on an
image fetched from FLIMKit, since nothing else shares that pixel grid.

Verified in a real headless Fiji: all four commands register, a drawn
region reaches FLIMKit with its coordinates intact, a FLIMKit region
comes back as an ImageJ Roi, and both images arrive with their units.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more commands. Open FLIM file posts the path to the bridge and
remembers the dataset id, which is the single-image stand-in for
QuPath's project. Fit ROI decays sends the ROI Manager contents against
that dataset and puts the results in an ImageJ Results table.

The settings dialog is generated from GET /v1/fit/defaults through
GenericDialog, so the fields follow the schema the server serves rather
than being laid out by hand, the same way QuPath's does it.

Images now come from the dataset's own planes when a file is open.
/v1/images serves a live FLIMKit session and 404s for a dataset, which
is what the first run against a real PTU showed.

Verified in headless Fiji against bi_01.ptu: opened as ds_1, intensity
plane 16x16 in photons, one region fitted to 2.258 ns over 316167
photons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IOPlugin.supportsOpen(Location) defaults to returning false and
AbstractIOPlugin only overrides the String variant, so the IOService,
which dispatches on Location, never matched. Fiji reported no opener
found for a FileLocation, which is the type that was failing.

Overriding both Location methods fixes it. Opening registers the dataset
as well, so fitting works straight afterwards with no separate step.

Verified on a 240 MB convallaria.ptu: opened 512x512 in photons through
the IOService.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fit per-pixel lifetimes runs the fit as a job, showing progress in the
ImageJ status bar and cancelling on Escape, then opens every returned
map as one stack with the plane names as slice labels.

Phasor plot draws the density and the universal semicircle in Swing.
Click to place an elliptical cursor, drag to move it, Draw region to
trace an outline. Each cursor reports pixel count and both lifetimes,
the settings dialog comes from GET /v1/phasor/settings, and Add to ROI
Manager brings the selected pixels back as regions.

The plot maths sits in PhasorPlot, away from the Swing, so the wire
format and the density decoding are testable without a toolkit.

Job progress is an object carrying a fraction rather than a number,
which is what the first real run against a job showed.

Verified against bi_01.ptu in headless Fiji: 13 planes fitted and opened
as a 13 slice stack, phasor density decoded over 65536 bins, and a
cursor on the population selecting all 16 pixels at tau_phi 6.30 ns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stitch and fit takes the container holding the tile positions, runs the
pipeline as a job, reopens the output as a dataset and shows the maps,
with tile_fit and stitch_fit both offered.

Every fit now opens its numbers beside its image. Per-pixel shows the
global summed fit that seeds it, which carries the lifetimes, the
fractions, the reduced chi-squared and which IRF was used. Stitch shows
its own summary. ROI fits already had their table.

Verified against bi_01.ptu: the summed fit table comes back with ten
columns including taus 3.630 and 0.279 ns and the machine IRF it used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stitch and fit offered only the pipeline choice. It now prompts from
GET /v1/pipeline/defaults, so tile_fit gets the exponentials, tau range,
binning, photon floor, IRF strategy and the rest, with the pipeline
choice among them. The settings dialog treats a schema entry without
applies_to as applying to every mode, which is how the pipeline schema
is written.

Reopening the output as a dataset failed with 400 not a stitched output
directory, because only stitch_fit writes the photon cube that check
looks for. tile_fit assembles maps instead. The summary already prepares
real-unit TIFFs, so the products are opened directly and the run says so
when it wrote none.

Phasor regions were built as a point polygon over scattered pixels,
which drew nonsense on the image. They are traced from the label mask
through ThresholdToSelection now, scaled by the binning, and carry the
cursor's colour.

Verified: a half-filled 4x4 mask traces to a bounds 2x4 region of
exactly 8 pixels, scales to 8x16 at binning 4, and a cursor that caught
nothing returns nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things stopped the second region appearing.

The draw toggle cleared its own flag after each outline but stayed
visually selected, so the next click only deselected it and you had to
press it twice. The toggle governs now, and it stays on until you turn
it off, which is what drawing several regions wants.

The label image can hold one value per pixel, and label_image assigns
each cursor only where nothing has claimed a pixel yet, so any region
overlapping an earlier one came back empty. Each cursor's mask is
requested on its own now, so overlap is fine: two cursors over the same
population return 128 pixels each rather than 128 and nothing.

Cursor ids are found rather than counted, so removing one and drawing
another cannot reuse an id, and the region names follow the cursor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mask request hardcoded min_photons 1.0 while the density query used
the server default of 0.01, so the plot showed pixels the mask could
never select. On a dim file that is nearly all of them: Ado_1.ptu has a
median of 0.015 photons per binned pixel and 65426 of 65536 below 1.0,
so three cursors placed on visible density returned 78, 0 and 46 pixels
where the plot had 10848, 10273 and 17299.

One floor governs both now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eighteen tests over the parts that do not need a toolkit: the GeoJSON
conversion both ways, the phasor wire format, the density decoding, the
cursor summary line and the query escaping. One of them pins the photon
floor at 0.01 with the reason, because a higher one hides pixels the
plot draws and on a dim file that is nearly all of them.

A plugin job runs mvn verify on JDK 21 and keeps the jar as an artifact,
so a Java break fails the pull request rather than someone's Fiji.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alex1075
alex1075 merged commit 2e59bc7 into main Aug 25, 2026
7 checks passed
@alex1075
alex1075 deleted the feature/fiji-plugin branch August 25, 2026 09:19
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.

The Fiji side should be a plugin jar, not a Groovy script

1 participant